From 53c9a2bc796c8c1ef07f03c1e63873f0a2476efd Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:58:07 +0530 Subject: [PATCH 1/7] feat(mcp): support protocol revision 2026-07-28 on SDK v2 Adopt the official TypeScript SDK v2 packages (@modelcontextprotocol/server, @modelcontextprotocol/client) and serve MCP protocol revision 2026-07-28 alongside the 2025 revisions on the same stdio connection. Modern era: - No initialize handshake and no Mcp-Session-Id. The protocol revision and the client's declared capabilities arrive in the _meta envelope of every request. - server/discover advertises the supported modern revisions, capabilities and instructions. - Results carry resultType, and cacheable operations carry ttlMs/cacheScope via server-level cache hints (private scope, since every result depends on local workspace and Xcode state). - Direct requests are served without prior discovery. - Per-request io.modelcontextprotocol/logLevel replaces logging/setLevel. - Notifications are delivered over subscriptions/listen. Legacy era is unchanged: an initialize opening pins the connection to the 2025 handshake, keeps logging/setLevel, and never sees modern-only result members. Serving contexts now build a fresh server instance per connection (and per HTTP request for the fetch-shaped handler, which the SDK entry uses to enforce the MCP-Protocol-Version / Mcp-Method / Mcp-Name header contract). The expensive manifest, tool-module and resource-module resolution stays process level and is replayed cheaply onto each instance; application session state (session defaults, debugger sessions, log captures) is process scoped and survives instance replacement, including the discarded server/discover probe instance. Sentry MCP instrumentation still wraps every instance - the SDK v2 McpServer passes its structural check. Because the released integration derives protocol and client identity from the initialize handshake, modern-era identity is now published from the validated _meta envelope so no observability is lost. Adds lifecycle, modern discovery/direct, legacy, notification, protocol-helper and instrumentation test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- CHANGELOG.md | 7 + package-lock.json | 990 +----------------- package.json | 3 +- scripts/probe-xcode-mcpbridge.ts | 7 +- src/cli/cli-tool-catalog.ts | 2 +- src/core/__tests__/resources.test.ts | 8 +- src/core/resources.ts | 21 +- src/daemon/daemon-server.ts | 2 +- src/daemon/protocol.ts | 2 +- .../fixtures/fake-xcode-tools-server.mjs | 4 +- .../__tests__/manager.test.ts | 3 +- .../__tests__/registry.integration.test.ts | 6 +- .../bridge-response-artifact.ts | 2 +- .../xcode-tools-bridge/bridge-tool-result.ts | 2 +- src/integrations/xcode-tools-bridge/client.ts | 9 +- src/integrations/xcode-tools-bridge/core.ts | 2 +- src/integrations/xcode-tools-bridge/index.ts | 2 +- .../xcode-tools-bridge/manager.ts | 19 +- .../xcode-tools-bridge/registry.ts | 47 +- .../xcode-tools-bridge/tool-service.ts | 2 +- src/runtime/types.ts | 2 +- .../__tests__/mcp-instrumentation.test.ts | 177 ++++ .../__tests__/mcp-legacy-protocol.test.ts | 136 +++ src/server/__tests__/mcp-lifecycle.test.ts | 4 +- .../__tests__/mcp-modern-protocol.test.ts | 213 ++++ .../__tests__/mcp-notifications.test.ts | 157 +++ src/server/__tests__/mcp-protocol.test.ts | 111 ++ .../__tests__/mcp-serving-lifecycle.test.ts | 253 +++++ src/server/__tests__/mcp-shutdown.test.ts | 18 +- src/server/__tests__/raw-mcp-peer.ts | 139 +++ src/server/__tests__/server.test.ts | 6 +- src/server/__tests__/serving-test-fixtures.ts | 101 ++ src/server/bootstrap.ts | 98 +- src/server/mcp-instrumentation.ts | 85 ++ src/server/mcp-lifecycle.ts | 24 +- src/server/mcp-protocol.ts | 187 ++++ src/server/mcp-shutdown.ts | 13 +- src/server/request-lifecycle.ts | 32 +- src/server/server-state.ts | 72 +- src/server/server.ts | 180 +++- src/server/start-mcp-server.ts | 19 +- .../__tests__/e2e-mcp-idle-timeout.test.ts | 4 +- src/smoke-tests/mcp-test-harness.ts | 3 +- .../__tests__/json-fixture-schema.test.ts | 2 +- src/snapshot-tests/json-schema-validation.ts | 2 +- src/snapshot-tests/mcp-harness.ts | 13 +- .../mcp-tool-contract-fixtures.ts | 2 +- src/snapshot-tests/resource-harness.ts | 4 +- src/utils/__tests__/tool-registry.test.ts | 2 +- src/utils/sentry.ts | 63 ++ src/utils/tool-registry.ts | 130 ++- 52 files changed, 2242 insertions(+), 1152 deletions(-) create mode 100644 src/server/__tests__/mcp-instrumentation.test.ts create mode 100644 src/server/__tests__/mcp-legacy-protocol.test.ts create mode 100644 src/server/__tests__/mcp-modern-protocol.test.ts create mode 100644 src/server/__tests__/mcp-notifications.test.ts create mode 100644 src/server/__tests__/mcp-protocol.test.ts create mode 100644 src/server/__tests__/mcp-serving-lifecycle.test.ts create mode 100644 src/server/__tests__/raw-mcp-peer.ts create mode 100644 src/server/__tests__/serving-test-fixtures.ts create mode 100644 src/server/mcp-instrumentation.ts create mode 100644 src/server/mcp-protocol.ts diff --git a/AGENTS.md b/AGENTS.md index 148b3cded..6ccb89daf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ ESM TypeScript project (`type: module`). Key layers: - `src/cli/` - CLI entrypoint, yargs wiring, daemon routing -- `src/server/` - MCP stdio server, lifecycle, workflow/resource registration +- `src/server/` - MCP serving contexts (dual-era stdio via the SDK v2 `serveStdio` entry), protocol constants, lifecycle, workflow/resource registration - `src/runtime/` - Config bootstrap, session state, tool catalog assembly - `src/core/manifest/` - YAML manifest loading, validation, tool module imports - `src/mcp/tools/` - Tool implementations grouped by workflow (mirrors `manifests/workflows/`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1130fa825..330d846ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,16 @@ ## [Unreleased] +### Added + +- MCP protocol revision `2026-07-28` support alongside the existing 2025 revisions. XcodeBuildMCP now serves both protocol eras on one stdio connection: a modern client opens with a `_meta` envelope (protocol version plus client capabilities on every request) and needs no `initialize` handshake or `Mcp-Session-Id`, while 2025-era clients keep using `initialize` unchanged. `server/discover` is answered with the supported modern revisions, capabilities and instructions; modern results carry `resultType` and, for cacheable operations, `ttlMs`/`cacheScope`. + ### Changed - Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation. +- Migrated from `@modelcontextprotocol/sdk` v1 to the official TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`). +- A fresh MCP server instance is now built per serving context instead of one process-wide singleton. Session defaults, debugger sessions, log captures and other application state remain process scoped and are unaffected when a protocol instance is replaced. +- Modern-era clients set log verbosity through the per-request `io.modelcontextprotocol/logLevel` metadata key; `logging/setLevel` continues to work for 2025-era clients. ## [2.7.0] diff --git a/package-lock.json b/package-lock.json index b26bebc17..e8e429c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", @@ -718,18 +719,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -853,44 +842,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.0.0", "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" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "engines": { + "node": ">=20" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -2569,19 +2561,6 @@ "url": "https://opencollective.com/vitest" } }, - "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/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2617,6 +2596,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2629,23 +2609,6 @@ "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", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2746,43 +2709,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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/bplist-parser": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", @@ -2845,15 +2771,6 @@ "esbuild": ">=0.18" } }, - "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/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2864,35 +2781,6 @@ "node": ">=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/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3041,27 +2929,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "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", @@ -3069,37 +2936,6 @@ "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.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3138,15 +2974,6 @@ "dev": true, "license": "MIT" }, - "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", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3157,20 +2984,6 @@ "node": ">=8" } }, - "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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3178,12 +2991,6 @@ "dev": true, "license": "MIT" }, - "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": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3191,33 +2998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -3225,18 +3005,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3288,12 +3056,6 @@ "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/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3569,15 +3331,6 @@ "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", @@ -3609,71 +3362,11 @@ "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.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "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==", + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -3731,6 +3424,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, "funding": [ { "type": "github", @@ -3796,23 +3490,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "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": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3896,24 +3573,6 @@ "node": ">=18.3.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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3929,15 +3588,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3947,43 +3597,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "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-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -4027,18 +3640,6 @@ "node": ">=10.13.0" } }, - "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", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4056,39 +3657,6 @@ "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.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4096,47 +3664,11 @@ "dev": true, "license": "MIT" }, - "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/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/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -4167,30 +3699,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4246,12 +3754,6 @@ "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4360,14 +3862,9 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, - "node_modules/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/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4836,36 +4333,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4890,27 +4357,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5018,15 +4464,6 @@ "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/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5041,23 +4478,12 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "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.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -5072,27 +4498,6 @@ "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", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5182,15 +4587,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "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/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5226,16 +4622,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", - "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", - "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", @@ -5423,19 +4809,6 @@ "dev": true, "license": "ISC" }, - "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/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5446,21 +4819,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5482,30 +4840,6 @@ ], "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -5532,6 +4866,7 @@ "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==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5660,22 +4995,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5700,32 +5019,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "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.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5739,49 +5032,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "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": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5803,78 +5053,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "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.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "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", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5989,15 +5167,6 @@ "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.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -6341,15 +5510,6 @@ "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/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -6524,37 +5684,6 @@ "node": ">= 0.8.0" } }, - "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/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -6617,15 +5746,6 @@ "dev": true, "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/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6649,15 +5769,6 @@ "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/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -7097,12 +6208,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7225,15 +6330,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/package.json b/package.json index 18eb6b5cc..b50f83936 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", diff --git a/scripts/probe-xcode-mcpbridge.ts b/scripts/probe-xcode-mcpbridge.ts index 0d5714c58..db9bf9e18 100644 --- a/scripts/probe-xcode-mcpbridge.ts +++ b/scripts/probe-xcode-mcpbridge.ts @@ -1,6 +1,5 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { Client, specTypeSchemas } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import process from 'node:process'; function parseArgs(argv: string[]): { limit: number; callWindows: boolean } { @@ -72,7 +71,7 @@ async function main(): Promise { if (callWindows) { const windows = await client.request( { method: 'tools/call', params: { name: 'XcodeListWindows', arguments: {} } }, - CompatibilityCallToolResultSchema, + specTypeSchemas.CompatibilityCallToolResult, { timeout: 15_000 }, ); console.log('XcodeListWindows:', windows); diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index 2d4a8eb5f..0479cbb8a 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -1,4 +1,4 @@ -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolAnnotations } from '@modelcontextprotocol/server'; import type { ToolSchemaShape } from '../core/plugin-types.ts'; import { startDaemonBackground } from './daemon-control.ts'; import { DaemonClient } from './daemon-client.ts'; diff --git a/src/core/__tests__/resources.test.ts b/src/core/__tests__/resources.test.ts index ecb52cbce..62d14e851 100644 --- a/src/core/__tests__/resources.test.ts +++ b/src/core/__tests__/resources.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CacheHint, McpServer } from '@modelcontextprotocol/server'; import type { PredicateContext } from '../../visibility/predicate-types.ts'; import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; @@ -67,7 +67,7 @@ describe('resources', () => { let registeredResources: Array<{ name: string; uri: string; - metadata: { mimeType: string; title: string }; + metadata: { mimeType: string; title: string; cacheHint?: CacheHint }; handler: any; }>; @@ -75,10 +75,10 @@ describe('resources', () => { vi.clearAllMocks(); registeredResources = []; mockServer = { - resource: ( + registerResource: ( name: string, uri: string, - metadata: { mimeType: string; title: string }, + metadata: { mimeType: string; title: string; cacheHint?: CacheHint }, handler: any, ) => { registeredResources.push({ name, uri, metadata, handler }); diff --git a/src/core/resources.ts b/src/core/resources.ts index 4d1cf647d..1a5a729be 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -5,14 +5,14 @@ * predicate-aware registration through the Model Context Protocol resource system. */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import type { McpServer, ReadResourceResult } from '@modelcontextprotocol/server'; import { log } from '../utils/logging/index.ts'; import { loadManifest } from './manifest/load-manifest.ts'; import { importResourceModule } from './manifest/import-resource-module.ts'; import type { ResourceManifestEntry } from './manifest/schema.ts'; import type { PredicateContext } from '../visibility/predicate-types.ts'; import { isResourceExposedForRuntime } from '../visibility/exposure.ts'; +import { MCP_RESOURCE_CACHE_HINT } from '../server/mcp-protocol.ts'; /** * Resource metadata interface (runtime-assembled from manifest + imported module). @@ -79,7 +79,20 @@ export async function registerResources( ctx: PredicateContext, ): Promise { const resources = await loadResources(ctx); + registerLoadedResources(server, resources); + return true; +} +/** + * Register already-loaded resources onto a server instance. + * + * Loading is process-level work (manifest parse plus module import); a fresh + * server per serving context only needs the cheap registration step. + */ +export function registerLoadedResources( + server: McpServer, + resources: Map, +): void { for (const [uri, resource] of resources) { const readCallback = async (resourceUri: URL): Promise => { const result = await resource.handler(resourceUri); @@ -92,12 +105,13 @@ export async function registerResources( }; }; - server.resource( + server.registerResource( resource.name, uri, { mimeType: resource.mimeType, title: resource.description, + cacheHint: MCP_RESOURCE_CACHE_HINT, }, readCallback, ); @@ -106,7 +120,6 @@ export async function registerResources( } log('info', `Registered ${resources.size} resources`); - return true; } /** diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index 2a2fdf6d1..d257ac313 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -1,6 +1,6 @@ import net from 'node:net'; import { writeFrame, createFrameReader } from './framing.ts'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import type { ToolCatalog } from '../runtime/types.ts'; import type { AnyFragment } from '../types/domain-fragments.ts'; import type { diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 976640504..857827eaa 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -1,4 +1,4 @@ -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolAnnotations } from '@modelcontextprotocol/server'; import type { StructuredToolOutput } from '../rendering/types.ts'; import type { NextStep, NextStepParamsMap } from '../types/common.ts'; import type { AnyFragment } from '../types/domain-fragments.ts'; diff --git a/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs index 6718f409a..0fefca22f 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs +++ b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs @@ -1,5 +1,5 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod'; const server = new McpServer( diff --git a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts index 3bf91201a..44c539576 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts @@ -1,5 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { McpServer, Tool } from '@modelcontextprotocol/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { diff --git a/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts index c02ec611e..40039e3f9 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { McpServer, type CallToolResult } from '@modelcontextprotocol/server'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { XcodeToolsBridgeClient } from '../client.ts'; diff --git a/src/integrations/xcode-tools-bridge/bridge-response-artifact.ts b/src/integrations/xcode-tools-bridge/bridge-response-artifact.ts index e2b4ec36f..f907ebf53 100644 --- a/src/integrations/xcode-tools-bridge/bridge-response-artifact.ts +++ b/src/integrations/xcode-tools-bridge/bridge-response-artifact.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import type { SerializedBridgeTool } from './core.ts'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; diff --git a/src/integrations/xcode-tools-bridge/bridge-tool-result.ts b/src/integrations/xcode-tools-bridge/bridge-tool-result.ts index fd8e9793d..b645de35b 100644 --- a/src/integrations/xcode-tools-bridge/bridge-tool-result.ts +++ b/src/integrations/xcode-tools-bridge/bridge-tool-result.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import type { NextStepParamsMap } from '../../types/common.ts'; import type { XcodeToolsBridgeStatus } from './core.ts'; import type { ProxySyncResult } from './registry.ts'; diff --git a/src/integrations/xcode-tools-bridge/client.ts b/src/integrations/xcode-tools-bridge/client.ts index 10cd1a587..adab5bfb2 100644 --- a/src/integrations/xcode-tools-bridge/client.ts +++ b/src/integrations/xcode-tools-bridge/client.ts @@ -1,10 +1,9 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { Client, specTypeSchemas } from '@modelcontextprotocol/client'; +import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; import { StdioClientTransport, type StdioServerParameters, -} from '@modelcontextprotocol/sdk/client/stdio.js'; -import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +} from '@modelcontextprotocol/client/stdio'; import process from 'node:process'; export interface XcodeToolsBridgeClientStatus { @@ -149,7 +148,7 @@ export class XcodeToolsBridgeClient { } const result: unknown = await this.client.request( { method: 'tools/call', params: { name, arguments: args } }, - CompatibilityCallToolResultSchema, + specTypeSchemas.CompatibilityCallToolResult, { timeout: opts.timeoutMs ?? this.options.callToolTimeoutMs, resetTimeoutOnProgress: true, diff --git a/src/integrations/xcode-tools-bridge/core.ts b/src/integrations/xcode-tools-bridge/core.ts index ee8780a52..cf2dcb3cb 100644 --- a/src/integrations/xcode-tools-bridge/core.ts +++ b/src/integrations/xcode-tools-bridge/core.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import process from 'node:process'; import { promisify } from 'node:util'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool } from '@modelcontextprotocol/server'; import type { XcodeToolsBridgeClientStatus } from './client.ts'; const execFileAsync = promisify(execFile); diff --git a/src/integrations/xcode-tools-bridge/index.ts b/src/integrations/xcode-tools-bridge/index.ts index 0dba39ac7..512669990 100644 --- a/src/integrations/xcode-tools-bridge/index.ts +++ b/src/integrations/xcode-tools-bridge/index.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import type { BridgeToolResult } from './bridge-tool-result.ts'; import { XcodeToolsBridgeManager } from './manager.ts'; import { StandaloneXcodeToolsBridge } from './standalone.ts'; diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index 5c4f2d315..b7e4450ff 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { log } from '../../utils/logger.ts'; import { writeBridgeToolListResponseArtifact } from './bridge-response-artifact.ts'; import { @@ -18,7 +18,7 @@ import { import { XcodeIdeToolService } from './tool-service.ts'; export class XcodeToolsBridgeManager { - private readonly server: McpServer; + private server: McpServer; private readonly registry: XcodeToolsProxyRegistry; private readonly service: XcodeIdeToolService; @@ -40,6 +40,21 @@ export class XcodeToolsBridgeManager { }); } + /** + * Binds the bridge to the server instance of the current serving context. + * + * The bridge is a process-level singleton while SDK v2 servers are per + * serving context, so proxied tools are moved onto the new instance instead + * of being lost. + */ + bindServer(server: McpServer): void { + if (this.server === server) { + return; + } + this.server = server; + this.registry.rebind(server); + } + setWorkflowEnabled(enabled: boolean): void { this.workflowEnabled = enabled; this.service.setWorkflowEnabled(enabled); diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts index 51c746f75..e295d700c 100644 --- a/src/integrations/xcode-tools-bridge/registry.ts +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -1,5 +1,5 @@ -import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { McpServer, RegisteredTool } from '@modelcontextprotocol/server'; +import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/server'; import * as z from 'zod'; import { jsonSchemaToZod } from './jsonschema-to-zod.ts'; @@ -12,6 +12,7 @@ type Entry = { remoteName: string; localName: string; fingerprint: string; + tool: Tool; registered: RegisteredTool; }; @@ -23,13 +24,50 @@ export type ProxySyncResult = { }; export class XcodeToolsProxyRegistry { - private readonly server: McpServer; + private server: McpServer; private readonly tools: Map = new Map(); + private callRemoteTool: CallRemoteTool | null = null; constructor(server: McpServer) { this.server = server; } + /** + * Re-targets the registry at a freshly built server instance. + * + * SDK v2 creates a new server per serving context, so the proxied Xcode tools + * must be re-registered on the new instance for `tools/list` and + * `notifications/tools/list_changed` to stay correct. + */ + rebind(server: McpServer): void { + if (this.server === server) { + return; + } + + const previous = [...this.tools.values()]; + for (const entry of previous) { + try { + entry.registered.remove(); + } catch { + // The previous instance may already be closed. + } + } + this.tools.clear(); + this.server = server; + + const callRemoteTool = this.callRemoteTool; + if (!callRemoteTool) { + return; + } + + for (const entry of previous) { + this.tools.set(entry.remoteName, { + ...entry, + registered: this.registerProxyTool(entry.tool, entry.localName, callRemoteTool), + }); + } + } + getRegisteredToolNames(): string[] { return [...this.tools.values()].map((t) => t.localName).sort(); } @@ -46,6 +84,7 @@ export class XcodeToolsProxyRegistry { } sync(remoteTools: Tool[], callRemoteTool: CallRemoteTool): ProxySyncResult { + this.callRemoteTool = callRemoteTool; const desiredRemoteNames = new Set(remoteTools.map((t) => t.name)); let added = 0; let updated = 0; @@ -62,6 +101,7 @@ export class XcodeToolsProxyRegistry { remoteName, localName, fingerprint, + tool: remoteTool, registered: this.registerProxyTool(remoteTool, localName, callRemoteTool), }); added += 1; @@ -74,6 +114,7 @@ export class XcodeToolsProxyRegistry { remoteName, localName, fingerprint, + tool: remoteTool, registered: this.registerProxyTool(remoteTool, localName, callRemoteTool), }); updated += 1; diff --git a/src/integrations/xcode-tools-bridge/tool-service.ts b/src/integrations/xcode-tools-bridge/tool-service.ts index ab9f8d28e..9564d4ea2 100644 --- a/src/integrations/xcode-tools-bridge/tool-service.ts +++ b/src/integrations/xcode-tools-bridge/tool-service.ts @@ -1,4 +1,4 @@ -import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, Tool } from '@modelcontextprotocol/server'; import { XcodeToolsBridgeClient, type XcodeToolsBridgeClientOptions, diff --git a/src/runtime/types.ts b/src/runtime/types.ts index b382a3475..18d05f972 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -1,4 +1,4 @@ -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolAnnotations } from '@modelcontextprotocol/server'; import type { ToolSchemaShape } from '../core/plugin-types.ts'; import type { StructuredOutputSchemaRef } from '../core/structured-output-schema.ts'; import type { diff --git a/src/server/__tests__/mcp-instrumentation.test.ts b/src/server/__tests__/mcp-instrumentation.test.ts new file mode 100644 index 000000000..06d9cfde1 --- /dev/null +++ b/src/server/__tests__/mcp-instrumentation.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Sentry from '@sentry/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { startStdioServer } from '../server.ts'; +import { __resetServerStateForTests, getServer } from '../server-state.ts'; +import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; +import { + __resetMcpInstrumentationForTests, + recordModernProtocolEnvelope, +} from '../mcp-instrumentation.ts'; +import { instrumentMcpRequestLifecycle } from '../request-lifecycle.ts'; +import { MODERN_PROTOCOL_VERSION } from '../mcp-protocol.ts'; +import { createTestRegistrations, TEST_TOOL_NAME } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; + +vi.mock('../../utils/sentry.ts', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + setMcpProtocolContext: vi.fn(), + recordMcpServingContextMetric: vi.fn(), + }; +}); + +import { recordMcpServingContextMetric, setMcpProtocolContext } from '../../utils/sentry.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +beforeEach(() => { + vi.mocked(setMcpProtocolContext).mockClear(); + vi.mocked(recordMcpServingContextMetric).mockClear(); + __resetMcpInstrumentationForTests(); +}); + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +describe('Sentry MCP instrumentation compatibility with SDK v2', () => { + it('accepts the SDK v2 McpServer shape and returns the same instance', () => { + const server = new McpServer({ name: 'probe', version: '1.0.0' }); + const wrapped = Sentry.wrapMcpServerWithSentry(server, { + recordInputs: false, + recordOutputs: false, + }); + + expect(wrapped).toBe(server); + expect(typeof wrapped.registerTool).toBe('function'); + expect(typeof wrapped.registerResource).toBe('function'); + expect(typeof wrapped.registerPrompt).toBe('function'); + expect(typeof wrapped.connect).toBe('function'); + }); + + it('keeps a wrapped server functional across a modern-era request', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + const response = await peer.request('tools/call', { + _meta: modernMeta(), + name: TEST_TOOL_NAME, + arguments: { text: 'instrumented' }, + }); + + expect(response.error).toBeUndefined(); + expect(getServer()).toBeDefined(); + }); + + it('records a serving-context metric per constructed instance', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(vi.mocked(recordMcpServingContextMetric)).toHaveBeenCalledWith({ + transport: 'stdio', + era: 'modern', + }); + }); + + it('publishes modern protocol identity that initialize-based extraction cannot see', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + await peer.request('tools/list', { + _meta: modernMeta({ + clientName: 'observed-client', + clientVersion: '4.5.6', + clientCapabilities: { elicitation: { form: {} } }, + }), + }); + + expect(vi.mocked(setMcpProtocolContext)).toHaveBeenCalledWith({ + era: 'modern', + protocolVersion: MODERN_PROTOCOL_VERSION, + clientName: 'observed-client', + clientVersion: '4.5.6', + clientCapabilities: ['elicitation'], + }); + }); + + it('does not republish an unchanged protocol identity', () => { + const observation = { + method: 'tools/list', + envelope: { + protocolVersion: MODERN_PROTOCOL_VERSION, + clientInfo: { name: 'stable', version: '1.0.0' }, + clientCapabilities: {}, + }, + }; + + recordModernProtocolEnvelope(observation); + recordModernProtocolEnvelope(observation); + + expect(vi.mocked(setMcpProtocolContext)).toHaveBeenCalledTimes(1); + }); + + it('republishes when the observed client identity changes', () => { + recordModernProtocolEnvelope({ + method: 'tools/list', + envelope: { + protocolVersion: MODERN_PROTOCOL_VERSION, + clientInfo: { name: 'first', version: '1.0.0' }, + clientCapabilities: {}, + }, + }); + recordModernProtocolEnvelope({ + method: 'tools/list', + envelope: { + protocolVersion: MODERN_PROTOCOL_VERSION, + clientInfo: { name: 'second', version: '2.0.0' }, + clientCapabilities: {}, + }, + }); + + expect(vi.mocked(setMcpProtocolContext)).toHaveBeenCalledTimes(2); + }); + + it('never reports a modern envelope for legacy-era traffic', async () => { + const observed: string[] = []; + const transport = { + onmessage: undefined as ((message: unknown, extra?: unknown) => void) | undefined, + start: async (): Promise => undefined, + close: async (): Promise => undefined, + send: async (): Promise => undefined, + }; + + instrumentMcpRequestLifecycle(transport as never, { + onModernEnvelope: (observation) => observed.push(observation.method), + }); + transport.onmessage = () => undefined; + await transport.start(); + + transport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'legacy', version: '1.0.0' }, + }, + }); + + expect(observed).toEqual([]); + }); +}); diff --git a/src/server/__tests__/mcp-legacy-protocol.test.ts b/src/server/__tests__/mcp-legacy-protocol.test.ts new file mode 100644 index 000000000..0e4178c1f --- /dev/null +++ b/src/server/__tests__/mcp-legacy-protocol.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { startStdioServer } from '../server.ts'; +import { __resetServerStateForTests } from '../server-state.ts'; +import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { + createTestRegistrations, + TEST_RESOURCE_URI, + TEST_TOOL_NAME, +} from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; +import { getLogLevel, setLogLevel } from '../../utils/logger.ts'; + +let handle: StdioServerHandle | null = null; +let client: Client | null = null; + +async function serveLegacyClient(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + handle = startStdioServer(createTestRegistrations(), { transport: serverTransport }); + client = new Client({ name: 'legacy-test-client', version: '1.0.0' }); + await client.connect(clientTransport); + return client; +} + +afterEach(async () => { + await client?.close(); + await handle?.close(); + client = null; + handle = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +describe('legacy era serving (2025 revisions)', () => { + it('completes the initialize handshake and reports server identity', async () => { + const legacyClient = await serveLegacyClient(); + + expect(legacyClient.getProtocolEra()).toBe('legacy'); + expect(legacyClient.getServerVersion()).toMatchObject({ name: 'xcodebuildmcp' }); + expect(legacyClient.getServerCapabilities()).toMatchObject({ + tools: { listChanged: true }, + resources: { subscribe: true, listChanged: true }, + }); + expect(legacyClient.getInstructions()).toContain('XcodeBuildMCP provides'); + }); + + it('lists and calls tools registered from the shared registration plan', async () => { + const legacyClient = await serveLegacyClient(); + + const tools = await legacyClient.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain(TEST_TOOL_NAME); + + const result = await legacyClient.callTool({ + name: TEST_TOOL_NAME, + arguments: { text: 'legacy' }, + }); + expect(result.structuredContent).toMatchObject({ + data: { artifacts: { bundleId: 'echo:legacy' } }, + }); + }); + + it('lists and reads resources', async () => { + const legacyClient = await serveLegacyClient(); + + const resources = await legacyClient.listResources(); + expect(resources.resources.map((resource) => resource.uri)).toContain(TEST_RESOURCE_URI); + + const read = await legacyClient.readResource({ uri: TEST_RESOURCE_URI }); + expect(read.contents[0]).toMatchObject({ + uri: TEST_RESOURCE_URI, + text: 'probe-contents', + mimeType: 'text/plain', + }); + }); + + it('omits modern-only result members from legacy results', async () => { + const legacyClient = await serveLegacyClient(); + + const tools = (await legacyClient.listTools()) as unknown as Record; + expect(tools).not.toHaveProperty('resultType'); + expect(tools).not.toHaveProperty('ttlMs'); + expect(tools).not.toHaveProperty('cacheScope'); + }); + + it('still honours logging/setLevel for legacy clients', async () => { + setLogLevel('info'); + const legacyClient = await serveLegacyClient(); + + await expect(legacyClient.setLoggingLevel('debug')).resolves.toEqual({}); + expect(getLogLevel()).toBe('debug'); + + setLogLevel('info'); + }); + + it('rejects a legacy initialize once the connection is pinned to the modern era', async () => { + const peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + const modern = await peer.request('tools/list', { _meta: modernMeta() }); + expect(modern.error).toBeUndefined(); + + const legacy = await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'late-legacy', version: '1.0.0' }, + }); + + expect(legacy.result).toBeUndefined(); + expect(legacy.error).toBeDefined(); + + await peer.close(); + }); + + it('serves a legacy opening when the connection has not been pinned yet', async () => { + const peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + const initialize = await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'raw-legacy', version: '1.0.0' }, + }); + + expect(initialize.error).toBeUndefined(); + expect(initialize.result?.protocolVersion).toBe('2025-06-18'); + expect(initialize.result?.serverInfo).toMatchObject({ name: 'xcodebuildmcp' }); + expect(initialize.result).not.toHaveProperty('resultType'); + + await peer.close(); + }); +}); diff --git a/src/server/__tests__/mcp-lifecycle.test.ts b/src/server/__tests__/mcp-lifecycle.test.ts index 91921b88a..6bb94764e 100644 --- a/src/server/__tests__/mcp-lifecycle.test.ts +++ b/src/server/__tests__/mcp-lifecycle.test.ts @@ -98,7 +98,7 @@ describe('mcp lifecycle coordinator', () => { expect(onShutdown.mock.calls[0]?.[0]?.reason).toBe('stdin-end'); }); - it('shuts down cleanly even if stdin closes before a server is registered', async () => { + it('shuts down cleanly even if stdin closes before a serving handle is registered', async () => { const processRef = new TestProcess(); const onShutdown = vi.fn().mockResolvedValue(undefined); const coordinator = createMcpLifecycleCoordinator({ @@ -113,7 +113,7 @@ describe('mcp lifecycle coordinator', () => { expect(onShutdown).toHaveBeenCalledTimes(1); }); - expect(onShutdown.mock.calls[0]?.[0]?.server).toBe(null); + expect(onShutdown.mock.calls[0]?.[0]?.serving).toBe(null); }); it('maps unhandled rejections to crash shutdowns', async () => { diff --git a/src/server/__tests__/mcp-modern-protocol.test.ts b/src/server/__tests__/mcp-modern-protocol.test.ts new file mode 100644 index 000000000..a82e1ce37 --- /dev/null +++ b/src/server/__tests__/mcp-modern-protocol.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { startStdioServer } from '../server.ts'; +import { MODERN_PROTOCOL_VERSION } from '../mcp-protocol.ts'; +import { __resetServerStateForTests, getActiveServers } from '../server-state.ts'; +import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { + createTestRegistrations, + TEST_RESOURCE_URI, + TEST_TOOL_NAME, +} from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer, waitFor } from './raw-mcp-peer.ts'; +import { getLogLevel, setLogLevel } from '../../utils/logger.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +async function serve(): Promise { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + return peer; +} + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +describe('MCP 2026-07-28 modern era serving', () => { + it('answers server/discover with the modern revision, capabilities and instructions', async () => { + const client = await serve(); + + const response = await client.request('server/discover', { _meta: modernMeta() }); + + expect(response.error).toBeUndefined(); + expect(response.result?.supportedVersions).toEqual([MODERN_PROTOCOL_VERSION]); + expect(response.result?.capabilities).toMatchObject({ + tools: { listChanged: true }, + resources: { subscribe: true, listChanged: true }, + }); + expect(String(response.result?.instructions)).toContain('XcodeBuildMCP provides'); + }); + + it('stamps resultType and server identity on every modern result', async () => { + const client = await serve(); + + const discover = await client.request('server/discover', { _meta: modernMeta() }); + const list = await client.request('tools/list', { _meta: modernMeta() }); + const call = await client.request('tools/call', { + _meta: modernMeta(), + name: TEST_TOOL_NAME, + arguments: { text: 'hi' }, + }); + + for (const response of [discover, list, call]) { + expect(response.result?.resultType).toBe('complete'); + expect(response.result?._meta).toMatchObject({ + 'io.modelcontextprotocol/serverInfo': { name: 'xcodebuildmcp' }, + }); + } + }); + + it('emits cache metadata on cacheable results and omits it on tools/call', async () => { + const client = await serve(); + + const toolsList = await client.request('tools/list', { _meta: modernMeta() }); + expect(toolsList.result?.ttlMs).toBe(60_000); + expect(toolsList.result?.cacheScope).toBe('private'); + + const resourcesList = await client.request('resources/list', { _meta: modernMeta() }); + expect(resourcesList.result?.ttlMs).toBe(60_000); + expect(resourcesList.result?.cacheScope).toBe('private'); + + const discover = await client.request('server/discover', { _meta: modernMeta() }); + expect(discover.result?.ttlMs).toBe(300_000); + expect(discover.result?.cacheScope).toBe('private'); + + const read = await client.request('resources/read', { + _meta: modernMeta(), + uri: TEST_RESOURCE_URI, + }); + expect(read.result?.ttlMs).toBe(0); + expect(read.result?.cacheScope).toBe('private'); + + const call = await client.request('tools/call', { + _meta: modernMeta(), + name: TEST_TOOL_NAME, + arguments: { text: 'hi' }, + }); + expect(call.result).not.toHaveProperty('ttlMs'); + expect(call.result).not.toHaveProperty('cacheScope'); + }); + + it('serves a direct tools/call without any prior discovery or initialize', async () => { + const client = await serve(); + + const response = await client.request('tools/call', { + _meta: modernMeta({ clientName: 'direct-client', clientVersion: '3.2.1' }), + name: TEST_TOOL_NAME, + arguments: { text: 'direct' }, + }); + + expect(response.error).toBeUndefined(); + expect(response.result?.structuredContent).toMatchObject({ + schema: 'bundle-id', + didError: false, + data: { artifacts: { bundleId: 'echo:direct' } }, + }); + expect( + client.received.some((message) => JSON.stringify(message).includes('"protocolVersion"')), + ).toBe(false); + }); + + it('never issues an Mcp-Session-Id or requires an initialize handshake', async () => { + const client = await serve(); + + await client.request('tools/list', { _meta: modernMeta() }); + + const wire = JSON.stringify(client.received); + expect(wire.toLowerCase()).not.toContain('mcp-session-id'); + expect(wire).not.toContain('"method":"initialize"'); + }); + + it('rejects a modern request whose _meta envelope omits client capabilities', async () => { + const client = await serve(); + + const response = await client.request('tools/list', { + _meta: modernMeta({ omitCapabilities: true }), + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.code).toBe(-32602); + expect(response.error?.message).toContain('clientCapabilities'); + }); + + it('rejects an unsupported protocol revision claim', async () => { + const client = await serve(); + + const response = await client.request('tools/list', { + _meta: modernMeta({ protocolVersion: '2099-01-01' }), + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.data).toMatchObject({ supported: [MODERN_PROTOCOL_VERSION] }); + }); + + it('delivers list_changed notifications on an opted-in subscription', async () => { + const client = await serve(); + + await client.request('tools/list', { _meta: modernMeta() }); + await client.sendRequest('subscriptions/listen', { + _meta: modernMeta(), + notifications: { toolsListChanged: true, resourcesListChanged: true }, + }); + + await waitFor(() => + client.notifications.some( + (message) => + (message as { method?: string }).method === 'notifications/subscriptions/acknowledged', + ), + ); + + const [server] = getActiveServers(); + expect(server).toBeDefined(); + server?.sendToolListChanged(); + server?.sendResourceListChanged(); + + await waitFor(() => + client.notifications.some( + (message) => (message as { method?: string }).method === 'notifications/tools/list_changed', + ), + ); + await waitFor(() => + client.notifications.some( + (message) => + (message as { method?: string }).method === 'notifications/resources/list_changed', + ), + ); + }); + + it('applies the per-request log level from the modern _meta envelope', async () => { + setLogLevel('info'); + const client = await serve(); + + await client.request('tools/list', { _meta: modernMeta({ logLevel: 'debug' }) }); + expect(getLogLevel()).toBe('debug'); + + setLogLevel('info'); + }); + + it('does not push list_changed notifications without a subscription', async () => { + const client = await serve(); + + await client.request('tools/list', { _meta: modernMeta() }); + const [server] = getActiveServers(); + server?.sendToolListChanged(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect( + client.notifications.some( + (message) => (message as { method?: string }).method === 'notifications/tools/list_changed', + ), + ).toBe(false); + }); +}); diff --git a/src/server/__tests__/mcp-notifications.test.ts b/src/server/__tests__/mcp-notifications.test.ts new file mode 100644 index 000000000..fffb70049 --- /dev/null +++ b/src/server/__tests__/mcp-notifications.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { startStdioServer } from '../server.ts'; +import { __resetServerStateForTests, getServer } from '../server-state.ts'; +import { __resetToolRegistryForTests, applyToolPlanToServer } from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { createTestRegistrations, TEST_TOOL_NAME } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer, waitFor } from './raw-mcp-peer.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; +let client: Client | null = null; + +afterEach(async () => { + await client?.close(); + await handle?.close(); + await peer?.close(); + client = null; + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +describe('dynamic tool and resource notifications', () => { + it('emits tools/list_changed to a subscribed modern client when the plan shrinks', async () => { + const registrations = createTestRegistrations(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + + await peer.request('tools/list', { _meta: modernMeta() }); + await peer.sendRequest('subscriptions/listen', { + _meta: modernMeta(), + notifications: { toolsListChanged: true }, + }); + await waitFor(() => + peer!.notifications.some( + (message) => + (message as { method?: string }).method === 'notifications/subscriptions/acknowledged', + ), + ); + + const server = getServer(); + expect(server).toBeDefined(); + + applyToolPlanToServer(server!, { + ...registrations.toolPlan!, + tools: [], + }); + + await waitFor(() => + peer!.notifications.some( + (message) => (message as { method?: string }).method === 'notifications/tools/list_changed', + ), + ); + + const listed = await peer.request('tools/list', { _meta: modernMeta() }); + expect(listed.result?.tools).toEqual([]); + }); + + it('emits tools/list_changed to legacy clients without a subscription', async () => { + const registrations = createTestRegistrations(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + handle = startStdioServer(registrations, { transport: serverTransport }); + + const notified = vi.fn(); + client = new Client( + { name: 'notification-client', version: '1.0.0' }, + { listChanged: { tools: { autoRefresh: false, onChanged: notified } } }, + ); + await client.connect(clientTransport); + + const initial = await client.listTools(); + expect(initial.tools.map((tool) => tool.name)).toContain(TEST_TOOL_NAME); + + const server = getServer(); + applyToolPlanToServer(server!, { ...registrations.toolPlan!, tools: [] }); + + await waitFor(() => notified.mock.calls.length > 0); + + const after = await client.listTools(); + expect(after.tools).toEqual([]); + }); + + it('re-registers the plan on a replacement instance so tools survive era fallback', async () => { + const registrations = createTestRegistrations(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + + await peer.request('server/discover', { _meta: modernMeta() }); + const probeInstance = getServer(); + + await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'fallback-client', version: '1.0.0' }, + }); + await peer.notify('notifications/initialized'); + + const pinnedInstance = getServer(); + expect(pinnedInstance).not.toBe(probeInstance); + + const listed = await peer.request('tools/list', {}); + expect( + (listed.result?.tools as Array<{ name: string }> | undefined)?.map((tool) => tool.name), + ).toContain(TEST_TOOL_NAME); + }); + + it('is idempotent when the same plan is applied twice to one instance', async () => { + const registrations = createTestRegistrations(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + + await peer.request('tools/list', { _meta: modernMeta() }); + const server = getServer(); + expect(server).toBeDefined(); + + expect(() => applyToolPlanToServer(server!, registrations.toolPlan!)).not.toThrow(); + + const listed = await peer.request('tools/list', { _meta: modernMeta() }); + expect( + (listed.result?.tools as Array<{ name: string }> | undefined)?.map((tool) => tool.name), + ).toEqual([TEST_TOOL_NAME]); + }); + + it('releases per-instance registration bookkeeping when the instance closes', async () => { + const registrations = createTestRegistrations(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + + await peer.request('tools/list', { _meta: modernMeta() }); + const server = getServer(); + expect(server).toBeDefined(); + + await server!.close(); + expect(getServer()).toBeUndefined(); + + // A replacement instance registers the same plan without colliding with the + // released instance's bookkeeping. + const secondPeer = new RawMcpPeer(); + await secondPeer.start(); + const secondHandle = startStdioServer(registrations, { transport: secondPeer.serverTransport }); + const listed = await secondPeer.request('tools/list', { _meta: modernMeta() }); + expect( + (listed.result?.tools as Array<{ name: string }> | undefined)?.map((tool) => tool.name), + ).toEqual([TEST_TOOL_NAME]); + + await secondHandle.close(); + await secondPeer.close(); + }); +}); diff --git a/src/server/__tests__/mcp-protocol.test.ts b/src/server/__tests__/mcp-protocol.test.ts new file mode 100644 index 000000000..0ca269284 --- /dev/null +++ b/src/server/__tests__/mcp-protocol.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + MCP_CACHE_HINTS, + MCP_METHOD_HEADER, + MCP_NAME_HEADER, + MCP_PROTOCOL_VERSION_HEADER, + MODERN_PROTOCOL_VERSION, + buildModernHttpHeaders, + encodeMcpHeaderValue, + mcpNameHeaderValue, + readModernRequestEnvelope, +} from '../mcp-protocol.ts'; + +describe('modern protocol helpers', () => { + it('uses the 2026-07-28 revision string', () => { + expect(MODERN_PROTOCOL_VERSION).toBe('2026-07-28'); + }); + + it('derives the Mcp-Name header value per method', () => { + expect(mcpNameHeaderValue('tools/call', { name: 'build_sim' })).toBe('build_sim'); + expect(mcpNameHeaderValue('prompts/get', { name: 'review' })).toBe('review'); + expect(mcpNameHeaderValue('resources/read', { uri: 'xcodebuildmcp://simulators' })).toBe( + 'xcodebuildmcp://simulators', + ); + expect(mcpNameHeaderValue('tools/list', {})).toBeUndefined(); + expect(mcpNameHeaderValue('tools/call', null)).toBeUndefined(); + }); + + it('base64-encodes header values that are not printable ASCII', () => { + expect(encodeMcpHeaderValue('build_sim')).toBe('build_sim'); + expect(encodeMcpHeaderValue('café')).toBe( + `=?base64?${Buffer.from('café', 'utf8').toString('base64')}?=`, + ); + }); + + it('builds the required modern HTTP headers', () => { + expect(buildModernHttpHeaders('tools/call', { name: 'build_sim' })).toEqual({ + [MCP_PROTOCOL_VERSION_HEADER]: MODERN_PROTOCOL_VERSION, + [MCP_METHOD_HEADER]: 'tools/call', + [MCP_NAME_HEADER]: 'build_sim', + }); + + expect(buildModernHttpHeaders('tools/list', {})).toEqual({ + [MCP_PROTOCOL_VERSION_HEADER]: MODERN_PROTOCOL_VERSION, + [MCP_METHOD_HEADER]: 'tools/list', + }); + }); + + it('reads the modern per-request envelope', () => { + const envelope = readModernRequestEnvelope({ + _meta: { + 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '1.2.3' }, + 'io.modelcontextprotocol/clientCapabilities': { elicitation: { form: {} } }, + 'io.modelcontextprotocol/logLevel': 'debug', + }, + }); + + expect(envelope).toEqual({ + protocolVersion: MODERN_PROTOCOL_VERSION, + clientInfo: { name: 'probe', version: '1.2.3' }, + clientCapabilities: { elicitation: { form: {} } }, + logLevel: 'debug', + }); + }); + + it('returns null for legacy-era params that carry no envelope', () => { + expect(readModernRequestEnvelope(undefined)).toBeNull(); + expect(readModernRequestEnvelope({})).toBeNull(); + expect(readModernRequestEnvelope({ _meta: {} })).toBeNull(); + expect( + readModernRequestEnvelope({ + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'legacy', version: '1.0.0' }, + }), + ).toBeNull(); + }); + + it('defaults client capabilities to an empty object when malformed', () => { + expect( + readModernRequestEnvelope({ + _meta: { + 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientCapabilities': 'not-an-object', + }, + }), + ).toEqual({ + protocolVersion: MODERN_PROTOCOL_VERSION, + clientCapabilities: {}, + }); + }); + + it('declares private cache hints for every cacheable operation', () => { + const cacheableMethods = [ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read', + ] as const; + + for (const method of cacheableMethods) { + const hint = MCP_CACHE_HINTS[method]; + expect(hint, `missing cache hint for ${method}`).toBeDefined(); + expect(hint?.cacheScope).toBe('private'); + expect(hint?.ttlMs).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/src/server/__tests__/mcp-serving-lifecycle.test.ts b/src/server/__tests__/mcp-serving-lifecycle.test.ts new file mode 100644 index 000000000..3ac857eec --- /dev/null +++ b/src/server/__tests__/mcp-serving-lifecycle.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { createMcpHttpHandler, createServer, startStdioServer } from '../server.ts'; +import { + __resetServerStateForTests, + getActiveServers, + getServer, + onActiveServerChanged, +} from '../server-state.ts'; +import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import { createTestRegistrations, TEST_TOOL_NAME } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; +import { MODERN_PROTOCOL_VERSION, buildModernHttpHeaders } from '../mcp-protocol.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + sessionStore.clearAll(); + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +describe('MCP serving context lifecycle', () => { + it('builds no server instance before the first message arrives', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + expect(getActiveServers()).toHaveLength(0); + }); + + it('builds exactly one instance for a modern connection', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + await peer.request('server/discover', { _meta: modernMeta() }); + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(getActiveServers()).toHaveLength(1); + }); + + it('discards the discovery probe instance when the client falls back to initialize', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + await peer.request('server/discover', { _meta: modernMeta() }); + expect(getActiveServers()).toHaveLength(1); + const probeInstance = getServer(); + + const initialize = await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'fallback-client', version: '1.0.0' }, + }); + + expect(initialize.error).toBeUndefined(); + expect(getActiveServers()).toHaveLength(1); + expect(getServer()).not.toBe(probeInstance); + }); + + it('keeps application session state across server instance replacement', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', simulatorName: 'iPhone 16' }); + + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + + await peer.request('server/discover', { _meta: modernMeta() }); + const probeInstance = getServer(); + + await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'fallback-client', version: '1.0.0' }, + }); + + expect(getServer()).not.toBe(probeInstance); + expect(sessionStore.getAll()).toMatchObject({ + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }); + }); + + it('unregisters an instance once it is closed', async () => { + const observed: Array = []; + const unsubscribe = onActiveServerChanged((server) => { + observed.push(server === undefined ? undefined : 'server'); + }); + + const server = createServer(); + expect(getActiveServers()).toHaveLength(1); + + await server.close(); + expect(getActiveServers()).toHaveLength(0); + expect(observed).toEqual(['server', undefined]); + + unsubscribe(); + }); + + it('serves each HTTP request from its own instance and enforces the modern headers', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + + const callBody = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { _meta: modernMeta(), name: TEST_TOOL_NAME, arguments: { text: 'http' } }, + }; + + const ok = await httpHandler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...buildModernHttpHeaders('tools/call', callBody.params), + }, + body: JSON.stringify(callBody), + }), + ); + + expect(ok.status).toBe(200); + const okPayload = (await ok.json()) as { result?: Record }; + expect(okPayload.result?.resultType).toBe('complete'); + + const missingName = await httpHandler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': MODERN_PROTOCOL_VERSION, + 'Mcp-Method': 'tools/call', + }, + body: JSON.stringify(callBody), + }), + ); + + expect(missingName.status).toBe(400); + const missingNamePayload = (await missingName.json()) as { + error?: { code: number; message: string }; + }; + expect(missingNamePayload.error?.code).toBe(-32020); + expect(missingNamePayload.error?.message).toContain('Mcp-Name'); + }); + + it('rejects an HTTP request whose Mcp-Method header disagrees with the body', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + + const response = await httpHandler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': MODERN_PROTOCOL_VERSION, + 'Mcp-Method': 'tools/call', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: modernMeta() }, + }), + }), + ); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error?: { code: number } }; + expect(payload.error?.code).toBe(-32020); + }); + + it('serves legacy HTTP traffic from the same handler', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + + const response = await httpHandler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'legacy-http', version: '1.0.0' }, + }, + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('"protocolVersion":"2025-06-18"'); + }); + + it('reports request start and completion to the idle-shutdown observer', async () => { + const started: number[] = []; + const completed: number[] = []; + + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { + transport: peer.serverTransport, + requestLifecycle: { + onRequestStarted: () => started.push(Date.now()), + onRequestCompleted: () => completed.push(Date.now()), + }, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + }); + + it('keeps serving after a client disconnects and a new connection opens', async () => { + const registrations = createTestRegistrations(); + + const firstPeer = new RawMcpPeer(); + await firstPeer.start(); + const firstHandle = startStdioServer(registrations, { transport: firstPeer.serverTransport }); + await firstPeer.request('tools/list', { _meta: modernMeta() }); + await firstHandle.close(); + await firstPeer.close(); + + expect(getActiveServers()).toHaveLength(0); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + handle = startStdioServer(registrations, { transport: serverTransport }); + const client = new Client({ name: 'second-connection', version: '1.0.0' }); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain(TEST_TOOL_NAME); + + await client.close(); + }); +}); diff --git a/src/server/__tests__/mcp-shutdown.test.ts b/src/server/__tests__/mcp-shutdown.test.ts index f03e34082..cba67701a 100644 --- a/src/server/__tests__/mcp-shutdown.test.ts +++ b/src/server/__tests__/mcp-shutdown.test.ts @@ -98,7 +98,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ orphaned: true }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); expect(result.exitCode).toBe(0); @@ -129,7 +129,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot(), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const filesystemStep = result.steps.find( @@ -150,7 +150,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ videoCaptureSessionCount: 1 }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const videoStep = result.steps.find((step) => step.name === 'video-capture.stop-all'); @@ -169,7 +169,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ videoCaptureSessionCount: 1 }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const videoStep = result.steps.find((step) => step.name === 'video-capture.stop-all'); @@ -188,7 +188,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ swiftPackageProcessCount: 1 }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const swiftStep = result.steps.find((step) => step.name === 'swift-processes.stop-all'); @@ -207,7 +207,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ swiftPackageProcessCount: 1 }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const swiftStep = result.steps.find((step) => step.name === 'swift-processes.stop-all'); @@ -238,7 +238,7 @@ describe('runMcpShutdown', () => { simulatorLaunchOsLogSessionCount: 1, ownedSimulatorLaunchOsLogSessionCount: 1, }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const filesystemStep = result.steps.find( @@ -269,7 +269,7 @@ describe('runMcpShutdown', () => { simulatorLaunchOsLogSessionCount: 2, ownedSimulatorLaunchOsLogSessionCount: 2, }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const filesystemStep = result.steps.find( @@ -289,7 +289,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', snapshot: createSnapshot({ debuggerSessionCount: 1 }), - server: { close: async () => undefined }, + serving: { close: async () => undefined }, }); const debuggerStep = result.steps.find((step) => step.name === 'debugger.dispose-all'); diff --git a/src/server/__tests__/raw-mcp-peer.ts b/src/server/__tests__/raw-mcp-peer.ts new file mode 100644 index 000000000..1697279c6 --- /dev/null +++ b/src/server/__tests__/raw-mcp-peer.ts @@ -0,0 +1,139 @@ +import { InMemoryTransport, type JSONRPCMessage } from '@modelcontextprotocol/server'; +import { MODERN_PROTOCOL_VERSION } from '../mcp-protocol.ts'; + +export interface ModernEnvelopeOptions { + protocolVersion?: string; + clientName?: string; + clientVersion?: string; + clientCapabilities?: Record; + logLevel?: string; + omitCapabilities?: boolean; +} + +export function modernMeta(options: ModernEnvelopeOptions = {}): Record { + const meta: Record = { + 'io.modelcontextprotocol/protocolVersion': options.protocolVersion ?? MODERN_PROTOCOL_VERSION, + }; + + if (options.clientName) { + meta['io.modelcontextprotocol/clientInfo'] = { + name: options.clientName, + version: options.clientVersion ?? '0.0.0', + }; + } + + if (!options.omitCapabilities) { + meta['io.modelcontextprotocol/clientCapabilities'] = options.clientCapabilities ?? {}; + } + + if (options.logLevel) { + meta['io.modelcontextprotocol/logLevel'] = options.logLevel; + } + + return meta; +} + +interface JsonRpcResponse { + id: string | number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +/** + * A raw JSON-RPC peer over an in-memory transport pair. + * + * The SDK v2 `Client` negotiates the 2025 era over in-memory transports, so + * modern-era assertions are made against raw wire messages instead. + */ +export class RawMcpPeer { + readonly serverTransport: InMemoryTransport; + private readonly clientTransport: InMemoryTransport; + private readonly pending = new Map void>(); + private nextId = 1; + + readonly notifications: JSONRPCMessage[] = []; + readonly received: JSONRPCMessage[] = []; + + constructor() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + this.clientTransport = clientTransport; + this.serverTransport = serverTransport; + + this.clientTransport.onmessage = (message: JSONRPCMessage): void => { + this.received.push(message); + const record = message as unknown as JsonRpcResponse & { method?: string }; + if (record.method !== undefined && record.id === undefined) { + this.notifications.push(message); + return; + } + if (record.id !== undefined) { + const resolve = this.pending.get(record.id); + if (resolve) { + this.pending.delete(record.id); + resolve(record); + } + } + }; + } + + async start(): Promise { + await this.clientTransport.start(); + } + + async close(): Promise { + await this.clientTransport.close(); + } + + async request( + method: string, + params: Record = {}, + timeoutMs = 5000, + ): Promise { + const id = this.nextId++; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for a response to ${method}`)); + }, timeoutMs); + this.pending.set(id, (value) => { + clearTimeout(timer); + resolve(value); + }); + }); + + const request: JSONRPCMessage = { jsonrpc: '2.0', id, method, params }; + await this.clientTransport.send(request); + return response; + } + + /** + * Sends a request without waiting for its response. `subscriptions/listen` + * only produces a result when the subscription ends, so its acknowledgement + * arrives as a notification instead. + */ + async sendRequest(method: string, params: Record = {}): Promise { + const id = this.nextId++; + const request: JSONRPCMessage = { jsonrpc: '2.0', id, method, params }; + await this.clientTransport.send(request); + return id; + } + + async notify(method: string, params: Record = {}): Promise { + const notification: JSONRPCMessage = { jsonrpc: '2.0', method, params }; + await this.clientTransport.send(notification); + } +} + +export async function waitFor( + predicate: () => boolean, + timeoutMs = 2000, + intervalMs = 10, +): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} diff --git a/src/server/__tests__/server.test.ts b/src/server/__tests__/server.test.ts index e94cdbc8b..ad84d672a 100644 --- a/src/server/__tests__/server.test.ts +++ b/src/server/__tests__/server.test.ts @@ -1,8 +1,4 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; -import type { - Transport, - TransportSendOptions, -} from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { JSONRPCMessage, Transport, TransportSendOptions } from '@modelcontextprotocol/server'; import { describe, expect, it, vi } from 'vitest'; import { instrumentMcpRequestLifecycle, diff --git a/src/server/__tests__/serving-test-fixtures.ts b/src/server/__tests__/serving-test-fixtures.ts new file mode 100644 index 000000000..765d17e26 --- /dev/null +++ b/src/server/__tests__/serving-test-fixtures.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import type { ToolSchemaShape } from '../../core/plugin-types.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; +import type { ServerRegistrations } from '../bootstrap.ts'; +import type { ResourceMeta } from '../../core/resources.ts'; +import { createToolCatalog } from '../../runtime/tool-catalog.ts'; +import type { ToolDefinition } from '../../runtime/types.ts'; + +export const TEST_TOOL_NAME = 'probe_echo'; +export const TEST_RESOURCE_URI = 'xcodebuildmcp://probe'; + +const probeSchema = z.object({ text: z.string() }) as unknown as ToolSchemaShape; + +function probeHandler(params: Record, ctx?: ToolHandlerContext): Promise { + if (ctx) { + ctx.structuredOutput = { + result: { + kind: 'bundle-id', + didError: false, + error: null, + artifacts: { + appPath: '/probe/App.app', + bundleId: `echo:${String(params.text ?? '')}`, + }, + }, + schema: 'bundle-id', + schemaVersion: '1.0.0', + }; + } + return Promise.resolve(undefined); +} + +function probeToolDefinition(): ToolDefinition { + return { + id: TEST_TOOL_NAME, + cliName: 'probe-echo', + mcpName: TEST_TOOL_NAME, + workflow: 'probe', + description: 'Echoes its input', + annotations: { readOnlyHint: true }, + nextStepTemplates: [], + mcpSchema: probeSchema, + cliSchema: probeSchema, + stateful: false, + handler: probeHandler as ToolDefinition['handler'], + }; +} + +/** + * A minimal registration set for serving-layer tests. + * + * Deliberately avoids the manifest bootstrap: these tests exercise the protocol + * and serving-context behaviour, not tool discovery. + */ +export function createTestRegistrations( + overrides: Partial = {}, +): ServerRegistrations { + const resources = new Map([ + [ + TEST_RESOURCE_URI, + { + uri: TEST_RESOURCE_URI, + name: 'probe', + description: 'Probe resource', + mimeType: 'text/plain', + handler: (): Promise<{ contents: Array<{ text: string }> }> => + Promise.resolve({ contents: [{ text: 'probe-contents' }] }), + }, + ], + ]); + + return { + toolPlan: { + tools: [ + { + manifest: { + id: TEST_TOOL_NAME, + module: 'probe/echo', + names: { mcp: TEST_TOOL_NAME }, + description: 'Echoes its input', + availability: { mcp: true, cli: false }, + predicates: [], + nextSteps: [], + annotations: { readOnlyHint: true }, + }, + module: { + schema: probeSchema, + mcpSchema: probeSchema, + handler: probeHandler, + }, + }, + ], + catalog: createToolCatalog([probeToolDefinition()]), + enabledWorkflows: new Set(['probe']), + workflowLabel: 'probe', + }, + resources, + xcodeIdeEnabled: false, + ...overrides, + }; +} diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index d4b54ea88..5f1f30e76 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -1,10 +1,15 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { registerResources } from '../core/resources.ts'; +import type { McpServer } from '@modelcontextprotocol/server'; +import { loadResources, registerLoadedResources, type ResourceMeta } from '../core/resources.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { log, normalizeLogLevel, setLogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; -import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; +import { + applyToolPlanToServer, + getRegisteredWorkflows, + getToolRegistrationPlan, + registerWorkflowsFromManifest, + type McpToolRegistrationPlan, +} from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; @@ -23,7 +28,21 @@ export interface BootstrapOptions { cwd?: string; } +/** + * Everything a fresh server instance needs, resolved once per process. + * + * SDK v2 builds a new server per serving context, so the expensive discovery + * work (manifest load, tool module imports, resource module imports, Xcode + * runtime detection) is done once and replayed cheaply onto each instance. + */ +export interface ServerRegistrations { + toolPlan: McpToolRegistrationPlan | null; + resources: Map; + xcodeIdeEnabled: boolean; +} + export interface BootstrapResult { + registrations: ServerRegistrations; runDeferredInitialization: (options?: { isShutdownRequested?: () => boolean }) => Promise; } @@ -48,13 +67,20 @@ function runStartupFilesystemLifecycleSweep(workspaceKey: string): Promise }); } -export async function bootstrapServer( +/** + * Applies process-level registrations onto a freshly built server instance. + * + * Called once per serving context. Everything here is cheap: the manifest, tool + * modules and resource modules were already resolved by `bootstrapServerRuntime`. + */ +export function applyServerRegistrations( server: McpServer, - options: BootstrapOptions = {}, -): Promise { - const profiler = createStartupProfiler('bootstrap'); - - server.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + registrations: ServerRegistrations, +): void { + // Legacy-era clients set verbosity with logging/setLevel. Modern-era clients + // send io.modelcontextprotocol/logLevel in each request _meta instead, which + // the request-lifecycle observer applies. + server.server.setRequestHandler('logging/setLevel', async (request) => { const { level } = request.params; const normalized = normalizeLogLevel(level); if (normalized) { @@ -64,6 +90,30 @@ export async function bootstrapServer( return {}; }); + if (registrations.toolPlan) { + applyToolPlanToServer(server, registrations.toolPlan); + } + + registerLoadedResources(server, registrations.resources); + + const xcodeToolsBridge = registrations.xcodeIdeEnabled + ? getXcodeToolsBridgeManager(server) + : null; + xcodeToolsBridge?.bindServer(server); + xcodeToolsBridge?.setWorkflowEnabled(registrations.xcodeIdeEnabled); +} + +/** + * Resolves the process-level runtime state and registration plan. + * + * Does not build or touch a server instance: serving entries construct fresh + * instances and replay `result.registrations` onto each of them. + */ +export async function bootstrapServerRuntime( + options: BootstrapOptions = {}, +): Promise { + const profiler = createStartupProfiler('bootstrap'); + const hasLegacyEnabledWorkflows = Object.prototype.hasOwnProperty.call( options, 'enabledWorkflows', @@ -114,14 +164,17 @@ export async function bootstrapServer( const resolvedWorkflows = getRegisteredWorkflows(); const xcodeIdeEnabled = resolvedWorkflows.includes('xcode-ide'); - const xcodeToolsBridge = xcodeIdeEnabled ? getXcodeToolsBridgeManager(server) : null; - xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); stageStartMs = getStartupProfileNowMs(); - await registerResources(server, ctx); - profiler.mark('registerResources', stageStartMs); + const resources = await loadResources(ctx); + profiler.mark('loadResources', stageStartMs); return { + registrations: { + toolPlan: getToolRegistrationPlan(), + resources, + xcodeIdeEnabled, + }, runDeferredInitialization: async (options = {}): Promise => { const deferredProfiler = createStartupProfiler('bootstrap-deferred'); const isShutdownRequested = options.isShutdownRequested; @@ -222,3 +275,20 @@ export async function bootstrapServer( }, }; } + +/** + * Bootstraps the process runtime and immediately applies the registrations to + * the given server instance. + * + * Kept for in-process serving contexts (tests, harnesses) that own a single + * long-lived instance. The stdio entry uses `bootstrapServerRuntime` plus + * `applyServerRegistrations` so each connection gets a fresh instance. + */ +export async function bootstrapServer( + server: McpServer, + options: BootstrapOptions = {}, +): Promise { + const result = await bootstrapServerRuntime(options); + applyServerRegistrations(server, result.registrations); + return result; +} diff --git a/src/server/mcp-instrumentation.ts b/src/server/mcp-instrumentation.ts new file mode 100644 index 000000000..bef62507e --- /dev/null +++ b/src/server/mcp-instrumentation.ts @@ -0,0 +1,85 @@ +/** + * Observability adapters for the MCP serving layer. + * + * `Sentry.wrapMcpServerWithSentry` instruments the SDK v2 `McpServer` shape + * (it detects `connect` plus `registerTool`/`registerResource`/`registerPrompt`) + * and continues to produce request spans and handler error capture. What it + * cannot see on protocol revision 2026-07-28 is the connection identity: the + * modern era removed `initialize`, and the released integration only extracts + * protocol version and client info from that handshake. + * + * This module fills exactly that gap by reading the per-request `_meta` + * envelope the SDK already validated, so no observability is lost when a client + * connects on the modern era. + */ + +import { log } from '../utils/logger.ts'; +import { recordMcpServingContextMetric, setMcpProtocolContext } from '../utils/sentry.ts'; +import type { McpModernRequestObservation } from './request-lifecycle.ts'; +import type { ProtocolEra } from './mcp-protocol.ts'; + +interface ObservedProtocolIdentity { + protocolVersion: string; + clientName: string | undefined; + clientVersion: string | undefined; + capabilityKeys: string; +} + +let lastObservedIdentity: ObservedProtocolIdentity | null = null; + +function identityChanged(next: ObservedProtocolIdentity): boolean { + const previous = lastObservedIdentity; + return ( + previous?.protocolVersion !== next.protocolVersion || + previous.clientName !== next.clientName || + previous.clientVersion !== next.clientVersion || + previous.capabilityKeys !== next.capabilityKeys + ); +} + +/** + * Publishes the modern-era protocol identity to Sentry. + * + * Every modern request carries the envelope, so this is called on a hot path: + * it deduplicates and only re-publishes when the identity actually changes. + */ +export function recordModernProtocolEnvelope(observation: McpModernRequestObservation): void { + const capabilities = Object.keys(observation.envelope.clientCapabilities).sort(); + const identity: ObservedProtocolIdentity = { + protocolVersion: observation.envelope.protocolVersion, + clientName: observation.envelope.clientInfo?.name, + clientVersion: observation.envelope.clientInfo?.version, + capabilityKeys: capabilities.join(','), + }; + + if (!identityChanged(identity)) { + return; + } + + lastObservedIdentity = identity; + + setMcpProtocolContext({ + era: 'modern', + protocolVersion: identity.protocolVersion, + ...(identity.clientName ? { clientName: identity.clientName } : {}), + ...(identity.clientVersion ? { clientVersion: identity.clientVersion } : {}), + clientCapabilities: capabilities, + }); + + log( + 'info', + `[mcp-protocol] modern client observed: version=${identity.protocolVersion} client=${identity.clientName ?? 'unknown'}@${identity.clientVersion ?? 'unknown'} capabilities=${identity.capabilityKeys || 'none'}`, + ); +} + +/** Records that a serving context constructed a fresh server instance. */ +export function recordServingContextStarted(context: { + transport: 'stdio' | 'http'; + era: ProtocolEra; +}): void { + recordMcpServingContextMetric(context); +} + +export function __resetMcpInstrumentationForTests(): void { + lastObservedIdentity = null; +} diff --git a/src/server/mcp-lifecycle.ts b/src/server/mcp-lifecycle.ts index 6cb18a03e..862d86c9a 100644 --- a/src/server/mcp-lifecycle.ts +++ b/src/server/mcp-lifecycle.ts @@ -1,5 +1,4 @@ import process from 'node:process'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { listActiveSimulatorLaunchOsLogSessions } from '../utils/log-capture/simulator-launch-oslog-sessions.ts'; import { terminateOwnedWorkspaceFilesystemArtifactsSync } from '../utils/workspace-filesystem-lifecycle.ts'; @@ -15,7 +14,6 @@ export type McpStartupPhase = | 'initializing' | 'hydrating-sentry-config' | 'initializing-sentry' - | 'creating-server' | 'bootstrapping-server' | 'starting-stdio-transport' | 'running' @@ -88,20 +86,28 @@ interface LifecycleProcessLike { removeListener(event: string, listener: (...args: unknown[]) => void): this; } +/** + * A serving context that owns the connection: the handle returned by the SDK + * stdio entry closes the pinned server instance and the transport together. + */ +export interface McpServingHandle { + close(): Promise; +} + interface McpLifecycleState { startedAtMs: number; phase: McpStartupPhase; shutdownReason: McpShutdownReason | null; shutdownPromise: Promise | null; shutdownRequested: boolean; - server: McpServer | null; + serving: McpServingHandle | null; } export interface McpLifecycleCoordinator { attachProcessHandlers(): void; detachProcessHandlers(): void; markPhase(phase: McpStartupPhase): void; - registerServer(server: McpServer): void; + registerServingHandle(handle: McpServingHandle): void; isShutdownRequested(): boolean; getSnapshot(): Promise; shutdown(reason: McpShutdownReason, error?: unknown): Promise; @@ -114,7 +120,7 @@ export interface McpLifecycleCoordinatorOptions { reason: McpShutdownReason; error?: unknown; snapshot: McpLifecycleSnapshot; - server: McpServer | null; + serving: McpServingHandle | null; }) => Promise; } @@ -325,7 +331,7 @@ export function createMcpLifecycleCoordinator( shutdownReason: null, shutdownPromise: null, shutdownRequested: false, - server: null, + serving: null, }; const handleSigterm = (): void => { @@ -407,8 +413,8 @@ export function createMcpLifecycleCoordinator( state.phase = phase; }, - registerServer(server: McpServer): void { - state.server = server; + registerServingHandle(handle: McpServingHandle): void { + state.serving = handle; }, isShutdownRequested(): boolean { @@ -445,7 +451,7 @@ export function createMcpLifecycleCoordinator( reason, error, snapshot, - server: state.server, + serving: state.serving, }); state.phase = 'stopped'; })(); diff --git a/src/server/mcp-protocol.ts b/src/server/mcp-protocol.ts new file mode 100644 index 000000000..82942363b --- /dev/null +++ b/src/server/mcp-protocol.ts @@ -0,0 +1,187 @@ +/** + * MCP protocol constants and pure helpers shared by every serving context. + * + * Everything here is either re-exported from the official SDK v2 packages or a + * small pure helper over spec-defined names. No protocol behaviour is + * implemented here - encoding, validation, negotiation and era selection are + * owned by the SDK serving entries (`serveStdio`, `createMcpHandler`). + */ + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, + type CacheHint, + type ClientCapabilities, + type Implementation, + type ProtocolEra, + type ServerOptions, +} from '@modelcontextprotocol/server'; + +export { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, +}; + +export type { ProtocolEra }; + +/** + * The first modern-era protocol revision. Modern clients send this in every + * request `_meta` envelope; the SDK keeps its own copy internal, so the value + * is restated here for logging, telemetry and tests. + */ +export const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** + * HTTP header carrying the protocol revision on modern-era requests. It must + * match `_meta['io.modelcontextprotocol/protocolVersion']` in the body. + */ +export const MCP_PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version'; + +/** HTTP header carrying the JSON-RPC method of a modern-era request. */ +export const MCP_METHOD_HEADER = 'Mcp-Method'; + +/** + * HTTP header carrying the primary target of a modern-era request: + * `params.name` for `tools/call` and `prompts/get`, `params.uri` for + * `resources/read`. + */ +export const MCP_NAME_HEADER = 'Mcp-Name'; + +const NAME_HEADER_METHODS = new Set(['tools/call', 'prompts/get']); +const URI_HEADER_METHODS = new Set(['resources/read']); + +/** + * The `Mcp-Name` header value a modern-era HTTP request must carry for the + * given method/params, or `undefined` when the method does not require one. + */ +export function mcpNameHeaderValue(method: string, params: unknown): string | undefined { + if (params === null || typeof params !== 'object') { + return undefined; + } + + const record = params as Record; + if (NAME_HEADER_METHODS.has(method) && typeof record.name === 'string') { + return record.name; + } + if (URI_HEADER_METHODS.has(method) && typeof record.uri === 'string') { + return record.uri; + } + return undefined; +} + +/** + * Header values that are not printable US-ASCII must be transported using the + * spec's base64 sentinel form. + */ +export function encodeMcpHeaderValue(value: string): string { + if (/^[\u0020-\u007e]*$/.test(value)) { + return value; + } + return `=?base64?${Buffer.from(value, 'utf8').toString('base64')}?=`; +} + +/** + * Builds the modern-era HTTP headers required for a JSON-RPC request body. + */ +export function buildModernHttpHeaders( + method: string, + params: unknown, + protocolVersion: string = MODERN_PROTOCOL_VERSION, +): Record { + const headers: Record = { + [MCP_PROTOCOL_VERSION_HEADER]: protocolVersion, + [MCP_METHOD_HEADER]: method, + }; + + const name = mcpNameHeaderValue(method, params); + if (name !== undefined) { + headers[MCP_NAME_HEADER] = encodeMcpHeaderValue(name); + } + + return headers; +} + +/** + * The modern-era per-request envelope, as observed on the wire. + * + * Modern clients do not send `initialize`, so the protocol revision and the + * client's declared capabilities arrive on every single request instead. + */ +export interface ModernRequestEnvelope { + protocolVersion: string; + clientInfo?: Implementation; + clientCapabilities: ClientCapabilities; + logLevel?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Reads the modern-era envelope out of request params, or returns `null` for + * legacy-era requests (which carry no envelope). + * + * Read-only: validation and rejection of malformed envelopes is the SDK's job. + */ +export function readModernRequestEnvelope(params: unknown): ModernRequestEnvelope | null { + if (!isRecord(params)) { + return null; + } + + const meta = params._meta; + if (!isRecord(meta)) { + return null; + } + + const protocolVersion = meta[PROTOCOL_VERSION_META_KEY]; + if (typeof protocolVersion !== 'string') { + return null; + } + + const clientInfo = meta[CLIENT_INFO_META_KEY]; + const clientCapabilities = meta[CLIENT_CAPABILITIES_META_KEY]; + const logLevel = meta[LOG_LEVEL_META_KEY]; + + return { + protocolVersion, + ...(isRecord(clientInfo) ? { clientInfo: clientInfo as unknown as Implementation } : {}), + clientCapabilities: isRecord(clientCapabilities) + ? (clientCapabilities as unknown as ClientCapabilities) + : {}, + ...(typeof logLevel === 'string' ? { logLevel } : {}), + }; +} + +const MINUTE_MS = 60_000; + +/** + * Cache hints for the modern-era cacheable results (`ttlMs` / `cacheScope`). + * + * All XcodeBuildMCP results depend on the local workspace, the enabled + * workflows and the connected Xcode installation, so nothing is shareable + * between authorization contexts: every scope is `private`. TTLs are short + * because the tool and resource catalogues change dynamically (workflow + * selection, Xcode bridge sync). + */ +export const MCP_CACHE_HINTS: NonNullable = { + 'server/discover': { ttlMs: 5 * MINUTE_MS, cacheScope: 'private' }, + 'tools/list': { ttlMs: MINUTE_MS, cacheScope: 'private' }, + 'prompts/list': { ttlMs: MINUTE_MS, cacheScope: 'private' }, + 'resources/list': { ttlMs: MINUTE_MS, cacheScope: 'private' }, + 'resources/templates/list': { ttlMs: MINUTE_MS, cacheScope: 'private' }, + 'resources/read': { ttlMs: 0, cacheScope: 'private' }, +}; + +/** + * Cache hint for a single registered resource. Resource contents are derived + * from live runtime state (session defaults, device lists), so they are never + * cached beyond the current request. + */ +export const MCP_RESOURCE_CACHE_HINT: CacheHint = { ttlMs: 0, cacheScope: 'private' }; diff --git a/src/server/mcp-shutdown.ts b/src/server/mcp-shutdown.ts index 060361249..fc3f1e3f8 100644 --- a/src/server/mcp-shutdown.ts +++ b/src/server/mcp-shutdown.ts @@ -1,4 +1,3 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { stopXcodeStateWatcher } from '../utils/xcode-state-watcher.ts'; import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/index.ts'; @@ -12,7 +11,7 @@ import { } from '../utils/sentry.ts'; import { sealSentryCapture } from '../utils/shutdown-state.ts'; import { toErrorMessage } from '../utils/errors.ts'; -import type { McpLifecycleSnapshot, McpShutdownReason } from './mcp-lifecycle.ts'; +import type { McpLifecycleSnapshot, McpServingHandle, McpShutdownReason } from './mcp-lifecycle.ts'; import { isTransportDisconnectReason } from './mcp-lifecycle.ts'; const DISCONNECT_SERVER_CLOSE_TIMEOUT_MS = 150; @@ -137,14 +136,14 @@ function workspaceFilesystemCleanupTimeoutForOwnedSessions(ownedSessionCount: nu } export async function closeServerWithTimeout( - server: Pick | null | undefined, + serving: McpServingHandle | null | undefined, timeoutMs: number, ): Promise<'skipped' | 'closed' | 'timed_out' | 'rejected'> { - if (!server) { + if (!serving) { return 'skipped'; } - const outcome = await runStep('server.close', timeoutMs, () => server.close()); + const outcome = await runStep('server.close', timeoutMs, () => serving.close()); if (outcome.status === 'completed') { return 'closed'; } @@ -158,7 +157,7 @@ export async function runMcpShutdown(input: { reason: McpShutdownReason; error?: unknown; snapshot: McpLifecycleSnapshot; - server: Pick | null; + serving: McpServingHandle | null; }): Promise { const shutdownStartedAt = Date.now(); const exitCode = buildExitCode(input.reason); @@ -191,7 +190,7 @@ export async function runMcpShutdown(input: { : DEFAULT_SERVER_CLOSE_TIMEOUT_MS; const serverCloseOutcome = await runStep('server.close', serverCloseTimeout, async () => { - await input.server?.close(); + await input.serving?.close(); }); pushStep('server.close', serverCloseOutcome); diff --git a/src/server/request-lifecycle.ts b/src/server/request-lifecycle.ts index 5490674f6..0d7cffbb8 100644 --- a/src/server/request-lifecycle.ts +++ b/src/server/request-lifecycle.ts @@ -1,17 +1,27 @@ -import type { - Transport, - TransportSendOptions, -} from '@modelcontextprotocol/sdk/shared/transport.js'; import { isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, type JSONRPCMessage, -} from '@modelcontextprotocol/sdk/types.js'; + type Transport, + type TransportSendOptions, +} from '@modelcontextprotocol/server'; +import { readModernRequestEnvelope, type ModernRequestEnvelope } from './mcp-protocol.ts'; + +export interface McpModernRequestObservation { + method: string; + envelope: ModernRequestEnvelope; +} export interface McpRequestLifecycleObserver { onRequestStarted?: () => void; onRequestCompleted?: () => void; + /** + * Called for every inbound modern-era request. Modern clients never send + * `initialize`, so this is the only place the protocol revision and the + * client's declared capabilities are observable at the transport seam. + */ + onModernEnvelope?: (observation: McpModernRequestObservation) => void; } function requestIdKey(id: string | number): string { @@ -39,6 +49,16 @@ export function instrumentMcpRequestLifecycle( const originalSend = transport.send.bind(transport); let onMessageWrapped = false; + const observeModernEnvelope = (message: JSONRPCMessage): void => { + if (!observer.onModernEnvelope || !isJSONRPCRequest(message)) { + return; + } + const envelope = readModernRequestEnvelope(message.params); + if (envelope) { + observer.onModernEnvelope({ method: message.method, envelope }); + } + }; + const wrapOnMessage = (): void => { if (onMessageWrapped || !transport.onmessage) { return; @@ -58,6 +78,8 @@ export function instrumentMcpRequestLifecycle( } } + observeModernEnvelope(message); + try { downstreamOnMessage(message, extra); } catch (error) { diff --git a/src/server/server-state.ts b/src/server/server-state.ts index 573a8c13e..45cfb0a31 100644 --- a/src/server/server-state.ts +++ b/src/server/server-state.ts @@ -1,17 +1,73 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; -let serverInstance: McpServer | undefined; +/** + * Active MCP server instances for this process. + * + * SDK v2 builds a fresh server per serving context (one per stdio connection, + * one per HTTP request), so this is a set rather than a singleton. Registration + * order is preserved: the most recently registered instance is the one a + * process-level singleton (for example the Xcode tools bridge) should target. + * + * This tracks *protocol* instances only. Application session state + * (`sessionStore`, debugger sessions, log capture) is deliberately process + * scoped and outlives any individual server instance. + */ +const activeServers = new Set(); +export type ServerInstanceListener = (server: McpServer | undefined) => void; + +const listeners = new Set(); + +function currentServer(): McpServer | undefined { + let latest: McpServer | undefined; + for (const server of activeServers) { + latest = server; + } + return latest; +} + +function notifyListeners(): void { + const server = currentServer(); + for (const listener of listeners) { + listener(server); + } +} + +/** The most recently registered active server instance, if any. */ export function getServer(): McpServer | undefined { - return serverInstance; + return currentServer(); } -export function setServer(server: McpServer): void { - serverInstance = server; +/** Every currently active server instance, in registration order. */ +export function getActiveServers(): McpServer[] { + return [...activeServers]; } -export function __resetServerStateForTests(): void { - serverInstance = undefined; +/** Registers a freshly built server instance as active. */ +export function registerActiveServer(server: McpServer): void { + activeServers.add(server); + notifyListeners(); } -export { serverInstance as server }; +/** Removes a server instance that is no longer serving. */ +export function unregisterActiveServer(server: McpServer): void { + if (activeServers.delete(server)) { + notifyListeners(); + } +} + +/** + * Subscribes to active-instance changes. Used by process-level singletons that + * must re-bind their registrations when a serving context is replaced. + */ +export function onActiveServerChanged(listener: ServerInstanceListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function __resetServerStateForTests(): void { + activeServers.clear(); + listeners.clear(); +} diff --git a/src/server/server.ts b/src/server/server.ts index d07426ffa..d95fd108e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,36 +1,47 @@ /** - * Server Configuration - MCP Server setup and lifecycle management + * Server Configuration - MCP server construction and serving contexts. * - * This module handles the creation, configuration, and lifecycle management of the - * Model Context Protocol (MCP) server. It provides the foundation for all tool - * registrations and server capabilities. + * XcodeBuildMCP speaks MCP protocol revision 2026-07-28 (the "modern" era) and + * the 2025 revisions (the "legacy" era) through the official TypeScript SDK v2. * - * Responsibilities: - * - Creating and configuring the MCP server instance - * - Setting up server capabilities and options - * - Managing server lifecycle (start/stop) - * - Handling transport configuration (stdio) + * Era selection, `server/discover`, the per-request `_meta` envelope, + * `resultType`, cache fields and the modern HTTP headers are all owned by the + * SDK serving entries. This module only: + * - builds a fresh `McpServer` per serving context, + * - replays the process-level registrations onto it, + * - keeps observability (Sentry) attached to every instance. */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + McpServer, + createMcpHandler, + type McpHttpHandler, + type McpRequestContext, + type Transport, +} from '@modelcontextprotocol/server'; +import { + StdioServerTransport, + serveStdio, + type StdioServerHandle, +} from '@modelcontextprotocol/server/stdio'; import * as Sentry from '@sentry/node'; -import { log } from '../utils/logger.ts'; +import { log, normalizeLogLevel, setLogLevel } from '../utils/logger.ts'; import { version } from '../version.ts'; import { instrumentMcpRequestLifecycle, + type McpModernRequestObservation, type McpRequestLifecycleObserver, } from './request-lifecycle.ts'; -import { getServer, setServer } from './server-state.ts'; +import { + recordModernProtocolEnvelope, + recordServingContextStarted, +} from './mcp-instrumentation.ts'; +import { MCP_CACHE_HINTS } from './mcp-protocol.ts'; +import { registerActiveServer, unregisterActiveServer } from './server-state.ts'; +import { releaseServerToolRegistrations } from '../utils/tool-registry.ts'; +import { applyServerRegistrations, type ServerRegistrations } from './bootstrap.ts'; -function createBaseServerInstance(): McpServer { - return new McpServer( - { - name: 'xcodebuildmcp', - version: String(version), - }, - { - instructions: `XcodeBuildMCP provides comprehensive tooling for Apple platform development (iOS, macOS, watchOS, tvOS, visionOS). +const SERVER_INSTRUCTIONS = `XcodeBuildMCP provides comprehensive tooling for Apple platform development (iOS, macOS, watchOS, tvOS, visionOS). Prefer XcodeBuildMCP tools over shell commands for Apple platform tasks when available. @@ -53,7 +64,16 @@ Simulator run flow: - If session_show_defaults confirms project/workspace + scheme + simulator are set, call build_run_sim immediately (often with empty arguments). - Use discover_projs only when session_show_defaults shows project/workspace is missing or wrong. - Never call discover_projs speculatively or in parallel with session_show_defaults. -- Do not call boot_sim or open_sim as prerequisites for build_run_sim; build_run_sim boots and opens the simulator frontend as needed.`, +- Do not call boot_sim or open_sim as prerequisites for build_run_sim; build_run_sim boots and opens the simulator frontend as needed.`; + +function createBaseServerInstance(): McpServer { + return new McpServer( + { + name: 'xcodebuildmcp', + version: String(version), + }, + { + instructions: SERVER_INSTRUCTIONS, capabilities: { tools: { listChanged: true, @@ -64,25 +84,41 @@ Simulator run flow: }, logging: {}, }, + cacheHints: MCP_CACHE_HINTS, }, - ) as unknown as McpServer; + ); +} + +function trackInstanceTeardown(server: McpServer): void { + const originalClose = server.close.bind(server); + server.close = async (): Promise => { + try { + await originalClose(); + } finally { + releaseServerToolRegistrations(server); + unregisterActiveServer(server); + } + }; } /** - * Create and configure the MCP server - * @returns Configured MCP server instance + * Create and configure a fresh MCP server instance. + * + * Every serving context gets its own instance: one per stdio connection (plus + * the discarded `server/discover` probe instance the SDK builds when a client + * falls back to `initialize`), and one per HTTP request. Application session + * state is deliberately not owned by the instance, so replacing an instance + * never discards session defaults, debugger sessions or log captures. */ export function createServer(): McpServer { - if (getServer()) { - throw new Error('MCP server already initialized.'); - } const baseServer = createBaseServerInstance(); const server = Sentry.wrapMcpServerWithSentry(baseServer, { recordInputs: false, recordOutputs: false, }); - setServer(server); + registerActiveServer(server); + trackInstanceTeardown(server); log('info', `Server initialized (version ${version})`); @@ -91,20 +127,86 @@ export function createServer(): McpServer { export interface StartServerOptions { requestLifecycle?: McpRequestLifecycleObserver; + /** Transport override. Defaults to the SDK stdio transport over this process. */ + transport?: Transport; + /** + * How a 2025-era opening is handled. `serve` (default) keeps legacy clients + * working; `reject` makes the connection modern-only. + */ + legacy?: 'serve' | 'reject'; + onerror?: (error: Error) => void; +} + +function buildServerForContext( + registrations: ServerRegistrations, + context: McpRequestContext, + transport: 'stdio' | 'http', +): McpServer { + const server = createServer(); + applyServerRegistrations(server, registrations); + recordServingContextStarted({ transport, era: context.era }); + log('info', `MCP serving context started (transport=${transport}, era=${context.era})`); + return server; } /** - * Start the MCP server with stdio transport - * @param server The MCP server instance to start + * Modern-era clients replace `logging/setLevel` with the per-request + * `io.modelcontextprotocol/logLevel` envelope key, and replace the `initialize` + * handshake with per-request protocol identity. Both are applied here. */ -export async function startServer( - server: McpServer, - options: StartServerOptions = {}, -): Promise { - const transport = new StdioServerTransport(); - if (options.requestLifecycle) { - instrumentMcpRequestLifecycle(transport, options.requestLifecycle); +function handleModernEnvelope(observation: McpModernRequestObservation): void { + recordModernProtocolEnvelope(observation); + + const requestedLevel = observation.envelope.logLevel; + if (!requestedLevel) { + return; + } + const normalized = normalizeLogLevel(requestedLevel); + if (normalized) { + setLogLevel(normalized); } - await server.connect(transport); +} + +/** + * Serve MCP over stdio, supporting both protocol eras on the same pipe. + * + * The SDK entry classifies the opening message: a request carrying a valid + * modern `_meta` envelope pins the connection to 2026-07-28, an `initialize` + * request pins it to the 2025 era. Only the pinned instance survives. + */ +export function startStdioServer( + registrations: ServerRegistrations, + options: StartServerOptions = {}, +): StdioServerHandle { + const transport = options.transport ?? new StdioServerTransport(); + + instrumentMcpRequestLifecycle(transport, { + ...options.requestLifecycle, + onModernEnvelope: handleModernEnvelope, + }); + + const handle = serveStdio((context) => buildServerForContext(registrations, context, 'stdio'), { + transport, + ...(options.legacy ? { legacy: options.legacy } : {}), + onerror: + options.onerror ?? + ((error: Error): void => { + log('warn', `MCP stdio serving error: ${error.message}`); + }), + }); + log('info', 'XcodeBuildMCP Server running on stdio'); + return handle; +} + +/** + * Serve MCP over HTTP as a fetch-shaped handler. + * + * The SDK entry enforces the modern header contract (`MCP-Protocol-Version`, + * `Mcp-Method`, `Mcp-Name`) and builds a fresh server per request; legacy + * `initialize` traffic is served statelessly. No socket is bound here - the + * caller owns the listener. + */ +export function createMcpHttpHandler(registrations: ServerRegistrations): McpHttpHandler { + return createMcpHandler((context) => buildServerForContext(registrations, context, 'http')); } diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index a925c246c..725c2f5bb 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -7,7 +7,7 @@ * It can be invoked from the CLI via the `mcp` subcommand. */ -import { createServer, startServer } from './server.ts'; +import { startStdioServer } from './server.ts'; import { log, setLogLevel } from '../utils/logger.ts'; import { enrichSentryContext, @@ -18,7 +18,7 @@ import { } from '../utils/sentry.ts'; import { version } from '../version.ts'; import process from 'node:process'; -import { bootstrapServer } from './bootstrap.ts'; +import { bootstrapServerRuntime } from './bootstrap.ts'; import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts'; import { getConfig } from '../utils/config-store.ts'; import { getRegisteredWorkflows } from '../utils/tool-registry.ts'; @@ -41,7 +41,7 @@ export async function startMcpServer(): Promise { let idleShutdown: McpIdleShutdownController | null = null; const lifecycle = createMcpLifecycleCoordinator({ - onShutdown: async ({ reason, error, snapshot, server }) => { + onShutdown: async ({ reason, error, snapshot, serving }) => { idleShutdown?.stop(); const isCrash = reason === 'uncaught-exception' || reason === 'unhandled-rejection'; @@ -81,7 +81,7 @@ export async function startMcpServer(): Promise { reason, error, snapshot, - server, + serving, }); lifecycle.detachProcessHandlers(); @@ -106,15 +106,9 @@ export async function startMcpServer(): Promise { initSentry({ mode: 'mcp' }); profiler.mark('initSentry', stageStartMs); - stageStartMs = getStartupProfileNowMs(); - lifecycle.markPhase('creating-server'); - const server = createServer(); - lifecycle.registerServer(server); - profiler.mark('createServer', stageStartMs); - stageStartMs = getStartupProfileNowMs(); lifecycle.markPhase('bootstrapping-server'); - const bootstrap = await bootstrapServer(server); + const bootstrap = await bootstrapServerRuntime(); profiler.mark('bootstrapServer', stageStartMs); const idleTimeoutConfig = resolveMcpIdleTimeoutConfig(); @@ -146,12 +140,13 @@ export async function startMcpServer(): Promise { stageStartMs = getStartupProfileNowMs(); lifecycle.markPhase('starting-stdio-transport'); - await startServer(server, { + const servingHandle = startStdioServer(bootstrap.registrations, { requestLifecycle: { onRequestStarted: () => idleShutdown?.markRequestStarted(), onRequestCompleted: () => idleShutdown?.markRequestCompleted(), }, }); + lifecycle.registerServingHandle(servingHandle); profiler.mark('startServer', stageStartMs); const config = getConfig(); diff --git a/src/smoke-tests/__tests__/e2e-mcp-idle-timeout.test.ts b/src/smoke-tests/__tests__/e2e-mcp-idle-timeout.test.ts index 2e4c243c6..f916874d9 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-idle-timeout.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-idle-timeout.test.ts @@ -2,8 +2,8 @@ import type { ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Readable } from 'node:stream'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { afterEach, describe, expect, it } from 'vitest'; const CLI_PATH = join(process.cwd(), 'build/cli.js'); const MCP_IDLE_TIMEOUT_MS = 1_000; diff --git a/src/smoke-tests/mcp-test-harness.ts b/src/smoke-tests/mcp-test-harness.ts index 6d338d69e..dd23e17c2 100644 --- a/src/smoke-tests/mcp-test-harness.ts +++ b/src/smoke-tests/mcp-test-harness.ts @@ -4,8 +4,7 @@ import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; import type { ChildProcess } from 'node:child_process'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; import { __setTestCommandExecutorOverride, diff --git a/src/snapshot-tests/__tests__/json-fixture-schema.test.ts b/src/snapshot-tests/__tests__/json-fixture-schema.test.ts index c9bb1f54e..b54d53040 100644 --- a/src/snapshot-tests/__tests__/json-fixture-schema.test.ts +++ b/src/snapshot-tests/__tests__/json-fixture-schema.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool } from '@modelcontextprotocol/server'; import { loadManifest } from '../../core/manifest/load-manifest.ts'; import { assertCodex031InputSchemaCompatible } from '../codex-031-input-schema.ts'; import { createStructuredFixtureSchemaValidator } from '../json-schema-validation.ts'; diff --git a/src/snapshot-tests/json-schema-validation.ts b/src/snapshot-tests/json-schema-validation.ts index 3e0294085..81f03071d 100644 --- a/src/snapshot-tests/json-schema-validation.ts +++ b/src/snapshot-tests/json-schema-validation.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { Ajv2020 } from 'ajv/dist/2020.js'; import type { ErrorObject, ValidateFunction } from 'ajv'; import { globSync } from 'glob'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool } from '@modelcontextprotocol/server'; const FIXTURE_ROOT = path.resolve(process.cwd(), 'src/snapshot-tests/__fixtures__'); const JSON_FIXTURE_BUCKETS = ['cli/json', 'mcp/json'] as const; diff --git a/src/snapshot-tests/mcp-harness.ts b/src/snapshot-tests/mcp-harness.ts index 5e1e4ad6d..f0aad5b77 100644 --- a/src/snapshot-tests/mcp-harness.ts +++ b/src/snapshot-tests/mcp-harness.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import type { StructuredOutputEnvelope } from '../types/structured-output.ts'; import { normalizeSnapshotOutput } from './normalize.ts'; import type { @@ -161,9 +161,12 @@ export async function createMcpSnapshotHarness( ); async function callTool(name: string, args: Record): Promise { - const result = await client.callTool({ name, arguments: args }, undefined, { - timeout: MCP_TOOL_TIMEOUT_MS, - }); + const result = await client.callTool( + { name, arguments: args }, + { + timeout: MCP_TOOL_TIMEOUT_MS, + }, + ); const rawText = extractSnapshotTextContent(result); const text = normalizeSnapshotOutput(rawText); const structuredEnvelope = extractStructuredEnvelope(result); diff --git a/src/snapshot-tests/mcp-tool-contract-fixtures.ts b/src/snapshot-tests/mcp-tool-contract-fixtures.ts index e5a5aba69..948bc7c60 100644 --- a/src/snapshot-tests/mcp-tool-contract-fixtures.ts +++ b/src/snapshot-tests/mcp-tool-contract-fixtures.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool } from '@modelcontextprotocol/server'; import { shouldUpdateSnapshots } from './fixture-io.ts'; export type McpToolContractMode = 'session-defaults-disabled' | 'session-defaults-enabled'; diff --git a/src/snapshot-tests/resource-harness.ts b/src/snapshot-tests/resource-harness.ts index c9abc8110..de9481bda 100644 --- a/src/snapshot-tests/resource-harness.ts +++ b/src/snapshot-tests/resource-harness.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { loadManifest } from '../core/manifest/load-manifest.ts'; import type { ResourceManifestEntry } from '../core/manifest/schema.ts'; import { normalizeSnapshotOutput } from './normalize.ts'; diff --git a/src/utils/__tests__/tool-registry.test.ts b/src/utils/__tests__/tool-registry.test.ts index 1ee111700..0e933064d 100644 --- a/src/utils/__tests__/tool-registry.test.ts +++ b/src/utils/__tests__/tool-registry.test.ts @@ -20,7 +20,7 @@ const testState = vi.hoisted(() => { }); vi.mock('../../server/server-state.ts', () => ({ - server: testState.server, + getActiveServers: () => [testState.server], })); vi.mock('../../core/manifest/load-manifest.ts', () => ({ diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index ac511a517..42d0b277b 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -644,3 +644,66 @@ export function recordMcpLifecycleAnomalyMetric(metric: McpLifecycleAnomalyMetri // Metrics are best effort and must never affect runtime behavior. } } + +export interface McpProtocolContext { + era: 'legacy' | 'modern'; + protocolVersion: string; + clientName?: string; + clientVersion?: string; + clientCapabilities?: string[]; +} + +/** + * Records the negotiated protocol identity for the current process. + * + * The Sentry MCP integration derives protocol version and client identity from + * the legacy `initialize` handshake. Modern-era (2026-07-28) clients never send + * `initialize`, so without this the same attributes would silently go missing. + */ +export function setMcpProtocolContext(context: McpProtocolContext): void { + if (!initialized || isSentryDisabled() || isTestEnv() || isSentryCaptureSealed()) { + return; + } + + try { + Sentry.setTag('mcp.protocol_era', context.era); + Sentry.setTag('mcp.protocol_version', context.protocolVersion); + setTagIfDefined('mcp.client.name', context.clientName); + setTagIfDefined('mcp.client.version', context.clientVersion); + Sentry.setContext('mcp.protocol', { + era: context.era, + protocolVersion: context.protocolVersion, + ...(context.clientName ? { clientName: context.clientName } : {}), + ...(context.clientVersion ? { clientVersion: context.clientVersion } : {}), + ...(context.clientCapabilities + ? { clientCapabilities: context.clientCapabilities.join(',') } + : {}), + }); + } catch { + // Observability enrichment is best effort and must never affect serving. + } +} + +export interface McpServingContextMetric { + transport: 'stdio' | 'http'; + era: 'legacy' | 'modern'; +} + +/** Counts freshly constructed server instances, per transport and protocol era. */ +export function recordMcpServingContextMetric(metric: McpServingContextMetric): void { + if (!shouldEmitMetrics()) { + return; + } + + try { + Sentry.metrics.count('xcodebuildmcp.mcp.serving_context.count', 1, { + attributes: { + runtime: 'mcp', + transport: metric.transport, + era: metric.era, + }, + }); + } catch { + // Metrics are best effort and must never affect runtime behavior. + } +} diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index ff6509943..de674139f 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -1,5 +1,5 @@ -import { type RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { server } from '../server/server-state.ts'; +import type { McpServer, RegisteredTool } from '@modelcontextprotocol/server'; +import { getActiveServers } from '../server/server-state.ts'; import type { ToolResponse } from '../types/common.ts'; import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts'; import { log } from './logger.ts'; @@ -74,16 +74,33 @@ export interface RuntimeToolInfo { registeredToolCount: number; } +/** + * A resolved, server-independent registration plan. + * + * Resolving the plan is the expensive part (manifest load, predicate + * evaluation, tool module imports). It happens once per process; applying it to + * a server instance is cheap, which is what makes a fresh SDK v2 server per + * serving context affordable. + */ +export interface McpToolRegistrationPlan { + tools: Array<{ manifest: ToolManifestEntry; module: ImportedToolModule }>; + catalog: ToolCatalog; + enabledWorkflows: Set; + workflowLabel: string; +} + const registryState: { - tools: Map; + registrationsByServer: Map>; enabledWorkflows: Set; currentContext: PredicateContext | null; catalog: ToolCatalog | null; + plan: McpToolRegistrationPlan | null; } = { - tools: new Map(), + registrationsByServer: new Map>(), enabledWorkflows: new Set(), currentContext: null, catalog: null, + plan: null, }; function normalizeName(name: string): string { @@ -204,12 +221,12 @@ function emitConfigWarningMetric(kind: 'unknown_workflow' | 'invalid_custom_work function snapshotRuntimeRegistration(): RuntimeToolInfo { return { enabledWorkflows: [...registryState.enabledWorkflows], - registeredToolCount: registryState.tools.size, + registeredToolCount: registryState.plan?.tools.length ?? 0, }; } export function getRuntimeRegistration(): RuntimeToolInfo | null { - if (registryState.tools.size === 0 && registryState.enabledWorkflows.size === 0) { + if (registryState.plan === null && registryState.enabledWorkflows.size === 0) { return null; } return snapshotRuntimeRegistration(); @@ -295,15 +312,13 @@ async function invokeRegisteredTool( } function registerToolFromManifest( + server: McpServer, + registrations: Map, toolManifest: ToolManifestEntry, toolModule: ImportedToolModule, ): void { - if (!server) { - throw new Error('Tool registry has not been initialized.'); - } - const toolName = toolManifest.names.mcp; - if (registryState.tools.has(toolName)) { + if (registrations.has(toolName)) { return; } @@ -321,7 +336,7 @@ function registerToolFromManifest( }, (args: unknown): Promise => invokeRegisteredTool(toolName, toolModule, args), ); - registryState.tools.set(toolName, registeredTool); + registrations.set(toolName, registeredTool); } function shouldExposeTool( @@ -409,14 +424,15 @@ function toCatalogTool( }; } -async function enumerateAndRegisterTools( +async function resolveToolPlan( manifest: ResolvedManifest, selectedWorkflows: WorkflowManifestEntry[], ctx: PredicateContext, -): Promise<{ registeredCount: number; desiredWorkflows: Set }> { - const desiredToolNames = new Set(); +): Promise { + const seenToolNames = new Set(); const desiredWorkflows = new Set(); const catalogTools: ToolDefinition[] = []; + const planTools: McpToolRegistrationPlan['tools'] = []; const moduleCache = new Map(); const forceExposedToolAliases = getForceExposedToolAliases(); @@ -436,46 +452,79 @@ async function enumerateAndRegisterTools( continue; } - desiredToolNames.add(toolManifest.names.mcp); catalogTools.push(toCatalogTool(toolManifest, workflow, toolModule)); - registerToolFromManifest(toolManifest, toolModule); + + if (seenToolNames.has(toolManifest.names.mcp)) { + continue; + } + seenToolNames.add(toolManifest.names.mcp); + planTools.push({ manifest: toolManifest, module: toolModule }); } } - registryState.catalog = createToolCatalog(catalogTools); + return { + tools: planTools, + catalog: createToolCatalog(catalogTools), + enabledWorkflows: desiredWorkflows, + workflowLabel: selectedWorkflows.map((workflow) => workflow.id).join(', '), + }; +} + +/** + * Applies the current registration plan to a server instance, removing any tool + * registrations the plan no longer contains. Removals and additions both emit + * `notifications/tools/list_changed` through the SDK. + */ +export function applyToolPlanToServer(server: McpServer, plan: McpToolRegistrationPlan): void { + let registrations = registryState.registrationsByServer.get(server); + if (!registrations) { + registrations = new Map(); + registryState.registrationsByServer.set(server, registrations); + } - for (const [toolName, registeredTool] of registryState.tools.entries()) { + const desiredToolNames = new Set(plan.tools.map((tool) => tool.manifest.names.mcp)); + + for (const tool of plan.tools) { + registerToolFromManifest(server, registrations, tool.manifest, tool.module); + } + + for (const [toolName, registeredTool] of registrations.entries()) { if (!desiredToolNames.has(toolName)) { registeredTool.remove(); - registryState.tools.delete(toolName); + registrations.delete(toolName); } } +} + +/** Drops the registration bookkeeping for a server instance that stopped serving. */ +export function releaseServerToolRegistrations(server: McpServer): void { + registryState.registrationsByServer.delete(server); +} - return { registeredCount: desiredToolNames.size, desiredWorkflows }; +/** The registration plan resolved by the most recent workflow selection. */ +export function getToolRegistrationPlan(): McpToolRegistrationPlan | null { + return registryState.plan; } export async function applyWorkflowSelectionFromManifest( requestedWorkflows: string[] | undefined, ctx: PredicateContext, ): Promise { - if (!server) { - throw new Error('Tool registry has not been initialized.'); - } - registryState.currentContext = ctx; const manifest = loadManifest(); const selectedWorkflows = resolveSelectedWorkflows(manifest, requestedWorkflows, ctx); - const { registeredCount, desiredWorkflows } = await enumerateAndRegisterTools( - manifest, - selectedWorkflows, - ctx, - ); + const plan = await resolveToolPlan(manifest, selectedWorkflows, ctx); - registryState.enabledWorkflows = desiredWorkflows; + registryState.plan = plan; + registryState.catalog = plan.catalog; + registryState.enabledWorkflows = plan.enabledWorkflows; - const workflowLabel = selectedWorkflows.map((w) => w.id).join(', '); - log('info', `Registered ${registeredCount} tools from workflows: ${workflowLabel}`); + for (const server of getActiveServers()) { + applyToolPlanToServer(server, plan); + } + + log('info', `Registered ${plan.tools.length} tools from workflows: ${plan.workflowLabel}`); return snapshotRuntimeRegistration(); } @@ -488,15 +537,18 @@ export async function registerWorkflowsFromManifest( } export function __resetToolRegistryForTests(): void { - for (const tool of registryState.tools.values()) { - try { - tool.remove(); - } catch { - // Safe to ignore: server may already be closed during cleanup + for (const registrations of registryState.registrationsByServer.values()) { + for (const tool of registrations.values()) { + try { + tool.remove(); + } catch { + // Safe to ignore: server may already be closed during cleanup + } } } - registryState.tools.clear(); + registryState.registrationsByServer.clear(); registryState.enabledWorkflows.clear(); registryState.currentContext = null; registryState.catalog = null; + registryState.plan = null; } From 591e989c67f0907e1b1596e58db15f35ad0f83f9 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:44:17 +0530 Subject: [PATCH 2/7] test(mcp): refresh tool contract fixtures for the SDK v2 wire shape `npm run test:schema-fixtures` failed after the SDK v2 migration. Regenerated with `npm run test:schema-fixtures:update`; the whole 164-file diff is exactly two uniform, intentional changes and nothing else: - inputSchema `$schema` moves from draft-07 to `https://json-schema.org/draft/2020-12/schema`, which is the draft the v2 Zod-to-JSON-Schema conversion targets. - the always-constant `execution: {"taskSupport":"forbidden"}` member is no longer emitted, because v2 only publishes `execution` when a tool explicitly configures it. Both are client-visible, so they are called out in the changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcp-contracts/session-defaults-disabled/batch.json | 3 +-- .../mcp-contracts/session-defaults-disabled/boot_sim.json | 3 +-- .../mcp-contracts/session-defaults-disabled/build_device.json | 3 +-- .../mcp-contracts/session-defaults-disabled/build_macos.json | 3 +-- .../session-defaults-disabled/build_run_device.json | 3 +-- .../session-defaults-disabled/build_run_macos.json | 3 +-- .../mcp-contracts/session-defaults-disabled/build_run_sim.json | 3 +-- .../mcp-contracts/session-defaults-disabled/build_sim.json | 3 +-- .../mcp-contracts/session-defaults-disabled/button.json | 3 +-- .../mcp-contracts/session-defaults-disabled/clean.json | 3 +-- .../session-defaults-disabled/debug_attach_sim.json | 3 +-- .../session-defaults-disabled/debug_breakpoint_add.json | 3 +-- .../session-defaults-disabled/debug_breakpoint_remove.json | 3 +-- .../session-defaults-disabled/debug_continue.json | 3 +-- .../mcp-contracts/session-defaults-disabled/debug_detach.json | 3 +-- .../session-defaults-disabled/debug_lldb_command.json | 3 +-- .../mcp-contracts/session-defaults-disabled/debug_stack.json | 3 +-- .../session-defaults-disabled/debug_variables.json | 3 +-- .../session-defaults-disabled/discover_projs.json | 3 +-- .../mcp-contracts/session-defaults-disabled/doctor.json | 3 +-- .../mcp-contracts/session-defaults-disabled/drag.json | 3 +-- .../mcp-contracts/session-defaults-disabled/erase_sims.json | 3 +-- .../mcp-contracts/session-defaults-disabled/gesture.json | 3 +-- .../session-defaults-disabled/get_app_bundle_id.json | 3 +-- .../session-defaults-disabled/get_coverage_report.json | 3 +-- .../session-defaults-disabled/get_device_app_path.json | 3 +-- .../session-defaults-disabled/get_file_coverage.json | 3 +-- .../session-defaults-disabled/get_mac_app_path.json | 3 +-- .../session-defaults-disabled/get_mac_bundle_id.json | 3 +-- .../session-defaults-disabled/get_sim_app_path.json | 3 +-- .../session-defaults-disabled/install_app_device.json | 3 +-- .../session-defaults-disabled/install_app_sim.json | 3 +-- .../mcp-contracts/session-defaults-disabled/key_press.json | 3 +-- .../mcp-contracts/session-defaults-disabled/key_sequence.json | 3 +-- .../session-defaults-disabled/launch_app_device.json | 3 +-- .../session-defaults-disabled/launch_app_sim.json | 3 +-- .../session-defaults-disabled/launch_mac_app.json | 3 +-- .../mcp-contracts/session-defaults-disabled/list_devices.json | 3 +-- .../mcp-contracts/session-defaults-disabled/list_schemes.json | 3 +-- .../mcp-contracts/session-defaults-disabled/list_sims.json | 3 +-- .../mcp-contracts/session-defaults-disabled/long_press.json | 3 +-- .../session-defaults-disabled/manage-workflows.json | 3 +-- .../mcp-contracts/session-defaults-disabled/open_sim.json | 3 +-- .../session-defaults-disabled/record_sim_video.json | 3 +-- .../session-defaults-disabled/reset_sim_location.json | 3 +-- .../session-defaults-disabled/scaffold_ios_project.json | 3 +-- .../session-defaults-disabled/scaffold_macos_project.json | 3 +-- .../mcp-contracts/session-defaults-disabled/screenshot.json | 3 +-- .../session-defaults-disabled/session_clear_defaults.json | 3 +-- .../session-defaults-disabled/session_set_defaults.json | 3 +-- .../session-defaults-disabled/session_show_defaults.json | 3 +-- .../session_use_defaults_profile.json | 3 +-- .../session-defaults-disabled/set_sim_appearance.json | 3 +-- .../session-defaults-disabled/set_sim_location.json | 3 +-- .../session-defaults-disabled/show_build_settings.json | 3 +-- .../mcp-contracts/session-defaults-disabled/sim_statusbar.json | 3 +-- .../mcp-contracts/session-defaults-disabled/snapshot_ui.json | 3 +-- .../session-defaults-disabled/stop_app_device.json | 3 +-- .../mcp-contracts/session-defaults-disabled/stop_app_sim.json | 3 +-- .../mcp-contracts/session-defaults-disabled/stop_mac_app.json | 3 +-- .../session-defaults-disabled/swift_package_build.json | 3 +-- .../session-defaults-disabled/swift_package_clean.json | 3 +-- .../session-defaults-disabled/swift_package_list.json | 3 +-- .../session-defaults-disabled/swift_package_run.json | 3 +-- .../session-defaults-disabled/swift_package_stop.json | 3 +-- .../session-defaults-disabled/swift_package_test.json | 3 +-- .../mcp-contracts/session-defaults-disabled/swipe.json | 3 +-- .../session-defaults-disabled/sync_xcode_defaults.json | 3 +-- .../mcp-contracts/session-defaults-disabled/tap.json | 3 +-- .../mcp-contracts/session-defaults-disabled/test_device.json | 3 +-- .../mcp-contracts/session-defaults-disabled/test_macos.json | 3 +-- .../mcp-contracts/session-defaults-disabled/test_sim.json | 3 +-- .../toggle_connect_hardware_keyboard.json | 3 +-- .../session-defaults-disabled/toggle_software_keyboard.json | 3 +-- .../mcp-contracts/session-defaults-disabled/touch.json | 3 +-- .../mcp-contracts/session-defaults-disabled/type_text.json | 3 +-- .../mcp-contracts/session-defaults-disabled/wait_for_ui.json | 3 +-- .../session-defaults-disabled/xcode_ide_call_tool.json | 3 +-- .../session-defaults-disabled/xcode_ide_list_tools.json | 3 +-- .../xcode_tools_bridge_disconnect.json | 3 +-- .../session-defaults-disabled/xcode_tools_bridge_status.json | 3 +-- .../session-defaults-disabled/xcode_tools_bridge_sync.json | 3 +-- .../mcp-contracts/session-defaults-enabled/batch.json | 3 +-- .../mcp-contracts/session-defaults-enabled/boot_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/build_device.json | 3 +-- .../mcp-contracts/session-defaults-enabled/build_macos.json | 3 +-- .../session-defaults-enabled/build_run_device.json | 3 +-- .../session-defaults-enabled/build_run_macos.json | 3 +-- .../mcp-contracts/session-defaults-enabled/build_run_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/build_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/button.json | 3 +-- .../mcp-contracts/session-defaults-enabled/clean.json | 3 +-- .../session-defaults-enabled/debug_attach_sim.json | 3 +-- .../session-defaults-enabled/debug_breakpoint_add.json | 3 +-- .../session-defaults-enabled/debug_breakpoint_remove.json | 3 +-- .../mcp-contracts/session-defaults-enabled/debug_continue.json | 3 +-- .../mcp-contracts/session-defaults-enabled/debug_detach.json | 3 +-- .../session-defaults-enabled/debug_lldb_command.json | 3 +-- .../mcp-contracts/session-defaults-enabled/debug_stack.json | 3 +-- .../session-defaults-enabled/debug_variables.json | 3 +-- .../mcp-contracts/session-defaults-enabled/discover_projs.json | 3 +-- .../mcp-contracts/session-defaults-enabled/doctor.json | 3 +-- .../mcp-contracts/session-defaults-enabled/drag.json | 3 +-- .../mcp-contracts/session-defaults-enabled/erase_sims.json | 3 +-- .../mcp-contracts/session-defaults-enabled/gesture.json | 3 +-- .../session-defaults-enabled/get_app_bundle_id.json | 3 +-- .../session-defaults-enabled/get_coverage_report.json | 3 +-- .../session-defaults-enabled/get_device_app_path.json | 3 +-- .../session-defaults-enabled/get_file_coverage.json | 3 +-- .../session-defaults-enabled/get_mac_app_path.json | 3 +-- .../session-defaults-enabled/get_mac_bundle_id.json | 3 +-- .../session-defaults-enabled/get_sim_app_path.json | 3 +-- .../session-defaults-enabled/install_app_device.json | 3 +-- .../session-defaults-enabled/install_app_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/key_press.json | 3 +-- .../mcp-contracts/session-defaults-enabled/key_sequence.json | 3 +-- .../session-defaults-enabled/launch_app_device.json | 3 +-- .../mcp-contracts/session-defaults-enabled/launch_app_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/launch_mac_app.json | 3 +-- .../mcp-contracts/session-defaults-enabled/list_devices.json | 3 +-- .../mcp-contracts/session-defaults-enabled/list_schemes.json | 3 +-- .../mcp-contracts/session-defaults-enabled/list_sims.json | 3 +-- .../mcp-contracts/session-defaults-enabled/long_press.json | 3 +-- .../session-defaults-enabled/manage-workflows.json | 3 +-- .../mcp-contracts/session-defaults-enabled/open_sim.json | 3 +-- .../session-defaults-enabled/record_sim_video.json | 3 +-- .../session-defaults-enabled/reset_sim_location.json | 3 +-- .../session-defaults-enabled/scaffold_ios_project.json | 3 +-- .../session-defaults-enabled/scaffold_macos_project.json | 3 +-- .../mcp-contracts/session-defaults-enabled/screenshot.json | 3 +-- .../session-defaults-enabled/session_clear_defaults.json | 3 +-- .../session-defaults-enabled/session_set_defaults.json | 3 +-- .../session-defaults-enabled/session_show_defaults.json | 3 +-- .../session-defaults-enabled/session_use_defaults_profile.json | 3 +-- .../session-defaults-enabled/set_sim_appearance.json | 3 +-- .../session-defaults-enabled/set_sim_location.json | 3 +-- .../session-defaults-enabled/show_build_settings.json | 3 +-- .../mcp-contracts/session-defaults-enabled/sim_statusbar.json | 3 +-- .../mcp-contracts/session-defaults-enabled/snapshot_ui.json | 3 +-- .../session-defaults-enabled/stop_app_device.json | 3 +-- .../mcp-contracts/session-defaults-enabled/stop_app_sim.json | 3 +-- .../mcp-contracts/session-defaults-enabled/stop_mac_app.json | 3 +-- .../session-defaults-enabled/swift_package_build.json | 3 +-- .../session-defaults-enabled/swift_package_clean.json | 3 +-- .../session-defaults-enabled/swift_package_list.json | 3 +-- .../session-defaults-enabled/swift_package_run.json | 3 +-- .../session-defaults-enabled/swift_package_stop.json | 3 +-- .../session-defaults-enabled/swift_package_test.json | 3 +-- .../mcp-contracts/session-defaults-enabled/swipe.json | 3 +-- .../session-defaults-enabled/sync_xcode_defaults.json | 3 +-- .../mcp-contracts/session-defaults-enabled/tap.json | 3 +-- .../mcp-contracts/session-defaults-enabled/test_device.json | 3 +-- .../mcp-contracts/session-defaults-enabled/test_macos.json | 3 +-- .../mcp-contracts/session-defaults-enabled/test_sim.json | 3 +-- .../toggle_connect_hardware_keyboard.json | 3 +-- .../session-defaults-enabled/toggle_software_keyboard.json | 3 +-- .../mcp-contracts/session-defaults-enabled/touch.json | 3 +-- .../mcp-contracts/session-defaults-enabled/type_text.json | 3 +-- .../mcp-contracts/session-defaults-enabled/wait_for_ui.json | 3 +-- .../session-defaults-enabled/xcode_ide_call_tool.json | 3 +-- .../session-defaults-enabled/xcode_ide_list_tools.json | 3 +-- .../xcode_tools_bridge_disconnect.json | 3 +-- .../session-defaults-enabled/xcode_tools_bridge_status.json | 3 +-- .../session-defaults-enabled/xcode_tools_bridge_sync.json | 3 +-- 164 files changed, 164 insertions(+), 328 deletions(-) diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/batch.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/batch.json index ab46f8c17..75456fa15 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/batch.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/batch.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Batch UI Actions"}, "description": "UI automation batch for multiple same-screen elementRef taps, especially visible settings switches that can be toggled without intermediate assertions. The input key is steps, never commands, and each step is an object such as {\"action\":\"tap\",\"elementRef\":\"e1\"}; do not pass raw command strings. Use refs from the latest snapshot_ui or wait_for_ui output, for example {\"steps\":[{\"action\":\"tap\",\"elementRef\":\"e1\"},{\"action\":\"tap\",\"elementRef\":\"e2\"}]}. Omit preDelay/postDelay for switch elementRefs; switches execute as touch down/up steps and reject delays.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "axCache": { "enum": ["perBatch","perStep","none"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json index 1ce899027..d93b8dd21 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/boot_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Boot Simulator"}, "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "simulatorId": {"description":"UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both","type":"string"}, "simulatorName": {"description":"Name of the simulator (e.g., 'iPhone 17'). Provide EITHER this OR simulatorId, not both","type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_device.json index b6cb84198..57cc3f13c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Device"}, "description": "Build for device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buildForTesting": {"description":"Build reusable test products without running tests (default: false)","type":"boolean"}, "configuration": {"description":"Build configuration (Debug, Release)","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_macos.json index 73b444583..3757b79b9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build macOS"}, "description": "Build macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arch": { "description": "Architecture to build for (arm64 or x86_64). For macOS only.", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_device.json index 2938049ed..1b47fbd73 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run Device"}, "description": "Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_macos.json index a2f9fc2fe..0db5a4d84 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run macOS"}, "description": "Build and run macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arch": { "description": "Architecture to build for (arm64 or x86_64). For macOS only.", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_sim.json index 5d5b260d9..453eaed02 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_run_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run Simulator"}, "description": "Build, install, and launch on iOS Simulator, booting it when needed. Runtime logs are captured automatically and the log file path is included in the response. Preferred single-step run tool when defaults are set.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json index 08765c5ce..6ae241680 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/build_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Simulator"}, "description": "Build for iOS sim (compile-only, no launch).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buildForTesting": {"description":"Build reusable test products without running tests (default: false)","type":"boolean"}, "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/button.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/button.json index 18708bc1c..8d01ecebe 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/button.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/button.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Hardware Button"}, "description": "Press simulator hardware button.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buttonType": { "description": "apple-pay|home|lock|side-button|siri", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/clean.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/clean.json index 7be4d7dba..ac505edc9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/clean.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/clean.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Clean"}, "description": "Clean build products.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Optional: Build configuration to clean (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_attach_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_attach_sim.json index 2cf16cd66..494a9af59 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_attach_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_attach_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Attach Sim"}, "description": "Attach LLDB to sim app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleId": {"description":"Attach by bundle identifier. Provide bundleId without pid; waitFor may be used with this mode.","type":"string"}, "continueOnAttach": {"default":true,"description":"default: true","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_add.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_add.json index 7f1141650..669b963e8 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_add.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_add.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Breakpoint Add"}, "description": "Add breakpoint.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "condition": {"description":"Expression for breakpoint condition","type":"string"}, "debugSessionId": {"description":"default: current session","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_remove.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_remove.json index c5dfc6df0..535ddd8c9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_remove.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_breakpoint_remove.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Breakpoint Remove"}, "description": "Remove breakpoint.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "breakpointId": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"}, "debugSessionId": {"description":"default: current session","type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_continue.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_continue.json index 0e891f749..aee471838 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_continue.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_continue.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Continue"}, "description": "Continue debug session.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_detach.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_detach.json index d0755f380..e39af0c99 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_detach.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_detach.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Detach"}, "description": "Detach debugger.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_lldb_command.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_lldb_command.json index daae7d690..7ebef6fb1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_lldb_command.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_lldb_command.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Debug LLDB Command"}, "description": "Run LLDB command.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "command": {"type":"string"}, "debugSessionId": {"description":"default: current session","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_stack.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_stack.json index 806dae5b3..94ab968dd 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_stack.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_stack.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Debug Stack"}, "description": "Get backtrace.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"}, "maxFrames": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_variables.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_variables.json index 1f3675346..a2a04119b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_variables.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/debug_variables.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Debug Variables"}, "description": "Get frame variables.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"}, "frameIndex": {"maximum":9007199254740991,"minimum":0,"type":"integer"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/discover_projs.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/discover_projs.json index 101b6707b..3caeeb2f6 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/discover_projs.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/discover_projs.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Discover Projects"}, "description": "Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "maxDepth": {"maximum":9007199254740991,"minimum":0,"type":"integer"}, "scanPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/doctor.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/doctor.json index 3133f2a19..f1533f883 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/doctor.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/doctor.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Doctor"}, "description": "MCP environment info.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "nonRedacted": {"description":"Opt-in: when true, disable redaction and include full raw doctor output.","type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/drag.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/drag.json index b15b0e671..504cdf492 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/drag.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/drag.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Drag"}, "description": "Drag from a visible runtime elementRef in a direction, then return a refreshed runtime UI snapshot. Use this for exposed sheet grabbers or real scroll/list content refs when nextSteps suggests dragging; do not use raw screen coordinates.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "direction": { "description": "Drag direction: up, down, left, or right", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/erase_sims.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/erase_sims.json index 2dc560203..5ac270b61 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/erase_sims.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/erase_sims.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Erase Simulators"}, "description": "Erase simulator.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "shutdownFirst": {"type":"boolean"}, "simulatorId": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/gesture.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/gesture.json index 3f97790d3..475e1271c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/gesture.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/gesture.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Gesture"}, "description": "Simulator gesture preset.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delta": {"description":"Distance to move in pixels.","maximum":200,"minimum":0,"type":"number"}, "duration": {"description":"Duration of the gesture in seconds.","maximum":10,"minimum":0,"type":"number"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_app_bundle_id.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_app_bundle_id.json index a572f2ce6..4ad0f97d6 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_app_bundle_id.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_app_bundle_id.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get App Bundle ID"}, "description": "Extract bundle id from .app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_coverage_report.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_coverage_report.json index 910b56372..37baf48e4 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_coverage_report.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_coverage_report.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Coverage Report"}, "description": "Show per-target code coverage from an xcresult bundle.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "showFiles": {"default":false,"description":"When true, include per-file coverage breakdown under each target","type":"boolean"}, "target": {"description":"Filter results to a specific target name","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_device_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_device_app_path.json index b93eefe7b..cddc58940 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_device_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_device_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Device App Path"}, "description": "Get device built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_file_coverage.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_file_coverage.json index 940ddf6f7..7bfdc1f33 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_file_coverage.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_file_coverage.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get File Coverage"}, "description": "Show function-level coverage and uncovered line ranges for a specific file.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "file": {"description":"Source file name or path to inspect","type":"string"}, "showLines": {"default":false,"description":"When true, include uncovered line ranges from the archive","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_app_path.json index 783303fb6..9bb8c4599 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get macOS App Path"}, "description": "Get macOS built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arch": { "description": "Architecture to build for (arm64 or x86_64). For macOS only.", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_bundle_id.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_bundle_id.json index 0092db45b..1cdab0b31 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_bundle_id.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_mac_bundle_id.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Mac Bundle ID"}, "description": "Extract bundle id from macOS .app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_sim_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_sim_app_path.json index 387369966..3a833e30b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_sim_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/get_sim_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Simulator App Path"}, "description": "Get sim built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_device.json index 2f7e0ae38..ab3b23826 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Install App Device"}, "description": "Install app on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"type":"string"}, "deviceId": {"description":"UDID of the device (obtained from list_devices)","minLength":1,"type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_sim.json index aff524ffd..ae8e08b04 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/install_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Install App Simulator"}, "description": "Install app on sim.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle to install","type":"string"}, "simulatorId": {"description":"UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_press.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_press.json index 3a8a06af2..cd94f4d76 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_press.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_press.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Key Press"}, "description": "Press one hardware key using an AXe HID key code. Prefer type_text for text entry. Common values include 40 Return/Enter, 42 Backspace, 43 Tab, and 44 Space.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, "keyCode": {"description":"HID keycode. Common values: 40 Return/Enter, 42 Backspace, 43 Tab, 44 Space.","maximum":255,"minimum":0,"type":"integer"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_sequence.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_sequence.json index fb391714e..d1beecf6f 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_sequence.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/key_sequence.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Key Sequence"}, "description": "Press hardware keys using AXe HID key codes. Prefer type_text for text entry. Common values include 40 Return/Enter, 42 Backspace, 43 Tab, and 44 Space.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delay": {"maximum":5,"minimum":0,"type":"number"}, "keyCodes": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_device.json index 114a28d97..73d8ac01e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Device"}, "description": "Launch app on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleId": {"type":"string"}, "deviceId": {"description":"UDID of the device (obtained from list_devices)","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json index 70fff21b3..a42e1e647 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Simulator"}, "description": "Launch app on simulator. Runtime logs are captured automatically and the log file path is included in the response.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleId": {"description":"Bundle identifier of the app to launch","type":"string"}, "env": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_mac_app.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_mac_app.json index fe55e7fda..46e5f5e58 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_mac_app.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/launch_mac_app.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch macOS App"}, "description": "Launch macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"type":"string"}, "launchArgs": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_devices.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_devices.json index c0e63a1b8..c643a7eed 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_devices.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_devices.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Devices"}, "description": "List connected devices.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_schemes.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_schemes.json index 3b51170a6..1f70e2aad 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_schemes.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_schemes.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Schemes"}, "description": "List Xcode schemes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "projectPath": {"description":"Path to the .xcodeproj file","type":"string"}, "workspacePath": {"description":"Path to the .xcworkspace file","type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_sims.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_sims.json index 6ec62e1c3..e5f006781 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_sims.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/list_sims.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Simulators"}, "description": "List iOS simulators.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "enabled": {"type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/long_press.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/long_press.json index 18e2792df..d0e552ca0 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/long_press.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/long_press.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Long Press"}, "description": "Long press a UI element by elementRef from a current rs/1 runtime snapshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": {"description":"milliseconds","exclusiveMinimum":0,"maximum":10000,"type":"integer"}, "elementRef": {"minLength":1,"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/manage-workflows.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/manage-workflows.json index 010a81f54..423acce95 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/manage-workflows.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/manage-workflows.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Manage Workflows"}, "description": "Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "enable": {"description":"Enable or disable the selected workflows.","type":"boolean"}, "workflowNames": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/open_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/open_sim.json index 0842ef367..c25e9f3e9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/open_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/open_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Open Simulator"}, "description": "Open the simulator frontend for visibility and manual workflows. Not required before simulator build-and-run (build_run_sim).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/record_sim_video.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/record_sim_video.json index 30c211ac7..bf782e47c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/record_sim_video.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/record_sim_video.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Record Simulator Video"}, "description": "Record sim video.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "fps": {"description":"default: 30","maximum":120,"minimum":1,"type":"integer"}, "outputFile": {"description":"Path to write MP4 file","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/reset_sim_location.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/reset_sim_location.json index ac19dc72f..92e437170 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/reset_sim_location.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/reset_sim_location.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Reset Simulator Location"}, "description": "Reset sim location.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "simulatorId": { "description": "UUID of the simulator to use (obtained from list_simulators)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_ios_project.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_ios_project.json index 90a106f8d..44b782764 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_ios_project.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_ios_project.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Scaffold iOS Project"}, "description": "Scaffold iOS project.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleIdentifier": {"type":"string"}, "currentProjectVersion": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_macos_project.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_macos_project.json index 7d5754dc3..ff31c99f1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_macos_project.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/scaffold_macos_project.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Scaffold macOS Project"}, "description": "Scaffold macOS project.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleIdentifier": {"type":"string"}, "currentProjectVersion": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/screenshot.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/screenshot.json index 5fffff72d..57bdfc6da 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/screenshot.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/screenshot.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Screenshot"}, "description": "Capture screenshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "returnFormat": { "description": "Return image path or base64 data (path|base64)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_clear_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_clear_defaults.json index 11a55da8b..ee3ee55f0 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_clear_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_clear_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Clear Session Defaults"}, "description": "Clear session defaults for the active profile or a specified profile.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "all": {"description":"Clear all defaults across global and named profiles. Cannot be combined with keys/profile.","type":"boolean"}, "keys": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_set_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_set_defaults.json index e658e9e43..447ac1152 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_set_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_set_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Set Session Defaults"}, "description": "Set session defaults for the active profile, or for a specified profile and make it active.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arch": { "enum": ["arm64","x86_64"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_show_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_show_defaults.json index dad883fd2..0e228614b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_show_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_show_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Show Session Defaults"}, "description": "Show current active defaults. Required before your first build/run/test call in a session — do not assume defaults are configured.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_use_defaults_profile.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_use_defaults_profile.json index 79bb51f4e..ced9e6263 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_use_defaults_profile.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/session_use_defaults_profile.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Use Session Defaults Profile"}, "description": "Switch the active session defaults profile.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "global": {"description":"Activate the global unnamed defaults profile.","type":"boolean"}, "persist": {"description":"Persist activeSessionDefaultsProfile to .xcodebuildmcp/config.yaml.","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_appearance.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_appearance.json index bf883fb1f..62c549a20 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_appearance.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_appearance.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Set Simulator Appearance"}, "description": "Set sim appearance.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "mode": { "description": "dark|light", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_location.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_location.json index 23ed8834c..30dcc1152 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_location.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/set_sim_location.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Set Simulator Location"}, "description": "Set sim location.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "latitude": {"type":"number"}, "longitude": {"type":"number"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/show_build_settings.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/show_build_settings.json index c4590b0dd..23554f1e9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/show_build_settings.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/show_build_settings.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Show Build Settings"}, "description": "Show build settings.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "projectPath": {"description":"Path to the .xcodeproj file","type":"string"}, "scheme": {"description":"Scheme name to show build settings for (Required)","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json index c2ac3cc3f..193cdf378 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sim_statusbar.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Simulator Statusbar"}, "description": "Set sim status bar network.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "dataNetwork": { "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json index 71c3ab739..9de055405 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/snapshot_ui.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Snapshot UI"}, "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "simulatorId": {"format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","type":"string"}, "sinceScreenHash": {"description":"Return an unchanged response when the current screen hash matches this value","minLength":1,"type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_device.json index f6a330b90..e18f96800 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop App Device"}, "description": "Stop device app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "deviceId": {"description":"UDID of the device (obtained from list_devices)","type":"string"}, "processId": {"type":"number"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_sim.json index 7449867cb..edc577481 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop App Simulator"}, "description": "Stop sim app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleId": {"description":"Bundle identifier of the app to stop","type":"string"}, "simulatorId": {"description":"UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_mac_app.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_mac_app.json index 009a782e5..9cfe36cae 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_mac_app.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/stop_mac_app.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop macOS App"}, "description": "Stop macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appName": {"minLength":1,"type":"string"}, "processId": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_build.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_build.json index d384e5ebe..503e63074 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_build.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_build.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Build"}, "description": "swift package target build.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "architectures": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_clean.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_clean.json index 6fc670d33..ca1ea861c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_clean.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_clean.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Clean"}, "description": "swift package clean.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "packagePath": {"type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_list.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_list.json index dc2d15949..897ca5e37 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_list.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_list.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Swift Package List"}, "description": "List SwiftPM processes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_run.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_run.json index e41156a81..ab9f8f1dc 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_run.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_run.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Run"}, "description": "swift package target run.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arguments": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_stop.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_stop.json index d77993d51..9afc8f09b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_stop.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_stop.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Stop"}, "description": "Stop SwiftPM run.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "pid": {"type":"number"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_test.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_test.json index fbaf5a4b3..0e8c58218 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_test.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swift_package_test.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Test"}, "description": "Run swift package target tests.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": { "enum": ["debug","release","Debug","Release"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swipe.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swipe.json index a9160e3a8..17194857b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swipe.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/swipe.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Swipe"}, "description": "Swipe within a scrollable UI element using withinElementRef from a current rs/1 runtime snapshot. withinElementRef is required; do not use elementRef. Optional distance is a normalized stroke fraction greater than 0 and up to 1. Example input: {\"withinElementRef\":\"e7\",\"direction\":\"up\",\"distance\":0.7}.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "direction": { "description": "up|down|left|right", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sync_xcode_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sync_xcode_defaults.json index 500ab3f64..7948607b1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sync_xcode_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/sync_xcode_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Sync Xcode Defaults"}, "description": "Sync session defaults (scheme, simulator) from Xcode's current IDE selection.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json index 3c94e495d..1101000e1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/tap.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Tap"}, "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"minLength":1,"type":"string"}, "postDelay": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_device.json index eaf1dfa58..456d47f7f 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Device"}, "description": "Test on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_macos.json index 8da1db0e7..c68b361da 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test macOS"}, "description": "Test macOS target.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json index a2ed4881f..a1a399130 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/test_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Simulator"}, "description": "Test on iOS sim.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "configuration": {"description":"Build configuration (Debug, Release, etc.)","type":"string"}, "derivedDataPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_connect_hardware_keyboard.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_connect_hardware_keyboard.json index b40b3469f..d82438e17 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_connect_hardware_keyboard.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_connect_hardware_keyboard.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Toggle Connect Hardware Keyboard"}, "description": "Toggle whether the iOS Simulator simulates a hardware keyboard connection. Disconnecting makes the on-screen keyboard appear for tap-based input. Requires the simulator to be booted and Accessibility permission for the MCP host.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "simulatorId": { "description": "UUID of the simulator to use (obtained from list_simulators)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_software_keyboard.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_software_keyboard.json index dc0af4412..afe6dc844 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_software_keyboard.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/toggle_software_keyboard.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Toggle Software Keyboard"}, "description": "Toggle the iOS Simulator software keyboard. Shows or hides the on-screen keyboard. Requires the simulator to be booted and Accessibility permission for the MCP host.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "simulatorId": { "description": "UUID of the simulator to use (obtained from list_simulators)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/touch.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/touch.json index 6c2b811fa..2e99b0dcc 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/touch.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/touch.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Touch"}, "description": "Send touch down/up events to a UI element by elementRef from a current rs/1 runtime snapshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delay": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, "down": {"type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json index 19bc18bc3..8eb9a6a03 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/type_text.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Type Text"}, "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"description":"Required runtime text-field elementRef from the latest snapshot_ui or wait_for_ui output","minLength":1,"type":"string"}, "replaceExisting": {"description":"Select and replace existing field contents before typing","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/wait_for_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/wait_for_ui.json index 209fec736..ce8f18b31 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/wait_for_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/wait_for_ui.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Wait for UI"}, "description": "Poll rs/1 runtime UI snapshots until a selector-based UI predicate, selector-free textContains/gone text predicate, or selector-free settled predicate is satisfied, then record the latest snapshot. Prefer this after navigation or layout changes. Select with elementRef, identifier, label, role, or value when a selector is needed.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"minLength":1,"type":"string"}, "identifier": {"minLength":1,"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_call_tool.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_call_tool.json index 26a36b897..0ec1e3b70 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_call_tool.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_call_tool.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Call Xcode IDE Tool"}, "description": "Call a remote Xcode IDE MCP tool.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arguments": {"default":"{}","description":"JSON object string containing arguments for the remote Xcode MCP tool.","type":"string"}, "remoteTool": {"description":"Exact remote Xcode MCP tool name.","minLength":1,"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_list_tools.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_list_tools.json index e33f490d9..f969f4762 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_list_tools.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_ide_list_tools.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Xcode IDE Tools"}, "description": "Lists Xcode-IDE-only MCP capabilities (Use for: SwiftUI previews image capture, code snippet execution, issue Navigator/build logs, and window/tab context).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "refresh": {"description":"When true, forces a refresh from Xcode bridge. When omitted, uses cached tools if available and refreshes only when the cache is empty.","type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_disconnect.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_disconnect.json index c71284dbc..707c4cb0b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_disconnect.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_disconnect.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Disconnect Xcode Tools Bridge"}, "description": "Disconnect bridge and unregister proxied `xcode_tools_*` tools.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_status.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_status.json index 2e22e8a18..f03c3fc08 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_status.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_status.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Xcode Tools Bridge Status"}, "description": "Show xcrun mcpbridge availability and proxy tool sync status.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_sync.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_sync.json index 6c2e6189e..40a5634fb 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_sync.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-disabled/xcode_tools_bridge_sync.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Sync Xcode Tools Bridge"}, "description": "One-shot connect + tools/list sync (manual retry; avoids background prompt spam).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/batch.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/batch.json index 9873988e5..942f06373 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/batch.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/batch.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Batch UI Actions"}, "description": "UI automation batch for multiple same-screen elementRef taps, especially visible settings switches that can be toggled without intermediate assertions. The input key is steps, never commands, and each step is an object such as {\"action\":\"tap\",\"elementRef\":\"e1\"}; do not pass raw command strings. Use refs from the latest snapshot_ui or wait_for_ui output, for example {\"steps\":[{\"action\":\"tap\",\"elementRef\":\"e1\"},{\"action\":\"tap\",\"elementRef\":\"e2\"}]}. Omit preDelay/postDelay for switch elementRefs; switches execute as touch down/up steps and reject delays.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "axCache": { "enum": ["perBatch","perStep","none"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json index 97241b432..4ddd05e36 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/boot_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Boot Simulator"}, "description": "Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_device.json index 52a7e2f03..1e1bea12f 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Device"}, "description": "Build for device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buildForTesting": {"description":"Build reusable test products without running tests (default: false)","type":"boolean"}, "deviceId": {"description":"UDID of the destination device","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_macos.json index 455d02161..4334675ce 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build macOS"}, "description": "Build macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buildForTesting": {"description":"Build reusable test products without running tests (default: false)","type":"boolean"}, "extraArgs": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_device.json index 97f8aada8..9891a342e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run Device"}, "description": "Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "env": { "description": "Environment variables to pass to the launched app as key-value entries", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_macos.json index 35c28a928..606efc792 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run macOS"}, "description": "Build and run macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "description": "Additional xcodebuild/build-settings arguments (not app launch arguments)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_sim.json index aea99909c..b98fd5235 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_run_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Run Simulator"}, "description": "Build, install, and launch on iOS Simulator, booting it when needed. Runtime logs are captured automatically and the log file path is included in the response. Preferred single-step run tool when defaults are set.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "description": "Additional xcodebuild/build-settings arguments (not app launch arguments)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json index 0e7aa8f76..ebfc3c778 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/build_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build Simulator"}, "description": "Build for iOS sim (compile-only, no launch).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buildForTesting": {"description":"Build reusable test products without running tests (default: false)","type":"boolean"}, "extraArgs": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/button.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/button.json index a2f4aba8a..5d23a7b0b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/button.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/button.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Hardware Button"}, "description": "Press simulator hardware button.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "buttonType": { "description": "apple-pay|home|lock|side-button|siri", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/clean.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/clean.json index 7cc5f0439..c1de3a67c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/clean.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/clean.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Clean"}, "description": "Clean build products.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_attach_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_attach_sim.json index 5d0af8b1a..e54d9f2af 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_attach_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_attach_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Attach Sim"}, "description": "Attach LLDB to sim app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleId": {"description":"Attach by bundle identifier. Provide bundleId without pid; waitFor may be used with this mode.","type":"string"}, "continueOnAttach": {"default":true,"description":"default: true","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_add.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_add.json index 7f1141650..669b963e8 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_add.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_add.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Breakpoint Add"}, "description": "Add breakpoint.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "condition": {"description":"Expression for breakpoint condition","type":"string"}, "debugSessionId": {"description":"default: current session","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_remove.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_remove.json index c5dfc6df0..535ddd8c9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_remove.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_breakpoint_remove.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Breakpoint Remove"}, "description": "Remove breakpoint.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "breakpointId": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"}, "debugSessionId": {"description":"default: current session","type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_continue.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_continue.json index 0e891f749..aee471838 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_continue.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_continue.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Continue"}, "description": "Continue debug session.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_detach.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_detach.json index d0755f380..e39af0c99 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_detach.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_detach.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Debug Detach"}, "description": "Detach debugger.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_lldb_command.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_lldb_command.json index daae7d690..7ebef6fb1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_lldb_command.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_lldb_command.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Debug LLDB Command"}, "description": "Run LLDB command.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "command": {"type":"string"}, "debugSessionId": {"description":"default: current session","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_stack.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_stack.json index 806dae5b3..94ab968dd 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_stack.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_stack.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Debug Stack"}, "description": "Get backtrace.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"}, "maxFrames": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_variables.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_variables.json index 1f3675346..a2a04119b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_variables.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/debug_variables.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Debug Variables"}, "description": "Get frame variables.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "debugSessionId": {"description":"default: current session","type":"string"}, "frameIndex": {"maximum":9007199254740991,"minimum":0,"type":"integer"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/discover_projs.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/discover_projs.json index 101b6707b..3caeeb2f6 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/discover_projs.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/discover_projs.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Discover Projects"}, "description": "Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "maxDepth": {"maximum":9007199254740991,"minimum":0,"type":"integer"}, "scanPath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/doctor.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/doctor.json index 3133f2a19..f1533f883 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/doctor.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/doctor.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Doctor"}, "description": "MCP environment info.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "nonRedacted": {"description":"Opt-in: when true, disable redaction and include full raw doctor output.","type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/drag.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/drag.json index 331b8c3ee..afa87b042 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/drag.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/drag.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Drag"}, "description": "Drag from a visible runtime elementRef in a direction, then return a refreshed runtime UI snapshot. Use this for exposed sheet grabbers or real scroll/list content refs when nextSteps suggests dragging; do not use raw screen coordinates.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "direction": { "description": "Drag direction: up, down, left, or right", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/erase_sims.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/erase_sims.json index c98f191aa..b44b902b1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/erase_sims.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/erase_sims.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Erase Simulators"}, "description": "Erase simulator.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "shutdownFirst": {"type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/gesture.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/gesture.json index 32af71073..fcdc8544d 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/gesture.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/gesture.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Gesture"}, "description": "Simulator gesture preset.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delta": {"description":"Distance to move in pixels.","maximum":200,"minimum":0,"type":"number"}, "duration": {"description":"Duration of the gesture in seconds.","maximum":10,"minimum":0,"type":"number"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_app_bundle_id.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_app_bundle_id.json index a572f2ce6..4ad0f97d6 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_app_bundle_id.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_app_bundle_id.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get App Bundle ID"}, "description": "Extract bundle id from .app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_coverage_report.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_coverage_report.json index 910b56372..37baf48e4 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_coverage_report.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_coverage_report.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Coverage Report"}, "description": "Show per-target code coverage from an xcresult bundle.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "showFiles": {"default":false,"description":"When true, include per-file coverage breakdown under each target","type":"boolean"}, "target": {"description":"Filter results to a specific target name","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_device_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_device_app_path.json index decd2d41f..1b15de232 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_device_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_device_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Device App Path"}, "description": "Get device built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "platform": { "description": "Device platform: iOS, watchOS, tvOS, or visionOS. Defaults to iOS.", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_file_coverage.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_file_coverage.json index 940ddf6f7..7bfdc1f33 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_file_coverage.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_file_coverage.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get File Coverage"}, "description": "Show function-level coverage and uncovered line ranges for a specific file.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "file": {"description":"Source file name or path to inspect","type":"string"}, "showLines": {"default":false,"description":"When true, include uncovered line ranges from the archive","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_app_path.json index 226a9c535..4fa3519af 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get macOS App Path"}, "description": "Get macOS built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "derivedDataPath": {"type":"string"}, "extraArgs": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_bundle_id.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_bundle_id.json index 0092db45b..1cdab0b31 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_bundle_id.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_mac_bundle_id.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Mac Bundle ID"}, "description": "Extract bundle id from macOS .app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_sim_app_path.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_sim_app_path.json index 2201e10ec..36614533a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_sim_app_path.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/get_sim_app_path.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Get Simulator App Path"}, "description": "Get sim built app path.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "platform": { "enum": ["iOS Simulator","watchOS Simulator","tvOS Simulator","visionOS Simulator"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_device.json index 47640cc49..341f199a9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Install App Device"}, "description": "Install app on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_sim.json index 468874bb9..2781ef0b3 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/install_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Install App Simulator"}, "description": "Install app on sim.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"description":"Path to the .app bundle to install","type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_press.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_press.json index 34cce1b78..360a28a61 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_press.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_press.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Key Press"}, "description": "Press one hardware key using an AXe HID key code. Prefer type_text for text entry. Common values include 40 Return/Enter, 42 Backspace, 43 Tab, and 44 Space.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, "keyCode": {"description":"HID keycode. Common values: 40 Return/Enter, 42 Backspace, 43 Tab, 44 Space.","maximum":255,"minimum":0,"type":"integer"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_sequence.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_sequence.json index 38816ac0a..ea9d77bd5 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_sequence.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/key_sequence.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Key Sequence"}, "description": "Press hardware keys using AXe HID key codes. Prefer type_text for text entry. Common values include 40 Return/Enter, 42 Backspace, 43 Tab, and 44 Space.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delay": {"maximum":5,"minimum":0,"type":"number"}, "keyCodes": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_device.json index 5e42674e4..2d510b4d0 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Device"}, "description": "Launch app on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "env": { "description": "Environment variables to pass to the launched app as key-value entries", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json index 7f6690f3e..25d5c6c4b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch App Simulator"}, "description": "Launch app on simulator. Runtime logs are captured automatically and the log file path is included in the response.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "env": { "description": "Environment variables to pass to the launched app as key-value entries (SIMCTL_CHILD_ prefix added automatically)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_mac_app.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_mac_app.json index fe55e7fda..46e5f5e58 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_mac_app.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/launch_mac_app.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Launch macOS App"}, "description": "Launch macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appPath": {"type":"string"}, "launchArgs": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_devices.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_devices.json index c0e63a1b8..c643a7eed 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_devices.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_devices.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Devices"}, "description": "List connected devices.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_schemes.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_schemes.json index 3b51170a6..1f70e2aad 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_schemes.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_schemes.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Schemes"}, "description": "List Xcode schemes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "projectPath": {"description":"Path to the .xcodeproj file","type":"string"}, "workspacePath": {"description":"Path to the .xcworkspace file","type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_sims.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_sims.json index 6ec62e1c3..e5f006781 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_sims.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/list_sims.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Simulators"}, "description": "List iOS simulators.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "enabled": {"type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/long_press.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/long_press.json index 5d4a6625a..4faa08073 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/long_press.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/long_press.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Long Press"}, "description": "Long press a UI element by elementRef from a current rs/1 runtime snapshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": {"description":"milliseconds","exclusiveMinimum":0,"maximum":10000,"type":"integer"}, "elementRef": {"minLength":1,"type":"string"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/manage-workflows.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/manage-workflows.json index 010a81f54..423acce95 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/manage-workflows.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/manage-workflows.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Manage Workflows"}, "description": "Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "enable": {"description":"Enable or disable the selected workflows.","type":"boolean"}, "workflowNames": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/open_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/open_sim.json index 0842ef367..c25e9f3e9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/open_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/open_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Open Simulator"}, "description": "Open the simulator frontend for visibility and manual workflows. Not required before simulator build-and-run (build_run_sim).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/record_sim_video.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/record_sim_video.json index 118cbfab1..41f11441c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/record_sim_video.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/record_sim_video.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Record Simulator Video"}, "description": "Record sim video.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "fps": {"description":"default: 30","maximum":120,"minimum":1,"type":"integer"}, "outputFile": {"description":"Path to write MP4 file","type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/reset_sim_location.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/reset_sim_location.json index c753e01e3..1f980a39a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/reset_sim_location.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/reset_sim_location.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Reset Simulator Location"}, "description": "Reset sim location.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_ios_project.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_ios_project.json index 90a106f8d..44b782764 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_ios_project.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_ios_project.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Scaffold iOS Project"}, "description": "Scaffold iOS project.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleIdentifier": {"type":"string"}, "currentProjectVersion": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_macos_project.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_macos_project.json index 7d5754dc3..ff31c99f1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_macos_project.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/scaffold_macos_project.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Scaffold macOS Project"}, "description": "Scaffold macOS project.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "bundleIdentifier": {"type":"string"}, "currentProjectVersion": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/screenshot.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/screenshot.json index 3c8af8a70..334eb5b1a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/screenshot.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/screenshot.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Screenshot"}, "description": "Capture screenshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "returnFormat": { "description": "Return image path or base64 data (path|base64)", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_clear_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_clear_defaults.json index 11a55da8b..ee3ee55f0 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_clear_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_clear_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Clear Session Defaults"}, "description": "Clear session defaults for the active profile or a specified profile.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "all": {"description":"Clear all defaults across global and named profiles. Cannot be combined with keys/profile.","type":"boolean"}, "keys": { diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_set_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_set_defaults.json index e658e9e43..447ac1152 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_set_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_set_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Set Session Defaults"}, "description": "Set session defaults for the active profile, or for a specified profile and make it active.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arch": { "enum": ["arm64","x86_64"], diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_show_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_show_defaults.json index dad883fd2..0e228614b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_show_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_show_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Show Session Defaults"}, "description": "Show current active defaults. Required before your first build/run/test call in a session — do not assume defaults are configured.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_use_defaults_profile.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_use_defaults_profile.json index 79bb51f4e..ced9e6263 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_use_defaults_profile.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/session_use_defaults_profile.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Use Session Defaults Profile"}, "description": "Switch the active session defaults profile.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "global": {"description":"Activate the global unnamed defaults profile.","type":"boolean"}, "persist": {"description":"Persist activeSessionDefaultsProfile to .xcodebuildmcp/config.yaml.","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_appearance.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_appearance.json index eeab5d251..57ec45926 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_appearance.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_appearance.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Set Simulator Appearance"}, "description": "Set sim appearance.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "mode": { "description": "dark|light", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_location.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_location.json index 83abe328d..67d429703 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_location.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/set_sim_location.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Set Simulator Location"}, "description": "Set sim location.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "latitude": {"type":"number"}, "longitude": {"type":"number"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/show_build_settings.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/show_build_settings.json index 9e0c15083..1fd067b0c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/show_build_settings.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/show_build_settings.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Show Build Settings"}, "description": "Show build settings.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json index d8e9a5864..991497344 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sim_statusbar.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Simulator Statusbar"}, "description": "Set sim status bar network.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "dataNetwork": { "description": "clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json index 1f57986c3..744cdcc7f 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/snapshot_ui.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Snapshot UI"}, "description": "Capture a semantic rs/1 runtime UI snapshot with elementRef targets. Observe once, use tap for one target or batch for multiple same-screen targets, and refresh after navigation, scrolling, sheet changes, or obvious layout changes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "sinceScreenHash": {"description":"Return an unchanged response when the current screen hash matches this value","minLength":1,"type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_device.json index fad7cd2b1..ba3e72def 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop App Device"}, "description": "Stop device app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "processId": {"type":"number"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_sim.json index 61a1b4bf8..583ec0461 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_app_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop App Simulator"}, "description": "Stop sim app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_mac_app.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_mac_app.json index 009a782e5..9cfe36cae 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_mac_app.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/stop_mac_app.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Stop macOS App"}, "description": "Stop macOS app.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "appName": {"minLength":1,"type":"string"}, "processId": {"exclusiveMinimum":0,"maximum":9007199254740991,"type":"integer"} diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_build.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_build.json index b5c95388a..43e5bd85d 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_build.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_build.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Build"}, "description": "swift package target build.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "architectures": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_clean.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_clean.json index 6fc670d33..ca1ea861c 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_clean.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_clean.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Clean"}, "description": "swift package clean.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "packagePath": {"type":"string"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_list.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_list.json index dc2d15949..897ca5e37 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_list.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_list.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Swift Package List"}, "description": "List SwiftPM processes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_run.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_run.json index 8727e3fed..4806620e9 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_run.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_run.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Run"}, "description": "swift package target run.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arguments": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_stop.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_stop.json index d77993d51..9afc8f09b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_stop.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_stop.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Stop"}, "description": "Stop SwiftPM run.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "pid": {"type":"number"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_test.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_test.json index 1cdd9514a..f1a9ca469 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_test.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swift_package_test.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Swift Package Test"}, "description": "Run swift package target tests.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "filter": {"description":"regex: pattern","type":"string"}, "packagePath": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swipe.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swipe.json index 042224c68..7a113c20e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swipe.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/swipe.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Swipe"}, "description": "Swipe within a scrollable UI element using withinElementRef from a current rs/1 runtime snapshot. withinElementRef is required; do not use elementRef. Optional distance is a normalized stroke fraction greater than 0 and up to 1. Example input: {\"withinElementRef\":\"e7\",\"direction\":\"up\",\"distance\":0.7}.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "direction": { "description": "up|down|left|right", diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sync_xcode_defaults.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sync_xcode_defaults.json index 500ab3f64..7948607b1 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sync_xcode_defaults.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/sync_xcode_defaults.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Sync Xcode Defaults"}, "description": "Sync session defaults (scheme, simulator) from Xcode's current IDE selection.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json index b426e8d7e..35490976b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/tap.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Tap"}, "description": "Tap one elementRef from the latest snapshot_ui or wait_for_ui output. The elementRef must list the tap action in the snapshot targets; do not use refs from text-only rows. For multiple same-screen taps or visible switch toggles with no intermediate assertion, use batch instead of repeated tap calls. Other same-screen refs may remain usable after success; refresh after navigation, scrolling, sheet changes, or obvious layout changes.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"minLength":1,"type":"string"}, "postDelay": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_device.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_device.json index 81405d072..78d95040a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_device.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_device.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Device"}, "description": "Test on device.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_macos.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_macos.json index f5b6d53fa..5b1c6922e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_macos.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_macos.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test macOS"}, "description": "Test macOS target.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json index a9c02ce97..e3630c54b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/test_sim.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Test Simulator"}, "description": "Test on iOS sim.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "extraArgs": { "items": {"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_connect_hardware_keyboard.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_connect_hardware_keyboard.json index 423eb4fa6..0b197ea5e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_connect_hardware_keyboard.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_connect_hardware_keyboard.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Toggle Connect Hardware Keyboard"}, "description": "Toggle whether the iOS Simulator simulates a hardware keyboard connection. Disconnecting makes the on-screen keyboard appear for tap-based input. Requires the simulator to be booted and Accessibility permission for the MCP host.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_software_keyboard.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_software_keyboard.json index b2f7bec68..2b2946156 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_software_keyboard.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/toggle_software_keyboard.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Toggle Software Keyboard"}, "description": "Toggle the iOS Simulator software keyboard. Shows or hides the on-screen keyboard. Requires the simulator to be booted and Accessibility permission for the MCP host.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/touch.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/touch.json index 3f85cfa18..e365f25b2 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/touch.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/touch.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Touch"}, "description": "Send touch down/up events to a UI element by elementRef from a current rs/1 runtime snapshot.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "delay": {"description":"seconds","maximum":10,"minimum":0,"type":"number"}, "down": {"type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json index c46ef873a..c76256d3a 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/type_text.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Type Text"}, "description": "Type text into a UI element by elementRef from a current rs/1 runtime snapshot, optionally replacing existing field contents. elementRef is required; do not call with only text. Example input: {\"elementRef\":\"e8\",\"text\":\"London\",\"replaceExisting\":true}.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"description":"Required runtime text-field elementRef from the latest snapshot_ui or wait_for_ui output","minLength":1,"type":"string"}, "replaceExisting": {"description":"Select and replace existing field contents before typing","type":"boolean"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/wait_for_ui.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/wait_for_ui.json index 4e697aec2..604883e4e 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/wait_for_ui.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/wait_for_ui.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Wait for UI"}, "description": "Poll rs/1 runtime UI snapshots until a selector-based UI predicate, selector-free textContains/gone text predicate, or selector-free settled predicate is satisfied, then record the latest snapshot. Prefer this after navigation or layout changes. Select with elementRef, identifier, label, role, or value when a selector is needed.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elementRef": {"minLength":1,"type":"string"}, "identifier": {"minLength":1,"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_call_tool.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_call_tool.json index 26a36b897..0ec1e3b70 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_call_tool.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_call_tool.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Call Xcode IDE Tool"}, "description": "Call a remote Xcode IDE MCP tool.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "arguments": {"default":"{}","description":"JSON object string containing arguments for the remote Xcode MCP tool.","type":"string"}, "remoteTool": {"description":"Exact remote Xcode MCP tool name.","minLength":1,"type":"string"}, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_list_tools.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_list_tools.json index e33f490d9..f969f4762 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_list_tools.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_ide_list_tools.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"List Xcode IDE Tools"}, "description": "Lists Xcode-IDE-only MCP capabilities (Use for: SwiftUI previews image capture, code snippet execution, issue Navigator/build logs, and window/tab context).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "refresh": {"description":"When true, forces a refresh from Xcode bridge. When omitted, uses cached tools if available and refreshes only when the cache is empty.","type":"boolean"} }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_disconnect.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_disconnect.json index c71284dbc..707c4cb0b 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_disconnect.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_disconnect.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Disconnect Xcode Tools Bridge"}, "description": "Disconnect bridge and unregister proxied `xcode_tools_*` tools.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_status.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_status.json index 2e22e8a18..f03c3fc08 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_status.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_status.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":true,"title":"Xcode Tools Bridge Status"}, "description": "Show xcrun mcpbridge availability and proxy tool sync status.", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, diff --git a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_sync.json b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_sync.json index 6c2e6189e..40a5634fb 100644 --- a/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_sync.json +++ b/src/snapshot-tests/__fixtures__/mcp-contracts/session-defaults-enabled/xcode_tools_bridge_sync.json @@ -1,9 +1,8 @@ { "annotations": {"destructiveHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Sync Xcode Tools Bridge"}, "description": "One-shot connect + tools/list sync (manual retry; avoids background prompt spam).", - "execution": {"taskSupport":"forbidden"}, "inputSchema": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object" }, From 41a8b4a3139df413237235be430a18ae76de6685 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:44:48 +0530 Subject: [PATCH 3/7] fix(mcp): settle entry-served requests and per-request server teardown Two serving-layer leaks found in review of the 2026-07-28 work. Idle shutdown never fired once a modern client opened a subscription. The serving entry answers `subscriptions/listen` out of band - an acknowledgement notification now, the JSON-RPC result only at teardown - and a cancelled request receives no response at all, because the SDK drops the reply for an aborted handler. The transport observer counted both as ordinary in-flight work, so the count never returned to zero and the process stayed alive forever. Long-lived requests are now tracked separately and never counted as in-flight, and `notifications/cancelled` settles whichever kind of request it names. Ordinary request start/complete metrics are unchanged. HTTP serving contexts leaked a server instance and its tool registrations on every request. Teardown was hooked on `McpServer.close`, but `createMcpHandler` closes the low-level `server.server` per request and never touches the high-level wrapper, so `activeServers` and `registrationsByServer` grew without bound. Teardown is now wired to the low-level close lifecycle as well, and is idempotent so an unconnected instance closed through the high-level API is still released. Two consequences of the per-request model are fixed with it: - the process-level Xcode tools bridge is bound only by connection-scoped contexts, so an HTTP request can no longer move the proxied tools off the live stdio connection; - server instances carry their serving scope, so `getServer()` prefers the connection-scoped instance and a per-request instance can never shadow it. Adds a modern stdio e2e regression driving listen plus cancellation under XCODEBUILDMCP_MCP_IDLE_TIMEOUT_MS (verified failing before the fix), observer unit coverage for every settle path, HTTP sequential/concurrent/handler.close teardown assertions, and bridge-binding coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + .../__tests__/mcp-serving-cleanup.test.ts | 233 ++++++++++++++++ .../mcp-xcode-bridge-binding.test.ts | 115 ++++++++ src/server/__tests__/server.test.ts | 99 +++++++ src/server/bootstrap.ts | 28 +- src/server/mcp-protocol.ts | 33 +++ src/server/request-lifecycle.ts | 77 ++++- src/server/server-state.ts | 44 ++- src/server/server.ts | 49 +++- .../e2e-mcp-modern-idle-timeout.test.ts | 263 ++++++++++++++++++ src/utils/tool-registry.ts | 10 + 11 files changed, 921 insertions(+), 36 deletions(-) create mode 100644 src/server/__tests__/mcp-serving-cleanup.test.ts create mode 100644 src/server/__tests__/mcp-xcode-bridge-binding.test.ts create mode 100644 src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 330d846ce..4e40c9a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,16 @@ - MCP protocol revision `2026-07-28` support alongside the existing 2025 revisions. XcodeBuildMCP now serves both protocol eras on one stdio connection: a modern client opens with a `_meta` envelope (protocol version plus client capabilities on every request) and needs no `initialize` handshake or `Mcp-Session-Id`, while 2025-era clients keep using `initialize` unchanged. `server/discover` is answered with the supported modern revisions, capabilities and instructions; modern results carry `resultType` and, for cacheable operations, `ttlMs`/`cacheScope`. +### Fixed + +- MCP idle shutdown no longer hangs when a modern client opens a `subscriptions/listen` stream. The serving entry answers listen out-of-band and writes no result until teardown, and a cancelled request receives no response at all, so both are now settled explicitly instead of pinning the in-flight request count open forever. +- HTTP serving contexts no longer leak server instances and tool registrations. The SDK closes the low-level server per request, so teardown is wired to that lifecycle as well as to the high-level `close()`. The process-level Xcode tools bridge binding now stays with the connection-scoped stdio instance instead of being rebound by every HTTP request. + ### Changed - Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation. - Migrated from `@modelcontextprotocol/sdk` v1 to the official TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`). +- Tool `inputSchema` values published by `tools/list` now declare JSON Schema draft 2020-12 (`https://json-schema.org/draft/2020-12/schema`) instead of draft-07, and the `execution` member (previously always `{"taskSupport":"forbidden"}`) is no longer emitted. Both follow from the SDK v2 tool contract; consumers that pin the draft-07 `$schema` string or read `execution.taskSupport` need updating. - A fresh MCP server instance is now built per serving context instead of one process-wide singleton. Session defaults, debugger sessions, log captures and other application state remain process scoped and are unaffected when a protocol instance is replaced. - Modern-era clients set log verbosity through the per-request `io.modelcontextprotocol/logLevel` metadata key; `logging/setLevel` continues to work for 2025-era clients. diff --git a/src/server/__tests__/mcp-serving-cleanup.test.ts b/src/server/__tests__/mcp-serving-cleanup.test.ts new file mode 100644 index 000000000..314be0f39 --- /dev/null +++ b/src/server/__tests__/mcp-serving-cleanup.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { createMcpHttpHandler, startStdioServer } from '../server.ts'; +import { __resetServerStateForTests, getActiveServers, getServer } from '../server-state.ts'; +import { + __resetToolRegistryForTests, + getTrackedRegistrationServerCount, +} from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { createTestRegistrations, TEST_TOOL_NAME } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer, waitFor } from './raw-mcp-peer.ts'; +import { buildModernHttpHeaders } from '../mcp-protocol.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +function createLifecycleCounter(): { + observer: { onRequestStarted: () => void; onRequestCompleted: () => void }; + inFlight: () => number; + started: () => number; + completed: () => number; +} { + let inFlight = 0; + let started = 0; + let completed = 0; + return { + observer: { + onRequestStarted: (): void => { + inFlight += 1; + started += 1; + }, + onRequestCompleted: (): void => { + inFlight -= 1; + completed += 1; + }, + }, + inFlight: () => inFlight, + started: () => started, + completed: () => completed, + }; +} + +describe('idle-shutdown accounting for entry-served requests', () => { + it('returns to zero in-flight after an ordinary modern request', async () => { + const counter = createLifecycleCounter(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { + transport: peer.serverTransport, + requestLifecycle: counter.observer, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(counter.started()).toBe(1); + expect(counter.completed()).toBe(1); + expect(counter.inFlight()).toBe(0); + }); + + it('does not keep a subscriptions/listen request in flight forever', async () => { + const counter = createLifecycleCounter(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { + transport: peer.serverTransport, + requestLifecycle: counter.observer, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + const listenId = await peer.sendRequest('subscriptions/listen', { + _meta: modernMeta(), + notifications: { toolsListChanged: true }, + }); + + await waitFor(() => + peer!.notifications.some( + (message) => + (message as { method?: string }).method === 'notifications/subscriptions/acknowledged', + ), + ); + + // The entry answers `subscriptions/listen` with an acknowledgement + // notification; the JSON-RPC result only arrives at teardown. It must not + // pin the idle-shutdown in-flight count. + expect(counter.inFlight()).toBe(0); + expect(listenId).toBeGreaterThan(0); + }); + + it('settles the listen accounting when the client cancels the subscription', async () => { + const counter = createLifecycleCounter(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { + transport: peer.serverTransport, + requestLifecycle: counter.observer, + }); + + const listenId = await peer.sendRequest('subscriptions/listen', { + _meta: modernMeta(), + notifications: { toolsListChanged: true }, + }); + await waitFor(() => + peer!.notifications.some( + (message) => + (message as { method?: string }).method === 'notifications/subscriptions/acknowledged', + ), + ); + + await peer.notify('notifications/cancelled', { requestId: listenId, reason: 'done' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(counter.inFlight()).toBe(0); + + // Ordinary requests keep working and keep their metrics after a cancel. + await peer.request('tools/list', { _meta: modernMeta() }); + expect(counter.inFlight()).toBe(0); + expect(counter.completed()).toBeGreaterThanOrEqual(1); + }); + + it('settles an ordinary request that is cancelled without a response', async () => { + const counter = createLifecycleCounter(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { + transport: peer.serverTransport, + requestLifecycle: counter.observer, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + const callId = await peer.sendRequest('tools/call', { + _meta: modernMeta(), + name: TEST_TOOL_NAME, + arguments: { text: 'cancelled' }, + }); + await peer.notify('notifications/cancelled', { requestId: callId, reason: 'user aborted' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(counter.inFlight()).toBe(0); + }); +}); + +describe('HTTP serving context cleanup', () => { + async function callTools(handler: ReturnType): Promise { + const params = { _meta: modernMeta() }; + return handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...buildModernHttpHeaders('tools/list', params), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params }), + }), + ); + } + + it('returns active-server count to baseline after sequential requests', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + expect(getActiveServers()).toHaveLength(0); + + for (let index = 0; index < 5; index += 1) { + const response = await callTools(httpHandler); + expect(response.status).toBe(200); + await response.text(); + } + + await waitFor(() => getActiveServers().length === 0); + expect(getActiveServers()).toHaveLength(0); + expect(getTrackedRegistrationServerCount()).toBe(0); + + await httpHandler.close(); + }); + + it('returns active-server count to baseline after concurrent requests', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + + const responses = await Promise.all(Array.from({ length: 6 }, () => callTools(httpHandler))); + for (const response of responses) { + expect(response.status).toBe(200); + await response.text(); + } + + await waitFor(() => getActiveServers().length === 0); + expect(getActiveServers()).toHaveLength(0); + expect(getTrackedRegistrationServerCount()).toBe(0); + + await httpHandler.close(); + }); + + it('never lets a per-request HTTP instance shadow the live stdio connection', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations(), { transport: peer.serverTransport }); + await peer.request('tools/list', { _meta: modernMeta() }); + + const connectionInstance = getServer(); + expect(connectionInstance).toBeDefined(); + + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + await (await callTools(httpHandler)).text(); + + expect(getServer()).toBe(connectionInstance); + + await waitFor(() => getActiveServers().length === 1); + expect(getServer()).toBe(connectionInstance); + + await httpHandler.close(); + }); + + it('leaves no dangling active server after handler.close()', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations()); + const response = await callTools(httpHandler); + await response.text(); + + await httpHandler.close(); + + await waitFor(() => getActiveServers().length === 0); + expect(getActiveServers()).toHaveLength(0); + expect(getTrackedRegistrationServerCount()).toBe(0); + }); +}); diff --git a/src/server/__tests__/mcp-xcode-bridge-binding.test.ts b/src/server/__tests__/mcp-xcode-bridge-binding.test.ts new file mode 100644 index 000000000..77f8821b5 --- /dev/null +++ b/src/server/__tests__/mcp-xcode-bridge-binding.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; + +const bridgeMocks = vi.hoisted(() => ({ + bindServer: vi.fn(), + setWorkflowEnabled: vi.fn(), + getXcodeToolsBridgeManager: vi.fn(), +})); + +vi.mock('../../integrations/xcode-tools-bridge/index.ts', () => ({ + getXcodeToolsBridgeManager: bridgeMocks.getXcodeToolsBridgeManager, +})); + +import { createMcpHttpHandler, startStdioServer } from '../server.ts'; +import { __resetServerStateForTests } from '../server-state.ts'; +import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { createTestRegistrations } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; +import { buildModernHttpHeaders } from '../mcp-protocol.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +beforeEach(() => { + bridgeMocks.bindServer.mockClear(); + bridgeMocks.setWorkflowEnabled.mockClear(); + bridgeMocks.getXcodeToolsBridgeManager.mockReset(); + bridgeMocks.getXcodeToolsBridgeManager.mockReturnValue({ + bindServer: bridgeMocks.bindServer, + setWorkflowEnabled: bridgeMocks.setWorkflowEnabled, + }); +}); + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +async function httpToolsList(handler: ReturnType): Promise { + const params = { _meta: modernMeta() }; + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...buildModernHttpHeaders('tools/list', params), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params }), + }), + ); + await response.text(); +} + +describe('Xcode tools bridge binding across serving contexts', () => { + it('binds the process-level bridge to a connection-scoped stdio instance', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: true }), { + transport: peer.serverTransport, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(bridgeMocks.bindServer).toHaveBeenCalledTimes(1); + expect(bridgeMocks.setWorkflowEnabled).toHaveBeenCalledWith(true); + }); + + it('never rebinds the bridge for per-request HTTP instances', async () => { + const httpHandler = createMcpHttpHandler(createTestRegistrations({ xcodeIdeEnabled: true })); + + await httpToolsList(httpHandler); + await httpToolsList(httpHandler); + await httpToolsList(httpHandler); + + expect(bridgeMocks.getXcodeToolsBridgeManager).not.toHaveBeenCalled(); + expect(bridgeMocks.bindServer).not.toHaveBeenCalled(); + + await httpHandler.close(); + }); + + it('leaves the stdio binding intact while HTTP requests are served', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: true }), { + transport: peer.serverTransport, + }); + await peer.request('tools/list', { _meta: modernMeta() }); + expect(bridgeMocks.bindServer).toHaveBeenCalledTimes(1); + + const httpHandler = createMcpHttpHandler(createTestRegistrations({ xcodeIdeEnabled: true })); + await httpToolsList(httpHandler); + await httpHandler.close(); + + expect(bridgeMocks.bindServer).toHaveBeenCalledTimes(1); + }); + + it('does not touch the bridge when the xcode-ide workflow is disabled', async () => { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: false }), { + transport: peer.serverTransport, + }); + + await peer.request('tools/list', { _meta: modernMeta() }); + + expect(bridgeMocks.getXcodeToolsBridgeManager).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/__tests__/server.test.ts b/src/server/__tests__/server.test.ts index ad84d672a..8b4b83f09 100644 --- a/src/server/__tests__/server.test.ts +++ b/src/server/__tests__/server.test.ts @@ -127,6 +127,105 @@ describe('MCP server transport request lifecycle instrumentation', () => { expect(onRequestCompleted).toHaveBeenCalledTimes(1); }); + it('does not count a long-lived subscriptions/listen request as in flight', async () => { + const onRequestStarted = vi.fn(); + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestStarted, + onRequestCompleted, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + + expect(onRequestStarted).not.toHaveBeenCalled(); + expect(onRequestCompleted).not.toHaveBeenCalled(); + }); + + it('does not double-settle when a listen request is finally answered at teardown', async () => { + const onRequestStarted = vi.fn(); + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestStarted, + onRequestCompleted, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + await transport.send({ jsonrpc: '2.0', id: 'listen-1', result: {} }); + + expect(onRequestStarted).not.toHaveBeenCalled(); + expect(onRequestCompleted).not.toHaveBeenCalled(); + }); + + it('settles a listen request cancelled by the client without emitting metrics', async () => { + const onRequestStarted = vi.fn(); + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestStarted, + onRequestCompleted, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'listen-1' }, + }); + await transport.send({ jsonrpc: '2.0', id: 'listen-1', result: {} }); + + expect(onRequestStarted).not.toHaveBeenCalled(); + expect(onRequestCompleted).not.toHaveBeenCalled(); + }); + + it('settles an ordinary request cancelled without a response', async () => { + const onRequestStarted = vi.fn(); + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestStarted, + onRequestCompleted, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 7, method: 'tools/call' }); + expect(onRequestStarted).toHaveBeenCalledTimes(1); + expect(onRequestCompleted).not.toHaveBeenCalled(); + + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 7 }, + }); + + expect(onRequestCompleted).toHaveBeenCalledTimes(1); + }); + + it('settles a cancelled request only once even if a late response is written', async () => { + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ onRequestCompleted }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 7, method: 'tools/call' }); + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 7 }, + }); + await transport.send({ jsonrpc: '2.0', id: 7, result: {} }); + + expect(onRequestCompleted).toHaveBeenCalledTimes(1); + }); + + it('ignores cancellations that carry no usable request id', async () => { + const onRequestCompleted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ onRequestCompleted }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 7, method: 'tools/call' }); + transport.onmessage?.({ jsonrpc: '2.0', method: 'notifications/cancelled', params: {} }); + transport.onmessage?.({ jsonrpc: '2.0', method: 'notifications/cancelled' }); + + expect(onRequestCompleted).not.toHaveBeenCalled(); + + await transport.send({ jsonrpc: '2.0', id: 7, result: {} }); + expect(onRequestCompleted).toHaveBeenCalledTimes(1); + }); + it('marks completion when downstream message handling throws synchronously', async () => { const onRequestStarted = vi.fn(); const onRequestCompleted = vi.fn(); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 5f1f30e76..aa73ec3ad 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -67,6 +67,23 @@ function runStartupFilesystemLifecycleSweep(workspaceKey: string): Promise }); } +export interface ApplyServerRegistrationsOptions { + /** + * Whether this serving context owns the process-level Xcode tools bridge + * binding. + * + * The bridge is a process singleton that registers its proxied tools onto one + * server instance. A connection-scoped context (stdio) owns that binding for + * the life of the connection. Per-request contexts (HTTP) must not take it: + * rebinding on every request would move the proxied tools off the live + * connection and leave the bridge pointing at an instance that is closed as + * soon as the response is written. + * + * @default true + */ + bindXcodeToolsBridge?: boolean; +} + /** * Applies process-level registrations onto a freshly built server instance. * @@ -76,6 +93,7 @@ function runStartupFilesystemLifecycleSweep(workspaceKey: string): Promise export function applyServerRegistrations( server: McpServer, registrations: ServerRegistrations, + options: ApplyServerRegistrationsOptions = {}, ): void { // Legacy-era clients set verbosity with logging/setLevel. Modern-era clients // send io.modelcontextprotocol/logLevel in each request _meta instead, which @@ -96,11 +114,11 @@ export function applyServerRegistrations( registerLoadedResources(server, registrations.resources); - const xcodeToolsBridge = registrations.xcodeIdeEnabled - ? getXcodeToolsBridgeManager(server) - : null; - xcodeToolsBridge?.bindServer(server); - xcodeToolsBridge?.setWorkflowEnabled(registrations.xcodeIdeEnabled); + if (registrations.xcodeIdeEnabled && options.bindXcodeToolsBridge !== false) { + const xcodeToolsBridge = getXcodeToolsBridgeManager(server); + xcodeToolsBridge?.bindServer(server); + xcodeToolsBridge?.setWorkflowEnabled(true); + } } /** diff --git a/src/server/mcp-protocol.ts b/src/server/mcp-protocol.ts index 82942363b..6643051cb 100644 --- a/src/server/mcp-protocol.ts +++ b/src/server/mcp-protocol.ts @@ -161,6 +161,39 @@ export function readModernRequestEnvelope(params: unknown): ModernRequestEnvelop const MINUTE_MS = 60_000; +/** + * Requests the serving entry answers out-of-band rather than with an ordinary + * JSON-RPC result. + * + * `subscriptions/listen` opens a long-lived notification stream: the entry + * replies with `notifications/subscriptions/acknowledged` immediately and only + * writes the JSON-RPC result when the subscription ends (client cancellation or + * connection teardown). Treating it as an ordinary in-flight request would pin + * idle-shutdown accounting open for the lifetime of the subscription. + */ +export const LONG_LIVED_REQUEST_METHODS: ReadonlySet = new Set(['subscriptions/listen']); + +/** Whether a request opens a long-lived stream instead of settling with a result. */ +export function isLongLivedRequestMethod(method: string): boolean { + return LONG_LIVED_REQUEST_METHODS.has(method); +} + +/** + * The request id a `notifications/cancelled` notification settles, or `null` + * when the notification is not a cancellation. + * + * A cancelled request never receives a JSON-RPC response, so this is the only + * signal that its in-flight accounting can be released. + */ +export function cancelledRequestId(method: string, params: unknown): string | number | null { + if (method !== 'notifications/cancelled' || params === null || typeof params !== 'object') { + return null; + } + + const requestId = (params as Record).requestId; + return typeof requestId === 'string' || typeof requestId === 'number' ? requestId : null; +} + /** * Cache hints for the modern-era cacheable results (`ttlMs` / `cacheScope`). * diff --git a/src/server/request-lifecycle.ts b/src/server/request-lifecycle.ts index 0d7cffbb8..77badb098 100644 --- a/src/server/request-lifecycle.ts +++ b/src/server/request-lifecycle.ts @@ -1,12 +1,18 @@ import { isJSONRPCErrorResponse, + isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, type JSONRPCMessage, type Transport, type TransportSendOptions, } from '@modelcontextprotocol/server'; -import { readModernRequestEnvelope, type ModernRequestEnvelope } from './mcp-protocol.ts'; +import { + cancelledRequestId, + isLongLivedRequestMethod, + readModernRequestEnvelope, + type ModernRequestEnvelope, +} from './mcp-protocol.ts'; export interface McpModernRequestObservation { method: string; @@ -40,15 +46,65 @@ function completedRequestIdKey(message: JSONRPCMessage): string | null { return null; } +/** + * Observes the transport so idle shutdown knows when the server is busy. + * + * Three settle paths exist and all of them must be honoured, otherwise the + * in-flight count never returns to zero and the process never idles out: + * - an ordinary request settles when its result or error response is written; + * - a cancelled request settles on `notifications/cancelled`, because the SDK + * deliberately writes no response for an aborted request; + * - a long-lived request (`subscriptions/listen`) is answered out-of-band by + * the serving entry, so it is tracked separately and never counted as + * in-flight work. + */ export function instrumentMcpRequestLifecycle( transport: Transport, observer: McpRequestLifecycleObserver, ): void { const pendingRequestIds = new Set(); + const longLivedRequestIds = new Set(); const originalStart = transport.start.bind(transport); const originalSend = transport.send.bind(transport); let onMessageWrapped = false; + const settleRequest = (requestId: string): void => { + if (longLivedRequestIds.delete(requestId)) { + // Never counted as in-flight, so there is nothing to release. + return; + } + if (pendingRequestIds.delete(requestId)) { + observer.onRequestCompleted?.(); + } + }; + + const observeInbound = (message: JSONRPCMessage): string | null => { + if (isJSONRPCRequest(message)) { + const requestId = requestIdKey(message.id); + + if (isLongLivedRequestMethod(message.method)) { + longLivedRequestIds.add(requestId); + return null; + } + + if (!pendingRequestIds.has(requestId)) { + pendingRequestIds.add(requestId); + observer.onRequestStarted?.(); + return requestId; + } + return null; + } + + if (isJSONRPCNotification(message)) { + const cancelledId = cancelledRequestId(message.method, message.params); + if (cancelledId !== null) { + settleRequest(requestIdKey(cancelledId)); + } + } + + return null; + }; + const observeModernEnvelope = (message: JSONRPCMessage): void => { if (!observer.onModernEnvelope || !isJSONRPCRequest(message)) { return; @@ -66,17 +122,8 @@ export function instrumentMcpRequestLifecycle( onMessageWrapped = true; const downstreamOnMessage = transport.onmessage; - transport.onmessage = (message, extra) => { - let startedRequestId: string | null = null; - - if (isJSONRPCRequest(message)) { - const requestId = requestIdKey(message.id); - if (!pendingRequestIds.has(requestId)) { - pendingRequestIds.add(requestId); - startedRequestId = requestId; - observer.onRequestStarted?.(); - } - } + transport.onmessage = (message, extra): void => { + const startedRequestId = observeInbound(message); observeModernEnvelope(message); @@ -101,8 +148,12 @@ export function instrumentMcpRequestLifecycle( options?: TransportSendOptions, ): Promise => { const completedRequestId = completedRequestIdKey(message); + const completesLongLivedRequest = + completedRequestId !== null && longLivedRequestIds.delete(completedRequestId); const completesPendingRequest = - completedRequestId !== null && pendingRequestIds.delete(completedRequestId); + !completesLongLivedRequest && + completedRequestId !== null && + pendingRequestIds.delete(completedRequestId); try { await originalSend(message, options); diff --git a/src/server/server-state.ts b/src/server/server-state.ts index 45cfb0a31..5acb8f1c2 100644 --- a/src/server/server-state.ts +++ b/src/server/server-state.ts @@ -1,29 +1,48 @@ import type { McpServer } from '@modelcontextprotocol/server'; +/** + * How long a server instance serves. + * + * A `connection` instance is pinned for the lifetime of a transport connection + * (stdio). A `request` instance serves exactly one HTTP request and is closed + * as soon as the response is written. + */ +export type McpServingScope = 'connection' | 'request'; + /** * Active MCP server instances for this process. * * SDK v2 builds a fresh server per serving context (one per stdio connection, * one per HTTP request), so this is a set rather than a singleton. Registration - * order is preserved: the most recently registered instance is the one a - * process-level singleton (for example the Xcode tools bridge) should target. + * order is preserved. * * This tracks *protocol* instances only. Application session state * (`sessionStore`, debugger sessions, log capture) is deliberately process * scoped and outlives any individual server instance. */ -const activeServers = new Set(); +const activeServers = new Map(); export type ServerInstanceListener = (server: McpServer | undefined) => void; const listeners = new Set(); +/** + * The instance a process-level singleton should target. + * + * Connection-scoped instances win: a per-request HTTP instance must never + * shadow the live stdio connection, and it is closed before any process-level + * consumer could usefully hold on to it. + */ function currentServer(): McpServer | undefined { - let latest: McpServer | undefined; - for (const server of activeServers) { - latest = server; + let latestConnection: McpServer | undefined; + let latestAny: McpServer | undefined; + for (const [server, scope] of activeServers) { + latestAny = server; + if (scope === 'connection') { + latestConnection = server; + } } - return latest; + return latestConnection ?? latestAny; } function notifyListeners(): void { @@ -33,19 +52,22 @@ function notifyListeners(): void { } } -/** The most recently registered active server instance, if any. */ +/** The active server instance a process-level singleton should target, if any. */ export function getServer(): McpServer | undefined { return currentServer(); } /** Every currently active server instance, in registration order. */ export function getActiveServers(): McpServer[] { - return [...activeServers]; + return [...activeServers.keys()]; } /** Registers a freshly built server instance as active. */ -export function registerActiveServer(server: McpServer): void { - activeServers.add(server); +export function registerActiveServer( + server: McpServer, + scope: McpServingScope = 'connection', +): void { + activeServers.set(server, scope); notifyListeners(); } diff --git a/src/server/server.ts b/src/server/server.ts index d95fd108e..7768af029 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -37,7 +37,11 @@ import { recordServingContextStarted, } from './mcp-instrumentation.ts'; import { MCP_CACHE_HINTS } from './mcp-protocol.ts'; -import { registerActiveServer, unregisterActiveServer } from './server-state.ts'; +import { + registerActiveServer, + unregisterActiveServer, + type McpServingScope, +} from './server-state.ts'; import { releaseServerToolRegistrations } from '../utils/tool-registry.ts'; import { applyServerRegistrations, type ServerRegistrations } from './bootstrap.ts'; @@ -89,14 +93,41 @@ function createBaseServerInstance(): McpServer { ); } +/** + * Releases the process-level bookkeeping for a server instance. + * + * Idempotent, because it is reachable from two seams that both fire depending + * on how the instance is torn down. + */ +function releaseServerInstance(server: McpServer): void { + releaseServerToolRegistrations(server); + unregisterActiveServer(server); +} + +/** + * Wires instance teardown to every close path the SDK uses. + * + * `serveStdio` closes the high-level `McpServer`, while `createMcpHandler` + * closes the low-level `server.server` once per request and never touches the + * high-level wrapper. Hooking only one of them leaks the instance and its tool + * registrations, so both are covered; `releaseServerInstance` is idempotent. + */ function trackInstanceTeardown(server: McpServer): void { + const lowLevelServer = server.server; + const previousOnClose = lowLevelServer.onclose?.bind(lowLevelServer); + lowLevelServer.onclose = (): void => { + releaseServerInstance(server); + previousOnClose?.(); + }; + const originalClose = server.close.bind(server); server.close = async (): Promise => { try { await originalClose(); } finally { - releaseServerToolRegistrations(server); - unregisterActiveServer(server); + // Covers an instance that was never connected, where closing the + // low-level server has no transport to trigger `onclose`. + releaseServerInstance(server); } }; } @@ -110,14 +141,14 @@ function trackInstanceTeardown(server: McpServer): void { * state is deliberately not owned by the instance, so replacing an instance * never discards session defaults, debugger sessions or log captures. */ -export function createServer(): McpServer { +export function createServer(scope: McpServingScope = 'connection'): McpServer { const baseServer = createBaseServerInstance(); const server = Sentry.wrapMcpServerWithSentry(baseServer, { recordInputs: false, recordOutputs: false, }); - registerActiveServer(server); + registerActiveServer(server, scope); trackInstanceTeardown(server); log('info', `Server initialized (version ${version})`); @@ -142,8 +173,12 @@ function buildServerForContext( context: McpRequestContext, transport: 'stdio' | 'http', ): McpServer { - const server = createServer(); - applyServerRegistrations(server, registrations); + const server = createServer(transport === 'stdio' ? 'connection' : 'request'); + applyServerRegistrations(server, registrations, { + // Only a connection-scoped context owns the process-level Xcode tools + // bridge binding; HTTP instances live for a single request. + bindXcodeToolsBridge: transport === 'stdio', + }); recordServingContextStarted({ transport, era: context.era }); log('info', `MCP serving context started (transport=${transport}, era=${context.era})`); return server; diff --git a/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts b/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts new file mode 100644 index 000000000..f6b7242b4 --- /dev/null +++ b/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts @@ -0,0 +1,263 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Readable } from 'node:stream'; +import { afterEach, describe, expect, it } from 'vitest'; + +const CLI_PATH = join(process.cwd(), 'build/cli.js'); +const MCP_IDLE_TIMEOUT_MS = 1_000; +const MCP_READY_TIMEOUT_MS = 20_000; +const MCP_EXIT_WAIT_MS = 15_000; +const MCP_TEST_TIMEOUT_MS = 45_000; +const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +type ChildExit = { + code: number | null; + signal: NodeJS.Signals | null; +}; + +interface JsonRpcMessage { + id?: string | number; + method?: string; + result?: Record; + error?: { code: number; message: string }; +} + +function getSmokeTestEnv(overrides: Record = {}): Record { + const { VITEST: _vitest, NODE_ENV: _nodeEnv, ...rest } = process.env; + const env = Object.fromEntries( + Object.entries(rest).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + return { ...env, ...overrides }; +} + +function modernMeta(): Record { + return { + 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'mcp-modern-idle-timeout-e2e-client', + version: '1.0.0', + }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }; +} + +/** + * A raw modern-era stdio peer. + * + * The SDK client negotiates the 2025 era over stdio, so the 2026-07-28 wire + * shape (and `subscriptions/listen`, which has no client helper that leaves the + * request pending) is driven directly. + */ +class ModernStdioChild { + readonly child: ChildProcess; + private buffer = ''; + private stderrOutput = ''; + private nextId = 1; + + readonly messages: JsonRpcMessage[] = []; + + constructor(idleTimeoutMs: number) { + this.child = spawn('node', [CLI_PATH, 'mcp'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + env: getSmokeTestEnv({ + SENTRY_DISABLED: 'true', + XCODEBUILDMCP_ENABLED_WORKFLOWS: 'simulator', + XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS: 'true', + XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC: '1', + XCODEBUILDMCP_MCP_IDLE_TIMEOUT_MS: String(idleTimeoutMs), + }), + }); + + const stdout = this.child.stdout as Readable; + stdout.setEncoding('utf8'); + stdout.on('data', (chunk: string) => { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + if (line) { + this.messages.push(JSON.parse(line) as JsonRpcMessage); + } + newlineIndex = this.buffer.indexOf('\n'); + } + }); + + const stderr = this.child.stderr as Readable; + stderr.setEncoding('utf8'); + stderr.on('data', (chunk: string) => { + this.stderrOutput += chunk; + }); + } + + get stderr(): string { + return this.stderrOutput; + } + + send(message: Record): void { + this.child.stdin?.write(`${JSON.stringify(message)}\n`); + } + + sendRequest(method: string, params: Record = {}): number { + const id = this.nextId++; + this.send({ jsonrpc: '2.0', id, method, params: { ...params, _meta: modernMeta() } }); + return id; + } + + async awaitResult(id: number, timeoutMs: number): Promise { + return this.awaitMessage( + (message) => + message.id === id && (message.result !== undefined || message.error !== undefined), + timeoutMs, + `a response to request ${id}`, + ); + } + + async awaitNotification(method: string, timeoutMs: number): Promise { + return this.awaitMessage( + (message) => message.method === method && message.id === undefined, + timeoutMs, + method, + ); + } + + private async awaitMessage( + predicate: (message: JsonRpcMessage) => boolean, + timeoutMs: number, + description: string, + ): Promise { + const startedAt = Date.now(); + for (;;) { + const found = this.messages.find(predicate); + if (found) { + return found; + } + if (Date.now() - startedAt > timeoutMs) { + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${description}. stderr:\n${this.stderrOutput}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + waitForExit(timeoutMs: number): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return Promise.resolve({ code: this.child.exitCode, signal: this.child.signalCode }); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject( + new Error( + `MCP server process did not exit within ${timeoutMs}ms. stderr:\n${this.stderrOutput}`, + ), + ); + }, timeoutMs); + + const cleanup = (): void => { + clearTimeout(timeout); + this.child.removeListener('close', onClose); + }; + + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + cleanup(); + resolve({ code, signal }); + }; + + this.child.once('close', onClose); + }); + } + + async dispose(): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return; + } + this.child.kill('SIGTERM'); + await this.waitForExit(3_000).catch(() => undefined); + } +} + +let activeChild: ModernStdioChild | null = null; + +function requireBuild(): void { + if (!existsSync(CLI_PATH)) { + throw new Error( + 'MCP modern idle timeout e2e test requires build/cli.js. Run npm run build first.', + ); + } +} + +afterEach(async () => { + await activeChild?.dispose(); + activeChild = null; +}); + +describe('MCP modern-era idle timeout e2e', () => { + it( + 'exits after the client cancels a subscriptions/listen stream', + async () => { + requireBuild(); + const server = new ModernStdioChild(MCP_IDLE_TIMEOUT_MS); + activeChild = server; + + const listId = server.sendRequest('tools/list'); + const tools = await server.awaitResult(listId, MCP_READY_TIMEOUT_MS); + expect(tools.error).toBeUndefined(); + expect((tools.result?.tools as unknown[] | undefined)?.length).toBeGreaterThan(0); + + const listenId = server.sendRequest('subscriptions/listen', { + notifications: { toolsListChanged: true, resourcesListChanged: true }, + }); + await server.awaitNotification( + 'notifications/subscriptions/acknowledged', + MCP_READY_TIMEOUT_MS, + ); + + server.send({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: listenId, reason: 'test finished' }, + }); + + const exit = await server.waitForExit(MCP_EXIT_WAIT_MS); + activeChild = null; + + expect(exit).toEqual({ code: 0, signal: null }); + expect(server.stderr).toContain('MCP idle timeout reached'); + }, + MCP_TEST_TIMEOUT_MS, + ); + + it( + 'exits while a subscriptions/listen stream is still open', + async () => { + requireBuild(); + const server = new ModernStdioChild(MCP_IDLE_TIMEOUT_MS); + activeChild = server; + + const listId = server.sendRequest('tools/list'); + await server.awaitResult(listId, MCP_READY_TIMEOUT_MS); + + server.sendRequest('subscriptions/listen', { + notifications: { toolsListChanged: true }, + }); + await server.awaitNotification( + 'notifications/subscriptions/acknowledged', + MCP_READY_TIMEOUT_MS, + ); + + // An idle subscription carries no traffic, so it must not hold the + // process open past the configured idle timeout. + const exit = await server.waitForExit(MCP_EXIT_WAIT_MS); + activeChild = null; + + expect(exit).toEqual({ code: 0, signal: null }); + expect(server.stderr).toContain('MCP idle timeout reached'); + }, + MCP_TEST_TIMEOUT_MS, + ); +}); diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index de674139f..9254b7791 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -501,6 +501,16 @@ export function releaseServerToolRegistrations(server: McpServer): void { registryState.registrationsByServer.delete(server); } +/** + * How many server instances currently hold tool registrations. + * + * Diagnostics for serving-context teardown: this must return to its baseline + * once every serving context has closed. + */ +export function getTrackedRegistrationServerCount(): number { + return registryState.registrationsByServer.size; +} + /** The registration plan resolved by the most recent workflow selection. */ export function getToolRegistrationPlan(): McpToolRegistrationPlan | null { return registryState.plan; From 5fac5fa615ac623c7ae7e1d5f2ddcb6f09102ef5 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:48:23 +0530 Subject: [PATCH 4/7] fix(mcp): resolve the workflow plan per serving context and refresh idle on listen Both findings from the PR #508 review reproduce. `ServerRegistrations.toolPlan` captured `getToolRegistrationPlan()` once during bootstrap, and every serving context replayed that snapshot. `manage_workflows` rewrites the process-level plan at runtime and applies it to the servers that are already active, so the live context was correct, but any context built afterwards re-registered the boot-time workflows and silently undid the client's selection. It is now a resolver (`resolveToolPlan`) read at apply time, so each context registers the plan that is current when it is built. The Xcode tools bridge binding is untouched: per-request contexts still never take it, so this adds no rebind churn. Opening a `subscriptions/listen` stream was excluded from the lifecycle callbacks entirely. That kept it out of the in-flight count, which is required, but it also meant the interaction never refreshed the idle window, so a subscription opened near the deadline could be killed by the very next idle check. Long-lived requests now report activity exactly once on open through a new `onRequestActivity` observer hook and `McpIdleShutdownController.markActivity`, which moves the idle deadline without touching the in-flight count. Idle shutdown still runs once the client goes quiet. Regressions, all verified failing before the fix: - new HTTP context and stdio probe-to-pinned replacement after a plan change; - an idle-timeout e2e where the client only ever opens listen streams - it previously died mid-loop with "opening a subscription did not refresh the idle window"; - controller timing tests pinning the restarted window, the unchanged original deadline, and that activity cannot mask in-flight work; - observer tests for once-per-stream activity and no activity for ordinary requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/mcp-idle-shutdown.test.ts | 80 ++++++++++++ .../__tests__/mcp-notifications.test.ts | 6 +- .../__tests__/mcp-serving-cleanup.test.ts | 16 ++- .../__tests__/mcp-workflow-selection.test.ts | 118 ++++++++++++++++++ src/server/__tests__/server.test.ts | 42 +++++++ src/server/__tests__/serving-test-fixtures.ts | 89 ++++++++----- src/server/bootstrap.ts | 18 ++- src/server/mcp-idle-shutdown.ts | 13 ++ src/server/request-lifecycle.ts | 16 ++- src/server/start-mcp-server.ts | 1 + .../e2e-mcp-modern-idle-timeout.test.ts | 79 ++++++++++-- 11 files changed, 430 insertions(+), 48 deletions(-) create mode 100644 src/server/__tests__/mcp-workflow-selection.test.ts diff --git a/src/server/__tests__/mcp-idle-shutdown.test.ts b/src/server/__tests__/mcp-idle-shutdown.test.ts index 3eee003c9..909b20035 100644 --- a/src/server/__tests__/mcp-idle-shutdown.test.ts +++ b/src/server/__tests__/mcp-idle-shutdown.test.ts @@ -74,6 +74,86 @@ describe('MCP idle shutdown', () => { expect(requestShutdown).not.toHaveBeenCalled(); }); + it('restarts the idle window when activity is reported without in-flight work', async () => { + vi.useFakeTimers(); + const requestShutdown = vi.fn(); + let now = 0; + const controller = createMcpIdleShutdownController({ + timeoutMs: 1_000, + intervalMs: 100, + nowMs: () => now, + requestShutdown, + }); + + controller.start(); + + // Just short of the deadline: still alive. + now = 950; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).not.toHaveBeenCalled(); + + // A subscription opens. It is not in-flight work, but it is activity. + controller.markActivity(); + expect(controller.getInFlightRequestCount()).toBe(0); + + // Past the original deadline, but inside the restarted window. + now = 1_500; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).not.toHaveBeenCalled(); + + // The refreshed window is a delay, not a reprieve: idle shutdown still runs. + now = 2_100; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).toHaveBeenCalledTimes(1); + }); + + it('shuts down at the original deadline when no activity is reported', async () => { + vi.useFakeTimers(); + const requestShutdown = vi.fn(); + let now = 0; + const controller = createMcpIdleShutdownController({ + timeoutMs: 1_000, + intervalMs: 100, + nowMs: () => now, + requestShutdown, + }); + + controller.start(); + + now = 950; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).not.toHaveBeenCalled(); + + now = 1_050; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).toHaveBeenCalledTimes(1); + }); + + it('does not let reported activity mask in-flight work', async () => { + vi.useFakeTimers(); + const requestShutdown = vi.fn(); + let now = 0; + const controller = createMcpIdleShutdownController({ + timeoutMs: 1_000, + intervalMs: 100, + nowMs: () => now, + requestShutdown, + }); + + controller.start(); + controller.markRequestStarted(); + controller.markActivity(); + + now = 5_000; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).not.toHaveBeenCalled(); + + controller.markRequestCompleted(); + now = 6_100; + await vi.advanceTimersByTimeAsync(100); + expect(requestShutdown).toHaveBeenCalledTimes(1); + }); + it('starts an unref interval when enabled', () => { const unref = vi.fn(); const timer = { unref } as unknown as NodeJS.Timeout; diff --git a/src/server/__tests__/mcp-notifications.test.ts b/src/server/__tests__/mcp-notifications.test.ts index fffb70049..fd5d554e6 100644 --- a/src/server/__tests__/mcp-notifications.test.ts +++ b/src/server/__tests__/mcp-notifications.test.ts @@ -47,7 +47,7 @@ describe('dynamic tool and resource notifications', () => { expect(server).toBeDefined(); applyToolPlanToServer(server!, { - ...registrations.toolPlan!, + ...registrations.resolveToolPlan()!, tools: [], }); @@ -77,7 +77,7 @@ describe('dynamic tool and resource notifications', () => { expect(initial.tools.map((tool) => tool.name)).toContain(TEST_TOOL_NAME); const server = getServer(); - applyToolPlanToServer(server!, { ...registrations.toolPlan!, tools: [] }); + applyToolPlanToServer(server!, { ...registrations.resolveToolPlan()!, tools: [] }); await waitFor(() => notified.mock.calls.length > 0); @@ -120,7 +120,7 @@ describe('dynamic tool and resource notifications', () => { const server = getServer(); expect(server).toBeDefined(); - expect(() => applyToolPlanToServer(server!, registrations.toolPlan!)).not.toThrow(); + expect(() => applyToolPlanToServer(server!, registrations.resolveToolPlan()!)).not.toThrow(); const listed = await peer.request('tools/list', { _meta: modernMeta() }); expect( diff --git a/src/server/__tests__/mcp-serving-cleanup.test.ts b/src/server/__tests__/mcp-serving-cleanup.test.ts index 314be0f39..df2cc7c45 100644 --- a/src/server/__tests__/mcp-serving-cleanup.test.ts +++ b/src/server/__tests__/mcp-serving-cleanup.test.ts @@ -25,14 +25,20 @@ afterEach(async () => { }); function createLifecycleCounter(): { - observer: { onRequestStarted: () => void; onRequestCompleted: () => void }; + observer: { + onRequestStarted: () => void; + onRequestCompleted: () => void; + onRequestActivity: () => void; + }; inFlight: () => number; started: () => number; completed: () => number; + activity: () => number; } { let inFlight = 0; let started = 0; let completed = 0; + let activity = 0; return { observer: { onRequestStarted: (): void => { @@ -43,10 +49,14 @@ function createLifecycleCounter(): { inFlight -= 1; completed += 1; }, + onRequestActivity: (): void => { + activity += 1; + }, }, inFlight: () => inFlight, started: () => started, completed: () => completed, + activity: () => activity, }; } @@ -91,8 +101,10 @@ describe('idle-shutdown accounting for entry-served requests', () => { // The entry answers `subscriptions/listen` with an acknowledgement // notification; the JSON-RPC result only arrives at teardown. It must not - // pin the idle-shutdown in-flight count. + // pin the idle-shutdown in-flight count, but it must still refresh the + // idle window so a subscription opened near the deadline is not raced. expect(counter.inFlight()).toBe(0); + expect(counter.activity()).toBe(1); expect(listenId).toBeGreaterThan(0); }); diff --git a/src/server/__tests__/mcp-workflow-selection.test.ts b/src/server/__tests__/mcp-workflow-selection.test.ts new file mode 100644 index 000000000..88c6621d2 --- /dev/null +++ b/src/server/__tests__/mcp-workflow-selection.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; +import { createMcpHttpHandler, startStdioServer } from '../server.ts'; +import { __resetServerStateForTests, getServer } from '../server-state.ts'; +import { + __resetToolRegistryForTests, + applyToolPlanToServer, + type McpToolRegistrationPlan, +} from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { + createTestRegistrations, + SECOND_TEST_TOOL_NAME, + secondTestToolPlanEntry, + setCurrentTestPlan, + TEST_TOOL_NAME, +} from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer, waitFor } from './raw-mcp-peer.ts'; +import { buildModernHttpHeaders } from '../mcp-protocol.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +async function httpToolNames(handler: ReturnType): Promise { + const params = { _meta: modernMeta() }; + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...buildModernHttpHeaders('tools/list', params), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params }), + }), + ); + const payload = (await response.json()) as { + result?: { tools?: Array<{ name: string }> }; + }; + return (payload.result?.tools ?? []).map((tool) => tool.name); +} + +describe('workflow selection across serving contexts', () => { + it('serves a workflow change to a new HTTP serving context', async () => { + const registrations = createTestRegistrations(); + const httpHandler = createMcpHttpHandler(registrations); + + expect(await httpToolNames(httpHandler)).toEqual([TEST_TOOL_NAME]); + + // Simulates manage_workflows: the process-level plan changes after startup. + const nextPlan: McpToolRegistrationPlan = { + ...registrations.resolveToolPlan()!, + tools: [...registrations.resolveToolPlan()!.tools, secondTestToolPlanEntry()], + }; + setCurrentTestPlan(nextPlan); + + expect(await httpToolNames(httpHandler)).toEqual([TEST_TOOL_NAME, SECOND_TEST_TOOL_NAME]); + + await httpHandler.close(); + }); + + it('serves a workflow removal to a new HTTP serving context', async () => { + const registrations = createTestRegistrations(); + const httpHandler = createMcpHttpHandler(registrations); + + expect(await httpToolNames(httpHandler)).toEqual([TEST_TOOL_NAME]); + + setCurrentTestPlan({ ...registrations.resolveToolPlan()!, tools: [] }); + + expect(await httpToolNames(httpHandler)).toEqual([]); + + await httpHandler.close(); + }); + + it('applies a workflow change to the live stdio context and to its replacement', async () => { + const registrations = createTestRegistrations(); + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + + await peer.request('server/discover', { _meta: modernMeta() }); + const probeInstance = getServer(); + expect(probeInstance).toBeDefined(); + + const nextPlan: McpToolRegistrationPlan = { + ...registrations.resolveToolPlan()!, + tools: [...registrations.resolveToolPlan()!.tools, secondTestToolPlanEntry()], + }; + setCurrentTestPlan(nextPlan); + applyToolPlanToServer(probeInstance!, nextPlan); + + // The client falls back to the 2025 handshake, so the entry discards the + // probe instance and builds a replacement. It must pick up the current + // plan, not the plan captured when the process started. + await peer.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'fallback-client', version: '1.0.0' }, + }); + await peer.notify('notifications/initialized'); + await waitFor(() => getServer() !== probeInstance); + + const listed = await peer.request('tools/list', {}); + expect( + (listed.result?.tools as Array<{ name: string }> | undefined)?.map((tool) => tool.name), + ).toEqual([TEST_TOOL_NAME, SECOND_TEST_TOOL_NAME]); + }); +}); diff --git a/src/server/__tests__/server.test.ts b/src/server/__tests__/server.test.ts index 8b4b83f09..cb01a532a 100644 --- a/src/server/__tests__/server.test.ts +++ b/src/server/__tests__/server.test.ts @@ -141,6 +141,48 @@ describe('MCP server transport request lifecycle instrumentation', () => { expect(onRequestCompleted).not.toHaveBeenCalled(); }); + it('reports activity exactly once when a listen request opens', async () => { + const onRequestStarted = vi.fn(); + const onRequestCompleted = vi.fn(); + const onRequestActivity = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestStarted, + onRequestCompleted, + onRequestActivity, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + + expect(onRequestActivity).toHaveBeenCalledTimes(1); + expect(onRequestStarted).not.toHaveBeenCalled(); + expect(onRequestCompleted).not.toHaveBeenCalled(); + }); + + it('reports activity for each distinct listen stream', async () => { + const onRequestActivity = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ onRequestActivity }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-1', method: 'subscriptions/listen' }); + transport.onmessage?.({ jsonrpc: '2.0', id: 'listen-2', method: 'subscriptions/listen' }); + + expect(onRequestActivity).toHaveBeenCalledTimes(2); + }); + + it('does not report activity for ordinary requests', async () => { + const onRequestActivity = vi.fn(); + const onRequestStarted = vi.fn(); + const { transport } = await createStartedInstrumentedTransport({ + onRequestActivity, + onRequestStarted, + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + + expect(onRequestStarted).toHaveBeenCalledTimes(1); + expect(onRequestActivity).not.toHaveBeenCalled(); + }); + it('does not double-settle when a listen request is finally answered at teardown', async () => { const onRequestStarted = vi.fn(); const onRequestCompleted = vi.fn(); diff --git a/src/server/__tests__/serving-test-fixtures.ts b/src/server/__tests__/serving-test-fixtures.ts index 765d17e26..47d2caa88 100644 --- a/src/server/__tests__/serving-test-fixtures.ts +++ b/src/server/__tests__/serving-test-fixtures.ts @@ -4,9 +4,11 @@ import type { ToolHandlerContext } from '../../rendering/types.ts'; import type { ServerRegistrations } from '../bootstrap.ts'; import type { ResourceMeta } from '../../core/resources.ts'; import { createToolCatalog } from '../../runtime/tool-catalog.ts'; +import type { McpToolRegistrationPlan } from '../../utils/tool-registry.ts'; import type { ToolDefinition } from '../../runtime/types.ts'; export const TEST_TOOL_NAME = 'probe_echo'; +export const SECOND_TEST_TOOL_NAME = 'probe_second'; export const TEST_RESOURCE_URI = 'xcodebuildmcp://probe'; const probeSchema = z.object({ text: z.string() }) as unknown as ToolSchemaShape; @@ -30,11 +32,11 @@ function probeHandler(params: Record, ctx?: ToolHandlerContext) return Promise.resolve(undefined); } -function probeToolDefinition(): ToolDefinition { +function probeToolDefinition(mcpName: string): ToolDefinition { return { - id: TEST_TOOL_NAME, - cliName: 'probe-echo', - mcpName: TEST_TOOL_NAME, + id: mcpName, + cliName: mcpName.replace(/_/g, '-'), + mcpName, workflow: 'probe', description: 'Echoes its input', annotations: { readOnlyHint: true }, @@ -46,6 +48,58 @@ function probeToolDefinition(): ToolDefinition { }; } +function probeToolPlanEntry(mcpName: string): McpToolRegistrationPlan['tools'][number] { + return { + manifest: { + id: mcpName, + module: 'probe/echo', + names: { mcp: mcpName }, + description: 'Echoes its input', + availability: { mcp: true, cli: false }, + predicates: [], + nextSteps: [], + annotations: { readOnlyHint: true }, + }, + module: { + schema: probeSchema, + mcpSchema: probeSchema, + handler: probeHandler, + }, + }; +} + +/** A second tool, for asserting that a workflow change reaches a new context. */ +export function secondTestToolPlanEntry(): McpToolRegistrationPlan['tools'][number] { + return probeToolPlanEntry(SECOND_TEST_TOOL_NAME); +} + +function buildTestToolPlan(mcpNames: string[]): McpToolRegistrationPlan { + return { + tools: mcpNames.map((name) => probeToolPlanEntry(name)), + catalog: createToolCatalog(mcpNames.map((name) => probeToolDefinition(name))), + enabledWorkflows: new Set(['probe']), + workflowLabel: 'probe', + }; +} + +/** + * The process-level plan the fixture registrations resolve. + * + * Stands in for the registry that `manage_workflows` rewrites at runtime, so a + * test can change the selection and assert that later serving contexts see it. + */ +let currentTestPlan: McpToolRegistrationPlan | null = null; + +/** Replaces the current plan, as `applyWorkflowSelectionFromManifest` does. */ +export function setCurrentTestPlan(plan: McpToolRegistrationPlan | null): void { + currentTestPlan = plan; +} + +/** The plan the fixture registrations currently resolve. */ +export function getCurrentTestPlan(): McpToolRegistrationPlan | null { + return currentTestPlan; +} + /** * A minimal registration set for serving-layer tests. * @@ -69,31 +123,10 @@ export function createTestRegistrations( ], ]); + currentTestPlan = buildTestToolPlan([TEST_TOOL_NAME]); + return { - toolPlan: { - tools: [ - { - manifest: { - id: TEST_TOOL_NAME, - module: 'probe/echo', - names: { mcp: TEST_TOOL_NAME }, - description: 'Echoes its input', - availability: { mcp: true, cli: false }, - predicates: [], - nextSteps: [], - annotations: { readOnlyHint: true }, - }, - module: { - schema: probeSchema, - mcpSchema: probeSchema, - handler: probeHandler, - }, - }, - ], - catalog: createToolCatalog([probeToolDefinition()]), - enabledWorkflows: new Set(['probe']), - workflowLabel: 'probe', - }, + resolveToolPlan: () => currentTestPlan, resources, xcodeIdeEnabled: false, ...overrides, diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index aa73ec3ad..1d9e62249 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -36,7 +36,16 @@ export interface BootstrapOptions { * runtime detection) is done once and replayed cheaply onto each instance. */ export interface ServerRegistrations { - toolPlan: McpToolRegistrationPlan | null; + /** + * Reads the tool registration plan that is current *now*. + * + * Deliberately a resolver rather than a captured value: `manage_workflows` + * rewrites the process-level plan at runtime, and a serving context created + * afterwards must serve the new selection. Capturing the plan at startup + * would make every later context replay the boot-time workflows and silently + * undo the client's selection. + */ + resolveToolPlan: () => McpToolRegistrationPlan | null; resources: Map; xcodeIdeEnabled: boolean; } @@ -108,8 +117,9 @@ export function applyServerRegistrations( return {}; }); - if (registrations.toolPlan) { - applyToolPlanToServer(server, registrations.toolPlan); + const toolPlan = registrations.resolveToolPlan(); + if (toolPlan) { + applyToolPlanToServer(server, toolPlan); } registerLoadedResources(server, registrations.resources); @@ -189,7 +199,7 @@ export async function bootstrapServerRuntime( return { registrations: { - toolPlan: getToolRegistrationPlan(), + resolveToolPlan: getToolRegistrationPlan, resources, xcodeIdeEnabled, }, diff --git a/src/server/mcp-idle-shutdown.ts b/src/server/mcp-idle-shutdown.ts index c474d2f59..0020b3b47 100644 --- a/src/server/mcp-idle-shutdown.ts +++ b/src/server/mcp-idle-shutdown.ts @@ -18,6 +18,15 @@ export interface McpIdleShutdownController { stop(): void; markRequestStarted(): void; markRequestCompleted(): void; + /** + * Refreshes the idle window without touching the in-flight count. + * + * For client interactions that are real activity but are not settle-able + * work, such as opening a `subscriptions/listen` stream: the entry answers it + * out of band, so it must not be counted as in-flight, but it still means the + * client is alive right now and the idle window should restart. + */ + markActivity(): void; getInFlightRequestCount(): number; } @@ -119,6 +128,10 @@ export function createMcpIdleShutdownController(options: { inFlightRequestCount += 1; }, + markActivity(): void { + lastRequestCompletedAtMs = nowMs(); + }, + markRequestCompleted(): void { inFlightRequestCount = Math.max(0, inFlightRequestCount - 1); lastRequestCompletedAtMs = nowMs(); diff --git a/src/server/request-lifecycle.ts b/src/server/request-lifecycle.ts index 77badb098..c09885011 100644 --- a/src/server/request-lifecycle.ts +++ b/src/server/request-lifecycle.ts @@ -22,6 +22,14 @@ export interface McpModernRequestObservation { export interface McpRequestLifecycleObserver { onRequestStarted?: () => void; onRequestCompleted?: () => void; + /** + * Called once when a long-lived request opens. + * + * Opening a `subscriptions/listen` stream is real client activity, so it must + * restart the idle window, but it is answered out of band and so must never + * be counted as in-flight work or the process could never idle out. + */ + onRequestActivity?: () => void; /** * Called for every inbound modern-era request. Modern clients never send * `initialize`, so this is the only place the protocol revision and the @@ -56,7 +64,8 @@ function completedRequestIdKey(message: JSONRPCMessage): string | null { * deliberately writes no response for an aborted request; * - a long-lived request (`subscriptions/listen`) is answered out-of-band by * the serving entry, so it is tracked separately and never counted as - * in-flight work. + * in-flight work. Opening one still reports activity once, so a subscription + * opened near the idle deadline restarts the window instead of racing it. */ export function instrumentMcpRequestLifecycle( transport: Transport, @@ -83,7 +92,10 @@ export function instrumentMcpRequestLifecycle( const requestId = requestIdKey(message.id); if (isLongLivedRequestMethod(message.method)) { - longLivedRequestIds.add(requestId); + if (!longLivedRequestIds.has(requestId)) { + longLivedRequestIds.add(requestId); + observer.onRequestActivity?.(); + } return null; } diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index 725c2f5bb..5629c322b 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -144,6 +144,7 @@ export async function startMcpServer(): Promise { requestLifecycle: { onRequestStarted: () => idleShutdown?.markRequestStarted(), onRequestCompleted: () => idleShutdown?.markRequestCompleted(), + onRequestActivity: () => idleShutdown?.markActivity(), }, }); lifecycle.registerServingHandle(servingHandle); diff --git a/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts b/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts index f6b7242b4..a3f443d63 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-modern-idle-timeout.test.ts @@ -6,6 +6,9 @@ import { afterEach, describe, expect, it } from 'vitest'; const CLI_PATH = join(process.cwd(), 'build/cli.js'); const MCP_IDLE_TIMEOUT_MS = 1_000; +const MCP_KEEPALIVE_IDLE_TIMEOUT_MS = 1_500; +const LISTEN_REFRESH_INTERVAL_MS = 900; +const LISTEN_REFRESH_ROUNDS = 5; const MCP_READY_TIMEOUT_MS = 20_000; const MCP_EXIT_WAIT_MS = 15_000; const MCP_TEST_TIMEOUT_MS = 45_000; @@ -108,31 +111,39 @@ class ModernStdioChild { async awaitResult(id: number, timeoutMs: number): Promise { return this.awaitMessage( - (message) => - message.id === id && (message.result !== undefined || message.error !== undefined), + (_message, matches) => matches >= 1, timeoutMs, `a response to request ${id}`, + (message) => + message.id === id && (message.result !== undefined || message.error !== undefined), ); } - async awaitNotification(method: string, timeoutMs: number): Promise { + async awaitNotification( + method: string, + timeoutMs: number, + occurrence = 1, + ): Promise { return this.awaitMessage( - (message) => message.method === method && message.id === undefined, + (_message, matches) => matches >= occurrence, timeoutMs, - method, + `${method} #${occurrence}`, + (message) => message.method === method && message.id === undefined, ); } private async awaitMessage( - predicate: (message: JsonRpcMessage) => boolean, + accept: (message: JsonRpcMessage, matchCount: number) => boolean, timeoutMs: number, description: string, + filter: (message: JsonRpcMessage) => boolean = () => true, ): Promise { const startedAt = Date.now(); for (;;) { - const found = this.messages.find(predicate); - if (found) { - return found; + const matched = this.messages.filter(filter); + const last = matched[matched.length - 1]; + if (last && accept(last, matched.length)) { + return last; } if (Date.now() - startedAt > timeoutMs) { throw new Error( @@ -232,6 +243,56 @@ describe('MCP modern-era idle timeout e2e', () => { MCP_TEST_TIMEOUT_MS, ); + it( + 'keeps the process alive while listen streams keep opening inside the idle window', + async () => { + requireBuild(); + const server = new ModernStdioChild(MCP_KEEPALIVE_IDLE_TIMEOUT_MS); + activeChild = server; + + const listId = server.sendRequest('tools/list'); + await server.awaitResult(listId, MCP_READY_TIMEOUT_MS); + const lastOrdinaryRequestAt = Date.now(); + + // Open a subscription every time the window is about to lapse, and never + // send any other traffic. Opening a stream is real client activity, so it + // must refresh the idle window; without that refresh the server shuts + // down at the first idle check after `lastOrdinaryRequestAt`. + for (let round = 0; round < LISTEN_REFRESH_ROUNDS; round += 1) { + await new Promise((resolve) => setTimeout(resolve, LISTEN_REFRESH_INTERVAL_MS)); + + if (server.child.exitCode !== null || server.child.signalCode !== null) { + throw new Error( + `MCP server exited after ${Date.now() - lastOrdinaryRequestAt}ms of listen-only ` + + `activity (round ${round}); opening a subscription did not refresh the idle window.`, + ); + } + + server.sendRequest('subscriptions/listen', { + notifications: { toolsListChanged: true }, + }); + await server.awaitNotification( + 'notifications/subscriptions/acknowledged', + MCP_READY_TIMEOUT_MS, + round + 1, + ); + } + + const keptAliveForMs = Date.now() - lastOrdinaryRequestAt; + expect(server.child.exitCode).toBeNull(); + expect(keptAliveForMs).toBeGreaterThan(MCP_KEEPALIVE_IDLE_TIMEOUT_MS * 2); + + // Refreshing is a delay, not a reprieve: once the streams stop opening, + // idle shutdown still runs. + const exit = await server.waitForExit(MCP_EXIT_WAIT_MS); + activeChild = null; + + expect(exit).toEqual({ code: 0, signal: null }); + expect(server.stderr).toContain('MCP idle timeout reached'); + }, + MCP_TEST_TIMEOUT_MS, + ); + it( 'exits while a subscriptions/listen stream is still open', async () => { From 92b8abd197d27ffb77585ed86b6b641d3d079c1c Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:04:40 +0530 Subject: [PATCH 5/7] fix(mcp): gate the Xcode bridge on the current serving-context plan The previous commit made tool registration follow the live workflow selection, but the Xcode tools bridge was still gated on `xcodeIdeEnabled`, a boolean captured during bootstrap. The two sources disagreed as soon as `manage_workflows` ran, in both directions: - xcode-ide enabled after startup: a later context registered the xcode-ide manifest tools from the current plan but never constructed, bound or enabled the bridge manager, so the proxied `xcode_tools_*` tools were missing; - xcode-ide disabled after startup: a later context still bound and re-enabled the bridge, undoing the client's disable. The boot-time flag is removed rather than refreshed, so there is no stale value left to read: `ServerRegistrations` now exposes only `resolveToolPlan`, and both the tool registrations and the bridge decision are derived from that one plan via `planEnablesXcodeIde`. Disable is now a real teardown. `setWorkflowEnabled(false)` drops the proxied registrations on the transition, so a context built after a disable cannot keep serving tools the client turned off, and a later re-enable syncs fresh instead of replaying a stale catalogue through `rebind`. A disabled context only ever peeks at the manager, so it never constructs one just to switch it off. HTTP ownership is unchanged and now explicit in both directions: a per-request context neither takes the binding nor disables a bridge the live stdio connection still owns. Regressions (enable-after-start and disable-after-start both verified failing before the fix): serving-context gating for stdio and HTTP, no-manager-creation on the disabled path, and manager-level coverage that disabling clears the proxied registrations, is idempotent, and stops listChanged-driven resyncs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/manager.test.ts | 43 +++++ .../xcode-tools-bridge/manager.ts | 14 ++ .../mcp-xcode-bridge-binding.test.ts | 25 ++- .../__tests__/mcp-xcode-bridge-gating.test.ts | 165 ++++++++++++++++++ src/server/__tests__/serving-test-fixtures.ts | 27 ++- src/server/bootstrap.ts | 36 +++- 6 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 src/server/__tests__/mcp-xcode-bridge-gating.test.ts diff --git a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts index 44c539576..82f0f1313 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts @@ -147,4 +147,47 @@ describe('XcodeToolsBridgeManager', () => { expect(syncSpy).toHaveBeenCalledWith({ reason: 'listChanged' }); }); + it('drops proxied tool registrations when the workflow is disabled', () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + expect(registryMocks.clear).not.toHaveBeenCalled(); + + manager.setWorkflowEnabled(false); + + expect(serviceMocks.setWorkflowEnabled).toHaveBeenLastCalledWith(false); + expect(registryMocks.clear).toHaveBeenCalledOnce(); + }); + + it('does not clear registrations when the workflow was already disabled', () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(false); + manager.setWorkflowEnabled(false); + + expect(registryMocks.clear).not.toHaveBeenCalled(); + }); + + it('does not resync from a listChanged notification after being disabled', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + manager.setWorkflowEnabled(false); + + const syncSpy = vi.spyOn(manager, 'syncTools'); + onToolCatalogInvalidatedRef.current?.(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(syncSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index b7e4450ff..c05c4c533 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -55,9 +55,23 @@ export class XcodeToolsBridgeManager { this.registry.rebind(server); } + /** + * Enables or disables the xcode-ide workflow. + * + * Disabling drops the proxied tool registrations as well: the workflow being + * off must mean the `xcode_tools_*` tools are gone, otherwise a serving + * context built after a `manage_workflows` disable would keep serving tools + * the client just turned off. + */ setWorkflowEnabled(enabled: boolean): void { + const wasEnabled = this.workflowEnabled; this.workflowEnabled = enabled; this.service.setWorkflowEnabled(enabled); + + if (!enabled && wasEnabled) { + this.suppressListChangedSync = true; + this.registry.clear(); + } } async shutdown(): Promise { diff --git a/src/server/__tests__/mcp-xcode-bridge-binding.test.ts b/src/server/__tests__/mcp-xcode-bridge-binding.test.ts index 77f8821b5..8567b3865 100644 --- a/src/server/__tests__/mcp-xcode-bridge-binding.test.ts +++ b/src/server/__tests__/mcp-xcode-bridge-binding.test.ts @@ -5,17 +5,19 @@ const bridgeMocks = vi.hoisted(() => ({ bindServer: vi.fn(), setWorkflowEnabled: vi.fn(), getXcodeToolsBridgeManager: vi.fn(), + peekXcodeToolsBridgeManager: vi.fn(), })); vi.mock('../../integrations/xcode-tools-bridge/index.ts', () => ({ getXcodeToolsBridgeManager: bridgeMocks.getXcodeToolsBridgeManager, + peekXcodeToolsBridgeManager: bridgeMocks.peekXcodeToolsBridgeManager, })); import { createMcpHttpHandler, startStdioServer } from '../server.ts'; import { __resetServerStateForTests } from '../server-state.ts'; import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; -import { createTestRegistrations } from './serving-test-fixtures.ts'; +import { createTestRegistrations, setCurrentTestWorkflows } from './serving-test-fixtures.ts'; import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; import { buildModernHttpHeaders } from '../mcp-protocol.ts'; @@ -30,6 +32,11 @@ beforeEach(() => { bindServer: bridgeMocks.bindServer, setWorkflowEnabled: bridgeMocks.setWorkflowEnabled, }); + bridgeMocks.peekXcodeToolsBridgeManager.mockReset(); + bridgeMocks.peekXcodeToolsBridgeManager.mockReturnValue({ + bindServer: bridgeMocks.bindServer, + setWorkflowEnabled: bridgeMocks.setWorkflowEnabled, + }); }); afterEach(async () => { @@ -42,6 +49,12 @@ afterEach(async () => { __resetMcpInstrumentationForTests(); }); +function xcodeIdeRegistrations(enabled: boolean): ReturnType { + const registrations = createTestRegistrations(); + setCurrentTestWorkflows(enabled ? ['probe', 'xcode-ide'] : ['probe']); + return registrations; +} + async function httpToolsList(handler: ReturnType): Promise { const params = { _meta: modernMeta() }; const response = await handler.fetch( @@ -62,7 +75,7 @@ describe('Xcode tools bridge binding across serving contexts', () => { it('binds the process-level bridge to a connection-scoped stdio instance', async () => { peer = new RawMcpPeer(); await peer.start(); - handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: true }), { + handle = startStdioServer(xcodeIdeRegistrations(true), { transport: peer.serverTransport, }); @@ -73,7 +86,7 @@ describe('Xcode tools bridge binding across serving contexts', () => { }); it('never rebinds the bridge for per-request HTTP instances', async () => { - const httpHandler = createMcpHttpHandler(createTestRegistrations({ xcodeIdeEnabled: true })); + const httpHandler = createMcpHttpHandler(xcodeIdeRegistrations(true)); await httpToolsList(httpHandler); await httpToolsList(httpHandler); @@ -88,13 +101,13 @@ describe('Xcode tools bridge binding across serving contexts', () => { it('leaves the stdio binding intact while HTTP requests are served', async () => { peer = new RawMcpPeer(); await peer.start(); - handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: true }), { + handle = startStdioServer(xcodeIdeRegistrations(true), { transport: peer.serverTransport, }); await peer.request('tools/list', { _meta: modernMeta() }); expect(bridgeMocks.bindServer).toHaveBeenCalledTimes(1); - const httpHandler = createMcpHttpHandler(createTestRegistrations({ xcodeIdeEnabled: true })); + const httpHandler = createMcpHttpHandler(xcodeIdeRegistrations(true)); await httpToolsList(httpHandler); await httpHandler.close(); @@ -104,7 +117,7 @@ describe('Xcode tools bridge binding across serving contexts', () => { it('does not touch the bridge when the xcode-ide workflow is disabled', async () => { peer = new RawMcpPeer(); await peer.start(); - handle = startStdioServer(createTestRegistrations({ xcodeIdeEnabled: false }), { + handle = startStdioServer(xcodeIdeRegistrations(false), { transport: peer.serverTransport, }); diff --git a/src/server/__tests__/mcp-xcode-bridge-gating.test.ts b/src/server/__tests__/mcp-xcode-bridge-gating.test.ts new file mode 100644 index 000000000..1841c504d --- /dev/null +++ b/src/server/__tests__/mcp-xcode-bridge-gating.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StdioServerHandle } from '@modelcontextprotocol/server/stdio'; + +const bridgeMocks = vi.hoisted(() => ({ + bindServer: vi.fn(), + setWorkflowEnabled: vi.fn(), + getXcodeToolsBridgeManager: vi.fn(), + peekXcodeToolsBridgeManager: vi.fn(), +})); + +vi.mock('../../integrations/xcode-tools-bridge/index.ts', () => ({ + getXcodeToolsBridgeManager: bridgeMocks.getXcodeToolsBridgeManager, + peekXcodeToolsBridgeManager: bridgeMocks.peekXcodeToolsBridgeManager, +})); + +import { createMcpHttpHandler, startStdioServer } from '../server.ts'; +import { __resetServerStateForTests } from '../server-state.ts'; +import { + __resetToolRegistryForTests, + type McpToolRegistrationPlan, +} from '../../utils/tool-registry.ts'; +import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; +import { createTestRegistrations, setCurrentTestPlan } from './serving-test-fixtures.ts'; +import { modernMeta, RawMcpPeer } from './raw-mcp-peer.ts'; +import { buildModernHttpHeaders } from '../mcp-protocol.ts'; + +let handle: StdioServerHandle | null = null; +let peer: RawMcpPeer | null = null; + +const managerStub = { + bindServer: bridgeMocks.bindServer, + setWorkflowEnabled: bridgeMocks.setWorkflowEnabled, +}; + +beforeEach(() => { + bridgeMocks.bindServer.mockClear(); + bridgeMocks.setWorkflowEnabled.mockClear(); + bridgeMocks.getXcodeToolsBridgeManager.mockReset(); + bridgeMocks.getXcodeToolsBridgeManager.mockReturnValue(managerStub); + bridgeMocks.peekXcodeToolsBridgeManager.mockReset(); + bridgeMocks.peekXcodeToolsBridgeManager.mockReturnValue(managerStub); +}); + +afterEach(async () => { + await handle?.close(); + await peer?.close(); + handle = null; + peer = null; + __resetToolRegistryForTests(); + __resetServerStateForTests(); + __resetMcpInstrumentationForTests(); +}); + +function planWithWorkflows( + base: McpToolRegistrationPlan, + workflows: string[], +): McpToolRegistrationPlan { + return { ...base, enabledWorkflows: new Set(workflows) }; +} + +async function httpToolsList(handler: ReturnType): Promise { + const params = { _meta: modernMeta() }; + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...buildModernHttpHeaders('tools/list', params), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params }), + }), + ); + await response.text(); +} + +async function openStdioContext( + registrations: ReturnType, +): Promise { + peer = new RawMcpPeer(); + await peer.start(); + handle = startStdioServer(registrations, { transport: peer.serverTransport }); + await peer.request('tools/list', { _meta: modernMeta() }); +} + +describe('Xcode tools bridge gating follows the current workflow selection', () => { + it('enables and binds the bridge when xcode-ide is enabled after startup', async () => { + // Startup selection has no xcode-ide workflow. + const registrations = createTestRegistrations(); + const bootPlan = registrations.resolveToolPlan()!; + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe'])); + + await openStdioContext(registrations); + expect(bridgeMocks.getXcodeToolsBridgeManager).not.toHaveBeenCalled(); + + await handle!.close(); + await peer!.close(); + handle = null; + peer = null; + + // manage_workflows enables xcode-ide, then a new connection opens. + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe', 'xcode-ide'])); + await openStdioContext(registrations); + + expect(bridgeMocks.getXcodeToolsBridgeManager).toHaveBeenCalledTimes(1); + expect(bridgeMocks.bindServer).toHaveBeenCalledTimes(1); + expect(bridgeMocks.setWorkflowEnabled).toHaveBeenLastCalledWith(true); + }); + + it('disables the bridge when xcode-ide is disabled after startup', async () => { + const registrations = createTestRegistrations(); + const bootPlan = registrations.resolveToolPlan()!; + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe', 'xcode-ide'])); + + await openStdioContext(registrations); + expect(bridgeMocks.setWorkflowEnabled).toHaveBeenLastCalledWith(true); + + await handle!.close(); + await peer!.close(); + handle = null; + peer = null; + + bridgeMocks.bindServer.mockClear(); + bridgeMocks.setWorkflowEnabled.mockClear(); + + // manage_workflows disables xcode-ide, then a new connection opens. + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe'])); + await openStdioContext(registrations); + + expect(bridgeMocks.bindServer).not.toHaveBeenCalled(); + expect(bridgeMocks.setWorkflowEnabled).toHaveBeenCalledWith(false); + }); + + it('never creates the bridge manager just to disable it', async () => { + bridgeMocks.peekXcodeToolsBridgeManager.mockReturnValue(null); + + const registrations = createTestRegistrations(); + setCurrentTestPlan(planWithWorkflows(registrations.resolveToolPlan()!, ['probe'])); + + await openStdioContext(registrations); + + expect(bridgeMocks.getXcodeToolsBridgeManager).not.toHaveBeenCalled(); + expect(bridgeMocks.setWorkflowEnabled).not.toHaveBeenCalled(); + }); + + it('keeps HTTP contexts out of bridge ownership in both directions', async () => { + const registrations = createTestRegistrations(); + const bootPlan = registrations.resolveToolPlan()!; + + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe', 'xcode-ide'])); + const enabledHandler = createMcpHttpHandler(registrations); + await httpToolsList(enabledHandler); + await enabledHandler.close(); + + setCurrentTestPlan(planWithWorkflows(bootPlan, ['probe'])); + const disabledHandler = createMcpHttpHandler(registrations); + await httpToolsList(disabledHandler); + await disabledHandler.close(); + + expect(bridgeMocks.getXcodeToolsBridgeManager).not.toHaveBeenCalled(); + expect(bridgeMocks.peekXcodeToolsBridgeManager).not.toHaveBeenCalled(); + expect(bridgeMocks.bindServer).not.toHaveBeenCalled(); + expect(bridgeMocks.setWorkflowEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/__tests__/serving-test-fixtures.ts b/src/server/__tests__/serving-test-fixtures.ts index 47d2caa88..531e9f0f6 100644 --- a/src/server/__tests__/serving-test-fixtures.ts +++ b/src/server/__tests__/serving-test-fixtures.ts @@ -73,12 +73,15 @@ export function secondTestToolPlanEntry(): McpToolRegistrationPlan['tools'][numb return probeToolPlanEntry(SECOND_TEST_TOOL_NAME); } -function buildTestToolPlan(mcpNames: string[]): McpToolRegistrationPlan { +function buildTestToolPlan( + mcpNames: string[], + workflows: string[] = ['probe'], +): McpToolRegistrationPlan { return { tools: mcpNames.map((name) => probeToolPlanEntry(name)), catalog: createToolCatalog(mcpNames.map((name) => probeToolDefinition(name))), - enabledWorkflows: new Set(['probe']), - workflowLabel: 'probe', + enabledWorkflows: new Set(workflows), + workflowLabel: workflows.join(', '), }; } @@ -95,6 +98,23 @@ export function setCurrentTestPlan(plan: McpToolRegistrationPlan | null): void { currentTestPlan = plan; } +/** + * Rewrites the enabled workflows of the current plan, keeping its tools. + * + * Stands in for a `manage_workflows` call that turns a workflow on or off. + */ +export function setCurrentTestWorkflows(workflows: string[]): void { + const plan = currentTestPlan; + if (!plan) { + throw new Error('No current test plan; call createTestRegistrations() first.'); + } + currentTestPlan = { + ...plan, + enabledWorkflows: new Set(workflows), + workflowLabel: workflows.join(', '), + }; +} + /** The plan the fixture registrations currently resolve. */ export function getCurrentTestPlan(): McpToolRegistrationPlan | null { return currentTestPlan; @@ -128,7 +148,6 @@ export function createTestRegistrations( return { resolveToolPlan: () => currentTestPlan, resources, - xcodeIdeEnabled: false, ...overrides, }; } diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 1d9e62249..e19515501 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -5,13 +5,15 @@ import { log, normalizeLogLevel, setLogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; import { applyToolPlanToServer, - getRegisteredWorkflows, getToolRegistrationPlan, registerWorkflowsFromManifest, type McpToolRegistrationPlan, } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; -import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; +import { + getXcodeToolsBridgeManager, + peekXcodeToolsBridgeManager, +} from '../integrations/xcode-tools-bridge/index.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; import { sessionStore } from '../utils/session-store.ts'; @@ -44,10 +46,20 @@ export interface ServerRegistrations { * afterwards must serve the new selection. Capturing the plan at startup * would make every later context replay the boot-time workflows and silently * undo the client's selection. + * + * Every workflow-derived decision a serving context makes - which tools to + * register, and whether the Xcode tools bridge is active - is resolved from + * this one plan, so the two can never disagree. */ resolveToolPlan: () => McpToolRegistrationPlan | null; resources: Map; - xcodeIdeEnabled: boolean; +} + +const XCODE_IDE_WORKFLOW_ID = 'xcode-ide'; + +/** Whether the given plan has the xcode-ide workflow enabled. */ +export function planEnablesXcodeIde(plan: McpToolRegistrationPlan | null): boolean { + return plan?.enabledWorkflows.has(XCODE_IDE_WORKFLOW_ID) ?? false; } export interface BootstrapResult { @@ -124,11 +136,23 @@ export function applyServerRegistrations( registerLoadedResources(server, registrations.resources); - if (registrations.xcodeIdeEnabled && options.bindXcodeToolsBridge !== false) { + if (options.bindXcodeToolsBridge === false) { + // A per-request context never owns the process-level bridge, in either + // direction: it must neither take the binding nor disable a bridge the live + // connection still owns. + return; + } + + if (planEnablesXcodeIde(toolPlan)) { const xcodeToolsBridge = getXcodeToolsBridgeManager(server); xcodeToolsBridge?.bindServer(server); xcodeToolsBridge?.setWorkflowEnabled(true); + return; } + + // The workflow is off for this context. Only tell an existing manager to + // stand down - never construct one just to disable it. + peekXcodeToolsBridgeManager()?.setWorkflowEnabled(false); } /** @@ -190,9 +214,6 @@ export async function bootstrapServerRuntime( await registerWorkflowsFromManifest(enabledWorkflows, ctx); profiler.mark('registerWorkflowsFromManifest', stageStartMs); - const resolvedWorkflows = getRegisteredWorkflows(); - const xcodeIdeEnabled = resolvedWorkflows.includes('xcode-ide'); - stageStartMs = getStartupProfileNowMs(); const resources = await loadResources(ctx); profiler.mark('loadResources', stageStartMs); @@ -201,7 +222,6 @@ export async function bootstrapServerRuntime( registrations: { resolveToolPlan: getToolRegistrationPlan, resources, - xcodeIdeEnabled, }, runDeferredInitialization: async (options = {}): Promise => { const deferredProfiler = createStartupProfiler('bootstrap-deferred'); From 63c7bed85e148f71cd4fdf13528979bf693af63c Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:19:07 +0530 Subject: [PATCH 6/7] fix(mcp): make xcode-ide workflow transitions atomic and drop empty Sentry capabilities Three follow-ups from the review of 92b8abd1. Re-enabling the workflow left `suppressListChangedSync` set by the preceding disable, so `onToolCatalogInvalidated` returned early forever and the proxied catalogue never refreshed again unless something happened to run a manual sync. Enabling now clears the suppression, and `setWorkflowEnabled` short-circuits when the state is unchanged so repeated calls cannot double-clear the registry. A disable racing an in-flight sync could resurrect the tools it had just torn down: `syncTools` awaits bridge availability and then the remote tool list, and nothing revalidated the workflow after either await, so a sync started before the disable still reached `registry.sync`. Every transition - enable, disable and manual disconnect - now advances a workflow epoch, the sync captures it and revalidates after each await, and an in-flight sync is only shared with callers from the same epoch. A stale completion returns a zeroed result and touches neither the registry nor the outbound notification. `clientCapabilities` is an array, and an empty array is truthy, so the Sentry `mcp.protocol` context published `clientCapabilities: ''` for every client that declares none - which is the common case on the modern era. The payload is now built by a pure `buildMcpProtocolContextPayload` that omits the member unless it has entries, which also makes the emission unit-testable given the surrounding function is a no-op under test. All seven new regressions verified failing before the fix: re-enable restores invalidation-driven syncing, repeated transitions keep working, disable during a delayed sync leaves zero tools registered (both awaits covered), a manual disconnect invalidates the same way, a later re-enable syncs the fresh catalogue, and empty capabilities are omitted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/manager.test.ts | 151 ++++++++++++++++++ .../xcode-tools-bridge/manager.ts | 124 ++++++++++---- .../sentry-mcp-protocol-context.test.ts | 59 +++++++ src/utils/sentry.ts | 32 ++-- 4 files changed, 322 insertions(+), 44 deletions(-) create mode 100644 src/utils/__tests__/sentry-mcp-protocol-context.test.ts diff --git a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts index 82f0f1313..13b48bca7 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts @@ -190,4 +190,155 @@ describe('XcodeToolsBridgeManager', () => { expect(syncSpy).not.toHaveBeenCalled(); }); + + it('resumes listChanged-driven syncs after the workflow is re-enabled', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + manager.setWorkflowEnabled(false); + manager.setWorkflowEnabled(true); + + const syncSpy = vi.spyOn(manager, 'syncTools'); + onToolCatalogInvalidatedRef.current?.(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(syncSpy).toHaveBeenCalledWith({ reason: 'listChanged' }); + }); + + it('survives repeated enable/disable transitions without losing invalidation', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + for (let round = 0; round < 3; round += 1) { + manager.setWorkflowEnabled(true); + manager.setWorkflowEnabled(false); + } + manager.setWorkflowEnabled(true); + + const syncSpy = vi.spyOn(manager, 'syncTools'); + onToolCatalogInvalidatedRef.current?.(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(syncSpy).toHaveBeenCalledWith({ reason: 'listChanged' }); + expect(registryMocks.clear).toHaveBeenCalledTimes(3); + }); + + it('discards an in-flight sync whose workflow was disabled mid-flight', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + let releaseListTools: ((tools: Tool[]) => void) | undefined; + serviceMocks.listTools.mockReturnValue( + new Promise((resolve) => { + releaseListTools = resolve; + }), + ); + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + + const syncPromise = manager.syncTools({ reason: 'manual' }); + await Promise.resolve(); + + // Disable lands while the remote tool list is still being fetched. + manager.setWorkflowEnabled(false); + + releaseListTools?.([{ name: 'remote.tool', inputSchema: { type: 'object' } }]); + const result = await syncPromise; + + expect(registryMocks.sync).not.toHaveBeenCalled(); + expect(result).toEqual({ added: 0, updated: 0, removed: 0, total: 0 }); + }); + + it('discards an in-flight sync disabled before bridge availability resolves', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + let releaseAvailability: ((value: { available: boolean; path: string }) => void) | undefined; + getMcpBridgeAvailabilityMock.mockReturnValue( + new Promise((resolve) => { + releaseAvailability = resolve; + }), + ); + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + + const syncPromise = manager.syncTools({ reason: 'manual' }); + await Promise.resolve(); + + manager.setWorkflowEnabled(false); + + releaseAvailability?.({ available: true, path: '/usr/bin/mcpbridge' }); + const result = await syncPromise; + + expect(serviceMocks.listTools).not.toHaveBeenCalled(); + expect(registryMocks.sync).not.toHaveBeenCalled(); + expect(result).toEqual({ added: 0, updated: 0, removed: 0, total: 0 }); + }); + + it('discards an in-flight sync invalidated by a manual disconnect', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + let releaseListTools: ((tools: Tool[]) => void) | undefined; + serviceMocks.listTools.mockReturnValue( + new Promise((resolve) => { + releaseListTools = resolve; + }), + ); + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + + const syncPromise = manager.syncTools({ reason: 'manual' }); + await Promise.resolve(); + + await manager.disconnect(); + + releaseListTools?.([{ name: 'remote.tool', inputSchema: { type: 'object' } }]); + await syncPromise; + + expect(registryMocks.sync).not.toHaveBeenCalled(); + }); + + it('syncs normally once the workflow is re-enabled after a disabled sync', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + let releaseListTools: ((tools: Tool[]) => void) | undefined; + serviceMocks.listTools.mockReturnValueOnce( + new Promise((resolve) => { + releaseListTools = resolve; + }), + ); + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + const stale = manager.syncTools({ reason: 'manual' }); + await Promise.resolve(); + manager.setWorkflowEnabled(false); + releaseListTools?.([{ name: 'stale.tool', inputSchema: { type: 'object' } }]); + await stale; + expect(registryMocks.sync).not.toHaveBeenCalled(); + + const freshTools: Tool[] = [{ name: 'fresh.tool', inputSchema: { type: 'object' } }]; + serviceMocks.listTools.mockResolvedValue(freshTools); + manager.setWorkflowEnabled(true); + await manager.syncTools({ reason: 'manual' }); + + expect(registryMocks.sync).toHaveBeenCalledTimes(1); + expect(registryMocks.sync).toHaveBeenCalledWith(freshTools, expect.any(Function)); + }); }); diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index c05c4c533..abb3b1c5f 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -23,8 +23,10 @@ export class XcodeToolsBridgeManager { private readonly service: XcodeIdeToolService; private workflowEnabled = false; + private workflowEpoch = 0; private lastError: string | null = null; private syncInFlight: Promise | null = null; + private syncInFlightEpoch = -1; private suppressListChangedSync = false; constructor(server: McpServer) { @@ -62,16 +64,41 @@ export class XcodeToolsBridgeManager { * off must mean the `xcode_tools_*` tools are gone, otherwise a serving * context built after a `manage_workflows` disable would keep serving tools * the client just turned off. + * + * Every transition advances the workflow epoch, which invalidates any sync + * that is already in flight so its result can never be written back after the + * workflow changed underneath it. */ setWorkflowEnabled(enabled: boolean): void { const wasEnabled = this.workflowEnabled; + if (wasEnabled === enabled) { + return; + } + this.workflowEnabled = enabled; this.service.setWorkflowEnabled(enabled); + this.workflowEpoch += 1; - if (!enabled && wasEnabled) { - this.suppressListChangedSync = true; - this.registry.clear(); + if (enabled) { + // Re-enabling must restore listChanged-driven syncing; otherwise the + // suppression set by the preceding disable would silently survive and + // the proxied catalogue would never refresh again. + this.suppressListChangedSync = false; + return; } + + this.suppressListChangedSync = true; + this.registry.clear(); + } + + /** + * Whether a sync started at `epoch` may still write to the registry. + * + * A sync performs two awaits before it registers anything, and the workflow + * can be disabled during either of them. + */ + private isSyncStale(epoch: number): boolean { + return epoch !== this.workflowEpoch || !this.workflowEnabled; } async shutdown(): Promise { @@ -99,45 +126,69 @@ export class XcodeToolsBridgeManager { this.suppressListChangedSync = false; } - if (this.syncInFlight) return this.syncInFlight; + const epoch = this.workflowEpoch; - this.syncInFlight = (async (): Promise => { - const bridge = await getMcpBridgeAvailability(); - if (!bridge.available) { - this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; - const existingCount = this.registry.getRegisteredCount(); - this.registry.clear(); - this.server.sendToolListChanged(); - return { added: 0, updated: 0, removed: existingCount, total: 0 }; - } + // Only share an in-flight sync with callers from the same epoch. A sync + // started before a disable/enable cycle answers for a catalogue the caller + // is no longer asking about. + if (this.syncInFlight && this.syncInFlightEpoch === epoch) { + return this.syncInFlight; + } - try { - const remoteTools = await this.service.listTools({ refresh: true }); + const noop: ProxySyncResult = { added: 0, updated: 0, removed: 0, total: 0 }; - const sync = this.registry.sync(remoteTools, async (remoteName, args) => { - return this.service.invokeTool(remoteName, args); - }); + this.syncInFlightEpoch = epoch; + this.syncInFlight = (async (): Promise => { + try { + const bridge = await getMcpBridgeAvailability(); + if (this.isSyncStale(epoch)) { + return noop; + } - if (opts.reason !== 'listChanged') { - log( - 'info', - `[xcode-ide] Synced proxied tools (added=${sync.added}, updated=${sync.updated}, removed=${sync.removed}, total=${sync.total})`, - ); + if (!bridge.available) { + this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; + const existingCount = this.registry.getRegisteredCount(); + this.registry.clear(); + this.server.sendToolListChanged(); + return { added: 0, updated: 0, removed: existingCount, total: 0 }; } - this.lastError = null; - this.server.sendToolListChanged(); - - return sync; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.lastError = message; - log('warn', `[xcode-ide] Tool sync failed: ${message}`); - this.registry.clear(); - this.server.sendToolListChanged(); - return { added: 0, updated: 0, removed: 0, total: 0 }; + try { + const remoteTools = await this.service.listTools({ refresh: true }); + if (this.isSyncStale(epoch)) { + return noop; + } + + const sync = this.registry.sync(remoteTools, async (remoteName, args) => { + return this.service.invokeTool(remoteName, args); + }); + + if (opts.reason !== 'listChanged') { + log( + 'info', + `[xcode-ide] Synced proxied tools (added=${sync.added}, updated=${sync.updated}, removed=${sync.removed}, total=${sync.total})`, + ); + } + + this.lastError = null; + this.server.sendToolListChanged(); + + return sync; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (this.isSyncStale(epoch)) { + return noop; + } + this.lastError = message; + log('warn', `[xcode-ide] Tool sync failed: ${message}`); + this.registry.clear(); + this.server.sendToolListChanged(); + return noop; + } } finally { - this.syncInFlight = null; + if (this.syncInFlightEpoch === epoch) { + this.syncInFlight = null; + } } })(); @@ -146,6 +197,9 @@ export class XcodeToolsBridgeManager { async disconnect(): Promise { this.suppressListChangedSync = true; + // A manual disconnect is a catalogue transition too: invalidate any sync + // already in flight so it cannot re-register the tools just torn down. + this.workflowEpoch += 1; this.registry.clear(); this.server.sendToolListChanged(); await this.service.disconnect(); diff --git a/src/utils/__tests__/sentry-mcp-protocol-context.test.ts b/src/utils/__tests__/sentry-mcp-protocol-context.test.ts new file mode 100644 index 000000000..512fe0c72 --- /dev/null +++ b/src/utils/__tests__/sentry-mcp-protocol-context.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildMcpProtocolContextPayload } from '../sentry.ts'; + +describe('buildMcpProtocolContextPayload', () => { + it('always reports the era and protocol version', () => { + expect( + buildMcpProtocolContextPayload({ era: 'modern', protocolVersion: '2026-07-28' }), + ).toEqual({ + era: 'modern', + protocolVersion: '2026-07-28', + }); + }); + + it('omits client capabilities when the client declares none', () => { + const payload = buildMcpProtocolContextPayload({ + era: 'modern', + protocolVersion: '2026-07-28', + clientName: 'probe', + clientVersion: '1.0.0', + clientCapabilities: [], + }); + + expect(payload).not.toHaveProperty('clientCapabilities'); + expect(payload).toEqual({ + era: 'modern', + protocolVersion: '2026-07-28', + clientName: 'probe', + clientVersion: '1.0.0', + }); + }); + + it('omits client capabilities when they are not provided', () => { + expect( + buildMcpProtocolContextPayload({ era: 'legacy', protocolVersion: '2025-06-18' }), + ).not.toHaveProperty('clientCapabilities'); + }); + + it('joins declared client capabilities', () => { + expect( + buildMcpProtocolContextPayload({ + era: 'modern', + protocolVersion: '2026-07-28', + clientCapabilities: ['elicitation', 'experimental'], + }).clientCapabilities, + ).toBe('elicitation,experimental'); + }); + + it('omits empty client name and version rather than emitting blanks', () => { + const payload = buildMcpProtocolContextPayload({ + era: 'modern', + protocolVersion: '2026-07-28', + clientName: '', + clientVersion: '', + }); + + expect(payload).not.toHaveProperty('clientName'); + expect(payload).not.toHaveProperty('clientVersion'); + }); +}); diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index 42d0b277b..54622dcb5 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -653,6 +653,28 @@ export interface McpProtocolContext { clientCapabilities?: string[]; } +/** + * Builds the `mcp.protocol` Sentry context payload. + * + * Optional members are omitted rather than emitted empty. `clientCapabilities` + * in particular is an array, and an empty array is truthy, so a presence check + * alone would publish `clientCapabilities: ''` for every client that declares + * no capabilities - which is the common case on the modern era. + */ +export function buildMcpProtocolContextPayload( + context: McpProtocolContext, +): Record { + return { + era: context.era, + protocolVersion: context.protocolVersion, + ...(context.clientName ? { clientName: context.clientName } : {}), + ...(context.clientVersion ? { clientVersion: context.clientVersion } : {}), + ...(context.clientCapabilities && context.clientCapabilities.length > 0 + ? { clientCapabilities: context.clientCapabilities.join(',') } + : {}), + }; +} + /** * Records the negotiated protocol identity for the current process. * @@ -670,15 +692,7 @@ export function setMcpProtocolContext(context: McpProtocolContext): void { Sentry.setTag('mcp.protocol_version', context.protocolVersion); setTagIfDefined('mcp.client.name', context.clientName); setTagIfDefined('mcp.client.version', context.clientVersion); - Sentry.setContext('mcp.protocol', { - era: context.era, - protocolVersion: context.protocolVersion, - ...(context.clientName ? { clientName: context.clientName } : {}), - ...(context.clientVersion ? { clientVersion: context.clientVersion } : {}), - ...(context.clientCapabilities - ? { clientCapabilities: context.clientCapabilities.join(',') } - : {}), - }); + Sentry.setContext('mcp.protocol', buildMcpProtocolContextPayload(context)); } catch { // Observability enrichment is best effort and must never affect serving. } From ad5f56d4567d7dfd8d9be79239d379524378a222 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:45:35 +0530 Subject: [PATCH 7/7] test(mcp): use the canonical structured-output contract in serving fixtures The serving-layer fixture emitted `schema: 'bundle-id'` with `schemaVersion: '1.0.0'`. Neither matches the repo contract: real tools emit the fully-qualified identifier and an integer-string version - for this kind, `setBundleIdStructuredOutput` emits `xcodebuildmcp.output.bundle-id` at version `2` - and every snapshot fixture agrees. That mattered because these tests assert the MCP `structuredContent` envelope end to end through a fresh SDK v2 server, so they were attesting to an envelope the published contract rejects. The `2.schema.json` pins both members with `const`, and validating the two envelopes against it confirms it: before -> INVALID: /schema must be equal to constant, /schemaVersion must be equal to constant after -> VALID The fixture now exports `PROBE_SCHEMA` / `PROBE_SCHEMA_VERSION` sourced from the same `BUNDLE_ID_STRUCTURED_OUTPUT_SCHEMA` constant production uses, so the two cannot drift again, and the one assertion that hard-coded the identifier now checks the version too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/mcp-modern-protocol.test.ts | 5 ++++- src/server/__tests__/serving-test-fixtures.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/server/__tests__/mcp-modern-protocol.test.ts b/src/server/__tests__/mcp-modern-protocol.test.ts index a82e1ce37..475a7ef4f 100644 --- a/src/server/__tests__/mcp-modern-protocol.test.ts +++ b/src/server/__tests__/mcp-modern-protocol.test.ts @@ -7,6 +7,8 @@ import { __resetToolRegistryForTests } from '../../utils/tool-registry.ts'; import { __resetMcpInstrumentationForTests } from '../mcp-instrumentation.ts'; import { createTestRegistrations, + PROBE_SCHEMA, + PROBE_SCHEMA_VERSION, TEST_RESOURCE_URI, TEST_TOOL_NAME, } from './serving-test-fixtures.ts'; @@ -109,7 +111,8 @@ describe('MCP 2026-07-28 modern era serving', () => { expect(response.error).toBeUndefined(); expect(response.result?.structuredContent).toMatchObject({ - schema: 'bundle-id', + schema: PROBE_SCHEMA, + schemaVersion: PROBE_SCHEMA_VERSION, didError: false, data: { artifacts: { bundleId: 'echo:direct' } }, }); diff --git a/src/server/__tests__/serving-test-fixtures.ts b/src/server/__tests__/serving-test-fixtures.ts index 531e9f0f6..74d4a02cc 100644 --- a/src/server/__tests__/serving-test-fixtures.ts +++ b/src/server/__tests__/serving-test-fixtures.ts @@ -3,6 +3,7 @@ import type { ToolSchemaShape } from '../../core/plugin-types.ts'; import type { ToolHandlerContext } from '../../rendering/types.ts'; import type { ServerRegistrations } from '../bootstrap.ts'; import type { ResourceMeta } from '../../core/resources.ts'; +import { BUNDLE_ID_STRUCTURED_OUTPUT_SCHEMA } from '../../utils/app-query-results.ts'; import { createToolCatalog } from '../../runtime/tool-catalog.ts'; import type { McpToolRegistrationPlan } from '../../utils/tool-registry.ts'; import type { ToolDefinition } from '../../runtime/types.ts'; @@ -11,6 +12,19 @@ export const TEST_TOOL_NAME = 'probe_echo'; export const SECOND_TEST_TOOL_NAME = 'probe_second'; export const TEST_RESOURCE_URI = 'xcodebuildmcp://probe'; +/** + * The structured-output contract the probe tool emits. + * + * Serving-layer tests assert the MCP `structuredContent` envelope end to end, so + * the fixture must emit the same identifier and version a real tool does + * (`setBundleIdStructuredOutput`). The published + * `xcodebuildmcp.output.bundle-id/2.schema.json` pins both fields with `const`, + * so an ad-hoc identifier or a semver-shaped version would make these tests + * attest to an envelope the contract rejects. + */ +export const PROBE_SCHEMA = BUNDLE_ID_STRUCTURED_OUTPUT_SCHEMA; +export const PROBE_SCHEMA_VERSION = '2'; + const probeSchema = z.object({ text: z.string() }) as unknown as ToolSchemaShape; function probeHandler(params: Record, ctx?: ToolHandlerContext): Promise { @@ -25,8 +39,8 @@ function probeHandler(params: Record, ctx?: ToolHandlerContext) bundleId: `echo:${String(params.text ?? '')}`, }, }, - schema: 'bundle-id', - schemaVersion: '1.0.0', + schema: BUNDLE_ID_STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: PROBE_SCHEMA_VERSION, }; } return Promise.resolve(undefined);