diff --git a/README.md b/README.md index 9d5dfaf..4fb933b 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,30 @@ Please see examples and further documentation [here](https://learn.microsoft.com +## MCP Server +The extension can host a local [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server so MCP clients can inspect and drive your debug session. + +1. Enable the `minecraft-debugger.mcp.enabled` setting (the port is `minecraft-debugger.mcp.port`, default `19180`). +2. Run the command `Minecraft Debugger: Copy MCP Server Config`. +3. Paste the result into your MCP client's server configuration. + +Tools: +- `get_status` - session, connection, command support and paused state. +- `get_logs` - recent debugger output, optionally filtered by level. +- `get_stats` - latest Live Diagnostics values (from enabled tabs). +- `get_diagnostics_tabs`, `set_diagnostics_tab` - list and enable or disable Live Diagnostics tabs. +- `debugger_request` - send the requests behind the Live Diagnostics buttons, such as starting a profiler. +- `get_stack` - call stack of a paused thread. +- `get_variables` - scopes and variables of a stack frame. +- `evaluate` - evaluate an expression, in a stack frame while paused or globally while running. +- `get_entities` - list entities of a dimension with their type and location. +- `run_command` - send a Minecraft command (fire-and-forget). +- `continue`, `pause`, `step` - control execution. +- `set_breakpoint`, `clear_breakpoints` - manage breakpoints. +- `wait_for_event` - wait for a log line or a stop, with an optional regex. + +Security: the server only listens on `127.0.0.1` and requires a bearer token. The token is stored in the MCP client's config file you pasted it into; run `Minecraft Debugger: Regenerate MCP Token` to rotate it. Recent tool calls are shown in the home panel. + ## Feedback Send us your feedback by [filing an issue](https://github.com/mojang/minecraft-debugger/issues/new) against this extension's [GitHub repo](https://github.com/mojang/minecraft-debugger). diff --git a/package-lock.json b/package-lock.json index 284d152..b5cbbcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0-semantically-released", "license": "MIT", "devDependencies": { + "@modelcontextprotocol/sdk": "^1.32.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/node": "^24.12.4", @@ -29,7 +30,8 @@ "stream-parser": "^0.3.1", "ts-loader": "^9.5.1", "typescript": "^5.5.4", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "zod": "^4.6.5" }, "engines": { "node": "^24.0.0", @@ -785,6 +787,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@hono/node-server": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", @@ -1032,6 +1047,89 @@ "exenv-es6": "^1.1.1" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "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.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3767,6 +3865,47 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4138,6 +4277,62 @@ "node": ">= 6" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4269,6 +4464,16 @@ "license": "MIT", "peer": true }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4772,6 +4977,30 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", @@ -4844,6 +5073,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4851,6 +5100,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -4879,9 +5146,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -5077,6 +5344,16 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -5245,6 +5522,13 @@ "url": "https://bevry.me/fund" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "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", @@ -5267,6 +5551,16 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -5739,6 +6033,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -6128,6 +6429,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6138,6 +6449,29 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6190,6 +6524,97 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6287,6 +6712,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -6420,6 +6867,26 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -6815,6 +7282,16 @@ "node": "*" } }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hook-std": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", @@ -6861,6 +7338,27 @@ "entities": "^4.5.0" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7062,6 +7560,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "dev": true, + "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", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7190,6 +7708,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7345,7 +7870,17 @@ "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, "node_modules/js-tokens": { @@ -7396,6 +7931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7852,6 +8394,20 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -7865,6 +8421,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8057,6 +8626,37 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -11383,6 +11983,19 @@ "node": ">= 0.4" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11678,6 +12291,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -11735,6 +12358,17 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11799,6 +12433,16 @@ "node": ">=4" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-conf": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", @@ -11930,6 +12574,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -11963,13 +12625,14 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -12010,6 +12673,53 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -12337,6 +13047,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12983,6 +13710,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -12994,6 +13775,33 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13018,15 +13826,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -13038,14 +13846,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -13381,6 +14189,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -14011,6 +14829,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -14201,6 +15029,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -14328,6 +15216,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -14412,6 +15310,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -15407,6 +16315,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index c361f69..efe3737 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "private": true, "activationEvents": [ "onDebug", + "onStartupFinished", "onCommand:extension.minecraft-js.getPort" ], "main": "./dist/extension.js", @@ -69,6 +70,14 @@ "command": "minecraft-debugger.runMinecraftCommand", "enablement": "inDebugMode", "title": "Minecraft Run Command" + }, + { + "command": "minecraft-debugger.copyMcpConfig", + "title": "Minecraft Debugger: Copy MCP Server Config" + }, + { + "command": "minecraft-debugger.regenerateMcpToken", + "title": "Minecraft Debugger: Regenerate MCP Token" } ], "keybindings": [ @@ -197,6 +206,18 @@ "type": "boolean", "default": false, "description": "Open the diagnostics view on connection to Minecraft." + }, + "minecraft-debugger.mcp.enabled": { + "type": "boolean", + "default": false, + "description": "Host a local Model Context Protocol (MCP) server that lets MCP clients inspect and control the Minecraft debug session. Enable it per workspace: every VS Code window with it enabled tries to bind the same port." + }, + "minecraft-debugger.mcp.port": { + "type": "number", + "default": 19180, + "minimum": 1024, + "maximum": 65535, + "description": "Port for the local MCP server. Must differ from the Minecraft debug port (19144)." } } } @@ -221,6 +242,7 @@ "package:all": "npm run build:webview && npm run check-types && node esbuild.mjs --production" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.32.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/node": "^24.12.4", @@ -241,6 +263,7 @@ "stream-parser": "^0.3.1", "ts-loader": "^9.5.1", "typescript": "^5.5.4", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "zod": "^4.6.5" } } diff --git a/src/extension.ts b/src/extension.ts index 21c43c5..5db4bbc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; +import * as crypto from 'crypto'; import { EventEmitter } from 'stream'; @@ -11,6 +12,11 @@ import { MinecraftDiagnosticsPanel } from './panels/minecraft-diagnostics'; import { ServerDebugAdapterFactory } from './server-debug-adapter-factory'; import { StatsProvider } from './stats/stats-provider'; import { ReplayStatsProvider } from './stats/replay-stats-provider'; +import { ActivityStore } from './mcp/activity-store'; +import { MinecraftDebuggerBridge } from './mcp/debugger-bridge'; +import { McpHttpServer, startMcpServer } from './mcp/mcp-server'; + +const MCP_TOKEN_KEY = 'minecraft-debugger.mcp.token'; // called when extension is activated // @@ -33,6 +39,73 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(descriptorFactory); } + // MCP server + const activityStore = new ActivityStore(); + const debuggerBridge = new MinecraftDebuggerBridge(eventEmitter, liveStatsProvider); + homeViewProvider.setMcpActivityStore(activityStore); + context.subscriptions.push(debuggerBridge); + + let mcpServer: McpHttpServer | undefined; + let mcpToken = ''; + let mcpRunId = 0; + + const getMcpSettings = () => { + const config = vscode.workspace.getConfiguration('minecraft-debugger.mcp'); + return { enabled: config.get('enabled', false), port: config.get('port', 19180) }; + }; + + const getOrCreateMcpToken = async (): Promise => { + let token = await context.secrets.get(MCP_TOKEN_KEY); + if (!token) { + token = crypto.randomBytes(32).toString('hex'); + await context.secrets.store(MCP_TOKEN_KEY, token); + } + return token; + }; + + const startOrStopMcp = async (): Promise => { + const runId = ++mcpRunId; + const previous = mcpServer; + mcpServer = undefined; + await previous?.close().catch(() => undefined); + const { enabled, port } = getMcpSettings(); + if (!enabled) { + activityStore.onServerState({ state: 'off', clientActive: false }); + return; + } + try { + mcpToken = await getOrCreateMcpToken(); + const server = await startMcpServer({ port, token: mcpToken, bridge: debuggerBridge, sink: activityStore }); + if (runId !== mcpRunId) { + // a newer start/stop request superseded this one while it was starting + await server.close(); + return; + } + mcpServer = server; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + activityStore.onServerState({ state: 'error', clientActive: false, message }); + vscode.window.showWarningMessage( + `MCP server could not start on port ${port}: ${message}. Change 'minecraft-debugger.mcp.port'.` + ); + } + }; + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('minecraft-debugger.mcp')) { + startOrStopMcp(); + } + }), + { + dispose: () => { + mcpRunId++; + mcpServer?.close(); + }, + } + ); + startOrStopMcp(); + // // Command Registrations // @@ -102,13 +175,43 @@ export function activate(context: vscode.ExtensionContext): void { }, ); + const copyMcpConfigCommand = vscode.commands.registerCommand('minecraft-debugger.copyMcpConfig', async () => { + const { enabled, port } = getMcpSettings(); + const token = await getOrCreateMcpToken(); + await vscode.env.clipboard.writeText( + JSON.stringify( + { type: 'http', url: `http://127.0.0.1:${port}/mcp`, headers: { Authorization: `Bearer ${token}` } }, // eslint-disable-line @typescript-eslint/naming-convention + null, + 2 + ) + ); + if (enabled) { + vscode.window.showInformationMessage('MCP server config copied to clipboard.'); + } else { + vscode.window.showWarningMessage( + "MCP server config copied to clipboard, but the server is disabled. Enable 'minecraft-debugger.mcp.enabled'." + ); + } + }); + + const regenerateMcpTokenCommand = vscode.commands.registerCommand( + 'minecraft-debugger.regenerateMcpToken', + async () => { + await context.secrets.store(MCP_TOKEN_KEY, crypto.randomBytes(32).toString('hex')); + await startOrStopMcp(); + vscode.window.showInformationMessage('MCP token regenerated. Copy the MCP server config again.'); + } + ); + // Add commands to the extension context context.subscriptions.push( getPortCommand, minecraftReloadCommand, runMinecraftCommand, liveDiagnosticsCommand, - replayDiagnosticsCommand + replayDiagnosticsCommand, + copyMcpConfigCommand, + regenerateMcpTokenCommand ); // diff --git a/src/mcp/activity-store.test.ts b/src/mcp/activity-store.test.ts new file mode 100644 index 0000000..5068a94 --- /dev/null +++ b/src/mcp/activity-store.test.ts @@ -0,0 +1,55 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { describe, expect, it, vi } from 'vitest'; +import { ActivityStore } from './activity-store'; +import { ActivityEntry } from './types'; + +const MakeEntry = (id: number, status: ActivityEntry['status'] = 'running'): ActivityEntry => ({ + id, + time: 0, + tool: 'get_status', + args: '{}', + status, +}); + +describe('ActivityStore', () => { + it('appends entries', () => { + const store = new ActivityStore(); + store.onActivity(MakeEntry(1)); + store.onActivity(MakeEntry(2)); + expect(store.snapshot().entries.map(e => e.id)).toEqual([1, 2]); + }); + + it('replaces an entry with the same id in place', () => { + const store = new ActivityStore(); + store.onActivity(MakeEntry(1)); + store.onActivity(MakeEntry(2)); + store.onActivity(MakeEntry(1, 'ok')); + const { entries } = store.snapshot(); + expect(entries.map(e => e.id)).toEqual([1, 2]); + expect(entries[0].status).toBe('ok'); + }); + + it('keeps only the newest entries up to capacity', () => { + const store = new ActivityStore(2); + [1, 2, 3].forEach(id => store.onActivity(MakeEntry(id))); + expect(store.snapshot().entries.map(e => e.id)).toEqual([2, 3]); + }); + + it('updates server state in the snapshot', () => { + const store = new ActivityStore(); + expect(store.snapshot().state).toEqual({ state: 'off', clientActive: false }); + store.onServerState({ state: 'listening', port: 1, clientActive: true }); + expect(store.snapshot().state).toEqual({ state: 'listening', port: 1, clientActive: true }); + }); + + it('notifies listeners', () => { + const store = new ActivityStore(); + const listener = vi.fn(); + store.onChange(listener); + store.onActivity(MakeEntry(1)); + store.onServerState({ state: 'off', clientActive: false }); + expect(listener).toHaveBeenNthCalledWith(1, { type: 'mcp-activity', entry: MakeEntry(1) }); + expect(listener).toHaveBeenNthCalledWith(2, { type: 'mcp-state', state: { state: 'off', clientActive: false } }); + }); +}); diff --git a/src/mcp/activity-store.ts b/src/mcp/activity-store.ts new file mode 100644 index 0000000..8edbae5 --- /dev/null +++ b/src/mcp/activity-store.ts @@ -0,0 +1,42 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { ActivityEntry, ActivitySink, McpServerState, McpWebviewMessage } from './types'; + +// Extension-side buffer of MCP tool activity so the home panel can replay it when (re)opened. +export class ActivityStore implements ActivitySink { + private _entries: ActivityEntry[] = []; + private _state: McpServerState = { state: 'off', clientActive: false }; + private _listeners: ((msg: McpWebviewMessage) => void)[] = []; + + constructor(private readonly _capacity = 200) {} + + public onActivity(entry: ActivityEntry): void { + const index = this._entries.findIndex(e => e.id === entry.id); + if (index >= 0) { + this._entries[index] = entry; + } else { + this._entries.push(entry); + if (this._entries.length > this._capacity) { + this._entries.splice(0, this._entries.length - this._capacity); + } + } + this._notify({ type: 'mcp-activity', entry }); + } + + public onServerState(state: McpServerState): void { + this._state = state; + this._notify({ type: 'mcp-state', state }); + } + + public snapshot(): { entries: ActivityEntry[]; state: McpServerState } { + return { entries: [...this._entries], state: this._state }; + } + + public onChange(listener: (msg: McpWebviewMessage) => void): void { + this._listeners.push(listener); + } + + private _notify(msg: McpWebviewMessage): void { + this._listeners.forEach(l => l(msg)); + } +} diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts new file mode 100644 index 0000000..9ed9d27 --- /dev/null +++ b/src/mcp/debugger-bridge.ts @@ -0,0 +1,415 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EventEmitter } from 'stream'; + +import { MinecraftCapabilities } from '../session'; +import { StatsListener, StatsProvider } from '../stats/stats-provider'; +import { EventBuffer } from './event-buffer'; +import { + BreakpointResult, + DebugEvent, + DebuggerBridge, + DebuggerStatus, + DiagnosticsTab, + LogLevel, + StatSnapshot, + StepKind, +} from './types'; + +const DAP_TIMEOUT_MS = 10000; +const STEP_COMMANDS: Record = { + over: 'workbench.action.debug.stepOver', + in: 'workbench.action.debug.stepInto', + out: 'workbench.action.debug.stepOut', +}; + +async function waitUntil(condition: () => boolean, timeoutMs: number): Promise { + for (let waited = 0; !condition() && waited < timeoutMs; waited += 100) { + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +interface StoredStat { + name: string; + group: string; + value: number | string | undefined; + rows: string[][]; + tick: number; + receivedAt: number; +} + +// Implements the MCP tool surface on top of the active 'minecraft-js' debug session. +export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposable { + private readonly _events = new EventBuffer(1000); + private readonly _disposables: vscode.Disposable[] = []; + private readonly _stats = new Map(); + private readonly _statListener: StatsListener; + private readonly _onDebuggerStatus: (isConnected: boolean, caps: MinecraftCapabilities) => void; + private _session?: vscode.DebugSession; + private _paused = false; + private _stoppedThreadId: number | undefined; + private _connected = false; + private _supportsCommands = false; + private _ownBreakpoints: vscode.Breakpoint[] = []; + private _tabs: DiagnosticsTab[] | undefined; + private readonly _onDiagnosticsTabs: (tabs?: DiagnosticsTab[]) => void; + + constructor( + private readonly _eventEmitter: EventEmitter, + private readonly _statsProvider: StatsProvider, + ) { + const active = vscode.debug.activeDebugSession; + if (active?.type === 'minecraft-js') { + this._session = active; + } + + this._disposables.push( + vscode.debug.onDidStartDebugSession(session => { + if (session.type !== 'minecraft-js') { + return; + } + this._session = session; + this._paused = false; + this._events.push({ kind: 'session', text: 'Debug session started' }); + }), + vscode.debug.onDidTerminateDebugSession(session => { + if (session.type !== 'minecraft-js') { + return; + } + if (this._session?.id === session.id) { + this._session = undefined; + } + this._paused = false; + this._connected = false; + this._events.push({ kind: 'session', text: 'Debug session ended' }); + }), + vscode.debug.registerDebugAdapterTrackerFactory('minecraft-js', { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createDebugAdapterTracker: () => ({ onDidSendMessage: (m: any) => this._onDapMessage(m) }), + }), + ); + + this._onDebuggerStatus = (isConnected, caps) => { + this._connected = isConnected; + this._supportsCommands = !!caps?.supportsCommands; + }; + this._eventEmitter.on('debugger-status', this._onDebuggerStatus); + this._eventEmitter.emit('request-debugger-status'); + + this._statListener = { + onStatUpdated: stat => { + this._stats.set(stat.full_id, { + name: stat.name, + group: stat.parent_name, + value: stat.values[stat.values.length - 1], + rows: stat.children_string_values, + tick: stat.tick, + receivedAt: Date.now(), + }); + }, + }; + this._statsProvider.addStatListener(this._statListener); + + // reported by the Live Diagnostics panel while it is open + this._onDiagnosticsTabs = tabs => { + this._tabs = tabs; + }; + this._eventEmitter.on('diagnostics-tabs', this._onDiagnosticsTabs); + } + + public dispose(): void { + this._eventEmitter.off('debugger-status', this._onDebuggerStatus); + this._eventEmitter.off('diagnostics-tabs', this._onDiagnosticsTabs); + this._statsProvider.removeStatListener(this._statListener); + this._disposables.forEach(d => d.dispose()); + this._disposables.length = 0; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _onDapMessage(m: any): void { + if (m.type === 'event' && m.event === 'output') { + const category = m.body?.category; + const level: LogLevel = category === 'stderr' ? 'error' : category === 'console' ? 'warn' : 'info'; + this._events.push({ kind: 'log', level, text: String(m.body?.output ?? '').trimEnd() }); + } else if (m.type === 'event' && m.event === 'stopped') { + this._paused = true; + this._stoppedThreadId = m.body?.threadId; + const reason = m.body?.reason ?? 'stopped'; + const description = m.body?.description; + this._events.push({ kind: 'stopped', text: reason + (description ? ': ' + description : '') }); + } else if ( + (m.type === 'event' && m.event === 'continued') || + (m.type === 'response' && m.success && ['continue', 'next', 'stepIn', 'stepOut'].includes(m.command)) + ) { + this._paused = false; + this._events.push({ kind: 'continued', text: 'Execution resumed' }); + } + } + + private _requireSession(): vscode.DebugSession { + if (!this._session) { + throw new Error('No active Minecraft Debugger session'); + } + return this._session; + } + + // workbench debug commands act on the focused session, so refuse when that is not ours + private _requireFocusedSession(): void { + const session = this._requireSession(); + if (vscode.debug.activeDebugSession?.id !== session.id) { + throw new Error('Minecraft Debugger session is not the active debug session'); + } + } + + private _requirePaused(): vscode.DebugSession { + const session = this._requireSession(); + if (!this._paused) { + throw new Error('Debugger is not paused'); + } + return session; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async _dap(cmd: string, args?: any): Promise { + const session = this._requireSession(); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Debugger request '${cmd}' timed out`)), DAP_TIMEOUT_MS); + }); + try { + return await Promise.race([session.customRequest(cmd, args), timeout]); + } finally { + clearTimeout(timer); + } + } + + public getStatus(): DebuggerStatus { + return { + sessionActive: !!this._session, + connected: this._connected, + supportsCommands: this._supportsCommands, + paused: this._paused, + }; + } + + public getEvents(sinceSeq: number, limit: number, level?: LogLevel): { events: DebugEvent[]; nextSeq: number } { + return this._events.since(sinceSeq, limit, level); + } + + public waitForEvent( + match: (event: DebugEvent) => boolean, + timeoutMs: number, + sinceSeq?: number, + ): Promise { + return this._events.waitFor(match, timeoutMs, sinceSeq); + } + + public getStats(filter?: string): StatSnapshot[] { + const needle = filter?.toLowerCase(); + const now = Date.now(); + const result: StatSnapshot[] = []; + for (const [id, s] of this._stats) { + if (needle && ![id, s.name, s.group].some(text => text.toLowerCase().includes(needle))) { + continue; + } + result.push({ + id, + name: s.name, + group: s.group, + value: s.value, + ...(s.rows.length > 0 && { rows: s.rows }), + tick: s.tick, + ageMs: now - s.receivedAt, + }); + } + return result; + } + + public async getStack(threadId?: number): Promise { + this._requirePaused(); + const id = threadId ?? this._stoppedThreadId ?? (await this._dap('threads')).threads?.[0]?.id; + if (id === undefined) { + throw new Error('No threads available'); + } + const response = await this._dap('stackTrace', { threadId: id, startFrame: 0, levels: 50 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (response.stackFrames ?? []).map((f: any) => ({ + id: f.id, + name: f.name, + source: f.source?.path, + line: f.line, + column: f.column, + })); + } + + public async getVariables(frameId: number): Promise { + this._requirePaused(); + const { scopes } = await this._dap('scopes', { frameId }); + const result = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const scope of (scopes ?? []) as any[]) { + const { variables } = await this._dap('variables', { variablesReference: scope.variablesReference }); + result.push({ + scope: scope.name, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + variables: (variables ?? []).map((v: any) => ({ + name: v.name, + value: v.value, + type: v.type, + variablesReference: v.variablesReference, + })), + }); + } + return result; + } + + public async evaluate(expression: string, frameId?: number): Promise { + const { result, type } = await this._dap('evaluate', { expression, frameId, context: 'repl' }); + return { result, type }; + } + + // Evaluates in the running game. Minecraft sometimes answers an evaluate with an empty result without + // running it, so expressions return 'R:'-prefixed strings and are resent until one comes back. + private async _evaluateMarked(expression: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const { result } = await this._dap('evaluate', { expression, context: 'repl' }); + if (typeof result === 'string' && result.startsWith('R:')) { + return result.slice(2); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Minecraft did not evaluate the request'); + } + + // Loads @minecraft/server once through a dynamic import (it resolves on the next game tick), + // then queries entities without breakpoints or profilers. + public async getEntities(dimension: string, type?: string, limit = 200): Promise { + if (!this._connected) { + throw new Error('Minecraft is not connected'); + } + if (this._paused) { + throw new Error('Resume the game first: the scripting API loads on a game tick'); + } + const load = + "globalThis.__mcpServerModule ? 'R:ready' : (import('@minecraft/server').then(m => { globalThis.__mcpServerModule = m; }), 'R:loading')"; + let state = await this._evaluateMarked(load); + for (let waited = 0; state !== 'ready' && waited < 3000; waited += 100) { + await new Promise(resolve => setTimeout(resolve, 100)); + state = await this._evaluateMarked(load); + } + if (state !== 'ready') { + throw new Error('Could not load @minecraft/server in the game'); + } + const query = `(() => { + const all = globalThis.__mcpServerModule.world.getDimension(${JSON.stringify(dimension)}) + .getEntities(${JSON.stringify(type ? { type } : {})}); + const entities = all.slice(0, ${limit}).map(e => ({ + id: e.id, type: e.typeId, name: e.nameTag || undefined, location: e.location, dimension: e.dimension.id, + })); + return 'R:' + JSON.stringify({ total: all.length, entities }); + })()`; + return JSON.parse(await this._evaluateMarked(query)); + } + + // Same channel the Live Diagnostics tabs use for their buttons (profilers, filters, CSV exports). + public debuggerRequest(request: string, args?: Record): Promise { + return this._dap('debugger-request', { request, args }); + } + + // Opens or reveals the Live Diagnostics panel; the tab list comes from it. + public async getDiagnosticsTabs(): Promise { + await vscode.commands.executeCommand('minecraft-debugger.liveDiagnostics'); + await waitUntil(() => this._tabs !== undefined, 5000); + if (!this._tabs) { + throw new Error('The Live Diagnostics panel did not report its tabs'); + } + return this._tabs; + } + + // Toggles a tab exactly like its switch in the panel, which enables or disables its collectors. + public async setDiagnosticsTab(tab: string, active: boolean): Promise { + const tabs = await this.getDiagnosticsTabs(); + if (!tabs.some(t => t.name === tab)) { + throw new Error(`Unknown tab '${tab}'. Tabs: ${tabs.map(t => t.name).join(', ')}`); + } + this._eventEmitter.emit('set-diagnostics-tab', tab, active); + await waitUntil(() => this._tabs?.find(t => t.name === tab)?.active === active, 3000); + return this._tabs?.find(t => t.name === tab) ?? { name: tab, active: !active }; + } + + public runCommand(command: string): void { + this._requireSession(); + if (!this._connected || !this._supportsCommands) { + throw new Error('Minecraft is not connected or does not support commands'); + } + this._eventEmitter.emit('run-minecraft-command', command); + } + + public async continue(): Promise { + this._requireFocusedSession(); + await vscode.commands.executeCommand('workbench.action.debug.continue'); + } + + // workbench.action.debug.pause is a no-op when invoked without a thread context, so ask the adapter directly. + // Only the first thread, like the toolbar: a pause sent to every thread stays armed and stops them later. + public async pause(): Promise { + const threadId = (await this._dap('threads')).threads?.[0]?.id; + if (threadId === undefined) { + // the adapter learns thread ids only from the game's thread/stopped events + throw new Error('Minecraft has not reported a script thread yet; hit a breakpoint once, then pause works'); + } + await this._dap('pause', { threadId }); + } + + public async step(kind: StepKind): Promise { + this._requireFocusedSession(); + await vscode.commands.executeCommand(STEP_COMMANDS[kind]); + } + + public async setBreakpoint(file: string, line: number, condition?: string): Promise { + // relative paths resolve against the session's localRoot (where the sources live), else the workspace root + const base: string = + this._session?.configuration.localRoot ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + const abs = path.resolve(base, file); + if (!fs.existsSync(abs)) { + // refuse instead of leaving a phantom breakpoint that errors on every debug start + throw new Error(`File not found: ${abs}`); + } + const bp = new vscode.SourceBreakpoint( + new vscode.Location(vscode.Uri.file(abs), new vscode.Position(line - 1, 0)), + true, + condition, + ); + vscode.debug.addBreakpoints([bp]); + this._ownBreakpoints.push(bp); + + const session = this._session; + if (!session) { + return { file: abs, line, verified: false, message: 'No active session; sent when debugging starts' }; + } + // VS Code sends setBreakpoints asynchronously; poll briefly for the adapter's answer + for (let i = 0; i < 20; i++) { + const dapBp = await session.getDebugProtocolBreakpoint(bp); + if (dapBp) { + const { + verified, + message, + line: boundLine, + } = dapBp as { verified: boolean; message?: string; line?: number }; + return { file: abs, line: boundLine ?? line, verified, message }; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return { file: abs, line, verified: false, message: 'No response from the debug adapter' }; + } + + public clearBreakpoints(all: boolean): number { + const toRemove = all ? [...vscode.debug.breakpoints] : this._ownBreakpoints; + vscode.debug.removeBreakpoints(toRemove); + this._ownBreakpoints = []; + return toRemove.length; + } +} diff --git a/src/mcp/event-buffer.test.ts b/src/mcp/event-buffer.test.ts new file mode 100644 index 0000000..c079fe7 --- /dev/null +++ b/src/mcp/event-buffer.test.ts @@ -0,0 +1,96 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EventBuffer } from './event-buffer'; + +const Log = (text: string, level?: 'info' | 'warn' | 'error') => ({ kind: 'log' as const, text, level }); + +describe('EventBuffer', () => { + afterEach(() => vi.useRealTimers()); + + it('assigns increasing seq starting at 1', () => { + const b = new EventBuffer(); + expect(b.push(Log('a')).seq).toBe(1); + expect(b.push(Log('b')).seq).toBe(2); + expect(b.head()).toBe(2); + }); + + it('keeps only the last capacity entries', () => { + const b = new EventBuffer(3); + for (let i = 0; i < 5; i++) { + b.push(Log(String(i))); + } + expect(b.since(0, 10).events.map(e => e.text)).toEqual(['2', '3', '4']); + }); + + it('since returns newer events and nextSeq', () => { + const b = new EventBuffer(); + for (let i = 0; i < 4; i++) { + b.push(Log(String(i))); + } + const r = b.since(2, 10); + expect(r.events.map(e => e.seq)).toEqual([3, 4]); + expect(r.nextSeq).toBe(4); + expect(b.since(4, 10)).toEqual({ events: [], nextSeq: 4 }); + }); + + it('since filters by level and honours limit', () => { + const b = new EventBuffer(); + b.push(Log('a', 'info')); + b.push(Log('b', 'error')); + b.push(Log('c', 'error')); + const r = b.since(0, 1, 'error'); + expect(r.events.map(e => e.text)).toEqual(['b']); + expect(r.nextSeq).toBe(2); + // not truncated by limit: the cursor moves to the head, past non-matching events + expect(b.since(0, 10, 'info')).toEqual({ events: [expect.objectContaining({ text: 'a' })], nextSeq: 3 }); + }); + + it('clear keeps seq monotonic', () => { + const b = new EventBuffer(); + b.push(Log('a')); + b.clear(); + expect(b.since(0, 10).events).toEqual([]); + expect(b.push(Log('b')).seq).toBe(2); + }); + + it('waitFor resolves with an event pushed after the call, ignoring earlier ones', async () => { + const b = new EventBuffer(); + b.push(Log('hello')); + const p = b.waitFor(e => e.text === 'hello', 1000); + b.push(Log('nope')); + const e = b.push(Log('hello')); + expect(await p).toEqual(e); + }); + + it('waitFor resolves undefined on timeout', async () => { + vi.useFakeTimers(); + const b = new EventBuffer(); + const p = b.waitFor(() => true, 500); + vi.advanceTimersByTime(500); + expect(await p).toBeUndefined(); + }); + + it('waitFor with sinceSeq returns an already-buffered match newer than sinceSeq', async () => { + const b = new EventBuffer(); + b.push(Log('old')); + const head = b.head(); + const e = b.push(Log('hit')); + expect(await b.waitFor(x => x.text === 'hit', 1000, head)).toEqual(e); + }); + + it('waitFor with sinceSeq ignores matches at or before sinceSeq', () => { + vi.useFakeTimers(); + const b = new EventBuffer(); + b.push(Log('hit')); + const p = b.waitFor(x => x.text === 'hit', 500, b.head()); + vi.advanceTimersByTime(500); + return expect(p).resolves.toBeUndefined(); + }); + it('treats a cursor from before a reset as a read from the start', () => { + const b = new EventBuffer(); + b.push({ kind: 'log', text: 'a' }); + expect(b.since(54, 10).events.map(e => e.text)).toEqual(['a']); + return expect(b.waitFor(e => e.text === 'a', 10, 54)).resolves.toMatchObject({ text: 'a' }); + }); +}); diff --git a/src/mcp/event-buffer.ts b/src/mcp/event-buffer.ts new file mode 100644 index 0000000..23bb166 --- /dev/null +++ b/src/mcp/event-buffer.ts @@ -0,0 +1,69 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { DebugEvent, LogLevel } from './types'; + +export class EventBuffer { + private _events: DebugEvent[] = []; + private _seq = 0; + private _waiters = new Set<(e: DebugEvent) => void>(); + + constructor(private readonly _capacity = 1000) {} + + push(e: Omit): DebugEvent { + const event: DebugEvent = { ...e, seq: ++this._seq, time: Date.now() }; + this._events.push(event); + if (this._events.length > this._capacity) { + this._events.splice(0, this._events.length - this._capacity); + } + for (const w of [...this._waiters]) { + w(event); + } + return event; + } + + since(sinceSeq: number, limit: number, level?: LogLevel): { events: DebugEvent[]; nextSeq: number } { + sinceSeq = this._cursor(sinceSeq); + const events = this._events.filter(e => e.seq > sinceSeq && (!level || e.level === level)).slice(0, limit); + // truncated by limit: resume after the last returned event; otherwise everything was scanned + return { events, nextSeq: events.length === limit ? events[events.length - 1].seq : this._seq }; + } + + // A cursor ahead of the newest seq comes from before a reset (window reload): read from the start. + private _cursor(sinceSeq: number): number { + return sinceSeq > this._seq ? 0 : sinceSeq; + } + + head(): number { + return this._seq; + } + + // With sinceSeq, an already-buffered match newer than sinceSeq resolves immediately. + waitFor(match: (e: DebugEvent) => boolean, timeoutMs: number, sinceSeq?: number): Promise { + if (sinceSeq !== undefined) { + const cursor = this._cursor(sinceSeq); + const buffered = this._events.find(e => e.seq > cursor && match(e)); + if (buffered) { + return Promise.resolve(buffered); + } + } + return new Promise(resolve => { + const done = (e?: DebugEvent) => { + clearTimeout(timer); + this._waiters.delete(listener); + resolve(e); + }; + const listener = (e: DebugEvent) => { + if (match(e)) { + done(e); + } + }; + const timer = setTimeout(() => done(undefined), timeoutMs); + this._waiters.add(listener); + }); + } + + // Keeps seq monotonic. + clear(): void { + this._events = []; + } +} diff --git a/src/mcp/mcp-server.test.ts b/src/mcp/mcp-server.test.ts new file mode 100644 index 0000000..ace8bfe --- /dev/null +++ b/src/mcp/mcp-server.test.ts @@ -0,0 +1,201 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import * as http from 'http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpHttpServer, startMcpServer } from './mcp-server'; +import { ActivityEntry, DebuggerBridge, McpServerState } from './types'; + +const TOKEN = 'a'.repeat(64); + +function makeBridge(overrides: Partial = {}): DebuggerBridge { + return { + getStatus: () => ({ sessionActive: true, connected: true, supportsCommands: true, paused: false }), + getEvents: () => ({ events: [], nextSeq: 0 }), + getStats: () => [], + waitForEvent: async () => undefined, + getStack: async () => [], + getVariables: async () => [], + evaluate: async () => ({}), + runCommand: vi.fn(), + continue: async () => undefined, + pause: async () => undefined, + step: async () => undefined, + setBreakpoint: async (file: string, line: number) => ({ file, line, verified: true }), + clearBreakpoints: () => 0, + getDiagnosticsTabs: async () => [{ name: 'World', active: false }], + setDiagnosticsTab: async (name: string, active: boolean) => ({ name, active }), + debuggerRequest: async () => ({}), + getEntities: async () => ({ total: 0, entities: [] }), + ...overrides, + }; +} + +describe('mcp server', () => { + let server: McpHttpServer; + let activity: ActivityEntry[]; + let states: McpServerState[]; + let bridge: DebuggerBridge; + + const start = async (b: DebuggerBridge) => { + bridge = b; + server = await startMcpServer({ + port: 0, + token: TOKEN, + bridge, + sink: { onActivity: e => activity.push(e), onServerState: s => states.push(s) }, + }); + }; + + const post = (body: unknown, headers: Record = {}) => + fetch(`http://127.0.0.1:${server.port}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: 'Bearer ' + TOKEN, + ...headers, + }, + body: JSON.stringify(body), + }); + + const call = async (name: string, args: Record = {}) => { + const res = await post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + expect(res.status).toBe(200); + return ((await res.json()) as any).result as { isError?: boolean; content: { text: string }[] }; + }; + + beforeEach(() => { + activity = []; + states = []; + }); + afterEach(async () => { + await server.close(); + }); + + describe('auth and routing', () => { + beforeEach(() => start(makeBridge())); + const rpc = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; + + it('rejects missing, short and wrong same-length tokens with 401', async () => { + const noAuth = await fetch(`http://127.0.0.1:${server.port}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(rpc), + }); + expect(noAuth.status).toBe(401); + expect((await post(rpc, { authorization: 'Bearer x' })).status).toBe(401); + expect((await post(rpc, { authorization: 'Bearer ' })).status).toBe(401); + expect((await post(rpc, { authorization: 'garbage' })).status).toBe(401); + expect((await post(rpc, { authorization: 'Bearer ' + 'b'.repeat(64) })).status).toBe(401); + }); + + it('rejects a foreign Origin with 403', async () => { + expect((await post(rpc, { origin: 'https://evil.example' })).status).toBe(403); + }); + + it('rejects a foreign Host with 403', async () => { + const status = await new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port: server.port, + path: '/mcp', + method: 'POST', + headers: { + host: `evil.example:${server.port}`, + authorization: 'Bearer ' + TOKEN, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + }, + res => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on('error', reject); + req.end(JSON.stringify(rpc)); + }); + expect(status).toBe(403); + }); + + it('returns 404 for other paths', async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/other`); + expect(res.status).toBe(404); + }); + + it('answers initialize', async () => { + const res = await post({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '1' } }, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).result.serverInfo.name).toBe('minecraft-debugger'); + }); + + it('reports client activity and off state on close', async () => { + await post(rpc); + expect(states.some(s => s.state === 'listening' && s.clientActive)).toBe(true); + await server.close(); + expect(states[states.length - 1]).toEqual({ state: 'off', clientActive: false }); + }); + }); + + describe('tools', () => { + it('get_status succeeds and reports activity', async () => { + await start(makeBridge()); + const r = await call('get_status'); + expect(r.content[0].text).toContain('"connected"'); + expect(activity.map(a => a.status)).toEqual(['running', 'ok']); + }); + + it('run_command strips the leading slash', async () => { + const runCommand = vi.fn(); + await start(makeBridge({ runCommand })); + const r = await call('run_command', { command: '/say hi' }); + expect(runCommand).toHaveBeenCalledWith('say hi'); + expect(r.isError).toBeFalsy(); + }); + + it('returns an error result when the bridge throws, get_status still works', async () => { + const err = () => { + throw new Error('No active Minecraft Debugger session'); + }; + await start(makeBridge({ runCommand: err })); + const r = await call('run_command', { command: 'say hi' }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain('No active Minecraft Debugger session'); + expect((await call('get_status')).isError).toBeFalsy(); + }); + + it('set_diagnostics_tab forwards the tab and returns its state', async () => { + const setDiagnosticsTab = vi.fn(async (name: string, active: boolean) => ({ name, active })); + await start(makeBridge({ setDiagnosticsTab })); + const r = await call('set_diagnostics_tab', { tab: 'World', active: true }); + expect(setDiagnosticsTab).toHaveBeenCalledWith('World', true); + expect(JSON.parse(r.content[0].text)).toEqual({ name: 'World', active: true }); + }); + + it('debugger_request forwards the request and returns the response', async () => { + const debuggerRequest = vi.fn(async () => ({ started: true })); + await start(makeBridge({ debuggerRequest })); + const r = await call('debugger_request', { request: 'Start Entity System Profiler' }); + expect(debuggerRequest).toHaveBeenCalledWith('Start Entity System Profiler', undefined); + expect(JSON.parse(r.content[0].text)).toEqual({ started: true }); + }); + + it('get_entities defaults to the overworld', async () => { + const getEntities = vi.fn(async () => ({ total: 0, entities: [] })); + await start(makeBridge({ getEntities })); + await call('get_entities', { type: 'minecraft:pig' }); + expect(getEntities).toHaveBeenCalledWith('overworld', 'minecraft:pig', undefined); + }); + + it('wait_for_event with an invalid regex is an error result', async () => { + await start(makeBridge()); + expect((await call('wait_for_event', { pattern: '(' })).isError).toBe(true); + }); + }); +}); diff --git a/src/mcp/mcp-server.ts b/src/mcp/mcp-server.ts new file mode 100644 index 0000000..71734d1 --- /dev/null +++ b/src/mcp/mcp-server.ts @@ -0,0 +1,122 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { createHash, timingSafeEqual } from 'crypto'; +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { registerTools } from './mcp-tools'; +import { ActivitySink, DebuggerBridge } from './types'; + +export interface McpHttpServer { + readonly port: number; + close(): Promise; +} + +const CLIENT_IDLE_MS = 60_000; + +const Sha256 = (s: string) => createHash('sha256').update(s).digest(); + +function reply(res: http.ServerResponse, status: number, message: string): void { + res.writeHead(status, { 'content-type': 'text/plain' }).end(message); +} + +function isLoopbackOrigin(origin: string): boolean { + try { + const host = new URL(origin).hostname; + return host === '127.0.0.1' || host === 'localhost'; + } catch { + return false; + } +} + +export function startMcpServer(opts: { + port: number; + token: string; + bridge: DebuggerBridge; + sink: ActivitySink; +}): Promise { + const { token, bridge, sink } = opts; + const expectedDigest = Sha256(token); + let actualPort = opts.port; + let idleTimer: NodeJS.Timeout | undefined; + + const markClientActive = () => { + if (!idleTimer) { + sink.onServerState({ state: 'listening', port: actualPort, clientActive: true }); + } + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = undefined; + sink.onServerState({ state: 'listening', port: actualPort, clientActive: false }); + }, CLIENT_IDLE_MS); + idleTimer.unref(); + }; + + const authorized = (req: http.IncomingMessage): boolean => { + const match = /^bearer\s+(.+)$/i.exec(req.headers.authorization ?? ''); + // Digests have equal length, so timingSafeEqual never throws. + return !!match && timingSafeEqual(Sha256(match[1]), expectedDigest); + }; + + const handle = async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') { + return reply(res, 404, 'Not found'); + } + if (req.headers.host !== `127.0.0.1:${actualPort}` && req.headers.host !== `localhost:${actualPort}`) { + return reply(res, 403, 'Forbidden host'); + } + if (req.headers.origin !== undefined && !isLoopbackOrigin(req.headers.origin)) { + return reply(res, 403, 'Forbidden origin'); + } + if (!authorized(req)) { + return reply(res, 401, 'Unauthorized'); + } + markClientActive(); + + // Stateless: a fresh server + transport per request. + const server = new McpServer({ name: 'minecraft-debugger', version: '1.0.0' }); + registerTools(server, bridge, sink); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res); + }; + + const httpServer = http.createServer((req, res) => { + handle(req, res).catch(() => { + if (!res.headersSent) { + reply(res, 500, 'Internal server error'); + } else { + res.end(); + } + }); + }); + + return new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(opts.port, '127.0.0.1', () => { + httpServer.off('error', reject); + actualPort = (httpServer.address() as AddressInfo).port; + sink.onServerState({ state: 'listening', port: actualPort, clientActive: false }); + resolve({ + port: actualPort, + close: () => + new Promise(done => { + clearTimeout(idleTimer); + httpServer.close(() => { + sink.onServerState({ state: 'off', clientActive: false }); + done(); + }); + httpServer.closeAllConnections(); + }), + }); + }); + }); +} diff --git a/src/mcp/mcp-tools.ts b/src/mcp/mcp-tools.ts new file mode 100644 index 0000000..13fa6ff --- /dev/null +++ b/src/mcp/mcp-tools.ts @@ -0,0 +1,224 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { ActivityEntry, ActivitySink, DebuggerBridge } from './types'; + +let nextId = 0; + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +async function withActivity( + sink: ActivitySink, + tool: string, + args: unknown, + fn: () => Promise | unknown, +): Promise { + const entry: ActivityEntry = { + id: ++nextId, + time: Date.now(), + tool, + args: truncate(JSON.stringify(args ?? {}), 200), + status: 'running', + }; + sink.onActivity({ ...entry }); + const start = Date.now(); + try { + const result = await fn(); + const text = JSON.stringify(result ?? { ok: true }, null, 2); + sink.onActivity({ ...entry, status: 'ok', durationMs: Date.now() - start, summary: truncate(text, 200) }); + return { content: [{ type: 'text', text }] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + sink.onActivity({ ...entry, status: 'error', durationMs: Date.now() - start, summary: message }); + return { content: [{ type: 'text', text: message }], isError: true }; + } +} + +export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: ActivitySink): void { + const run = (tool: string, args: unknown, fn: () => Promise | unknown) => + withActivity(sink, tool, args, fn); + + server.registerTool( + 'get_status', + { description: 'Get the debug session state: sessionActive, connected, supportsCommands, paused.' }, + () => run('get_status', {}, () => bridge.getStatus()), + ); + + server.registerTool( + 'get_logs', + { + description: + 'Get buffered Minecraft/debugger output events. Pass the returned nextSeq as sinceSeq to read only new events.', + inputSchema: { + sinceSeq: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(500).optional(), + level: z.enum(['info', 'warn', 'error']).optional(), + }, + }, + args => run('get_logs', args, () => bridge.getEvents(args.sinceSeq ?? 0, args.limit ?? 100, args.level)), + ); + + server.registerTool( + 'get_stats', + { + description: + 'Get the latest value of live diagnostics stats. Stats arrive for the tabs enabled in the Live Diagnostics panel (see set_diagnostics_tab).', + inputSchema: { filter: z.string().optional() }, + }, + args => run('get_stats', args, () => bridge.getStats(args.filter)), + ); + + server.registerTool( + 'debugger_request', + { + description: + "Send a named request to Minecraft, the same ones the Live Diagnostics tab buttons send, and return its response. Examples: 'Start Entity System Profiler', 'Stop Entity System Profiler', 'Clear Entity System Profiler', 'Request CSV Entity System Profiler', 'Start CPU Profiler', 'Stop CPU Profiler'. Profiler data then arrives as stats (get_stats).", + inputSchema: { request: z.string().min(1), args: z.record(z.string(), z.unknown()).optional() }, + }, + args => run('debugger_request', args, () => bridge.debuggerRequest(args.request, args.args)), + ); + + server.registerTool( + 'get_diagnostics_tabs', + { + description: + 'List the Live Diagnostics panel tabs and whether each is enabled. Opens the panel if it is closed.', + }, + () => run('get_diagnostics_tabs', {}, () => bridge.getDiagnosticsTabs()), + ); + + server.registerTool( + 'set_diagnostics_tab', + { + description: + "Enable or disable a Live Diagnostics tab (name from get_diagnostics_tabs), like its switch in the panel, and show it. Enabling a tab turns on its stat collectors for get_stats. Returns the tab's resulting state.", + inputSchema: { tab: z.string().min(1), active: z.boolean() }, + }, + args => run('set_diagnostics_tab', args, () => bridge.setDiagnosticsTab(args.tab, args.active)), + ); + + server.registerTool( + 'get_stack', + { + description: 'Get the call stack of a paused thread (defaults to the first thread).', + inputSchema: { threadId: z.number().int().optional() }, + }, + args => run('get_stack', args, () => bridge.getStack(args.threadId)), + ); + + server.registerTool( + 'get_variables', + { + description: + 'Get scopes and their variables for a stack frame id (from get_stack). Requires a paused debugger.', + inputSchema: { frameId: z.number().int() }, + }, + args => run('get_variables', args, () => bridge.getVariables(args.frameId)), + ); + + server.registerTool( + 'evaluate', + { + description: + "Evaluate a script expression, in a stack frame (frameId from get_stack) while paused or in the global scope while running. While running, Minecraft sometimes returns an empty result (type 'undefined') without evaluating: resend, and return a marker such as 'R:' + value to tell a real undefined apart.", + inputSchema: { expression: z.string().min(1), frameId: z.number().int().optional() }, + }, + args => run('evaluate', args, () => bridge.evaluate(args.expression, args.frameId)), + ); + + server.registerTool( + 'get_entities', + { + description: + "List entities in a dimension with id, type, name tag and location, while the game is running. dimension defaults to 'overworld'; type filters by entity type (e.g. 'minecraft:pig'); returns at most limit entities (default 200) plus the total.", + inputSchema: { + dimension: z.string().min(1).optional(), + type: z.string().min(1).optional(), + limit: z.number().int().min(1).max(5000).optional(), + }, + }, + args => + run('get_entities', args, () => bridge.getEntities(args.dimension ?? 'overworld', args.type, args.limit)), + ); + + server.registerTool( + 'run_command', + { + description: + "Run a Minecraft command (leading '/' optional). Fire-and-forget: use wait_for_event or get_logs to observe its effects. 'reload' reloads scripts.", + inputSchema: { command: z.string().min(1) }, + }, + args => + run('run_command', args, () => { + bridge.runCommand(args.command.replace(/^\//, '')); + return { sent: true }; + }), + ); + + server.registerTool('continue', { description: 'Resume execution of the paused debugger.' }, () => + run('continue', {}, () => bridge.continue()), + ); + + server.registerTool('pause', { description: 'Pause execution.' }, () => run('pause', {}, () => bridge.pause())); + + server.registerTool( + 'step', + { + description: 'Step over, in, or out while paused.', + inputSchema: { kind: z.enum(['over', 'in', 'out']) }, + }, + args => run('step', args, () => bridge.step(args.kind)), + ); + + server.registerTool( + 'set_breakpoint', + { + description: + "Set a breakpoint. file is absolute or relative to the debug session's localRoot (workspace root when no session); line is 1-based. Returns verified=false with a message when the debugger could not bind it.", + inputSchema: { + file: z.string(), + line: z.number().int().min(1), + condition: z.string().optional(), + }, + }, + args => run('set_breakpoint', args, () => bridge.setBreakpoint(args.file, args.line, args.condition)), + ); + + server.registerTool( + 'clear_breakpoints', + { + description: 'Remove breakpoints created through this server, or all breakpoints when all is true.', + inputSchema: { all: z.boolean().optional() }, + }, + args => run('clear_breakpoints', args, () => ({ removed: bridge.clearBreakpoints(args.all ?? false) })), + ); + + server.registerTool( + 'wait_for_event', + { + description: + 'Wait for the next event (only events after the call, or with sinceSeq also already-buffered events newer than sinceSeq, e.g. nextSeq from get_logs taken before run_command). kind=any (default) matches any event (log, stopped, continued, session), optionally matching a regex pattern. Returns { timedOut: true } on timeout.', + inputSchema: { + kind: z.enum(['log', 'stopped', 'any']).optional(), + pattern: z.string().optional(), + timeoutSeconds: z.number().min(1).max(30).optional(), + sinceSeq: z.number().int().min(0).optional(), + }, + }, + args => + run('wait_for_event', args, async () => { + const re = args.pattern ? new RegExp(args.pattern) : undefined; + const kind = args.kind ?? 'any'; + const event = await bridge.waitForEvent( + e => (kind === 'any' || e.kind === kind) && (!re || re.test(e.text)), + (args.timeoutSeconds ?? 15) * 1000, + args.sinceSeq, + ); + return event ?? { timedOut: true }; + }), + ); +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..2b14beb --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,99 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +// Shared contract between the MCP server/tools (vscode-free) and the debugger bridge. + +export type LogLevel = 'info' | 'warn' | 'error'; + +export type DebugEventKind = 'log' | 'stopped' | 'continued' | 'session'; + +export interface DebugEvent { + seq: number; + time: number; + kind: DebugEventKind; + level?: LogLevel; + text: string; +} + +export interface DebuggerStatus { + sessionActive: boolean; + connected: boolean; + supportsCommands: boolean; + paused: boolean; +} + +export interface StatSnapshot { + id: string; + name: string; + group: string; + value: number | string | undefined; + rows?: string[][]; + tick: number; + ageMs: number; +} + +export interface BreakpointResult { + file: string; + line: number; + verified: boolean; + message?: string; +} + +export type StepKind = 'over' | 'in' | 'out'; + +export interface DiagnosticsTab { + name: string; + active: boolean; +} + +// Implemented by MinecraftDebuggerBridge. Methods that touch the game throw an Error with a +// human-readable message when they cannot run (no session, not connected, not paused, timeout). +export interface DebuggerBridge { + getStatus(): DebuggerStatus; + getEvents(sinceSeq: number, limit: number, level?: LogLevel): { events: DebugEvent[]; nextSeq: number }; + getStats(filter?: string): StatSnapshot[]; + waitForEvent( + match: (event: DebugEvent) => boolean, + timeoutMs: number, + sinceSeq?: number, + ): Promise; + getStack(threadId?: number): Promise; + getVariables(frameId: number): Promise; + evaluate(expression: string, frameId?: number): Promise; + getEntities(dimension: string, type?: string, limit?: number): Promise; + runCommand(command: string): void; + continue(): Promise; + pause(): Promise; + step(kind: StepKind): Promise; + setBreakpoint(file: string, line: number, condition?: string): Promise; + clearBreakpoints(all: boolean): number; + getDiagnosticsTabs(): Promise; + setDiagnosticsTab(tab: string, active: boolean): Promise; + debuggerRequest(request: string, args?: Record): Promise; +} + +export interface ActivityEntry { + id: number; + time: number; + tool: string; + args: string; + status: 'running' | 'ok' | 'error'; + durationMs?: number; + summary?: string; +} + +export interface McpServerState { + state: 'off' | 'listening' | 'error'; + port?: number; + clientActive: boolean; + message?: string; +} + +export interface ActivitySink { + onActivity(entry: ActivityEntry): void; + onServerState(state: McpServerState): void; +} + +export type McpWebviewMessage = + | { type: 'mcp-replay'; entries: ActivityEntry[]; state: McpServerState } + | { type: 'mcp-activity'; entry: ActivityEntry } + | { type: 'mcp-state'; state: McpServerState }; diff --git a/src/panels/home-view-provider.ts b/src/panels/home-view-provider.ts index 137f334..034b7e0 100644 --- a/src/panels/home-view-provider.ts +++ b/src/panels/home-view-provider.ts @@ -7,6 +7,7 @@ import { getNonce } from '../utilities/getNonce'; import { getUri } from '../utilities/getUri'; import { EventEmitter } from 'stream'; import { MinecraftCapabilities } from '../session'; +import { ActivityStore } from '../mcp/activity-store'; export class HomeViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'minecraft-debugger-home-panel'; @@ -14,6 +15,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { private readonly _extensionUri: vscode.Uri; private _eventEmitter: EventEmitter; private _view?: vscode.WebviewView; + private _mcpActivityStore?: ActivityStore; constructor(extensionUri: vscode.Uri, eventEmitter: EventEmitter) { this._extensionUri = extensionUri; @@ -32,6 +34,12 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { supportsCommands: minecraftCapabilities.supportsCommands, supportsProfiler: minecraftCapabilities.supportsProfiler, }); + this._eventEmitter.emit('debugger-status', isConnected, minecraftCapabilities, isListening); + } + + public setMcpActivityStore(store: ActivityStore): void { + this._mcpActivityStore = store; + store.onChange(msg => this._view?.webview.postMessage(msg)); } private _requestDebuggerStatus() { @@ -95,6 +103,20 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { this._requestDebuggerStatus(); break; } + case 'request-mcp-status': { + if (this._mcpActivityStore) { + this._view?.webview.postMessage({ type: 'mcp-replay', ...this._mcpActivityStore.snapshot() }); + } + break; + } + case 'copy-mcp-config': { + vscode.commands.executeCommand('minecraft-debugger.copyMcpConfig'); + break; + } + case 'show-mcp-settings': { + vscode.commands.executeCommand('workbench.action.openSettings', 'minecraft-debugger.mcp'); + break; + } case 'browse-captures-base-path': { await this._browseCapturesBasePath(); break; diff --git a/src/panels/minecraft-diagnostics.ts b/src/panels/minecraft-diagnostics.ts index eafc068..5f7d7ec 100644 --- a/src/panels/minecraft-diagnostics.ts +++ b/src/panels/minecraft-diagnostics.ts @@ -20,6 +20,7 @@ export class MinecraftDiagnosticsPanel { private _statsCallback: StatsListener | undefined = undefined; private _eventEmitter: EventEmitter; private _debuggerRequestHandler: DebuggerRequestHandler; + private _onSetDiagnosticsTab: ((tabName: string, active: boolean) => void) | undefined; private constructor( panel: WebviewPanel, @@ -87,6 +88,9 @@ export class MinecraftDiagnosticsPanel { case 'sync-diagnostics-tabs': this._eventEmitter.emit('sync-diagnostics-tabs', message.states); break; + case 'diagnostics-tabs': + this._eventEmitter.emit('diagnostics-tabs', message.tabs); + break; case 'export-data': void this.handleExportDataMessage(message); break; @@ -142,6 +146,13 @@ export class MinecraftDiagnosticsPanel { }; this._statsTracker.addStatListener(this._statsCallback); + + // lets the MCP server toggle tabs of the live panel + if (!statsTracker.manualControl()) { + this._onSetDiagnosticsTab = (tabName, active) => + this._panel.webview.postMessage({ type: 'set-diagnostics-tab', tabName, active }); + this._eventEmitter.on('set-diagnostics-tab', this._onSetDiagnosticsTab); + } } private getDiagnosticsTabStates(): DiagnosticsTabStates { @@ -251,6 +262,12 @@ export class MinecraftDiagnosticsPanel { this._statsCallback = undefined; } + if (this._onSetDiagnosticsTab !== undefined) { + this._eventEmitter.off('set-diagnostics-tab', this._onSetDiagnosticsTab); + this._onSetDiagnosticsTab = undefined; + this._eventEmitter.emit('diagnostics-tabs', undefined); + } + // Remove the current panel from the active panel list MinecraftDiagnosticsPanel.activeDiagnosticsPanels = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.filter( panel => panel !== this, diff --git a/tsconfig.json b/tsconfig.json index b1f5ce3..8a12c64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "noFallthroughCasesInSwitch": true, "lib": ["es2022"], "moduleResolution": "Bundler", - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true } } diff --git a/webview-ui/src/diagnostics_panel/App.tsx b/webview-ui/src/diagnostics_panel/App.tsx index 2071455..4e8d8c1 100644 --- a/webview-ui/src/diagnostics_panel/App.tsx +++ b/webview-ui/src/diagnostics_panel/App.tsx @@ -166,6 +166,31 @@ function App() { }); }; + // Report the tabs to the extension and let it toggle them (used by the MCP server). + useEffect(() => { + if (!window.initialParams.showReplayControls) { + vscode.postMessage({ + type: 'diagnostics-tabs', + tabs: mergedTabs.map(tab => ({ name: tab.name, active: enabledTabs[tab.name] === true })), + }); + } + }, [mergedTabs, enabledTabs]); + + useEffect(() => { + const handleSetTab = (event: MessageEvent) => { + const message = event.data; + if (message.type !== 'set-diagnostics-tab' || !mergedTabs.some(tab => tab.name === message.tabName)) { + return; + } + setCurrentTab(message.tabName); + if ((enabledTabs[message.tabName] === true) !== message.active) { + setTabActive(message.tabName, message.active); + } + }; + window.addEventListener('message', handleSetTab); + return () => window.removeEventListener('message', handleSetTab); + }); + return (
{window.initialParams.showReplayControls && ( diff --git a/webview-ui/src/home_panel/App.css b/webview-ui/src/home_panel/App.css index 2f5e4c2..df1725c 100644 --- a/webview-ui/src/home_panel/App.css +++ b/webview-ui/src/home_panel/App.css @@ -197,3 +197,38 @@ .capture-item-delete-button:hover { color: var(--vscode-errorForeground); } + +.mcp-status { + margin-bottom: 10px; +} + +.mcp-button-container { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.mcp-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--vscode-editorGroup-border); + width: 90%; + text-align: left; + box-sizing: border-box; + margin-bottom: 10px; +} + +.mcp-entry { + padding: 4px 8px; + border-bottom: 1px solid var(--vscode-editorGroup-border); + overflow-wrap: anywhere; +} + +.mcp-entry-error { + color: var(--vscode-errorForeground); +} + +.mcp-entry-detail { + opacity: 0.7; + font-size: 0.9em; +} diff --git a/webview-ui/src/home_panel/App.tsx b/webview-ui/src/home_panel/App.tsx index efee20d..b0a4058 100644 --- a/webview-ui/src/home_panel/App.tsx +++ b/webview-ui/src/home_panel/App.tsx @@ -6,6 +6,7 @@ import { CommandButton, CommandHandlers, getCommandHandlers } from './handlers/C import GeneralSection from './controls/GeneralSection'; import ProfilerSection from './controls/ProfilerSection'; import { CaptureItem, ProfilerHandlers, getProfilerHandlers } from './handlers/ProfilerHandlers'; +import McpSection, { ActivityEntry, McpServerState } from './controls/McpSection'; import StatusSection from './controls/StatusSection'; import { WebviewApi } from 'vscode-webview'; import './App.css'; @@ -37,12 +38,23 @@ const onCaptureBasePathBrowseButtonPressed = () => { vscode.postMessage({ type: 'browse-captures-base-path' }); }; +const onCopyMcpConfig = () => { + vscode.postMessage({ type: 'copy-mcp-config' }); +}; + +const onShowMcpSettings = () => { + vscode.postMessage({ type: 'show-mcp-settings' }); +}; + const App = () => { const [debuggerConnected, setDebuggerConnected] = useState(false); const [debuggerListening, setDebuggerListening] = useState(false); const [supportsCommands, setSupportsCommands] = useState(false); const [supportsProfiler, setSupportsProfiler] = useState(false); + const [mcpEntries, setMcpEntries] = useState([]); + const [mcpState, setMcpState] = useState({ state: 'off', clientActive: false }); + const { commandButtons, setCommandButtons, onAddCommand, onDeleteCommand, onEditCommand }: CommandHandlers = getCommandHandlers(); @@ -87,6 +99,7 @@ const App = () => { // events from vscode useEffect(() => { vscode.postMessage({ type: 'request-debugger-status' }); + vscode.postMessage({ type: 'request-mcp-status' }); const handleMessage = (event: MessageEvent) => { const message = event.data; @@ -108,6 +121,19 @@ const App = () => { setDebuggerListening(message.isListening); setSupportsCommands(message.supportsCommands); setSupportsProfiler(message.supportsProfiler); + } else if (message.type === 'mcp-replay') { + // store sends entries oldest-first; reverse for newest-first display + setMcpEntries([...(message.entries as ActivityEntry[])].reverse().slice(0, 200)); + setMcpState(message.state); + } else if (message.type === 'mcp-activity') { + const entry = message.entry as ActivityEntry; + setMcpEntries(prev => + prev.some(e => e.id === entry.id) + ? prev.map(e => (e.id === entry.id ? entry : e)) + : [entry, ...prev].slice(0, 200), + ); + } else if (message.type === 'mcp-state') { + setMcpState(message.state); } }; @@ -151,6 +177,12 @@ const App = () => { supportsProfiler={supportsProfiler} debuggerConnected={debuggerConnected} /> +
); }; diff --git a/webview-ui/src/home_panel/controls/McpSection.tsx b/webview-ui/src/home_panel/controls/McpSection.tsx new file mode 100644 index 0000000..7253070 --- /dev/null +++ b/webview-ui/src/home_panel/controls/McpSection.tsx @@ -0,0 +1,75 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import React from 'react'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; + +// Mirrors src/mcp/types.ts (webview is a separate build and cannot import from src/). +export interface ActivityEntry { + id: number; + time: number; + tool: string; + args: string; + status: 'running' | 'ok' | 'error'; + durationMs?: number; + summary?: string; +} + +export interface McpServerState { + state: 'off' | 'listening' | 'error'; + port?: number; + clientActive: boolean; + message?: string; +} + +interface McpSectionProps { + entries: ActivityEntry[]; + serverState: McpServerState; + onCopyConfig: () => void; + onShowSettings: () => void; +} + +const statusText = (s: McpServerState): string => { + if (s.state === 'listening') { + return `Listening on 127.0.0.1:${s.port} (${s.clientActive ? 'client active' : 'no client'})`; + } + return s.state === 'error' ? `Error: ${s.message ?? 'unknown'}` : 'Off'; +}; + +const McpSection: React.FC = ({ entries, serverState, onCopyConfig, onShowSettings }) => { + return ( +
+

MCP Activity

+
+ {statusText(serverState)} +
+
+ Copy Config + Settings +
+ {entries.length === 0 ? ( +
No MCP activity yet.
+ ) : ( +
+ {entries.map(entry => ( +
+
+ {new Date(entry.time).toLocaleTimeString([], { hour12: false })} {entry.tool}{' '} + {entry.status} + {entry.durationMs !== undefined ? ` ${entry.durationMs}ms` : ''} +
+
+ {entry.args} + {entry.status === 'error' && entry.summary ? ` - ${entry.summary}` : ''} +
+
+ ))} +
+ )} +
+ ); +}; + +export default React.memo(McpSection);