From 90ede0334ba31367de871ea3bc5acd9a04d00aeb Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Mon, 9 Feb 2026 16:33:22 +0000 Subject: [PATCH 1/6] Basic react list-projects mcp-app --- package-lock.json | 951 +++++++++++++++++++++++++- package.json | 12 +- src/bugsnag/client.ts | 55 ++ src/bugsnag/ui/list-projects.css | 18 + src/bugsnag/ui/list-projects.html | 11 + src/bugsnag/ui/list-projects.tsx | 39 ++ src/common/server.ts | 3 +- src/common/transport-http.ts | 1 + src/common/types.ts | 6 +- src/tests/unit/bugsnag/client.test.ts | 31 +- vite.config.ui.ts | 18 + 11 files changed, 1101 insertions(+), 44 deletions(-) create mode 100644 src/bugsnag/ui/list-projects.css create mode 100644 src/bugsnag/ui/list-projects.html create mode 100644 src/bugsnag/ui/list-projects.tsx create mode 100644 vite.config.ui.ts diff --git a/package-lock.json b/package-lock.json index 68ce4602..9ce08f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,11 @@ "license": "MIT", "dependencies": { "@bugsnag/js": "^8.2.0", - "@modelcontextprotocol/sdk": "^1.15.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.26.0", "node-cache": "^5.1.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", "swagger-client": "^3.35.6", "vite": "^7.3.1", "zod": "^4" @@ -23,10 +26,14 @@ "@biomejs/biome": "^2.2.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^3.2.4", "globals": "^16.2.0", "shx": "^0.3.4", "typescript": "^5.6.2", + "vite-plugin-singlefile": "^2.3.0", "vitest": "^3.2.4", "vitest-fetch-mock": "^0.4.5" } @@ -45,6 +52,185 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -65,14 +251,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -81,6 +291,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime-corejs3": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", @@ -93,10 +335,44 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -755,9 +1031,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -805,6 +1081,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -833,13 +1120,56 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", + "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", - "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -847,14 +1177,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -872,6 +1203,149 @@ } } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.8.tgz", + "integrity": "sha512-hPERz4IgXCM6Y6GdEEsJAFceyJMt29f3HlFzsvE/k+TQjChRhar6S+JggL35b9VmFfsdxyCOOTPqgnSrdV0etA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.8.tgz", + "integrity": "sha512-SaWIxsRQYiT/eA60bqA4l8iNO7cJ6YD8ie82RerRp9voceBxPIZiwX4y20cTKy5qNaSGr9LxfYq7vDywTipiog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.8.tgz", + "integrity": "sha512-ArHVWpCRZI3vGLoN2/8ud8Kzqlgn1Gv+fNw+pMB9x18IzgAEhKxFxsWffnoaH21amam4tAOhpeewRIgdNtB0Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.8.tgz", + "integrity": "sha512-rq0nNckobtS+ONoB95/Frfqr8jCtmSjjjEZlN4oyUx0KEBV11Vj4v3cDVaWzuI34ryL8FCog3HaqjfKn8R82Tw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.8.tgz", + "integrity": "sha512-HvJmhrfipL7GtuqFz6xNpmf27NGcCOMwCalPjNR6fvkLpe8A7Z1+QbxKKjOglelmlmZc3Vi2TgDUtxSqfqOToQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.8.tgz", + "integrity": "sha512-YDgqVx1MI8E0oDbCEUSkAMBKKGnUKfaRtMdLh9Bjhu7JQacQ/ZCpxwi4HPf5Q0O1TbWRrdxGw2tA2Ytxkn7s1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.8.tgz", + "integrity": "sha512-3IkS3TuVOzMqPW6Gg9/8FEoKF/rpKZ9DZUfNy9GQ54+k4PGcXpptU3+dy8D4iDFCt4qe6bvoiAOdM44OOsZ+Wg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.8.tgz", + "integrity": "sha512-o7Jm5zL4aw9UBs3BcZLVbgGm2V4F10MzAQAV+ziKzoEfYmYtvDqRVxgKEq7BzUOVy4LgfrfwzEXw5gAQGRrhQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.8.tgz", + "integrity": "sha512-5g8XJwHhcTh8SGoKO7pR54ILYDbuFkGo+68DOMTiVB5eLxuLET+Or/camHgk4QWp3nUS5kNjip4G8BE8i0rHVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.8.tgz", + "integrity": "sha512-UDI3rowMm/tI6DIynpE4XqrOhr+1Ztk1NG707Wxv2nygup+anTswgCwjfjgmIe78LdoRNFrux2GpeolhQGW6vQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.8.tgz", + "integrity": "sha512-K6qBUKAZLXsjAwFxGTG87dsWlDjyDl2fqjJr7+x7lmv2m+aSEzmLOK+Z5pSvGkpjBp3LXV35UUgj8G0UTd0pPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -883,6 +1357,13 @@ "node": ">=14" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -1845,6 +2326,51 @@ "node": ">=12.20.0" } }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1895,6 +2421,47 @@ "types-ramda": "^0.30.1" } }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -2173,10 +2740,20 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -2185,7 +2762,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -2208,6 +2785,53 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -2265,6 +2889,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2362,6 +3007,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2418,6 +3070,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2499,6 +3158,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -2626,6 +3292,16 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2726,10 +3402,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2785,6 +3464,19 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2928,6 +3620,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3062,11 +3764,10 @@ } }, "node_modules/hono": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", - "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3099,9 +3800,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3142,6 +3843,15 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3177,6 +3887,16 @@ "node": ">=8" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3293,6 +4013,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3305,6 +4038,19 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -3393,6 +4139,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -3582,6 +4355,13 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3892,6 +4672,37 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -4000,6 +4811,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4560,6 +5377,19 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4672,6 +5502,37 @@ "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==", "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4778,6 +5639,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", + "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.44.1", + "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -5016,6 +5894,13 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/zod": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", diff --git a/package.json b/package.json index 2094fe3c..f82aea79 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "mcpServerName": "SmartBear MCP Server" }, "scripts": { - "build": "vite build && shx chmod +x dist/*.js", + "build": "vite build && vite build --config vite.config.ui.ts && shx chmod +x dist/*.js", "lint": "biome lint .", "lint:fix": "biome lint . --fix", "format": "biome format . --write", @@ -43,13 +43,17 @@ "test:coverage": "vitest --coverage", "test:coverage:ci": "vitest --coverage --reporter=verbose", "test:run": "vitest run", + "ts": "tsc", "coverage:check": "vitest --coverage --reporter=verbose --config vitest.config.coverage.ts", "bump": "node scripts/bump.js" }, "dependencies": { "@bugsnag/js": "^8.2.0", - "@modelcontextprotocol/sdk": "^1.15.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.26.0", "node-cache": "^5.1.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", "swagger-client": "^3.35.6", "vite": "^7.3.1", "zod": "^4" @@ -58,10 +62,14 @@ "@biomejs/biome": "^2.2.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^3.2.4", "globals": "^16.2.0", "shx": "^0.3.4", "typescript": "^5.6.2", + "vite-plugin-singlefile": "^2.3.0", "vitest": "^3.2.4", "vitest-fetch-mock": "^0.4.5" } diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 029df8d8..f8191681 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { z } from "zod"; import type { CacheService } from "../common/cache"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info"; @@ -24,6 +27,7 @@ import { } from "./client/api/index"; import { type FilterObject, toUrlSearchParams } from "./client/filters"; import { toolInputParameters } from "./input-schemas"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps"; const HUB_PREFIX = "00000"; const DEFAULT_DOMAIN = "bugsnag.com"; @@ -37,6 +41,13 @@ const cacheKeys = { CURRENT_PROJECT: "bugsnag_current_project", }; +interface UIResource { + name: string; + uri: string; + content: string; + meta: any; +} + // Exclude certain event fields from the project event filters to improve agent usage const EXCLUDED_EVENT_FIELDS = new Set([ "search", // This is searches multiple fields and is more a convenience for humans, we're removing to avoid over-matching @@ -102,6 +113,27 @@ export class BugsnagClient implements Client { configPrefix = "Bugsnag"; config = ConfigurationSchema; + createUiResource = (name: string): UIResource => { + const uri = `ui://${this.toolPrefix}/${name}`; + return { + name, + uri, + content: readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "ui", `${name}.html`), + "utf-8", + ), + meta: { + ui: { + resourceUri: uri, + }, + }, + }; + }; + + uiResource = { + listProjects: this.createUiResource("list-projects"), + }; + async configure( server: SmartBearMcpServer, config: z.infer, @@ -443,6 +475,7 @@ export class BugsnagClient implements Client { hints: [ "Project IDs from this list can be used with other tools when no project API key is configured", ], + _meta: { ...this.uiResource.listProjects.meta }, }, async (args, _extra) => { const params = listProjectsInputSchema.parse(args); @@ -1702,5 +1735,27 @@ export class BugsnagClient implements Client { ], }; }); + + this.registerUIResources(register, this.uiResource.listProjects); + } + + private registerUIResources( + register: RegisterResourceFunction, + resource: UIResource, + ): void { + register(resource.name, resource, async (_uri, _variables, _extra) => { + return { + contents: [ + { + uri: resource.uri, + mimeType: RESOURCE_MIME_TYPE, + text: resource.content, + _meta: { + something: "true", + }, + }, + ], + }; + }); } } diff --git a/src/bugsnag/ui/list-projects.css b/src/bugsnag/ui/list-projects.css new file mode 100644 index 00000000..0938f8dc --- /dev/null +++ b/src/bugsnag/ui/list-projects.css @@ -0,0 +1,18 @@ +ol { + list-style-position: inside; + padding: 0; + margin: 0; +} + +li { + padding: 0.75rem; + margin-bottom: 0.1rem; + border-radius: 2px; + color: #212121; + background: white; + cursor: pointer; + + &:hover { + background: #ededfc; + } +} diff --git a/src/bugsnag/ui/list-projects.html b/src/bugsnag/ui/list-projects.html new file mode 100644 index 00000000..95a54452 --- /dev/null +++ b/src/bugsnag/ui/list-projects.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/src/bugsnag/ui/list-projects.tsx b/src/bugsnag/ui/list-projects.tsx new file mode 100644 index 00000000..332b2e65 --- /dev/null +++ b/src/bugsnag/ui/list-projects.tsx @@ -0,0 +1,39 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import { createRoot } from "react-dom/client"; +import "./list-projects.css"; + +const app = new App({ + name: "List Projects", + version: "0.0.1", +}); + +const contentDiv = document.getElementById("container"); +const reactRoot = createRoot(contentDiv!); + +app.ontoolresult = (data) => { + const projectData = data.content[0]; + if (projectData.type !== "text") { + reactRoot.render(
Woops something went wrong...
); + throw new Error(`Expected JSON content, but got ${projectData.type}`); + } + + const projects = JSON.parse(projectData.text); + + reactRoot.render( +
+
    + {projects.data.map((p) => ( +
  1. {p.name}
  2. + ))} +
+
, + ); +}; + +// Listen for incoming data from the MCP tool +app.ontoolinput = (data) => { + console.log("Received data:", data); +}; + +// Signal that the app is ready +app.connect(); diff --git a/src/common/server.ts b/src/common/server.ts index aa676cc1..e1176741 100644 --- a/src/common/server.ts +++ b/src/common/server.ts @@ -90,6 +90,7 @@ export class SmartBearMcpServer extends McpServer { inputSchema: this.getInputSchema(params), outputSchema: this.getOutputSchema(params), annotations: this.getAnnotations(toolTitle, params), + ...(params._meta && { _meta: params._meta }), }, async (args: any, extra: any) => { try { @@ -156,7 +157,7 @@ export class SmartBearMcpServer extends McpServer { if (client.registerResources) { client.registerResources((name, path, cb) => { - const url = `${client.toolPrefix}://${name}/${path}`; + const url = typeof path === 'string' ? `${client.toolPrefix}://${name}/${path}` : path.uri; return super.registerResource( name, new ResourceTemplate(url, { diff --git a/src/common/transport-http.ts b/src/common/transport-http.ts index ed1bb40e..1ffebbe5 100644 --- a/src/common/transport-http.ts +++ b/src/common/transport-http.ts @@ -36,6 +36,7 @@ export async function runHttpMode() { "Content-Type", "Authorization", "MCP-Session-Id", // Required for StreamableHTTP + "MCP-Protocol-Version", "x-custom-auth-headers", // used by mcp-inspector ...allowedAuthHeaders, ].join(", "); diff --git a/src/common/types.ts b/src/common/types.ts index 131e9349..864a647d 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -6,6 +6,7 @@ import type { RegisteredTool, ToolCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpUiToolMeta } from "@modelcontextprotocol/ext-apps"; import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ElicitRequest, @@ -40,6 +41,9 @@ export interface ToolParams { destructive?: boolean; idempotent?: boolean; openWorld?: boolean; + _meta?: { + ui?: McpUiToolMeta; + }; } export interface PromptParams { @@ -59,7 +63,7 @@ export type RegisterToolsFunction = ( export type RegisterResourceFunction = ( name: string, - path: string, + path: string | { uri: string }, cb: ReadResourceTemplateCallback, ) => RegisteredResourceTemplate; diff --git a/src/tests/unit/bugsnag/client.test.ts b/src/tests/unit/bugsnag/client.test.ts index b68be823..c3ec639f 100644 --- a/src/tests/unit/bugsnag/client.test.ts +++ b/src/tests/unit/bugsnag/client.test.ts @@ -158,6 +158,9 @@ describe("BugsnagClient", () => { mockConsole.log.mockClear(); mockConsole.info.mockClear(); mockConsole.debug.mockClear(); + + client = await createConfiguredClient("test-token", "test-project-key"); + clientWithNoApiKey = await createConfiguredClient("test-token"); }); afterEach(() => { @@ -771,10 +774,6 @@ describe("BugsnagClient", () => { }); describe("API methods", async () => { - beforeEach(async () => { - client = await createConfiguredClient("test-token", "test-project-key"); - }); - describe("getProjects", () => { const mockOrg = getMockOrganization("org-1", "Test Org"); const mockProjects = [getMockProject("proj-1", "Project 1")]; @@ -929,9 +928,6 @@ describe("BugsnagClient", () => { beforeEach(async () => { registerToolsSpy = vi.fn(); getInputFunctionSpy = vi.fn(); - - client = await createConfiguredClient("test-token", "test-project-key"); - clientWithNoApiKey = await createConfiguredClient("test-token"); }); describe("Setting the current project", () => { @@ -2909,5 +2905,26 @@ describe("BugsnagClient", () => { expect(result.contents[0].text).toBe(JSON.stringify(mockEvent)); }); }); + + describe("List projects UI", () => { + it("should return the resource html", async () => { + const mockEvent = getMockEvent("event-1"); + const mockProjects = [getMockProject("proj-1", "Project 1")]; + + mockCache.get.mockReturnValueOnce(mockProjects); + mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent }); + + client.registerResources(registerResourcesSpy); + const resourceHandler = registerResourcesSpy.mock.calls[1][2]; + + const result = await resourceHandler( + { href: "ui://bugsnag/list-projects" }, + {}, + ); + + expect(result.contents[0].uri).toBe("ui://bugsnag/list-projects"); + expect(result.contents[0].text).toMatch(//); + }); + }); }); }); diff --git a/vite.config.ui.ts b/vite.config.ui.ts new file mode 100644 index 00000000..44493cda --- /dev/null +++ b/vite.config.ui.ts @@ -0,0 +1,18 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + root: "src", + build: { + outDir: "../dist", + emptyOutDir: false, + rollupOptions: { + input: resolve(__dirname, "src/bugsnag/ui/list-projects.html"), + }, + minify: true, + target: "es2020", + }, +}); From e764dfe96e7bf7a8cb69032f11c9f9185d13fef7 Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Tue, 10 Feb 2026 15:27:50 +0000 Subject: [PATCH 2/6] POC with hot reloading and bundle splitting --- package.json | 5 +- src/bugsnag/client.ts | 113 ++++++++++++++------------ src/bugsnag/ui/ListProjects.css | 58 +++++++++++++ src/bugsnag/ui/ListProjects.tsx | 100 +++++++++++++++++++++++ src/bugsnag/ui/README.md | 21 +++++ src/bugsnag/ui/app.html | 12 +++ src/bugsnag/ui/app.tsx | 53 ++++++++++++ src/bugsnag/ui/appContext.ts | 12 +++ src/bugsnag/ui/list-projects.css | 18 ---- src/bugsnag/ui/list-projects.html | 11 --- src/bugsnag/ui/list-projects.tsx | 39 --------- src/bugsnag/ui/util.ts | 11 +++ src/common/transport-http.ts | 33 +++++++- src/common/types.ts | 8 +- src/index.ts | 11 ++- src/tests/unit/bugsnag/client.test.ts | 11 +-- vite.config.ui.ts | 48 +++++++++-- 17 files changed, 428 insertions(+), 136 deletions(-) create mode 100644 src/bugsnag/ui/ListProjects.css create mode 100644 src/bugsnag/ui/ListProjects.tsx create mode 100644 src/bugsnag/ui/README.md create mode 100644 src/bugsnag/ui/app.html create mode 100644 src/bugsnag/ui/app.tsx create mode 100644 src/bugsnag/ui/appContext.ts delete mode 100644 src/bugsnag/ui/list-projects.css delete mode 100644 src/bugsnag/ui/list-projects.html delete mode 100644 src/bugsnag/ui/list-projects.tsx create mode 100644 src/bugsnag/ui/util.ts diff --git a/package.json b/package.json index f82aea79..3f90602e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "mcpServerName": "SmartBear MCP Server" }, "scripts": { - "build": "vite build && vite build --config vite.config.ui.ts && shx chmod +x dist/*.js", + "build": "vite build && npm run ui && shx chmod +x dist/*.js", + "ui": "vite build --config vite.config.ui.ts", + "ui:dev": "vite --config vite.config.ui.ts", "lint": "biome lint .", "lint:fix": "biome lint . --fix", "format": "biome format . --write", @@ -43,7 +45,6 @@ "test:coverage": "vitest --coverage", "test:coverage:ci": "vitest --coverage --reporter=verbose", "test:run": "vitest run", - "ts": "tsc", "coverage:check": "vitest --coverage --reporter=verbose --config vitest.config.coverage.ts", "bump": "node scripts/bump.js" }, diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index f8191681..449e3960 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -1,6 +1,10 @@ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + type McpUiResourceMeta, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps"; import { z } from "zod"; import type { CacheService } from "../common/cache"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info"; @@ -24,10 +28,9 @@ import { ProjectAPI, type Release, type TraceField, -} from "./client/api/index"; +} from "./client/api"; import { type FilterObject, toUrlSearchParams } from "./client/filters"; import { toolInputParameters } from "./input-schemas"; -import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps"; const HUB_PREFIX = "00000"; const DEFAULT_DOMAIN = "bugsnag.com"; @@ -41,13 +44,6 @@ const cacheKeys = { CURRENT_PROJECT: "bugsnag_current_project", }; -interface UIResource { - name: string; - uri: string; - content: string; - meta: any; -} - // Exclude certain event fields from the project event filters to improve agent usage const EXCLUDED_EVENT_FIELDS = new Set([ "search", // This is searches multiple fields and is more a convenience for humans, we're removing to avoid over-matching @@ -113,26 +109,9 @@ export class BugsnagClient implements Client { configPrefix = "Bugsnag"; config = ConfigurationSchema; - createUiResource = (name: string): UIResource => { - const uri = `ui://${this.toolPrefix}/${name}`; - return { - name, - uri, - content: readFileSync( - join(dirname(fileURLToPath(import.meta.url)), "ui", `${name}.html`), - "utf-8", - ), - meta: { - ui: { - resourceUri: uri, - }, - }, - }; - }; - - uiResource = { - listProjects: this.createUiResource("list-projects"), - }; + createAppUri(tool: string = "{tool}") { + return `ui://${this.toolPrefix}/app/${tool}`; + } async configure( server: SmartBearMcpServer, @@ -475,7 +454,11 @@ export class BugsnagClient implements Client { hints: [ "Project IDs from this list can be used with other tools when no project API key is configured", ], - _meta: { ...this.uiResource.listProjects.meta }, + _meta: { + ui: { + resourceUri: this.createAppUri("list-projects"), + }, + }, }, async (args, _extra) => { const params = listProjectsInputSchema.parse(args); @@ -1736,26 +1719,56 @@ export class BugsnagClient implements Client { }; }); - this.registerUIResources(register, this.uiResource.listProjects); - } + let appHtml: string; - private registerUIResources( - register: RegisterResourceFunction, - resource: UIResource, - ): void { - register(resource.name, resource, async (_uri, _variables, _extra) => { - return { - contents: [ - { - uri: resource.uri, - mimeType: RESOURCE_MIME_TYPE, - text: resource.content, - _meta: { - something: "true", + register( + "bugsnag-ui", + { uri: this.createAppUri() }, + async (uri, variables, _extra) => { + const toolPlaceholder = "{{tool}}"; + + const isDev = process.env.UI_DEV; + if (isDev || !appHtml) { + appHtml = await (isDev + ? // always re-fetch from the vite dev server + fetch("http://localhost:3001/bugsnag/ui/app.html").then((res) => + res.text(), + ) + : // only read the file once when served from the dist folder rather than the vite dev server + readFile( + join(dirname(fileURLToPath(import.meta.url)), "ui", "app.html"), + "utf-8", + )); + + if (!appHtml.includes(toolPlaceholder)) { + throw new Error( + `expected meta tool placeholder ${toolPlaceholder} not found`, + ); + } + } + + return { + contents: [ + { + uri: uri.href, + mimeType: RESOURCE_MIME_TYPE, + text: appHtml.replace(toolPlaceholder, variables.tool as string), + _meta: { + ui: { + csp: { + resourceDomains: isDev + ? ["http://localhost:3001"] + : ["http://localhost:3000"], + connectDomains: isDev + ? ["http://localhost:3001", "ws://localhost:3001"] + : [], + }, + } satisfies McpUiResourceMeta, + }, }, - }, - ], - }; - }); + ], + }; + }, + ); } } diff --git a/src/bugsnag/ui/ListProjects.css b/src/bugsnag/ui/ListProjects.css new file mode 100644 index 00000000..4db6a951 --- /dev/null +++ b/src/bugsnag/ui/ListProjects.css @@ -0,0 +1,58 @@ +.list-projects { + display: flex; + flex-direction: column; + gap: 0.5rem; + + input { + padding: 0.2rem; + } + + .project-list { + list-style: none; + padding: 0; + margin: 0; + + li { + details { + padding: 0.75rem; + margin-bottom: 0.1rem; + border-radius: 2px; + color: #212121; + background: white; + + summary { + cursor: pointer; + } + + &:hover { + background: #ededfc; + } + + .message, + .error-list { + margin-top: 0.5rem; + } + } + } + } + + .error-list { + padding: 0; + list-style: none; + + li { + margin: 0 0 0.3rem; + padding: 0.5rem; + border-radius: 3px; + background: #ffd7d7; + } + + .error-header { + font-weight: bold; + } + .error-message { + margin-top: 0.25rem; + color: #5a5959; + } + } +} diff --git a/src/bugsnag/ui/ListProjects.tsx b/src/bugsnag/ui/ListProjects.tsx new file mode 100644 index 00000000..6c66e81a --- /dev/null +++ b/src/bugsnag/ui/ListProjects.tsx @@ -0,0 +1,100 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Suspense, use, useMemo, useState } from "react"; +import type { ErrorApiView, Project } from "../client/api"; +import "./ListProjects.css"; +import { useApp } from "./appContext"; +import { getToolResult } from "./util"; + +export default function ListProjects(props: { data: CallToolResult }) { + const { data } = props; + + const projects = useMemo( + () => getToolResult<{ data: Project[]; count: number }>(data), + [data], + ); + + const [searchTerm, setSearchTerm] = useState(""); + + return ( +
+ setSearchTerm(e.target.value)} + /> +
    + {projects.data + .filter((p) => + p.name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()), + ) + .map((p) => ( + + ))} +
+
+ ); +} + +function ProjectListItem(props: { project: Project }) { + const { id, name } = props.project; + + const app = useApp(); + const [projectErrorsResource, setProjectErrorsResource] = useState(null); + + return ( +
  • +
    { + const detail = event.target as HTMLDetailsElement; + if (detail.open) { + setProjectErrorsResource( + app.callServerTool({ + name: "bugsnag_list_project_errors", + arguments: { projectId: id }, + }), + ); + } else { + setProjectErrorsResource(null); + } + }} + > + {name} + Loading...}> + {projectErrorsResource && ( + + )} + +
    +
  • + ); +} + +function ProjectErrors(props: { resource: Promise }) { + const result = use(props.resource); + const data = getToolResult<{ + data: ErrorApiView[]; + next_url?: string; + data_count?: number; + total_count?: number; + }>(result); + const errors = data.data; + + if (errors.length === 0) { + return
    No errors found for this project.
    ; + } + + return ( +
      + {errors.map((e) => ( +
    • +
      + {e.error_class} {e.context} +
      +
      {e.message}
      +
    • + ))} +
    + ); +} diff --git a/src/bugsnag/ui/README.md b/src/bugsnag/ui/README.md new file mode 100644 index 00000000..aa55a13d --- /dev/null +++ b/src/bugsnag/ui/README.md @@ -0,0 +1,21 @@ +# MCP App POC + +See https://modelcontextprotocol.io/docs/extensions/apps + +When running the MCP server locally from the built dist dir. The app will be served by the MCP server. +The HTML file is read directly once and cached and assets are served from /assets. + +### Local development +To improve the development experience, you can run the vite dev server, and start the MCP server with UI_DEV=true. +This will cause the MCP server to proxy the html from the dev server and the dev server will handle the assets. +This allows hot reloading to work and a far better experience. + +``` +$ UI_DEV=1 node dist/server.js +``` + +To use the basic-host app for testing, see https://modelcontextprotocol.io/docs/extensions/apps#testing-with-the-basic-host. + +``` +$ SERVERS='["http://localhost:3000/mcp"]' npm start +``` diff --git a/src/bugsnag/ui/app.html b/src/bugsnag/ui/app.html new file mode 100644 index 00000000..f7bb15bf --- /dev/null +++ b/src/bugsnag/ui/app.html @@ -0,0 +1,12 @@ + + + + + + BugSnag MCP App + + +
    + + + diff --git a/src/bugsnag/ui/app.tsx b/src/bugsnag/ui/app.tsx new file mode 100644 index 00000000..d55f1992 --- /dev/null +++ b/src/bugsnag/ui/app.tsx @@ -0,0 +1,53 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { lazy, Suspense } from "react"; +import { createRoot } from "react-dom/client"; +import "./ListProjects.css"; +import { AppContext } from "./appContext"; + +const ListProjects = lazy(() => import("./ListProjects")); + +const contentDiv = document.getElementById("container"); +if (!contentDiv) { + throw new Error("Could not find container element"); +} +const reactRoot = createRoot(contentDiv); + +const app = new App({ + name: "BugSnag MCP App", + version: "0.0.1", +}); + +app.connect(); + +app.ontoolresult = (data) => { + // get the tool that was initially called and rendered the app + const toolId = document + .querySelector('meta[name="mcp-tool-id"]') + ?.getAttribute("content"); + if (!toolId) { + throw new Error("Could not find mcp-tool-id meta tag"); + } + + reactRoot.render( + + + + + , + ); +}; + +/** + * Based on the tool that was called, render the appropriate component. + * + * The routes should be imported lazily. + */ +function Router({ toolId, data }: { toolId: string; data: CallToolResult }) { + switch (toolId) { + case "list-projects": + return ; + default: + throw new Error(`Unknown tool ID: ${toolId}`); + } +} diff --git a/src/bugsnag/ui/appContext.ts b/src/bugsnag/ui/appContext.ts new file mode 100644 index 00000000..18d65187 --- /dev/null +++ b/src/bugsnag/ui/appContext.ts @@ -0,0 +1,12 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { createContext, useContext } from "react"; + +export const AppContext = createContext(undefined); + +export function useApp() { + const app = useContext(AppContext); + if (!app) { + throw new Error("useApp must be used within an AppContext.Provider"); + } + return app; +} diff --git a/src/bugsnag/ui/list-projects.css b/src/bugsnag/ui/list-projects.css deleted file mode 100644 index 0938f8dc..00000000 --- a/src/bugsnag/ui/list-projects.css +++ /dev/null @@ -1,18 +0,0 @@ -ol { - list-style-position: inside; - padding: 0; - margin: 0; -} - -li { - padding: 0.75rem; - margin-bottom: 0.1rem; - border-radius: 2px; - color: #212121; - background: white; - cursor: pointer; - - &:hover { - background: #ededfc; - } -} diff --git a/src/bugsnag/ui/list-projects.html b/src/bugsnag/ui/list-projects.html deleted file mode 100644 index 95a54452..00000000 --- a/src/bugsnag/ui/list-projects.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - -
    - - - diff --git a/src/bugsnag/ui/list-projects.tsx b/src/bugsnag/ui/list-projects.tsx deleted file mode 100644 index 332b2e65..00000000 --- a/src/bugsnag/ui/list-projects.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { App } from "@modelcontextprotocol/ext-apps"; -import { createRoot } from "react-dom/client"; -import "./list-projects.css"; - -const app = new App({ - name: "List Projects", - version: "0.0.1", -}); - -const contentDiv = document.getElementById("container"); -const reactRoot = createRoot(contentDiv!); - -app.ontoolresult = (data) => { - const projectData = data.content[0]; - if (projectData.type !== "text") { - reactRoot.render(
    Woops something went wrong...
    ); - throw new Error(`Expected JSON content, but got ${projectData.type}`); - } - - const projects = JSON.parse(projectData.text); - - reactRoot.render( -
    -
      - {projects.data.map((p) => ( -
    1. {p.name}
    2. - ))} -
    -
    , - ); -}; - -// Listen for incoming data from the MCP tool -app.ontoolinput = (data) => { - console.log("Received data:", data); -}; - -// Signal that the app is ready -app.connect(); diff --git a/src/bugsnag/ui/util.ts b/src/bugsnag/ui/util.ts new file mode 100644 index 00000000..542e4d50 --- /dev/null +++ b/src/bugsnag/ui/util.ts @@ -0,0 +1,11 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export function getToolResult(toolResult: CallToolResult): T { + const content = toolResult.content.find((c) => c.type === "text"); + if (!content) { + throw new Error( + `Expected text content, but got ${toolResult.content[0].type}`, + ); + } + return JSON.parse(content.text) as T; +} diff --git a/src/common/transport-http.ts b/src/common/transport-http.ts index 1ffebbe5..e9e4c902 100644 --- a/src/common/transport-http.ts +++ b/src/common/transport-http.ts @@ -1,7 +1,9 @@ import { randomUUID } from "node:crypto"; +import { createReadStream, existsSync } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import { createServer } from "node:http"; - +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; @@ -48,6 +50,7 @@ export async function runHttpMode() { if (allowedOrigins.includes(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); } + res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader( "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS", @@ -78,6 +81,34 @@ export async function runHttpMode() { return; } + // Serve static assets from dist/assets/ + if (req.method === "GET" && url.pathname.startsWith("/assets/")) { + const distDir = join( + fileURLToPath(new URL(".", import.meta.url)), + "../", + ); + const filePath = join(distDir, url.pathname); + + // Prevent directory traversal attacks + if (!filePath.startsWith(distDir) || !existsSync(filePath)) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + return; + } + + const ext = url.pathname.split(".").pop(); + const contentType = + ext === "css" + ? "text/css" + : ext === "js" + ? "application/javascript" + : "text/plain"; + + res.writeHead(200, { "Content-Type": contentType }); + createReadStream(filePath).pipe(res); + return; + } + // LEGACY SSE ENDPOINT (for backwards compatibility) if (req.method === "GET" && url.pathname === "/sse") { await handleLegacySseRequest(req, res, transports); diff --git a/src/common/types.ts b/src/common/types.ts index 864a647d..dda12c3d 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,3 +1,4 @@ +import type { McpUiToolMeta } from "@modelcontextprotocol/ext-apps"; import type { PromptCallback, ReadResourceTemplateCallback, @@ -6,7 +7,6 @@ import type { RegisteredTool, ToolCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpUiToolMeta } from "@modelcontextprotocol/ext-apps"; import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ElicitRequest, @@ -63,7 +63,11 @@ export type RegisterToolsFunction = ( export type RegisterResourceFunction = ( name: string, - path: string | { uri: string }, + path: + | string + | { + uri: string; + } /* replace the default uri construction with a full uri string (may include template variables) */, cb: ReadResourceTemplateCallback, ) => RegisteredResourceTemplate; diff --git a/src/index.ts b/src/index.ts index d0a9ba5c..99dde4ee 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import Bugsnag from "./common/bugsnag"; import "./common/register-clients"; // Register all available clients +import { spawn } from "node:child_process"; import { runHttpMode } from "./common/transport-http"; -import { runStdioMode } from "./common/transport-stdio"; +import { runStdioMode } from "./common/transport-stdio.ts"; // This is used to report errors in the MCP server itself // If you want to use your own BugSnag API key, set the MCP_SERVER_BUGSNAG_API_KEY environment variable @@ -27,6 +28,14 @@ async function main() { ); process.exit(1); } + + if (process.env.UI_DEV) { + // Run the vite dev server alongside the MCP server + spawn("npm", ["run", "ui:dev", "--", "--clearScreen=false"], { + stdio: "inherit", + shell: true, + }); + } } try { diff --git a/src/tests/unit/bugsnag/client.test.ts b/src/tests/unit/bugsnag/client.test.ts index c3ec639f..aceec6f7 100644 --- a/src/tests/unit/bugsnag/client.test.ts +++ b/src/tests/unit/bugsnag/client.test.ts @@ -2908,22 +2908,23 @@ describe("BugsnagClient", () => { describe("List projects UI", () => { it("should return the resource html", async () => { - const mockEvent = getMockEvent("event-1"); const mockProjects = [getMockProject("proj-1", "Project 1")]; mockCache.get.mockReturnValueOnce(mockProjects); - mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent }); client.registerResources(registerResourcesSpy); const resourceHandler = registerResourcesSpy.mock.calls[1][2]; const result = await resourceHandler( - { href: "ui://bugsnag/list-projects" }, - {}, + { href: "ui://bugsnag/app/list-projects" }, + { tool: "list-projects" }, ); - expect(result.contents[0].uri).toBe("ui://bugsnag/list-projects"); + expect(result.contents[0].uri).toBe("ui://bugsnag/app/list-projects"); expect(result.contents[0].text).toMatch(//); + expect(result.contents[0].text).toContain( + '', + ); }); }); }); diff --git a/vite.config.ui.ts b/vite.config.ui.ts index 44493cda..59b6338e 100644 --- a/vite.config.ui.ts +++ b/vite.config.ui.ts @@ -1,18 +1,52 @@ +import { dirname, join, resolve } from "node:path"; +import { resolve as resolveUrl } from "node:url"; import react from "@vitejs/plugin-react"; -import { resolve } from "node:path"; import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; + +// are we running the dev server or doing a build +const isDev = !process.argv.includes("build"); +const port = isDev ? 3001 : 3000; +const base = `http://localhost:${port}/`; + +/** + * Modify the HTML so the imports/src/hrefs are absolute urls to the dev server. + * As the MCP server reads and serves the HTML statically + */ +function absoluteUrls() { + return { + name: "absolute-urls", + transformIndexHtml: { + order: "post", + handler(html: string, context) { + const path = dirname(context.path); + return html + .replace( + /(src|href)="([^/].*?)"/g, + (...m) => `${m[1]}="${resolveUrl(base, join(path, m[2]))}"`, + ) + .replace(/(src|href)="\//g, `$1="${base}`) + .replace(/(from ['"])\//g, `$1${base}`); + }, + }, + }; +} export default defineConfig({ - plugins: [react(), viteSingleFile()], - root: "src", + plugins: [react(), ...(isDev ? [absoluteUrls()] : [])], + root: resolve(__dirname, "src"), + base: `${base}`, + server: { + hmr: true, + port, + allowedHosts: ["http://localhost:8081"], + }, build: { - outDir: "../dist", + outDir: resolve(__dirname, "dist"), emptyOutDir: false, rollupOptions: { - input: resolve(__dirname, "src/bugsnag/ui/list-projects.html"), + input: [resolve(__dirname, "src/bugsnag/ui/app.html")], }, - minify: true, + minify: false, target: "es2020", }, }); From 3d0edc65a9d1e4e7fd695e02a24dd305a5e96111 Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Tue, 10 Feb 2026 16:44:10 +0000 Subject: [PATCH 3/6] Refactoring/tidy --- package-lock.json | 94 ---------------- package.json | 1 - src/bugsnag/client.ts | 73 ++----------- .../ui/{appContext.ts => AppContext.ts} | 0 src/bugsnag/ui/ListProjects.tsx | 38 ++++--- src/bugsnag/ui/app.tsx | 35 +----- src/bugsnag/ui/util.ts | 11 -- src/common/types.ts | 100 ++++++++++++++++-- src/commonUi/util.tsx | 61 +++++++++++ src/index.ts | 2 +- 10 files changed, 184 insertions(+), 231 deletions(-) rename src/bugsnag/ui/{appContext.ts => AppContext.ts} (100%) delete mode 100644 src/bugsnag/ui/util.ts create mode 100644 src/commonUi/util.tsx diff --git a/package-lock.json b/package-lock.json index 9ce08f73..b2f31016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,6 @@ "globals": "^16.2.0", "shx": "^0.3.4", "typescript": "^5.6.2", - "vite-plugin-singlefile": "^2.3.0", "vitest": "^3.2.4", "vitest-fetch-mock": "^0.4.5" } @@ -2785,19 +2784,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3464,19 +3450,6 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3887,16 +3860,6 @@ "node": ">=8" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4139,33 +4102,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5377,19 +5313,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5639,23 +5562,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-plugin-singlefile": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", - "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">18.0.0" - }, - "peerDependencies": { - "rollup": "^4.44.1", - "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", diff --git a/package.json b/package.json index 3f90602e..349f8804 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "globals": "^16.2.0", "shx": "^0.3.4", "typescript": "^5.6.2", - "vite-plugin-singlefile": "^2.3.0", "vitest": "^3.2.4", "vitest-fetch-mock": "^0.4.5" } diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 449e3960..7faa4044 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -1,20 +1,13 @@ -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - type McpUiResourceMeta, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps"; import { z } from "zod"; import type { CacheService } from "../common/cache"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info"; import type { SmartBearMcpServer } from "../common/server"; import { ToolError } from "../common/tools"; -import type { +import { Client, - GetInputFunction, - RegisterResourceFunction, - RegisterToolsFunction, + type GetInputFunction, + type RegisterResourceFunction, + type RegisterToolsFunction, } from "../common/types"; import { type Build, @@ -74,7 +67,7 @@ const ConfigurationSchema = z.object({ endpoint: z.url().describe("BugSnag endpoint URL").optional(), }); -export class BugsnagClient implements Client { +export class BugsnagClient extends Client { private cache?: CacheService; private projectApiKey?: string; private configuredProjectApiKey?: string; @@ -109,10 +102,6 @@ export class BugsnagClient implements Client { configPrefix = "Bugsnag"; config = ConfigurationSchema; - createAppUri(tool: string = "{tool}") { - return `ui://${this.toolPrefix}/app/${tool}`; - } - async configure( server: SmartBearMcpServer, config: z.infer, @@ -1719,56 +1708,6 @@ export class BugsnagClient implements Client { }; }); - let appHtml: string; - - register( - "bugsnag-ui", - { uri: this.createAppUri() }, - async (uri, variables, _extra) => { - const toolPlaceholder = "{{tool}}"; - - const isDev = process.env.UI_DEV; - if (isDev || !appHtml) { - appHtml = await (isDev - ? // always re-fetch from the vite dev server - fetch("http://localhost:3001/bugsnag/ui/app.html").then((res) => - res.text(), - ) - : // only read the file once when served from the dist folder rather than the vite dev server - readFile( - join(dirname(fileURLToPath(import.meta.url)), "ui", "app.html"), - "utf-8", - )); - - if (!appHtml.includes(toolPlaceholder)) { - throw new Error( - `expected meta tool placeholder ${toolPlaceholder} not found`, - ); - } - } - - return { - contents: [ - { - uri: uri.href, - mimeType: RESOURCE_MIME_TYPE, - text: appHtml.replace(toolPlaceholder, variables.tool as string), - _meta: { - ui: { - csp: { - resourceDomains: isDev - ? ["http://localhost:3001"] - : ["http://localhost:3000"], - connectDomains: isDev - ? ["http://localhost:3001", "ws://localhost:3001"] - : [], - }, - } satisfies McpUiResourceMeta, - }, - }, - ], - }; - }, - ); + this.registerUIResource(register); } } diff --git a/src/bugsnag/ui/appContext.ts b/src/bugsnag/ui/AppContext.ts similarity index 100% rename from src/bugsnag/ui/appContext.ts rename to src/bugsnag/ui/AppContext.ts diff --git a/src/bugsnag/ui/ListProjects.tsx b/src/bugsnag/ui/ListProjects.tsx index 6c66e81a..0f0c6134 100644 --- a/src/bugsnag/ui/ListProjects.tsx +++ b/src/bugsnag/ui/ListProjects.tsx @@ -1,9 +1,9 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { Suspense, use, useMemo, useState } from "react"; +import { getToolResult } from "../../commonUi/util"; import type { ErrorApiView, Project } from "../client/api"; import "./ListProjects.css"; -import { useApp } from "./appContext"; -import { getToolResult } from "./util"; +import { useApp } from "./AppContext"; export default function ListProjects(props: { data: CallToolResult }) { const { data } = props; @@ -43,23 +43,27 @@ function ProjectListItem(props: { project: Project }) { const app = useApp(); const [projectErrorsResource, setProjectErrorsResource] = useState(null); + /** + * When expanded, load the top project errors + * When collapsed, clear the errors + */ + const handleToggle = (event: React.ToggleEvent) => { + const detail = event.target as HTMLDetailsElement; + if (detail.open) { + setProjectErrorsResource( + app.callServerTool({ + name: "bugsnag_list_project_errors", + arguments: { projectId: id }, + }), + ); + } else { + setProjectErrorsResource(null); + } + }; + return (
  • -
    { - const detail = event.target as HTMLDetailsElement; - if (detail.open) { - setProjectErrorsResource( - app.callServerTool({ - name: "bugsnag_list_project_errors", - arguments: { projectId: id }, - }), - ); - } else { - setProjectErrorsResource(null); - } - }} - > +
    {name} Loading...}> {projectErrorsResource && ( diff --git a/src/bugsnag/ui/app.tsx b/src/bugsnag/ui/app.tsx index d55f1992..5ffc96d8 100644 --- a/src/bugsnag/ui/app.tsx +++ b/src/bugsnag/ui/app.tsx @@ -1,43 +1,16 @@ -import { App } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { lazy, Suspense } from "react"; -import { createRoot } from "react-dom/client"; +import { lazy } from "react"; import "./ListProjects.css"; -import { AppContext } from "./appContext"; +import { createMcpApp } from "../../commonUi/util"; const ListProjects = lazy(() => import("./ListProjects")); -const contentDiv = document.getElementById("container"); -if (!contentDiv) { - throw new Error("Could not find container element"); -} -const reactRoot = createRoot(contentDiv); - -const app = new App({ +createMcpApp({ name: "BugSnag MCP App", version: "0.0.1", + RootComponent: Router, }); -app.connect(); - -app.ontoolresult = (data) => { - // get the tool that was initially called and rendered the app - const toolId = document - .querySelector('meta[name="mcp-tool-id"]') - ?.getAttribute("content"); - if (!toolId) { - throw new Error("Could not find mcp-tool-id meta tag"); - } - - reactRoot.render( - - - - - , - ); -}; - /** * Based on the tool that was called, render the appropriate component. * diff --git a/src/bugsnag/ui/util.ts b/src/bugsnag/ui/util.ts deleted file mode 100644 index 542e4d50..00000000 --- a/src/bugsnag/ui/util.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; - -export function getToolResult(toolResult: CallToolResult): T { - const content = toolResult.content.find((c) => c.type === "text"); - if (!content) { - throw new Error( - `Expected text content, but got ${toolResult.content[0].type}`, - ); - } - return JSON.parse(content.text) as T; -} diff --git a/src/common/types.ts b/src/common/types.ts index dda12c3d..a112e938 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,4 +1,11 @@ -import type { McpUiToolMeta } from "@modelcontextprotocol/ext-apps"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type McpUiResourceMeta, + type McpUiToolMeta, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps"; import type { PromptCallback, ReadResourceTemplateCallback, @@ -95,30 +102,105 @@ export type Parameters = Array<{ constraints?: string[]; }>; -export interface Client { +export abstract class Client { /** Human-readable name for the client - usually the product name - used to prefix tool names */ - name: string; + abstract name: string; /** Prefix for tool IDs */ - toolPrefix: string; + abstract toolPrefix: string; /** Prefix for configuration (environment variables and http headers) */ - configPrefix: string; + abstract configPrefix: string; /** * Zod schema defining configuration fields for this client * Field names must use snake case to ensure they are mapped to environment variables and HTTP headers correctly. * e.g., `config.my_property` would refer to the environment variable `TOOL_MY_PROPERTY`, http header `Tool-My-Property` */ - config: ZodObject<{ + abstract config: ZodObject<{ [key: string]: ZodType; }>; /** * Configure the client with the given server and configuration */ - configure: (server: SmartBearMcpServer, config: any) => Promise; - isConfigured: () => boolean; - registerTools( + abstract configure(server: SmartBearMcpServer, config: any): Promise; + abstract isConfigured(): boolean; + abstract registerTools( register: RegisterToolsFunction, getInput: GetInputFunction, ): Promise; registerResources?(register: RegisterResourceFunction): void; registerPrompts?(register: RegisterPromptFunction): void; + + protected createAppUri(tool: string = "{tool}") { + return `ui://${this.toolPrefix}/app/${tool}`; + } + + protected registerUIResource( + register: RegisterResourceFunction, + config?: { + name?: string; + uri?: string; + /** + * Path to html entry relative to the src directory + * @default `${toolPrefix}/ui/app.html` + * @example "bugsnag/ui/app.html" + */ + filePath?: string; + /** + * Not required if filePath is provided + * @default "app.html" + */ + htmlFile?: string; + }, + ) { + const { + name = `${this.toolPrefix}-ui`, + uri = this.createAppUri(), + htmlFile = "app.html", + filePath = `${this.toolPrefix}/ui/${htmlFile}`, + } = config || {}; + + let html: string; + const isDev = process.env.UI_DEV; + const toolPlaceholder = "{{tool}}"; + + register(name, { uri }, async (uri, variables, _extra) => { + if (isDev || !html) { + html = await (isDev + ? // always re-fetch from the vite dev server + fetch(`http://localhost:3001/${filePath}`).then((res) => res.text()) + : // only read the file once when served from the dist folder rather than the vite dev server + readFile( + join(dirname(fileURLToPath(import.meta.url)), "..", filePath), + "utf-8", + )); + + if (!html.includes(toolPlaceholder)) { + throw new Error( + `expected meta tag mcp-tool-id with content placeholder ${toolPlaceholder} but was not found`, + ); + } + } + + return { + contents: [ + { + uri: uri.href, + mimeType: RESOURCE_MIME_TYPE, + text: html.replace(toolPlaceholder, variables.tool as string), + _meta: { + ui: { + csp: { + resourceDomains: isDev + ? ["http://localhost:3001"] + : ["http://localhost:3000"], + connectDomains: isDev + ? ["http://localhost:3001", "ws://localhost:3001"] + : [], + }, + } satisfies McpUiResourceMeta, + }, + }, + ], + }; + }); + } } diff --git a/src/commonUi/util.tsx b/src/commonUi/util.tsx new file mode 100644 index 00000000..f00beee4 --- /dev/null +++ b/src/commonUi/util.tsx @@ -0,0 +1,61 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type ComponentType, Suspense } from "react"; +import { createRoot } from "react-dom/client"; +import { AppContext } from "../bugsnag/ui/AppContext"; + +interface McpAppConfig { + name: string; + version: string; + RootComponent: ComponentType<{ toolId: string; data: CallToolResult }>; +} + +/** + * Helper function to set up an MCP app with React + * + * A meta tag with the tool id is expected mcp-tool-id. + */ +export function createMcpApp(config: McpAppConfig) { + const { name, version, RootComponent } = config; + + const app = new App({ name, version }); + + const contentDiv = document.getElementById("container"); + if (!contentDiv) { + throw new Error("Could not find container element"); + } + const reactRoot = createRoot(contentDiv); + + app.connect(); + + app.ontoolresult = (data) => { + // get the tool that was initially called and rendered the app + const toolId = document + .querySelector('meta[name="mcp-tool-id"]') + ?.getAttribute("content"); + if (!toolId) { + throw new Error("Could not find mcp-tool-id meta tag"); + } + + reactRoot.render( + + + + + , + ); + }; +} + +/** + * Get the result of a tool call, assuming the content is text and contains JSON + */ +export function getToolResult(toolResult: CallToolResult): T { + const content = toolResult.content.find((c) => c.type === "text"); + if (!content) { + throw new Error( + `Expected text content, but got ${toolResult.content[0].type}`, + ); + } + return JSON.parse(content.text) as T; +} diff --git a/src/index.ts b/src/index.ts index 99dde4ee..5f302475 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import Bugsnag from "./common/bugsnag"; import "./common/register-clients"; // Register all available clients import { spawn } from "node:child_process"; import { runHttpMode } from "./common/transport-http"; -import { runStdioMode } from "./common/transport-stdio.ts"; +import { runStdioMode } from "./common/transport-stdio"; // This is used to report errors in the MCP server itself // If you want to use your own BugSnag API key, set the MCP_SERVER_BUGSNAG_API_KEY environment variable From f62f62e8d672d395f54b8d79ab4a8ab3c11bfddc Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Tue, 10 Feb 2026 17:09:47 +0000 Subject: [PATCH 4/6] tidy --- src/bugsnag/ui/ListProjects.tsx | 40 ++++++++++++++++++--------------- tsconfig.json | 1 + 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/bugsnag/ui/ListProjects.tsx b/src/bugsnag/ui/ListProjects.tsx index 0f0c6134..fad6cf93 100644 --- a/src/bugsnag/ui/ListProjects.tsx +++ b/src/bugsnag/ui/ListProjects.tsx @@ -27,7 +27,9 @@ export default function ListProjects(props: { data: CallToolResult }) {
      {projects.data .filter((p) => - p.name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()), + p.name + ?.toLocaleLowerCase() + .includes(searchTerm.toLocaleLowerCase()), ) .map((p) => ( @@ -37,27 +39,36 @@ export default function ListProjects(props: { data: CallToolResult }) { ); } +interface ErrorResult { + data: ErrorApiView[]; + next_url?: string; + data_count?: number; + total_count?: number; +} + function ProjectListItem(props: { project: Project }) { const { id, name } = props.project; const app = useApp(); - const [projectErrorsResource, setProjectErrorsResource] = useState(null); + const [projectErrorsResource, setProjectErrorsResource] = + useState>(); /** * When expanded, load the top project errors * When collapsed, clear the errors */ const handleToggle = (event: React.ToggleEvent) => { - const detail = event.target as HTMLDetailsElement; - if (detail.open) { + if (event.newState === "open") { setProjectErrorsResource( - app.callServerTool({ - name: "bugsnag_list_project_errors", - arguments: { projectId: id }, - }), + app + .callServerTool({ + name: "bugsnag_list_project_errors", + arguments: { projectId: id }, + }) + .then((result) => getToolResult(result)), ); } else { - setProjectErrorsResource(null); + setProjectErrorsResource(undefined); } }; @@ -75,15 +86,8 @@ function ProjectListItem(props: { project: Project }) { ); } -function ProjectErrors(props: { resource: Promise }) { - const result = use(props.resource); - const data = getToolResult<{ - data: ErrorApiView[]; - next_url?: string; - data_count?: number; - total_count?: number; - }>(result); - const errors = data.data; +function ProjectErrors(props: { resource: Promise }) { + const errors = use(props.resource).data; if (errors.length === 0) { return
      No errors found for this project.
      ; diff --git a/tsconfig.json b/tsconfig.json index 498b92b3..2fe133e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 99fb8eb23836e84948d02074189e5ae4f392ce55 Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Tue, 10 Feb 2026 17:43:49 +0000 Subject: [PATCH 5/6] format --- src/common/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/common/server.ts b/src/common/server.ts index e1176741..1139076d 100644 --- a/src/common/server.ts +++ b/src/common/server.ts @@ -157,7 +157,10 @@ export class SmartBearMcpServer extends McpServer { if (client.registerResources) { client.registerResources((name, path, cb) => { - const url = typeof path === 'string' ? `${client.toolPrefix}://${name}/${path}` : path.uri; + const url = + typeof path === "string" + ? `${client.toolPrefix}://${name}/${path}` + : path.uri; return super.registerResource( name, new ResourceTemplate(url, { From 55439b2d540963d558c0991916fe8d2dac11673b Mon Sep 17 00:00:00 2001 From: Dom Armstrong Date: Wed, 11 Feb 2026 09:22:30 +0000 Subject: [PATCH 6/6] add vite ui file to docker --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ff82f1e4..941f6400 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM node:22-alpine AS builder WORKDIR /app COPY src/ ./src/ -COPY package.json package-lock.json tsconfig.json vite.config.ts ./ +COPY package.json package-lock.json tsconfig.json vite.config.ts vite.config.ui.ts ./ RUN --mount=type=cache,target=/root/.npm npm ci