diff --git a/docs/design/mcp-2026-core-client-foundation.md b/docs/design/mcp-2026-core-client-foundation.md new file mode 100644 index 00000000000..b883c6b1780 --- /dev/null +++ b/docs/design/mcp-2026-core-client-foundation.md @@ -0,0 +1,143 @@ +# MCP 2026 core client foundation + +## Context + +Qwen Code's configured MCP sessions currently use the v1 TypeScript SDK. A +server that only implements the MCP `2026-07-28` stateless protocol cannot +complete the legacy `initialize` handshake, while unconditionally switching to +the modern protocol would break existing servers. + +The official TypeScript SDK v2 already owns the wire-level compatibility +logic: `server/discover` negotiation, legacy fallback, per-request metadata and +HTTP headers, pagination, and cache-hint handling. Qwen Code should configure +that behavior rather than duplicate it. + +## Scope + +This slice of #8968 migrates configured MCP sessions to the v2 client, adds +opt-in automatic protocol negotiation for stdio sessions, and adds the first +MCP Apps host for daemon-backed WebShell sessions. Tool, prompt, resource-list, +and resource-read operations use the v2 cache-aware helpers when the negotiated +protocol is modern. + +Remote HTTP / SSE / TCP clients stay on `versionNegotiation.mode = 'legacy'`. +SDK v2 rejects HTTP `server/discover` probe timeouts with no `initialize` +fallback, so auto-negotiation would drop working remote servers that ignore +unknown pre-initialize methods. Connecting to a 2026-07-28-only remote server +is deferred until that SDK gap closes. + +The following remain separate follow-ups: + +- modern-only remote (HTTP / SSE / TCP) protocol negotiation; +- interactive MRTR elicitation and approval across TUI, WebShell, headless, and + ACP; +- MCP App initiated tool calls, links, downloads, messages, model-context + updates, and fullscreen display; +- migration of Qwen Code's internal IDE, Computer Use, and embedded MCP server + integrations, which are not configured external MCP sessions. + +## Design + +Configured stdio MCP clients default to `versionNegotiation.mode = 'legacy'`. +Setting `versionNegotiation: "auto"` opts a server into a `server/discover` +probe capped at 5s, and further shortened so the probe plus initialize fallback +still fit inside `discoveryTimeoutMs` (the discovery window clamp is +`[100ms, 300s]`; a budget that cannot cover both steps skips the probe and uses +`legacy`). Definitive modern evidence selects the stateless `2026-07-28` +protocol; legacy evidence — including a silent stdio server that never answers +the probe — falls back to the unchanged `initialize` flow. + +The SDK performs opt-in stdio auto-negotiation on a disposable sibling process +before starting the session process, so the configured command runs twice per +connection. The default legacy policy skips the probe and retains the +single-process initialize flow for servers with non-idempotent startup side +effects or single-owner resources such as lockfiles. + +Remote HTTP / SSE / TCP clients use `versionNegotiation.mode = 'legacy'` and +never send `server/discover`. + +Modern sessions use the typed v2 list/read methods so the SDK can aggregate +pagination and honor `ttlMs` and `cacheScope`. Legacy sessions keep Qwen Code's +raw request path for prompts and resources because it intentionally tolerates +older servers that expose methods without declaring the matching capability. + +Tool discovery uses the single cache-aware `tools/list` result for both schema +registration and annotations. Tool execution continues through the raw client +so progress, cancellation, timeout, permission checks, and output handling stay +inside the existing Qwen Code path. + +Configured clients advertise the `io.modelcontextprotocol/ui` extension and +the `text/html;profile=mcp-app` resource type. When a server also advertises +that extension, tool discovery preserves its `ui://` resource URI. After a +successful call, Qwen Code reads and validates the matching HTML resource and +stores it in a structured display result while leaving the model-visible result +unchanged. A missing, oversized, malformed, or unreadable resource falls back +to the normal text result. + +The daemon serves a static sandbox proxy before bearer authentication. It +contains no session data or credentials. WebShell loads that proxy in an +outer iframe that omits `allow-same-origin`, so even a same-URL `localhost` +load is an opaque origin and cannot read WebShell `sessionStorage`. When the +daemon is already on `127.0.0.1` or `[::1]`, the host also swaps onto +`localhost` for a second loopback origin. AppBridge and postMessage deliver +the validated HTML, tool input, and tool result to an inner sandboxed iframe. +The proxy validates parent and child origins, applies resource CSP as an HTTP +response header, and forwards AppBridge postMessage traffic between the two +frames. The host AppBridge schema-validates inbound messages; the proxy itself +does not filter payload shape. The inner App iframe also omits +`allow-same-origin`, giving untrusted HTML an opaque origin that cannot call +the daemon's loopback API as a same-origin client. The first host slice does +not advertise privileged App capabilities. + +## Compatibility and safety + +- No configured server is pinned to the modern protocol. +- Configured stdio servers use the single-process legacy flow by default and + can opt into the extra negotiation process with `versionNegotiation: "auto"`. +- Legacy fallback remains the SDK's byte-compatible v1 sequence. +- Authorization and Qwen Code's MCP permission boundary are unchanged. +- The modern cache is private per client instance; no result is shared across + workspaces or authorization principals. +- MCP App HTML is limited to 1 MiB and never enters model context. +- App HTML runs in a double-iframe sandbox. Both frames omit + `allow-same-origin`, and the outer frame additionally uses a different + loopback origin when one is available. Server-declared CSP is enforced by + the daemon response. +- If the isolation origin is unavailable, WebShell displays the ordinary tool + text rather than rendering the App. +- Compacted session history keeps `type: 'mcp_app'` with empty `html` and the + original `fallbackText`; WebShell renders that text instead of mounting an + empty sandbox. +- The host sends `ui/resource-teardown` and waits for it to settle before + unloading the sandbox iframe. + +## Verification + +- A modern-only control transport must connect through `server/discover`, list + and call a tool without `initialize`, and carry the modern request metadata. +- A real Streamable HTTP transport uses the legacy `initialize` handshake and + must still send the protocol and method headers, plus the tool name header + on `tools/call`. Modern-only remote negotiation is out of scope. +- A legacy control transport must fall back to `initialize` and retain existing + discovery and call behavior. +- A cache-hinted modern list result must be reused without a second wire + request. +- A mock stdio MCP server must advertise the Apps extension, return a `ui://` + dashboard resource, and render that dashboard inside an actual daemon-backed + WebShell transcript. The PR description includes the external test fixture + used for this verification without shipping it in the product repository. +- Compacted replay of an App result must show fallback text and must not mount + a sandbox iframe. +- Invalid App resource MIME types and unavailable resources must retain the + ordinary text result. +- The sandbox route must reject CSP directive injection and remain a static, + no-store pre-auth resource. +- Existing MCP client, transport-pool, tool, OAuth, and resource tests must + continue to pass, followed by the repository build and typecheck. + +## Demo + +The external stdio demo used for verification advertises one +`show_revenue_dashboard` tool and its `ui://revenue-dashboard` resource. Its +reference implementation and daemon configuration are included in the PR +description. diff --git a/docs/developers/daemon/05-mcp-transport-pool.md b/docs/developers/daemon/05-mcp-transport-pool.md index 7374e25e54a..65c1db8193f 100644 --- a/docs/developers/daemon/05-mcp-transport-pool.md +++ b/docs/developers/daemon/05-mcp-transport-pool.md @@ -315,11 +315,12 @@ ordering. The pool key comes from `fingerprint(cfg)` in `mcp-pool-key.ts`. The hash covers all transport-defining fields: -> `transport, command, args, cwd, env, url, httpUrl, tcp, headers, timeout, oauth` +> `transport, command, args, cwd, env, url, httpUrl, tcp, headers, timeout, versionNegotiation, oauth` Per-session filtering and metadata fields (`includeTools`, `excludeTools`, `trust`, `description`, `extensionName`, `discoveryTimeoutMs`) are excluded, so -sessions with different filters can share one entry. +sessions with different filters can share one entry. The automatic negotiation +opt-in is included because it changes how the underlying process connects. For the OAuth cell, `canonicalOAuth(o)` hashes every `MCPOAuthConfig` field: `clientId`, `clientSecret`, sorted `scopes`, sorted `audiences`, diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index 519c0f7a6a3..d82f2fe10fc 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -116,6 +116,7 @@ Each server configuration supports the following properties: - **`env`** (object): Environment variables for the server process. Values can reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax - **`cwd`** (string): Working directory for Stdio transport - **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = 10 minutes) +- **`versionNegotiation`** (`"auto" | "legacy"`, default: `"legacy"`): For Stdio servers, `"auto"` opts into the `server/discover` probe on a disposable sibling process. - **`trust`** (boolean): When `true`, bypasses tool call confirmations for this server in a trusted workspace (default: `false`) - **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 3c58396ee4b..2fcbffd5960 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -261,6 +261,28 @@ The existing `timeout` field is **tool-call** timeout (used for each `discoveryTimeoutMs` — a long-running tool invocation is not a startup pathology. +### Automatic stdio negotiation + +Stdio servers use the single-process legacy initialize flow by default. To +connect to a modern-only stdio server, opt into automatic protocol negotiation: + +```jsonc +{ + "mcpServers": { + "modern-server": { + "command": "node", + "args": ["./server.js"], + "versionNegotiation": "auto", + }, + }, +} +``` + +Automatic negotiation runs a short-lived copy of the configured server before +starting the session process and can use up to five seconds of the discovery +budget. Keep the default legacy policy for servers with non-idempotent startup +side effects, single-owner locks or PID files, or slow initialize handshakes. + ### Rolling back progressive MCP If you need the old synchronous behavior (cli waits for every MCP server @@ -454,18 +476,19 @@ Required (one of the following): Optional: -| Property | Type/Default | Description | -| ---------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `args` | array | Command-line arguments for Stdio transport | -| `headers` | object | Custom HTTP headers when using `url` or `httpUrl` | -| `env` | object | Environment variables for the server process. Values can reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax | -| `cwd` | string | Working directory for Stdio transport | -| `timeout` | number
(default: 600,000) | Request timeout in milliseconds (default: 600,000ms = 10 minutes) | -| `trust` | boolean
(default: false) | When `true`, bypasses tool call confirmations for this server in a trusted workspace (default: `false`) | -| `includeTools` | array | List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. | -| `excludeTools` | array | List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server.
Note: `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. | -| `targetAudience` | string | The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. | -| `targetServiceAccount` | string | The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`. | +| Property | Type/Default | Description | +| ---------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `args` | array | Command-line arguments for Stdio transport | +| `headers` | object | Custom HTTP headers when using `url` or `httpUrl` | +| `env` | object | Environment variables for the server process. Values can reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax | +| `cwd` | string | Working directory for Stdio transport | +| `timeout` | number
(default: 600,000) | Request timeout in milliseconds (default: 600,000ms = 10 minutes) | +| `versionNegotiation` | `"auto" \| "legacy"`
(default: `"legacy"`) | For Stdio servers, `"auto"` opts into protocol negotiation on a disposable sibling process. The default `"legacy"` starts only the session process. | +| `trust` | boolean
(default: false) | When `true`, bypasses tool call confirmations for this server in a trusted workspace (default: `false`) | +| `includeTools` | array | List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. | +| `excludeTools` | array | List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server.
Note: `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. | +| `targetAudience` | string | The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. | +| `targetServiceAccount` | string | The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`. | diff --git a/package-lock.json b/package-lock.json index e8e6e75dccc..e37f0360164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3831,6 +3831,83 @@ "node": ">=18" } }, + "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": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/client/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -7634,6 +7711,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@storybook/addon-a11y": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.0.tgz", @@ -29784,6 +29867,8 @@ "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "2.6.0", "@iarna/toml": "^2.2.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", @@ -32844,6 +32929,7 @@ "@datafe-open/markdown-chart": "^0.1.12", "@datafe-open/markdown-chart-echarts": "^0.1.12", "@datafe-open/markdown-chart-react": "^0.1.12", + "@modelcontextprotocol/ext-apps": "^1.7.5", "@tanstack/react-virtual": "^3.13.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 3276722c7f7..4f3e733d65c 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11040,13 +11040,21 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agent.extMethod('qwen/settings/setMcpServer', { scope: 'user', name: 'local', - server: { transport: 'stdio', command: 'node', args: ['server.js'] }, + server: { + transport: 'stdio', + command: 'node', + args: ['server.js'], + versionNegotiation: 'auto', + }, }); expect(settings.setValue).toHaveBeenCalledWith( 'User', 'mcpServers', expect.objectContaining({ - local: expect.objectContaining({ command: 'node' }), + local: expect.objectContaining({ + command: 'node', + versionNegotiation: 'auto', + }), }), ); @@ -11054,6 +11062,26 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/settings/setMcpServer rejects invalid version negotiation', async () => { + const settings = makeCoreSettings(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await expect( + agent.extMethod('qwen/settings/setMcpServer', { + scope: 'user', + name: 'invalid-negotiation', + server: { + transport: 'stdio', + command: 'node', + versionNegotiation: 'modern', + }, + }), + ).rejects.toThrowError(/MCP versionNegotiation must be auto or legacy/); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/settings/setMcpServer restores redacted secrets instead of persisting the sentinel', async () => { const settings = makeCoreSettings(); (settings.user.settings as Record)['mcpServers'] = { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 5dfeaa1ae4b..4e0d67e3a80 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -1298,6 +1298,7 @@ type QwenMcpServerConfig = { url?: string; headers?: Record; timeout?: number; + versionNegotiation?: 'auto' | 'legacy'; trust?: boolean; description?: string; includeTools?: string[]; @@ -2655,6 +2656,19 @@ function normalizeMcpServerConfig(value: unknown): QwenMcpServerConfig { if (typeof cwd === 'string' && cwd.trim()) server.cwd = cwd.trim(); const timeout = normalizeOptionalNumber(input['timeout']); if (timeout !== undefined) server.timeout = timeout; + const versionNegotiation = toMcpVersionNegotiation( + input['versionNegotiation'], + ); + if ( + input['versionNegotiation'] !== undefined && + versionNegotiation === undefined + ) { + throw RequestError.invalidParams( + undefined, + 'MCP versionNegotiation must be auto or legacy', + ); + } + server.versionNegotiation = versionNegotiation; if (typeof input['trust'] === 'boolean') server.trust = input['trust']; server.includeTools = normalizeStringArray(input['includeTools']); server.excludeTools = normalizeStringArray(input['excludeTools']); @@ -2693,6 +2707,7 @@ function toStoredMcpServerConfig( const result: Record = {}; for (const key of [ 'timeout', + 'versionNegotiation', 'trust', 'description', 'includeTools', @@ -2723,6 +2738,7 @@ function toMcpServerConfig(value: unknown): QwenMcpServerConfig | undefined { httpUrl: server['httpUrl'], headers: normalizeStringRecord(server['headers']), timeout: normalizeOptionalNumber(server['timeout']), + versionNegotiation: toMcpVersionNegotiation(server['versionNegotiation']), trust: typeof server['trust'] === 'boolean' ? server['trust'] : undefined, description: typeof server['description'] === 'string' @@ -2742,6 +2758,7 @@ function toMcpServerConfig(value: unknown): QwenMcpServerConfig | undefined { url: server['url'], headers: normalizeStringRecord(server['headers']), timeout: normalizeOptionalNumber(server['timeout']), + versionNegotiation: toMcpVersionNegotiation(server['versionNegotiation']), trust: typeof server['trust'] === 'boolean' ? server['trust'] : undefined, description: typeof server['description'] === 'string' @@ -2763,6 +2780,7 @@ function toMcpServerConfig(value: unknown): QwenMcpServerConfig | undefined { cwd: typeof server['cwd'] === 'string' ? server['cwd'] : undefined, env: normalizeStringRecord(server['env']), timeout: normalizeOptionalNumber(server['timeout']), + versionNegotiation: toMcpVersionNegotiation(server['versionNegotiation']), trust: typeof server['trust'] === 'boolean' ? server['trust'] : undefined, description: typeof server['description'] === 'string' @@ -2779,6 +2797,12 @@ function toMcpServerConfig(value: unknown): QwenMcpServerConfig | undefined { return undefined; } +function toMcpVersionNegotiation( + value: unknown, +): 'auto' | 'legacy' | undefined { + return value === 'auto' || value === 'legacy' ? value : undefined; +} + function redactSecretRecord( record: Record | undefined, ): Record | undefined { diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index af8b6447c5b..f9fbc45bfb7 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -10,8 +10,11 @@ import { loadSettings } from '../../config/settings.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { assembleMcpServers } from '../../config/mcpServers.js'; import { loadMcpApprovals } from '../../config/mcpApprovals.js'; -import { createTransport, ExtensionManager } from '@qwen-code/qwen-code-core'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + createMcpClient, + createTransport, + ExtensionManager, +} from '@qwen-code/qwen-code-core'; const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); const mockWriteStderrLine = vi.hoisted(() => vi.fn()); @@ -38,6 +41,7 @@ vi.mock('../../config/trustedFolders.js', () => ({ })); vi.mock('@qwen-code/qwen-code-core', () => ({ createTransport: vi.fn(), + createMcpClient: vi.fn(), MCPServerStatus: { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', @@ -65,19 +69,17 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ isGatedMcpScope: (scope: string | undefined) => scope === 'project' || scope === 'workspace', })); -vi.mock('@modelcontextprotocol/sdk/client/index.js'); const mockedLoadSettings = loadSettings as Mock; const mockedAssembleMcpServers = assembleMcpServers as Mock; const mockedLoadMcpApprovals = loadMcpApprovals as Mock; const mockedIsWorkspaceTrusted = isWorkspaceTrusted as Mock; const mockedCreateTransport = createTransport as Mock; +const mockedCreateMcpClient = createMcpClient as Mock; const MockedExtensionManager = ExtensionManager as Mock; -const MockedClient = Client as Mock; interface MockClient { connect: Mock; - ping: Mock; close: Mock; } @@ -100,7 +102,6 @@ describe('mcp list command', () => { mockTransport = { close: vi.fn() }; mockClient = { connect: vi.fn(), - ping: vi.fn(), close: vi.fn(), }; @@ -109,7 +110,7 @@ describe('mcp list command', () => { getLoadedExtensions: vi.fn().mockReturnValue([]), }; - MockedClient.mockImplementation(() => mockClient); + mockedCreateMcpClient.mockReturnValue(mockClient); mockedCreateTransport.mockResolvedValue(mockTransport); MockedExtensionManager.mockImplementation(() => mockExtensionManager); mockedIsWorkspaceTrusted.mockReturnValue({ @@ -160,7 +161,6 @@ describe('mcp list command', () => { }); mockClient.connect.mockResolvedValue(undefined); - mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); @@ -182,6 +182,21 @@ describe('mcp list command', () => { 'http-server: https://example.com/http (http) - Connected', ), ); + expect(mockedCreateMcpClient).toHaveBeenCalledWith( + 'mcp-test-client', + expect.objectContaining({ command: '/path/to/server' }), + ); + expect(mockedCreateMcpClient).toHaveBeenCalledWith( + 'mcp-test-client', + expect.objectContaining({ url: 'https://example.com/sse' }), + ); + expect(mockedCreateMcpClient).toHaveBeenCalledWith( + 'mcp-test-client', + expect.objectContaining({ httpUrl: 'https://example.com/http' }), + ); + expect(mockClient.connect).toHaveBeenCalledWith(mockTransport, { + timeout: 10_000, + }); }); it('should display disconnected status when connection fails', async () => { @@ -217,7 +232,7 @@ describe('mcp list command', () => { mockClient.connect.mockImplementation(() => new Promise(() => {})); const listPromise = listMcpServers(); - await vi.advanceTimersByTimeAsync(4999); + await vi.advanceTimersByTimeAsync(9999); expect(mockTransport.close).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); @@ -226,7 +241,7 @@ describe('mcp list command', () => { expect(mockTransport.close).toHaveBeenCalledOnce(); expect(mockWriteStdoutLine).toHaveBeenCalledWith( expect.stringContaining( - 'slow-server: https://example.com/sse (sse) - Disconnected (timed out after 5000ms)', + 'slow-server: https://example.com/sse (sse) - Disconnected (timed out after 10000ms)', ), ); } finally { @@ -245,7 +260,6 @@ describe('mcp list command', () => { }, }); mockClient.connect.mockResolvedValue(undefined); - mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); @@ -273,7 +287,6 @@ describe('mcp list command', () => { ]); mockClient.connect.mockResolvedValue(undefined); - mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index d8839a54e28..111483ea772 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -11,12 +11,12 @@ import { writeStdoutLine } from '../../utils/stdioHelpers.js'; import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import { MCPServerStatus, + createMcpClient, createTransport, ExtensionManager, isGatedMcpScope, runWithTimeout, } from '@qwen-code/qwen-code-core'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { assembleMcpServers } from '../../config/mcpServers.js'; import { loadMcpApprovals } from '../../config/mcpApprovals.js'; @@ -26,7 +26,11 @@ const COLOR_GREEN = '\u001b[32m'; const COLOR_YELLOW = '\u001b[33m'; const COLOR_RED = '\u001b[31m'; const RESET_COLOR = '\u001b[0m'; -const MCP_CONNECT_TIMEOUT_MS = 5000; +// Stdio `createMcpClient` spends up to 5s on `server/discover` before +// falling back to `initialize`. The list probe must keep leftover +// budget for that handshake, or silent legacy servers time out as +// Disconnected after R13-1 started sharing the session factory. +const MCP_CONNECT_TIMEOUT_MS = 10_000; interface McpConnectionResult { status: MCPServerStatus; @@ -74,10 +78,7 @@ async function testMCPConnection( serverName: string, config: MCPServerConfig, ): Promise { - const client = new Client({ - name: 'mcp-test-client', - version: '0.0.1', - }); + const client = createMcpClient('mcp-test-client', config); let transport; try { @@ -97,9 +98,9 @@ async function testMCPConnection( `MCP connection for ${serverName}`, ); - // Test basic MCP protocol by pinging the server - await client.ping(); - + // Connect + version negotiation is the liveness proof. `ping` is + // absent from the 2026 request registry, so an unconditional ping + // marks working modern servers Disconnected. await client.close(); return { status: MCPServerStatus.CONNECTED, timedOut: false }; } catch (error) { diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts index 215f3178f5a..42f25388a4c 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts @@ -226,6 +226,37 @@ describe('SystemController', () => { }); }); + describe('initialize MCP configuration', () => { + it('preserves explicit automatic version negotiation', async () => { + const context = createContext(); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + await controller.handleRequest( + { + subtype: 'initialize', + mcpServers: { + automatic: { + command: 'node', + versionNegotiation: 'auto', + }, + }, + }, + 'mcp-1', + ); + + expect(context.config.addMcpServers).toHaveBeenCalledWith({ + automatic: expect.objectContaining({ + command: 'node', + versionNegotiation: 'auto', + }), + }); + }); + }); + describe('continue_last_turn', () => { it('delegates to the session callback and merges its payload', async () => { const onContinueLastTurn = vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 69dd2366253..737d581b84a 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -402,6 +402,12 @@ export class SystemController extends BaseController { authProvider, config.targetAudience, config.targetServiceAccount, + undefined, // type + undefined, // discoveryTimeoutMs + undefined, // scope + undefined, // alwaysLoadTools + undefined, // agentPluginV1 + config.versionNegotiation, ); } diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts index e68718125a4..11f213a0684 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -1767,6 +1767,34 @@ describe('BaseJsonOutputAdapter', () => { expect(result).toBe('Tool result'); }); + it('maps mcp_app displays to fallbackText', () => { + const response = { + callId: 'app-1', + resultDisplay: { + type: 'mcp_app' as const, + serverName: 'demo', + resourceUri: 'ui://demo/dashboard', + html: '
Revenue
', + toolResult: { + content: [{ type: 'text', text: 'Dashboard ready' }], + }, + toolArguments: {}, + fallbackText: 'Dashboard ready', + }, + responseParts: [ + { + functionResponse: { + response: {}, + }, + }, + ], + error: undefined, + errorType: undefined, + }; + + expect(toolResultContent(response)).toBe('Dashboard ready'); + }); + it('includes the vision bridge disclosure with tool content', () => { const response = { callId: 'pdf-success', diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index bb0f6cf0de0..95c75dc9b13 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -1420,6 +1420,21 @@ function checkResponsePartsForError( * @param response - Tool call response * @returns String content or undefined */ +function mcpAppFallbackText(display: unknown): string | undefined { + if ( + !display || + typeof display !== 'object' || + !('type' in display) || + display.type !== 'mcp_app' || + !('fallbackText' in display) || + typeof display.fallbackText !== 'string' + ) { + return undefined; + } + const text = display.fallbackText.trim(); + return text.length > 0 ? display.fallbackText : undefined; +} + export function toolResultContent( response: ToolCallResponseInfo, ): string | undefined { @@ -1456,6 +1471,10 @@ export function toolResultContent( if (response.error) { return response.error.message; } + const mcpAppFallback = mcpAppFallbackText(response.resultDisplay); + if (mcpAppFallback) { + return mcpAppFallback; + } if ( typeof response.resultDisplay === 'string' && response.resultDisplay.trim().length > 0 diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 53801c8b14f..f347807652d 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -354,6 +354,7 @@ export interface CLIMcpServerConfig { headers?: Record; tcp?: string; timeout?: number; + versionNegotiation?: 'auto' | 'legacy'; trust?: boolean; description?: string; includeTools?: string[]; diff --git a/packages/cli/src/serve/mcp-app-sandbox.test.ts b/packages/cli/src/serve/mcp-app-sandbox.test.ts new file mode 100644 index 00000000000..5056baeaae1 --- /dev/null +++ b/packages/cli/src/serve/mcp-app-sandbox.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { + buildMcpAppCsp, + mountMcpAppSandbox, + parseMcpAppCsp, +} from './mcp-app-sandbox.js'; + +describe('MCP App sandbox', () => { + it('keeps declared origins and drops CSP injection attempts', () => { + const parsed = parseMcpAppCsp( + JSON.stringify({ + connectDomains: [ + 'https://api.example.com', + 'HTTPS://API2.EXAMPLE.COM', + 'https://bad.test; script-src *', + ], + resourceDomains: ['https://*.example.com'], + }), + ); + + expect(buildMcpAppCsp(parsed)).toContain( + "connect-src 'self' https://api.example.com", + ); + expect(buildMcpAppCsp(parsed)).toContain('HTTPS://API2.EXAMPLE.COM'); + expect(buildMcpAppCsp(parsed)).toContain( + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: https://*.example.com", + ); + expect(buildMcpAppCsp(parsed)).toContain("form-action 'none'"); + expect(buildMcpAppCsp(parsed)).not.toContain('bad.test'); + }); + + it('keeps declared frame and base-uri origins', () => { + const parsed = parseMcpAppCsp( + JSON.stringify({ + frameDomains: ['https://frames.example.com'], + baseUriDomains: ['https://base.example.com'], + }), + ); + + expect(buildMcpAppCsp(parsed)).toContain( + 'frame-src https://frames.example.com', + ); + expect(buildMcpAppCsp(parsed)).toContain( + 'base-uri https://base.example.com', + ); + expect(buildMcpAppCsp(parsed)).toContain("form-action 'none'"); + }); + + it('serves the proxy with CSP and no-store headers', async () => { + const app = express(); + mountMcpAppSandbox(app); + + const response = await request(app).get('/mcp-app-sandbox'); + + expect(response.status).toBe(200); + expect(response.headers['content-security-policy']).toContain( + "frame-src 'none'", + ); + expect(response.headers['cache-control']).toContain('no-store'); + expect(response.headers['content-security-policy']).toContain( + "form-action 'none'", + ); + expect(response.text).toContain('ui/notifications/sandbox-proxy-ready'); + expect(response.text).toContain( + "inner.setAttribute('sandbox', 'allow-scripts allow-forms')", + ); + expect(response.text).not.toContain( + "inner.setAttribute('sandbox', 'allow-scripts allow-same-origin", + ); + expect(response.text).not.toContain("inner.setAttribute('allow'"); + expect(response.text).not.toContain("clipboardWrite: 'clipboard-write'"); + expect(response.text).not.toContain("camera: 'camera'"); + expect(response.text).not.toContain("microphone: 'microphone'"); + expect(response.text).not.toContain("geolocation: 'geolocation'"); + expect(response.text).toContain('inner.srcdoc = params.html'); + expect(response.text).toContain("event.origin === 'null'"); + }); + + it.each([ + 'https://K.example.com', + 'https://ſ.example.com', + 'httpſ://example.com', + ])( + 'drops Unicode case-folding match %s before writing CSP headers', + async (domain) => { + const app = express(); + mountMcpAppSandbox(app); + + const response = await request(app) + .get('/mcp-app-sandbox') + .query({ + csp: JSON.stringify({ connectDomains: [domain] }), + }); + + expect(response.status).toBe(200); + expect(response.headers['content-security-policy']).not.toContain(domain); + }, + ); +}); diff --git a/packages/cli/src/serve/mcp-app-sandbox.ts b/packages/cli/src/serve/mcp-app-sandbox.ts new file mode 100644 index 00000000000..378179860f4 --- /dev/null +++ b/packages/cli/src/serve/mcp-app-sandbox.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application } from 'express'; + +interface McpAppResourceCsp { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; +} + +const MAX_CSP_QUERY_LENGTH = 8192; +const CSP_SOURCE_PATTERN = + /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9.-]+(?::\d+)?$/i; + +function sanitizeCspDomains(domains: unknown): string[] { + if (!Array.isArray(domains)) return []; + return domains.filter( + (domain): domain is string => + typeof domain === 'string' && CSP_SOURCE_PATTERN.test(domain), + ); +} + +export function parseMcpAppCsp(value: unknown): McpAppResourceCsp | undefined { + if (typeof value !== 'string' || value.length > MAX_CSP_QUERY_LENGTH) { + return undefined; + } + try { + const parsed = JSON.parse(value) as Record; + return { + connectDomains: sanitizeCspDomains(parsed['connectDomains']), + resourceDomains: sanitizeCspDomains(parsed['resourceDomains']), + frameDomains: sanitizeCspDomains(parsed['frameDomains']), + baseUriDomains: sanitizeCspDomains(parsed['baseUriDomains']), + }; + } catch { + return undefined; + } +} + +export function buildMcpAppCsp(csp?: McpAppResourceCsp): string { + const resources = sanitizeCspDomains(csp?.resourceDomains).join(' '); + const connections = sanitizeCspDomains(csp?.connectDomains).join(' '); + const frames = sanitizeCspDomains(csp?.frameDomains).join(' '); + const baseUris = sanitizeCspDomains(csp?.baseUriDomains).join(' '); + return [ + "default-src 'self' 'unsafe-inline'", + `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resources}`.trim(), + `style-src 'self' 'unsafe-inline' blob: data: ${resources}`.trim(), + `img-src 'self' data: blob: ${resources}`.trim(), + `font-src 'self' data: blob: ${resources}`.trim(), + `media-src 'self' data: blob: ${resources}`.trim(), + `connect-src 'self' ${connections}`.trim(), + `worker-src 'self' blob: ${resources}`.trim(), + frames ? `frame-src ${frames}` : "frame-src 'none'", + "form-action 'none'", + "object-src 'none'", + baseUris ? `base-uri ${baseUris}` : "base-uri 'none'", + ].join('; '); +} + +const MCP_APP_SANDBOX_HTML = String.raw` + + + + + + + + + +`; + +export function mountMcpAppSandbox(app: Application): void { + app.get('/mcp-app-sandbox', (req, res) => { + const csp = parseMcpAppCsp(req.query['csp']); + res + .status(200) + .set('Content-Security-Policy', buildMcpAppCsp(csp)) + .set('Cache-Control', 'no-cache, no-store, must-revalidate') + .set('X-Content-Type-Options', 'nosniff') + .set('Referrer-Policy', 'strict-origin-when-cross-origin') + .type('html') + .send(MCP_APP_SANDBOX_HTML); + }); +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 0029c91881c..08ae0851664 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -3858,6 +3858,22 @@ describe('createServeApp', () => { .set('Accept', 'text/html'); expect(api.status).toBe(401); }); + + it('serves /mcp-app-sandbox pre-auth while the API stays token-gated', async () => { + const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, { + webShellDir, + }); + const sandbox = await request(app) + .get('/mcp-app-sandbox') + .set('Host', host); + expect(sandbox.status).toBe(200); + expect(sandbox.text).toContain('ui/notifications/sandbox-proxy-ready'); + expect(sandbox.headers['content-security-policy']).toContain( + "form-action 'none'", + ); + const api = await request(app).get('/capabilities').set('Host', host); + expect(api.status).toBe(401); + }); }); describe('GET /health', () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 55fa3a8c960..4d4f3f8860c 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -81,6 +81,7 @@ import { mountWebShellAssets, mountWebShellSpaFallback, } from './web-shell-static.js'; +import { mountMcpAppSandbox } from './mcp-app-sandbox.js'; import { mountWorkspaceMemoryRoutes, mountWorkspaceQualifiedMemoryRoutes, @@ -1763,6 +1764,7 @@ export function createServeApp( : []; if (webShellDir) { mountWebShellAssets(app, webShellDir, webShellFrameAncestors); + mountMcpAppSandbox(app); } if (deps.enqueueChannelWebhookTask) { diff --git a/packages/cli/src/serve/web-shell-static.test.ts b/packages/cli/src/serve/web-shell-static.test.ts new file mode 100644 index 00000000000..0d12d35d1e3 --- /dev/null +++ b/packages/cli/src/serve/web-shell-static.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildWebShellCsp, + buildWebShellPermissionsPolicy, + loopbackSandboxOrigins, + portFromHostHeader, +} from './web-shell-static.js'; + +describe('Web Shell sandbox framing', () => { + it('pins loopback sandbox origins to the request Host port', () => { + expect(portFromHostHeader('localhost:4170')).toBe('4170'); + expect(portFromHostHeader('[::1]:4170')).toBe('4170'); + expect(portFromHostHeader('127.0.0.1')).toBeUndefined(); + expect(loopbackSandboxOrigins('127.0.0.1:4170')).toEqual([ + 'http://localhost:4170', + 'http://127.0.0.1:4170', + 'https://localhost:4170', + 'https://127.0.0.1:4170', + ]); + expect(loopbackSandboxOrigins('127.0.0.1:4170').join(' ')).not.toContain( + '[::1]', + ); + }); + + it('allows only the daemon loopback port in frame-src', () => { + const csp = buildWebShellCsp([], loopbackSandboxOrigins('localhost:4170')); + expect(csp).toContain( + 'frame-src http://localhost:4170 http://127.0.0.1:4170 https://localhost:4170 https://127.0.0.1:4170', + ); + expect(csp).not.toContain('[::1]'); + expect(csp).not.toContain('http://localhost:*'); + expect(csp).not.toContain('http://127.0.0.1:*'); + }); + + it('keeps camera, microphone, and geolocation host-blocked', () => { + const policy = buildWebShellPermissionsPolicy(); + expect(policy).toContain('camera=()'); + expect(policy).toContain('microphone=(self)'); + expect(policy).toContain('geolocation=()'); + expect(policy).toContain('payment=()'); + expect(policy).toContain('clipboard-write=(self)'); + expect(policy).not.toContain('localhost'); + }); +}); diff --git a/packages/cli/src/serve/web-shell-static.ts b/packages/cli/src/serve/web-shell-static.ts index 7a88ec67d03..00f832910bf 100644 --- a/packages/cli/src/serve/web-shell-static.ts +++ b/packages/cli/src/serve/web-shell-static.ts @@ -37,6 +37,52 @@ const WEB_SHELL_CSP_DIRECTIVES = [ "base-uri 'none'", ]; +/** + * Loopback origins the Web Shell may frame for the MCP App sandbox, pinned to + * the request's Host port. Wildcard ports would let a compromised shell embed + * any loopback listener. + */ +export function loopbackSandboxOrigins( + hostHeader: string | undefined, +): string[] { + const port = portFromHostHeader(hostHeader); + const suffix = port ? `:${port}` : ''; + // CSP host-sources reject bracketed IPv6 (`http://[::1]:`). The + // sandbox iframe aliases `[::1]` to `localhost`, so these hosts are enough. + const hosts = ['localhost', '127.0.0.1'] as const; + return (['http', 'https'] as const).flatMap((scheme) => + hosts.map((host) => `${scheme}://${host}${suffix}`), + ); +} + +export function portFromHostHeader( + hostHeader: string | undefined, +): string | undefined { + if (!hostHeader) return undefined; + if (hostHeader.startsWith('[')) { + const end = hostHeader.indexOf(']'); + if (end === -1) return undefined; + const rest = hostHeader.slice(end + 1); + return rest.startsWith(':') && /^\d+$/u.test(rest.slice(1)) + ? rest.slice(1) + : undefined; + } + const colon = hostHeader.lastIndexOf(':'); + if (colon === -1) return undefined; + const port = hostHeader.slice(colon + 1); + return /^\d+$/u.test(port) ? port : undefined; +} + +export function buildWebShellPermissionsPolicy(): string { + return [ + 'camera=()', + 'microphone=(self)', + 'geolocation=()', + 'payment=()', + 'clipboard-write=(self)', + ].join(', '); +} + /** * Build the Web Shell CSP. `frame-ancestors` defaults to `'none'` (the caller * also sets `X-Frame-Options: DENY`) to block clickjacking. When the daemon is @@ -47,11 +93,13 @@ const WEB_SHELL_CSP_DIRECTIVES = [ */ export function buildWebShellCsp( frameAncestors: readonly string[] = [], + frameSrcOrigins: readonly string[] = loopbackSandboxOrigins(undefined), ): string { const fa = frameAncestors.length ? `frame-ancestors ${frameAncestors.join(' ')}` : "frame-ancestors 'none'"; - return [...WEB_SHELL_CSP_DIRECTIVES, fa].join('; '); + const frameSrc = `frame-src ${frameSrcOrigins.join(' ')}`; + return [...WEB_SHELL_CSP_DIRECTIVES, frameSrc, fa].join('; '); } /** Default (no-framing) Web Shell CSP. */ @@ -93,9 +141,10 @@ const SESSION_DEEP_LINK_PATH = /^\/session\/[^/]+\/?$/u; * that cannot attach the bearer header. Percent-encoded single-segment deep * links (e.g. `/session/%2fstatus`) also match — Express does not decode * `%2F` during route matching — but they cannot reach an API route or session - * data: pre-auth answers serve only the public shell HTML, identical to - * `GET /` (or the startup-failure envelope). Keep in sync with the routes - * registered in `mountWebShellAssets`. + * data: pre-auth answers serve only the public shell HTML or the MCP App + * sandbox proxy, identical to `GET /` (or the startup-failure envelope). + * Keep in sync with the routes registered in `mountWebShellAssets` and + * `mountMcpAppSandbox`. */ export function isPreAuthWebShellRequest(req: Request): boolean { if (req.method !== 'GET' && req.method !== 'HEAD') return false; @@ -108,7 +157,8 @@ export function isPreAuthWebShellRequest(req: Request): boolean { // a raw `//` also matches `app.get('/')` pre-auth (but `///` does not). reqPath === '//' || reqPath === '/assets' || - reqPath.startsWith('/assets/') + reqPath.startsWith('/assets/') || + reqPath === '/mcp-app-sandbox' ) return true; return SESSION_DEEP_LINK_PATH.test(reqPath) && isDocumentNavigation(req); @@ -123,10 +173,11 @@ export function isPreAuthWebShellRequest(req: Request): boolean { function createSendIndex( webShellDir: string, frameAncestors: readonly string[] = [], -): (res: Response) => void { +): (req: Request, res: Response) => void { const indexPath = path.join(webShellDir, 'index.html'); - const csp = buildWebShellCsp(frameAncestors); - return (res: Response): void => { + return (req: Request, res: Response): void => { + const sandboxOrigins = loopbackSandboxOrigins(req.get('host')); + const csp = buildWebShellCsp(frameAncestors, sandboxOrigins); res .status(200) .set('Content-Security-Policy', csp) @@ -135,10 +186,12 @@ function createSendIndex( .set( // `microphone=(self)` lets the same-origin Web Shell document request // the mic for voice dictation (the prompt won't even appear under an - // empty `microphone=()` allowlist). Still blocks cross-origin iframes; - // camera/geolocation/payment stay disabled (unused). + // empty `microphone=()` allowlist). Camera/geolocation stay disabled: + // the MCP App inner iframe is opaque-origin, so those grants cannot + // work there, and this header also blocks delegating them to the + // cross-origin sandbox. 'Permissions-Policy', - 'camera=(), microphone=(self), geolocation=(), payment=()', + buildWebShellPermissionsPolicy(), ) .set('Cache-Control', 'no-cache'); // X-Frame-Options can't express an allowlist, so only send the hard DENY @@ -189,6 +242,9 @@ function createSendIndex( * - `GET /session/:id` document navigations — the HTML shell, so a browser * refresh can load before the front-end adds its bearer header. * + * `GET /mcp-app-sandbox` is a separate pre-auth route mounted by + * `mountMcpAppSandbox` (the iframe proxy, not the shell HTML). + * * `isPreAuthWebShellRequest` encodes this same surface for the * deferred-runtime gate; keep the two in sync. * @@ -224,10 +280,10 @@ export function mountWebShellAssets( } res.status(404).type('text/plain').send('Not found'); }); - app.get('/', (_req: Request, res: Response) => sendIndex(res)); + app.get('/', (req: Request, res: Response) => sendIndex(req, res)); app.get('/session/:id', (req: Request, res: Response, next: NextFunction) => { if (!isDocumentNavigation(req)) return next(); - sendIndex(res); + sendIndex(req, res); }); } @@ -268,6 +324,6 @@ export function mountWebShellSpaFallback( `qwen serve: Web Shell SPA fallback served for ${req.method} ${req.originalUrl}`, ); } - sendIndex(res); + sendIndex(req, res); }); } diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 8d65e374910..bbc774768e8 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -1802,6 +1802,31 @@ describe('', () => { expect(output).toContain('- Step 2: Do another thing'); }); + it('renders MCP App fallback text instead of stringifying HTML', () => { + const html = `
PROBE_MCP_APP_HTML${'x'.repeat(200)}
`; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + const output = lastFrame(); + expect(output).toContain('MockMarkdown:Dashboard ready'); + expect(output).not.toContain('PROBE_MCP_APP_HTML'); + expect(output).not.toContain('mcp_app'); + }); + it('renders approved plan content with approval message', () => { const planResultDisplay = { type: 'plan_summary' as const, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index ba7b47a6d88..4947ee492ec 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -289,6 +289,20 @@ const useResultDisplayRenderer = ( return { type: 'none' }; } + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'type' in resultDisplay && + resultDisplay.type === 'mcp_app' && + 'fallbackText' in resultDisplay && + typeof resultDisplay.fallbackText === 'string' + ) { + return { + type: 'string', + data: resultDisplay.fallbackText, + }; + } + // Default to string — safeguard against non-string objects return { type: 'string', diff --git a/packages/cli/src/ui/daemon/daemon-tui-adapter.test.ts b/packages/cli/src/ui/daemon/daemon-tui-adapter.test.ts index 66350878f81..6144a407fa9 100644 --- a/packages/cli/src/ui/daemon/daemon-tui-adapter.test.ts +++ b/packages/cli/src/ui/daemon/daemon-tui-adapter.test.ts @@ -274,6 +274,46 @@ describe('reduceDaemonEventToTuiUpdates', () => { daemonEventId: 3, }); + const mcpAppHtml = `
PROBE_MCP_APP_HTML${'x'.repeat(200)}
`; + const mcpAppUpdates = reduceDaemonEventToTuiUpdates({ + id: 31, + v: 1, + type: 'session_update', + data: { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-app', + kind: 'mcp', + title: 'Show dashboard', + status: 'completed', + rawOutput: { + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/dashboard', + html: mcpAppHtml, + toolResult: { + content: [{ type: 'text', text: 'Dashboard ready' }], + }, + toolArguments: {}, + fallbackText: 'Dashboard ready', + }, + }, + }, + }); + expect(mcpAppUpdates).toHaveLength(1); + expect(mcpAppUpdates[0]).toMatchObject({ + type: 'tool_group_update', + item: { + tools: [ + { + resultDisplay: 'Dashboard ready', + }, + ], + }, + }); + expect(JSON.stringify(mcpAppUpdates)).not.toContain('PROBE_MCP_APP_HTML'); + expect( reduceDaemonEventToTuiUpdates({ id: 4, diff --git a/packages/cli/src/ui/daemon/daemon-tui-adapter.ts b/packages/cli/src/ui/daemon/daemon-tui-adapter.ts index 469be02cdcf..5aa4ee22ec3 100644 --- a/packages/cli/src/ui/daemon/daemon-tui-adapter.ts +++ b/packages/cli/src/ui/daemon/daemon-tui-adapter.ts @@ -276,6 +276,13 @@ function formatToolResultDisplay( value, ) as IndividualToolCallDisplay['resultDisplay']; } + if ( + isRecord(value) && + value['type'] === 'mcp_app' && + typeof value['fallbackText'] === 'string' + ) { + return sanitizeDisplayText(value['fallbackText']); + } if ( isRecord(value) && (typeof value['fileDiff'] === 'string' || diff --git a/packages/core/package.json b/packages/core/package.json index 88caa08c421..e85576e0c0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,8 @@ "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "2.6.0", "@iarna/toml": "^2.2.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 301eb4d6685..746e03eaf61 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -876,6 +876,7 @@ export class MCPServerConfig { readonly scope?: McpServerScope, readonly alwaysLoadTools?: boolean, readonly agentPluginV1?: boolean, + readonly versionNegotiation?: 'auto' | 'legacy', ) {} } diff --git a/packages/core/src/mcp/configHash.test.ts b/packages/core/src/mcp/configHash.test.ts index b43ec10d3f6..f2722cc324b 100644 --- a/packages/core/src/mcp/configHash.test.ts +++ b/packages/core/src/mcp/configHash.test.ts @@ -108,6 +108,15 @@ describe('hashMcpServerConfig', () => { expect(hashMcpServerConfig({ ...base, trust: true })).not.toBe(baseHash); }); + it('version negotiation policy', () => { + expect( + hashMcpServerConfig({ + ...base, + versionNegotiation: 'auto', + }), + ).not.toBe(baseHash); + }); + it('remote url', () => { expect(hashMcpServerConfig({ url: 'https://a.example' })).not.toBe( hashMcpServerConfig({ url: 'https://b.example' }), diff --git a/packages/core/src/tools/mcp-client-v2.test.ts b/packages/core/src/tools/mcp-client-v2.test.ts new file mode 100644 index 00000000000..1101721aea9 --- /dev/null +++ b/packages/core/src/tools/mcp-client-v2.test.ts @@ -0,0 +1,648 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + type JSONRPCMessage, + type JSONRPCRequest, +} from '@modelcontextprotocol/client'; +import { describe, expect, it, vi } from 'vitest'; +import type { Config, MCPServerConfig } from '../config/config.js'; +import type { WorkspaceContext } from '../utils/workspaceContext.js'; +import { + getMcpAppResourceUri, + isMcpToolVisibleToModel, + connectToMcpServer, + createMcpClient, + discoverTools, + invokeMcpPrompt, + listMcpPrompts, + listMcpResources, + MCP_DEFAULT_TIMEOUT_MSEC, + MCP_VERSION_NEGOTIATION_FALLBACK_HEADROOM_MS, + MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS, + mcpVersionNegotiationFor, +} from './mcp-client.js'; +import { + discoveryTimeoutFor, + runWithTimeout, +} from './mcp-discovery-timeout.js'; +import { + SdkControlClientTransport, + type SendMcpMessageCallback, +} from './sdk-control-client-transport.js'; + +type RequestMessage = JSONRPCRequest; + +function response( + request: RequestMessage, + result: Record, +): JSONRPCMessage { + return { jsonrpc: '2.0', id: request.id, result } as JSONRPCMessage; +} + +function workspaceContext(): WorkspaceContext { + return { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; +} + +async function connectNegotiatingControl( + serverName: string, + sendMcpMessage: SendMcpMessageCallback, +) { + const client = createMcpClient('qwen-code-mcp-client', { + command: 'test-control', + versionNegotiation: 'auto', + } as MCPServerConfig); + await client.connect( + new SdkControlClientTransport({ serverName, sendMcpMessage }), + ); + return client; +} + +describe('configured MCP SDK v2 negotiation', () => { + it('bounds the auto-negotiation probe below the inherited request timeout', () => { + expect(MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS).toBe(5_000); + expect(MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS).toBeLessThan( + MCP_DEFAULT_TIMEOUT_MSEC, + ); + }); + + it('keeps defaults, non-stdio, and explicit legacy configs on legacy', () => { + expect( + mcpVersionNegotiationFor({ + httpUrl: 'https://example.com/mcp', + } as MCPServerConfig), + ).toEqual({ mode: 'legacy' }); + expect( + mcpVersionNegotiationFor({ type: 'sdk' } as MCPServerConfig), + ).toEqual({ mode: 'legacy' }); + expect( + mcpVersionNegotiationFor({ + command: 'node', + versionNegotiation: 'legacy', + } as MCPServerConfig), + ).toEqual({ mode: 'legacy' }); + expect( + mcpVersionNegotiationFor({ command: 'node' } as MCPServerConfig), + ).toEqual({ mode: 'legacy' }); + expect( + mcpVersionNegotiationFor({ + command: 'node', + versionNegotiation: 'auto', + } as MCPServerConfig), + ).toEqual({ + mode: 'auto', + probe: { timeoutMs: MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS }, + }); + expect( + mcpVersionNegotiationFor({ + command: 'node', + versionNegotiation: 'auto', + discoveryTimeoutMs: 2_000, + } as MCPServerConfig), + ).toEqual({ mode: 'legacy' }); + expect( + mcpVersionNegotiationFor({ + command: 'node', + versionNegotiation: 'auto', + discoveryTimeoutMs: 8_000, + } as MCPServerConfig), + ).toEqual({ + mode: 'auto', + probe: { + timeoutMs: 8_000 - MCP_VERSION_NEGOTIATION_FALLBACK_HEADROOM_MS, + }, + }); + }); + + it('preserves the default discovery budget for legacy initialization', async () => { + const config = { + command: process.execPath, + args: [ + '--input-type=module', + '--eval', + ` + import readline from 'node:readline'; + const lines = readline.createInterface({ input: process.stdin }); + lines.on('line', (line) => { + const request = JSON.parse(line); + if (request.method !== 'initialize') return; + setTimeout(() => { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2025-06-18', + capabilities: {}, + serverInfo: { name: 'slow-legacy', version: '1.0.0' }, + }, + }) + '\\n'); + }, 5100); + }); + `, + ], + discoveryTimeoutMs: 8_000, + } as MCPServerConfig; + + const client = await runWithTimeout( + connectToMcpServer('slow-legacy', config, false, workspaceContext()), + discoveryTimeoutFor(config), + 'slow legacy negotiation', + ); + + try { + expect(client.getProtocolEra()).toBe('legacy'); + } finally { + await client.close(); + } + }); + + it('connects to a modern-only server and reuses cache-hinted tool lists', async () => { + const requests: RequestMessage[] = []; + const send = vi.fn(async (_serverName: string, message: JSONRPCMessage) => { + const request = message as RequestMessage; + requests.push(request); + + switch (request.method) { + case 'server/discover': + return response(request, { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {}, prompts: {}, resources: {} }, + ttlMs: 60_000, + cacheScope: 'private', + }); + case 'tools/list': + return response(request, { + resultType: 'complete', + tools: [ + { + name: 'echo', + description: 'Echo input', + inputSchema: { type: 'object' }, + _meta: { ui: { resourceUri: 'ui://demo/dashboard' } }, + }, + ], + ttlMs: 60_000, + cacheScope: 'private', + }); + case 'tools/call': + return response(request, { + resultType: 'complete', + content: [{ type: 'text', text: 'ok' }], + }); + case 'prompts/list': + return response(request, { + resultType: 'complete', + prompts: [{ name: 'modern-prompt' }], + ttlMs: 60_000, + cacheScope: 'private', + }); + case 'prompts/get': + return response(request, { + resultType: 'complete', + messages: [ + { + role: 'user', + content: { type: 'text', text: 'modern-prompt-result' }, + }, + ], + }); + case 'resources/list': + return response(request, { + resultType: 'complete', + resources: [{ uri: 'file:///modern.txt', name: 'modern.txt' }], + ttlMs: 60_000, + cacheScope: 'private', + }); + default: + throw new Error(`Unexpected modern MCP method: ${request.method}`); + } + }); + + const client = await connectNegotiatingControl('modern-only', send); + + try { + expect(client.getProtocolEra()).toBe('modern'); + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ name: 'echo' }], + }); + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ name: 'echo' }], + }); + const [discoveredTool] = await discoverTools( + 'modern-only', + { type: 'sdk' } as MCPServerConfig, + client, + {} as Config, + { applyConfigFilters: false }, + ); + expect(discoveredTool?.appResourceUri).toBe('ui://demo/dashboard'); + await expect( + client.callTool({ name: 'echo', arguments: { text: 'hello' } }), + ).resolves.toMatchObject({ + content: [{ type: 'text', text: 'ok' }], + }); + await expect(listMcpPrompts('modern-only', client)).resolves.toHaveLength( + 1, + ); + await expect(listMcpPrompts('modern-only', client)).resolves.toHaveLength( + 1, + ); + await expect( + invokeMcpPrompt('modern-only', client, 'modern-prompt', {}), + ).resolves.toMatchObject({ + messages: [{ content: { text: 'modern-prompt-result' } }], + }); + await expect( + listMcpResources('modern-only', client), + ).resolves.toHaveLength(1); + await expect( + listMcpResources('modern-only', client), + ).resolves.toHaveLength(1); + + expect(requests.map((request) => request.method)).toEqual([ + 'server/discover', + 'tools/list', + 'tools/call', + 'prompts/list', + 'prompts/get', + 'resources/list', + ]); + expect(requests.some((request) => request.method === 'initialize')).toBe( + false, + ); + for (const request of requests) { + expect(request.params?._meta).toMatchObject({ + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: expect.objectContaining({ + name: 'qwen-code-mcp-client', + }), + [CLIENT_CAPABILITIES_META_KEY]: expect.any(Object), + }); + } + expect( + requests[0]?.params?._meta?.[CLIENT_CAPABILITIES_META_KEY], + ).toMatchObject({ + extensions: { + 'io.modelcontextprotocol/ui': { + mimeTypes: ['text/html;profile=mcp-app'], + }, + }, + }); + } finally { + await client.close(); + } + }); + + it('lists every page of a modern tools/list instead of dropping the catalog', async () => { + const pageCount = 65; + const send = vi.fn(async (_serverName: string, message: JSONRPCMessage) => { + const request = message as RequestMessage; + switch (request.method) { + case 'server/discover': + return response(request, { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + }); + case 'tools/list': { + const cursor = Number( + (request.params as { cursor?: string } | undefined)?.cursor ?? '0', + ); + return response(request, { + resultType: 'complete', + tools: [ + { + name: `tool-${cursor}`, + inputSchema: { type: 'object' }, + }, + ], + ttlMs: 60_000, + cacheScope: 'private', + ...(cursor + 1 < pageCount + ? { nextCursor: String(cursor + 1) } + : {}), + }); + } + default: + throw new Error(`Unexpected modern MCP method: ${request.method}`); + } + }); + + const client = await connectNegotiatingControl('paged', send); + + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual( + Array.from({ length: pageCount }, (_, index) => `tool-${index}`), + ); + const tools = await discoverTools( + 'paged', + { type: 'sdk' } as MCPServerConfig, + client, + {} as Config, + { applyConfigFilters: false }, + ); + expect(tools.map((tool) => tool.serverToolName)).toEqual( + Array.from({ length: pageCount }, (_, index) => `tool-${index}`), + ); + } finally { + await client.close(); + } + }); + + it('connects remote HTTP with the legacy initialize handshake', async () => { + const http = await import('node:http'); + const methods: string[] = []; + const server = http.createServer((request, responseStream) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.on('end', () => { + if (!body) { + responseStream.writeHead(405); + responseStream.end(); + return; + } + const message = JSON.parse(body) as JSONRPCMessage; + if (!('method' in message) || typeof message.method !== 'string') { + responseStream.writeHead(400); + responseStream.end(); + return; + } + methods.push(message.method); + if (message.method === 'server/discover') { + responseStream.writeHead(500); + responseStream.end(); + return; + } + if (!('id' in message)) { + responseStream.writeHead(202); + responseStream.end(); + return; + } + + let result: Record; + switch (message.method) { + case 'initialize': + result = { + protocolVersion: '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'http-legacy', version: '1.0.0' }, + }; + break; + case 'tools/list': + result = { + tools: [{ name: 'echo', inputSchema: { type: 'object' } }], + }; + break; + case 'tools/call': + result = { + content: [{ type: 'text', text: 'ok' }], + }; + break; + default: + responseStream.writeHead(500); + responseStream.end(); + return; + } + + responseStream.writeHead(200, { 'Content-Type': 'application/json' }); + responseStream.end( + JSON.stringify(response(message as RequestMessage, result)), + ); + }); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve), + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the test server to listen on a TCP port'); + } + + let client: Awaited> | undefined; + try { + client = await connectToMcpServer( + 'remote-http', + { + httpUrl: `http://127.0.0.1:${address.port}/mcp`, + } as MCPServerConfig, + false, + workspaceContext(), + ); + expect(client.getProtocolEra()).toBe('legacy'); + await client.listTools(); + await client.callTool({ name: 'echo' }); + expect(methods).not.toContain('server/discover'); + expect(methods[0]).toBe('initialize'); + expect(methods).toEqual( + expect.arrayContaining(['initialize', 'tools/list', 'tools/call']), + ); + } finally { + await client?.close(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + + it('falls back to the legacy initialize flow', async () => { + const requests: RequestMessage[] = []; + const send = vi.fn(async (_serverName: string, message: JSONRPCMessage) => { + if (!('id' in message)) { + return { + jsonrpc: '2.0', + id: 0, + result: {}, + } as JSONRPCMessage; + } + + const request = message as RequestMessage; + requests.push(request); + switch (request.method) { + case 'server/discover': + return { + jsonrpc: '2.0', + id: request.id, + error: { code: -32601, message: 'Method not found' }, + } as JSONRPCMessage; + case 'initialize': + return response(request, { + protocolVersion: '2025-06-18', + capabilities: {}, + serverInfo: { name: 'legacy-server', version: '1.0.0' }, + }); + case 'tools/list': + return response(request, { + tools: [{ name: 'echo', inputSchema: { type: 'object' } }], + }); + case 'tools/call': + return response(request, { + content: [{ type: 'text', text: 'legacy-ok' }], + }); + case 'prompts/list': + return response(request, { + prompts: [{ name: 'legacy-prompt' }], + }); + case 'prompts/get': + return response(request, { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'legacy-prompt-result' }, + }, + ], + }); + case 'resources/list': + return response(request, { + resources: [{ uri: 'file:///legacy.txt', name: 'legacy.txt' }], + }); + default: + throw new Error(`Unexpected legacy MCP method: ${request.method}`); + } + }); + + const client = await connectNegotiatingControl('legacy', send); + + try { + expect(client.getProtocolEra()).toBe('legacy'); + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + const tools = await discoverTools( + 'legacy', + { type: 'sdk' } as MCPServerConfig, + client, + {} as Config, + { applyConfigFilters: false }, + ); + expect(tools.map((tool) => tool.serverToolName)).toEqual(['echo']); + await expect(client.callTool({ name: 'echo' })).resolves.toMatchObject({ + content: [{ type: 'text', text: 'legacy-ok' }], + }); + await expect(listMcpPrompts('legacy', client)).resolves.toMatchObject([ + { name: 'legacy-prompt', serverName: 'legacy' }, + ]); + await expect( + invokeMcpPrompt('legacy', client, 'legacy-prompt', {}), + ).resolves.toMatchObject({ + messages: [{ content: { text: 'legacy-prompt-result' } }], + }); + await expect(listMcpResources('legacy', client)).resolves.toMatchObject([ + { uri: 'file:///legacy.txt', serverName: 'legacy' }, + ]); + expect(requests.map((request) => request.method)).toEqual([ + 'server/discover', + 'initialize', + 'tools/list', + 'tools/call', + 'prompts/list', + 'prompts/get', + 'resources/list', + ]); + expect( + requests.find((request) => request.method === 'tools/list')?.params + ?._meta, + ).toBeUndefined(); + expect( + requests.find((request) => request.method === 'initialize')?.params?.[ + 'capabilities' + ], + ).toMatchObject({ + extensions: { + 'io.modelcontextprotocol/ui': { + mimeTypes: ['text/html;profile=mcp-app'], + }, + }, + }); + } finally { + await client.close(); + } + }); + + it('connects SDK control-plane servers without probing them', async () => { + const methods: string[] = []; + const send = vi.fn(async (_serverName: string, message: JSONRPCMessage) => { + if (!('method' in message)) { + throw new Error('Unexpected MCP response'); + } + methods.push(message.method); + if (!('id' in message)) { + return { jsonrpc: '2.0', id: 0, result: {} } as JSONRPCMessage; + } + if (message.method !== 'initialize') { + throw new Error(`Unexpected SDK MCP method: ${message.method}`); + } + return response(message, { + protocolVersion: '2025-06-18', + capabilities: {}, + serverInfo: { name: 'sdk-legacy', version: '1.0.0' }, + }); + }); + + const client = await connectToMcpServer( + 'sdk-legacy', + { type: 'sdk' } as MCPServerConfig, + false, + workspaceContext(), + send, + ); + + try { + expect(client.getProtocolEra()).toBe('legacy'); + expect(methods).toEqual(['initialize', 'notifications/initialized']); + } finally { + await client.close(); + } + }); + + it('accepts nested and legacy MCP Apps tool metadata', () => { + expect( + getMcpAppResourceUri({ + _meta: { ui: { resourceUri: 'ui://demo/dashboard' } }, + }), + ).toBe('ui://demo/dashboard'); + expect( + getMcpAppResourceUri({ + _meta: { 'ui/resourceUri': 'ui://demo/legacy' }, + }), + ).toBe('ui://demo/legacy'); + expect( + getMcpAppResourceUri({ + _meta: { ui: { resourceUri: 'https://example.com/app' } }, + }), + ).toBeUndefined(); + }); + + it('hides MCP App tools whose visibility does not include model', () => { + expect(isMcpToolVisibleToModel({})).toBe(true); + expect( + isMcpToolVisibleToModel({ + _meta: { ui: { resourceUri: 'ui://demo/dashboard' } }, + }), + ).toBe(true); + expect( + isMcpToolVisibleToModel({ + _meta: { ui: { visibility: ['model', 'app'] } }, + }), + ).toBe(true); + expect( + isMcpToolVisibleToModel({ + _meta: { ui: { visibility: ['app'] } }, + }), + ).toBe(false); + expect( + isMcpToolVisibleToModel({ + _meta: { ui: { visibility: null } }, + }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 5b25da5da98..b8886d0fdf3 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -5,10 +5,12 @@ */ import * as GenAiLib from '@google/genai'; -import * as ClientLib from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import * as ClientLib from '@modelcontextprotocol/client'; +import { + SSEClientTransport, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import * as SdkClientStdioLib from '@modelcontextprotocol/client/stdio'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthProviderType, @@ -32,6 +34,7 @@ import { _setMcpFetchForTest, addMCPStatusChangeListener, attemptAutomaticMcpOAuth, + connectAndDiscover, connectToMcpServer, createStreamableHttpCompatibilityFetch, createTransport, @@ -69,8 +72,12 @@ const TEST_MCP_TOOL_IDLE_TIMEOUT_MS = 300000; vi.mock('node:fs', () => ({ existsSync: mockExistsSync, })); -vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); -vi.mock('@modelcontextprotocol/sdk/client/index.js'); +vi.mock('@modelcontextprotocol/client/stdio'); +vi.mock('@modelcontextprotocol/client', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, Client: vi.fn() }; +}); vi.mock('@google/genai'); vi.mock('../mcp/oauth-provider.js'); vi.mock('../mcp/oauth-token-storage.js'); @@ -95,6 +102,38 @@ function cfgWithResources(): Config { } as unknown as Config; } +function mockAppOnlyMcpServer(): void { + const methodNotFound = Object.assign(new Error('Method not found'), { + code: -32601, + }); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }), + request: vi.fn().mockRejectedValue(methodNotFound), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'internal_refresh', + _meta: { ui: { visibility: ['app'] } }, + }, + ], + }), + getInstructions: vi.fn(), + close: vi.fn(), + } as unknown as ClientLib.Client); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [{ name: 'internal_refresh' }], + }), + } as unknown as GenAiLib.CallableTool); +} + describe('mcp-client', () => { afterEach(() => { _setMcpFetchForTest(undefined); @@ -1305,6 +1344,83 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect((result.contents[0] as { text: string }).text).toBe('BODY'); }); + it('readResource uses the cache-aware helper for modern sessions', async () => { + const mockedClient = { + connect: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }), + readResource: vi.fn().mockResolvedValue({ + contents: [{ uri: 'res://doc', text: 'BODY' }], + }), + getInstructions: vi.fn(), + }; + vi.mocked(ClientLib.Client).mockReturnValue( + mockedClient as unknown as ClientLib.Client, + ); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + const client = new McpClient( + 'srv', + { command: 'test-command' }, + {} as ToolRegistry, + {} as PromptRegistry, + {} as WorkspaceContext, + false, + ); + await client.connect(); + + const result = await client.readResource('res://doc'); + expect(mockedClient.readResource).toHaveBeenCalledWith( + { uri: 'res://doc' }, + undefined, + ); + expect((result.contents[0] as { text: string }).text).toBe('BODY'); + }); + + it('readResource falls back to a raw request when a modern server omits resources', async () => { + const mockedClient = { + connect: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({}), + readResource: vi.fn().mockResolvedValue({ + contents: [{ uri: 'res://doc', text: 'TYPED' }], + }), + request: vi.fn().mockResolvedValue({ + contents: [{ uri: 'res://doc', text: 'BODY' }], + }), + getInstructions: vi.fn(), + }; + vi.mocked(ClientLib.Client).mockReturnValue( + mockedClient as unknown as ClientLib.Client, + ); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + const client = new McpClient( + 'srv', + { command: 'test-command' }, + {} as ToolRegistry, + {} as PromptRegistry, + {} as WorkspaceContext, + false, + ); + await client.connect(); + + const result = await client.readResource('res://doc'); + expect(mockedClient.readResource).not.toHaveBeenCalled(); + expect(mockedClient.request).toHaveBeenCalledWith( + { method: 'resources/read', params: { uri: 'res://doc' } }, + expect.anything(), + undefined, + ); + expect((result.contents[0] as { text: string }).text).toBe('BODY'); + }); + it('should not skip tools even if a parameter is missing a type', async () => { const mockedClient = { connect: vi.fn(), @@ -1537,6 +1653,155 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect(tools[0].alwaysLoad).toBe(true); }); + it('skips MCP App tools whose visibility does not include model', async () => { + const mockedClient = { + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'show_dashboard', + _meta: { + ui: { + resourceUri: 'ui://demo/dash', + visibility: ['model'], + }, + }, + }, + { + name: 'internal_refresh', + _meta: { + ui: { + resourceUri: 'ui://demo/refresh', + visibility: ['app'], + }, + }, + }, + ], + }), + } as unknown as ClientLib.Client; + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { name: 'show_dashboard' }, + { name: 'internal_refresh' }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools( + 'apps', + { command: 'test-command' }, + mockedClient, + cfgWithResources(), + { applyConfigFilters: false }, + ); + + expect(tools.map((tool) => tool.serverToolName)).toEqual([ + 'show_dashboard', + ]); + }); + + it('attaches listing-level app resource UI onto discovered tools', async () => { + const mockedClient = { + connect: vi.fn(), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({ + tools: {}, + resources: {}, + }), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'show_dashboard', + _meta: { ui: { resourceUri: 'ui://demo/dash' } }, + }, + ], + }), + listResources: vi.fn().mockResolvedValue({ + resources: [ + { + uri: 'ui://demo/dash', + name: 'dash', + _meta: { + ui: { + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }, + }, + }, + ], + }), + listPrompts: vi.fn().mockResolvedValue({ prompts: [] }), + request: vi.fn().mockResolvedValue({ prompts: [] }), + getInstructions: vi.fn(), + }; + vi.mocked(ClientLib.Client).mockReturnValue( + mockedClient as unknown as ClientLib.Client, + ); + vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue( + {} as SdkClientStdioLib.StdioClientTransport, + ); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [{ name: 'show_dashboard' }], + }), + } as unknown as GenAiLib.CallableTool); + + const client = new McpClient( + 'apps', + { command: 'test-command' }, + { registerTool: vi.fn() } as unknown as ToolRegistry, + { registerPrompt: vi.fn() } as unknown as PromptRegistry, + {} as WorkspaceContext, + false, + ); + await client.connect(); + const snapshot = await client.discoverAndReturn(cfgWithResources(), { + applyConfigFilters: false, + }); + + expect(snapshot.tools[0]?.appResourceUri).toBe('ui://demo/dash'); + expect(snapshot.tools[0]?.appResourceUi).toEqual({ + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }); + }); + + it('lists tools via request when a modern server omits the tools capability', async () => { + const mockedClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({}), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + request: vi.fn().mockResolvedValue({ + tools: [{ name: 'echo' }], + }), + } as unknown as ClientLib.Client; + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [{ name: 'echo' }], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools( + 'under-declared', + { command: 'test-command' }, + mockedClient, + cfgWithResources(), + { applyConfigFilters: false }, + ); + + expect(vi.mocked(mockedClient.request)).toHaveBeenCalledWith( + { method: 'tools/list', params: {} }, + expect.anything(), + ); + expect(vi.mocked(mockedClient.listTools)).not.toHaveBeenCalled(); + expect(tools.map((tool) => tool.serverToolName)).toEqual(['echo']); + }); + it('allows invocation context only for a client bound to a created stdio transport', async () => { const callTool = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], @@ -1872,6 +2137,48 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect(promptRegistry.registerPrompt).not.toHaveBeenCalled(); }); + it('keeps a server with only app-visible tools connected', async () => { + mockAppOnlyMcpServer(); + const client = new McpClient( + 'app-only-server', + { command: 'test-command' }, + {} as ToolRegistry, + {} as PromptRegistry, + {} as WorkspaceContext, + false, + ); + await client.connect(); + + const snapshot = await client.discoverAndReturn(cfgWithResources()); + + expect(snapshot).toEqual({ tools: [], prompts: [], resources: [] }); + expect(client.getStatus()).toBe(MCPServerStatus.CONNECTED); + }); + + it('keeps standalone discovery connected for app-visible-only tools', async () => { + mockAppOnlyMcpServer(); + const serverName = `app-only-standalone-${Date.now()}`; + const toolRegistry = { + registerTool: vi.fn(), + } as unknown as ToolRegistry; + + await connectAndDiscover( + serverName, + { command: 'test-command' }, + toolRegistry, + { registerPrompt: vi.fn() } as unknown as PromptRegistry, + false, + { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext, + cfgWithResources(), + ); + + expect(getMCPServerStatus(serverName)).toBe(MCPServerStatus.CONNECTED); + expect(toolRegistry.registerTool).not.toHaveBeenCalled(); + }); + it('discoverAndReturn throws when called before connect()', async () => { const client = new McpClient( 'unconnected-server', @@ -2097,11 +2404,13 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= }); it('attempts prompts/list even when the prompts capability is undeclared (lenient)', async () => { - // Regression guard: pre-fix this returned [] WITHOUT a request when - // `capabilities.prompts` was absent, hiding prompts from servers that - // under-declare the capability. We now always attempt the call. + // Regression guard: the v2 typed helper returns [] WITHOUT a request + // when `capabilities.prompts` is absent. Modern under-declared servers + // must still hit the wire. const mockClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), getServerCapabilities: vi.fn().mockReturnValue({}), + listPrompts: vi.fn().mockResolvedValue({ prompts: [] }), request: vi .fn() .mockRejectedValue(new Error('MCP error -32601: Method not found')), @@ -2109,11 +2418,14 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= const result = await listMcpPrompts('no-prompts', mockClient); expect(result).toEqual([]); expect(vi.mocked(mockClient.request)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockClient.listPrompts)).not.toHaveBeenCalled(); }); it('lists prompts from a server that omits the prompts capability but still answers', async () => { const mockClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), getServerCapabilities: vi.fn().mockReturnValue({}), + listPrompts: vi.fn().mockResolvedValue({ prompts: [] }), request: vi.fn().mockResolvedValue({ prompts: [{ name: 'greet' }], }), @@ -2122,6 +2434,23 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect(result).toHaveLength(1); expect(result[0].name).toBe('greet'); expect(result[0].serverName).toBe('under-declared'); + expect(vi.mocked(mockClient.listPrompts)).not.toHaveBeenCalled(); + }); + + it('uses the typed helper when a modern server declares prompts', async () => { + const mockClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }), + listPrompts: vi.fn().mockResolvedValue({ + prompts: [{ name: 'greet' }], + }), + request: vi.fn(), + } as unknown as ClientLib.Client; + const result = await listMcpPrompts('modern', mockClient); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('greet'); + expect(vi.mocked(mockClient.listPrompts)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockClient.request)).not.toHaveBeenCalled(); }); it('returns [] on protocol error (server up but list call rejects)', async () => { @@ -2184,7 +2513,9 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= it('attempts resources/list even when the resources capability is undeclared (lenient)', async () => { const mockClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), getServerCapabilities: vi.fn().mockReturnValue({}), + listResources: vi.fn().mockResolvedValue({ resources: [] }), request: vi .fn() .mockRejectedValue(new Error('MCP error -32601: Method not found')), @@ -2192,6 +2523,23 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= const result = await listMcpResources('no-resources', mockClient); expect(result).toEqual([]); expect(vi.mocked(mockClient.request)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockClient.listResources)).not.toHaveBeenCalled(); + }); + + it('uses the typed helper when a modern server declares resources', async () => { + const mockClient = { + getProtocolEra: vi.fn().mockReturnValue('modern'), + getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }), + listResources: vi.fn().mockResolvedValue({ + resources: [{ uri: 'file:///a.txt', name: 'a' }], + }), + request: vi.fn(), + } as unknown as ClientLib.Client; + const result = await listMcpResources('modern', mockClient); + expect(result).toHaveLength(1); + expect(result[0].uri).toBe('file:///a.txt'); + expect(vi.mocked(mockClient.listResources)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockClient.request)).not.toHaveBeenCalled(); }); it('returns [] on protocol error (server up but list call rejects)', async () => { @@ -2911,7 +3259,7 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const authProvider = (transport as any)._authProvider; + const authProvider = (transport as any)._oauthProvider; expect(authProvider).toBeInstanceOf(GoogleCredentialProvider); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((transport as any)._fetch).toEqual(expect.any(Function)); @@ -2932,7 +3280,7 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= expect(transport).toBeInstanceOf(SSEClientTransport); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const authProvider = (transport as any)._authProvider; + const authProvider = (transport as any)._oauthProvider; expect(authProvider).toBeInstanceOf(GoogleCredentialProvider); }); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 2a6326b1455..ef5b08749f6 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -4,27 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import type { StreamableHTTPClientTransportOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { - GetPromptResult, - JSONRPCMessage, - Prompt, - ReadResourceResult, - Resource, -} from '@modelcontextprotocol/sdk/types.js'; +import { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, + type GetPromptResult, + type JSONRPCMessage, + type Prompt, + type ReadResourceResult, + type Resource, + type SSEClientTransportOptions, + type StreamableHTTPClientTransportOptions, + type Transport, +} from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import type { Client as GenAiMcpClient } from '@modelcontextprotocol/sdk/client/index.js'; import { GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, - ListRootsRequestSchema, + ListToolsResultSchema, ReadResourceResultSchema, -} from '@modelcontextprotocol/sdk/types.js'; +} from '@modelcontextprotocol/core'; import { parse } from 'shell-quote'; import type { Config, MCPServerConfig } from '../config/config.js'; import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js'; @@ -63,6 +64,7 @@ import { resetDispatcherCache, } from '../utils/runtimeFetchOptions.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { discoveryTimeoutFor } from './mcp-discovery-timeout.js'; import { retryWithBackoff } from './mcp-retry.js'; import { normalizePathEnvForWindows } from '../utils/windowsPath.js'; import { sanitizeChildEnv } from '../utils/sanitize-child-env.js'; @@ -81,6 +83,19 @@ export type SendSdkMcpMessage = ( ) => Promise; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes +// Auto-negotiation `server/discover` otherwise inherits connect()'s +// timeout (10 minutes here, 60s SDK default). Silent legacy stdio +// servers never answer that probe; cap it so fallback fits inside +// the discovery window. Remote HTTP/SSE/WS skip the probe +// entirely (`mode: 'legacy'`): SDK v2 rejects HTTP probe timeouts +// with no `initialize` fallback. Modern-only remotes are deferred +// until that SDK gap closes. +export const MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS = 5_000; +/** Reserve a full handshake window after a silent stdio discovery probe. */ +export const MCP_VERSION_NEGOTIATION_FALLBACK_HEADROOM_MS = + MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS; +export const MCP_APPS_EXTENSION_ID = 'io.modelcontextprotocol/ui'; +export const MCP_APP_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'; const debugLogger = createDebugLogger('MCP'); const AUTOMATIC_MCP_OAUTH_TIMEOUT_MS = 60_000; @@ -349,6 +364,90 @@ export type DiscoveredMCPResource = Resource & { serverName: string; }; +export type McpVersionNegotiation = + | { mode: 'legacy' } + | { mode: 'auto'; probe: { timeoutMs: number } }; + +/** + * External stdio clients negotiate automatically only when explicitly opted + * in. Internal, remote, and default stdio transports stay on legacy. + */ +export function mcpVersionNegotiationFor( + cfg: MCPServerConfig, +): McpVersionNegotiation { + if ( + cfg.versionNegotiation !== 'auto' || + isSdkMcpServerConfig(cfg) || + !cfg.command || + cfg.httpUrl || + cfg.url || + cfg.tcp + ) { + return { mode: 'legacy' }; + } + const discoveryTimeoutMs = discoveryTimeoutFor(cfg); + const probeTimeoutMs = Math.min( + MCP_VERSION_NEGOTIATION_PROBE_TIMEOUT_MS, + discoveryTimeoutMs - MCP_VERSION_NEGOTIATION_FALLBACK_HEADROOM_MS, + ); + if (probeTimeoutMs <= 0) { + return { mode: 'legacy' }; + } + return { + mode: 'auto', + probe: { timeoutMs: probeTimeoutMs }, + }; +} + +export function createMcpClient(name: string, cfg: MCPServerConfig): Client { + return new Client( + { name, version: '0.0.1' }, + { + versionNegotiation: mcpVersionNegotiationFor(cfg), + // Keep listing until the server ends pagination. The SDK still stops on + // repeated cursors, while its default 64-page cap turns larger valid + // catalogs into a discovery failure that this module reports as empty. + listMaxPages: 0, + capabilities: { + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE], + }, + }, + }, + }, + ); +} + +export function getMcpAppResourceUri(tool: { + _meta?: Record; +}): string | undefined { + const ui = tool._meta?.['ui']; + const nested = + typeof ui === 'object' && ui !== null && !Array.isArray(ui) + ? (ui as Record)['resourceUri'] + : undefined; + const value = nested ?? tool._meta?.['ui/resourceUri']; + return typeof value === 'string' && value.startsWith('ui://') + ? value + : undefined; +} + +/** SEP-1865: omit tools whose visibility does not include `"model"`. */ +export function isMcpToolVisibleToModel(tool: { + _meta?: Record; +}): boolean { + const ui = tool._meta?.['ui']; + if (typeof ui !== 'object' || ui === null || Array.isArray(ui)) { + return true; + } + const visibility = (ui as Record)['visibility']; + if (visibility === undefined || visibility === null) { + return true; + } + return Array.isArray(visibility) && visibility.includes('model'); +} + /** * Enum representing the overall MCP discovery state */ @@ -398,10 +497,10 @@ export class McpClient { private readonly debugMode: boolean, private readonly sendSdkMcpMessage?: SendSdkMcpMessage, ) { - this.client = new Client({ - name: `qwen-cli-mcp-client-${this.serverName}`, - version: '0.0.1', - }); + this.client = createMcpClient( + `qwen-cli-mcp-client-${this.serverName}`, + this.serverConfig, + ); } /** @@ -447,7 +546,7 @@ export class McpClient { roots: {}, }); - this.client.setRequestHandler(ListRootsRequestSchema, async () => { + this.client.setRequestHandler('roots/list', async () => { const roots = []; for (const dir of this.workspaceContext.getDirectories()) { roots.push({ @@ -594,10 +693,10 @@ export class McpClient { // requests by JSON-RPC id) to save round-trips per server at startup. // Each helper retries transient errors internally, then swallows // permanent errors and returns [], so Promise.all never rejects here. - const [prompts, resources, tools] = await Promise.all([ + const [prompts, resources, toolDiscovery] = await Promise.all([ listMcpPrompts(this.serverName, this.client), listMcpResources(this.serverName, this.client), - discoverTools( + discoverToolsWithMetadata( this.serverName, this.serverConfig, this.client, @@ -605,11 +704,13 @@ export class McpClient { { applyConfigFilters: opts?.applyConfigFilters ?? true }, ), ]); + const tools = applyListingAppResourceUi(toolDiscovery.tools, resources); if ( prompts.length === 0 && tools.length === 0 && - resources.length === 0 + resources.length === 0 && + !toolDiscovery.hadVisibilityFilteredTools ) { throw new Error('No prompts, tools, or resources found on the server.'); } @@ -707,9 +808,12 @@ export class McpClient { // but under-declares the `resources` capability would otherwise have its // resources discovered, listed in `/mcp`, and offered in `@server:` // completion, yet fail on read with a misleading "does not support - // resources" error. The underlying `request` is the raw `Protocol.request` - // (no capability assertion); a server that genuinely lacks resources - // answers `-32601`, which surfaces naturally to the caller. + // resources" error. The v2 typed helper skips the wire request when the + // capability is omitted, so modern sessions use it only when `resources` + // is declared; otherwise we issue the same raw request as the legacy path. + if (canUseModernTypedHelper(this.client, 'resources')) { + return this.client.readResource({ uri }, options); + } return this.client.request( { method: 'resources/read', params: { uri } }, ReadResourceResultSchema, @@ -1286,15 +1390,21 @@ export async function connectAndDiscover( mcpClient, cliConfig.getResourceRegistry(), ); - const tools = await discoverTools( + const toolDiscovery = await discoverToolsWithMetadata( mcpServerName, mcpServerConfig, mcpClient, cliConfig, ); + const tools = applyListingAppResourceUi(toolDiscovery.tools, resources); // If we found no prompts, resources, or tools, it's a failed discovery - if (prompts.length === 0 && resources.length === 0 && tools.length === 0) { + if ( + prompts.length === 0 && + resources.length === 0 && + tools.length === 0 && + !toolDiscovery.hadVisibilityFilteredTools + ) { throw new Error('No prompts, tools, or resources found on the server.'); } @@ -1332,6 +1442,28 @@ export async function connectAndDiscover( * @returns A promise that resolves to an array of discovered and enabled tools. * @throws An error if no enabled tools are found or if the server provides invalid function declarations. */ +function applyListingAppResourceUi( + tools: DiscoveredMCPTool[], + resources: readonly DiscoveredMCPResource[], +): DiscoveredMCPTool[] { + if (tools.length === 0 || resources.length === 0) return tools; + const uiByUri = new Map>(); + for (const resource of resources) { + const meta = resource._meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue; + const ui = meta['ui']; + if (!ui || typeof ui !== 'object' || Array.isArray(ui)) continue; + uiByUri.set(resource.uri, ui as Record); + } + if (uiByUri.size === 0) return tools; + return tools.map((tool) => { + const listingUi = tool.appResourceUri + ? uiByUri.get(tool.appResourceUri) + : undefined; + return listingUi ? tool.withAppResourceUi(listingUi) : tool; + }); +} + export async function discoverTools( mcpServerName: string, mcpServerConfig: MCPServerConfig, @@ -1339,9 +1471,57 @@ export async function discoverTools( cliConfig: Config, opts?: { applyConfigFilters?: boolean }, ): Promise { + return ( + await discoverToolsWithMetadata( + mcpServerName, + mcpServerConfig, + mcpClient, + cliConfig, + opts, + ) + ).tools; +} + +type ToolDiscoveryResult = { + tools: DiscoveredMCPTool[]; + hadVisibilityFilteredTools: boolean; +}; + +async function discoverToolsWithMetadata( + mcpServerName: string, + mcpServerConfig: MCPServerConfig, + mcpClient: Client, + cliConfig: Config, + opts?: { applyConfigFilters?: boolean }, +): Promise { try { const { mcpToTool } = await import('@google/genai'); - const mcpCallableTool = mcpToTool(mcpClient, { + const listedTools: Array< + Awaited>['tools'][number] + > = []; + let didListTools = false; + const listTools = async (params?: { cursor?: string }) => { + // Clients without getProtocolEra (tests and the genai adapter) keep + // the typed helper. Modern sessions use it only when `tools` is + // declared; otherwise the v2 helper returns [] without a wire request. + const result = + typeof mcpClient.getProtocolEra !== 'function' || + canUseModernTypedHelper(mcpClient, 'tools') + ? await mcpClient.listTools(params) + : await mcpClient.request( + { method: 'tools/list', params: params ?? {} }, + ListToolsResultSchema, + ); + didListTools = true; + listedTools.push(...result.tools); + return result; + }; + // @google/genai still types this structural adapter against SDK v1. Its + // discovery path only calls listTools(); execution stays on the v2 client. + // Legacy listTools must remain lenient because some servers implement + // tools/list without declaring the tools capability during initialization. + const discoveryClient = { listTools } as unknown as GenAiMcpClient; + const mcpCallableTool = mcpToTool(discoveryClient, { timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, }); const tool = await retryWithBackoff( @@ -1351,18 +1531,21 @@ export async function discoverTools( if (!Array.isArray(tool.functionDeclarations)) { // This is a valid case for a prompt-only server - return []; + return { tools: [], hadVisibilityFilteredTools: false }; } // Fetch raw tool list from MCP client to get annotations (readOnlyHint, etc.) // that are not preserved by mcpToTool's functionDeclarations conversion. const annotationsMap = new Map(); + const appResourceUriMap = new Map(); try { - const listToolsResult = await mcpClient.listTools(); - for (const mcpTool of listToolsResult.tools) { + if (!didListTools) await listTools(); + for (const mcpTool of listedTools) { if (mcpTool.annotations) { annotationsMap.set(mcpTool.name, mcpTool.annotations); } + const resourceUri = getMcpAppResourceUri(mcpTool); + if (resourceUri) appResourceUriMap.set(mcpTool.name, resourceUri); } } catch { // If listTools fails, proceed without annotations — non-critical @@ -1374,6 +1557,7 @@ export async function discoverTools( const mcpTimeout = mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC; const applyConfigFilters = opts?.applyConfigFilters ?? true; const discoveredTools: DiscoveredMCPTool[] = []; + let hadVisibilityFilteredTools = false; for (const funcDecl of tool.functionDeclarations) { try { if (!funcDecl.name) { @@ -1399,6 +1583,14 @@ export async function discoverTools( continue; } + const listed = listedTools.find( + (mcpTool) => mcpTool.name === funcDecl.name, + ); + if (listed && !isMcpToolVisibleToModel(listed)) { + hadVisibilityFilteredTools = true; + continue; + } + discoveredTools.push( new DiscoveredMCPTool( mcpCallableTool, @@ -1415,6 +1607,7 @@ export async function discoverTools( annotationsMap.get(funcDecl.name!), mcpServerConfig.alwaysLoadTools === true, invocationContextClients.has(mcpClient), + appResourceUriMap.get(funcDecl.name!), ), ); } catch (error) { @@ -1425,7 +1618,7 @@ export async function discoverTools( ); } } - return discoveredTools; + return { tools: discoveredTools, hadVisibilityFilteredTools }; } catch (error) { if (!isMethodNotFound(error)) { debugLogger.error( @@ -1434,7 +1627,7 @@ export async function discoverTools( )}`, ); } - return []; + return { tools: [], hadVisibilityFilteredTools: false }; } } @@ -1452,6 +1645,22 @@ function isMethodNotFound(error: unknown): boolean { return error instanceof Error && error.message.includes('Method not found'); } +/** + * v2 typed list/read helpers return empty (or skip the call) when the + * server omitted that capability. Use them only when the capability is + * declared; otherwise fall back to a raw request so under-declared + * servers stay visible. + */ +function canUseModernTypedHelper( + mcpClient: Client, + capability: 'prompts' | 'resources' | 'tools', +): boolean { + return ( + mcpClient.getProtocolEra?.() === 'modern' && + Boolean(mcpClient.getServerCapabilities?.()?.[capability]) + ); +} + /** * Pure prompt listing. Asks the MCP server for its prompts and returns * enriched `DiscoveredMCPPrompt[]` (with `serverName` + bound `invoke`) @@ -1462,15 +1671,14 @@ function isMethodNotFound(error: unknown): boolean { * Returns `[]` on protocol errors or when the server has no prompts — * matches `discoverPrompts` swallow-and-continue behavior. * - * We deliberately do NOT gate on `getServerCapabilities()?.prompts`. A - * non-trivial number of real MCP servers implement `prompts/list` but - * under-declare (or omit) the `prompts` capability in their `initialize` - * response; gating on the declared capability made those servers' prompts - * silently invisible in qwen-code (no `/`-menu entry) while lenient - * clients still surfaced them. The underlying `mcpClient.request` is the - * raw `Protocol.request` (the SDK only asserts capabilities for its typed - * `listPrompts()` helper, which we don't use), so attempting the call is - * safe: a server that truly lacks prompts answers `-32601 Method not + * We deliberately do NOT skip the wire request when + * `getServerCapabilities()?.prompts` is absent. A non-trivial number of + * real MCP servers implement `prompts/list` but under-declare (or omit) + * the `prompts` capability in their `initialize` response; the v2 typed + * helper returns `[]` without a request in that case. Modern sessions + * therefore use the helper only when the capability is declared; + * otherwise we issue the same raw `prompts/list` request as the legacy + * path. A server that truly lacks prompts answers `-32601 Method not * found`, which the catch below swallows silently. */ export async function listMcpPrompts( @@ -1480,10 +1688,12 @@ export async function listMcpPrompts( try { const response = await retryWithBackoff( () => - mcpClient.request( - { method: 'prompts/list', params: {} }, - ListPromptsResultSchema, - ), + canUseModernTypedHelper(mcpClient, 'prompts') + ? mcpClient.listPrompts() + : mcpClient.request( + { method: 'prompts/list', params: {} }, + ListPromptsResultSchema, + ), `${mcpServerName}/prompts/list`, ); @@ -1548,16 +1758,17 @@ export async function invokeMcpPrompt( promptParams: Record, ): Promise { try { - const response = await mcpClient.request( - { - method: 'prompts/get', - params: { - name: promptName, - arguments: promptParams, - }, - }, - GetPromptResultSchema, - ); + const params = { + name: promptName, + arguments: promptParams as Record, + }; + const response = + mcpClient.getProtocolEra?.() === 'modern' + ? await mcpClient.getPrompt(params) + : await mcpClient.request( + { method: 'prompts/get', params }, + GetPromptResultSchema, + ); return response; } catch (error) { @@ -1578,15 +1789,16 @@ export async function invokeMcpPrompt( * transport can produce the snapshot once and let each session register * into its own registry. Mirrors `listMcpPrompts`. * - * As with prompts, we do NOT gate on `getServerCapabilities()?.resources`: - * some servers expose resources but under-declare the capability, and the - * raw `mcpClient.request` does not assert capabilities. A server with no - * resources answers `-32601 Method not found`, swallowed below. + * As with prompts, we do NOT skip the wire request when + * `getServerCapabilities()?.resources` is absent: some servers expose + * resources but under-declare the capability. The v2 typed helper returns + * `[]` without a request in that case, so modern sessions use it only + * when `resources` is declared; otherwise we issue the same raw request + * as the legacy path. A server with no resources answers `-32601 Method + * not found`, swallowed below. * - * Note: cursor pagination is not followed (matching `listMcpPrompts`); - * only the first page of resources is returned. Servers that paginate - * their resource list would have later pages omitted — acceptable parity - * with the prompt path and rare in practice. + * When the capability is declared, the v2 helper also aggregates cursor + * pagination. Legacy sessions preserve the existing first-page behavior. */ export async function listMcpResources( mcpServerName: string, @@ -1595,10 +1807,12 @@ export async function listMcpResources( try { const response = await retryWithBackoff( () => - mcpClient.request( - { method: 'resources/list', params: {} }, - ListResourcesResultSchema, - ), + canUseModernTypedHelper(mcpClient, 'resources') + ? mcpClient.listResources() + : mcpClient.request( + { method: 'resources/list', params: {} }, + ListResourcesResultSchema, + ), `${mcpServerName}/resources/list`, ); @@ -1671,10 +1885,7 @@ export async function connectToMcpServer( sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { clearMcpOAuthRequirement(mcpServerName, mcpServerConfig); - const mcpClient = new Client({ - name: 'qwen-code-mcp-client', - version: '0.0.1', - }); + const mcpClient = createMcpClient('qwen-code-mcp-client', mcpServerConfig); mcpClient.registerCapabilities({ roots: { @@ -1682,7 +1893,7 @@ export async function connectToMcpServer( }, }); - mcpClient.setRequestHandler(ListRootsRequestSchema, async () => { + mcpClient.setRequestHandler('roots/list', async () => { const roots = []; for (const dir of workspaceContext.getDirectories()) { roots.push({ diff --git a/packages/core/src/tools/mcp-pool-key.test.ts b/packages/core/src/tools/mcp-pool-key.test.ts index 90233465c29..243640ad2e2 100644 --- a/packages/core/src/tools/mcp-pool-key.test.ts +++ b/packages/core/src/tools/mcp-pool-key.test.ts @@ -112,6 +112,21 @@ describe('mcp-pool-key', () => { const fp = fingerprint(new MCPServerConfig('node')); expect(fp).toMatch(/^[0-9a-f]{16}$/); }); + + it('separates explicit automatic negotiation from the default legacy mode', () => { + const base = { command: 'node' } as MCPServerConfig; + const legacy = { + ...base, + versionNegotiation: 'legacy', + } as MCPServerConfig; + const automatic = { + ...base, + versionNegotiation: 'auto', + } as MCPServerConfig; + + expect(fingerprint(base)).toBe(fingerprint(legacy)); + expect(fingerprint(base)).not.toBe(fingerprint(automatic)); + }); }); describe('canonicalOAuth (V21-9)', () => { diff --git a/packages/core/src/tools/mcp-pool-key.ts b/packages/core/src/tools/mcp-pool-key.ts index b2ccdc2a76b..b259586e596 100644 --- a/packages/core/src/tools/mcp-pool-key.ts +++ b/packages/core/src/tools/mcp-pool-key.ts @@ -111,7 +111,8 @@ function sortedEntries( * * Hashed fields (transport-defining): * transport, command, args, cwd, env, url, httpUrl, tcp, headers, - * timeout, oauth, authProviderType, targetAudience, targetServiceAccount + * timeout, versionNegotiation, oauth, authProviderType, targetAudience, + * targetServiceAccount * * Excluded fields (per-session filter / metadata; do NOT change the * underlying transport): @@ -137,6 +138,7 @@ export function fingerprint(cfg: MCPServerConfig): PoolKey { tcp: cfg.tcp ?? null, headers: sortedEntries(cfg.headers), timeout: cfg.timeout ?? null, + automaticVersionNegotiation: cfg.versionNegotiation === 'auto', oauth: canonicalOAuth(cfg.oauth), authProviderType: cfg.authProviderType ?? null, targetAudience: cfg.targetAudience ?? null, diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index 9ad48b4bba2..92160aad248 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -170,7 +170,6 @@ describe('DiscoveredMCPTool', () => { [INVOCATION_CONTEXT_META_KEY]: invocationContext, }, }, - undefined, expect.objectContaining({ onprogress: expect.any(Function) }), ); }); @@ -921,7 +920,7 @@ describe('DiscoveredMCPTool', () => { it('forwards parent abort into the combined signal passed to the direct SDK client', async () => { let capturedSignal: AbortSignal | undefined; const mockDirectCallTool = vi.fn( - async (_params, _schema, options) => { + async (_params, options) => { capturedSignal = options?.signal; return new Promise(() => {}); }, @@ -1151,6 +1150,181 @@ describe('DiscoveredMCPTool', () => { }); }); + describe('MCP Apps display', () => { + const createAppTool = ( + mcpClient: McpDirectClient, + appResourceUi?: Record, + ) => + new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + undefined, + undefined, + undefined, + mcpClient, + undefined, + undefined, + undefined, + false, + false, + 'ui://demo/dashboard', + appResourceUi, + ); + + it('loads an MCP App resource without changing model-visible content', async () => { + const mcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Dashboard ready' }], + structuredContent: { revenue: 42 }, + })), + readResource: vi.fn(async () => ({ + contents: [ + { + uri: 'ui://demo/dashboard', + mimeType: 'text/html;profile=mcp-app', + text: '
Revenue
', + _meta: { + ui: { + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }, + }, + }, + ], + })), + }; + + const result = await createAppTool(mcpClient) + .build({ param: 'test' }) + .execute(new AbortController().signal); + + expect(result.llmContent).toEqual([{ text: 'Dashboard ready' }]); + expect(result.returnDisplay).toMatchObject({ + type: 'mcp_app', + resourceUri: 'ui://demo/dashboard', + html: '
Revenue
', + toolArguments: { param: 'test' }, + fallbackText: 'Dashboard ready', + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }); + }); + + it('uses listing-level app metadata when resources/read omits content _meta', async () => { + const mcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Dashboard ready' }], + })), + readResource: vi.fn(async () => ({ + contents: [ + { + uri: 'ui://demo/dashboard', + mimeType: 'text/html;profile=mcp-app', + text: '
Revenue
', + }, + ], + })), + }; + + const result = await createAppTool(mcpClient, { + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }) + .build({ param: 'test' }) + .execute(new AbortController().signal); + + expect(result.returnDisplay).toMatchObject({ + type: 'mcp_app', + html: '
Revenue
', + csp: { connectDomains: ['https://api.example.com'] }, + permissions: { clipboardWrite: {} }, + }); + }); + + it('lets content-level app metadata win over listing-level defaults', async () => { + const mcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Dashboard ready' }], + })), + readResource: vi.fn(async () => ({ + contents: [ + { + uri: 'ui://demo/dashboard', + mimeType: 'text/html;profile=mcp-app', + text: '
Revenue
', + _meta: { + ui: { + csp: { connectDomains: ['https://content.example.com'] }, + }, + }, + }, + ], + })), + }; + + const result = await createAppTool(mcpClient, { + csp: { connectDomains: ['https://listing.example.com'] }, + permissions: { clipboardWrite: {} }, + }) + .build({ param: 'test' }) + .execute(new AbortController().signal); + + expect(result.returnDisplay).toMatchObject({ + type: 'mcp_app', + csp: { connectDomains: ['https://content.example.com'] }, + }); + expect( + (result.returnDisplay as { permissions?: unknown }).permissions, + ).toBeUndefined(); + }); + + it('falls back to the normal tool text when the app resource is invalid', async () => { + const mcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Dashboard ready' }], + })), + readResource: vi.fn(async () => ({ + contents: [ + { + uri: 'ui://demo/dashboard', + mimeType: 'text/html', + text: '
Wrong MIME
', + }, + ], + })), + }; + + const result = await createAppTool(mcpClient) + .build({ param: 'test' }) + .execute(new AbortController().signal); + + expect(result.returnDisplay).toBe('Dashboard ready'); + }); + + it('keeps the tool result when aborting the optional app resource fetch', async () => { + const controller = new AbortController(); + const mcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Dashboard ready' }], + })), + readResource: vi.fn(async () => { + controller.abort(); + throw new DOMException('Aborted', 'AbortError'); + }), + }; + + const result = await createAppTool(mcpClient) + .build({ param: 'test' }) + .execute(controller.signal); + + expect(result.llmContent).toEqual([{ text: 'Dashboard ready' }]); + expect(result.returnDisplay).toBe('Dashboard ready'); + }); + }); + describe('output truncation for large MCP results', () => { const THRESHOLD = 1000; const TRUNCATE_LINES = 50; @@ -1391,7 +1565,7 @@ describe('DiscoveredMCPTool', () => { // When callTool is called with an onprogress callback, it invokes // the callback to simulate the MCP server sending progress updates. const mockMcpClient: McpDirectClient = { - callTool: vi.fn(async (_params, _schema, options) => { + callTool: vi.fn(async (_params, options) => { // Simulate 3 progress notifications from the MCP server for (let i = 1; i <= 3; i++) { await new Promise((resolve) => setTimeout(resolve, 10)); @@ -1472,7 +1646,7 @@ describe('DiscoveredMCPTool', () => { ]; const mockMcpClient: McpDirectClient = { - callTool: vi.fn(async (_params, _schema, options) => { + callTool: vi.fn(async (_params, options) => { for (let i = 0; i < steps.length; i++) { await new Promise((resolve) => setTimeout(resolve, 10)); options?.onprogress?.({ @@ -2464,7 +2638,7 @@ describe('DiscoveredMCPTool', () => { const discoverToolsForServer = vi.fn(); const mockMcpClient: McpDirectClient = { callTool: vi.fn().mockImplementation( - (_params, _schema, options) => + (_params, options) => new Promise((_resolve, reject) => { options?.signal?.addEventListener( 'abort', @@ -2588,7 +2762,7 @@ describe('DiscoveredMCPTool', () => { const idleTimeoutMs = 1000; // 1 second for testing const mockMcpClient: McpDirectClient = { callTool: vi.fn().mockImplementation( - (_params, _schema, options) => + (_params, options) => new Promise((_resolve, reject) => { // Simulate SDK behavior: reject when signal is aborted options?.signal?.addEventListener('abort', () => { @@ -2642,7 +2816,7 @@ describe('DiscoveredMCPTool', () => { const idleTimeoutMs = 1000; const mockMcpClient: McpDirectClient = { callTool: vi.fn().mockImplementation( - (_params, _schema, options) => + (_params, options) => new Promise((_resolve, reject) => { options?.signal?.addEventListener('abort', () => { queueMicrotask(() => reject(options.signal?.reason)); @@ -2686,7 +2860,7 @@ describe('DiscoveredMCPTool', () => { let onProgressCallback: ((progress: any) => void) | undefined; const mockMcpClient: McpDirectClient = { - callTool: vi.fn().mockImplementation((_params, _schema, options) => { + callTool: vi.fn().mockImplementation((_params, options) => { onProgressCallback = options?.onprogress; return new Promise((resolve, reject) => { // Listen for abort signal to properly reject when timeout fires diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index c86194bcb86..5874a76e620 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -13,6 +13,10 @@ import type { ToolResultDisplay, ToolConfirmationPayload, McpToolProgressData, + McpAppResultDisplay, + McpAppResourceCsp, + McpAppResourcePermissions, + McpAppToolResult, ToolConfirmationOutcome, } from './tools.js'; import type { PermissionDecision } from '../permissions/types.js'; @@ -160,7 +164,7 @@ type ToolParams = Record; /** * Minimal interface for the raw MCP Client's callTool method. - * This avoids a direct import of @modelcontextprotocol/sdk in this file, + * This avoids a direct import of the MCP SDK in this file, * keeping the dependency contained in mcp-client.ts. */ export interface McpDirectClient { @@ -170,7 +174,6 @@ export interface McpDirectClient { arguments?: Record; _meta?: Record; }, - resultSchema?: unknown, options?: { onprogress?: (progress: { progress: number; @@ -181,21 +184,31 @@ export interface McpDirectClient { signal?: AbortSignal; }, ): Promise; + readResource?( + params: { uri: string }, + options?: { timeout?: number; signal?: AbortSignal }, + ): Promise; } /** The result shape returned by MCP SDK Client.callTool(). */ -interface McpCallToolResult { - content?: Array<{ - type: string; - text?: string; - data?: string; +type McpCallToolResult = McpAppToolResult; + +interface McpReadResourceResult { + contents: Array<{ + uri: string; mimeType?: string; + text?: string; + blob?: string; + _meta?: Record; [key: string]: unknown; }>; - isError?: boolean; [key: string]: unknown; } +const MCP_APP_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'; +const MCP_APP_RESOURCE_MAX_BYTES = 1024 * 1024; +const MCP_APP_RESOURCE_TIMEOUT_MS = 10_000; + // Discriminated union for MCP Content Blocks to ensure type safety. type McpTextBlock = { type: 'text'; @@ -265,6 +278,8 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< private readonly mcpToolIdleTimeoutMs?: number, private readonly annotations?: McpToolAnnotations, private readonly allowInvocationContext: boolean = false, + private readonly appResourceUri?: string, + private readonly appResourceUi?: Record, private readonly retryCount: number = 0, ) { super(params); @@ -402,6 +417,8 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< this.mcpToolIdleTimeoutMs, newTool.annotations, newTool['allowInvocationContext'] === true, + newTool['appResourceUri'], + newTool.appResourceUi, this.retryCount + 1, ); if (!newInvocation.canSafelyReplay()) { @@ -551,7 +568,6 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< } : {}), }, - undefined, { onprogress: (progress) => { // Reset idle timeout on progress @@ -580,6 +596,11 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< } const callToolResult = outcome; + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + idleTimeoutId = undefined; + } + // Wrap the raw CallToolResult into the Part[] format that the // existing transform/display functions expect. const rawResponseParts = wrapMcpCallToolResultAsParts( @@ -596,13 +617,19 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< const transformedParts = transformMcpContentToParts(rawResponseParts); const truncated = await this.truncateTextParts(transformedParts); + const fallbackText = getDisplayFromPartsWithPersistedOutput( + transformedParts, + truncated.persistedOutputFiles, + ); + const appDisplay = await this.loadMcpAppDisplay( + callToolResult, + fallbackText, + signal, + ); return { llmContent: truncated.parts, - returnDisplay: getDisplayFromPartsWithPersistedOutput( - transformedParts, - truncated.persistedOutputFiles, - ), + returnDisplay: appDisplay ?? fallbackText, persistedOutputFiles: truncated.persistedOutputFiles, }; } catch (error) { @@ -627,6 +654,69 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< } } + private async loadMcpAppDisplay( + toolResult: McpCallToolResult, + fallbackText: string, + signal: AbortSignal, + ): Promise { + if (!this.appResourceUri || !this.mcpClient?.readResource) return undefined; + + try { + const resource = await this.mcpClient.readResource( + { uri: this.appResourceUri }, + { + timeout: Math.min( + this.mcpTimeout ?? MCP_APP_RESOURCE_TIMEOUT_MS, + MCP_APP_RESOURCE_TIMEOUT_MS, + ), + signal: AbortSignal.any([ + signal, + AbortSignal.timeout(MCP_APP_RESOURCE_TIMEOUT_MS), + ]), + }, + ); + const content = resource.contents.find( + (entry) => entry.uri === this.appResourceUri, + ); + if (!content || content.mimeType !== MCP_APP_RESOURCE_MIME_TYPE) { + throw new Error( + `resource must return ${MCP_APP_RESOURCE_MIME_TYPE} for ${this.appResourceUri}`, + ); + } + const html = + typeof content.text === 'string' + ? content.text + : typeof content.blob === 'string' + ? Buffer.from(content.blob, 'base64').toString('utf8') + : undefined; + if (!html) throw new Error('resource did not return HTML content'); + if (Buffer.byteLength(html, 'utf8') > MCP_APP_RESOURCE_MAX_BYTES) { + throw new Error('resource HTML exceeds the 1 MiB host limit'); + } + + const metadata = getMcpAppResourceMetadata( + content._meta, + this.appResourceUi, + ); + return { + type: 'mcp_app', + serverName: this.serverName, + resourceUri: this.appResourceUri, + html, + toolResult, + toolArguments: this.params, + fallbackText, + ...metadata, + }; + } catch (error) { + if (signal.aborted) return undefined; + debugLogger.warn( + `Failed to load MCP App '${this.appResourceUri}' from '${this.serverName}': ${getErrorMessage(error)}`, + ); + return undefined; + } + } + /** * Fallback: execute using the @google/genai CallableTool wrapper. * This path does NOT support progress notifications. @@ -810,6 +900,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< readonly annotations?: McpToolAnnotations, alwaysLoad = false, private readonly allowInvocationContext: boolean = false, + readonly appResourceUri?: string, + readonly appResourceUi?: Record, ) { super( nameOverride ?? @@ -845,6 +937,32 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.annotations, this.alwaysLoad, this.allowInvocationContext, + this.appResourceUri, + this.appResourceUi, + ); + } + + withAppResourceUi( + appResourceUi: Record | undefined, + ): DiscoveredMCPTool { + if (appResourceUi === this.appResourceUi) return this; + return new DiscoveredMCPTool( + this.mcpTool, + this.serverName, + this.serverToolName, + this.description, + this.parameterSchema, + this.trust, + this.name, + this.cliConfig, + this.mcpClient, + this.mcpTimeout, + this.mcpToolIdleTimeoutMs, + this.annotations, + this.alwaysLoad, + this.allowInvocationContext, + this.appResourceUri, + appResourceUi, ); } @@ -891,6 +1009,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.annotations, alwaysLoad, this.allowInvocationContext, + this.appResourceUri, + this.appResourceUi, ); } @@ -912,10 +1032,61 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.mcpToolIdleTimeoutMs, this.annotations, this.allowInvocationContext, + this.appResourceUri, + this.appResourceUi, ); } } +function getMcpAppResourceMetadata( + meta: Record | undefined, + listingUi?: Record, +): { + csp?: McpAppResourceCsp; + permissions?: McpAppResourcePermissions; +} { + const ui = getRecord(meta?.['ui']) ?? listingUi; + const rawCsp = getRecord(ui?.['csp']); + const rawPermissions = getRecord(ui?.['permissions']); + const csp = rawCsp + ? { + ...readStringArray(rawCsp, 'connectDomains'), + ...readStringArray(rawCsp, 'resourceDomains'), + ...readStringArray(rawCsp, 'frameDomains'), + ...readStringArray(rawCsp, 'baseUriDomains'), + } + : undefined; + const permissions = rawPermissions + ? Object.fromEntries( + ['camera', 'microphone', 'geolocation', 'clipboardWrite'] + .filter((key) => getRecord(rawPermissions[key])) + .map((key) => [key, {}]), + ) + : undefined; + return { + ...(csp && Object.keys(csp).length > 0 ? { csp } : {}), + ...(permissions && Object.keys(permissions).length > 0 + ? { permissions: permissions as McpAppResourcePermissions } + : {}), + }; +} + +function getRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readStringArray( + value: Record, + key: keyof McpAppResourceCsp, +): Partial { + const entry = value[key]; + return Array.isArray(entry) && entry.every((item) => typeof item === 'string') + ? { [key]: entry } + : {}; +} + /** * Wraps a raw MCP CallToolResult into the Part[] format that the * existing transform/display functions expect. This bridges the gap diff --git a/packages/core/src/tools/mcp-transport-pool.test.ts b/packages/core/src/tools/mcp-transport-pool.test.ts index e61da1bc903..94ceff3a550 100644 --- a/packages/core/src/tools/mcp-transport-pool.test.ts +++ b/packages/core/src/tools/mcp-transport-pool.test.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as ClientLib from '@modelcontextprotocol/sdk/client/index.js'; -import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js'; +import * as ClientLib from '@modelcontextprotocol/client'; +import * as SdkClientStdioLib from '@modelcontextprotocol/client/stdio'; import * as GenAiLib from '@google/genai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MCPServerConfig, type Config } from '../config/config.js'; @@ -21,8 +21,12 @@ import { import { SessionMcpView } from './session-mcp-view.js'; import type { ToolRegistry } from './tool-registry.js'; -vi.mock('@modelcontextprotocol/sdk/client/index.js'); -vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); +vi.mock('@modelcontextprotocol/client', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, Client: vi.fn() }; +}); +vi.mock('@modelcontextprotocol/client/stdio'); vi.mock('@google/genai'); // F2 (#4175 follow-up — W134): mocked so per-test overrides can make diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index a43bf1caeb7..4bebc7793b7 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -693,6 +693,46 @@ export interface McpToolProgressData { message?: string; } +export interface McpAppResourceCsp { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; +} + +export interface McpAppResourcePermissions { + camera?: Record; + microphone?: Record; + geolocation?: Record; + clipboardWrite?: Record; +} + +export interface McpAppToolResult { + content?: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + [key: string]: unknown; + }>; + isError?: boolean; + structuredContent?: unknown; + [key: string]: unknown; +} + +/** A completed MCP tool call with an interactive MCP Apps resource. */ +export interface McpAppResultDisplay { + type: 'mcp_app'; + serverName: string; + resourceUri: string; + html: string; + toolResult: McpAppToolResult; + toolArguments: Record; + fallbackText: string; + csp?: McpAppResourceCsp; + permissions?: McpAppResourcePermissions; +} + /** * Structured heartbeat for silent foreground shell commands, emitted through * the updateOutput channel while no display update has fired for @@ -759,6 +799,7 @@ export type ToolResultDisplay = | TaskListResultDisplay | AnsiOutputDisplay | McpToolProgressData + | McpAppResultDisplay | VisionBridgeNoticeDisplay | ShellProgressData | TerminalImageDisplay; diff --git a/packages/core/src/utils/toolResultDisplayCompaction.test.ts b/packages/core/src/utils/toolResultDisplayCompaction.test.ts index 5703f7c01a3..a497dd812a8 100644 --- a/packages/core/src/utils/toolResultDisplayCompaction.test.ts +++ b/packages/core/src/utils/toolResultDisplayCompaction.test.ts @@ -9,6 +9,7 @@ import type { AgentResultDisplay, AnsiOutputDisplay, FileDiff, + McpAppResultDisplay, McpToolProgressData, PlanResultDisplay, TaskListResultDisplay, @@ -380,6 +381,26 @@ describe('toolResultDisplayCompaction', () => { expect(compactedTask.tasks[0].owner).toContain('truncated from'); expect(compactedTeam.teamName).toContain('truncated from'); }); + + it('drops MCP App HTML and tool results from retained displays', () => { + const marker = 'PROBE_MCP_APP_HTML_UNIQUE_MARKER'; + const display: McpAppResultDisplay = { + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/dashboard', + html: `
${marker}${'x'.repeat(2000)}
`, + toolResult: { content: [{ type: 'text', text: 'Dashboard ready' }] }, + toolArguments: { region: 'APAC' }, + fallbackText: 'Dashboard ready', + }; + + const compacted = compactToolResultDisplayForHistory(display); + + expect(compacted.html).toBe(''); + expect(compacted.toolResult).toEqual({}); + expect(compacted.fallbackText).toBe('Dashboard ready'); + expect(JSON.stringify(compacted)).not.toContain(marker); + }); }); describe('compactString limit', () => { diff --git a/packages/core/src/utils/toolResultDisplayCompaction.ts b/packages/core/src/utils/toolResultDisplayCompaction.ts index e118c09b6f2..c6778f87e69 100644 --- a/packages/core/src/utils/toolResultDisplayCompaction.ts +++ b/packages/core/src/utils/toolResultDisplayCompaction.ts @@ -8,6 +8,7 @@ import type { AgentResultDisplay, AnsiOutputDisplay, FileDiff, + McpAppResultDisplay, McpToolProgressData, PlanResultDisplay, TaskListResultDisplay, @@ -474,6 +475,33 @@ function isTaskListResultDisplay( ); } +function isMcpAppResultDisplay( + resultDisplay: unknown, +): resultDisplay is McpAppResultDisplay { + return ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'type' in resultDisplay && + resultDisplay.type === 'mcp_app' + ); +} + +function compactMcpAppResultDisplay( + display: McpAppResultDisplay, + purpose: CompactionPurpose, +): McpAppResultDisplay { + return { + ...display, + html: '', + toolResult: {}, + fallbackText: compactString( + display.fallbackText, + purpose, + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + ), + }; +} + function compactTaskListResultDisplay( display: TaskListResultDisplay, purpose: CompactionPurpose, @@ -542,6 +570,10 @@ function compactToolResultDisplay( return compactTaskListResultDisplay(resultDisplay, purpose) as T; } + if (isMcpAppResultDisplay(resultDisplay)) { + return compactMcpAppResultDisplay(resultDisplay, purpose) as T; + } + return resultDisplay; } diff --git a/packages/desktop/apps/electron/src/renderer/pages/settings/QwenSettingsPage.tsx b/packages/desktop/apps/electron/src/renderer/pages/settings/QwenSettingsPage.tsx index 49dbd24db95..f22984cb30e 100644 --- a/packages/desktop/apps/electron/src/renderer/pages/settings/QwenSettingsPage.tsx +++ b/packages/desktop/apps/electron/src/renderer/pages/settings/QwenSettingsPage.tsx @@ -182,6 +182,7 @@ function createEmptyMcpDraft(): McpDraft { env: '', headers: '', timeout: '', + versionNegotiation: undefined, trust: false, description: '', includeTools: '', @@ -199,6 +200,7 @@ type McpDraft = { env: string; headers: string; timeout: string; + versionNegotiation?: 'auto' | 'legacy'; trust: boolean; description: string; includeTools: string; @@ -222,6 +224,7 @@ function serverToDraft(entry: QwenMcpServerEntry): McpDraft { env: stringifyKeyValueLines(server.env), headers: stringifyKeyValueLines(server.headers), timeout: server.timeout === undefined ? '' : String(server.timeout), + versionNegotiation: server.versionNegotiation, trust: server.trust ?? false, description: server.description ?? '', includeTools: stringifyLines(server.includeTools), @@ -236,6 +239,7 @@ function draftToServer(draft: McpDraft): QwenMcpServerConfig { const base = { transport: draft.transport, timeout, + versionNegotiation: draft.versionNegotiation, trust: draft.trust, description: draft.description.trim() || undefined, includeTools: parseLines(draft.includeTools), diff --git a/packages/desktop/packages/shared/src/protocol/dto.ts b/packages/desktop/packages/shared/src/protocol/dto.ts index 65eedeb4b5d..2d8af0da428 100644 --- a/packages/desktop/packages/shared/src/protocol/dto.ts +++ b/packages/desktop/packages/shared/src/protocol/dto.ts @@ -578,6 +578,7 @@ export interface QwenMcpServerConfig { url?: string headers?: Record timeout?: number + versionNegotiation?: 'auto' | 'legacy' trust?: boolean description?: string includeTools?: string[] diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts index 07d54a1c7c5..885cbf82c64 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts @@ -283,6 +283,7 @@ export interface MCPServerConfig { headers?: Record; tcp?: string; timeout?: number; + versionNegotiation?: 'auto' | 'legacy'; trust?: boolean; description?: string; includeTools?: string[]; diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 7d851a36987..aabfda41bc1 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3753,6 +3753,7 @@ export interface MCPServerConfigShape { readonly tcp?: string; readonly timeout?: number; readonly discoveryTimeoutMs?: number; + readonly versionNegotiation?: 'auto' | 'legacy'; readonly trust?: boolean; readonly description?: string; readonly oauth?: Record; diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 97b765fbb32..e2698288790 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -303,6 +303,7 @@ export interface MCPServerConfig { headers?: Record; tcp?: string; timeout?: number; + versionNegotiation?: 'auto' | 'legacy'; trust?: boolean; description?: string; includeTools?: string[]; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 20064a8dd85..dd05da81f20 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -107,6 +107,7 @@ export const CLIMcpServerConfigSchema = z.object({ tcp: z.string().optional(), // Common timeout: z.number().optional(), + versionNegotiation: z.enum(['auto', 'legacy']).optional(), trust: z.boolean().optional(), // Metadata description: z.string().optional(), diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index ca930178df7..5e7b0646156 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -142,6 +142,7 @@ export interface CLIMcpServerConfig { tcp?: string; // Common timeout?: number; + versionNegotiation?: 'auto' | 'legacy'; trust?: boolean; // Metadata description?: string; diff --git a/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts b/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts index c5aa71ca171..88eb424b649 100644 --- a/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts +++ b/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts @@ -11,6 +11,30 @@ describe('QueryOptionsSchema', () => { expect(result.success).toBe(true); }); + it('accepts automatic and legacy MCP negotiation policies', () => { + expect( + QueryOptionsSchema.safeParse({ + mcpServers: { + legacy: { command: 'node', versionNegotiation: 'legacy' }, + }, + }).success, + ).toBe(true); + expect( + QueryOptionsSchema.safeParse({ + mcpServers: { + automatic: { command: 'node', versionNegotiation: 'auto' }, + }, + }).success, + ).toBe(true); + expect( + QueryOptionsSchema.safeParse({ + mcpServers: { + invalid: { command: 'node', versionNegotiation: 'modern' }, + }, + }).success, + ).toBe(false); + }); + it('accepts fallbackModel with up to 3 models', () => { const result = QueryOptionsSchema.safeParse({ fallbackModel: ['a', 'b', 'c'], diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 758ef086b0e..1993b0957cf 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -3488,6 +3488,700 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +============================================================ +@modelcontextprotocol/client@2.0.0 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. + + +============================================================ +@modelcontextprotocol/core@2.0.0 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. + + +============================================================ +zod@4.4.3 +(git+https://github.com/colinhacks/zod.git) + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +cross-spawn@7.0.6 +(git@github.com:moxystudio/node-cross-spawn.git) + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +path-key@3.1.1 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-command@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-regex@3.0.0 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +which@2.0.2 +(git://github.com/isaacs/node-which.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +isexe@2.0.0 +(git+https://github.com/isaacs/isexe.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +eventsource@3.0.7 +(git://git@github.com/EventSource/eventsource.git) + +The MIT License + +Copyright (c) EventSource GitHub organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +eventsource-parser@3.0.3 +(git+ssh://git@github.com/rexxars/eventsource-parser.git) + +MIT License + +Copyright (c) 2025 Espen Hovlandsdal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +jose@6.1.3 +(No repository found) + +The MIT License (MIT) + +Copyright (c) 2018 Filip Skokan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +pkce-challenge@5.0.0 +(git+https://github.com/crouchcd/pkce-challenge.git) + +MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ============================================================ @modelcontextprotocol/sdk@1.30.0 (git+https://github.com/modelcontextprotocol/typescript-sdk.git) @@ -3824,175 +4518,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -cross-spawn@7.0.6 -(git@github.com:moxystudio/node-cross-spawn.git) - -The MIT License (MIT) - -Copyright (c) 2018 Made With MOXY Lda - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -============================================================ -path-key@3.1.1 -(No repository found) - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -shebang-command@2.0.0 -(No repository found) - -MIT License - -Copyright (c) Kevin Mårtensson (github.com/kevva) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -shebang-regex@3.0.0 -(No repository found) - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -which@2.0.2 -(git://github.com/isaacs/node-which.git) - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -============================================================ -isexe@2.0.0 -(git+https://github.com/isaacs/isexe.git) - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -============================================================ -eventsource@3.0.7 -(git://git@github.com/EventSource/eventsource.git) - -The MIT License - -Copyright (c) EventSource GitHub organisation - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -eventsource-parser@3.0.3 -(git+ssh://git@github.com/rexxars/eventsource-parser.git) - -MIT License - -Copyright (c) 2025 Espen Hovlandsdal - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ============================================================ express@5.2.1 (No repository found) @@ -5418,33 +5943,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -jose@6.1.3 -(No repository found) - -The MIT License (MIT) - -Copyright (c) 2018 Filip Skokan - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ============================================================ json-schema-typed@8.0.2 (https://github.com/RemyRylan/json-schema-typed.git) @@ -5508,33 +6006,6 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -============================================================ -pkce-challenge@5.0.0 -(git+https://github.com/crouchcd/pkce-challenge.git) - -MIT License - -Copyright (c) 2019 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ============================================================ zod@3.25.76 (git+https://github.com/colinhacks/zod.git) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index bcb34225b98..e52523b78c5 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -174,6 +174,7 @@ import { ExtensionsManagerPage } from './components/extensions/ExtensionsManager import { PluginManagerPage } from './components/plugins/PluginManagerPage'; import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage'; import { ShadowDomBoundary } from './components/ShadowDomBoundary'; +import { McpAppHostContext } from './mcpAppHostContext'; import { SettingsMessage } from './components/messages/SettingsMessage'; import { isAskUserPermission } from './utils/askUserPermission'; import { ToolApproval } from './components/messages/ToolApproval'; @@ -11294,8 +11295,9 @@ export function App({ return ( - {/* prettier-ignore */} - + + {/* prettier-ignore */} +
+
); diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index 0a0be8f3a5b..614e49eb9c9 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -1208,6 +1208,39 @@ describe('applyTurnCollapse', () => { }); }); + it('keeps a completed turn with an MCP App expanded by default', () => { + const appToolGroup: Extract = { + id: 'g1', + role: 'tool_group', + tools: [ + { + callId: 'call-app', + toolName: 'mcp__demo__dashboard', + status: 'completed', + rawOutput: { + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/dashboard', + html: '
Dashboard
', + toolResult: { content: [] }, + toolArguments: {}, + fallbackText: 'Dashboard ready', + }, + }, + ], + }; + const items = groupParallelAgents([ + makeUserMessage('u1'), + appToolGroup, + makeAssistantMessage('a1'), + ]); + + const out = collapseItems(items); + + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']); + expect(collapseOf(out, 0)?.collapsed).toBe(false); + }); + it('keeps narration followed by a tool visible when expanded', () => { const items = groupParallelAgents([ makeUserMessage('u1'), diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index e201d750a5f..d835f6b8986 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -57,6 +57,7 @@ import { isActiveToolStatus, toolContainsCallId, } from './messages/toolFormatting'; +import { getMcpAppDisplay } from './messages/McpApp'; import { isTodoWriteToolName } from '../utils/todos'; import turnCollapseStyles from './TurnCollapseRow.module.css'; import flashStyles from './MessageLocateFlash.module.css'; @@ -1524,6 +1525,19 @@ function turnHasActiveAgent( ); } +function turnHasMcpApp( + items: DisplayItem[], + start: number, + end: number, +): boolean { + return someTurnToolCall( + items, + start, + end, + (tool) => getMcpAppDisplay(tool.rawOutput) !== undefined, + ); +} + function completedBackgroundShellTaskIds( items: readonly DisplayItem[], terminalTaskIds?: ReadonlySet, @@ -1824,6 +1838,7 @@ export function applyTurnCollapse( const isLastTurn = k === userIdxs.length - 1; const isActiveTurn = isLastTurn && isResponding; const hasActiveAgent = turnHasActiveAgent(items, start, end); + const hasMcpApp = turnHasMcpApp(items, start, end); const hasPendingBackgroundShell = turnHasPendingBackgroundShell( items, start, @@ -1949,6 +1964,7 @@ export function applyTurnCollapse( const shouldStayOpen = isActiveTurn || hasActiveAgent || + hasMcpApp || hasPendingBackgroundShell || hasAutomaticallyExpandedAgent || awaitsBackgroundSummary || diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx index 9638af0d992..35f944b32d0 100644 --- a/packages/web-shell/client/components/WebShellTranscript.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.tsx @@ -43,6 +43,7 @@ import { } from '../themeContext'; import { TranscriptRenderModeProvider } from '../transcriptRenderMode'; import styles from '../App.module.css'; +import { McpAppHostContext } from '../mcpAppHostContext'; const DEFAULT_CHAT_MAX_WIDTH = 1000; const CHAT_SHELL_HORIZONTAL_PADDING = 40; @@ -67,6 +68,7 @@ export interface WebShellTranscriptProps { renderComposerTag?: ComposerTagRenderer; renderComposerTagTooltip?: ComposerTagRenderer; renderAssistantTurnFooter?: AssistantTurnFooterRenderer; + mcpAppBaseUrl?: string; } function resolveLanguage( @@ -117,6 +119,7 @@ function WebShellTranscriptContent({ renderComposerTag, renderComposerTagTooltip, renderAssistantTurnFooter, + mcpAppBaseUrl, }: WebShellTranscriptProps): ReactElement { const resolvedLanguage = resolveLanguage(language); const t = useMemo(() => getTranslator(resolvedLanguage), [resolvedLanguage]); @@ -233,38 +236,40 @@ function WebShellTranscriptContent({ return ( - - - - - - -
+ + + + + + +
- +
+ +
-
-
-
-
-
-
-
+ + + + + + +
); diff --git a/packages/web-shell/client/components/messages/McpApp.dom.test.tsx b/packages/web-shell/client/components/messages/McpApp.dom.test.tsx new file mode 100644 index 00000000000..e73a84be028 --- /dev/null +++ b/packages/web-shell/client/components/messages/McpApp.dom.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { McpAppHostContext } from '../../mcpAppHostContext'; +import { ThemeProvider, WebShellThemeId } from '../../themeContext'; +import type { McpAppDisplay } from './McpApp'; + +const appBridgeMocks = vi.hoisted(() => ({ + constructed: 0, + last: null as { + onsandboxready?: () => void; + oninitialized?: () => void; + } | null, + lastCapabilities: undefined as unknown, + setHostContext: vi.fn(), + connect: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()), + sendSandboxResourceReady: vi.fn(() => Promise.resolve()), + sendToolInput: vi.fn(() => Promise.resolve()), + sendToolResult: vi.fn(() => Promise.resolve()), + teardownResource: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => ({ + PostMessageTransport: class PostMessageTransport {}, + AppBridge: class AppBridge { + setHostContext = appBridgeMocks.setHostContext; + connect = appBridgeMocks.connect; + close = appBridgeMocks.close; + sendSandboxResourceReady = appBridgeMocks.sendSandboxResourceReady; + sendToolInput = appBridgeMocks.sendToolInput; + sendToolResult = appBridgeMocks.sendToolResult; + teardownResource = appBridgeMocks.teardownResource; + constructor(_app: unknown, _info: unknown, capabilities?: unknown) { + appBridgeMocks.constructed += 1; + appBridgeMocks.last = this; + appBridgeMocks.lastCapabilities = capabilities; + } + }, +})); + +import { McpApp } from './McpApp'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +beforeEach(() => { + appBridgeMocks.constructed = 0; + appBridgeMocks.last = null; + appBridgeMocks.lastCapabilities = undefined; + appBridgeMocks.setHostContext.mockClear(); + appBridgeMocks.connect.mockClear(); + appBridgeMocks.close.mockClear(); + appBridgeMocks.sendSandboxResourceReady.mockClear(); + appBridgeMocks.sendToolInput.mockClear(); + appBridgeMocks.sendToolResult.mockClear(); + appBridgeMocks.teardownResource.mockReset(); + appBridgeMocks.teardownResource.mockImplementation(() => Promise.resolve()); +}); + +function appDisplay(overrides: Partial = {}): McpAppDisplay { + return { + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/app', + html: '
Demo
', + toolResult: { content: [] }, + toolArguments: {}, + fallbackText: 'Demo result', + ...overrides, + }; +} + +function renderApp( + display: McpAppDisplay, + theme: (typeof WebShellThemeId)[keyof typeof WebShellThemeId] = WebShellThemeId.Dark, +): { container: HTMLElement; rerender: (node: ReactNode) => void } { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const wrap = (node: ReactNode) => ( + + {node} + + ); + act(() => root.render(wrap())); + mounted.push({ root, container }); + return { + container, + rerender: (node: ReactNode) => { + act(() => root.render(wrap(node))); + }, + }; +} + +describe('McpApp host lifetime', () => { + it('does not rebuild AppBridge when display is a new object with the same fields', async () => { + const { rerender } = renderApp(appDisplay()); + await act(async () => { + await Promise.resolve(); + }); + expect(appBridgeMocks.constructed).toBe(1); + + rerender(); + await act(async () => { + await Promise.resolve(); + }); + + expect(appBridgeMocks.constructed).toBe(1); + expect(appBridgeMocks.close).not.toHaveBeenCalled(); + }); + + it('pushes theme changes through setHostContext instead of remounting', async () => { + const display = appDisplay(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + const wrap = ( + theme: (typeof WebShellThemeId)[keyof typeof WebShellThemeId], + ) => ( + + + + + + ); + + act(() => root.render(wrap(WebShellThemeId.Dark))); + await act(async () => { + await Promise.resolve(); + }); + expect(appBridgeMocks.constructed).toBe(1); + + act(() => root.render(wrap(WebShellThemeId.Light))); + await act(async () => { + await Promise.resolve(); + }); + + expect(appBridgeMocks.constructed).toBe(1); + expect(appBridgeMocks.close).not.toHaveBeenCalled(); + expect(appBridgeMocks.setHostContext).toHaveBeenCalledWith( + expect.objectContaining({ theme: WebShellThemeId.Light }), + ); + }); + + it('sends the sandbox resource after AppBridge reports ready', async () => { + renderApp(appDisplay({ html: '
Ready
' })); + await act(async () => { + await Promise.resolve(); + }); + + expect(appBridgeMocks.connect).toHaveBeenCalled(); + expect(appBridgeMocks.last?.onsandboxready).toEqual(expect.any(Function)); + + await act(async () => { + appBridgeMocks.last?.onsandboxready?.(); + await Promise.resolve(); + }); + + const iframe = document.querySelector('iframe'); + expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts allow-forms'); + expect(iframe?.getAttribute('sandbox')).not.toContain('allow-same-origin'); + + expect(appBridgeMocks.sendSandboxResourceReady).toHaveBeenCalledWith( + expect.objectContaining({ html: '
Ready
' }), + ); + + await act(async () => { + appBridgeMocks.last?.oninitialized?.(); + await Promise.resolve(); + }); + + expect(appBridgeMocks.sendToolInput).toHaveBeenCalledWith({ + arguments: {}, + }); + expect(appBridgeMocks.sendToolResult).toHaveBeenCalledWith({ content: [] }); + }); + + it('does not advertise or delegate requested sandbox permissions', async () => { + const { container } = renderApp( + appDisplay({ + permissions: { + clipboardWrite: {}, + camera: {}, + } as McpAppDisplay['permissions'], + }), + ); + await act(async () => { + await Promise.resolve(); + }); + + expect(appBridgeMocks.lastCapabilities).toEqual({ sandbox: {} }); + expect(container.querySelector('iframe')?.getAttribute('allow')).toBeNull(); + expect(appBridgeMocks.last?.onsandboxready).toEqual(expect.any(Function)); + + await act(async () => { + appBridgeMocks.last?.onsandboxready?.(); + await Promise.resolve(); + }); + + expect(appBridgeMocks.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: '
Demo
', + }); + }); + + it('renders fallbackText for compacted html and never mounts the sandbox', () => { + const { container } = renderApp(appDisplay({ html: '' })); + + expect(container.textContent).toContain('Demo result'); + expect(container.querySelector('iframe')).toBeNull(); + expect(appBridgeMocks.constructed).toBe(0); + }); + + it('tears down the resource before unloading the iframe', async () => { + let resolveTeardown: (() => void) | undefined; + appBridgeMocks.teardownResource.mockImplementation( + () => + new Promise((resolve) => { + resolveTeardown = resolve; + }), + ); + + const { container } = renderApp(appDisplay()); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + appBridgeMocks.last?.onsandboxready?.(); + await Promise.resolve(); + }); + await act(async () => { + appBridgeMocks.last?.oninitialized?.(); + await Promise.resolve(); + }); + + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + const removeAttribute = vi.spyOn(iframe!, 'removeAttribute'); + + const entry = mounted.pop(); + act(() => entry?.root.unmount()); + + expect(appBridgeMocks.teardownResource).toHaveBeenCalled(); + expect(removeAttribute).not.toHaveBeenCalled(); + + await act(async () => { + resolveTeardown?.(); + await Promise.resolve(); + }); + + expect(removeAttribute).toHaveBeenCalledWith('src'); + expect(appBridgeMocks.close).toHaveBeenCalled(); + entry?.container.remove(); + }); + + it('does not blank the live iframe when a superseded teardown settles', async () => { + let resolveTeardown: (() => void) | undefined; + appBridgeMocks.teardownResource.mockImplementation( + () => + new Promise((resolve) => { + resolveTeardown = resolve; + }), + ); + + const { container, rerender } = renderApp(appDisplay()); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + appBridgeMocks.last?.onsandboxready?.(); + await Promise.resolve(); + }); + await act(async () => { + appBridgeMocks.last?.oninitialized?.(); + await Promise.resolve(); + }); + + const iframe = container.querySelector('iframe'); + expect(iframe?.getAttribute('src')).toContain('/mcp-app-sandbox'); + + rerender( + , + ); + await act(async () => { + await Promise.resolve(); + }); + + expect(appBridgeMocks.constructed).toBe(2); + expect(iframe?.getAttribute('src')).toContain('/mcp-app-sandbox'); + + await act(async () => { + resolveTeardown?.(); + await Promise.resolve(); + }); + + expect(iframe?.getAttribute('src')).toContain('/mcp-app-sandbox'); + }); +}); diff --git a/packages/web-shell/client/components/messages/McpApp.module.css b/packages/web-shell/client/components/messages/McpApp.module.css new file mode 100644 index 00000000000..056729aff9b --- /dev/null +++ b/packages/web-shell/client/components/messages/McpApp.module.css @@ -0,0 +1,45 @@ +.card { + margin: 6px 0 2px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--background); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + color: var(--foreground); + font-size: 12px; + font-weight: 600; +} + +.server { + color: var(--muted-foreground); + font-weight: 400; +} + +.frame { + display: block; + width: 100%; + min-height: 120px; + border: 0; + background: transparent; + transition: height 180ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .frame { + transition: none; + } +} + +.fallback { + padding: 12px; + color: var(--muted-foreground); + font-size: 13px; + white-space: pre-wrap; +} diff --git a/packages/web-shell/client/components/messages/McpApp.test.ts b/packages/web-shell/client/components/messages/McpApp.test.ts new file mode 100644 index 00000000000..6002a0e0da4 --- /dev/null +++ b/packages/web-shell/client/components/messages/McpApp.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + applySandboxCspQuery, + getMcpAppDisplay, + resolveMcpAppSandboxUrl, +} from './McpApp'; + +describe('MCP App host helpers', () => { + it('recognizes a complete app display', () => { + expect( + getMcpAppDisplay({ + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/app', + html: '
Demo
', + toolResult: { content: [] }, + toolArguments: {}, + fallbackText: 'Demo result', + }), + ).toMatchObject({ resourceUri: 'ui://demo/app' }); + }); + + it('recognizes compacted displays so the host can render fallbackText', () => { + expect( + getMcpAppDisplay({ + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/app', + html: '', + toolResult: {}, + toolArguments: {}, + fallbackText: 'Demo result', + }), + ).toMatchObject({ fallbackText: 'Demo result', html: '' }); + }); + + it('uses the daemon origin and swaps the hostname when needed', () => { + expect( + resolveMcpAppSandboxUrl( + 'http://127.0.0.1:4170', + 'http://127.0.0.1:4170/session/demo', + ), + ).toBe( + 'http://localhost:4170/mcp-app-sandbox?hostOrigin=http%3A%2F%2F127.0.0.1%3A4170', + ); + }); + + it('swaps [::1] onto localhost so IPv6-only binds stay reachable', () => { + expect( + resolveMcpAppSandboxUrl( + 'http://[::1]:4170', + 'http://[::1]:4170/session/demo', + ), + ).toBe( + 'http://localhost:4170/mcp-app-sandbox?hostOrigin=http%3A%2F%2F%5B%3A%3A1%5D%3A4170', + ); + }); + + it('aliases a cross-origin [::1] daemon onto localhost for CSP', () => { + expect( + resolveMcpAppSandboxUrl( + 'http://[::1]:4170', + 'http://localhost:4170/session/demo', + ), + ).toBe( + 'http://localhost:4170/mcp-app-sandbox?hostOrigin=http%3A%2F%2Flocalhost%3A4170', + ); + }); + + it('keeps a localhost sandbox on localhost instead of guessing 127.0.0.1', () => { + expect( + resolveMcpAppSandboxUrl( + 'http://localhost:4170', + 'http://localhost:4170/session/demo', + ), + ).toBe( + 'http://localhost:4170/mcp-app-sandbox?hostOrigin=http%3A%2F%2Flocalhost%3A4170', + ); + }); + + it('omits CSP from the sandbox URL when it would overflow the request line', () => { + const sandboxUrl = + 'http://localhost:4170/mcp-app-sandbox?hostOrigin=http%3A%2F%2F127.0.0.1%3A4170'; + expect(applySandboxCspQuery(sandboxUrl, '{"connectDomains":[]}')).toContain( + 'csp=', + ); + expect(applySandboxCspQuery(sandboxUrl, 'x'.repeat(8193))).toBe(sandboxUrl); + const encodedOverflow = JSON.stringify({ + connectDomains: Array(680).fill('https://a'), + }); + expect(encodedOverflow.length).toBeLessThan(8192); + const encodedUrl = new URL(sandboxUrl); + encodedUrl.searchParams.set('csp', encodedOverflow); + expect( + encodedUrl.pathname.length + encodedUrl.search.length, + ).toBeGreaterThan(16 * 1024); + expect(applySandboxCspQuery(sandboxUrl, encodedOverflow)).toBe(sandboxUrl); + }); + + it('rejects non-loopback hosts', () => { + expect( + resolveMcpAppSandboxUrl( + 'https://daemon.example.com', + 'https://host.example.com', + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/web-shell/client/components/messages/McpApp.tsx b/packages/web-shell/client/components/messages/McpApp.tsx new file mode 100644 index 00000000000..00744c36222 --- /dev/null +++ b/packages/web-shell/client/components/messages/McpApp.tsx @@ -0,0 +1,252 @@ +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + AppBridge, + PostMessageTransport, +} from '@modelcontextprotocol/ext-apps/app-bridge'; +import { McpAppHostContext } from '../../mcpAppHostContext'; +import { useTheme } from '../../themeContext'; +import styles from './McpApp.module.css'; + +type SandboxResource = Parameters[0]; +type AppToolResult = Parameters[0]; + +export interface McpAppDisplay { + type: 'mcp_app'; + serverName: string; + resourceUri: string; + html: string; + toolResult: AppToolResult; + toolArguments: Record; + fallbackText: string; + csp?: SandboxResource['csp']; + permissions?: SandboxResource['permissions']; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function getMcpAppDisplay(value: unknown): McpAppDisplay | undefined { + if (!isRecord(value) || value['type'] !== 'mcp_app') return undefined; + if ( + typeof value['serverName'] !== 'string' || + typeof value['resourceUri'] !== 'string' || + typeof value['html'] !== 'string' || + typeof value['fallbackText'] !== 'string' || + !isRecord(value['toolResult']) || + !isRecord(value['toolArguments']) + ) { + return undefined; + } + return value as unknown as McpAppDisplay; +} + +// Must stay under Node's default 16 KiB HTTP request-line limit. +const MAX_SANDBOX_QUERY_LENGTH = 8192; + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' + ); +} + +function loopbackCrossOriginHostname(hostname: string): string | undefined { + if (hostname === '127.0.0.1' || hostname === '[::1]') return 'localhost'; + return undefined; +} + +export function resolveMcpAppSandboxUrl( + daemonBaseUrl: string, + hostUrl: string, +): string | undefined { + try { + const host = new URL(hostUrl); + const sandbox = new URL(daemonBaseUrl, host); + if ( + !isLoopbackHostname(host.hostname) || + !isLoopbackHostname(sandbox.hostname) + ) { + return undefined; + } + // CSP cannot allow `http://[::1]:`. Always rewrite IPv6 + // loopback onto localhost before checking same-origin swap. + if (sandbox.hostname === '[::1]') { + sandbox.hostname = 'localhost'; + } + if (sandbox.origin === host.origin) { + const alias = loopbackCrossOriginHostname(sandbox.hostname); + if (alias) sandbox.hostname = alias; + } + sandbox.pathname = '/mcp-app-sandbox'; + sandbox.search = ''; + sandbox.hash = ''; + sandbox.searchParams.set('hostOrigin', host.origin); + return sandbox.toString(); + } catch { + return undefined; + } +} + +export function applySandboxCspQuery( + sandboxUrl: string, + cspJson: string, +): string { + if (!cspJson) return sandboxUrl; + const url = new URL(sandboxUrl); + url.searchParams.set('csp', cspJson); + return url.search.length <= MAX_SANDBOX_QUERY_LENGTH + ? url.toString() + : sandboxUrl; +} + +function mcpAppHostContext(theme: ReturnType) { + return { + theme, + platform: 'web' as const, + displayMode: 'inline' as const, + availableDisplayModes: ['inline' as const], + containerDimensions: { maxHeight: 640 }, + }; +} + +export function McpApp({ display }: { display: McpAppDisplay }) { + const daemonBaseUrl = useContext(McpAppHostContext); + const theme = useTheme(); + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const mountGenerationRef = useRef(0); + const displayRef = useRef(display); + const themeRef = useRef(theme); + displayRef.current = display; + themeRef.current = theme; + const [height, setHeight] = useState(260); + const [error, setError] = useState(); + const cspKey = display.csp ? JSON.stringify(display.csp) : ''; + const toolArgumentsKey = JSON.stringify(display.toolArguments); + const toolResultKey = JSON.stringify(display.toolResult); + const sandboxUrl = useMemo(() => { + if (!daemonBaseUrl || typeof window === 'undefined') return undefined; + const resolved = resolveMcpAppSandboxUrl( + daemonBaseUrl, + window.location.href, + ); + if (!resolved) return undefined; + return applySandboxCspQuery(resolved, cspKey); + }, [daemonBaseUrl, cspKey]); + + useEffect(() => { + setError(undefined); + const iframe = iframeRef.current; + if (!iframe || !sandboxUrl) return; + const generation = ++mountGenerationRef.current; + let initialized = false; + const current = displayRef.current; + const bridge = new AppBridge( + null, + { name: 'qwen-code-web-shell', version: '0.0.1' }, + { + sandbox: { + ...(current.csp ? { csp: current.csp } : {}), + }, + }, + { hostContext: mcpAppHostContext(themeRef.current) }, + ); + bridgeRef.current = bridge; + + bridge.onsandboxready = () => { + const resource = displayRef.current; + void bridge + .sendSandboxResourceReady({ + html: resource.html, + ...(resource.csp ? { csp: resource.csp } : {}), + }) + .catch((reason: unknown) => setError(String(reason))); + }; + bridge.oninitialized = () => { + initialized = true; + const resource = displayRef.current; + void bridge + .sendToolInput({ arguments: resource.toolArguments }) + .then(() => bridge.sendToolResult(resource.toolResult)) + .catch((reason: unknown) => setError(String(reason))); + }; + bridge.onsizechange = ({ height: requestedHeight }) => { + if ( + typeof requestedHeight === 'number' && + Number.isFinite(requestedHeight) + ) { + setHeight(Math.min(640, Math.max(120, Math.ceil(requestedHeight)))); + } + }; + + void bridge + .connect( + new PostMessageTransport( + iframe.contentWindow ?? undefined, + iframe.contentWindow!, + ), + ) + .then(() => { + iframe.src = sandboxUrl; + }) + .catch((reason: unknown) => setError(String(reason))); + + return () => { + bridgeRef.current = null; + const unload = () => { + // Compare against later effect runs so a superseded teardown + // does not blank the iframe the next mount already owns. + // eslint-disable-next-line react-hooks/exhaustive-deps -- live generation, not a stale copy + if (mountGenerationRef.current === generation) { + iframe.removeAttribute('src'); + } + void bridge.close().catch(() => {}); + }; + if (initialized) { + void bridge + .teardownResource({}, { timeout: 500 }) + .catch(() => {}) + .finally(unload); + return; + } + unload(); + }; + }, [ + sandboxUrl, + display.serverName, + display.resourceUri, + display.html, + cspKey, + toolArgumentsKey, + toolResultKey, + ]); + + useEffect(() => { + bridgeRef.current?.setHostContext(mcpAppHostContext(theme)); + }, [theme]); + + if (!display.html || !sandboxUrl) { + return
{display.fallbackText}
; + } + + return ( +
+
+ MCP App + {display.serverName} +
+ {error ? ( +
{display.fallbackText}
+ ) : null} +