From 1e82003ef3c1bc46f0993d9bc7fe18f0de707f31 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:30:36 +0200 Subject: [PATCH 1/6] feat: Host a local MCP server with live activity feed Adds an optional Model Context Protocol server (loopback only, bearer token) that lets MCP clients read logs, stats, stack and variables, evaluate expressions, run commands, control execution and manage breakpoints. Tool calls are shown live in a new MCP Activity section of the home panel. --- README.md | 21 + package-lock.json | 964 +++++++++++++++++- package.json | 25 +- src/extension.ts | 105 +- src/mcp/activity-store.test.ts | 55 + src/mcp/activity-store.ts | 42 + src/mcp/debugger-bridge.ts | 274 +++++ src/mcp/event-buffer.test.ts | 71 ++ src/mcp/event-buffer.ts | 54 + src/mcp/mcp-server.test.ts | 174 ++++ src/mcp/mcp-server.ts | 122 +++ src/mcp/mcp-tools.ts | 176 ++++ src/mcp/types.ts | 77 ++ src/panels/home-view-provider.ts | 22 + tsconfig.json | 3 +- webview-ui/src/home_panel/App.css | 35 + webview-ui/src/home_panel/App.tsx | 32 + .../src/home_panel/controls/McpSection.tsx | 75 ++ 18 files changed, 2306 insertions(+), 21 deletions(-) create mode 100644 src/mcp/activity-store.test.ts create mode 100644 src/mcp/activity-store.ts create mode 100644 src/mcp/debugger-bridge.ts create mode 100644 src/mcp/event-buffer.test.ts create mode 100644 src/mcp/event-buffer.ts create mode 100644 src/mcp/mcp-server.test.ts create mode 100644 src/mcp/mcp-server.ts create mode 100644 src/mcp/mcp-tools.ts create mode 100644 src/mcp/types.ts create mode 100644 webview-ui/src/home_panel/controls/McpSection.tsx diff --git a/README.md b/README.md index 9d5dfafb..eadb7d5d 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,27 @@ 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 (empty until collectors are enabled in the Live Diagnostics panel). +- `get_stack` - call stack of a paused thread. +- `get_variables` - scopes and variables of a stack frame. +- `evaluate` - evaluate an expression while paused. +- `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 284d152a..b5cbbcb0 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 c361f692..efe37379 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 21c43c59..5db4bbc3 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 00000000..5068a946 --- /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 00000000..8edbae53 --- /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 00000000..5ee44d21 --- /dev/null +++ b/src/mcp/debugger-bridge.ts @@ -0,0 +1,274 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import * as vscode from 'vscode'; +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 { DebugEvent, DebuggerBridge, DebuggerStatus, 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', +}; + +interface StoredStat { + name: string; + value: number | string | undefined; + 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 _connected = false; + private _supportsCommands = false; + private _ownBreakpoints: vscode.Breakpoint[] = []; + + 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, + value: stat.values[stat.values.length - 1], + tick: stat.tick, + receivedAt: Date.now(), + }); + }, + }; + this._statsProvider.addStatListener(this._statListener); + } + + public dispose(): void { + this._eventEmitter.off('debugger-status', this._onDebuggerStatus); + 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; + 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): Promise { + return this._events.waitFor(match, timeoutMs); + } + + 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.toLowerCase().includes(needle) && !s.name.toLowerCase().includes(needle)) { + continue; + } + result.push({ id, name: s.name, value: s.value, tick: s.tick, ageMs: now - s.receivedAt }); + } + return result; + } + + public async getStack(threadId?: number): Promise { + this._requirePaused(); + const id = threadId ?? (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 { + this._requirePaused(); + const { result, type } = await this._dap('evaluate', { expression, frameId, context: 'repl' }); + return { result, type }; + } + + 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'); + } + + public async pause(): Promise { + this._requireFocusedSession(); + await vscode.commands.executeCommand('workbench.action.debug.pause'); + } + + public async step(kind: StepKind): Promise { + this._requireFocusedSession(); + await vscode.commands.executeCommand(STEP_COMMANDS[kind]); + } + + public setBreakpoint(file: string, line: number, condition?: string): void { + const abs = path.isAbsolute(file) + ? file + : path.join(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '', file); + 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); + } + + 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 00000000..a45946a1 --- /dev/null +++ b/src/mcp/event-buffer.test.ts @@ -0,0 +1,71 @@ +// 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); + }); + + 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(); + }); +}); diff --git a/src/mcp/event-buffer.ts b/src/mcp/event-buffer.ts new file mode 100644 index 00000000..46d15879 --- /dev/null +++ b/src/mcp/event-buffer.ts @@ -0,0 +1,54 @@ +// 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 } { + const events = this._events.filter(e => e.seq > sinceSeq && (!level || e.level === level)).slice(0, limit); + return { events, nextSeq: events.length > 0 ? events[events.length - 1].seq : sinceSeq }; + } + + head(): number { + return this._seq; + } + + waitFor(match: (e: DebugEvent) => boolean, timeoutMs: number): Promise { + 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 00000000..79659409 --- /dev/null +++ b/src/mcp/mcp-server.test.ts @@ -0,0 +1,174 @@ +// 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: () => undefined, + clearBreakpoints: () => 0, + ...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('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 00000000..71734d18 --- /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 00000000..f7dc2596 --- /dev/null +++ b/src/mcp/mcp-tools.ts @@ -0,0 +1,176 @@ +// 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. May be empty until collectors are enabled in the Live Diagnostics panel.', + inputSchema: { filter: z.string().optional() }, + }, + args => run('get_stats', args, () => bridge.getStats(args.filter)), + ); + + 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 the paused debugger, optionally in the context of a frame.', + inputSchema: { expression: z.string().min(1), frameId: z.number().int().optional() }, + }, + args => run('evaluate', args, () => bridge.evaluate(args.expression, args.frameId)), + ); + + 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 workspace-relative or absolute; line is 1-based.', + 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). 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(), + }, + }, + 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, + ); + return event ?? { timedOut: true }; + }), + ); +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 00000000..2868aae7 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,77 @@ +// 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; + value: number | string | undefined; + tick: number; + ageMs: number; +} + +export type StepKind = 'over' | 'in' | 'out'; + +// 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): Promise; + getStack(threadId?: number): Promise; + getVariables(frameId: number): Promise; + evaluate(expression: string, frameId?: number): Promise; + runCommand(command: string): void; + continue(): Promise; + pause(): Promise; + step(kind: StepKind): Promise; + setBreakpoint(file: string, line: number, condition?: string): void; + clearBreakpoints(all: boolean): number; +} + +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 137f334b..034b7e02 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/tsconfig.json b/tsconfig.json index b1f5ce3b..8a12c64c 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/home_panel/App.css b/webview-ui/src/home_panel/App.css index 2f5e4c20..df1725c7 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 efee20de..b0a40584 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 00000000..72530709 --- /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); From 594fbbe5bc0101d9be31731fe40968fab90f8350 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:20:01 +0200 Subject: [PATCH 2/6] fix: Resolve MCP breakpoint paths against localRoot and report verification set_breakpoint now resolves relative paths against the debug session's localRoot, rejects missing files instead of leaving phantom breakpoints, and returns the adapter's verified flag and message. wait_for_event accepts sinceSeq to catch events emitted before the call. --- src/mcp/debugger-bridge.ts | 51 +++++++++++++++++++++++++++++++----- src/mcp/event-buffer.test.ts | 17 ++++++++++++ src/mcp/event-buffer.ts | 9 ++++++- src/mcp/mcp-server.test.ts | 2 +- src/mcp/mcp-tools.ts | 7 +++-- src/mcp/types.ts | 15 +++++++++-- 6 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts index 5ee44d21..a3fb1353 100644 --- a/src/mcp/debugger-bridge.ts +++ b/src/mcp/debugger-bridge.ts @@ -1,13 +1,22 @@ // 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 { DebugEvent, DebuggerBridge, DebuggerStatus, LogLevel, StatSnapshot, StepKind } from './types'; +import { + BreakpointResult, + DebugEvent, + DebuggerBridge, + DebuggerStatus, + LogLevel, + StatSnapshot, + StepKind, +} from './types'; const DAP_TIMEOUT_MS = 10000; const STEP_COMMANDS: Record = { @@ -168,8 +177,12 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl return this._events.since(sinceSeq, limit, level); } - public waitForEvent(match: (event: DebugEvent) => boolean, timeoutMs: number): Promise { - return this._events.waitFor(match, timeoutMs); + public waitForEvent( + match: (event: DebugEvent) => boolean, + timeoutMs: number, + sinceSeq?: number, + ): Promise { + return this._events.waitFor(match, timeoutMs, sinceSeq); } public getStats(filter?: string): StatSnapshot[] { @@ -252,10 +265,15 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl await vscode.commands.executeCommand(STEP_COMMANDS[kind]); } - public setBreakpoint(file: string, line: number, condition?: string): void { - const abs = path.isAbsolute(file) - ? file - : path.join(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '', file); + 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, @@ -263,6 +281,25 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl ); 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 { diff --git a/src/mcp/event-buffer.test.ts b/src/mcp/event-buffer.test.ts index a45946a1..8eea1002 100644 --- a/src/mcp/event-buffer.test.ts +++ b/src/mcp/event-buffer.test.ts @@ -68,4 +68,21 @@ describe('EventBuffer', () => { 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(); + }); }); diff --git a/src/mcp/event-buffer.ts b/src/mcp/event-buffer.ts index 46d15879..deb28892 100644 --- a/src/mcp/event-buffer.ts +++ b/src/mcp/event-buffer.ts @@ -30,7 +30,14 @@ export class EventBuffer { return this._seq; } - waitFor(match: (e: DebugEvent) => boolean, timeoutMs: number): Promise { + // 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 buffered = this._events.find(e => e.seq > sinceSeq && match(e)); + if (buffered) { + return Promise.resolve(buffered); + } + } return new Promise(resolve => { const done = (e?: DebugEvent) => { clearTimeout(timer); diff --git a/src/mcp/mcp-server.test.ts b/src/mcp/mcp-server.test.ts index 79659409..253df736 100644 --- a/src/mcp/mcp-server.test.ts +++ b/src/mcp/mcp-server.test.ts @@ -20,7 +20,7 @@ function makeBridge(overrides: Partial = {}): DebuggerBridge { continue: async () => undefined, pause: async () => undefined, step: async () => undefined, - setBreakpoint: () => undefined, + setBreakpoint: async (file: string, line: number) => ({ file, line, verified: true }), clearBreakpoints: () => 0, ...overrides, }; diff --git a/src/mcp/mcp-tools.ts b/src/mcp/mcp-tools.ts index f7dc2596..61eb724d 100644 --- a/src/mcp/mcp-tools.ts +++ b/src/mcp/mcp-tools.ts @@ -132,7 +132,8 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A server.registerTool( 'set_breakpoint', { - description: 'Set a breakpoint. file is workspace-relative or absolute; line is 1-based.', + 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), @@ -155,11 +156,12 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A 'wait_for_event', { description: - 'Wait for the next event (only events after the call). kind=any (default) matches any event (log, stopped, continued, session), optionally matching a regex pattern. Returns { timedOut: true } on timeout.', + '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 => @@ -169,6 +171,7 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A 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 index 2868aae7..191a2765 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -29,6 +29,13 @@ export interface StatSnapshot { ageMs: number; } +export interface BreakpointResult { + file: string; + line: number; + verified: boolean; + message?: string; +} + export type StepKind = 'over' | 'in' | 'out'; // Implemented by MinecraftDebuggerBridge. Methods that touch the game throw an Error with a @@ -37,7 +44,11 @@ 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): Promise; + waitForEvent( + match: (event: DebugEvent) => boolean, + timeoutMs: number, + sinceSeq?: number, + ): Promise; getStack(threadId?: number): Promise; getVariables(frameId: number): Promise; evaluate(expression: string, frameId?: number): Promise; @@ -45,7 +56,7 @@ export interface DebuggerBridge { continue(): Promise; pause(): Promise; step(kind: StepKind): Promise; - setBreakpoint(file: string, line: number, condition?: string): void; + setBreakpoint(file: string, line: number, condition?: string): Promise; clearBreakpoints(all: boolean): number; } From 9779e9ee0692a30d0919a343a104b8b9c21ffcc5 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:25:56 +0200 Subject: [PATCH 3/6] fix: Pause MCP sessions through the debug adapter and survive event buffer resets Pause sends a DAP pause to the first thread instead of the workbench command, which is a no-op without a thread context. Stack traces default to the thread that stopped. Event cursors from before a window reload read from the start. --- src/mcp/debugger-bridge.ts | 14 +++++++++++--- src/mcp/event-buffer.test.ts | 6 ++++++ src/mcp/event-buffer.ts | 9 ++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts index a3fb1353..412de31f 100644 --- a/src/mcp/debugger-bridge.ts +++ b/src/mcp/debugger-bridge.ts @@ -41,6 +41,7 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl 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[] = []; @@ -115,6 +116,7 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl 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 : '') }); @@ -200,7 +202,7 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl public async getStack(threadId?: number): Promise { this._requirePaused(); - const id = threadId ?? (await this._dap('threads')).threads?.[0]?.id; + const id = threadId ?? this._stoppedThreadId ?? (await this._dap('threads')).threads?.[0]?.id; if (id === undefined) { throw new Error('No threads available'); } @@ -255,9 +257,15 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl 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 { - this._requireFocusedSession(); - await vscode.commands.executeCommand('workbench.action.debug.pause'); + 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 { diff --git a/src/mcp/event-buffer.test.ts b/src/mcp/event-buffer.test.ts index 8eea1002..72dc4ce1 100644 --- a/src/mcp/event-buffer.test.ts +++ b/src/mcp/event-buffer.test.ts @@ -85,4 +85,10 @@ describe('EventBuffer', () => { 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 index deb28892..9288e474 100644 --- a/src/mcp/event-buffer.ts +++ b/src/mcp/event-buffer.ts @@ -22,10 +22,16 @@ export class EventBuffer { } 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); return { events, nextSeq: events.length > 0 ? events[events.length - 1].seq : sinceSeq }; } + // 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; } @@ -33,7 +39,8 @@ export class EventBuffer { // 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 buffered = this._events.find(e => e.seq > sinceSeq && match(e)); + const cursor = this._cursor(sinceSeq); + const buffered = this._events.find(e => e.seq > cursor && match(e)); if (buffered) { return Promise.resolve(buffered); } From 384be6eccce9777a2c365184a6f549cd4ffb6605 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:29:58 +0200 Subject: [PATCH 4/6] feat: Add MCP tool to enable live diagnostics collectors set_diagnostics_active toggles collectors through the existing set-diagnostics-active event, so get_stats no longer depends on the Live Diagnostics panel being open. --- README.md | 3 ++- src/mcp/debugger-bridge.ts | 28 ++++++++++++++++++++++++++++ src/mcp/mcp-server.test.ts | 10 ++++++++++ src/mcp/mcp-tools.ts | 16 +++++++++++++++- src/mcp/types.ts | 2 ++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eadb7d5d..96e69013 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,8 @@ The extension can host a local [Model Context Protocol](https://modelcontextprot 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 (empty until collectors are enabled in the Live Diagnostics panel). +- `get_stats` - latest Live Diagnostics values (only from active collectors). +- `set_diagnostics_active` - enable or disable Live Diagnostics collectors (e.g. `entities`, `chunks`). - `get_stack` - call stack of a paused thread. - `get_variables` - scopes and variables of a stack frame. - `evaluate` - evaluate an expression while paused. diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts index 412de31f..bf28bfcb 100644 --- a/src/mcp/debugger-bridge.ts +++ b/src/mcp/debugger-bridge.ts @@ -25,6 +25,18 @@ const STEP_COMMANDS: Record = { out: 'workbench.action.debug.stepOut', }; +// collectors behind the built-in Live Diagnostics tabs; the game's schema can add more +const PREFAB_COLLECTORS = [ + 'app_memory', + 'runtime_memory', + 'packets', + 'networking_packets_details', + 'server_tick_timings', + 'commands', + 'entities', + 'chunks', +]; + interface StoredStat { name: string; value: number | string | undefined; @@ -45,6 +57,7 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl private _connected = false; private _supportsCommands = false; private _ownBreakpoints: vscode.Breakpoint[] = []; + private _schemaCollectors: string[] = []; constructor( private readonly _eventEmitter: EventEmitter, @@ -97,6 +110,9 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl receivedAt: Date.now(), }); }, + onSchemaReceived: schema => { + this._schemaCollectors = schema.map(d => d.stat_group_id); + }, }; this._statsProvider.addStatListener(this._statListener); } @@ -244,6 +260,18 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl return { result, type }; } + public getCollectors(): string[] { + return [...new Set([...PREFAB_COLLECTORS, ...this._schemaCollectors])]; + } + + public setDiagnosticsActive(collectors: string[], active: boolean): void { + this._requireSession(); + if (!this._connected) { + throw new Error('Minecraft is not connected'); + } + this._eventEmitter.emit('set-diagnostics-active', collectors, active); + } + public runCommand(command: string): void { this._requireSession(); if (!this._connected || !this._supportsCommands) { diff --git a/src/mcp/mcp-server.test.ts b/src/mcp/mcp-server.test.ts index 253df736..ca14fcba 100644 --- a/src/mcp/mcp-server.test.ts +++ b/src/mcp/mcp-server.test.ts @@ -22,6 +22,8 @@ function makeBridge(overrides: Partial = {}): DebuggerBridge { step: async () => undefined, setBreakpoint: async (file: string, line: number) => ({ file, line, verified: true }), clearBreakpoints: () => 0, + getCollectors: () => ['entities'], + setDiagnosticsActive: vi.fn(), ...overrides, }; } @@ -166,6 +168,14 @@ describe('mcp server', () => { expect((await call('get_status')).isError).toBeFalsy(); }); + it('set_diagnostics_active forwards collectors and lists known ones', async () => { + const setDiagnosticsActive = vi.fn(); + await start(makeBridge({ setDiagnosticsActive })); + const r = await call('set_diagnostics_active', { collectors: ['entities'], active: true }); + expect(setDiagnosticsActive).toHaveBeenCalledWith(['entities'], true); + expect(r.content[0].text).toContain('"known"'); + }); + 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-tools.ts b/src/mcp/mcp-tools.ts index 61eb724d..efa8d780 100644 --- a/src/mcp/mcp-tools.ts +++ b/src/mcp/mcp-tools.ts @@ -66,12 +66,26 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A 'get_stats', { description: - 'Get the latest value of live diagnostics stats. May be empty until collectors are enabled in the Live Diagnostics panel.', + 'Get the latest value of live diagnostics stats. Only active collectors send stats: enable them with set_diagnostics_active or in the Live Diagnostics panel.', inputSchema: { filter: z.string().optional() }, }, args => run('get_stats', args, () => bridge.getStats(args.filter)), ); + server.registerTool( + 'set_diagnostics_active', + { + description: + 'Enable or disable live diagnostics collectors (e.g. entities, chunks, server_tick_timings) so get_stats returns their stats. Returns the known collector names. The Live Diagnostics panel, when open, may toggle collectors again when its tabs change.', + inputSchema: { collectors: z.array(z.string().min(1)).min(1), active: z.boolean() }, + }, + args => + run('set_diagnostics_active', args, () => { + bridge.setDiagnosticsActive(args.collectors, args.active); + return { collectors: args.collectors, active: args.active, known: bridge.getCollectors() }; + }), + ); + server.registerTool( 'get_stack', { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 191a2765..ff32e70d 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -58,6 +58,8 @@ export interface DebuggerBridge { step(kind: StepKind): Promise; setBreakpoint(file: string, line: number, condition?: string): Promise; clearBreakpoints(all: boolean): number; + getCollectors(): string[]; + setDiagnosticsActive(collectors: string[], active: boolean): void; } export interface ActivityEntry { From 8a760fb720568d1c4cb6430cd7c3da990ac3de9f Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:52:31 +0200 Subject: [PATCH 5/6] feat: Toggle Live Diagnostics tabs from the MCP server Replaces set_diagnostics_active with get_diagnostics_tabs and set_diagnostics_tab, which drive the panel's tab switches so the change is visible in VS Code. --- README.md | 4 +- src/mcp/debugger-bridge.ts | 53 ++++++++++++++---------- src/mcp/mcp-server.test.ts | 16 +++---- src/mcp/mcp-tools.ts | 23 ++++++---- src/mcp/types.ts | 9 +++- src/panels/minecraft-diagnostics.ts | 17 ++++++++ webview-ui/src/diagnostics_panel/App.tsx | 25 +++++++++++ 7 files changed, 104 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 96e69013..04fb963f 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,8 @@ The extension can host a local [Model Context Protocol](https://modelcontextprot 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 (only from active collectors). -- `set_diagnostics_active` - enable or disable Live Diagnostics collectors (e.g. `entities`, `chunks`). +- `get_stats` - latest Live Diagnostics values (from enabled tabs). +- `get_diagnostics_tabs`, `set_diagnostics_tab` - list and enable or disable Live Diagnostics tabs. - `get_stack` - call stack of a paused thread. - `get_variables` - scopes and variables of a stack frame. - `evaluate` - evaluate an expression while paused. diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts index bf28bfcb..1fc1c0c4 100644 --- a/src/mcp/debugger-bridge.ts +++ b/src/mcp/debugger-bridge.ts @@ -13,6 +13,7 @@ import { DebugEvent, DebuggerBridge, DebuggerStatus, + DiagnosticsTab, LogLevel, StatSnapshot, StepKind, @@ -25,17 +26,11 @@ const STEP_COMMANDS: Record = { out: 'workbench.action.debug.stepOut', }; -// collectors behind the built-in Live Diagnostics tabs; the game's schema can add more -const PREFAB_COLLECTORS = [ - 'app_memory', - 'runtime_memory', - 'packets', - 'networking_packets_details', - 'server_tick_timings', - 'commands', - 'entities', - 'chunks', -]; +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; @@ -57,7 +52,8 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl private _connected = false; private _supportsCommands = false; private _ownBreakpoints: vscode.Breakpoint[] = []; - private _schemaCollectors: string[] = []; + private _tabs: DiagnosticsTab[] | undefined; + private readonly _onDiagnosticsTabs: (tabs?: DiagnosticsTab[]) => void; constructor( private readonly _eventEmitter: EventEmitter, @@ -110,15 +106,19 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl receivedAt: Date.now(), }); }, - onSchemaReceived: schema => { - this._schemaCollectors = schema.map(d => d.stat_group_id); - }, }; 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; @@ -260,16 +260,25 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl return { result, type }; } - public getCollectors(): string[] { - return [...new Set([...PREFAB_COLLECTORS, ...this._schemaCollectors])]; + // 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; } - public setDiagnosticsActive(collectors: string[], active: boolean): void { - this._requireSession(); - if (!this._connected) { - throw new Error('Minecraft is not connected'); + // 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-active', collectors, active); + 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 { diff --git a/src/mcp/mcp-server.test.ts b/src/mcp/mcp-server.test.ts index ca14fcba..0c648838 100644 --- a/src/mcp/mcp-server.test.ts +++ b/src/mcp/mcp-server.test.ts @@ -22,8 +22,8 @@ function makeBridge(overrides: Partial = {}): DebuggerBridge { step: async () => undefined, setBreakpoint: async (file: string, line: number) => ({ file, line, verified: true }), clearBreakpoints: () => 0, - getCollectors: () => ['entities'], - setDiagnosticsActive: vi.fn(), + getDiagnosticsTabs: async () => [{ name: 'World', active: false }], + setDiagnosticsTab: async (name: string, active: boolean) => ({ name, active }), ...overrides, }; } @@ -168,12 +168,12 @@ describe('mcp server', () => { expect((await call('get_status')).isError).toBeFalsy(); }); - it('set_diagnostics_active forwards collectors and lists known ones', async () => { - const setDiagnosticsActive = vi.fn(); - await start(makeBridge({ setDiagnosticsActive })); - const r = await call('set_diagnostics_active', { collectors: ['entities'], active: true }); - expect(setDiagnosticsActive).toHaveBeenCalledWith(['entities'], true); - expect(r.content[0].text).toContain('"known"'); + 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('wait_for_event with an invalid regex is an error result', async () => { diff --git a/src/mcp/mcp-tools.ts b/src/mcp/mcp-tools.ts index efa8d780..7c5d4d81 100644 --- a/src/mcp/mcp-tools.ts +++ b/src/mcp/mcp-tools.ts @@ -66,24 +66,29 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A 'get_stats', { description: - 'Get the latest value of live diagnostics stats. Only active collectors send stats: enable them with set_diagnostics_active or in the Live Diagnostics panel.', + '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( - 'set_diagnostics_active', + 'get_diagnostics_tabs', { description: - 'Enable or disable live diagnostics collectors (e.g. entities, chunks, server_tick_timings) so get_stats returns their stats. Returns the known collector names. The Live Diagnostics panel, when open, may toggle collectors again when its tabs change.', - inputSchema: { collectors: z.array(z.string().min(1)).min(1), active: z.boolean() }, + 'List the Live Diagnostics panel tabs and whether each is enabled. Opens the panel if it is closed.', }, - args => - run('set_diagnostics_active', args, () => { - bridge.setDiagnosticsActive(args.collectors, args.active); - return { collectors: args.collectors, active: args.active, known: bridge.getCollectors() }; - }), + () => 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( diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ff32e70d..80620978 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -38,6 +38,11 @@ export interface BreakpointResult { 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 { @@ -58,8 +63,8 @@ export interface DebuggerBridge { step(kind: StepKind): Promise; setBreakpoint(file: string, line: number, condition?: string): Promise; clearBreakpoints(all: boolean): number; - getCollectors(): string[]; - setDiagnosticsActive(collectors: string[], active: boolean): void; + getDiagnosticsTabs(): Promise; + setDiagnosticsTab(tab: string, active: boolean): Promise; } export interface ActivityEntry { diff --git a/src/panels/minecraft-diagnostics.ts b/src/panels/minecraft-diagnostics.ts index eafc0684..5f7d7ecd 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/webview-ui/src/diagnostics_panel/App.tsx b/webview-ui/src/diagnostics_panel/App.tsx index 20714555..4e8d8c15 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 && ( From c69bc3713d4b871ddb8ab9903928e2e30abb2e93 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:51:56 +0200 Subject: [PATCH 6/6] feat: Add MCP tools for debugger requests and entity queries debugger_request sends the requests behind the Live Diagnostics buttons, get_entities lists entities through the scripting API while the game runs, and evaluate no longer requires a pause. get_stats includes each stat's group and table rows, and get_logs moves its cursor to the head when the read is not truncated. --- README.md | 4 ++- src/mcp/debugger-bridge.ts | 65 ++++++++++++++++++++++++++++++++++-- src/mcp/event-buffer.test.ts | 2 ++ src/mcp/event-buffer.ts | 3 +- src/mcp/mcp-server.test.ts | 17 ++++++++++ src/mcp/mcp-tools.ts | 28 +++++++++++++++- src/mcp/types.ts | 4 +++ 7 files changed, 117 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 04fb963f..4fb933b0 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,11 @@ Tools: - `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 while paused. +- `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. diff --git a/src/mcp/debugger-bridge.ts b/src/mcp/debugger-bridge.ts index 1fc1c0c4..9ed9d270 100644 --- a/src/mcp/debugger-bridge.ts +++ b/src/mcp/debugger-bridge.ts @@ -34,7 +34,9 @@ async function waitUntil(condition: () => boolean, timeoutMs: number): Promise { 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(), }); @@ -208,10 +212,18 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl const now = Date.now(); const result: StatSnapshot[] = []; for (const [id, s] of this._stats) { - if (needle && !id.toLowerCase().includes(needle) && !s.name.toLowerCase().includes(needle)) { + if (needle && ![id, s.name, s.group].some(text => text.toLowerCase().includes(needle))) { continue; } - result.push({ id, name: s.name, value: s.value, tick: s.tick, ageMs: now - s.receivedAt }); + 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; } @@ -255,11 +267,58 @@ export class MinecraftDebuggerBridge implements DebuggerBridge, vscode.Disposabl } public async evaluate(expression: string, frameId?: number): Promise { - this._requirePaused(); 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'); diff --git a/src/mcp/event-buffer.test.ts b/src/mcp/event-buffer.test.ts index 72dc4ce1..c079fe75 100644 --- a/src/mcp/event-buffer.test.ts +++ b/src/mcp/event-buffer.test.ts @@ -42,6 +42,8 @@ describe('EventBuffer', () => { 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', () => { diff --git a/src/mcp/event-buffer.ts b/src/mcp/event-buffer.ts index 9288e474..23bb1661 100644 --- a/src/mcp/event-buffer.ts +++ b/src/mcp/event-buffer.ts @@ -24,7 +24,8 @@ export class EventBuffer { 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); - return { events, nextSeq: events.length > 0 ? events[events.length - 1].seq : sinceSeq }; + // 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. diff --git a/src/mcp/mcp-server.test.ts b/src/mcp/mcp-server.test.ts index 0c648838..ace8bfe8 100644 --- a/src/mcp/mcp-server.test.ts +++ b/src/mcp/mcp-server.test.ts @@ -24,6 +24,8 @@ function makeBridge(overrides: Partial = {}): DebuggerBridge { clearBreakpoints: () => 0, getDiagnosticsTabs: async () => [{ name: 'World', active: false }], setDiagnosticsTab: async (name: string, active: boolean) => ({ name, active }), + debuggerRequest: async () => ({}), + getEntities: async () => ({ total: 0, entities: [] }), ...overrides, }; } @@ -176,6 +178,21 @@ describe('mcp server', () => { 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-tools.ts b/src/mcp/mcp-tools.ts index 7c5d4d81..13fa6ffb 100644 --- a/src/mcp/mcp-tools.ts +++ b/src/mcp/mcp-tools.ts @@ -72,6 +72,16 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A 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', { @@ -113,12 +123,28 @@ export function registerTools(server: McpServer, bridge: DebuggerBridge, sink: A server.registerTool( 'evaluate', { - description: 'Evaluate a script expression in the paused debugger, optionally in the context of a frame.', + 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', { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 80620978..2b14beb4 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -24,7 +24,9 @@ export interface DebuggerStatus { export interface StatSnapshot { id: string; name: string; + group: string; value: number | string | undefined; + rows?: string[][]; tick: number; ageMs: number; } @@ -57,6 +59,7 @@ export interface DebuggerBridge { 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; @@ -65,6 +68,7 @@ export interface DebuggerBridge { clearBreakpoints(all: boolean): number; getDiagnosticsTabs(): Promise; setDiagnosticsTab(tab: string, active: boolean): Promise; + debuggerRequest(request: string, args?: Record): Promise; } export interface ActivityEntry {