From 30b44fae2a47cd5475f843f2b28fff3366f76422 Mon Sep 17 00:00:00 2001 From: hallerite Date: Thu, 13 Aug 2026 21:54:43 +0200 Subject: [PATCH 1/9] feat(coding-agent): support ACP MCP programs --- package-lock.json | 1034 ++++++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/acp.md | 16 + packages/coding-agent/package.json | 1 + .../coding-agent/src/core/agent-session.ts | 26 + .../coding-agent/src/core/resource-loader.ts | 18 + .../coding-agent/src/modes/acp/acp-mcp.ts | 255 ++++ .../coding-agent/src/modes/acp/acp-mode.ts | 47 +- .../daemon-agent-connection.ts | 12 + .../in-process-agent-connection.ts | 4 + .../src/modes/agent-connection/types.ts | 2 + .../src/modes/daemon/daemon-mode.ts | 9 + .../src/modes/daemon/daemon-protocol.ts | 17 +- .../src/modes/daemon/daemon-supervisor.ts | 1 + packages/coding-agent/test/acp-mcp.test.ts | 191 +++ .../test/agent-connection-daemon.test.ts | 22 + .../coding-agent/test/daemon-protocol.test.ts | 9 + prime-agent-runtime/src/rlm/__init__.py | 10 +- prime-agent-runtime/src/rlm/mcp_base.py | 188 ++- prime-agent-runtime/test/test_mcp_base.py | 124 +- 20 files changed, 1938 insertions(+), 49 deletions(-) create mode 100644 packages/coding-agent/src/modes/acp/acp-mcp.ts create mode 100644 packages/coding-agent/test/acp-mcp.test.ts diff --git a/package-lock.json b/package-lock.json index ac51fc593ac..ed081c286d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1112,6 +1112,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1322,6 +1334,105 @@ "zod-to-json-schema": "^3.25.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", @@ -2546,6 +2657,19 @@ "addons/*" ] }, + "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==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agent-base": { "version": "7.1.4", "license": "MIT", @@ -2553,6 +2677,39 @@ "node": ">= 14" } }, + "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==", + "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/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "license": "MIT", @@ -2649,6 +2806,43 @@ "readable-stream": "^3.4.0" } }, + "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==", + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -2712,6 +2906,44 @@ "version": "1.0.1", "license": "BSD-3-Clause" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/canvas": { "version": "3.2.3", "dev": true, @@ -2957,6 +3189,28 @@ "node": ">=12" } }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2964,6 +3218,41 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "6.0.6", "dev": true, @@ -3043,6 +3332,15 @@ "node": ">= 14" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "dev": true, @@ -3060,6 +3358,20 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "license": "Apache-2.0", @@ -3067,10 +3379,25 @@ "safe-buffer": "^5.0.1" } }, + "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==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "license": "MIT", @@ -3078,9 +3405,17 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3093,6 +3428,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -3142,6 +3489,12 @@ "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==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "license": "BSD-2-Clause", @@ -3196,6 +3549,36 @@ "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "1.0.0", "dev": true, @@ -3240,6 +3623,68 @@ "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==", + "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.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "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/extend": { "version": "3.0.2", "license": "MIT" @@ -3262,6 +3707,12 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -3277,9 +3728,25 @@ "node": ">=8.6.0" } }, - "node_modules/fastq": { - "version": "1.20.1", - "dev": true, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3340,6 +3807,27 @@ "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==", + "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/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -3350,6 +3838,24 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "dev": true, @@ -3372,7 +3878,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3421,6 +3926,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "license": "MIT", @@ -3506,6 +4048,18 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "license": "ISC" @@ -3517,9 +4071,20 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3535,6 +4100,15 @@ "node": "*" } }, + "node_modules/hono": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "9.0.3", "license": "ISC", @@ -3545,6 +4119,26 @@ "node": "^20.17.0 || >=22.9.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==", + "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", "license": "MIT", @@ -3581,6 +4175,22 @@ "url": "https://github.com/sponsors/typicode" } }, + "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==", + "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/ieee754": { "version": "1.2.1", "funding": [ @@ -3608,7 +4218,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -3633,6 +4242,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "dev": true, @@ -3681,6 +4299,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "1.1.0", "dev": true, @@ -3691,7 +4315,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -3701,6 +4324,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "license": "MIT", @@ -3719,6 +4351,18 @@ "node": ">=16" } }, + "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==", + "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==", + "license": "BSD-2-Clause" + }, "node_modules/jwa": { "version": "2.0.1", "license": "MIT", @@ -4053,6 +4697,40 @@ "node": ">= 20" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "dev": true, @@ -4175,6 +4853,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { "version": "2.1.1", "license": "MIT", @@ -4264,6 +4951,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", @@ -4278,6 +4977,18 @@ "node": ">=12.20.0" } }, + "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==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "license": "ISC", @@ -4377,6 +5088,15 @@ "version": "6.0.1", "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/partial-json": { "version": "0.1.7", "license": "MIT" @@ -4408,6 +5128,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4453,6 +5183,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -4546,6 +5285,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "license": "MIT", @@ -4582,6 +5334,22 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -4601,6 +5369,34 @@ ], "license": "MIT" }, + "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==", + "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==", + "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/rc": { "version": "1.2.8", "dev": true, @@ -4645,6 +5441,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "dev": true, @@ -4715,6 +5520,22 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "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", "dev": true, @@ -4763,6 +5584,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4776,6 +5603,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "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/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "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==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "1.2.0", "dev": true, @@ -4839,6 +5717,78 @@ "node": ">=18" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "dev": true, @@ -4946,6 +5896,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -5185,6 +6144,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/token-types": { "version": "6.1.2", "license": "MIT", @@ -5247,6 +6215,37 @@ "node": "*" } }, + "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==", + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typebox": { "version": "1.3.10", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.10.tgz", @@ -5311,6 +6310,15 @@ "version": "6.21.0", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -5327,6 +6335,15 @@ "uuid": "dist-node/bin/uuid" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -5795,6 +6812,7 @@ "@earendil-works/pi-agent-core": "^0.7.2", "@earendil-works/pi-ai": "^0.7.2", "@earendil-works/pi-tui": "^0.7.2", + "@modelcontextprotocol/sdk": "^1.30.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bfd4abb6049..4ddd0349a51 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added ACP MCP server support: HTTP and stdio tools supplied by `session/new` are exposed as session-scoped Python programs in the RLM kernel. - Fixed URLs not opening on click in fullscreen mode on terminals such as Ghostty; clicking a link in the transcript, dock, or overlays now opens it in the browser. ## [0.7.2] - 2026-08-11 diff --git a/packages/coding-agent/docs/acp.md b/packages/coding-agent/docs/acp.md index a77542597d1..96ca51cf8dc 100644 --- a/packages/coding-agent/docs/acp.md +++ b/packages/coding-agent/docs/acp.md @@ -28,6 +28,22 @@ One session per connection is a deliberate limit: Prime Agent's underlying sessi Likewise `session/prompt` refuses a concurrent turn while one is running, and the working directory cannot be changed after startup — a client-supplied `cwd` that differs from the agent's real one is reported back in `_meta` rather than silently ignored. +## MCP servers + +Prime Agent accepts both standard stdio servers and negotiated HTTP servers in +`session/new.mcpServers`. It discovers their tools before creating the session and installs one +session-scoped Python program per tool in the RLM kernel. A server named `task-tools` with a +`lookup` tool is available as `task_tools_lookup`; its signature is derived from the tool's JSON +Schema and it can be called directly: + +```python +result = await task_tools_lookup(query="example") +``` + +These programs use only the transport and credentials supplied by the ACP client. They neither +read nor modify the user's persistent MCP connection settings, and they are replaced when the +connection creates a new ACP session. + ## Streamed updates Session activity arrives as `session/update` notifications: diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9dd13c936c7..68025e1c2ed 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -51,6 +51,7 @@ "@earendil-works/pi-agent-core": "^0.7.2", "@earendil-works/pi-ai": "^0.7.2", "@earendil-works/pi-tui": "^0.7.2", + "@modelcontextprotocol/sdk": "^1.30.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 46d2bd4f8a7..6887601b3b1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1383,6 +1383,32 @@ export class AgentSession { this.agent.state.systemPrompt = this._baseSystemPrompt; } + replaceTemporarySkills(skillPaths: string[], source: string): void { + if (this.isStreaming) { + throw new Error("Cannot replace temporary skills while the agent is running"); + } + const resources = skillPaths.map((path) => ({ + path, + metadata: { + source, + scope: "temporary" as const, + origin: "top-level" as const, + baseDir: dirname(path), + }, + })); + if (this._resourceLoader.replaceSkillResources) { + this._resourceLoader.replaceSkillResources(resources, source); + } else if (resources.length > 0) { + this._resourceLoader.extendResources({ skillPaths: resources }); + } + this._buildRuntime({ + activeToolNames: this.getActiveToolNames(), + includeAllExtensionTools: true, + }); + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + /** Model registry for API key resolution and model discovery */ get modelRegistry(): ModelRegistry { return this._modelRegistry; diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 5cb03a4bf5a..90372e89241 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -35,6 +35,7 @@ export interface ResourceLoader { getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; extendResources(paths: ResourceExtensionPaths): void; + replaceSkillResources?(paths: Array<{ path: string; metadata: PathMetadata }>, source: string): void; reload(): Promise; } @@ -333,6 +334,23 @@ export class DefaultResourceLoader implements ResourceLoader { } } + replaceSkillResources(paths: Array<{ path: string; metadata: PathMetadata }>, source: string): void { + const skillPaths = this.normalizeExtensionPaths(paths); + this.lastSkillPaths = this.lastSkillPaths.filter((path) => { + if (this.extensionSkillSourceInfos.get(path)?.source !== source) return true; + this.extensionSkillSourceInfos.delete(path); + return false; + }); + for (const entry of skillPaths) { + this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + this.lastSkillPaths = this.mergePaths( + this.lastSkillPaths, + skillPaths.map((entry) => entry.path), + ); + this.updateSkillsFromPaths(this.lastSkillPaths); + } + async reload(): Promise { await this.settingsManager.reload(); const resolvedPaths = await this.packageManager.resolve(); diff --git a/packages/coding-agent/src/modes/acp/acp-mcp.ts b/packages/coding-agent/src/modes/acp/acp-mcp.ts new file mode 100644 index 00000000000..5db5374a574 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-mcp.ts @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { McpServer } from "@agentclientprotocol/sdk"; +import { RequestError } from "@agentclientprotocol/sdk"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +interface TemporarySkillHost { + replaceTemporarySkills(skillPaths: string[], source: string): Promise | void; + getSkillNames?(): Promise; +} + +type AcpMcpServerConfig = + | { type: "http"; url: string; headers: Record } + | { type: "stdio"; command: string; args: string[]; env: Record }; + +type AcpMcpTool = Pick; +type DiscoverTools = (server: string, config: AcpMcpServerConfig, cwd: string) => Promise; + +interface DiscoveredSkill { + server: string; + config: AcpMcpServerConfig; + tool: AcpMcpTool; + importName: string; +} + +function resolveServers(servers: readonly McpServer[]): Record { + const resolved = Object.create(null) as Record; + for (const server of servers) { + if (Object.hasOwn(resolved, server.name)) { + throw RequestError.invalidParams({ reason: `duplicate MCP server name: ${server.name}` }); + } + if (!server.name) { + throw RequestError.invalidParams({ reason: "MCP server names must not be empty" }); + } + if ("command" in server) { + if (!server.command) { + throw RequestError.invalidParams({ reason: `MCP server ${server.name} has no stdio command` }); + } + resolved[server.name] = { + type: "stdio", + command: server.command, + args: [...server.args], + env: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), + }; + continue; + } + if (server.type === "http") { + if (!server.url) { + throw RequestError.invalidParams({ reason: `MCP server ${server.name} has no HTTP URL` }); + } + resolved[server.name] = { + type: "http", + url: server.url, + headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), + }; + continue; + } + throw RequestError.invalidParams({ + reason: `MCP server ${server.name} uses unsupported ${server.type} transport`, + }); + } + return resolved; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function discoverServerTools(server: string, config: AcpMcpServerConfig, cwd: string): Promise { + const client = new Client({ name: "prime-agent-acp", version: "1.0.0" }); + try { + const transport = + config.type === "http" + ? new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { headers: config.headers }, + }) + : new StdioClientTransport({ + command: config.command, + args: config.args, + env: { ...getDefaultEnvironment(), ...config.env }, + cwd, + }); + await client.connect(transport); + const result = await client.listTools(); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + } catch (error) { + throw RequestError.invalidParams({ + reason: `failed to discover tools from MCP server ${server}: ${errorText(error)}`, + }); + } finally { + await client.close().catch(() => undefined); + } +} + +function skillImportName(server: string, tool: string): string { + const normalized = `${server}_${tool}`.replace(/\W/g, "_"); + return /^\d/.test(normalized) ? `_${normalized}` : normalized; +} + +function skillDirectoryName(importName: string): string { + return importName.replaceAll("_", "-"); +} + +function pythonModule(server: string, config: AcpMcpServerConfig, tool: AcpMcpTool): string { + const encodedConfig = JSON.stringify(JSON.stringify(config)); + const encodedTool = JSON.stringify(JSON.stringify(tool)); + return `"""ACP MCP tool generated for this session.""" + +import json + +from rlm import make_acp_mcp_skill + +run = make_acp_mcp_skill( + ${JSON.stringify(server)}, + json.loads(${encodedConfig}), + json.loads(${encodedTool}), +) +`; +} + +function skillMarkdown(skill: DiscoveredSkill): string { + const summary = skill.tool.description?.split("\n", 1)[0] || `Call ${skill.tool.name} on ${skill.server}.`; + const description = `MCP tool ${skill.server}/${skill.tool.name}: ${summary}`.slice(0, 1024); + const skillName = skillDirectoryName(skill.importName); + return `--- +name: ${JSON.stringify(skillName)} +description: ${JSON.stringify(description)} +--- + +# ${skill.importName} + +This session-scoped skill calls MCP tool ${JSON.stringify(skill.tool.name)} on server +${JSON.stringify(skill.server)}. It is pre-imported in IPython and callable directly: + +\`\`\`python +await ${skill.importName}(...) +\`\`\` + +Use \`help(${skill.importName})\` or \`inspect.signature(${skill.importName})\` for its +description and schema-derived keyword arguments. +`; +} + +function pyproject(skill: DiscoveredSkill): string { + const digest = createHash("sha256") + .update(JSON.stringify([skill.server, skill.config, skill.tool])) + .digest("hex") + .slice(0, 16); + return `[project] +name = "prime-agent-acp-mcp-${digest}" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = ["mcp>=1.28,<2", "httpx>=0.27,<1"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/${skill.importName}"] +`; +} + +export class AcpMcpSkillInstaller { + private root: string | undefined; + private installedSkillNames = new Set(); + + constructor( + private readonly host: TemporarySkillHost, + private readonly discoverTools: DiscoverTools = discoverServerTools, + ) {} + + async configure(servers: readonly McpServer[], cwd: string): Promise { + const config = resolveServers(servers); + const discovered: DiscoveredSkill[] = []; + const names = new Map(); + for (const [server, spec] of Object.entries(config)) { + const tools = await this.discoverTools(server, spec, cwd); + for (const tool of tools) { + if (!tool.name) { + throw RequestError.invalidParams({ reason: `MCP server ${server} returned a tool with an empty name` }); + } + const importName = skillImportName(server, tool.name); + const skillName = skillDirectoryName(importName); + if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(skillName)) { + throw RequestError.invalidParams({ + reason: `MCP tool ${server}/${tool.name} produces invalid Python skill name ${skillName}`, + }); + } + const prior = names.get(importName); + if (prior) { + throw RequestError.invalidParams({ + reason: + `MCP tools ${prior.server}/${prior.tool} and ${server}/${tool.name} ` + + `normalize to the same Python skill ${importName}`, + }); + } + names.set(importName, { server, tool: tool.name }); + discovered.push({ server, config: spec, tool, importName }); + } + } + + const existingSkillNames = new Set((await this.host.getSkillNames?.()) ?? []); + for (const installedName of this.installedSkillNames) existingSkillNames.delete(installedName); + for (const skill of discovered) { + const skillName = skillDirectoryName(skill.importName); + if (existingSkillNames.has(skillName)) { + throw RequestError.invalidParams({ + reason: `MCP tool ${skill.server}/${skill.tool.name} conflicts with existing skill ${skillName}`, + }); + } + } + + const nextRoot = discovered.length > 0 ? mkdtempSync(join(tmpdir(), "prime-agent-acp-mcp-")) : undefined; + const skillPaths: string[] = []; + for (const skill of discovered) { + const skillDir = join(nextRoot!, skillDirectoryName(skill.importName)); + const packageDir = join(skillDir, "src", skill.importName); + mkdirSync(packageDir, { recursive: true }); + const skillPath = join(skillDir, "SKILL.md"); + writeFileSync(skillPath, skillMarkdown(skill), { encoding: "utf-8", mode: 0o600 }); + writeFileSync(join(skillDir, "pyproject.toml"), pyproject(skill), { encoding: "utf-8", mode: 0o600 }); + writeFileSync(join(packageDir, "__init__.py"), pythonModule(skill.server, skill.config, skill.tool), { + encoding: "utf-8", + mode: 0o600, + }); + skillPaths.push(skillPath); + } + try { + await this.host.replaceTemporarySkills(skillPaths, "acp:mcp"); + } catch (error) { + if (nextRoot) rmSync(nextRoot, { recursive: true, force: true }); + throw error; + } + if (this.root) rmSync(this.root, { recursive: true, force: true }); + this.root = nextRoot; + this.installedSkillNames = new Set(discovered.map((skill) => skillDirectoryName(skill.importName))); + } + + dispose(): void { + if (!this.root) return; + rmSync(this.root, { recursive: true, force: true }); + this.root = undefined; + this.installedSkillNames.clear(); + } +} diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index eed228ae08e..0689f4e4615 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -13,6 +13,7 @@ import { InProcessAgentConnection } from "../agent-connection/in-process-agent-c import type { AgentConnection } from "../agent-connection/types.js"; import { latestAutonomousGateAttempt } from "../headless-completion.js"; import { type AcpEventMappingState, acpUpdatesForSessionEvent } from "./acp-events.js"; +import { AcpMcpSkillInstaller } from "./acp-mcp.js"; import { primeAgentMeta } from "./acp-meta.js"; import { type AcpStopReason, acpStopReason } from "./acp-stop-reason.js"; @@ -87,6 +88,8 @@ function sameCwd(left: string, right: string): boolean { export interface AcpModeOptions { /** Bind headless extensions once the connection is live (in-process mode). */ bindHeadlessExtensions?: () => Promise; + /** Install MCP servers supplied by session/new. Tests may override discovery. */ + configureMcpServers?: (servers: readonly acp.McpServer[], cwd: string) => Promise | void; /** * Transport override. Defaults to NDJSON over stdio; tests supply an * in-memory stream pair so the protocol runs without a subprocess. @@ -242,6 +245,14 @@ export async function runAcpModeWithConnection( if (options.ownStdout !== false && !options.stream) { takeOverStdout(); } + const mcp = connection.replaceTemporarySkills + ? new AcpMcpSkillInstaller({ + replaceTemporarySkills: (skillPaths, source) => connection.replaceTemporarySkills!(skillPaths, source), + getSkillNames: async () => (await connection.getResourceSnapshot()).skills.map((skill) => skill.name), + }) + : undefined; + const configureMcpServers = + options.configureMcpServers ?? (mcp ? (servers, cwd) => mcp.configure(servers, cwd) : undefined); // One ACP connection drives one AgentConnection, whose newSession() replaces // the live session rather than creating a parallel one. Tracking a single @@ -260,6 +271,7 @@ export async function runAcpModeWithConnection( agentCapabilities: { loadSession: false, promptCapabilities: { image: true, embeddedContext: true }, + ...(configureMcpServers ? { mcpCapabilities: { http: true } } : {}), // Advertise close so a client knows it can release the session (and // the single-session slot) instead of dropping the connection. sessionCapabilities: { close: {} }, @@ -270,31 +282,43 @@ export async function runAcpModeWithConnection( _meta: primeAgentMeta({}), })) .onRequest("session/new", async (ctx: any) => { + if (session) { + throw new Error( + "prime-agent ACP mode hosts one session per connection; " + + "start another prime-agent process for a second session", + ); + } + const params = ctx.params as acp.NewSessionRequest; if (!bound) { // Only latch after a successful bind: a rejected bind must not leave // extensions permanently unavailable for the rest of the process. await options.bindHeadlessExtensions?.(); bound = true; } - if (session) { - throw new Error( - "prime-agent ACP mode hosts one session per connection; " + - "start another prime-agent process for a second session", + const requestedCwd = params.cwd; + const actualCwd = await connection + .getState() + .then((state) => state.cwd) + .catch(() => undefined); + if (params.mcpServers.length > 0 && !configureMcpServers) { + throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); + } + try { + await configureMcpServers?.(params.mcpServers, actualCwd ?? requestedCwd); + } catch (error) { + console.error( + `prime-agent ACP MCP setup failed: ${error instanceof Error ? error.message : String(error)}`, ); + throw error; } // prime-agent's cwd is fixed at startup by the session it was launched // with, so a client-supplied cwd cannot be adopted after the fact. // Report the real cwd back in `_meta` rather than failing the request or // letting the client assume a directory the agent is not using. - const requestedCwd = (ctx.params as { cwd?: unknown } | undefined)?.cwd; let cwdMismatch: { requested: string; actual: string } | undefined; if (typeof requestedCwd === "string" && requestedCwd.length > 0) { - const actual = await connection - .getState() - .then((state) => state.cwd) - .catch(() => undefined); - if (actual && !sameCwd(requestedCwd, actual)) { - cwdMismatch = { requested: requestedCwd, actual }; + if (actualCwd && !sameCwd(requestedCwd, actualCwd)) { + cwdMismatch = { requested: requestedCwd, actual: actualCwd }; } } const sessionId = randomUUID(); @@ -420,6 +444,7 @@ export async function runAcpModeWithConnection( session?.unsubscribe?.(); session = undefined; await connection.dispose().catch(() => undefined); + mcp?.dispose(); // Only the real stdio entrypoint owns the process; a caller-supplied transport // (tests, embedding) must never have its host exited from under it. if (options.stream) return undefined as never; diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 016a0532796..d179315aa5f 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -456,6 +456,18 @@ export class DaemonAgentConnection implements AgentConnection { }); } + async replaceTemporarySkills(skillPaths: string[], source: string): Promise { + if (!this.client.supportsServerCapability("temporary_skills")) { + throw new DaemonCapabilityUnavailableError("replace_temporary_skills", "temporary_skills"); + } + await this.requestOk({ + type: "replace_temporary_skills", + activeSessionId: this.activeSessionId, + skillPaths, + source, + }); + } + async getAvailableModels(): Promise { const data = await this.requestData<{ models: AgentConnectionModel[] }>({ type: "get_available_models", diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index e8257b3fb1b..b4772ec3bbc 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -106,6 +106,10 @@ export class InProcessAgentConnection implements AgentConnection { await this.bindCurrentSessionExtensions(); } + async replaceTemporarySkills(skillPaths: string[], source: string): Promise { + this.runtimeHost.session.replaceTemporarySkills(skillPaths, source); + } + subscribe(listener: AgentConnectionEventListener): () => void { this.listeners.add(listener); return () => { diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 7e98f966817..478ebc8bc40 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -689,6 +689,8 @@ export interface AgentConnection { getToolDefinition(name: string): Promise; setSessionEntryLabel(entryId: string, label: string | undefined): Promise; respondToExtensionUiRequest(requestId: string, response: AgentConnectionExtensionUiResponse): Promise; + /** Replace one source's process-local Python skills before a headless turn starts. */ + replaceTemporarySkills?(skillPaths: string[], source: string): Promise; prompt(message: string, options?: AgentConnectionPromptOptions): Promise; promptAndWait(message: string, options?: AgentConnectionPromptOptions): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3443cd3f8c2..d677da10e41 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -277,6 +277,7 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "get_context_tree", "get_commands", "get_resource_snapshot", + "replace_temporary_skills", "get_model_catalog", "get_available_models", "get_queue", @@ -4158,6 +4159,14 @@ export class AgentDaemon { ); } + case "replace_temporary_skills": { + const state = this.getSessionState(command.activeSessionId); + await withClientEnv(state.clientEnv, async () => + state.runtime.session.replaceTemporarySkills(command.skillPaths, command.source), + ); + return success(command.id, "replace_temporary_skills"); + } + case "get_available_models": { const state = this.getSessionState(command.activeSessionId); return success(command.id, "get_available_models", { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc28..29fa1a7f923 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -export const DAEMON_SCHEMA_REVISION = 16; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; +// Revision 17 adds capability-gated replacement of process-local Python skills. +export const DAEMON_SCHEMA_REVISION = 17; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-e19a3f12dc03"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -100,7 +101,8 @@ export type DaemonServerCapability = | "transient_bash" | "session_input_admission" | "prompt_admission_cancellation" - | "queue_message_mutation"; + | "queue_message_mutation" + | "temporary_skills"; export type DaemonReplayStatus = "complete" | "partial" | "unavailable"; @@ -139,6 +141,7 @@ export const DAEMON_DEFAULT_SERVER_CAPABILITIES: readonly DaemonServerCapability "session_input_admission", "prompt_admission_cancellation", "queue_message_mutation", + "temporary_skills", ]; export interface DaemonRuntimeIdentity { @@ -510,6 +513,13 @@ export type DaemonCommand = | { id?: string; type: "get_context_tree"; activeSessionId: string } | { id?: string; type: "get_commands"; activeSessionId: string } | { id?: string; type: "get_resource_snapshot"; activeSessionId: string } + | { + id?: string; + type: "replace_temporary_skills"; + activeSessionId: string; + skillPaths: string[]; + source: string; + } | { id?: string; type: "get_model_catalog"; activeSessionId: string } | { id?: string; type: "get_available_models"; activeSessionId: string } | { id?: string; type: "get_queue"; activeSessionId: string } @@ -693,6 +703,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { get_context_tree: LEGACY_DAEMON_COMMAND, get_commands: LEGACY_DAEMON_COMMAND, get_resource_snapshot: LEGACY_DAEMON_COMMAND, + replace_temporary_skills: { minProtocol: 7, minSchemaRevision: 17, capability: "temporary_skills" }, get_model_catalog: { minProtocol: 7, capability: "model_catalog" }, get_available_models: LEGACY_DAEMON_COMMAND, get_queue: LEGACY_DAEMON_COMMAND, diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3069dd4af46..921c3cafdc5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -196,6 +196,7 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "get_context_tree", "get_commands", "get_resource_snapshot", + "replace_temporary_skills", "get_model_catalog", "get_available_models", "get_queue", diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts new file mode 100644 index 00000000000..d41801324b6 --- /dev/null +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -0,0 +1,191 @@ +import { once } from "node:events"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import * as acp from "@agentclientprotocol/sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.js"; +import { DefaultResourceLoader } from "../src/core/resource-loader.js"; +import { AcpMcpSkillInstaller } from "../src/modes/acp/acp-mcp.js"; +import { runAcpModeWithConnection } from "../src/modes/acp/acp-mode.js"; +import { InProcessAgentConnection } from "../src/modes/agent-connection/in-process-agent-connection.js"; +import { createHarness } from "./suite/harness.js"; + +const servers: Server[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.close(); + await once(server, "close"); + } +}); + +function runtimeHostFor(session: unknown): AgentSessionRuntime { + return { + session, + setRebindSession() {}, + setBeforeSessionInvalidate() {}, + async dispose() {}, + } as unknown as AgentSessionRuntime; +} + +async function startHttpMcpServer(): Promise<{ url: string; authorization: () => string | undefined }> { + let authorization: string | undefined; + const server = createServer(async (request, response) => { + authorization = request.headers.authorization; + if (request.method === "DELETE") { + response.writeHead(200).end(); + return; + } + if (request.method !== "POST") { + response.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const message = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { + id?: string | number; + method: string; + params?: { protocolVersion?: string }; + }; + if (message.id === undefined) { + response.writeHead(202).end(); + return; + } + const result = + message.method === "initialize" + ? { + protocolVersion: message.params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "test-tools", version: "1.0.0" }, + } + : { + tools: [ + { + name: "lookup", + description: "Look up a task by query.", + inputSchema: { + type: "object", + properties: { query: { type: "string" }, limit: { type: "integer" } }, + required: ["query"], + }, + }, + ], + }; + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("test MCP server did not bind TCP"); + return { url: `http://127.0.0.1:${address.port}/mcp`, authorization: () => authorization }; +} + +describe("ACP MCP servers", () => { + it("advertises HTTP support and configures session/new servers", async () => { + const harness = await createHarness(); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + const agentCwd = (await connection.getState()).cwd; + const configureMcpServers = vi.fn(); + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + const modeDone = runAcpModeWithConnection(connection, { + stream: acp.ndJsonStream(toClient.writable, toAgent.readable), + configureMcpServers, + }); + const handle = acp + .client({ name: "mcp-test-client" }) + .connect(acp.ndJsonStream(toAgent.writable, toClient.readable)); + try { + const initialized = await handle.agent.request("initialize", { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true); + + const server: acp.McpServer = { + type: "http", + name: "task-tools", + url: "http://127.0.0.1:8000/mcp", + headers: [{ name: "Authorization", value: "Bearer task" }], + }; + await handle.agent.request("session/new", { cwd: harness.tempDir, mcpServers: [server] }); + expect(configureMcpServers).toHaveBeenCalledWith([server], agentCwd); + } finally { + handle.close(); + await toAgent.writable.close().catch(() => undefined); + await modeDone; + harness.cleanup(); + } + }, 30_000); + + it("discovers HTTP tools and writes one callable Python program per tool", async () => { + const mcp = await startHttpMcpServer(); + const replaceTemporarySkills = vi.fn(); + const installer = new AcpMcpSkillInstaller({ replaceTemporarySkills }); + try { + await installer.configure( + [ + { + type: "http", + name: "task-tools", + url: mcp.url, + headers: [{ name: "Authorization", value: "Bearer task" }], + }, + ], + process.cwd(), + ); + + expect(mcp.authorization()).toBe("Bearer task"); + expect(replaceTemporarySkills).toHaveBeenCalledOnce(); + const [skillPaths] = replaceTemporarySkills.mock.calls[0] as [string[], string]; + expect(skillPaths).toHaveLength(1); + const skillDir = dirname(skillPaths[0]); + expect(basename(skillDir)).toBe("task-tools-lookup"); + expect(readFileSync(skillPaths[0], "utf-8")).toContain("await task_tools_lookup(...)"); + const source = readFileSync(join(skillDir, "src", "task_tools_lookup", "__init__.py"), "utf-8"); + expect(source).toContain("make_acp_mcp_skill"); + expect(source).toContain("Look up a task by query."); + } finally { + installer.dispose(); + } + }); + + it("replaces the previous ACP session's generated programs", async () => { + const resourceRoot = mkdtempSync(join(tmpdir(), "prime-agent-acp-mcp-replace-test-")); + const resourceLoader = new DefaultResourceLoader({ + cwd: resourceRoot, + agentDir: resourceRoot, + bundledSkillsDir: null, + }); + await resourceLoader.reload(); + const harness = await createHarness({ resourceLoader }); + const installer = new AcpMcpSkillInstaller(harness.session, async (server) => [ + { name: "lookup", description: `Look up data from ${server}.`, inputSchema: { type: "object" } }, + ]); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + try { + await installer.configure( + [{ type: "http", name: "first", url: "https://first.test/mcp", headers: [] }], + harness.tempDir, + ); + expect((await connection.getResourceSnapshot()).skills.map((skill) => skill.name)).toContain("first-lookup"); + + await installer.configure( + [{ type: "http", name: "second", url: "https://second.test/mcp", headers: [] }], + harness.tempDir, + ); + const replaced = (await connection.getResourceSnapshot()).skills.map((skill) => skill.name); + expect(replaced).not.toContain("first-lookup"); + expect(replaced).toContain("second-lookup"); + } finally { + installer.dispose(); + await connection.dispose(); + harness.cleanup(); + rmSync(resourceRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 7ff21e88c42..11f8e7276f6 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -180,6 +180,8 @@ class FakeDaemonClient { }, }, }; + case "replace_temporary_skills": + return { type: "response", command: command.type, success: true }; case "get_model_catalog": return { type: "response", @@ -959,6 +961,26 @@ describe("DaemonAgentConnection", () => { expect(sent).toMatchObject({ command: "ls", excludeFromContext: true, transient: true, runId: "side-run-1" }); }); + it("capability-gates temporary ACP skills on old daemons", async () => { + const oldDaemonClient = new FakeDaemonClient(); + const oldConnection = new DaemonAgentConnection(asDaemonClient(oldDaemonClient), "active-original"); + await expect(oldConnection.replaceTemporarySkills(["/tmp/task/SKILL.md"], "acp:mcp")).rejects.toThrow( + "does not support temporary_skills", + ); + expect(oldDaemonClient.requests).toEqual([]); + + const newDaemonClient = new FakeDaemonClient(); + newDaemonClient.serverCapabilities.add("temporary_skills"); + const newConnection = new DaemonAgentConnection(asDaemonClient(newDaemonClient), "active-original"); + await newConnection.replaceTemporarySkills(["/tmp/task/SKILL.md"], "acp:mcp"); + expect(newDaemonClient.requests.at(-1)).toMatchObject({ + type: "replace_temporary_skills", + activeSessionId: "active-original", + skillPaths: ["/tmp/task/SKILL.md"], + source: "acp:mcp", + }); + }); + it("degrades an unavailable heartbeat catalog without sending an unsupported command", async () => { const fakeClient = new FakeDaemonClient(); const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index d7eb8acf4a5..e9911df4db6 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -93,6 +93,15 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); + it("capability- and schema-gates temporary skill replacement", () => { + expect(DAEMON_COMMAND_COMPATIBILITY.replace_temporary_skills).toEqual({ + minProtocol: 7, + minSchemaRevision: 17, + capability: "temporary_skills", + }); + expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("temporary_skills"); + }); + it("schema-gates the RLM max depth commands at their introducing revision", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_rlm_max_depth_status).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); expect(DAEMON_COMMAND_COMPATIBILITY.set_rlm_max_depth).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); diff --git a/prime-agent-runtime/src/rlm/__init__.py b/prime-agent-runtime/src/rlm/__init__.py index 0c746fec9b8..a8e2e543d9d 100644 --- a/prime-agent-runtime/src/rlm/__init__.py +++ b/prime-agent-runtime/src/rlm/__init__.py @@ -313,6 +313,7 @@ async def __call__(self, prompt: str, **kwargs: Any) -> RLMSpawnHandle: sys.modules[__name__].__class__ = _CallableModule __all__ = [ + "AcpMcpIntegration", "HarnessEntry", "HarnessScope", "HarnessState", @@ -329,13 +330,20 @@ async def __call__(self, prompt: str, **kwargs: Any) -> RLMSpawnHandle: "harness", "host_request", "list_subagents", + "make_acp_mcp_skill", "rlm", "run", ] # Lazily re-export the MCP base class. Kept lazy so `import rlm` never requires # the optional `mcp` SDK — only integration packages that subclass it do. -_LAZY_MCP = {"McpIntegration", "McpToolError", "NotEnabled"} +_LAZY_MCP = { + "AcpMcpIntegration", + "McpIntegration", + "McpToolError", + "NotEnabled", + "make_acp_mcp_skill", +} def __getattr__(name: str) -> Any: # noqa: D401 - module-level lazy attr hook diff --git a/prime-agent-runtime/src/rlm/mcp_base.py b/prime-agent-runtime/src/rlm/mcp_base.py index 740df3e99e9..a7773e6bf01 100644 --- a/prime-agent-runtime/src/rlm/mcp_base.py +++ b/prime-agent-runtime/src/rlm/mcp_base.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio +import inspect import json import os import time @@ -26,7 +27,13 @@ from . import host_request -__all__ = ["McpIntegration", "McpToolError", "NotEnabled"] +__all__ = [ + "AcpMcpIntegration", + "McpIntegration", + "McpToolError", + "NotEnabled", + "make_acp_mcp_skill", +] # Stored access tokens are treated as expired this many seconds early so a token # never dies mid-request. Mirrors the host's refresh buffer. @@ -109,6 +116,34 @@ def _resolve_streamable_http(): ) +async def _open_http_session( + stack: AsyncExitStack, url: str, headers: dict[str, str] +): + """Open and initialize one streamable-HTTP MCP session.""" + from mcp import ClientSession # noqa: PLC0415 + + transport = _resolve_streamable_http() + params = inspect.signature(transport).parameters + if "headers" in params: + cm = transport(url, headers=headers or None) + elif "http_client" in params: + import httpx # noqa: PLC0415 + + client = await stack.enter_async_context( + httpx.AsyncClient(headers=headers or None) + ) + cm = transport(url, http_client=client) + else: + raise RuntimeError( + f"unsupported mcp streamable-HTTP client signature: {tuple(params)}" + ) + + read, write, *_ = await stack.enter_async_context(cm) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + return session + + class McpIntegration: """Subclass and set :attr:`server` (and :attr:`url` for remote servers). @@ -210,38 +245,15 @@ async def _open_session(self, stack: AsyncExitStack): streamable HTTP with a Bearer token from auth.json. The URL comes from the host (mcpServers override) when available, else ``self.url``. """ - import inspect # noqa: PLC0415 - - from mcp import ClientSession # noqa: PLC0415 - url, extra_headers = await self._resolve_config() if not url: raise ValueError( f"{type(self).__name__} must set `url` or override `_open_session`" ) token = await self._resolve_token() - transport = _resolve_streamable_http() # Extra configured headers first, Authorization last so it always wins. auth_header = {**extra_headers, "Authorization": f"Bearer {token}"} - - # SDK signatures vary: some take headers=, others only http_client=. - params = inspect.signature(transport).parameters - if "headers" in params: - cm = transport(url, headers=auth_header) - elif "http_client" in params: - import httpx # noqa: PLC0415 - - client = await stack.enter_async_context(httpx.AsyncClient(headers=auth_header)) - cm = transport(url, http_client=client) - else: - raise RuntimeError( - f"unsupported mcp streamable-HTTP client signature: {tuple(params)}" - ) - - read, write, *_ = await stack.enter_async_context(cm) - session = await stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - return session + return await _open_http_session(stack, url, auth_header) # -- tools -------------------------------------------------------------- @@ -303,6 +315,132 @@ async def _call(**kwargs: Any) -> Any: return _call +class AcpMcpIntegration(McpIntegration): + """An MCP integration supplied ephemerally by an ACP client.""" + + def __init__(self, server: str, config: dict[str, Any]) -> None: + self.server = server + self._acp_config = dict(config) + super().__init__() + + async def _open_session(self, stack: AsyncExitStack): + transport = self._acp_config.get("type") + if transport == "http": + url = self._acp_config.get("url") + headers = self._acp_config.get("headers", {}) + if not isinstance(url, str) or not url: + raise ValueError(f"ACP MCP server {self.server!r} has no HTTP URL") + if not isinstance(headers, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in headers.items() + ): + raise ValueError( + f"ACP MCP server {self.server!r} has invalid HTTP headers" + ) + return await _open_http_session(stack, url, headers) + + if transport == "stdio": + from mcp import ClientSession, StdioServerParameters # noqa: PLC0415 + from mcp.client.stdio import stdio_client # noqa: PLC0415 + + command = self._acp_config.get("command") + args = self._acp_config.get("args", []) + env = self._acp_config.get("env", {}) + if not isinstance(command, str) or not command: + raise ValueError( + f"ACP MCP server {self.server!r} has no stdio command" + ) + if not isinstance(args, list) or not all( + isinstance(value, str) for value in args + ): + raise ValueError( + f"ACP MCP server {self.server!r} has invalid stdio arguments" + ) + if not isinstance(env, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in env.items() + ): + raise ValueError( + f"ACP MCP server {self.server!r} has invalid stdio environment" + ) + streams = stdio_client( + StdioServerParameters(command=command, args=args, env=env) + ) + read, write = await stack.enter_async_context(streams) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + return session + + raise ValueError( + f"ACP MCP server {self.server!r} uses unsupported transport {transport!r}" + ) + + +_JSON_TO_PYTHON = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _mcp_tool_signature(schema: dict[str, Any]) -> inspect.Signature: + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + if not isinstance(properties, dict): + properties = {} + parameters = [] + for name, raw_property in properties.items(): + if not isinstance(name, str) or not name.isidentifier(): + continue + property_schema = raw_property if isinstance(raw_property, dict) else {} + parameters.append( + inspect.Parameter( + name, + inspect.Parameter.KEYWORD_ONLY, + default=(inspect.Parameter.empty if name in required else None), + annotation=_JSON_TO_PYTHON.get( + property_schema.get("type"), inspect.Parameter.empty + ), + ) + ) + parameters.sort( + key=lambda parameter: parameter.default is not inspect.Parameter.empty + ) + return inspect.Signature(parameters) + + +def make_acp_mcp_skill( + server: str, + config: dict[str, Any], + tool: dict[str, Any], +): + """Create one callable Python program for an eagerly discovered MCP tool.""" + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name: + raise ValueError("ACP MCP tool metadata has no non-empty name") + description = tool.get("description") + schema = tool.get("inputSchema") + if not isinstance(schema, dict): + schema = {} + integration = AcpMcpIntegration(server, config) + + async def run(**kwargs: Any) -> Any: + return await integration.call_tool(tool_name, kwargs) + + run.__name__ = tool_name + run.__qualname__ = tool_name + run.__signature__ = _mcp_tool_signature(schema) # type: ignore[attr-defined] + run.__doc__ = ( + description + if isinstance(description, str) and description + else f"MCP tool {tool_name!r} from server {server!r}." + ) + return run + + def _parse_result(result: Any) -> Any: """Normalize a CallToolResult into plain Python (structured output preferred). diff --git a/prime-agent-runtime/test/test_mcp_base.py b/prime-agent-runtime/test/test_mcp_base.py index 7f31c565bb8..c04a9bfe089 100644 --- a/prime-agent-runtime/test/test_mcp_base.py +++ b/prime-agent-runtime/test/test_mcp_base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import inspect import json import tempfile import time @@ -10,7 +11,13 @@ from unittest import mock from rlm import mcp_base -from rlm.mcp_base import McpIntegration, McpToolError, NotEnabled +from rlm.mcp_base import ( + AcpMcpIntegration, + McpIntegration, + McpToolError, + NotEnabled, + make_acp_mcp_skill, +) def _run(coro): @@ -269,6 +276,121 @@ async def host_empty(req_type, payload): self.assertEqual(url, _Integration.url) self.assertEqual(headers, {}) + def test_acp_http_server_uses_client_configuration_without_auth(self): + captured = {} + session = _FakeSession( + tools=[], + result=type("R", (), {"structuredContent": {"ok": True}})(), + ) + + async def fake_open(stack, url, headers): + captured.update(url=url, headers=headers) + return session + + integration = AcpMcpIntegration( + "task-tools", + { + "type": "http", + "url": "https://tools.example/mcp", + "headers": {"Authorization": "Bearer task", "X-Test": "1"}, + }, + ) + with mock.patch.object(mcp_base, "_open_http_session", fake_open): + result = _run(integration.call_tool("lookup", {"query": "ACP"})) + + self.assertEqual(result, {"ok": True}) + self.assertEqual(captured["url"], "https://tools.example/mcp") + self.assertEqual( + captured["headers"], + {"Authorization": "Bearer task", "X-Test": "1"}, + ) + self.assertEqual(session.calls, [("lookup", {"query": "ACP"})]) + + def test_acp_stdio_server_uses_client_command_arguments_and_environment(self): + captured = {} + + class _Streams: + async def __aenter__(self): + return ("read", "write") + + async def __aexit__(self, *args): + return False + + def fake_stdio_client(params): + captured["params"] = params + return _Streams() + + session = mock.MagicMock() + session.initialize = mock.AsyncMock() + session_cm = mock.MagicMock() + session_cm.__aenter__ = mock.AsyncMock(return_value=session) + session_cm.__aexit__ = mock.AsyncMock(return_value=False) + + integration = AcpMcpIntegration( + "local-tools", + { + "type": "stdio", + "command": "uvx", + "args": ["local-mcp", "--stdio"], + "env": {"TASK_TOKEN": "task-secret"}, + }, + ) + + async def open_session(): + async with AsyncExitStack() as stack: + return await integration._open_session(stack) + + with mock.patch("mcp.client.stdio.stdio_client", fake_stdio_client), mock.patch( + "mcp.ClientSession", return_value=session_cm + ): + opened = _run(open_session()) + + self.assertIs(opened, session) + params = captured["params"] + self.assertEqual(params.command, "uvx") + self.assertEqual(params.args, ["local-mcp", "--stdio"]) + self.assertEqual(params.env, {"TASK_TOKEN": "task-secret"}) + session.initialize.assert_awaited_once() + + def test_acp_server_rejects_unsupported_transport(self): + integration = AcpMcpIntegration("task-tools", {"type": "sse"}) + with self.assertRaisesRegex(ValueError, "unsupported transport"): + _run(integration._open_session(AsyncExitStack())) + + def test_acp_generated_skill_has_schema_signature_and_fixed_tool(self): + tool = { + "name": "lookup", + "description": "Look up a task.", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + "not-valid": {"type": "boolean"}, + }, + "required": ["query"], + }, + } + run = make_acp_mcp_skill( + "task-tools", + {"type": "http", "url": "https://tools.example/mcp", "headers": {}}, + tool, + ) + self.assertEqual( + str(inspect.signature(run)), "(*, query: str, limit: int = None)" + ) + self.assertEqual(run.__doc__, "Look up a task.") + + with mock.patch.object( + AcpMcpIntegration, + "call_tool", + mock.AsyncMock(return_value={"ok": True}), + ) as call_tool: + result = _run(run(query="ACP", limit=2)) + + self.assertEqual(result, {"ok": True}) + call_tool.assert_awaited_once_with("lookup", {"query": "ACP", "limit": 2}) + if __name__ == "__main__": unittest.main() From cde5c8cadbe55fc8a626595b3b0daa30940fa868 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 10:29:11 -0700 Subject: [PATCH 2/9] feat(coding-agent): port ACP MCP servers to kernel runtime --- .../.changes/1378-acp-mcp-programs.md | 1 + packages/coding-agent/docs/acp.md | 25 +- .../coding-agent/src/core/agent-session.ts | 16 + .../src/core/mcp/acp-mcp-types.ts | 15 + .../coding-agent/src/core/mcp/mcp-manager.ts | 33 +- .../coding-agent/src/core/resource-loader.ts | 18 - .../coding-agent/src/core/system-prompt.ts | 2 +- .../coding-agent/src/modes/acp/acp-mcp.ts | 308 +++++------------- .../coding-agent/src/modes/acp/acp-mode.ts | 46 ++- .../daemon-agent-connection.ts | 16 +- .../in-process-agent-connection.ts | 9 +- .../src/modes/agent-connection/types.ts | 5 +- .../src/modes/daemon/daemon-mode.ts | 8 +- .../src/modes/daemon/daemon-protocol.ts | 17 +- .../src/modes/daemon/daemon-supervisor.ts | 2 +- packages/coding-agent/test/acp-mcp.test.ts | 234 ++++++------- .../test/agent-connection-daemon.test.ts | 44 +-- .../test/agent-session-services.test.ts | 19 +- .../coding-agent/test/daemon-protocol.test.ts | 18 +- .../coding-agent/test/mcp-manager.test.ts | 40 +++ .../coding-agent/test/system-prompt.test.ts | 2 +- prime-agent-runtime/src/rlm/__init__.py | 10 +- prime-agent-runtime/src/rlm/mcp.py | 10 +- prime-agent-runtime/test/test_mcp.py | 61 +++- prime-agent-runtime/test/test_mcp_base.py | 124 +------ 25 files changed, 480 insertions(+), 603 deletions(-) create mode 100644 packages/coding-agent/.changes/1378-acp-mcp-programs.md create mode 100644 packages/coding-agent/src/core/mcp/acp-mcp-types.ts diff --git a/packages/coding-agent/.changes/1378-acp-mcp-programs.md b/packages/coding-agent/.changes/1378-acp-mcp-programs.md new file mode 100644 index 00000000000..c6b8a790fb6 --- /dev/null +++ b/packages/coding-agent/.changes/1378-acp-mcp-programs.md @@ -0,0 +1 @@ +- Added session-scoped ACP MCP servers through the kernel MCP program API ([#1378](https://github.com/PrimeIntellect-ai/prime-agent/pull/1378) by [@hallerite](https://github.com/hallerite)). diff --git a/packages/coding-agent/docs/acp.md b/packages/coding-agent/docs/acp.md index 96ca51cf8dc..49ea582a8b0 100644 --- a/packages/coding-agent/docs/acp.md +++ b/packages/coding-agent/docs/acp.md @@ -30,19 +30,26 @@ Likewise `session/prompt` refuses a concurrent turn while one is running, and th ## MCP servers -Prime Agent accepts both standard stdio servers and negotiated HTTP servers in -`session/new.mcpServers`. It discovers their tools before creating the session and installs one -session-scoped Python program per tool in the RLM kernel. A server named `task-tools` with a -`lookup` tool is available as `task_tools_lookup`; its signature is derived from the tool's JSON -Schema and it can be called directly: +Prime Agent accepts standard stdio and HTTP servers in `session/new.mcpServers`. The servers are +available through the pre-imported `mcp` Python program for that ACP session: ```python -result = await task_tools_lookup(query="example") +tools = await mcp.list_tools("task-tools") +result = await mcp.call_tool("task-tools", "lookup", {"query": "example"}) ``` -These programs use only the transport and credentials supplied by the ACP client. They neither -read nor modify the user's persistent MCP connection settings, and they are replaced when the -connection creates a new ACP session. +HTTP requests use only the URL and headers supplied by the ACP client. They do not read +`auth.json`, start or refresh Prime Agent OAuth, or modify persistent MCP settings. Stdio servers +run with the agent's actual session cwd, the supplied command and arguments, a scrubbed base +environment, and the exact environment values supplied by the ACP client. + +The configuration is removed when the ACP session closes or the client disconnects. A same-named +persistent MCP server can therefore be shadowed for the ACP session without sending its stored +OAuth credential to the client-supplied HTTP endpoint. + +ACP stdio is a trusted-code boundary, not a sandbox. The requested command runs as the Prime Agent +user and can access any files that user can access, including credential stores. Only accept stdio +servers from trusted ACP clients or run Prime Agent inside an appropriate sandbox. ## Streamed updates diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ef75fe91d75..4183b492473 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -156,6 +156,7 @@ import { } from "./goals.js"; import type { HostRequestHandlers, KernelSentAgentMessage } from "./kernel/index.js"; import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js"; +import type { AcpMcpServerConfig } from "./mcp/acp-mcp-types.js"; import type { McpManager } from "./mcp/mcp-manager.js"; import { type BashExecutionMessage, @@ -1303,6 +1304,21 @@ export class AgentSession { this.agent.state.systemPrompt = this._baseSystemPrompt; } + replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): void { + if (this.isStreaming) throw new Error("Cannot replace ACP MCP servers while the agent is running"); + if (!this._mcpManager) { + if (servers.length > 0) throw new Error("MCP is unavailable in this session"); + return; + } + if (!this._mcpManager.replaceAcpServers(servers)) return; + this._buildRuntime({ + activeToolNames: this.getActiveToolNames(), + includeAllExtensionTools: true, + }); + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + get modelRegistry(): ModelRegistry { return this._modelRegistry; } diff --git a/packages/coding-agent/src/core/mcp/acp-mcp-types.ts b/packages/coding-agent/src/core/mcp/acp-mcp-types.ts new file mode 100644 index 00000000000..e4d02220601 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/acp-mcp-types.ts @@ -0,0 +1,15 @@ +export type AcpMcpServerConfig = + | { + name: string; + type: "http"; + url: string; + headers: Record; + } + | { + name: string; + type: "stdio"; + command: string; + args: string[]; + cwd: string; + env: Record; + }; diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index fdf82204633..86f23f77d9d 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -10,6 +10,7 @@ import { import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/pi-ai/oauth"; import type { AuthStorage } from "../auth-storage.js"; import type { McpServerConfig } from "../settings-manager.js"; +import type { AcpMcpServerConfig } from "./acp-mcp-types.js"; export interface McpManagerOptions { authStorage: AuthStorage; @@ -36,6 +37,7 @@ export class McpManager { private readonly getUserServers: () => Record | undefined; private readonly beginLogin?: (server: string) => Promise; private integrations = new Map(); + private acpServers = new Map(); /** Provider ids we registered for user servers, so refresh can drop removed ones. */ private registeredUserProviderIds = new Set(); @@ -53,6 +55,22 @@ export class McpManager { this.registerProviders(); } + replaceAcpServers(servers: readonly AcpMcpServerConfig[]): boolean { + const next = new Map(); + for (const server of servers) { + if (next.has(server.name)) throw new Error(`Duplicate ACP MCP server: ${server.name}`); + next.set(server.name, server); + } + const unchanged = + next.size === this.acpServers.size && + Array.from(next).every( + ([name, config]) => JSON.stringify(this.acpServers.get(name)) === JSON.stringify(config), + ); + if (unchanged) return false; + this.acpServers = next; + return true; + } + private providerId(server: string): string { return `mcp:${server}`; } @@ -151,6 +169,7 @@ export class McpManager { "mcp.refresh": async (payload) => { const server = String(payload.server ?? ""); if (!server) throw new Error("mcp.refresh requires a server"); + if (this.acpServers.has(server)) throw new Error(`ACP MCP server ${server} does not use host OAuth`); // getApiKey refreshes + rewrites auth.json under lock; Python re-reads. // Surface failure (throw) instead of a false success so the kernel can // report a refresh error rather than a misleading "not enabled". @@ -163,6 +182,11 @@ export class McpManager { "mcp.config": async (payload) => { const server = String(payload.server ?? ""); if (!server) throw new Error("mcp.config requires a server"); + const acpServer = this.acpServers.get(server); + if (acpServer) { + const { name: _name, ...config } = acpServer; + return { ...config, credentialSource: "acp" }; + } const integration = this.integrations.get(server); if (!integration?.userDeclared || getCatalogEntry(server)) return {}; return { ...integration.config }; @@ -182,9 +206,9 @@ export class McpManager { return handlers; } - /** Enabled user servers available through the generic kernel API. */ + /** Enabled persistent and session-scoped servers available through the generic kernel API. */ getEnabledGenericServers(): string[] { - return Array.from(this.integrations.values()) + const servers = Array.from(this.integrations.values()) .filter( (integration) => integration.userDeclared && @@ -192,8 +216,9 @@ export class McpManager { !getCatalogEntry(integration.server) && this.isAuthed(integration), ) - .map((integration) => integration.server) - .sort((left, right) => left.localeCompare(right)); + .map((integration) => integration.server); + for (const server of this.acpServers.keys()) servers.push(server); + return [...new Set(servers)].sort((left, right) => left.localeCompare(right)); } /** Status for the /mcp list command. */ diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 90372e89241..5cb03a4bf5a 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -35,7 +35,6 @@ export interface ResourceLoader { getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; extendResources(paths: ResourceExtensionPaths): void; - replaceSkillResources?(paths: Array<{ path: string; metadata: PathMetadata }>, source: string): void; reload(): Promise; } @@ -334,23 +333,6 @@ export class DefaultResourceLoader implements ResourceLoader { } } - replaceSkillResources(paths: Array<{ path: string; metadata: PathMetadata }>, source: string): void { - const skillPaths = this.normalizeExtensionPaths(paths); - this.lastSkillPaths = this.lastSkillPaths.filter((path) => { - if (this.extensionSkillSourceInfos.get(path)?.source !== source) return true; - this.extensionSkillSourceInfos.delete(path); - return false; - }); - for (const entry of skillPaths) { - this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); - } - this.lastSkillPaths = this.mergePaths( - this.lastSkillPaths, - skillPaths.map((entry) => entry.path), - ); - this.updateSkillsFromPaths(this.lastSkillPaths); - } - async reload(): Promise { await this.settingsManager.reload(); const resolvedPaths = await this.packageManager.resolve(); diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index d68d5f51e45..e274a34ef70 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -187,7 +187,7 @@ function formatGenericMcpGuidance(servers: string[] | undefined): string { "# Generic MCP Connections", "", "Generic MCP connections are accessed through the pre-imported Python `mcp` object in IPython, not as top-level native tool namespaces or installed Python skills.", - `Enabled user-configured generic MCP servers: ${enabledServers.map((server) => `\`${server}\``).join(", ")}.`, + `Enabled generic MCP servers: ${enabledServers.map((server) => `\`${server}\``).join(", ")}.`, ...enabledServers.map( (server) => `For \`${server}\`, first discover its tools with \`await mcp.list_tools("${server}")\`, then call one with \`await mcp.call_tool("${server}", "", arguments)\`.`, diff --git a/packages/coding-agent/src/modes/acp/acp-mcp.ts b/packages/coding-agent/src/modes/acp/acp-mcp.ts index 5db5374a574..f68a87f7c03 100644 --- a/packages/coding-agent/src/modes/acp/acp-mcp.ts +++ b/packages/coding-agent/src/modes/acp/acp-mcp.ts @@ -1,255 +1,95 @@ -import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { validateHeaderName, validateHeaderValue } from "node:http"; import type { McpServer } from "@agentclientprotocol/sdk"; import { RequestError } from "@agentclientprotocol/sdk"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; - -interface TemporarySkillHost { - replaceTemporarySkills(skillPaths: string[], source: string): Promise | void; - getSkillNames?(): Promise; -} - -type AcpMcpServerConfig = - | { type: "http"; url: string; headers: Record } - | { type: "stdio"; command: string; args: string[]; env: Record }; - -type AcpMcpTool = Pick; -type DiscoverTools = (server: string, config: AcpMcpServerConfig, cwd: string) => Promise; - -interface DiscoveredSkill { - server: string; - config: AcpMcpServerConfig; - tool: AcpMcpTool; - importName: string; +import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js"; + +const SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; + +function entries( + server: string, + label: "header" | "environment", + values: readonly { name: string; value: string }[], +): Record { + const result = Object.create(null) as Record; + const seen = new Set(); + for (const entry of values) { + if (!entry.name) { + throw RequestError.invalidParams({ reason: `MCP server ${server} has an empty ${label} name` }); + } + const identity = label === "header" ? entry.name.toLowerCase() : entry.name; + if (seen.has(identity)) { + throw RequestError.invalidParams({ + reason: `MCP server ${server} has duplicate ${label} ${entry.name}`, + }); + } + if (label === "header") { + try { + validateHeaderName(entry.name); + validateHeaderValue(entry.name, entry.value); + } catch { + throw RequestError.invalidParams({ reason: `MCP server ${server} has an invalid HTTP header` }); + } + } else if (entry.name.includes("=") || entry.name.includes("\0") || entry.value.includes("\0")) { + throw RequestError.invalidParams({ reason: `MCP server ${server} has an invalid environment entry` }); + } + seen.add(identity); + result[entry.name] = entry.value; + } + return result; } -function resolveServers(servers: readonly McpServer[]): Record { - const resolved = Object.create(null) as Record; - for (const server of servers) { - if (Object.hasOwn(resolved, server.name)) { - throw RequestError.invalidParams({ reason: `duplicate MCP server name: ${server.name}` }); +export function resolveAcpMcpServers(servers: readonly McpServer[], cwd: string): AcpMcpServerConfig[] { + const names = new Set(); + return servers.map((server) => { + if (!SERVER_NAME_PATTERN.test(server.name)) { + throw RequestError.invalidParams({ + reason: + "MCP server names must start with an alphanumeric character and contain at most 64 alphanumeric, underscore, or hyphen characters", + }); } - if (!server.name) { - throw RequestError.invalidParams({ reason: "MCP server names must not be empty" }); + if (names.has(server.name)) { + throw RequestError.invalidParams({ reason: `duplicate MCP server name: ${server.name}` }); } + names.add(server.name); + if ("command" in server) { if (!server.command) { throw RequestError.invalidParams({ reason: `MCP server ${server.name} has no stdio command` }); } - resolved[server.name] = { + if (server.command.includes("\0") || server.args.some((argument) => argument.includes("\0"))) { + throw RequestError.invalidParams({ reason: `MCP server ${server.name} has an invalid stdio command` }); + } + return { + name: server.name, type: "stdio", command: server.command, args: [...server.args], - env: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), - }; - continue; - } - if (server.type === "http") { - if (!server.url) { - throw RequestError.invalidParams({ reason: `MCP server ${server.name} has no HTTP URL` }); - } - resolved[server.name] = { - type: "http", - url: server.url, - headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), + cwd, + env: entries(server.name, "environment", server.env), }; - continue; - } - throw RequestError.invalidParams({ - reason: `MCP server ${server.name} uses unsupported ${server.type} transport`, - }); - } - return resolved; -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function discoverServerTools(server: string, config: AcpMcpServerConfig, cwd: string): Promise { - const client = new Client({ name: "prime-agent-acp", version: "1.0.0" }); - try { - const transport = - config.type === "http" - ? new StreamableHTTPClientTransport(new URL(config.url), { - requestInit: { headers: config.headers }, - }) - : new StdioClientTransport({ - command: config.command, - args: config.args, - env: { ...getDefaultEnvironment(), ...config.env }, - cwd, - }); - await client.connect(transport); - const result = await client.listTools(); - return result.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - })); - } catch (error) { - throw RequestError.invalidParams({ - reason: `failed to discover tools from MCP server ${server}: ${errorText(error)}`, - }); - } finally { - await client.close().catch(() => undefined); - } -} - -function skillImportName(server: string, tool: string): string { - const normalized = `${server}_${tool}`.replace(/\W/g, "_"); - return /^\d/.test(normalized) ? `_${normalized}` : normalized; -} - -function skillDirectoryName(importName: string): string { - return importName.replaceAll("_", "-"); -} - -function pythonModule(server: string, config: AcpMcpServerConfig, tool: AcpMcpTool): string { - const encodedConfig = JSON.stringify(JSON.stringify(config)); - const encodedTool = JSON.stringify(JSON.stringify(tool)); - return `"""ACP MCP tool generated for this session.""" - -import json - -from rlm import make_acp_mcp_skill - -run = make_acp_mcp_skill( - ${JSON.stringify(server)}, - json.loads(${encodedConfig}), - json.loads(${encodedTool}), -) -`; -} - -function skillMarkdown(skill: DiscoveredSkill): string { - const summary = skill.tool.description?.split("\n", 1)[0] || `Call ${skill.tool.name} on ${skill.server}.`; - const description = `MCP tool ${skill.server}/${skill.tool.name}: ${summary}`.slice(0, 1024); - const skillName = skillDirectoryName(skill.importName); - return `--- -name: ${JSON.stringify(skillName)} -description: ${JSON.stringify(description)} ---- - -# ${skill.importName} - -This session-scoped skill calls MCP tool ${JSON.stringify(skill.tool.name)} on server -${JSON.stringify(skill.server)}. It is pre-imported in IPython and callable directly: - -\`\`\`python -await ${skill.importName}(...) -\`\`\` - -Use \`help(${skill.importName})\` or \`inspect.signature(${skill.importName})\` for its -description and schema-derived keyword arguments. -`; -} - -function pyproject(skill: DiscoveredSkill): string { - const digest = createHash("sha256") - .update(JSON.stringify([skill.server, skill.config, skill.tool])) - .digest("hex") - .slice(0, 16); - return `[project] -name = "prime-agent-acp-mcp-${digest}" -version = "0.0.0" -requires-python = ">=3.10" -dependencies = ["mcp>=1.28,<2", "httpx>=0.27,<1"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/${skill.importName}"] -`; -} - -export class AcpMcpSkillInstaller { - private root: string | undefined; - private installedSkillNames = new Set(); - - constructor( - private readonly host: TemporarySkillHost, - private readonly discoverTools: DiscoverTools = discoverServerTools, - ) {} - - async configure(servers: readonly McpServer[], cwd: string): Promise { - const config = resolveServers(servers); - const discovered: DiscoveredSkill[] = []; - const names = new Map(); - for (const [server, spec] of Object.entries(config)) { - const tools = await this.discoverTools(server, spec, cwd); - for (const tool of tools) { - if (!tool.name) { - throw RequestError.invalidParams({ reason: `MCP server ${server} returned a tool with an empty name` }); - } - const importName = skillImportName(server, tool.name); - const skillName = skillDirectoryName(importName); - if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(skillName)) { - throw RequestError.invalidParams({ - reason: `MCP tool ${server}/${tool.name} produces invalid Python skill name ${skillName}`, - }); - } - const prior = names.get(importName); - if (prior) { - throw RequestError.invalidParams({ - reason: - `MCP tools ${prior.server}/${prior.tool} and ${server}/${tool.name} ` + - `normalize to the same Python skill ${importName}`, - }); - } - names.set(importName, { server, tool: tool.name }); - discovered.push({ server, config: spec, tool, importName }); - } - } - - const existingSkillNames = new Set((await this.host.getSkillNames?.()) ?? []); - for (const installedName of this.installedSkillNames) existingSkillNames.delete(installedName); - for (const skill of discovered) { - const skillName = skillDirectoryName(skill.importName); - if (existingSkillNames.has(skillName)) { - throw RequestError.invalidParams({ - reason: `MCP tool ${skill.server}/${skill.tool.name} conflicts with existing skill ${skillName}`, - }); - } } - const nextRoot = discovered.length > 0 ? mkdtempSync(join(tmpdir(), "prime-agent-acp-mcp-")) : undefined; - const skillPaths: string[] = []; - for (const skill of discovered) { - const skillDir = join(nextRoot!, skillDirectoryName(skill.importName)); - const packageDir = join(skillDir, "src", skill.importName); - mkdirSync(packageDir, { recursive: true }); - const skillPath = join(skillDir, "SKILL.md"); - writeFileSync(skillPath, skillMarkdown(skill), { encoding: "utf-8", mode: 0o600 }); - writeFileSync(join(skillDir, "pyproject.toml"), pyproject(skill), { encoding: "utf-8", mode: 0o600 }); - writeFileSync(join(packageDir, "__init__.py"), pythonModule(skill.server, skill.config, skill.tool), { - encoding: "utf-8", - mode: 0o600, + if (server.type !== "http") { + throw RequestError.invalidParams({ + reason: `MCP server ${server.name} uses unsupported ${server.type} transport`, }); - skillPaths.push(skillPath); } + let url: URL; try { - await this.host.replaceTemporarySkills(skillPaths, "acp:mcp"); - } catch (error) { - if (nextRoot) rmSync(nextRoot, { recursive: true, force: true }); - throw error; + url = new URL(server.url); + } catch { + throw RequestError.invalidParams({ reason: `MCP server ${server.name} has an invalid HTTP URL` }); } - if (this.root) rmSync(this.root, { recursive: true, force: true }); - this.root = nextRoot; - this.installedSkillNames = new Set(discovered.map((skill) => skillDirectoryName(skill.importName))); - } - - dispose(): void { - if (!this.root) return; - rmSync(this.root, { recursive: true, force: true }); - this.root = undefined; - this.installedSkillNames.clear(); - } + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) { + throw RequestError.invalidParams({ + reason: `MCP server ${server.name} must use an HTTP(S) URL without embedded credentials`, + }); + } + return { + name: server.name, + type: "http", + url: url.toString(), + headers: entries(server.name, "header", server.headers), + }; + }); } diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 988dac32289..68c484656f2 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -18,6 +18,7 @@ import type { } from "../agent-connection/types.js"; import { latestAutonomousGateAttempt } from "../headless-completion.js"; import { type AcpEventMappingState, acpUpdatesForSessionEvent } from "./acp-events.js"; +import { resolveAcpMcpServers } from "./acp-mcp.js"; import { PRIME_AGENT_META_NAMESPACE, type PrimeAgentAutonomousMeta, primeAgentMeta } from "./acp-meta.js"; import { type AcpStopReason, acpStopReason } from "./acp-stop-reason.js"; @@ -466,6 +467,17 @@ export async function runAcpModeWithConnection( if (options.ownStdout !== false && !options.stream) { takeOverStdout(); } + const supportsMcpServers = + connection.supportsAcpMcpServers?.() === true && connection.replaceAcpMcpServers !== undefined; + let hasAcpMcpServers = false; + const replaceAcpMcpServers = async (servers: readonly acp.McpServer[], cwd: string): Promise => { + if (servers.length === 0 && !hasAcpMcpServers) return; + if (!supportsMcpServers || !connection.replaceAcpMcpServers) { + throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); + } + await connection.replaceAcpMcpServers(resolveAcpMcpServers(servers, cwd)); + hasAcpMcpServers = servers.length > 0; + }; // One ACP connection drives one AgentConnection, whose newSession() replaces // the live session rather than creating a parallel one. Tracking a single @@ -648,6 +660,7 @@ export async function runAcpModeWithConnection( agentCapabilities: { loadSession: false, promptCapabilities: { image: true, embeddedContext: true }, + ...(supportsMcpServers ? { mcpCapabilities: { http: true } } : {}), // Advertise close so a client knows it can release the session (and // the single-session slot) instead of dropping the connection. sessionCapabilities: { close: {} }, @@ -669,6 +682,11 @@ export async function runAcpModeWithConnection( } sessionNewInFlight = true; try { + const params = ctx.params as acp.NewSessionRequest; + const mcpServers = params.mcpServers ?? []; + if (mcpServers.length > 0 && !supportsMcpServers) { + throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); + } if (!bound) { // Only latch after a successful bind: a rejected bind must not leave // extensions permanently unavailable for the rest of the process. @@ -679,16 +697,23 @@ export async function runAcpModeWithConnection( // with, so a client-supplied cwd cannot be adopted after the fact. // Report the real cwd back in `_meta` rather than failing the request or // letting the client assume a directory the agent is not using. - const requestedCwd = (ctx.params as { cwd?: unknown } | undefined)?.cwd; + const requestedCwd = params.cwd; + const actualCwd = await connection + .getState() + .then((state) => state.cwd) + .catch(() => undefined); + if (!actualCwd && mcpServers.some((server) => "command" in server)) { + throw acp.RequestError.invalidParams({ reason: "Could not resolve the ACP session cwd for stdio MCP" }); + } + await replaceAcpMcpServers(mcpServers, actualCwd ?? ""); let cwdMismatch: { requested: string; actual: string } | undefined; - if (typeof requestedCwd === "string" && requestedCwd.length > 0) { - const actual = await connection - .getState() - .then((state) => state.cwd) - .catch(() => undefined); - if (actual && !sameCwd(requestedCwd, actual)) { - cwdMismatch = { requested: requestedCwd, actual }; - } + if ( + typeof requestedCwd === "string" && + requestedCwd.length > 0 && + actualCwd && + !sameCwd(requestedCwd, actualCwd) + ) { + cwdMismatch = { requested: requestedCwd, actual: actualCwd }; } const sessionId = randomUUID(); // Install the listener before fetching the snapshot. Child updates can arrive @@ -764,6 +789,7 @@ export async function runAcpModeWithConnection( } catch (error) { producer.failSessionNewAdmission(); unsubscribe(); + await replaceAcpMcpServers([], actualCwd ?? "").catch(() => undefined); throw error; } // Claim the single-session slot only once the subscription and snapshot are @@ -956,6 +982,7 @@ export async function runAcpModeWithConnection( closing.unsubscribe?.(); // Keep the backing session fenced until a replacement ACP session is admitted. await closing.producer.close(); + await replaceAcpMcpServers([], ""); closedInputPause = inputPause; closedInputPauseKey = inputPauseKey; if (closing.inputPause === inputPause) { @@ -1037,6 +1064,7 @@ export async function runAcpModeWithConnection( await closedInputPause?.release().catch(() => undefined); closedInputPause = undefined; closedInputPauseKey = undefined; + await replaceAcpMcpServers([], "").catch(() => undefined); await connection.dispose().catch(() => undefined); // Only the real stdio entrypoint owns the process; a caller-supplied transport // (tests, embedding) must never have its host exited from under it. diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 73a6f791afa..c6674d802d6 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -15,6 +15,7 @@ import type { AgentHeartbeatManagementAction, AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; +import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import { SessionAlreadyActiveError } from "../../core/session-lease.js"; @@ -499,15 +500,18 @@ export class DaemonAgentConnection implements AgentConnection { }); } - async replaceTemporarySkills(skillPaths: string[], source: string): Promise { - if (!this.client.supportsServerCapability("temporary_skills")) { - throw new DaemonCapabilityUnavailableError("replace_temporary_skills", "temporary_skills"); + supportsAcpMcpServers(): boolean { + return this.client.supportsServerCapability("acp_mcp_servers"); + } + + async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): Promise { + if (!this.supportsAcpMcpServers()) { + throw new DaemonCapabilityUnavailableError("replace_acp_mcp_servers", "acp_mcp_servers"); } await this.requestOk({ - type: "replace_temporary_skills", + type: "replace_acp_mcp_servers", activeSessionId: this.activeSessionId, - skillPaths, - source, + servers: [...servers], }); } diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index d6c81fc03f1..66d9720169e 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -14,6 +14,7 @@ import type { AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; import type { ExtensionUIContext } from "../../core/extensions/types.js"; +import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import { type DeleteSessionFileResult, deleteSessionFile } from "../../core/session-file-actions.js"; import { SessionManager } from "../../core/session-manager.js"; @@ -110,8 +111,12 @@ export class InProcessAgentConnection implements AgentConnection { await this.bindCurrentSessionExtensions(); } - async replaceTemporarySkills(skillPaths: string[], source: string): Promise { - this.runtimeHost.session.replaceTemporarySkills(skillPaths, source); + supportsAcpMcpServers(): boolean { + return true; + } + + async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): Promise { + this.runtimeHost.session.replaceAcpMcpServers(servers); } subscribe(listener: AgentConnectionEventListener): () => void { diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 9186c18582e..b36feb8871b 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -16,6 +16,7 @@ import type { ReplayBuiltInToolName } from "../../core/extensions/index.js"; import type { InputSource } from "../../core/extensions/types.js"; import type { GoalState } from "../../core/goals.js"; import type { KernelSentAgentMessage } from "../../core/kernel/index.js"; +import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { RlmMaxDepthStatus, SetRlmMaxDepthResult } from "../../core/rlm-max-depth.js"; import type { @@ -691,8 +692,8 @@ export interface AgentConnection { getToolDefinition(name: string): Promise; setSessionEntryLabel(entryId: string, label: string | undefined): Promise; respondToExtensionUiRequest(requestId: string, response: AgentConnectionExtensionUiResponse): Promise; - /** Replace one source's process-local Python skills before a headless turn starts. */ - replaceTemporarySkills?(skillPaths: string[], source: string): Promise; + supportsAcpMcpServers?(): boolean; + replaceAcpMcpServers?(servers: readonly AcpMcpServerConfig[]): Promise; prompt(message: string, options?: AgentConnectionPromptOptions): Promise; promptAndWait(message: string, options?: AgentConnectionPromptOptions): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 7437c09471c..d2b670f2732 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -281,7 +281,7 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "get_context_tree", "get_commands", "get_resource_snapshot", - "replace_temporary_skills", + "replace_acp_mcp_servers", "get_model_catalog", "get_available_models", "get_queue", @@ -4518,12 +4518,12 @@ export class AgentDaemon { ); } - case "replace_temporary_skills": { + case "replace_acp_mcp_servers": { const state = this.getSessionState(command.activeSessionId); await withClientEnv(state.clientEnv, async () => - state.runtime.session.replaceTemporarySkills(command.skillPaths, command.source), + state.runtime.session.replaceAcpMcpServers(command.servers), ); - return success(command.id, "replace_temporary_skills"); + return success(command.id, "replace_acp_mcp_servers"); } case "get_available_models": { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 6f77aeb040c..f332a23a1ab 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -17,6 +17,7 @@ import type { AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; import type { InputSource } from "../../core/extensions/types.js"; +import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js"; import type { CustomMessage } from "../../core/messages.js"; import type { QueuedMessageLane, QueuedMessageMutation } from "../../core/session-action-store.js"; import type { SessionCwdIssue } from "../../core/session-cwd.js"; @@ -64,8 +65,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 18 adds the opt-in RLM quiescence barrier to headless completion. // Revision 19 adds daemon-held session input pauses. // Revision 20 lets cancellation target a prompt the session owns but has not started. -export const DAEMON_SCHEMA_REVISION = 20; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-20-ed994cc39507"; +// Revision 21 adds capability-gated, session-scoped ACP MCP server replacement. +export const DAEMON_SCHEMA_REVISION = 21; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-21-fe5aaf0f6262"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -109,7 +111,8 @@ export type DaemonServerCapability = | "owned_session_recovery_context" | "rlm_quiescence_barrier" | "session_input_pause" - | "owned_prompt_cancellation"; + | "owned_prompt_cancellation" + | "acp_mcp_servers"; export type DaemonReplayStatus = "complete" | "partial" | "unavailable"; @@ -153,6 +156,7 @@ export const DAEMON_DEFAULT_SERVER_CAPABILITIES: readonly DaemonServerCapability "owned_session_recovery_context", "rlm_quiescence_barrier", "session_input_pause", + "acp_mcp_servers", ]; export interface DaemonRuntimeIdentity { @@ -534,6 +538,12 @@ export type DaemonCommand = | { id?: string; type: "get_context_tree"; activeSessionId: string } | { id?: string; type: "get_commands"; activeSessionId: string } | { id?: string; type: "get_resource_snapshot"; activeSessionId: string } + | { + id?: string; + type: "replace_acp_mcp_servers"; + activeSessionId: string; + servers: AcpMcpServerConfig[]; + } | { id?: string; type: "get_model_catalog"; activeSessionId: string } | { id?: string; type: "get_available_models"; activeSessionId: string } | { id?: string; type: "get_queue"; activeSessionId: string } @@ -745,6 +755,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { get_context_tree: LEGACY_DAEMON_COMMAND, get_commands: LEGACY_DAEMON_COMMAND, get_resource_snapshot: LEGACY_DAEMON_COMMAND, + replace_acp_mcp_servers: { minProtocol: 7, minSchemaRevision: 21, capability: "acp_mcp_servers" }, get_model_catalog: { minProtocol: 7, capability: "model_catalog" }, get_available_models: LEGACY_DAEMON_COMMAND, get_queue: LEGACY_DAEMON_COMMAND, diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 678ad9303f8..b69a4ad1c20 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -208,7 +208,7 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "get_context_tree", "get_commands", "get_resource_snapshot", - "replace_temporary_skills", + "replace_acp_mcp_servers", "get_model_catalog", "get_available_models", "get_queue", diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts index d41801324b6..162851e8767 100644 --- a/packages/coding-agent/test/acp-mcp.test.ts +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -1,26 +1,11 @@ -import { once } from "node:events"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { createServer, type Server } from "node:http"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; import * as acp from "@agentclientprotocol/sdk"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.js"; -import { DefaultResourceLoader } from "../src/core/resource-loader.js"; -import { AcpMcpSkillInstaller } from "../src/modes/acp/acp-mcp.js"; +import { resolveAcpMcpServers } from "../src/modes/acp/acp-mcp.js"; import { runAcpModeWithConnection } from "../src/modes/acp/acp-mode.js"; import { InProcessAgentConnection } from "../src/modes/agent-connection/in-process-agent-connection.js"; import { createHarness } from "./suite/harness.js"; -const servers: Server[] = []; - -afterEach(async () => { - for (const server of servers.splice(0)) { - server.close(); - await once(server, "close"); - } -}); - function runtimeHostFor(session: unknown): AgentSessionRuntime { return { session, @@ -30,71 +15,16 @@ function runtimeHostFor(session: unknown): AgentSessionRuntime { } as unknown as AgentSessionRuntime; } -async function startHttpMcpServer(): Promise<{ url: string; authorization: () => string | undefined }> { - let authorization: string | undefined; - const server = createServer(async (request, response) => { - authorization = request.headers.authorization; - if (request.method === "DELETE") { - response.writeHead(200).end(); - return; - } - if (request.method !== "POST") { - response.writeHead(405).end(); - return; - } - const chunks: Buffer[] = []; - for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - const message = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { - id?: string | number; - method: string; - params?: { protocolVersion?: string }; - }; - if (message.id === undefined) { - response.writeHead(202).end(); - return; - } - const result = - message.method === "initialize" - ? { - protocolVersion: message.params?.protocolVersion ?? "2025-06-18", - capabilities: { tools: {} }, - serverInfo: { name: "test-tools", version: "1.0.0" }, - } - : { - tools: [ - { - name: "lookup", - description: "Look up a task by query.", - inputSchema: { - type: "object", - properties: { query: { type: "string" }, limit: { type: "integer" } }, - required: ["query"], - }, - }, - ], - }; - response.writeHead(200, { "Content-Type": "application/json" }); - response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); - }); - servers.push(server); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("test MCP server did not bind TCP"); - return { url: `http://127.0.0.1:${address.port}/mcp`, authorization: () => authorization }; -} - describe("ACP MCP servers", () => { - it("advertises HTTP support and configures session/new servers", async () => { + it("advertises capability, installs session-scoped config, and clears it on close", async () => { const harness = await createHarness(); + const replace = vi.spyOn(harness.session, "replaceAcpMcpServers").mockImplementation(() => undefined); const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); const agentCwd = (await connection.getState()).cwd; - const configureMcpServers = vi.fn(); const toAgent = new TransformStream(); const toClient = new TransformStream(); const modeDone = runAcpModeWithConnection(connection, { stream: acp.ndJsonStream(toClient.writable, toAgent.readable), - configureMcpServers, }); const handle = acp .client({ name: "mcp-test-client" }) @@ -106,14 +36,41 @@ describe("ACP MCP servers", () => { }); expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true); - const server: acp.McpServer = { - type: "http", - name: "task-tools", - url: "http://127.0.0.1:8000/mcp", - headers: [{ name: "Authorization", value: "Bearer task" }], - }; - await handle.agent.request("session/new", { cwd: harness.tempDir, mcpServers: [server] }); - expect(configureMcpServers).toHaveBeenCalledWith([server], agentCwd); + const created = await handle.agent.request("session/new", { + cwd: harness.tempDir, + mcpServers: [ + { + type: "http", + name: "TaskTools", + url: "https://task.example/mcp", + headers: [{ name: "Authorization", value: "Bearer task" }], + }, + { + name: "LocalTools", + command: "node", + args: ["server.js"], + env: [{ name: "TASK_TOKEN", value: "task-secret" }], + }, + ], + }); + expect(replace).toHaveBeenCalledWith([ + { + name: "TaskTools", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task" }, + }, + { + name: "LocalTools", + type: "stdio", + command: "node", + args: ["server.js"], + cwd: agentCwd, + env: { TASK_TOKEN: "task-secret" }, + }, + ]); + await handle.agent.request("session/close", { sessionId: created.sessionId }); + expect(replace).toHaveBeenLastCalledWith([]); } finally { handle.close(); await toAgent.writable.close().catch(() => undefined); @@ -122,70 +79,63 @@ describe("ACP MCP servers", () => { } }, 30_000); - it("discovers HTTP tools and writes one callable Python program per tool", async () => { - const mcp = await startHttpMcpServer(); - const replaceTemporarySkills = vi.fn(); - const installer = new AcpMcpSkillInstaller({ replaceTemporarySkills }); - try { - await installer.configure( + it("preserves stdio cwd and literal environment", () => { + const [server] = resolveAcpMcpServers( + [ + { + name: "TaskTools", + command: "node", + args: ["server.js"], + env: [{ name: "TASK_TOKEN", value: "secret" }], + }, + ], + "/actual/session", + ); + expect(server).toEqual({ + name: "TaskTools", + type: "stdio", + command: "node", + args: ["server.js"], + cwd: "/actual/session", + env: { TASK_TOKEN: "secret" }, + }); + }); + + it("rejects names that could inject prompt code and ambiguous credentials", () => { + expect(() => + resolveAcpMcpServers( + [{ type: "http", name: 'bad"\nname', url: "https://task.example/mcp", headers: [] }], + "/tmp", + ), + ).toThrow("Invalid params"); + expect(() => + resolveAcpMcpServers( [ { type: "http", - name: "task-tools", - url: mcp.url, - headers: [{ name: "Authorization", value: "Bearer task" }], + name: "task", + url: "https://user:password@task.example/mcp", + headers: [], }, ], - process.cwd(), - ); - - expect(mcp.authorization()).toBe("Bearer task"); - expect(replaceTemporarySkills).toHaveBeenCalledOnce(); - const [skillPaths] = replaceTemporarySkills.mock.calls[0] as [string[], string]; - expect(skillPaths).toHaveLength(1); - const skillDir = dirname(skillPaths[0]); - expect(basename(skillDir)).toBe("task-tools-lookup"); - expect(readFileSync(skillPaths[0], "utf-8")).toContain("await task_tools_lookup(...)"); - const source = readFileSync(join(skillDir, "src", "task_tools_lookup", "__init__.py"), "utf-8"); - expect(source).toContain("make_acp_mcp_skill"); - expect(source).toContain("Look up a task by query."); - } finally { - installer.dispose(); - } - }); - - it("replaces the previous ACP session's generated programs", async () => { - const resourceRoot = mkdtempSync(join(tmpdir(), "prime-agent-acp-mcp-replace-test-")); - const resourceLoader = new DefaultResourceLoader({ - cwd: resourceRoot, - agentDir: resourceRoot, - bundledSkillsDir: null, - }); - await resourceLoader.reload(); - const harness = await createHarness({ resourceLoader }); - const installer = new AcpMcpSkillInstaller(harness.session, async (server) => [ - { name: "lookup", description: `Look up data from ${server}.`, inputSchema: { type: "object" } }, - ]); - const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); - try { - await installer.configure( - [{ type: "http", name: "first", url: "https://first.test/mcp", headers: [] }], - harness.tempDir, - ); - expect((await connection.getResourceSnapshot()).skills.map((skill) => skill.name)).toContain("first-lookup"); - - await installer.configure( - [{ type: "http", name: "second", url: "https://second.test/mcp", headers: [] }], - harness.tempDir, - ); - const replaced = (await connection.getResourceSnapshot()).skills.map((skill) => skill.name); - expect(replaced).not.toContain("first-lookup"); - expect(replaced).toContain("second-lookup"); - } finally { - installer.dispose(); - await connection.dispose(); - harness.cleanup(); - rmSync(resourceRoot, { recursive: true, force: true }); - } + "/tmp", + ), + ).toThrow("Invalid params"); + expect(() => + resolveAcpMcpServers( + [ + { + type: "http", + name: "task", + url: "https://task.example/mcp", + headers: [ + { name: "Authorization", value: "one" }, + { name: "authorization", value: "two" }, + ], + }, + ], + "/tmp", + ), + ).toThrow("Invalid params"); }); }); diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 5989bc9e33d..3e7d5d3d905 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -193,7 +193,7 @@ class FakeDaemonClient { }, }, }; - case "replace_temporary_skills": + case "replace_acp_mcp_servers": return { type: "response", command: command.type, success: true }; case "get_model_catalog": return { @@ -1061,26 +1061,6 @@ describe("DaemonAgentConnection", () => { expect(sent).toMatchObject({ command: "ls", excludeFromContext: true, transient: true, runId: "side-run-1" }); }); - it("capability-gates temporary ACP skills on old daemons", async () => { - const oldDaemonClient = new FakeDaemonClient(); - const oldConnection = new DaemonAgentConnection(asDaemonClient(oldDaemonClient), "active-original"); - await expect(oldConnection.replaceTemporarySkills(["/tmp/task/SKILL.md"], "acp:mcp")).rejects.toThrow( - "does not support temporary_skills", - ); - expect(oldDaemonClient.requests).toEqual([]); - - const newDaemonClient = new FakeDaemonClient(); - newDaemonClient.serverCapabilities.add("temporary_skills"); - const newConnection = new DaemonAgentConnection(asDaemonClient(newDaemonClient), "active-original"); - await newConnection.replaceTemporarySkills(["/tmp/task/SKILL.md"], "acp:mcp"); - expect(newDaemonClient.requests.at(-1)).toMatchObject({ - type: "replace_temporary_skills", - activeSessionId: "active-original", - skillPaths: ["/tmp/task/SKILL.md"], - source: "acp:mcp", - }); - }); - it("degrades an unavailable heartbeat catalog without sending an unsupported command", async () => { const fakeClient = new FakeDaemonClient(); const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); @@ -2594,6 +2574,28 @@ describe("DaemonAgentConnection", () => { }); }); + it("capability-gates ACP MCP server replacement", async () => { + const fakeClient = new FakeDaemonClient(); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "session-1"); + expect(connection.supportsAcpMcpServers()).toBe(false); + await expect( + connection.replaceAcpMcpServers([ + { + name: "task", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task" }, + }, + ]), + ).rejects.toBeInstanceOf(DaemonCapabilityUnavailableError); + + fakeClient.serverCapabilities.add("acp_mcp_servers"); + expect(connection.supportsAcpMcpServers()).toBe(true); + await connection.replaceAcpMcpServers([]); + expect(fakeClient.requests.at(-1)).toMatchObject({ type: "replace_acp_mcp_servers", servers: [] }); + await connection.dispose(); + }); + it("loads the full model catalog through the daemon protocol", async () => { const fakeClient = new FakeDaemonClient(); fakeClient.serverCapabilities.add("model_catalog"); diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 0a3afa5deac..97b2914bba3 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -150,7 +150,7 @@ describe("createAgentSessionFromServices", () => { expect(initialPrompt).toContain( "Generic MCP connections are accessed through the pre-imported Python `mcp` object in IPython, not as top-level native tool namespaces or installed Python skills.", ); - expect(initialPrompt).toContain("Enabled user-configured generic MCP servers: `filesystem`, `zebra`."); + expect(initialPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); expect(initialPrompt).toContain('await mcp.list_tools("filesystem")'); expect(initialPrompt).toContain('await mcp.call_tool("filesystem", "", arguments)'); for (const hidden of [ @@ -166,14 +166,27 @@ describe("createAgentSessionFromServices", () => { ]) { expect(initialPrompt).not.toContain(hidden); } - expect(initialPrompt).not.toContain("Enabled user-configured generic MCP servers: `linear`"); + expect(initialPrompt).not.toContain("Enabled generic MCP servers: `linear`"); + + session.replaceAcpMcpServers([ + { + name: "task", + type: "http", + url: "https://task-secret.example/mcp", + headers: { Authorization: "Bearer task-secret" }, + }, + ]); + expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); + expect(session.systemPrompt).not.toContain("task-secret"); + session.replaceAcpMcpServers([]); + expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" }); settingsManager.removeGlobalMcpServer("filesystem"); await settingsManager.flush(); await session.reload(); - expect(session.systemPrompt).toContain("Enabled user-configured generic MCP servers: `added`, `zebra`."); + expect(session.systemPrompt).toContain("Enabled generic MCP servers: `added`, `zebra`."); expect(session.systemPrompt).toContain('await mcp.list_tools("added")'); expect(session.systemPrompt).not.toContain('await mcp.list_tools("filesystem")'); expect(session.systemPrompt).not.toContain("new-secret"); diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index c25a82fca64..1ccf31da19d 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -127,6 +127,15 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("delete_rlm_subagent"); }); + it("capability- and schema-gates ACP MCP server replacement", () => { + expect(DAEMON_COMMAND_COMPATIBILITY.replace_acp_mcp_servers).toEqual({ + minProtocol: 7, + minSchemaRevision: 21, + capability: "acp_mcp_servers", + }); + expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("acp_mcp_servers"); + }); + it("capability-gates the optional model catalog surface", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_model_catalog).toEqual({ minProtocol: 7, @@ -144,15 +153,6 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); - it("capability- and schema-gates temporary skill replacement", () => { - expect(DAEMON_COMMAND_COMPATIBILITY.replace_temporary_skills).toEqual({ - minProtocol: 7, - minSchemaRevision: 17, - capability: "temporary_skills", - }); - expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("temporary_skills"); - }); - it("schema-gates the RLM max depth commands at their introducing revision", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_rlm_max_depth_status).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); expect(DAEMON_COMMAND_COMPATIBILITY.set_rlm_max_depth).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); diff --git a/packages/coding-agent/test/mcp-manager.test.ts b/packages/coding-agent/test/mcp-manager.test.ts index 2518fa71084..56790d57b50 100644 --- a/packages/coding-agent/test/mcp-manager.test.ts +++ b/packages/coding-agent/test/mcp-manager.test.ts @@ -234,4 +234,44 @@ describe("McpManager", () => { expect(manager.getDisabledBuiltinSkillOverrides()).toContain("-linear/SKILL.md"); } }); + it("keeps ACP credentials session-scoped and isolated from stored OAuth", async () => { + authStorage.set("mcp:task", { + type: "oauth", + access: "stored-oauth-token", + refresh: "refresh", + expires: Date.now() + 3600_000, + endpoint: "https://user.example/mcp", + }); + const manager = new McpManager({ + authStorage, + getUserServers: () => ({ task: { type: "http", url: "https://user.example/mcp", oauth: true } }), + }); + expect( + manager.replaceAcpServers([ + { + name: "task", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task-token" }, + }, + ]), + ).toBe(true); + const handlers = manager.hostHandlers(); + expect(await handlers["mcp.config"]({ server: "task" })).toEqual({ + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task-token" }, + credentialSource: "acp", + }); + await expect(handlers["mcp.refresh"]({ server: "task" })).rejects.toThrow("does not use host OAuth"); + expect(manager.getEnabledGenericServers()).toContain("task"); + + expect(manager.replaceAcpServers([])).toBe(true); + expect(await handlers["mcp.config"]({ server: "task" })).toEqual({ + type: "http", + url: "https://user.example/mcp", + oauth: true, + }); + expect(authStorage.get("mcp:task")).toMatchObject({ access: "stored-oauth-token" }); + }); }); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 2fc164d54c3..4417eaa709a 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -218,7 +218,7 @@ describe("buildSystemPrompt", () => { genericMcpServers: ["zebra", "filesystem"], }); - expect(prompt).toContain("Enabled user-configured generic MCP servers: `filesystem`, `zebra`."); + expect(prompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); expect(prompt).toContain('await mcp.list_tools("filesystem")'); expect(prompt).toContain('await mcp.call_tool("filesystem", "", arguments)'); expect(prompt).toContain("not as top-level native tool namespaces or installed Python skills"); diff --git a/prime-agent-runtime/src/rlm/__init__.py b/prime-agent-runtime/src/rlm/__init__.py index b2efa6757da..1633374f29c 100644 --- a/prime-agent-runtime/src/rlm/__init__.py +++ b/prime-agent-runtime/src/rlm/__init__.py @@ -320,7 +320,6 @@ async def __call__(self, prompt: str, **kwargs: Any) -> RLMSpawnHandle: sys.modules[__name__].__class__ = _CallableModule __all__ = [ - "AcpMcpIntegration", "HarnessEntry", "HarnessScope", "HarnessState", @@ -337,20 +336,13 @@ async def __call__(self, prompt: str, **kwargs: Any) -> RLMSpawnHandle: "harness", "host_request", "list_subagents", - "make_acp_mcp_skill", "rlm", "run", ] # Lazily re-export the MCP base class. Kept lazy so `import rlm` never requires # the optional `mcp` SDK — only integration packages that subclass it do. -_LAZY_MCP = { - "AcpMcpIntegration", - "McpIntegration", - "McpToolError", - "NotEnabled", - "make_acp_mcp_skill", -} +_LAZY_MCP = {"McpIntegration", "McpToolError", "NotEnabled"} def __getattr__(name: str) -> Any: # noqa: D401 - module-level lazy attr hook diff --git a/prime-agent-runtime/src/rlm/mcp.py b/prime-agent-runtime/src/rlm/mcp.py index a5dce193045..43a8846e31f 100644 --- a/prime-agent-runtime/src/rlm/mcp.py +++ b/prime-agent-runtime/src/rlm/mcp.py @@ -508,7 +508,8 @@ async def _config(server: str) -> dict[str, Any]: raise RuntimeError(f"MCP server '{server}' is disabled") if config.get("type") == "http": config = dict(config) - config["_authIdentity"] = await _auth_identity(server, config) + if config.get("credentialSource") != "acp": + config["_authIdentity"] = await _auth_identity(server, config) return config @@ -552,6 +553,8 @@ async def _headers(server: str, config: dict[str, Any]) -> dict[str, str]: if not isinstance(raw, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in raw.items()): raise ValueError("MCP HTTP headers must contain strings") headers = dict(raw) + if config.get("credentialSource") == "acp": + return headers env_name = config.get("bearerTokenEnvVar") token = os.environ.get(env_name, "").strip() if isinstance(env_name, str) else "" if config.get("oauth") is True and not token: @@ -569,6 +572,11 @@ def _stdio_env(config: dict[str, Any]) -> dict[str, str]: raw = config.get("env", {}) if not isinstance(raw, dict): raise ValueError("MCP stdio env must be an object") + if config.get("credentialSource") == "acp": + if not all(isinstance(key, str) and isinstance(value, str) for key, value in raw.items()): + raise ValueError("ACP MCP stdio env must contain string values") + env.update(raw) + return env for key, reference in raw.items(): if not isinstance(key, str) or not isinstance(reference, dict) or set(reference) != {"env"}: raise ValueError("MCP stdio env values must use {\"env\": \"NAME\"} references") diff --git a/prime-agent-runtime/test/test_mcp.py b/prime-agent-runtime/test/test_mcp.py index 361b227aa80..806105a2d16 100644 --- a/prime-agent-runtime/test/test_mcp.py +++ b/prime-agent-runtime/test/test_mcp.py @@ -33,7 +33,7 @@ async def main(): result = {"tools": [{"name": "fixture/raw.tool", "description": "fixture", "inputSchema": {"type": "object"}}]} else: params = request["params"] - result = {"content": [{"type": "text", "text": json.dumps({"args": sys.argv[1:], "cwd": os.getcwd(), "env": os.environ.get("FIXTURE_ENV"), "arguments": params.get("arguments", {})})}]} + result = {"content": [{"type": "text", "text": json.dumps({"args": sys.argv[1:], "cwd": os.getcwd(), "env": os.environ.get("FIXTURE_ENV"), "ambient": os.environ.get("UNRELATED"), "arguments": params.get("arguments", {})})}]} print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) asyncio.run(main()) """ @@ -268,6 +268,37 @@ def test_stdio_env_is_scrubbed_and_tagged(self): self.assertEqual(env, {"PATH": "/bin", "TOKEN": "value"}) self.assertNotIn("UNRELATED", env) + def test_acp_headers_never_consult_or_override_host_oauth(self): + config = { + "type": "http", + "url": "https://task.example/mcp", + "headers": {"Authorization": "Bearer task-token"}, + "credentialSource": "acp", + } + with mock.patch.object(mcp, "_read_auth", side_effect=AssertionError("auth.json must not be read")): + headers = run(mcp._headers("linear", config)) + self.assertEqual(headers, {"Authorization": "Bearer task-token"}) + + def test_acp_config_skips_oauth_identity_and_refresh(self): + async def host_request(_method, _payload): + return { + "type": "http", + "url": "https://task.example/mcp", + "headers": {"Authorization": "Bearer task-token"}, + "credentialSource": "acp", + } + + with mock.patch.object(mcp, "host_request", host_request), mock.patch.object( + mcp, "_auth_identity", side_effect=AssertionError("ACP must not resolve host credentials") + ): + config = run(mcp._config("linear")) + self.assertNotIn("_authIdentity", config) + + def test_acp_stdio_env_uses_literal_values_without_ambient_secrets(self): + with mock.patch.dict(os.environ, {"PATH": "/bin", "UNRELATED": "ambient-secret"}, clear=True): + env = mcp._stdio_env({"credentialSource": "acp", "env": {"TOKEN": "task-secret"}}) + self.assertEqual(env, {"PATH": "/bin", "TOKEN": "task-secret"}) + def test_endpoint_bound_credential_never_attaches_to_another_url(self): cred = {"access": "old-token", "endpoint": "https://old.example/mcp"} config = {"oauth": True, "url": "https://new.example/mcp"} @@ -428,6 +459,34 @@ async def scenario(): return run(scenario()) + def test_real_acp_stdio_preserves_cwd_and_exact_env(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = Path(tmp) / "stdio_server.py" + fixture.write_text(_STDIO_FIXTURE) + config = { + "type": "stdio", + "command": sys.executable, + "args": [str(fixture), "task"], + "cwd": str(fixture.parent), + "env": {"FIXTURE_ENV": "task-secret"}, + "credentialSource": "acp", + } + + async def scenario(): + with mock.patch.dict(os.environ, {"UNRELATED": "ambient-secret"}, clear=False): + generation = mcp._Generation("task-tools", config) + await generation.open() + try: + result = await generation.call("fixture/raw.tool", {}) + finally: + await generation.close() + return json.loads(result) + + output = run(scenario()) + self.assertEqual(Path(output["cwd"]).resolve(), fixture.parent.resolve()) + self.assertEqual(output["env"], "task-secret") + self.assertIsNone(output["ambient"]) + def test_real_stdio_startup_diagnostic_is_safe_bounded_and_reaped(self): secret = "stdio-secret-SENTINEL" with tempfile.TemporaryDirectory() as tmp: diff --git a/prime-agent-runtime/test/test_mcp_base.py b/prime-agent-runtime/test/test_mcp_base.py index c862e57dea6..98acfaa1b96 100644 --- a/prime-agent-runtime/test/test_mcp_base.py +++ b/prime-agent-runtime/test/test_mcp_base.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import inspect import json import tempfile import time @@ -11,13 +10,7 @@ from unittest import mock from rlm import mcp_base -from rlm.mcp_base import ( - AcpMcpIntegration, - McpIntegration, - McpToolError, - NotEnabled, - make_acp_mcp_skill, -) +from rlm.mcp_base import McpIntegration, McpToolError, NotEnabled def _run(coro): @@ -271,121 +264,6 @@ async def host_with_override(req_type, payload): self.assertEqual(url, _Integration.url) self.assertEqual(headers, {}) - def test_acp_http_server_uses_client_configuration_without_auth(self): - captured = {} - session = _FakeSession( - tools=[], - result=type("R", (), {"structuredContent": {"ok": True}})(), - ) - - async def fake_open(stack, url, headers): - captured.update(url=url, headers=headers) - return session - - integration = AcpMcpIntegration( - "task-tools", - { - "type": "http", - "url": "https://tools.example/mcp", - "headers": {"Authorization": "Bearer task", "X-Test": "1"}, - }, - ) - with mock.patch.object(mcp_base, "_open_http_session", fake_open): - result = _run(integration.call_tool("lookup", {"query": "ACP"})) - - self.assertEqual(result, {"ok": True}) - self.assertEqual(captured["url"], "https://tools.example/mcp") - self.assertEqual( - captured["headers"], - {"Authorization": "Bearer task", "X-Test": "1"}, - ) - self.assertEqual(session.calls, [("lookup", {"query": "ACP"})]) - - def test_acp_stdio_server_uses_client_command_arguments_and_environment(self): - captured = {} - - class _Streams: - async def __aenter__(self): - return ("read", "write") - - async def __aexit__(self, *args): - return False - - def fake_stdio_client(params): - captured["params"] = params - return _Streams() - - session = mock.MagicMock() - session.initialize = mock.AsyncMock() - session_cm = mock.MagicMock() - session_cm.__aenter__ = mock.AsyncMock(return_value=session) - session_cm.__aexit__ = mock.AsyncMock(return_value=False) - - integration = AcpMcpIntegration( - "local-tools", - { - "type": "stdio", - "command": "uvx", - "args": ["local-mcp", "--stdio"], - "env": {"TASK_TOKEN": "task-secret"}, - }, - ) - - async def open_session(): - async with AsyncExitStack() as stack: - return await integration._open_session(stack) - - with mock.patch("mcp.client.stdio.stdio_client", fake_stdio_client), mock.patch( - "mcp.ClientSession", return_value=session_cm - ): - opened = _run(open_session()) - - self.assertIs(opened, session) - params = captured["params"] - self.assertEqual(params.command, "uvx") - self.assertEqual(params.args, ["local-mcp", "--stdio"]) - self.assertEqual(params.env, {"TASK_TOKEN": "task-secret"}) - session.initialize.assert_awaited_once() - - def test_acp_server_rejects_unsupported_transport(self): - integration = AcpMcpIntegration("task-tools", {"type": "sse"}) - with self.assertRaisesRegex(ValueError, "unsupported transport"): - _run(integration._open_session(AsyncExitStack())) - - def test_acp_generated_skill_has_schema_signature_and_fixed_tool(self): - tool = { - "name": "lookup", - "description": "Look up a task.", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - "not-valid": {"type": "boolean"}, - }, - "required": ["query"], - }, - } - run = make_acp_mcp_skill( - "task-tools", - {"type": "http", "url": "https://tools.example/mcp", "headers": {}}, - tool, - ) - self.assertEqual( - str(inspect.signature(run)), "(*, query: str, limit: int = None)" - ) - self.assertEqual(run.__doc__, "Look up a task.") - - with mock.patch.object( - AcpMcpIntegration, - "call_tool", - mock.AsyncMock(return_value={"ok": True}), - ) as call_tool: - result = _run(run(query="ACP", limit=2)) - - self.assertEqual(result, {"ok": True}) - call_tool.assert_awaited_once_with("lookup", {"query": "ACP", "limit": 2}) - if __name__ == "__main__": unittest.main() From a5c4d7cf37c03e8f088233a90085f9b0702e76c9 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 10:38:49 -0700 Subject: [PATCH 3/9] fix(coding-agent): fence ACP MCP daemon ownership --- packages/coding-agent/docs/acp.md | 3 +- .../coding-agent/src/core/agent-session.ts | 4 +- .../coding-agent/src/core/mcp/mcp-manager.ts | 10 ++- .../coding-agent/src/modes/acp/acp-mode.ts | 22 ++++- .../daemon-agent-connection.ts | 3 +- .../in-process-agent-connection.ts | 4 +- .../src/modes/agent-connection/types.ts | 2 +- .../src/modes/daemon/daemon-mode.ts | 2 +- .../src/modes/daemon/daemon-protocol.ts | 8 +- packages/coding-agent/test/acp-mcp.test.ts | 84 +++++++++++++++---- .../test/agent-connection-daemon.test.ts | 27 +++--- .../test/agent-session-services.test.ts | 21 +++-- .../coding-agent/test/daemon-protocol.test.ts | 2 +- .../coding-agent/test/mcp-manager.test.ts | 33 ++++++-- 14 files changed, 163 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/docs/acp.md b/packages/coding-agent/docs/acp.md index 49ea582a8b0..bb56ad6cc7b 100644 --- a/packages/coding-agent/docs/acp.md +++ b/packages/coding-agent/docs/acp.md @@ -45,7 +45,8 @@ environment, and the exact environment values supplied by the ACP client. The configuration is removed when the ACP session closes or the client disconnects. A same-named persistent MCP server can therefore be shadowed for the ACP session without sending its stored -OAuth credential to the client-supplied HTTP endpoint. +OAuth credential to the client-supplied HTTP endpoint. Daemon-backed configuration is bound to the +ACP connection that installed it, so another attached client cannot replace or clear it. ACP stdio is a trusted-code boundary, not a sandbox. The requested command runs as the Prime Agent user and can access any files that user can access, including credential stores. Only accept stdio diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4183b492473..09cd5aed495 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1304,13 +1304,13 @@ export class AgentSession { this.agent.state.systemPrompt = this._baseSystemPrompt; } - replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): void { + replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): void { if (this.isStreaming) throw new Error("Cannot replace ACP MCP servers while the agent is running"); if (!this._mcpManager) { if (servers.length > 0) throw new Error("MCP is unavailable in this session"); return; } - if (!this._mcpManager.replaceAcpServers(servers)) return; + if (!this._mcpManager.replaceAcpServers(servers, ownerId)) return; this._buildRuntime({ activeToolNames: this.getActiveToolNames(), includeAllExtensionTools: true, diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index 86f23f77d9d..c030843a2b8 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -38,6 +38,7 @@ export class McpManager { private readonly beginLogin?: (server: string) => Promise; private integrations = new Map(); private acpServers = new Map(); + private acpOwnerId?: string; /** Provider ids we registered for user servers, so refresh can drop removed ones. */ private registeredUserProviderIds = new Set(); @@ -55,7 +56,13 @@ export class McpManager { this.registerProviders(); } - replaceAcpServers(servers: readonly AcpMcpServerConfig[]): boolean { + replaceAcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): boolean { + if (!ownerId) throw new Error("ACP MCP owner id is required"); + if (servers.length === 0 && this.acpOwnerId !== ownerId) return false; + if (servers.length > 0 && this.acpOwnerId && this.acpOwnerId !== ownerId) { + throw new Error("ACP MCP configuration is owned by another client"); + } + const next = new Map(); for (const server of servers) { if (next.has(server.name)) throw new Error(`Duplicate ACP MCP server: ${server.name}`); @@ -68,6 +75,7 @@ export class McpManager { ); if (unchanged) return false; this.acpServers = next; + this.acpOwnerId = next.size > 0 ? ownerId : undefined; return true; } diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 68c484656f2..8cc65a41601 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -469,13 +469,27 @@ export async function runAcpModeWithConnection( } const supportsMcpServers = connection.supportsAcpMcpServers?.() === true && connection.replaceAcpMcpServers !== undefined; + const acpMcpOwnerId = randomUUID(); let hasAcpMcpServers = false; + const clearAcpMcpServers = async (): Promise => { + if (!supportsMcpServers || !connection.replaceAcpMcpServers) return; + await connection.replaceAcpMcpServers([], acpMcpOwnerId); + hasAcpMcpServers = false; + }; const replaceAcpMcpServers = async (servers: readonly acp.McpServer[], cwd: string): Promise => { if (servers.length === 0 && !hasAcpMcpServers) return; if (!supportsMcpServers || !connection.replaceAcpMcpServers) { throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); } - await connection.replaceAcpMcpServers(resolveAcpMcpServers(servers, cwd)); + const resolved = resolveAcpMcpServers(servers, cwd); + try { + await connection.replaceAcpMcpServers(resolved, acpMcpOwnerId); + } catch (error) { + // The daemon may have applied the configuration before its acknowledgement + // was lost. Always attempt owner-scoped cleanup before rejecting admission. + await clearAcpMcpServers().catch(() => undefined); + throw error; + } hasAcpMcpServers = servers.length > 0; }; @@ -789,7 +803,7 @@ export async function runAcpModeWithConnection( } catch (error) { producer.failSessionNewAdmission(); unsubscribe(); - await replaceAcpMcpServers([], actualCwd ?? "").catch(() => undefined); + await clearAcpMcpServers().catch(() => undefined); throw error; } // Claim the single-session slot only once the subscription and snapshot are @@ -982,7 +996,7 @@ export async function runAcpModeWithConnection( closing.unsubscribe?.(); // Keep the backing session fenced until a replacement ACP session is admitted. await closing.producer.close(); - await replaceAcpMcpServers([], ""); + await clearAcpMcpServers(); closedInputPause = inputPause; closedInputPauseKey = inputPauseKey; if (closing.inputPause === inputPause) { @@ -1064,7 +1078,7 @@ export async function runAcpModeWithConnection( await closedInputPause?.release().catch(() => undefined); closedInputPause = undefined; closedInputPauseKey = undefined; - await replaceAcpMcpServers([], "").catch(() => undefined); + await clearAcpMcpServers().catch(() => undefined); await connection.dispose().catch(() => undefined); // Only the real stdio entrypoint owns the process; a caller-supplied transport // (tests, embedding) must never have its host exited from under it. diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index c6674d802d6..bbdda8a67ab 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -504,13 +504,14 @@ export class DaemonAgentConnection implements AgentConnection { return this.client.supportsServerCapability("acp_mcp_servers"); } - async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): Promise { + async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): Promise { if (!this.supportsAcpMcpServers()) { throw new DaemonCapabilityUnavailableError("replace_acp_mcp_servers", "acp_mcp_servers"); } await this.requestOk({ type: "replace_acp_mcp_servers", activeSessionId: this.activeSessionId, + ownerId, servers: [...servers], }); } diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index 66d9720169e..a3f5b291ef6 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -115,8 +115,8 @@ export class InProcessAgentConnection implements AgentConnection { return true; } - async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[]): Promise { - this.runtimeHost.session.replaceAcpMcpServers(servers); + async replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): Promise { + this.runtimeHost.session.replaceAcpMcpServers(servers, ownerId); } subscribe(listener: AgentConnectionEventListener): () => void { diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index b36feb8871b..3080af42ac0 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -693,7 +693,7 @@ export interface AgentConnection { setSessionEntryLabel(entryId: string, label: string | undefined): Promise; respondToExtensionUiRequest(requestId: string, response: AgentConnectionExtensionUiResponse): Promise; supportsAcpMcpServers?(): boolean; - replaceAcpMcpServers?(servers: readonly AcpMcpServerConfig[]): Promise; + replaceAcpMcpServers?(servers: readonly AcpMcpServerConfig[], ownerId: string): Promise; prompt(message: string, options?: AgentConnectionPromptOptions): Promise; promptAndWait(message: string, options?: AgentConnectionPromptOptions): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index d2b670f2732..c68cd952737 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4521,7 +4521,7 @@ export class AgentDaemon { case "replace_acp_mcp_servers": { const state = this.getSessionState(command.activeSessionId); await withClientEnv(state.clientEnv, async () => - state.runtime.session.replaceAcpMcpServers(command.servers), + state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), ); return success(command.id, "replace_acp_mcp_servers"); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index f332a23a1ab..1613538a067 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -66,8 +66,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 19 adds daemon-held session input pauses. // Revision 20 lets cancellation target a prompt the session owns but has not started. // Revision 21 adds capability-gated, session-scoped ACP MCP server replacement. -export const DAEMON_SCHEMA_REVISION = 21; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-21-fe5aaf0f6262"; +// Revision 22 scopes ACP MCP replacement and cleanup to a connection owner. +export const DAEMON_SCHEMA_REVISION = 22; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-22-4d515169dc6b"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -542,6 +543,7 @@ export type DaemonCommand = id?: string; type: "replace_acp_mcp_servers"; activeSessionId: string; + ownerId: string; servers: AcpMcpServerConfig[]; } | { id?: string; type: "get_model_catalog"; activeSessionId: string } @@ -755,7 +757,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { get_context_tree: LEGACY_DAEMON_COMMAND, get_commands: LEGACY_DAEMON_COMMAND, get_resource_snapshot: LEGACY_DAEMON_COMMAND, - replace_acp_mcp_servers: { minProtocol: 7, minSchemaRevision: 21, capability: "acp_mcp_servers" }, + replace_acp_mcp_servers: { minProtocol: 7, minSchemaRevision: 22, capability: "acp_mcp_servers" }, get_model_catalog: { minProtocol: 7, capability: "model_catalog" }, get_available_models: LEGACY_DAEMON_COMMAND, get_queue: LEGACY_DAEMON_COMMAND, diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts index 162851e8767..123ebb4d54d 100644 --- a/packages/coding-agent/test/acp-mcp.test.ts +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -53,24 +53,74 @@ describe("ACP MCP servers", () => { }, ], }); - expect(replace).toHaveBeenCalledWith([ - { - name: "TaskTools", - type: "http", - url: "https://task.example/mcp", - headers: { Authorization: "Bearer task" }, - }, - { - name: "LocalTools", - type: "stdio", - command: "node", - args: ["server.js"], - cwd: agentCwd, - env: { TASK_TOKEN: "task-secret" }, - }, - ]); + const ownerId = replace.mock.calls[0]?.[1]; + expect(ownerId).toEqual(expect.any(String)); + expect(replace).toHaveBeenCalledWith( + [ + { + name: "TaskTools", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task" }, + }, + { + name: "LocalTools", + type: "stdio", + command: "node", + args: ["server.js"], + cwd: agentCwd, + env: { TASK_TOKEN: "task-secret" }, + }, + ], + ownerId, + ); await handle.agent.request("session/close", { sessionId: created.sessionId }); - expect(replace).toHaveBeenLastCalledWith([]); + expect(replace).toHaveBeenLastCalledWith([], ownerId); + } finally { + handle.close(); + await toAgent.writable.close().catch(() => undefined); + await modeDone; + harness.cleanup(); + } + }, 30_000); + + it("clears owner-scoped config when daemon acknowledgement is lost", async () => { + const harness = await createHarness(); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + const replace = vi.spyOn(connection, "replaceAcpMcpServers").mockImplementation(async (servers) => { + if (servers.length > 0) throw new Error("replacement acknowledgement lost"); + }); + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + const modeDone = runAcpModeWithConnection(connection, { + stream: acp.ndJsonStream(toClient.writable, toAgent.readable), + }); + const handle = acp + .client({ name: "mcp-lost-ack-test-client" }) + .connect(acp.ndJsonStream(toAgent.writable, toClient.readable)); + try { + await handle.agent.request("initialize", { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + await expect( + handle.agent.request("session/new", { + cwd: harness.tempDir, + mcpServers: [ + { + type: "http", + name: "TaskTools", + url: "https://task.example/mcp", + headers: [], + }, + ], + }), + ).rejects.toThrow(); + + expect(replace.mock.calls[0]?.[0]).toHaveLength(1); + const ownerId = replace.mock.calls[0]?.[1]; + expect(ownerId).toEqual(expect.any(String)); + expect(replace.mock.calls[1]).toEqual([[], ownerId]); } finally { handle.close(); await toAgent.writable.close().catch(() => undefined); diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 3e7d5d3d905..54da2b4fda0 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -2579,20 +2579,27 @@ describe("DaemonAgentConnection", () => { const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "session-1"); expect(connection.supportsAcpMcpServers()).toBe(false); await expect( - connection.replaceAcpMcpServers([ - { - name: "task", - type: "http", - url: "https://task.example/mcp", - headers: { Authorization: "Bearer task" }, - }, - ]), + connection.replaceAcpMcpServers( + [ + { + name: "task", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task" }, + }, + ], + "owner-a", + ), ).rejects.toBeInstanceOf(DaemonCapabilityUnavailableError); fakeClient.serverCapabilities.add("acp_mcp_servers"); expect(connection.supportsAcpMcpServers()).toBe(true); - await connection.replaceAcpMcpServers([]); - expect(fakeClient.requests.at(-1)).toMatchObject({ type: "replace_acp_mcp_servers", servers: [] }); + await connection.replaceAcpMcpServers([], "owner-a"); + expect(fakeClient.requests.at(-1)).toMatchObject({ + type: "replace_acp_mcp_servers", + ownerId: "owner-a", + servers: [], + }); await connection.dispose(); }); diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 97b2914bba3..5368e6f5f69 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -168,17 +168,20 @@ describe("createAgentSessionFromServices", () => { } expect(initialPrompt).not.toContain("Enabled generic MCP servers: `linear`"); - session.replaceAcpMcpServers([ - { - name: "task", - type: "http", - url: "https://task-secret.example/mcp", - headers: { Authorization: "Bearer task-secret" }, - }, - ]); + session.replaceAcpMcpServers( + [ + { + name: "task", + type: "http", + url: "https://task-secret.example/mcp", + headers: { Authorization: "Bearer task-secret" }, + }, + ], + "owner-a", + ); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); expect(session.systemPrompt).not.toContain("task-secret"); - session.replaceAcpMcpServers([]); + session.replaceAcpMcpServers([], "owner-a"); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" }); diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index 1ccf31da19d..cee6f9f5afe 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -130,7 +130,7 @@ describe("daemon protocol helpers", () => { it("capability- and schema-gates ACP MCP server replacement", () => { expect(DAEMON_COMMAND_COMPATIBILITY.replace_acp_mcp_servers).toEqual({ minProtocol: 7, - minSchemaRevision: 21, + minSchemaRevision: 22, capability: "acp_mcp_servers", }); expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("acp_mcp_servers"); diff --git a/packages/coding-agent/test/mcp-manager.test.ts b/packages/coding-agent/test/mcp-manager.test.ts index 56790d57b50..fc3589f4fae 100644 --- a/packages/coding-agent/test/mcp-manager.test.ts +++ b/packages/coding-agent/test/mcp-manager.test.ts @@ -247,14 +247,17 @@ describe("McpManager", () => { getUserServers: () => ({ task: { type: "http", url: "https://user.example/mcp", oauth: true } }), }); expect( - manager.replaceAcpServers([ - { - name: "task", - type: "http", - url: "https://task.example/mcp", - headers: { Authorization: "Bearer task-token" }, - }, - ]), + manager.replaceAcpServers( + [ + { + name: "task", + type: "http", + url: "https://task.example/mcp", + headers: { Authorization: "Bearer task-token" }, + }, + ], + "owner-a", + ), ).toBe(true); const handlers = manager.hostHandlers(); expect(await handlers["mcp.config"]({ server: "task" })).toEqual({ @@ -266,7 +269,19 @@ describe("McpManager", () => { await expect(handlers["mcp.refresh"]({ server: "task" })).rejects.toThrow("does not use host OAuth"); expect(manager.getEnabledGenericServers()).toContain("task"); - expect(manager.replaceAcpServers([])).toBe(true); + expect(manager.replaceAcpServers([], "owner-b")).toBe(false); + expect(() => + manager.replaceAcpServers( + [{ name: "other", type: "http", url: "https://other.example/mcp", headers: {} }], + "owner-b", + ), + ).toThrow("owned by another client"); + expect(await handlers["mcp.config"]({ server: "task" })).toMatchObject({ + url: "https://task.example/mcp", + credentialSource: "acp", + }); + + expect(manager.replaceAcpServers([], "owner-a")).toBe(true); expect(await handlers["mcp.config"]({ server: "task" })).toEqual({ type: "http", url: "https://user.example/mcp", From 5a73b61af87923567686e49e9e1e1550b496a993 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 10:47:16 -0700 Subject: [PATCH 4/9] fix(coding-agent): release ACP MCP leases on detach --- .../coding-agent/src/core/agent-session.ts | 12 ++++ .../src/modes/daemon/daemon-mode.ts | 26 +++++++++ .../test/agent-session-services.test.ts | 2 +- .../coding-agent/test/daemon-mode.test.ts | 57 +++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 09cd5aed495..1cb8491f4af 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1311,6 +1311,18 @@ export class AgentSession { return; } if (!this._mcpManager.replaceAcpServers(servers, ownerId)) return; + this._rebuildRuntimeForAcpMcpServers(); + } + + async releaseAcpMcpServers(ownerId: string): Promise { + if (!this._mcpManager?.replaceAcpServers([], ownerId)) return; + // Drop the owner-scoped config immediately, even if its daemon client + // disappears while the agent is running. Rebuild only at the safe boundary. + await this.waitForIdle(); + this._rebuildRuntimeForAcpMcpServers(); + } + + private _rebuildRuntimeForAcpMcpServers(): void { this._buildRuntime({ activeToolNames: this.getActiveToolNames(), includeAllExtensionTools: true, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index c68cd952737..0fa2201116f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -461,6 +461,7 @@ export class AgentDaemon { string, { activeSessionId: string; owner: DaemonSocketClient; leaseKey: string; pause: { release(): void } } >(); + private readonly acpMcpOwners = new Map(); private readonly mutationDrain = new MutationDrainLatch(); private updateRestart?: { id: symbol; @@ -1488,6 +1489,7 @@ export class AgentDaemon { } private refreshReplacedSessionState(state: ActiveSessionState): void { + this.acpMcpOwners.delete(state.activeSessionId); for (const client of state.clients) { this.abortSideQuestionsFor(client, state.activeSessionId); } @@ -4519,10 +4521,26 @@ export class AgentDaemon { } case "replace_acp_mcp_servers": { + if (!command.ownerId) throw new Error("ACP MCP owner id is required"); const state = this.getSessionState(command.activeSessionId); + if (!state.clients.has(client)) throw new Error("Daemon client is not attached to this session"); + const currentOwner = this.acpMcpOwners.get(state.activeSessionId); + const ownedByClient = currentOwner?.client === client && currentOwner.ownerId === command.ownerId; + if (command.servers.length === 0 && !ownedByClient) { + return success(command.id, "replace_acp_mcp_servers"); + } + if (command.servers.length > 0) { + if (currentOwner && !ownedByClient) { + throw new Error("ACP MCP configuration is owned by another daemon client"); + } + this.acpMcpOwners.set(state.activeSessionId, { client, ownerId: command.ownerId }); + } await withClientEnv(state.clientEnv, async () => state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), ); + if (command.servers.length === 0) { + this.acpMcpOwners.delete(state.activeSessionId); + } return success(command.id, "replace_acp_mcp_servers"); } @@ -5938,6 +5956,13 @@ export class AgentDaemon { } private detachClientFromSession(client: DaemonSocketClient, state: ActiveSessionState): void { + const acpMcpOwner = this.acpMcpOwners.get(state.activeSessionId); + if (acpMcpOwner?.client === client) { + this.acpMcpOwners.delete(state.activeSessionId); + void state.runtime.session + .releaseAcpMcpServers(acpMcpOwner.ownerId) + .catch((error) => this.log(`failed to release detached ACP MCP config: ${String(error)}`)); + } this.abortSideQuestionsFor(client, state.activeSessionId); abortClientSnapshotStreaming(client, state.activeSessionId); detachClientFromActiveSession(client, state); @@ -6462,6 +6487,7 @@ export class AgentDaemon { removeDaemonClientSessionCapabilities(client, state.activeSessionId); } state.clients.clear(); + this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 5368e6f5f69..00de935f75a 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -181,7 +181,7 @@ describe("createAgentSessionFromServices", () => { ); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); expect(session.systemPrompt).not.toContain("task-secret"); - session.replaceAcpMcpServers([], "owner-a"); + await session.releaseAcpMcpServers("owner-a"); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 58facb9cf48..ad11da6911f 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -182,6 +182,63 @@ describe("daemon mode helpers", () => { expect(secondClient.attachedActiveSessionIds.has("active")).toBe(false); }); + it("releases owner-scoped ACP MCP config when its daemon client detaches", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-acp-owner.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const owner = makeClient("owner", "active"); + const other = makeClient("other", "active"); + const replaceAcpMcpServers = vi.fn(); + const releaseAcpMcpServers = vi.fn(async () => {}); + const state = makeState("active"); + state.clientEnv = {}; + state.clients.add(owner); + state.clients.add(other); + state.extensionUiRequests = new Map(); + state.runtime = { + ...state.runtime, + session: { replaceAcpMcpServers, releaseAcpMcpServers }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + detachClientFromSession(client: DaemonSocketClient, state: ActiveSessionState): void; + write: ReturnType; + }; + internals.sessions.set(state.activeSessionId, state); + internals.write = vi.fn(); + + await internals.handleCommand(owner, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "owner-token", + servers: [{ name: "task", type: "http", url: "https://task.example/mcp", headers: {} }], + }); + await expect( + internals.handleCommand(other, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "other-token", + servers: [{ name: "other", type: "http", url: "https://other.example/mcp", headers: {} }], + }), + ).rejects.toThrow("owned by another daemon client"); + + internals.detachClientFromSession(owner, state); + expect(releaseAcpMcpServers).toHaveBeenCalledWith("owner-token"); + + await internals.handleCommand(other, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "other-token", + servers: [{ name: "other", type: "http", url: "https://other.example/mcp", headers: {} }], + }); + expect(replaceAcpMcpServers).toHaveBeenLastCalledWith( + [{ name: "other", type: "http", url: "https://other.example/mcp", headers: {} }], + "other-token", + ); + }); + it("cancels pending extension UI requests directly", () => { const resolve = vi.fn(); const state = { From fb73d3a23cf71847f6c37d4cabf37a0eb750e42c Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 10:50:33 -0700 Subject: [PATCH 5/9] test(coding-agent): preserve daemon prototype fixtures --- packages/coding-agent/src/modes/daemon/daemon-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0fa2201116f..bd5bf2d9d07 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1489,7 +1489,7 @@ export class AgentDaemon { } private refreshReplacedSessionState(state: ActiveSessionState): void { - this.acpMcpOwners.delete(state.activeSessionId); + this.acpMcpOwners?.delete(state.activeSessionId); for (const client of state.clients) { this.abortSideQuestionsFor(client, state.activeSessionId); } From 8e13c9508b20658c8f1056eb2298bc67ca0feaec Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 10:55:39 -0700 Subject: [PATCH 6/9] fix(coding-agent): roll back failed ACP MCP claims --- .../coding-agent/src/core/agent-session.ts | 10 ++-- .../src/modes/daemon/daemon-mode.ts | 56 ++++++++++++++----- .../test/agent-session-services.test.ts | 6 ++ .../coding-agent/test/daemon-mode.test.ts | 15 ++++- 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1cb8491f4af..fda9a437d9f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1314,12 +1314,12 @@ export class AgentSession { this._rebuildRuntimeForAcpMcpServers(); } - async releaseAcpMcpServers(ownerId: string): Promise { + releaseAcpMcpServers(ownerId: string): void { if (!this._mcpManager?.replaceAcpServers([], ownerId)) return; - // Drop the owner-scoped config immediately, even if its daemon client - // disappears while the agent is running. Rebuild only at the safe boundary. - await this.waitForIdle(); - this._rebuildRuntimeForAcpMcpServers(); + // Host MCP handlers read this manager dynamically, so credentials disappear + // immediately without rebuilding a kernel that may still be running. + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; } private _rebuildRuntimeForAcpMcpServers(): void { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index bd5bf2d9d07..05259f0bdfb 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4529,17 +4529,42 @@ export class AgentDaemon { if (command.servers.length === 0 && !ownedByClient) { return success(command.id, "replace_acp_mcp_servers"); } - if (command.servers.length > 0) { - if (currentOwner && !ownedByClient) { - throw new Error("ACP MCP configuration is owned by another daemon client"); + if (command.servers.length === 0) { + await withClientEnv(state.clientEnv, async () => + state.runtime.session.replaceAcpMcpServers([], command.ownerId), + ); + if (this.acpMcpOwners.get(state.activeSessionId) === currentOwner) { + this.acpMcpOwners.delete(state.activeSessionId); } - this.acpMcpOwners.set(state.activeSessionId, { client, ownerId: command.ownerId }); + return success(command.id, "replace_acp_mcp_servers"); } - await withClientEnv(state.clientEnv, async () => - state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), - ); - if (command.servers.length === 0) { - this.acpMcpOwners.delete(state.activeSessionId); + + if (currentOwner && !ownedByClient) { + throw new Error("ACP MCP configuration is owned by another daemon client"); + } + const claim = currentOwner ?? { client, ownerId: command.ownerId }; + this.acpMcpOwners.set(state.activeSessionId, claim); + const rollback = (): void => { + try { + state.runtime.session.releaseAcpMcpServers(command.ownerId); + } catch (error) { + this.log(`failed to roll back ACP MCP config: ${String(error)}`); + } + if (this.acpMcpOwners.get(state.activeSessionId) === claim) { + this.acpMcpOwners.delete(state.activeSessionId); + } + }; + try { + await withClientEnv(state.clientEnv, async () => + state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), + ); + } catch (error) { + rollback(); + throw error; + } + if (!state.clients.has(client) || this.acpMcpOwners.get(state.activeSessionId) !== claim) { + rollback(); + throw new Error("Daemon client detached during ACP MCP replacement"); } return success(command.id, "replace_acp_mcp_servers"); } @@ -5958,10 +5983,15 @@ export class AgentDaemon { private detachClientFromSession(client: DaemonSocketClient, state: ActiveSessionState): void { const acpMcpOwner = this.acpMcpOwners.get(state.activeSessionId); if (acpMcpOwner?.client === client) { - this.acpMcpOwners.delete(state.activeSessionId); - void state.runtime.session - .releaseAcpMcpServers(acpMcpOwner.ownerId) - .catch((error) => this.log(`failed to release detached ACP MCP config: ${String(error)}`)); + try { + state.runtime.session.releaseAcpMcpServers(acpMcpOwner.ownerId); + } catch (error) { + this.log(`failed to release detached ACP MCP config: ${String(error)}`); + } finally { + if (this.acpMcpOwners.get(state.activeSessionId) === acpMcpOwner) { + this.acpMcpOwners.delete(state.activeSessionId); + } + } } this.abortSideQuestionsFor(client, state.activeSessionId); abortClientSnapshotStreaming(client, state.activeSessionId); diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 00de935f75a..f743b3ff6d2 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -168,6 +168,10 @@ describe("createAgentSessionFromServices", () => { } expect(initialPrompt).not.toContain("Enabled generic MCP servers: `linear`"); + const rebuildRuntime = vi.spyOn( + session as unknown as { _rebuildRuntimeForAcpMcpServers(): void }, + "_rebuildRuntimeForAcpMcpServers", + ); session.replaceAcpMcpServers( [ { @@ -181,7 +185,9 @@ describe("createAgentSessionFromServices", () => { ); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); expect(session.systemPrompt).not.toContain("task-secret"); + rebuildRuntime.mockClear(); await session.releaseAcpMcpServers("owner-a"); + expect(rebuildRuntime).not.toHaveBeenCalled(); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index ad11da6911f..923a0c85d84 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -190,7 +190,7 @@ describe("daemon mode helpers", () => { const owner = makeClient("owner", "active"); const other = makeClient("other", "active"); const replaceAcpMcpServers = vi.fn(); - const releaseAcpMcpServers = vi.fn(async () => {}); + const releaseAcpMcpServers = vi.fn(); const state = makeState("active"); state.clientEnv = {}; state.clients.add(owner); @@ -209,6 +209,19 @@ describe("daemon mode helpers", () => { internals.sessions.set(state.activeSessionId, state); internals.write = vi.fn(); + replaceAcpMcpServers.mockImplementationOnce(() => { + throw new Error("replacement failed"); + }); + await expect( + internals.handleCommand(owner, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "owner-token", + servers: [{ name: "failed", type: "http", url: "https://failed.example/mcp", headers: {} }], + }), + ).rejects.toThrow("replacement failed"); + expect(releaseAcpMcpServers).toHaveBeenLastCalledWith("owner-token"); + await internals.handleCommand(owner, { type: "replace_acp_mcp_servers", activeSessionId: state.activeSessionId, From b2a213a27cb4274bc4887852f0c217f6f470006f Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 11:10:28 -0700 Subject: [PATCH 7/9] fix(coding-agent): reap ACP MCP transports on release --- .../coding-agent/src/core/agent-session.ts | 46 ++++++- .../coding-agent/src/core/mcp/mcp-manager.ts | 4 + .../coding-agent/src/modes/acp/acp-mode.ts | 21 +-- .../daemon-agent-connection.ts | 4 + .../in-process-agent-connection.ts | 4 + .../src/modes/agent-connection/types.ts | 1 + .../src/modes/daemon/daemon-mode.ts | 122 +++++++++++------- packages/coding-agent/test/acp-mcp.test.ts | 6 +- .../test/agent-connection-daemon.test.ts | 2 +- .../test/agent-session-services.test.ts | 9 +- .../coding-agent/test/daemon-mode.test.ts | 20 ++- prime-agent-runtime/src/rlm/mcp.py | 11 +- prime-agent-runtime/test/test_mcp.py | 44 +++++++ 13 files changed, 222 insertions(+), 72 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fda9a437d9f..4f187593df4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1314,12 +1314,46 @@ export class AgentSession { this._rebuildRuntimeForAcpMcpServers(); } - releaseAcpMcpServers(ownerId: string): void { - if (!this._mcpManager?.replaceAcpServers([], ownerId)) return; - // Host MCP handlers read this manager dynamically, so credentials disappear - // immediately without rebuilding a kernel that may still be running. - this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); - this.agent.state.systemPrompt = this._baseSystemPrompt; + async releaseAcpMcpServers(ownerId: string, serverNames: readonly string[]): Promise { + if (!this._mcpManager?.canReleaseAcpServers(ownerId)) return; + if (this._mcpManager.replaceAcpServers([], ownerId)) { + // Host MCP handlers read this manager dynamically, so credentials disappear + // before the kernel-side transport is closed. + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + const names = [...new Set(serverNames)]; + if (names.length === 0) return; + + const inputPause = this.acquireSessionInputPause(); + try { + // Do not rebuild or kill the notebook. Wait for the current turn, then ask + // the kernel-owned MCP registry to close only these cached transports. + await this.agent.waitForIdle(); + await this._agentEventQueue; + const manager = this._ipythonKernelProvisioner?.manager; + if (!manager?.isRunning) return; + const code = [ + "import importlib as _prime_importlib", + '_prime_mcp = _prime_importlib.import_module("rlm.mcp")', + `_prime_mcp_names = ${JSON.stringify(names)}`, + "_prime_mcp_errors = []", + "for _prime_mcp_name in _prime_mcp_names:", + " try:", + " await _prime_mcp.reload(_prime_mcp_name)", + " except BaseException as _prime_mcp_error:", + " _prime_mcp_errors.append(_prime_mcp_error)", + "if _prime_mcp_errors:", + " raise _prime_mcp_errors[0]", + "del _prime_mcp, _prime_importlib, _prime_mcp_names, _prime_mcp_errors, _prime_mcp_name", + ].join("\n"); + const result = await manager.execute(code); + if (result.status !== "ok") { + throw new Error(`Failed to close ACP MCP kernel transports: ${result.stderr || "kernel error"}`); + } + } finally { + inputPause.release(); + } } private _rebuildRuntimeForAcpMcpServers(): void { diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index c030843a2b8..60316510780 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -56,6 +56,10 @@ export class McpManager { this.registerProviders(); } + canReleaseAcpServers(ownerId: string): boolean { + return this.acpOwnerId === undefined || this.acpOwnerId === ownerId; + } + replaceAcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): boolean { if (!ownerId) throw new Error("ACP MCP owner id is required"); if (servers.length === 0 && this.acpOwnerId !== ownerId) return false; diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 8cc65a41601..4915ec2f4c5 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -468,29 +468,32 @@ export async function runAcpModeWithConnection( takeOverStdout(); } const supportsMcpServers = - connection.supportsAcpMcpServers?.() === true && connection.replaceAcpMcpServers !== undefined; + connection.supportsAcpMcpServers?.() === true && + connection.replaceAcpMcpServers !== undefined && + connection.releaseAcpMcpServers !== undefined; const acpMcpOwnerId = randomUUID(); - let hasAcpMcpServers = false; - const clearAcpMcpServers = async (): Promise => { - if (!supportsMcpServers || !connection.replaceAcpMcpServers) return; - await connection.replaceAcpMcpServers([], acpMcpOwnerId); - hasAcpMcpServers = false; + let acpMcpServerNames: string[] = []; + const clearAcpMcpServers = async (serverNames = acpMcpServerNames): Promise => { + if (!supportsMcpServers || !connection.releaseAcpMcpServers) return; + await connection.releaseAcpMcpServers(acpMcpOwnerId, serverNames); + acpMcpServerNames = []; }; const replaceAcpMcpServers = async (servers: readonly acp.McpServer[], cwd: string): Promise => { - if (servers.length === 0 && !hasAcpMcpServers) return; + if (servers.length === 0 && acpMcpServerNames.length === 0) return; if (!supportsMcpServers || !connection.replaceAcpMcpServers) { throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); } const resolved = resolveAcpMcpServers(servers, cwd); + const serverNames = resolved.map((server) => server.name); try { await connection.replaceAcpMcpServers(resolved, acpMcpOwnerId); } catch (error) { // The daemon may have applied the configuration before its acknowledgement // was lost. Always attempt owner-scoped cleanup before rejecting admission. - await clearAcpMcpServers().catch(() => undefined); + await clearAcpMcpServers(serverNames).catch(() => undefined); throw error; } - hasAcpMcpServers = servers.length > 0; + acpMcpServerNames = serverNames; }; // One ACP connection drives one AgentConnection, whose newSession() replaces diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index bbdda8a67ab..ef48e756458 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -516,6 +516,10 @@ export class DaemonAgentConnection implements AgentConnection { }); } + async releaseAcpMcpServers(ownerId: string, _serverNames: readonly string[]): Promise { + await this.replaceAcpMcpServers([], ownerId); + } + async getAvailableModels(): Promise { const data = await this.requestData<{ models: AgentConnectionModel[] }>({ type: "get_available_models", diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index a3f5b291ef6..b3d4986e289 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -119,6 +119,10 @@ export class InProcessAgentConnection implements AgentConnection { this.runtimeHost.session.replaceAcpMcpServers(servers, ownerId); } + async releaseAcpMcpServers(ownerId: string, serverNames: readonly string[]): Promise { + await this.runtimeHost.session.releaseAcpMcpServers(ownerId, serverNames); + } + subscribe(listener: AgentConnectionEventListener): () => void { this.listeners.add(listener); return () => { diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 3080af42ac0..bf81de68ad2 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -694,6 +694,7 @@ export interface AgentConnection { respondToExtensionUiRequest(requestId: string, response: AgentConnectionExtensionUiResponse): Promise; supportsAcpMcpServers?(): boolean; replaceAcpMcpServers?(servers: readonly AcpMcpServerConfig[], ownerId: string): Promise; + releaseAcpMcpServers?(ownerId: string, serverNames: readonly string[]): Promise; prompt(message: string, options?: AgentConnectionPromptOptions): Promise; promptAndWait(message: string, options?: AgentConnectionPromptOptions): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 05259f0bdfb..cd6816be981 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -461,7 +461,10 @@ export class AgentDaemon { string, { activeSessionId: string; owner: DaemonSocketClient; leaseKey: string; pause: { release(): void } } >(); - private readonly acpMcpOwners = new Map(); + private readonly acpMcpOwners = new Map< + string, + { client: DaemonSocketClient; ownerId: string; serverNames: string[]; release?: Promise } + >(); private readonly mutationDrain = new MutationDrainLatch(); private updateRestart?: { id: symbol; @@ -4524,49 +4527,74 @@ export class AgentDaemon { if (!command.ownerId) throw new Error("ACP MCP owner id is required"); const state = this.getSessionState(command.activeSessionId); if (!state.clients.has(client)) throw new Error("Daemon client is not attached to this session"); - const currentOwner = this.acpMcpOwners.get(state.activeSessionId); - const ownedByClient = currentOwner?.client === client && currentOwner.ownerId === command.ownerId; - if (command.servers.length === 0 && !ownedByClient) { - return success(command.id, "replace_acp_mcp_servers"); - } - if (command.servers.length === 0) { - await withClientEnv(state.clientEnv, async () => - state.runtime.session.replaceAcpMcpServers([], command.ownerId), - ); - if (this.acpMcpOwners.get(state.activeSessionId) === currentOwner) { - this.acpMcpOwners.delete(state.activeSessionId); + const commandPause = + command.servers.length > 0 ? state.runtime.session.acquireSessionInputPause() : undefined; + try { + let currentOwner = this.acpMcpOwners.get(state.activeSessionId); + if (currentOwner?.release) { + await currentOwner.release.catch(() => undefined); + currentOwner = this.acpMcpOwners.get(state.activeSessionId); + } + const ownedByClient = currentOwner?.client === client && currentOwner.ownerId === command.ownerId; + if (command.servers.length === 0 && !ownedByClient) { + return success(command.id, "replace_acp_mcp_servers"); + } + if (command.servers.length === 0) { + if (!currentOwner) return success(command.id, "replace_acp_mcp_servers"); + const release = state.runtime.session.releaseAcpMcpServers(command.ownerId, currentOwner.serverNames); + currentOwner.release = release; + try { + await release; + } catch (error) { + currentOwner.release = undefined; + throw error; + } + if (this.acpMcpOwners.get(state.activeSessionId) === currentOwner) { + this.acpMcpOwners.delete(state.activeSessionId); + } + return success(command.id, "replace_acp_mcp_servers"); } - return success(command.id, "replace_acp_mcp_servers"); - } - if (currentOwner && !ownedByClient) { - throw new Error("ACP MCP configuration is owned by another daemon client"); - } - const claim = currentOwner ?? { client, ownerId: command.ownerId }; - this.acpMcpOwners.set(state.activeSessionId, claim); - const rollback = (): void => { + if (currentOwner && !ownedByClient) { + throw new Error("ACP MCP configuration is owned by another daemon client"); + } + const claim = { + client, + ownerId: command.ownerId, + serverNames: [ + ...new Set([ + ...(currentOwner?.serverNames ?? []), + ...command.servers.map((server) => server.name), + ]), + ], + }; + this.acpMcpOwners.set(state.activeSessionId, claim); + const rollback = async (): Promise => { + try { + await state.runtime.session.releaseAcpMcpServers(command.ownerId, claim.serverNames); + } catch (error) { + this.log(`failed to roll back ACP MCP config: ${String(error)}`); + } + if (this.acpMcpOwners.get(state.activeSessionId) === claim) { + this.acpMcpOwners.delete(state.activeSessionId); + } + }; try { - state.runtime.session.releaseAcpMcpServers(command.ownerId); + await withClientEnv(state.clientEnv, async () => + state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), + ); } catch (error) { - this.log(`failed to roll back ACP MCP config: ${String(error)}`); + await rollback(); + throw error; } - if (this.acpMcpOwners.get(state.activeSessionId) === claim) { - this.acpMcpOwners.delete(state.activeSessionId); + if (!state.clients.has(client) || this.acpMcpOwners.get(state.activeSessionId) !== claim) { + await rollback(); + throw new Error("Daemon client detached during ACP MCP replacement"); } - }; - try { - await withClientEnv(state.clientEnv, async () => - state.runtime.session.replaceAcpMcpServers(command.servers, command.ownerId), - ); - } catch (error) { - rollback(); - throw error; - } - if (!state.clients.has(client) || this.acpMcpOwners.get(state.activeSessionId) !== claim) { - rollback(); - throw new Error("Daemon client detached during ACP MCP replacement"); + return success(command.id, "replace_acp_mcp_servers"); + } finally { + commandPause?.release(); } - return success(command.id, "replace_acp_mcp_servers"); } case "get_available_models": { @@ -5982,16 +6010,16 @@ export class AgentDaemon { private detachClientFromSession(client: DaemonSocketClient, state: ActiveSessionState): void { const acpMcpOwner = this.acpMcpOwners.get(state.activeSessionId); - if (acpMcpOwner?.client === client) { - try { - state.runtime.session.releaseAcpMcpServers(acpMcpOwner.ownerId); - } catch (error) { - this.log(`failed to release detached ACP MCP config: ${String(error)}`); - } finally { - if (this.acpMcpOwners.get(state.activeSessionId) === acpMcpOwner) { - this.acpMcpOwners.delete(state.activeSessionId); - } - } + if (acpMcpOwner?.client === client && !acpMcpOwner.release) { + const release = state.runtime.session.releaseAcpMcpServers(acpMcpOwner.ownerId, acpMcpOwner.serverNames); + acpMcpOwner.release = release; + void release + .catch((error) => this.log(`failed to release detached ACP MCP config: ${String(error)}`)) + .finally(() => { + if (this.acpMcpOwners.get(state.activeSessionId) === acpMcpOwner) { + this.acpMcpOwners.delete(state.activeSessionId); + } + }); } this.abortSideQuestionsFor(client, state.activeSessionId); abortClientSnapshotStreaming(client, state.activeSessionId); diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts index 123ebb4d54d..4b447734ef1 100644 --- a/packages/coding-agent/test/acp-mcp.test.ts +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -19,6 +19,7 @@ describe("ACP MCP servers", () => { it("advertises capability, installs session-scoped config, and clears it on close", async () => { const harness = await createHarness(); const replace = vi.spyOn(harness.session, "replaceAcpMcpServers").mockImplementation(() => undefined); + const release = vi.spyOn(harness.session, "releaseAcpMcpServers").mockResolvedValue(undefined); const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); const agentCwd = (await connection.getState()).cwd; const toAgent = new TransformStream(); @@ -75,7 +76,7 @@ describe("ACP MCP servers", () => { ownerId, ); await handle.agent.request("session/close", { sessionId: created.sessionId }); - expect(replace).toHaveBeenLastCalledWith([], ownerId); + expect(release).toHaveBeenCalledWith(ownerId, ["TaskTools", "LocalTools"]); } finally { handle.close(); await toAgent.writable.close().catch(() => undefined); @@ -90,6 +91,7 @@ describe("ACP MCP servers", () => { const replace = vi.spyOn(connection, "replaceAcpMcpServers").mockImplementation(async (servers) => { if (servers.length > 0) throw new Error("replacement acknowledgement lost"); }); + const release = vi.spyOn(connection, "releaseAcpMcpServers").mockResolvedValue(undefined); const toAgent = new TransformStream(); const toClient = new TransformStream(); const modeDone = runAcpModeWithConnection(connection, { @@ -120,7 +122,7 @@ describe("ACP MCP servers", () => { expect(replace.mock.calls[0]?.[0]).toHaveLength(1); const ownerId = replace.mock.calls[0]?.[1]; expect(ownerId).toEqual(expect.any(String)); - expect(replace.mock.calls[1]).toEqual([[], ownerId]); + expect(release).toHaveBeenCalledWith(ownerId, ["TaskTools"]); } finally { handle.close(); await toAgent.writable.close().catch(() => undefined); diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 54da2b4fda0..6b22dbb500d 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -2594,7 +2594,7 @@ describe("DaemonAgentConnection", () => { fakeClient.serverCapabilities.add("acp_mcp_servers"); expect(connection.supportsAcpMcpServers()).toBe(true); - await connection.replaceAcpMcpServers([], "owner-a"); + await connection.releaseAcpMcpServers("owner-a", ["task"]); expect(fakeClient.requests.at(-1)).toMatchObject({ type: "replace_acp_mcp_servers", ownerId: "owner-a", diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index f743b3ff6d2..901d9fac027 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -186,8 +186,15 @@ describe("createAgentSessionFromServices", () => { expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); expect(session.systemPrompt).not.toContain("task-secret"); rebuildRuntime.mockClear(); - await session.releaseAcpMcpServers("owner-a"); + const originalProvisioner = Reflect.get(session, "_ipythonKernelProvisioner"); + const execute = vi.fn(async (_code: string) => ({ status: "ok" })); + Reflect.set(session, "_ipythonKernelProvisioner", { manager: { isRunning: true, execute } }); + await session.releaseAcpMcpServers("owner-a", ["task"]); + Reflect.set(session, "_ipythonKernelProvisioner", originalProvisioner); expect(rebuildRuntime).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toContain("await _prime_mcp.reload(_prime_mcp_name)"); + expect(execute.mock.calls[0]?.[0]).toContain('["task"]'); expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`."); settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 923a0c85d84..6a5126a12eb 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -190,7 +190,7 @@ describe("daemon mode helpers", () => { const owner = makeClient("owner", "active"); const other = makeClient("other", "active"); const replaceAcpMcpServers = vi.fn(); - const releaseAcpMcpServers = vi.fn(); + const releaseAcpMcpServers = vi.fn(async () => {}); const state = makeState("active"); state.clientEnv = {}; state.clients.add(owner); @@ -198,7 +198,11 @@ describe("daemon mode helpers", () => { state.extensionUiRequests = new Map(); state.runtime = { ...state.runtime, - session: { replaceAcpMcpServers, releaseAcpMcpServers }, + session: { + replaceAcpMcpServers, + releaseAcpMcpServers, + acquireSessionInputPause: () => ({ release: vi.fn() }), + }, } as never; const internals = daemon as unknown as { sessions: Map; @@ -220,7 +224,7 @@ describe("daemon mode helpers", () => { servers: [{ name: "failed", type: "http", url: "https://failed.example/mcp", headers: {} }], }), ).rejects.toThrow("replacement failed"); - expect(releaseAcpMcpServers).toHaveBeenLastCalledWith("owner-token"); + expect(releaseAcpMcpServers).toHaveBeenLastCalledWith("owner-token", ["failed"]); await internals.handleCommand(owner, { type: "replace_acp_mcp_servers", @@ -238,7 +242,7 @@ describe("daemon mode helpers", () => { ).rejects.toThrow("owned by another daemon client"); internals.detachClientFromSession(owner, state); - expect(releaseAcpMcpServers).toHaveBeenCalledWith("owner-token"); + expect(releaseAcpMcpServers).toHaveBeenCalledWith("owner-token", ["task"]); await internals.handleCommand(other, { type: "replace_acp_mcp_servers", @@ -250,6 +254,14 @@ describe("daemon mode helpers", () => { [{ name: "other", type: "http", url: "https://other.example/mcp", headers: {} }], "other-token", ); + + await internals.handleCommand(other, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "other-token", + servers: [], + }); + expect(releaseAcpMcpServers).toHaveBeenLastCalledWith("other-token", ["other"]); }); it("cancels pending extension UI requests directly", () => { diff --git a/prime-agent-runtime/src/rlm/mcp.py b/prime-agent-runtime/src/rlm/mcp.py index 43a8846e31f..92216f69604 100644 --- a/prime-agent-runtime/src/rlm/mcp.py +++ b/prime-agent-runtime/src/rlm/mcp.py @@ -356,10 +356,17 @@ async def _get(self, server: str) -> _Generation: return await self._get_locked(server) async def _get_locked(self, server: str) -> _Generation: - self._accepting_work() - config = await _config(server) self._accepting_work() current = self._generations.get(server) + try: + config = await _config(server) + except BaseException: + if current: + await current.close() + if self._generations.get(server) is current: + self._generations.pop(server, None) + raise + self._accepting_work() if current and current.config == config and not current.closed: return current if current: diff --git a/prime-agent-runtime/test/test_mcp.py b/prime-agent-runtime/test/test_mcp.py index 806105a2d16..49d6dfb757d 100644 --- a/prime-agent-runtime/test/test_mcp.py +++ b/prime-agent-runtime/test/test_mcp.py @@ -21,6 +21,8 @@ _STDIO_FIXTURE = r"""import asyncio, json, os, sys +if pid_file := os.environ.get("FIXTURE_PID_FILE"): + open(pid_file, "w").write(str(os.getpid())) async def main(): while line := await asyncio.get_running_loop().run_in_executor(None, sys.stdin.readline): request = json.loads(line) @@ -145,6 +147,22 @@ async def scenario(): self.assertTrue(first.closed) self.assertIs(second, opened[-1]) + def test_missing_config_closes_cached_generation(self): + generation = self.generation({"type": "http", "url": "a"}, []) + mcp._registry._generations["svc"] = generation + + async def missing(_server): + raise KeyError("removed") + + async def scenario(): + with mock.patch.object(mcp, "_config", missing): + with self.assertRaises(KeyError): + await mcp._registry.get("svc") + + run(scenario()) + self.assertTrue(generation.closed) + self.assertNotIn("svc", mcp._registry._generations) + def test_reload_waits_for_in_flight_first_open(self): opening = asyncio.Event() release = asyncio.Event() @@ -487,6 +505,32 @@ async def scenario(): self.assertEqual(output["env"], "task-secret") self.assertIsNone(output["ambient"]) + def test_reload_reaps_real_acp_stdio_process(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = Path(tmp) / "stdio_server.py" + pid_file = Path(tmp) / "stdio.pid" + fixture.write_text(_STDIO_FIXTURE) + config = { + "type": "stdio", + "command": sys.executable, + "args": [str(fixture)], + "cwd": str(fixture.parent), + "env": {"FIXTURE_PID_FILE": str(pid_file)}, + "credentialSource": "acp", + } + + async def scenario(): + with mock.patch.object(mcp, "_config", new=mock.AsyncMock(return_value=config)): + await mcp._registry.tools("task-tools") + pid = int(pid_file.read_text()) + os.kill(pid, 0) + await mcp._registry.reload("task-tools") + return pid + + pid = run(scenario()) + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + def test_real_stdio_startup_diagnostic_is_safe_bounded_and_reaped(self): secret = "stdio-secret-SENTINEL" with tempfile.TemporaryDirectory() as tmp: From 8e525ed6c0bfc2a5b790492506e96abddd7d3d19 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 11:20:57 -0700 Subject: [PATCH 8/9] fix(coding-agent): isolate ACP MCP release failures --- packages/coding-agent/src/modes/acp/acp-mode.ts | 8 +++++++- .../coding-agent/src/modes/daemon/daemon-mode.ts | 3 +++ packages/coding-agent/test/acp-mcp.test.ts | 16 ++++++++++++++-- .../test/agent-session-services.test.ts | 3 +++ packages/coding-agent/test/daemon-mode.test.ts | 14 ++++++++++++++ prime-agent-runtime/src/rlm/mcp.py | 9 +-------- prime-agent-runtime/test/test_mcp.py | 14 +++++++------- 7 files changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 4915ec2f4c5..7e386963e50 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -479,6 +479,10 @@ export async function runAcpModeWithConnection( acpMcpServerNames = []; }; const replaceAcpMcpServers = async (servers: readonly acp.McpServer[], cwd: string): Promise => { + if (servers.length > 0 && acpMcpServerNames.length > 0) { + // Retry a prior best-effort close before admitting another session. + await clearAcpMcpServers(); + } if (servers.length === 0 && acpMcpServerNames.length === 0) return; if (!supportsMcpServers || !connection.replaceAcpMcpServers) { throw acp.RequestError.invalidParams({ reason: "MCP servers are unavailable in this ACP host" }); @@ -999,7 +1003,9 @@ export async function runAcpModeWithConnection( closing.unsubscribe?.(); // Keep the backing session fenced until a replacement ACP session is admitted. await closing.producer.close(); - await clearAcpMcpServers(); + // Host credentials are already gone before kernel release runs. Do not + // retain the ACP session slot if best-effort transport reaping fails. + await clearAcpMcpServers().catch(() => undefined); closedInputPause = inputPause; closedInputPauseKey = inputPauseKey; if (closing.inputPause === inputPause) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index cd6816be981..0bda7e5ea52 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4527,6 +4527,9 @@ export class AgentDaemon { if (!command.ownerId) throw new Error("ACP MCP owner id is required"); const state = this.getSessionState(command.activeSessionId); if (!state.clients.has(client)) throw new Error("Daemon client is not attached to this session"); + if (command.servers.length > 0 && state.runtime.session.isStreaming) { + throw new Error("Cannot replace ACP MCP servers while the agent is running"); + } const commandPause = command.servers.length > 0 ? state.runtime.session.acquireSessionInputPause() : undefined; try { diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts index 4b447734ef1..409d3f65f32 100644 --- a/packages/coding-agent/test/acp-mcp.test.ts +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -16,10 +16,13 @@ function runtimeHostFor(session: unknown): AgentSessionRuntime { } describe("ACP MCP servers", () => { - it("advertises capability, installs session-scoped config, and clears it on close", async () => { + it("installs session MCP config and frees the session slot when kernel release fails", async () => { const harness = await createHarness(); const replace = vi.spyOn(harness.session, "replaceAcpMcpServers").mockImplementation(() => undefined); - const release = vi.spyOn(harness.session, "releaseAcpMcpServers").mockResolvedValue(undefined); + const release = vi + .spyOn(harness.session, "releaseAcpMcpServers") + .mockRejectedValueOnce(new Error("kernel release failed")) + .mockResolvedValue(undefined); const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); const agentCwd = (await connection.getState()).cwd; const toAgent = new TransformStream(); @@ -77,6 +80,15 @@ describe("ACP MCP servers", () => { ); await handle.agent.request("session/close", { sessionId: created.sessionId }); expect(release).toHaveBeenCalledWith(ownerId, ["TaskTools", "LocalTools"]); + + // The failed release is retried before a new ACP session is admitted, but it + // must not leave the single-session slot occupied. + const replacement = await handle.agent.request("session/new", { + cwd: harness.tempDir, + mcpServers: [{ type: "http", name: "NextTools", url: "https://next.example/mcp", headers: [] }], + }); + expect(release).toHaveBeenCalledWith(ownerId, ["TaskTools", "LocalTools"]); + await handle.agent.request("session/close", { sessionId: replacement.sessionId }); } finally { handle.close(); await toAgent.writable.close().catch(() => undefined); diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 901d9fac027..c6e38c878f8 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -186,6 +186,9 @@ describe("createAgentSessionFromServices", () => { expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`."); expect(session.systemPrompt).not.toContain("task-secret"); rebuildRuntime.mockClear(); + const waitForIdle = vi.spyOn(session.agent, "waitForIdle"); + await session.releaseAcpMcpServers("unknown-owner", ["task"]); + expect(waitForIdle).not.toHaveBeenCalled(); const originalProvisioner = Reflect.get(session, "_ipythonKernelProvisioner"); const execute = vi.fn(async (_code: string) => ({ status: "ok" })); Reflect.set(session, "_ipythonKernelProvisioner", { manager: { isRunning: true, execute } }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 6a5126a12eb..bc5d92e7226 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -199,6 +199,7 @@ describe("daemon mode helpers", () => { state.runtime = { ...state.runtime, session: { + isStreaming: false, replaceAcpMcpServers, releaseAcpMcpServers, acquireSessionInputPause: () => ({ release: vi.fn() }), @@ -232,6 +233,19 @@ describe("daemon mode helpers", () => { ownerId: "owner-token", servers: [{ name: "task", type: "http", url: "https://task.example/mcp", headers: {} }], }); + const releaseCount = releaseAcpMcpServers.mock.calls.length; + (state.runtime.session as unknown as { isStreaming: boolean }).isStreaming = true; + await expect( + internals.handleCommand(owner, { + type: "replace_acp_mcp_servers", + activeSessionId: state.activeSessionId, + ownerId: "owner-token", + servers: [{ name: "next", type: "http", url: "https://next.example/mcp", headers: {} }], + }), + ).rejects.toThrow("while the agent is running"); + expect(releaseAcpMcpServers).toHaveBeenCalledTimes(releaseCount); + (state.runtime.session as unknown as { isStreaming: boolean }).isStreaming = false; + await expect( internals.handleCommand(other, { type: "replace_acp_mcp_servers", diff --git a/prime-agent-runtime/src/rlm/mcp.py b/prime-agent-runtime/src/rlm/mcp.py index 92216f69604..13ac0b94d68 100644 --- a/prime-agent-runtime/src/rlm/mcp.py +++ b/prime-agent-runtime/src/rlm/mcp.py @@ -358,14 +358,7 @@ async def _get(self, server: str) -> _Generation: async def _get_locked(self, server: str) -> _Generation: self._accepting_work() current = self._generations.get(server) - try: - config = await _config(server) - except BaseException: - if current: - await current.close() - if self._generations.get(server) is current: - self._generations.pop(server, None) - raise + config = await _config(server) self._accepting_work() if current and current.config == config and not current.closed: return current diff --git a/prime-agent-runtime/test/test_mcp.py b/prime-agent-runtime/test/test_mcp.py index 49d6dfb757d..2ff1999752e 100644 --- a/prime-agent-runtime/test/test_mcp.py +++ b/prime-agent-runtime/test/test_mcp.py @@ -147,21 +147,21 @@ async def scenario(): self.assertTrue(first.closed) self.assertIs(second, opened[-1]) - def test_missing_config_closes_cached_generation(self): + def test_config_failure_preserves_cached_generation(self): generation = self.generation({"type": "http", "url": "a"}, []) mcp._registry._generations["svc"] = generation - async def missing(_server): - raise KeyError("removed") + async def unavailable(_server): + raise RuntimeError("host request timed out") async def scenario(): - with mock.patch.object(mcp, "_config", missing): - with self.assertRaises(KeyError): + with mock.patch.object(mcp, "_config", unavailable): + with self.assertRaises(RuntimeError): await mcp._registry.get("svc") run(scenario()) - self.assertTrue(generation.closed) - self.assertNotIn("svc", mcp._registry._generations) + self.assertFalse(generation.closed) + self.assertIs(mcp._registry._generations["svc"], generation) def test_reload_waits_for_in_flight_first_open(self): opening = asyncio.Event() From 80136a731a2c4ba9929865745bc052a8a5f6a86e Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 11:24:38 -0700 Subject: [PATCH 9/9] fix(coding-agent): retry empty-session MCP cleanup --- packages/coding-agent/src/modes/acp/acp-mode.ts | 5 +++-- packages/coding-agent/test/acp-mcp.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 7e386963e50..59ae63abebc 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -479,8 +479,9 @@ export async function runAcpModeWithConnection( acpMcpServerNames = []; }; const replaceAcpMcpServers = async (servers: readonly acp.McpServer[], cwd: string): Promise => { - if (servers.length > 0 && acpMcpServerNames.length > 0) { - // Retry a prior best-effort close before admitting another session. + if (acpMcpServerNames.length > 0) { + // Retry a prior best-effort close before admitting another session, + // including one that does not declare replacement MCP servers. await clearAcpMcpServers(); } if (servers.length === 0 && acpMcpServerNames.length === 0) return; diff --git a/packages/coding-agent/test/acp-mcp.test.ts b/packages/coding-agent/test/acp-mcp.test.ts index 409d3f65f32..a09ee0e7fa5 100644 --- a/packages/coding-agent/test/acp-mcp.test.ts +++ b/packages/coding-agent/test/acp-mcp.test.ts @@ -85,7 +85,7 @@ describe("ACP MCP servers", () => { // must not leave the single-session slot occupied. const replacement = await handle.agent.request("session/new", { cwd: harness.tempDir, - mcpServers: [{ type: "http", name: "NextTools", url: "https://next.example/mcp", headers: [] }], + mcpServers: [], }); expect(release).toHaveBeenCalledWith(ownerId, ["TaskTools", "LocalTools"]); await handle.agent.request("session/close", { sessionId: replacement.sessionId });