From 08708925994ae63c2f2e51b39246e5da91d52e7b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 00:40:22 +0800 Subject: [PATCH 01/13] fix(desktop): publish MCP tools through native capabilities Generated-by: OpenAI Codex --- .../main/__tests__/mcp-runtime-e2e.test.ts | 57 +++++++++++++++++++ .../main/runtime-host-native-capabilities.ts | 54 ++++++++++++++---- 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index 3d9e5142d7..caa2929c81 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -23,6 +23,8 @@ import { test } from 'node:test'; import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; import { McpClientManager } from '@maka/mcp'; import { buildMcpTools } from '@maka/runtime/mcp-tools'; +import { decodeClientCapabilityReplaceInput } from '@maka/runtime-host/protocol'; +import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; const fixturePath = fileURLToPath(new URL('../../../../../packages/mcp/dist/__fixtures__/stdio-server.js', import.meta.url)); @@ -41,6 +43,61 @@ test('MCP tools stay bound to the connection generation that advertised them', a const echo = tools.find((tool) => tool.name === 'mcp__fixture__echo'); assert.ok(echo); assert.equal(echo.categoryHint, 'network_send'); + + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + tools, + }, + ], + }); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + assert.deepEqual(provider.offers()[0]?.tools[0]?.inputSchema, { + type: 'object', + properties: { value: { type: 'string' } }, + }); + if (!provider.call) throw new Error('Expected a callable Desktop capability provider'); + assert.deepEqual( + await provider.call( + { + kind: 'client.capability.call', + invocationId: 'invocation-1', + registrationId: 'registration-1', + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'mcp__fixture__echo', + arguments: { value: 'desktop-capability' }, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'capability-call', + cwd: process.cwd(), + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + }, + ), + { + content: [ + { type: 'text', text: 'desktop-capability' }, + { type: 'text', text: '{"structuredContent":{"echoed":"desktop-capability"}}' }, + ], + }, + ); + const result = await echo.impl({ value: 'runtime-e2e' }, { sessionId: 'session', turnId: 'turn', cwd: process.cwd(), toolCallId: 'call', abortSignal: new AbortController().signal, emitOutput() {}, diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index bebe81db1c..1f04a6294d 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -337,8 +337,7 @@ async function invokeNativeTool( } const signal = AbortSignal.any([options.signal, invocation.signal]); signal.throwIfAborted(); - const parameters = requireZodSchema(binding.tool); - const args = await parameters.parseAsync(frame.arguments); + const args = await parseToolArguments(binding.tool, frame.arguments); signal.throwIfAborted(); const sessionId = frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID @@ -448,13 +447,15 @@ function capabilityOffer( } function toolInputSchema(tool: MakaTool): Record { - const schema = toJSONSchema(requireZodSchema(tool), { - io: "input", - target: "draft-07", - unrepresentable: "any", - cycles: "ref", - reused: "inline", - }); + const schema = tool.parameters instanceof z.ZodType + ? toJSONSchema(tool.parameters, { + io: "input", + target: "draft-07", + unrepresentable: "any", + cycles: "ref", + reused: "inline", + }) + : cloneDeclaredJsonSchema(tool); delete schema.$schema; if (schema.type !== "object") { throw new Error( @@ -464,13 +465,42 @@ function toolInputSchema(tool: MakaTool): Record { return Object.freeze(schema); } -function requireZodSchema(tool: MakaTool): z.ZodType { - if (!(tool.parameters instanceof z.ZodType)) { +function cloneDeclaredJsonSchema(tool: MakaTool): Record { + const parameters = tool.parameters as { readonly jsonSchema?: unknown } | undefined; + const schema = parameters?.jsonSchema; + if (!isPlainRecord(schema)) { throw new Error( `Desktop native capability tool has an invalid schema: ${tool.name}`, ); } - return tool.parameters; + return structuredClone(schema); +} + +async function parseToolArguments(tool: MakaTool, args: unknown): Promise { + if (tool.parameters instanceof z.ZodType) { + return tool.parameters.parseAsync(args); + } + const parameters = tool.parameters as { + readonly validate?: ( + value: unknown, + ) => + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: unknown } + | Promise< + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: unknown } + >; + }; + if (typeof parameters?.validate !== "function") return args; + const result = await parameters.validate(args); + if (result.success) return result.value; + throw result.error; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } function indexBindings( From a19097e6176eccdcae8510a8a884d682744541dc Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:55:19 +0800 Subject: [PATCH 02/13] fix(desktop): isolate incompatible MCP tool schemas Generated-by: OpenAI Codex --- .../main/__tests__/mcp-runtime-e2e.test.ts | 35 +++++- apps/desktop/src/main/runtime-host-boot.ts | 1 + .../main/runtime-host-native-capabilities.ts | 104 ++++++++++++------ packages/mcp/src/__fixtures__/stdio-server.ts | 1 + .../src/protocol/client-capability.ts | 15 ++- 5 files changed, 118 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index caa2929c81..3896f35231 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -36,10 +36,17 @@ test('MCP tools stay bound to the connection generation that advertised them', a await manager.sync({ version: MCP_CONFIG_VERSION, mcpServers: { - fixture: { command: process.execPath, args: [fixturePath] }, + fixture: { + command: process.execPath, + args: [fixturePath, '--schema-annotations'], + }, }, }); const tools = buildMcpTools(manager); + assert.deepEqual(tools.map((tool) => tool.name), [ + 'mcp__fixture__annotated', + 'mcp__fixture__echo', + ]); const echo = tools.find((tool) => tool.name === 'mcp__fixture__echo'); assert.ok(echo); assert.equal(echo.categoryHint, 'network_send'); @@ -56,6 +63,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools, + omitUnsupportedTools: true, }, ], }); @@ -69,7 +77,32 @@ test('MCP tools stay bound to the connection generation that advertised them', a type: 'object', properties: { value: { type: 'string' } }, }); + assert.deepEqual(provider.offers()[0]?.tools.map((tool) => tool.name), [ + 'mcp__fixture__echo', + ]); if (!provider.call) throw new Error('Expected a callable Desktop capability provider'); + assert.throws( + () => provider.call!( + { + kind: 'client.capability.call', + invocationId: 'incompatible-invocation', + registrationId: 'registration-1', + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'mcp__fixture__annotated', + arguments: {}, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'incompatible-capability-call', + cwd: process.cwd(), + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + }, + ), + /not offered/u, + ); assert.deepEqual( await provider.call( { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 74e68af3ee..c638639ee7 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1000,6 +1000,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( description: "Use MCP tools connected by this Desktop client.", tools: mcpTools, + omitUnsupportedTools: true, }, ]), ]; diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 1f04a6294d..ae25533a34 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -25,14 +25,16 @@ import { type ClientCapabilityProvider, type OAuthPresentationBackend, } from "@maka/runtime-host/client"; -import type { - ClientCapabilityCallFrame, - ClientCapabilityCallResult, - ClientCapabilityContentBlock, - ClientCapabilityHostPathAccess, - ClientCapabilityOffer, - ClientCapabilityServiceCallFrame, - ClientCapabilityServiceOffer, +import { + decodeClientCapabilityToolInputSchema, + type ClientCapabilityCallFrame, + type ClientCapabilityCallResult, + type ClientCapabilityContentBlock, + type ClientCapabilityHostPathAccess, + type ClientCapabilityOffer, + type ClientCapabilityServiceCallFrame, + type ClientCapabilityServiceOffer, + type ClientCapabilityToolDescriptor, } from "@maka/runtime-host/protocol"; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; @@ -47,12 +49,24 @@ export interface DesktopCapabilityGroup { readonly label: string; readonly description: string; readonly tools: readonly MakaTool[]; + /** Keep compatible tools when a dynamic provider declares a wider schema dialect. */ + readonly omitUnsupportedTools?: boolean; } -interface NativeToolBinding { +interface PreparedDesktopCapabilityTool { readonly tool: MakaTool; + readonly descriptor: ClientCapabilityToolDescriptor; +} + +interface PreparedDesktopCapabilityGroup { + readonly offerId: string; + readonly label: string; + readonly description: string; + readonly tools: readonly PreparedDesktopCapabilityTool[]; } +type NativeToolBinding = Pick; + type DesktopToolModelOutput = Awaited< ReturnType> >; @@ -112,8 +126,8 @@ export function createDesktopNativeCapabilityProvider( input: DesktopNativeCapabilityProviderInput, providerOptions: DesktopNativeCapabilityProviderOptions = {}, ): DesktopNativeCapabilityProvider { - const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; + const groups = prepareCapabilityGroups(capabilityGroups(input)); const offers = Object.freeze( groups.map((group) => capabilityOffer(group, hostPathAccess)), ); @@ -419,7 +433,7 @@ function abortInvocations( } function capabilityOffer( - group: DesktopCapabilityGroup, + group: PreparedDesktopCapabilityGroup, hostPathAccess: ClientCapabilityHostPathAccess, ): ClientCapabilityOffer { return Object.freeze({ @@ -430,23 +444,54 @@ function capabilityOffer( label: group.label, description: group.description, tools: Object.freeze( - group.tools.map((tool) => - Object.freeze({ - serverId: group.offerId, - name: tool.name, - description: tool.description, - inputSchema: toolInputSchema(tool), - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - ...(tool.displayName - ? { annotations: Object.freeze({ title: tool.displayName }) } - : {}), - }), - ), + group.tools.map(({ descriptor }) => descriptor), ), }); } -function toolInputSchema(tool: MakaTool): Record { +function prepareCapabilityGroups( + groups: readonly DesktopCapabilityGroup[], +): PreparedDesktopCapabilityGroup[] { + return groups.flatMap((group) => { + const tools = group.tools.flatMap((tool): PreparedDesktopCapabilityTool[] => { + const declaredSchema = declaredToolInputSchema(tool); + let inputSchema: Record; + try { + inputSchema = Object.freeze( + decodeClientCapabilityToolInputSchema(declaredSchema), + ); + } catch (error) { + if (!group.omitUnsupportedTools) throw error; + return []; + } + return [{ + tool, + descriptor: capabilityToolDescriptor(group.offerId, tool, inputSchema), + }]; + }); + if (group.omitUnsupportedTools && tools.length === 0) return []; + return [{ ...group, tools }]; + }); +} + +function capabilityToolDescriptor( + offerId: string, + tool: MakaTool, + inputSchema: Record, +): ClientCapabilityToolDescriptor { + return Object.freeze({ + serverId: offerId, + name: tool.name, + description: tool.description, + inputSchema, + ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), + ...(tool.displayName + ? { annotations: Object.freeze({ title: tool.displayName }) } + : {}), + }); +} + +function declaredToolInputSchema(tool: MakaTool): Record { const schema = tool.parameters instanceof z.ZodType ? toJSONSchema(tool.parameters, { io: "input", @@ -457,12 +502,7 @@ function toolInputSchema(tool: MakaTool): Record { }) : cloneDeclaredJsonSchema(tool); delete schema.$schema; - if (schema.type !== "object") { - throw new Error( - `Desktop native capability tool schema must be an object: ${tool.name}`, - ); - } - return Object.freeze(schema); + return schema; } function cloneDeclaredJsonSchema(tool: MakaTool): Record { @@ -504,11 +544,11 @@ function isPlainRecord(value: unknown): value is Record { } function indexBindings( - groups: readonly DesktopCapabilityGroup[], + groups: readonly PreparedDesktopCapabilityGroup[], ): Map { const bindings = new Map(); for (const group of groups) { - for (const tool of group.tools) { + for (const { tool } of group.tools) { const key = bindingKey({ offerId: group.offerId, serverId: group.offerId, diff --git a/packages/mcp/src/__fixtures__/stdio-server.ts b/packages/mcp/src/__fixtures__/stdio-server.ts index a14c5513c9..d12dd7267d 100644 --- a/packages/mcp/src/__fixtures__/stdio-server.ts +++ b/packages/mcp/src/__fixtures__/stdio-server.ts @@ -132,6 +132,7 @@ server.setRequestHandler(ListToolsRequestSchema, async ({ params }) => { }, }, }, + tool('echo', 'Echo text', true), ], }; } diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index ce51734621..d191f388e6 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -673,11 +673,7 @@ function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { ['serverId', 'name', 'inputSchema'], ['description', 'annotations', 'activityKind'], ); - const inputSchema = decodeJsonRecord(record.inputSchema, 'inputSchema'); - if (jsonByteLength(inputSchema) > 32 * 1024) { - throw invalidProtocolFrame('Client Capability tool schema is too large'); - } - validateToolInputSchema(inputSchema); + const inputSchema = decodeClientCapabilityToolInputSchema(record.inputSchema); return { serverId: requireString(record.serverId, 'serverId', 128), name: requireString(record.name, 'name', 128), @@ -700,6 +696,15 @@ function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { }; } +export function decodeClientCapabilityToolInputSchema(value: unknown): Record { + const inputSchema = decodeJsonRecord(value, 'inputSchema'); + if (jsonByteLength(inputSchema) > 32 * 1024) { + throw invalidProtocolFrame('Client Capability tool schema is too large'); + } + validateToolInputSchema(inputSchema); + return inputSchema; +} + function decodeToolActivityKind(value: unknown): ToolActivityKind { if (typeof value === 'string' && (TOOL_ACTIVITY_KINDS as readonly string[]).includes(value)) { return value as ToolActivityKind; From a5756ea2c37035adebd383749e364a11f2151c8a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:56:44 +0800 Subject: [PATCH 03/13] refactor(runtime-host): share capability tool decoding Generated-by: OpenAI Codex --- .../src/main/runtime-host-native-capabilities.ts | 15 +++++++-------- .../src/protocol/client-capability.ts | 8 +++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index ae25533a34..cefcaf7974 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -26,7 +26,7 @@ import { type OAuthPresentationBackend, } from "@maka/runtime-host/client"; import { - decodeClientCapabilityToolInputSchema, + decodeClientCapabilityToolDescriptor, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, type ClientCapabilityContentBlock, @@ -455,19 +455,18 @@ function prepareCapabilityGroups( return groups.flatMap((group) => { const tools = group.tools.flatMap((tool): PreparedDesktopCapabilityTool[] => { const declaredSchema = declaredToolInputSchema(tool); - let inputSchema: Record; + let descriptor: ClientCapabilityToolDescriptor; try { - inputSchema = Object.freeze( - decodeClientCapabilityToolInputSchema(declaredSchema), + descriptor = Object.freeze( + decodeClientCapabilityToolDescriptor( + capabilityToolDescriptor(group.offerId, tool, declaredSchema), + ), ); } catch (error) { if (!group.omitUnsupportedTools) throw error; return []; } - return [{ - tool, - descriptor: capabilityToolDescriptor(group.offerId, tool, inputSchema), - }]; + return [{ tool, descriptor }]; }); if (group.omitUnsupportedTools && tools.length === 0) return []; return [{ ...group, tools }]; diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index d191f388e6..7acc23d90a 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -640,7 +640,7 @@ function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { : { description: requireString(record.description, 'description', 1_024), }), - tools: record.tools.map(decodeToolDescriptor), + tools: record.tools.map(decodeClientCapabilityToolDescriptor), }; } @@ -665,7 +665,9 @@ function decodeClientCapabilityHostPathAccess(value: unknown): ClientCapabilityH throw invalidProtocolFrame('Invalid Client Capability Host path access'); } -function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { +export function decodeClientCapabilityToolDescriptor( + value: unknown, +): ClientCapabilityToolDescriptor { const record = requireRecord(value, 'Client Capability tool'); assertOptionalExactKeys( record, @@ -696,7 +698,7 @@ function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { }; } -export function decodeClientCapabilityToolInputSchema(value: unknown): Record { +function decodeClientCapabilityToolInputSchema(value: unknown): Record { const inputSchema = decodeJsonRecord(value, 'inputSchema'); if (jsonByteLength(inputSchema) > 32 * 1024) { throw invalidProtocolFrame('Client Capability tool schema is too large'); From bd663dfecc2e5f69664d746f55d483dcaa1f2c5f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 17:12:21 +0800 Subject: [PATCH 04/13] chore(runtime-host): declare capability decoder share wire-compatible --- .../client-capability-tool-decoder-share.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json new file mode 100644 index 0000000000..026421100d --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -0,0 +1,5 @@ +{ + "epoch": 104, + "files": ["packages/runtime-host/src/protocol/client-capability.ts"], + "reason": "Pure refactor: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." +} From 40e252c43ac75319644b16d906e01e5b492bf216 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 18:07:00 +0800 Subject: [PATCH 05/13] test(desktop): expect the shared decoder's schema-root error wording --- .../src/main/__tests__/runtime-host-desktop-candidate.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index da5c76d48b..87b353b43c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -564,7 +564,9 @@ test('closes the claimed Host connection when native capability construction fai releaseComputerUseSession() {}, }), ), - /tool schema must be an object/, + // The desktop-local schema check moved into the shared protocol decoder, + // which rejects a non-object tool schema root with its own wording. + /tool schema root must be an object/, ); assert.equal(ipc.size, 0); From 08172f73b7ef77c9dcfe67a3d8b53482b317a8a7 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 18:50:19 +0800 Subject: [PATCH 06/13] chore(runtime-host): track epoch 105 for the decoder-share declaration --- .../client-capability-tool-decoder-share.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json index 026421100d..ffa8958ee8 100644 --- a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -1,5 +1,5 @@ { - "epoch": 104, + "epoch": 105, "files": ["packages/runtime-host/src/protocol/client-capability.ts"], "reason": "Pure refactor: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." } From d5f1fb8bd0fdc49de8ce788d5d6652d61736494b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 22:10:07 +0800 Subject: [PATCH 07/13] chore(runtime-host): track epoch 106 for the decoder-share declaration --- .../client-capability-tool-decoder-share.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json index ffa8958ee8..1a1b63764d 100644 --- a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -1,5 +1,5 @@ { - "epoch": 105, + "epoch": 106, "files": ["packages/runtime-host/src/protocol/client-capability.ts"], "reason": "Pure refactor: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." } From b0af27f9bb43074893e6fe255aeb3f83e6e36e5f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 00:59:42 +0800 Subject: [PATCH 08/13] fix(runtime-host): admit Desktop MCP tools through the desktop_mcp Session Grant managedClientCapabilityGrantTarget() only recognized Desktop Settings and Browser, so a managed ask-mode call to a desktop_mcp tool failed before admission with 'no managed admission policy'. Recognize the Desktop-owned desktop_mcp server identity and produce the existing desktop_mcp/mcp_tool grant target, reusing the generic approval, grant persistence, and prompt plumbing. The policy keys on the descriptor serverId so chunked offers stay admissible. Adds Host-level ask regressions covering accept-approval-admit-execute, per-tool Session Grant reuse, and the deny path (#4490). --- .../client-capability-coordinator.test.ts | 135 ++++++++++++++++++ .../server/client-capability-coordinator.ts | 63 +++++--- 2 files changed, 175 insertions(+), 23 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index c34e4611f8..994b5874fb 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -471,6 +471,141 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); + test('approves a trusted Desktop MCP tool once and scopes the Session Grant per tool', async () => { + let approvedTarget: + | Parameters< + HostClientCapabilityCoordinatorOptions['interactions']['requestClientCapabilityApproval'] + >[0]['target'] + | undefined; + let approvalCount = 0; + const coordinator = createCoordinator(() => undefined, { + interactions: { + requestClientCapabilityApproval: async ({ target }) => { + approvalCount += 1; + approvedTarget = target; + return 'allow'; + }, + }, + grants: { + readClientCapabilitySessionGrant: async (key) => + approvedTarget && + approvedTarget.providerId === key.providerId && + approvedTarget.contractId === key.contractId && + approvedTarget.capability === key.capability && + approvedTarget.scope.kind === 'mcp_tool' && + key.scope.kind === 'mcp_tool' && + approvedTarget.scope.serverId === key.scope.serverId && + approvedTarget.scope.toolName === key.scope.toolName + ? { version: 1, ...key, grantedAt: 1 } + : undefined, + }, + }); + const sent: unknown[] = []; + const connection = attachAutoAdmittingConnection( + coordinator, + 'connection-a', + () => ({ kind: 'none' }), + 'done', + sent, + ); + await registerSessionTools(coordinator, 'connection-a', 'registration-mcp', 'desktop_mcp', [ + 'fixture_echo', + 'fixture_ping', + ]); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.ok(snapshot); + const tools = new Map(snapshot.tools.map((tool) => [tool.displayName, tool])); + + // accept -> approval -> admit -> execute: the provider is never admitted + // before the approval resolves. + const preparedEcho = await prepare(tools.get('fixture_echo'), {}, 'tool-echo'); + assert.equal(approvalCount, 1); + assert.equal(approvedTarget?.capability, 'desktop_mcp'); + assert.deepEqual(approvedTarget?.scope, { + kind: 'mcp_tool', + serverId: 'desktop_mcp', + toolName: 'fixture_echo', + }); + assert.equal( + sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.admitted'), + false, + ); + assert.deepEqual(await preparedEcho.execute(managedContext('tool-echo')), textResult('done')); + const admittedIndex = sent.findIndex( + (frame) => isRecord(frame) && frame.kind === 'client.capability.admitted', + ); + const callIndex = sent.findIndex( + (frame) => isRecord(frame) && frame.kind === 'client.capability.call', + ); + assert.ok(callIndex >= 0 && admittedIndex > callIndex); + + // The persisted Session Grant covers the approved tool without a new + // approval... + const preparedEchoAgain = await prepare(tools.get('fixture_echo'), {}, 'tool-echo-again'); + assert.equal(approvalCount, 1); + assert.deepEqual( + await preparedEchoAgain.execute(managedContext('tool-echo-again')), + textResult('done'), + ); + + // ...while a sibling tool under the same offer needs its own grant. + const preparedPing = await prepare(tools.get('fixture_ping'), {}, 'tool-ping'); + assert.equal(approvalCount, 2); + assert.deepEqual(approvedTarget?.scope, { + kind: 'mcp_tool', + serverId: 'desktop_mcp', + toolName: 'fixture_ping', + }); + assert.deepEqual(await preparedPing.execute(managedContext('tool-ping')), textResult('done')); + + snapshot.release(); + await connection.close(); + await coordinator.close(); + }); + + test('cancels a denied Desktop MCP call before admission', async () => { + const coordinator = createCoordinator(() => undefined, { + interactions: { + requestClientCapabilityApproval: async () => 'deny', + }, + grants: { + readClientCapabilitySessionGrant: async () => undefined, + }, + }); + const sent: unknown[] = []; + const connection = attachAutoAdmittingConnection( + coordinator, + 'connection-a', + () => ({ kind: 'none' }), + 'done', + sent, + ); + await registerSessionTools(coordinator, 'connection-a', 'registration-mcp', 'desktop_mcp', [ + 'fixture_echo', + ]); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.ok(snapshot); + + await assert.rejects( + () => prepare(snapshot.tools[0], {}, 'tool-mcp-denied'), + /Client Capability request was denied/u, + ); + assert.equal( + sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.admitted'), + false, + ); + assert.equal( + sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.cancel'), + true, + ); + + snapshot.release(); + await connection.close(); + await coordinator.close(); + }); + test('reports capability_lost before admission and outcome_unknown after admission', async () => { await assertLossClassification('before_acceptance', 'capability_lost'); await assertLossClassification('after_admission', 'outcome_unknown'); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 315c440086..038a433151 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -66,6 +66,7 @@ import { clientCapabilityProviderId } from './client-capability-provider-id.js'; const DEFAULT_CALL_TIMEOUT_MS = 150_000; const DESKTOP_BROWSER_SERVER_ID = 'desktop_browser'; const DESKTOP_SETTINGS_SERVER_ID = 'desktop_settings'; +const DESKTOP_MCP_SERVER_ID = 'desktop_mcp'; const DESKTOP_BROWSER_TOOLS = new Set([ 'browser_navigate', 'browser_snapshot', @@ -1497,32 +1498,48 @@ function managedClientCapabilityGrantTarget( return undefined; } if ( - tool.offerId !== DESKTOP_BROWSER_SERVER_ID || - serverId !== DESKTOP_BROWSER_SERVER_ID || - !DESKTOP_BROWSER_TOOLS.has(toolName) + tool.offerId === DESKTOP_BROWSER_SERVER_ID && + serverId === DESKTOP_BROWSER_SERVER_ID && + DESKTOP_BROWSER_TOOLS.has(toolName) ) { - throw new Error(`Client Capability has no managed admission policy: ${serverId}/${toolName}`); - } - if (evidence.kind !== 'browser_url') { - throw new Error('Desktop Browser admission requires URL evidence'); - } - let url: URL; - try { - url = new URL(evidence.url); - } catch { - throw new Error('Desktop Browser admission URL is invalid'); + if (evidence.kind !== 'browser_url') { + throw new Error('Desktop Browser admission requires URL evidence'); + } + let url: URL; + try { + url = new URL(evidence.url); + } catch { + throw new Error('Desktop Browser admission URL is invalid'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Desktop Browser admission requires an HTTP origin'); + } + return Object.freeze({ + providerId: registration.providerId, + contractId, + serverId, + toolName, + capability: 'browser', + scope: Object.freeze({ kind: 'browser_origin', origin: url.origin }), + }); } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new Error('Desktop Browser admission requires an HTTP origin'); + // Desktop MCP tools publish under a shared Desktop-owned server identity; + // chunking may spread one logical group across several offerIds, so the + // admission policy keys on the descriptor serverId rather than the offerId. + if (serverId === DESKTOP_MCP_SERVER_ID) { + if (evidence.kind !== 'none') { + throw new Error('Desktop MCP admission does not accept scope evidence'); + } + return Object.freeze({ + providerId: registration.providerId, + contractId, + serverId, + toolName, + capability: 'desktop_mcp', + scope: Object.freeze({ kind: 'mcp_tool', serverId, toolName }), + }); } - return Object.freeze({ - providerId: registration.providerId, - contractId, - serverId, - toolName, - capability: 'browser', - scope: Object.freeze({ kind: 'browser_origin', origin: url.origin }), - }); + throw new Error(`Client Capability has no managed admission policy: ${serverId}/${toolName}`); } function serviceContract(serviceId: string, version: string): string { From 78db038674a2ac502de51d309e1f0e0a4f5df228 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 00:59:43 +0800 Subject: [PATCH 09/13] fix(desktop): chunk and degrade oversized MCP capability manifests A desktop_mcp group beyond the 64-tool single-offer limit failed the full manifest decode, taking Browser, Settings, and Rive registration down with it. Chunk dynamic groups into stable per-offer ids while keeping the group's server identity, shed trailing dynamic tools when the total tool, offer, or byte budgets are exceeded, keep fixed groups loud when they overflow, decode the manifest authoritatively before it is sent, and report omissions through a visible diagnostic (#4490). --- .../main/__tests__/mcp-runtime-e2e.test.ts | 7 +- .../runtime-host-native-capabilities.test.ts | 183 ++++++++++++++++++ .../main/runtime-host-desktop-candidate.ts | 1 + .../main/runtime-host-native-capabilities.ts | 148 +++++++++++++- 4 files changed, 328 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index 3896f35231..a87ea2ca71 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -103,6 +103,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a ), /not offered/u, ); + let admissionEvidence: unknown; assert.deepEqual( await provider.call( { @@ -120,7 +121,9 @@ test('MCP tools stay bound to the connection generation that advertised them', a }, { signal: new AbortController().signal, - accept: async () => undefined, + accept: async (evidence) => { + admissionEvidence = evidence; + }, }, ), { @@ -130,6 +133,8 @@ test('MCP tools stay bound to the connection generation that advertised them', a ], }, ); + // The Host managed admission policy for desktop_mcp requires this contract. + assert.deepEqual(admissionEvidence, { kind: 'none' }); const result = await echo.impl({ value: 'runtime-e2e' }, { sessionId: 'session', turnId: 'turn', cwd: process.cwd(), toolCallId: 'call', diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 98d97e5ba9..bb63ec3dfc 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -524,6 +524,189 @@ test('dispatches through the same immutable tool snapshot it advertised', async ); }); +test('chunks a dynamic capability group beyond the single-offer tool limit', async () => { + const mcpTools = Array.from({ length: 65 }, (_, index) => + tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => `tool-${index}`), + ); + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + tools: mcpTools, + omitUnsupportedTools: true, + }, + ], + }); + + assert.deepEqual( + provider.offers().map((offer) => [offer.offerId, offer.tools.length] as const), + [ + ['desktop_browser', 1], + ['desktop_mcp', 64], + ['desktop_mcp_2', 1], + ], + ); + // Chunked offers keep the group's server identity. + assert.equal(provider.offers()[2]?.tools[0]?.serverId, 'desktop_mcp'); + assert.equal(provider.offers()[2]?.tools[0]?.name, 'mcp_tool_064'); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + // A tool in a later chunk dispatches through its chunk offerId. + assert.deepEqual( + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp_2', + serverId: 'desktop_mcp', + toolName: 'mcp_tool_064', + arguments: {}, + }), + ), + { content: [{ type: 'text', text: 'tool-64' }] }, + ); + await provider.close(); +}); + +test('omits trailing dynamic tools beyond the manifest tool budget and keeps fixed groups', async () => { + const diagnostics: string[] = []; + const mcpTools = Array.from({ length: 300 }, (_, index) => + tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => 'ok'), + ); + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + tools: mcpTools, + omitUnsupportedTools: true, + }, + ], + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + + const offers = provider.offers(); + assert.equal(offers[0]?.offerId, 'desktop_browser'); + assert.equal(offers[0]?.tools.length, 1); + let toolCount = 0; + for (const offer of offers) toolCount += offer.tools.length; + assert.equal(toolCount, 256); + assert.equal(offers.at(-1)?.tools.at(-1)?.name, 'mcp_tool_254'); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers, + }), + ); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0] ?? '', /omitted 45 MCP tool/u); + assert.match(diagnostics[0] ?? '', /mcp_tool_255/u); + await provider.close(); +}); + +test('omits trailing dynamic tools beyond the manifest byte budget', () => { + const diagnostics: string[] = []; + const mcpTools = Array.from({ length: 80 }, (_, index) => ({ + ...tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => 'ok'), + description: `mcp_tool_${index} ${'x'.repeat(1_000)}`, + })); + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + tools: mcpTools, + omitUnsupportedTools: true, + }, + ], + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + + const offers = provider.offers(); + const kept = offers.flatMap((offer) => offer.tools.map((descriptor) => descriptor.name)); + assert.ok(kept.length > 0 && kept.length < 80); + assert.deepEqual( + kept, + mcpTools.slice(0, kept.length).map((candidate) => candidate.name), + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers, + }), + ); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0] ?? '', /omitted [1-9]\d* MCP tool/u); +}); + +test('chunks and degrades a dynamic capability group deterministically', () => { + const mcpTools = Array.from({ length: 70 }, (_, index) => + tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => 'ok'), + ); + const create = () => + createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + tools: mcpTools, + omitUnsupportedTools: true, + }, + ], + }); + const first = create(); + const second = create(); + assert.deepEqual(first.offers(), second.offers()); +}); + +test('fails loudly when a fixed capability group exceeds the manifest budget', () => { + assert.throws( + () => + createDesktopNativeCapabilityProvider({ + browserTools: Array.from({ length: 65 }, (_, index) => + tool(`browser_tool_${index}`, z.object({}), async () => 'ok'), + ), + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + }), + /Invalid Client Capability offer tools/u, + ); +}); + test('reports provider retirement once after its registration is released', async () => { let retirements = 0; const provider = createDesktopNativeCapabilityProvider( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 582e6e9fae..22e0ddfa9b 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -739,6 +739,7 @@ export async function createDesktopRuntimeHostCandidate( onComputerUseTurnUsed: watchComputerUseTurn, isTargetValid: deps.isTargetValid, onClosed: () => providers.delete(provider), + onDiagnostic: logLocalRuntimeHostProcessDiagnostic, }, ); providers.add(provider); diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index cefcaf7974..c6705f3dc2 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -26,6 +26,11 @@ import { type OAuthPresentationBackend, } from "@maka/runtime-host/client"; import { + CLIENT_CAPABILITY_MAX_MANIFEST_BYTES, + CLIENT_CAPABILITY_MAX_OFFERS, + CLIENT_CAPABILITY_MAX_TOOLS, + CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, + decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, @@ -43,6 +48,8 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; const BROWSER_OFFER_ID = "desktop_browser"; const COMPUTER_USE_OFFER_ID = "desktop_computer_use"; +// Same wire length as the registrationId the Client Capability channel assigns. +const MANIFEST_REGISTRATION_ID_PLACEHOLDER = "00000000-0000-4000-8000-000000000000"; export interface DesktopCapabilityGroup { readonly offerId: string; @@ -63,6 +70,7 @@ interface PreparedDesktopCapabilityGroup { readonly label: string; readonly description: string; readonly tools: readonly PreparedDesktopCapabilityTool[]; + readonly omitUnsupportedTools?: boolean; } type NativeToolBinding = Pick; @@ -117,6 +125,8 @@ interface DesktopNativeCapabilityProviderOptions { readonly onSessionUsed?: (sessionId: string) => void; readonly onComputerUseTurnUsed?: (sessionId: string, turnId: string) => void; readonly onClosed?: () => void; + /** Reports a visible degradation while assembling the published manifest. */ + readonly onDiagnostic?: (diagnostic: string) => void; readonly nativeSessionId?: (sessionId: string) => string; readonly targetScope?: DesktopTargetScope; } @@ -127,11 +137,6 @@ export function createDesktopNativeCapabilityProvider( providerOptions: DesktopNativeCapabilityProviderOptions = {}, ): DesktopNativeCapabilityProvider { const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; - const groups = prepareCapabilityGroups(capabilityGroups(input)); - const offers = Object.freeze( - groups.map((group) => capabilityOffer(group, hostPathAccess)), - ); - const bindings = indexBindings(groups); const oauthPresentation = input.oauthPresentation ? createOAuthPresentationClientProvider(input.oauthPresentation) : undefined; @@ -139,6 +144,31 @@ export function createDesktopNativeCapabilityProvider( ? input.additionalServices(requireTargetScope(providerOptions.targetScope)) : []; const services = indexServices(oauthPresentation?.services?.() ?? [], additionalServices); + const serviceOffers = Object.freeze( + [...services.values()].map(({ serviceId, version }) => + Object.freeze({ serviceId, version }), + ), + ); + const groups = fitDesktopCapabilityManifest( + prepareCapabilityGroups(capabilityGroups(input)), + serviceOffers, + hostPathAccess, + providerOptions.onDiagnostic, + ); + const offers = Object.freeze( + groups.map((group) => capabilityOffer(group, hostPathAccess)), + ); + // Authoritative check: a manifest that will be sent must decode first, so a + // budget overrun can never fail the whole registration at the channel. An + // empty provider is legal and never registers. + if (offers.length > 0 || serviceOffers.length > 0) { + decodeClientCapabilityReplaceInput({ + registrationId: MANIFEST_REGISTRATION_ID_PLACEHOLDER, + offers, + ...(serviceOffers.length === 0 ? {} : { services: serviceOffers }), + }); + } + const bindings = indexBindings(groups); const releaseSessionResources = [ input.releaseBrowserSession, input.releaseComputerUseSession, @@ -165,7 +195,7 @@ export function createDesktopNativeCapabilityProvider( return { offers: () => offers, - services: () => [...services.values()].map(({ serviceId, version }) => ({ serviceId, version })), + services: () => [...serviceOffers], call: (frame, options) => { if (closed) throw new Error("Desktop native capability provider is closed"); @@ -469,10 +499,108 @@ function prepareCapabilityGroups( return [{ tool, descriptor }]; }); if (group.omitUnsupportedTools && tools.length === 0) return []; - return [{ ...group, tools }]; + return chunkPreparedGroup({ ...group, tools }); }); } +/** + * Split a dynamic group beyond the single-offer tool limit into stable chunks. + * Chunked offers keep the group's server identity, so published tool names, + * Session Grant scopes, and Host admission never depend on how the group was + * split. + */ +function chunkPreparedGroup( + group: PreparedDesktopCapabilityGroup, +): PreparedDesktopCapabilityGroup[] { + if ( + !group.omitUnsupportedTools || + group.tools.length <= CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER + ) { + return [group]; + } + const chunks: PreparedDesktopCapabilityGroup[] = []; + for ( + let offset = 0; + offset < group.tools.length; + offset += CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER + ) { + chunks.push({ + ...group, + offerId: + offset === 0 + ? group.offerId + : `${group.offerId}_${offset / CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER + 1}`, + tools: group.tools.slice(offset, offset + CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER), + }); + } + return chunks; +} + +/** + * Fit the assembled manifest to the Client Capability budgets before it is + * sent. Fixed capability groups are never degraded: if they alone exceed a + * budget the registration must fail loudly. Dynamic (omittable) groups shed + * their trailing tools instead, and the omission is reported. + */ +function fitDesktopCapabilityManifest( + groups: readonly PreparedDesktopCapabilityGroup[], + services: readonly ClientCapabilityServiceOffer[], + hostPathAccess: ClientCapabilityHostPathAccess, + onDiagnostic?: (diagnostic: string) => void, +): PreparedDesktopCapabilityGroup[] { + const fitting = groups.map((group) => ({ group, tools: [...group.tools] })); + const omitted: string[] = []; + const assemble = (): PreparedDesktopCapabilityGroup[] => + fitting + .filter((entry) => entry.tools.length > 0) + .map((entry) => ({ ...entry.group, tools: entry.tools })); + while (!manifestFitsBudget(assemble(), services, hostPathAccess)) { + let index = fitting.length - 1; + while ( + index >= 0 && + (!fitting[index]?.group.omitUnsupportedTools || fitting[index]?.tools.length === 0) + ) { + index -= 1; + } + const entry = fitting[index]; + if (!entry) { + throw new Error( + "Desktop fixed capability groups exceed the Client Capability manifest budget", + ); + } + const dropped = entry.tools.pop(); + if (dropped) omitted.push(dropped.descriptor.name); + } + if (omitted.length > 0) { + const names = omitted.reverse(); + const shown = names.slice(0, 8).join(", "); + onDiagnostic?.( + `Desktop omitted ${names.length} MCP tool(s) beyond the Client Capability manifest budget: ${shown}${names.length > 8 ? `, +${names.length - 8} more` : ""}`, + ); + } + return assemble(); +} + +function manifestFitsBudget( + groups: readonly PreparedDesktopCapabilityGroup[], + services: readonly ClientCapabilityServiceOffer[], + hostPathAccess: ClientCapabilityHostPathAccess, +): boolean { + if (groups.length > CLIENT_CAPABILITY_MAX_OFFERS) return false; + const offers = groups.map((group) => capabilityOffer(group, hostPathAccess)); + let toolCount = 0; + for (const offer of offers) toolCount += offer.tools.length; + if (toolCount > CLIENT_CAPABILITY_MAX_TOOLS) return false; + const manifest = { + registrationId: MANIFEST_REGISTRATION_ID_PLACEHOLDER, + offers, + ...(services.length === 0 ? {} : { services }), + }; + return ( + Buffer.byteLength(JSON.stringify(manifest), "utf8") <= CLIENT_CAPABILITY_MAX_MANIFEST_BYTES + ); +} + function capabilityToolDescriptor( offerId: string, tool: MakaTool, @@ -547,11 +675,11 @@ function indexBindings( ): Map { const bindings = new Map(); for (const group of groups) { - for (const { tool } of group.tools) { + for (const { tool, descriptor } of group.tools) { const key = bindingKey({ offerId: group.offerId, - serverId: group.offerId, - toolName: tool.name, + serverId: descriptor.serverId, + toolName: descriptor.name, }); if (bindings.has(key)) { throw new Error( From 4d136369a057a819b82e8f0b85db9a96b1e93127 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 01:06:33 +0800 Subject: [PATCH 10/13] chore(runtime-host): track epoch 107 for the decoder-share declaration --- .../client-capability-tool-decoder-share.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json index 1a1b63764d..374181c971 100644 --- a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -1,5 +1,5 @@ { - "epoch": 106, + "epoch": 107, "files": ["packages/runtime-host/src/protocol/client-capability.ts"], "reason": "Pure refactor: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." } From 6d79eacb3e0255adfdbb72a4219408f5a58f38be Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 01:47:52 +0800 Subject: [PATCH 11/13] refactor(desktop): make dynamic group degradation visible and precise Review #4490: report every tool the decoder rejects through the shared diagnostic channel instead of dropping it silently; drop the unreachable validate branch in parseToolArguments (JSON-Schema tools have no client-side validator); narrow the chunking comment to the scope and published names the grant key actually pins; rename omitUnsupportedTools to dynamic with a comment covering all three behaviors it gates. --- .../main/__tests__/mcp-runtime-e2e.test.ts | 2 +- .../runtime-host-native-capabilities.test.ts | 45 ++++++++++++++-- apps/desktop/src/main/runtime-host-boot.ts | 2 +- .../main/runtime-host-native-capabilities.ts | 51 +++++++++---------- 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index a87ea2ca71..9bd6cf6346 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -63,7 +63,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools, - omitUnsupportedTools: true, + dynamic: true, }, ], }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index bb63ec3dfc..4f8caffeb7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -540,7 +540,7 @@ test('chunks a dynamic capability group beyond the single-offer tool limit', asy label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools: mcpTools, - omitUnsupportedTools: true, + dynamic: true, }, ], }); @@ -596,7 +596,7 @@ test('omits trailing dynamic tools beyond the manifest tool budget and keeps fix label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools: mcpTools, - omitUnsupportedTools: true, + dynamic: true, }, ], }, @@ -641,7 +641,7 @@ test('omits trailing dynamic tools beyond the manifest byte budget', () => { label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools: mcpTools, - omitUnsupportedTools: true, + dynamic: true, }, ], }, @@ -665,6 +665,43 @@ test('omits trailing dynamic tools beyond the manifest byte budget', () => { assert.match(diagnostics[0] ?? '', /omitted [1-9]\d* MCP tool/u); }); +test('reports dynamic tools the decoder rejects instead of dropping them silently', () => { + const diagnostics: string[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools connected by this Desktop client.', + dynamic: true, + tools: [ + tool('good_tool', z.object({}), async () => 'ok'), + { + ...tool('bad_tool', z.object({}), async () => 'ok'), + description: 'x'.repeat(8_193), + }, + ], + }, + ], + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + + assert.deepEqual( + provider.offers()[0]?.tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0] ?? '', /omitted desktop_mcp tool bad_tool/u); + assert.match(diagnostics[0] ?? '', /Invalid description/u); +}); + test('chunks and degrades a dynamic capability group deterministically', () => { const mcpTools = Array.from({ length: 70 }, (_, index) => tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => 'ok'), @@ -682,7 +719,7 @@ test('chunks and degrades a dynamic capability group deterministically', () => { label: 'MCP', description: 'MCP tools connected by this Desktop client.', tools: mcpTools, - omitUnsupportedTools: true, + dynamic: true, }, ], }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index c638639ee7..630c0c17c3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1000,7 +1000,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( description: "Use MCP tools connected by this Desktop client.", tools: mcpTools, - omitUnsupportedTools: true, + dynamic: true, }, ]), ]; diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index c6705f3dc2..9dc858538a 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -56,8 +56,13 @@ export interface DesktopCapabilityGroup { readonly label: string; readonly description: string; readonly tools: readonly MakaTool[]; - /** Keep compatible tools when a dynamic provider declares a wider schema dialect. */ - readonly omitUnsupportedTools?: boolean; + /** + * Marks a dynamically sourced group: tools the decoder rejects are omitted + * with a diagnostic, the group may be chunked past the single-offer tool + * limit, and its trailing tools are shed first under the manifest budget. + * Fixed groups never set this — their failures stay loud. + */ + readonly dynamic?: boolean; } interface PreparedDesktopCapabilityTool { @@ -70,7 +75,7 @@ interface PreparedDesktopCapabilityGroup { readonly label: string; readonly description: string; readonly tools: readonly PreparedDesktopCapabilityTool[]; - readonly omitUnsupportedTools?: boolean; + readonly dynamic?: boolean; } type NativeToolBinding = Pick; @@ -150,7 +155,7 @@ export function createDesktopNativeCapabilityProvider( ), ); const groups = fitDesktopCapabilityManifest( - prepareCapabilityGroups(capabilityGroups(input)), + prepareCapabilityGroups(capabilityGroups(input), providerOptions.onDiagnostic), serviceOffers, hostPathAccess, providerOptions.onDiagnostic, @@ -481,6 +486,7 @@ function capabilityOffer( function prepareCapabilityGroups( groups: readonly DesktopCapabilityGroup[], + onDiagnostic?: (diagnostic: string) => void, ): PreparedDesktopCapabilityGroup[] { return groups.flatMap((group) => { const tools = group.tools.flatMap((tool): PreparedDesktopCapabilityTool[] => { @@ -493,27 +499,31 @@ function prepareCapabilityGroups( ), ); } catch (error) { - if (!group.omitUnsupportedTools) throw error; + if (!group.dynamic) throw error; + onDiagnostic?.( + `Desktop omitted ${group.offerId} tool ${tool.name}: ${error instanceof Error ? error.message : String(error)}`, + ); return []; } return [{ tool, descriptor }]; }); - if (group.omitUnsupportedTools && tools.length === 0) return []; + if (group.dynamic && tools.length === 0) return []; return chunkPreparedGroup({ ...group, tools }); }); } /** * Split a dynamic group beyond the single-offer tool limit into stable chunks. - * Chunked offers keep the group's server identity, so published tool names, - * Session Grant scopes, and Host admission never depend on how the group was - * split. + * Chunked offers keep the group's server identity, so published tool names and + * Session Grant scopes do not depend on how the group was split. The grant key + * still carries the offer contract, so changing the published tool set + * re-prompts already-approved tools. */ function chunkPreparedGroup( group: PreparedDesktopCapabilityGroup, ): PreparedDesktopCapabilityGroup[] { if ( - !group.omitUnsupportedTools || + !group.dynamic || group.tools.length <= CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER ) { return [group]; @@ -558,7 +568,7 @@ function fitDesktopCapabilityManifest( let index = fitting.length - 1; while ( index >= 0 && - (!fitting[index]?.group.omitUnsupportedTools || fitting[index]?.tools.length === 0) + (!fitting[index]?.group.dynamic || fitting[index]?.tools.length === 0) ) { index -= 1; } @@ -647,21 +657,10 @@ async function parseToolArguments(tool: MakaTool, args: unknown): Promise - | { readonly success: true; readonly value: unknown } - | { readonly success: false; readonly error: unknown } - | Promise< - | { readonly success: true; readonly value: unknown } - | { readonly success: false; readonly error: unknown } - >; - }; - if (typeof parameters?.validate !== "function") return args; - const result = await parameters.validate(args); - if (result.success) return result.value; - throw result.error; + // The only non-Zod parameters are JSON-Schema declarations (MCP tools via + // jsonSchema()), which carry no client-side validator: validation is the + // producing server's responsibility. + return args; } function isPlainRecord(value: unknown): value is Record { From 07a609e170dbde11d5bab1660a4de2835d30a093 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 02:08:06 +0800 Subject: [PATCH 12/13] feat(desktop): publish MCP tools under their real server identity Review #4490: the shared desktop_mcp server identity double-prefixed every model-facing tool name and collapsed the mcp_tool grant scope's per-server granularity to a constant. Publish one dynamic offer per MCP server with the descriptor carrying the normalized real serverId and bare tool name, so the model sees mcp____ and approval prompts and Session Grants are scoped per server. buildMcpToolsWithIdentities exports the source identity from a single snapshot read; clientCapabilityEntityId moves to the protocol package and is shared with the CLI provider; Host admission matches the desktop_mcp offer prefix. --- .../main/__tests__/mcp-runtime-e2e.test.ts | 63 ++++--- .../runtime-host-native-capabilities.test.ts | 63 +++++++ apps/desktop/src/main/runtime-host-boot.ts | 38 ++-- .../main/runtime-host-native-capabilities.ts | 29 ++- packages/cli/src/mcp-capability-provider.ts | 12 +- .../client-capability-tool-decoder-share.json | 8 +- .../client-capability-coordinator.test.ts | 50 ++++-- .../src/protocol/client-capability.ts | 14 ++ .../server/client-capability-coordinator.ts | 13 +- .../runtime/src/__tests__/mcp-tools.test.ts | 29 ++- packages/runtime/src/mcp-tools.ts | 165 ++++++++++-------- 11 files changed, 332 insertions(+), 152 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index 9bd6cf6346..fa0e6921a1 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; import { McpClientManager } from '@maka/mcp'; -import { buildMcpTools } from '@maka/runtime/mcp-tools'; +import { buildMcpToolsWithIdentities } from '@maka/runtime/mcp-tools'; import { decodeClientCapabilityReplaceInput } from '@maka/runtime-host/protocol'; import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; @@ -42,14 +42,14 @@ test('MCP tools stay bound to the connection generation that advertised them', a }, }, }); - const tools = buildMcpTools(manager); - assert.deepEqual(tools.map((tool) => tool.name), [ - 'mcp__fixture__annotated', - 'mcp__fixture__echo', - ]); - const echo = tools.find((tool) => tool.name === 'mcp__fixture__echo'); + const identified = buildMcpToolsWithIdentities(manager); + assert.deepEqual( + identified.map(({ tool }) => tool.name), + ['mcp__fixture__annotated', 'mcp__fixture__echo'], + ); + const echo = identified.find(({ tool }) => tool.name === 'mcp__fixture__echo'); assert.ok(echo); - assert.equal(echo.categoryHint, 'network_send'); + assert.equal(echo.tool.categoryHint, 'network_send'); const provider = createDesktopNativeCapabilityProvider({ browserTools: [], @@ -59,10 +59,10 @@ test('MCP tools stay bound to the connection generation that advertised them', a releaseComputerUseSession() {}, additionalGroups: () => [ { - offerId: 'desktop_mcp', - label: 'MCP', + offerId: 'desktop_mcp_fixture', + label: 'MCP: fixture', description: 'MCP tools connected by this Desktop client.', - tools, + tools: identified.map(({ tool, serverId, toolName }) => ({ tool, serverId, toolName })), dynamic: true, }, ], @@ -73,13 +73,20 @@ test('MCP tools stay bound to the connection generation that advertised them', a offers: provider.offers(), }), ); - assert.deepEqual(provider.offers()[0]?.tools[0]?.inputSchema, { - type: 'object', - properties: { value: { type: 'string' } }, + // The published descriptor carries the real MCP identity: the Host + // re-proxies it to the same mcp__fixture__echo model-facing name. + assert.deepEqual(provider.offers()[0]?.tools[0] && { + serverId: provider.offers()[0]?.tools[0]?.serverId, + name: provider.offers()[0]?.tools[0]?.name, + inputSchema: provider.offers()[0]?.tools[0]?.inputSchema, + }, { + serverId: 'fixture', + name: 'echo', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, }); - assert.deepEqual(provider.offers()[0]?.tools.map((tool) => tool.name), [ - 'mcp__fixture__echo', - ]); if (!provider.call) throw new Error('Expected a callable Desktop capability provider'); assert.throws( () => provider.call!( @@ -87,9 +94,9 @@ test('MCP tools stay bound to the connection generation that advertised them', a kind: 'client.capability.call', invocationId: 'incompatible-invocation', registrationId: 'registration-1', - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'mcp__fixture__annotated', + offerId: 'desktop_mcp_fixture', + serverId: 'fixture', + toolName: 'annotated', arguments: {}, sessionId: 'session', turnId: 'turn', @@ -110,9 +117,9 @@ test('MCP tools stay bound to the connection generation that advertised them', a kind: 'client.capability.call', invocationId: 'invocation-1', registrationId: 'registration-1', - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'mcp__fixture__echo', + offerId: 'desktop_mcp_fixture', + serverId: 'fixture', + toolName: 'echo', arguments: { value: 'desktop-capability' }, sessionId: 'session', turnId: 'turn', @@ -136,7 +143,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a // The Host managed admission policy for desktop_mcp requires this contract. assert.deepEqual(admissionEvidence, { kind: 'none' }); - const result = await echo.impl({ value: 'runtime-e2e' }, { + const result = await echo.tool.impl({ value: 'runtime-e2e' }, { sessionId: 'session', turnId: 'turn', cwd: process.cwd(), toolCallId: 'call', abortSignal: new AbortController().signal, emitOutput() {}, }); @@ -150,7 +157,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a assert.ok(manager.toolSnapshot().revision > firstRevision); await assert.rejects( async () => - echo.impl( + echo.tool.impl( { value: 'stale-generation' }, { sessionId: 'session', @@ -164,12 +171,12 @@ test('MCP tools stay bound to the connection generation that advertised them', a /tool binding is stale/u, ); - const replacement = buildMcpTools(manager).find( - (tool) => tool.name === 'mcp__fixture__echo', + const replacement = buildMcpToolsWithIdentities(manager).find( + ({ tool }) => tool.name === 'mcp__fixture__echo', ); assert.ok(replacement); assert.deepEqual( - await replacement.impl( + await replacement.tool.impl( { value: 'replacement-generation' }, { sessionId: 'session', diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 4f8caffeb7..74f1cb5dd2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -702,6 +702,69 @@ test('reports dynamic tools the decoder rejects instead of dropping them silentl assert.match(diagnostics[0] ?? '', /Invalid description/u); }); +test('publishes identified tools under their real normalized MCP identity', async () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: [] as never, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp_fixture', + label: 'MCP: fixture', + description: 'MCP tools connected by this Desktop client.', + dynamic: true, + tools: [ + { + tool: tool('mcp__fixture__echo', z.object({}), async () => 'echo result'), + serverId: 'fixture', + toolName: 'echo', + }, + { + tool: tool('mcp__my_server__run', z.object({}), async () => 'run result'), + serverId: 'my.server', + toolName: 'run', + }, + ], + }, + ], + }); + + const published = provider.offers()[0]?.tools ?? []; + assert.equal(published[0]?.serverId, 'fixture'); + assert.equal(published[0]?.name, 'echo'); + // Unsafe identities are normalized to wire-safe entity ids. + assert.match(published[1]?.serverId ?? '', /^my_server_[0-9a-f]{24}$/u); + const normalizedServerId = published[1]?.serverId ?? assert.fail('Expected normalized serverId'); + + assert.deepEqual( + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp_fixture', + serverId: 'fixture', + toolName: 'echo', + arguments: {}, + }), + ), + { content: [{ type: 'text', text: 'echo result' }] }, + ); + assert.deepEqual( + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp_fixture', + serverId: normalizedServerId, + toolName: 'run', + arguments: {}, + }), + ), + { content: [{ type: 'text', text: 'run result' }] }, + ); + await provider.close(); +}); + test('chunks and degrades a dynamic capability group deterministically', () => { const mcpTools = Array.from({ length: 70 }, (_, index) => tool(`mcp_tool_${String(index).padStart(3, '0')}`, z.object({}), async () => 'ok'), diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 630c0c17c3..8e4cbda50b 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -45,7 +45,7 @@ import { SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID, SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_VERSION, } from '@maka/runtime/scheduled-task-tools'; -import { buildMcpTools } from '@maka/runtime/mcp-tools'; +import { buildMcpToolsWithIdentities } from '@maka/runtime/mcp-tools'; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, @@ -64,7 +64,7 @@ import { openRuntimeHostPeerEndpointOwner, type RuntimeHostPeerEndpointOwner, } from '@maka/runtime-host/peer-reachability'; -import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import { clientCapabilityEntityId, type WorkspaceTarget } from "@maka/runtime-host/protocol"; import { runtimeHostProfileUsesHostWorkspace } from "@maka/runtime-host/profile-kind"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; import { createWorkBoardStore } from "@maka/storage/work-board-store"; @@ -975,7 +975,13 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( releaseBrowserSession, computerUseTools: native.computerUseTools, additionalGroups: () => { - const mcpTools = buildMcpTools(mcpManager); + const mcpTools = buildMcpToolsWithIdentities(mcpManager); + const mcpServers = new Map(); + for (const identified of mcpTools) { + const server = mcpServers.get(identified.serverId); + if (server) server.push(identified); + else mcpServers.set(identified.serverId, [identified]); + } return [ { offerId: "desktop_settings", @@ -991,18 +997,20 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( "Use durable Rive workflows through this Desktop client.", tools: [riveWorkflowTool], }, - ...(mcpTools.length === 0 - ? [] - : [ - { - offerId: "desktop_mcp", - label: "MCP", - description: - "Use MCP tools connected by this Desktop client.", - tools: mcpTools, - dynamic: true, - }, - ]), + // One offer per MCP server keeps grant contracts server-scoped: a + // server change re-prompts only that server's tools. + ...[...mcpServers.keys()].sort().map((serverId) => ({ + offerId: `desktop_mcp_${clientCapabilityEntityId(serverId, 116)}`, + label: `MCP: ${serverId}`.slice(0, 128), + description: + "Use MCP tools connected by this Desktop client.", + tools: (mcpServers.get(serverId) ?? []).map((identified) => ({ + tool: identified.tool, + serverId: identified.serverId, + toolName: identified.toolName, + })), + dynamic: true as const, + })), ]; }, additionalServices: (scope) => [ diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 9dc858538a..ad6698a2b9 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -30,6 +30,7 @@ import { CLIENT_CAPABILITY_MAX_OFFERS, CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, + clientCapabilityEntityId, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, type ClientCapabilityCallFrame, @@ -51,11 +52,18 @@ const COMPUTER_USE_OFFER_ID = "desktop_computer_use"; // Same wire length as the registrationId the Client Capability channel assigns. const MANIFEST_REGISTRATION_ID_PLACEHOLDER = "00000000-0000-4000-8000-000000000000"; +/** A tool published under its own wire identity instead of the group default. */ +export interface DesktopIdentifiedCapabilityTool { + readonly tool: MakaTool; + readonly serverId: string; + readonly toolName: string; +} + export interface DesktopCapabilityGroup { readonly offerId: string; readonly label: string; readonly description: string; - readonly tools: readonly MakaTool[]; + readonly tools: readonly (MakaTool | DesktopIdentifiedCapabilityTool)[]; /** * Marks a dynamically sourced group: tools the decoder rejects are omitted * with a diagnostic, the group may be chunked past the single-offer tool @@ -489,13 +497,17 @@ function prepareCapabilityGroups( onDiagnostic?: (diagnostic: string) => void, ): PreparedDesktopCapabilityGroup[] { return groups.flatMap((group) => { - const tools = group.tools.flatMap((tool): PreparedDesktopCapabilityTool[] => { + const tools = group.tools.flatMap((entry): PreparedDesktopCapabilityTool[] => { + const tool = isIdentifiedEntry(entry) ? entry.tool : entry; + const identity = isIdentifiedEntry(entry) + ? { serverId: entry.serverId, toolName: entry.toolName } + : undefined; const declaredSchema = declaredToolInputSchema(tool); let descriptor: ClientCapabilityToolDescriptor; try { descriptor = Object.freeze( decodeClientCapabilityToolDescriptor( - capabilityToolDescriptor(group.offerId, tool, declaredSchema), + capabilityToolDescriptor(group.offerId, tool, declaredSchema, identity), ), ); } catch (error) { @@ -611,14 +623,21 @@ function manifestFitsBudget( ); } +function isIdentifiedEntry( + entry: MakaTool | DesktopIdentifiedCapabilityTool, +): entry is DesktopIdentifiedCapabilityTool { + return 'tool' in entry; +} + function capabilityToolDescriptor( offerId: string, tool: MakaTool, inputSchema: Record, + identity?: { readonly serverId: string; readonly toolName: string }, ): ClientCapabilityToolDescriptor { return Object.freeze({ - serverId: offerId, - name: tool.name, + serverId: clientCapabilityEntityId(identity?.serverId ?? offerId), + name: clientCapabilityEntityId(identity?.toolName ?? tool.name), description: tool.description, inputSchema, ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts index ad79fe7146..99c2fae840 100644 --- a/packages/cli/src/mcp-capability-provider.ts +++ b/packages/cli/src/mcp-capability-provider.ts @@ -24,6 +24,7 @@ import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, + clientCapabilityEntityId, decodeClientCapabilityReplaceInput, type ClientCapabilityCallResult, type ClientCapabilityOffer, @@ -57,7 +58,7 @@ export function createMcpCapabilityProvider( for (const source of tools) { const descriptor = projectMcpTool( source.descriptor, - capabilityEntityId(source.descriptor.serverId), + clientCapabilityEntityId(source.descriptor.serverId), ); const identity = `${descriptor.serverId}\0${descriptor.name}`; if (projectedIdentities.has(identity)) { @@ -115,20 +116,13 @@ export function createMcpCapabilityProvider( function projectMcpTool(tool: McpToolDescriptor, wireServerId: string) { return { serverId: wireServerId, - name: capabilityEntityId(tool.name), + name: clientCapabilityEntityId(tool.name), ...(tool.description ? { description: tool.description } : {}), inputSchema: structuredClone(tool.inputSchema), ...(tool.annotations ? { annotations: { ...tool.annotations } } : {}), }; } -function capabilityEntityId(value: string): string { - if (/^[A-Za-z0-9_-]{1,128}$/u.test(value)) return value; - const label = value.replace(/[^A-Za-z0-9_-]+/gu, '_').slice(0, 103) || 'mcp'; - const digest = createHash('sha256').update(value).digest('hex').slice(0, 24); - return `${label}_${digest}`; -} - function projectMcpResult(result: McpCallResult): ClientCapabilityCallResult { return { content: result.content.map((block) => structuredClone(block)), diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json index 374181c971..b6d207a44c 100644 --- a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -1,5 +1,7 @@ { - "epoch": 107, - "files": ["packages/runtime-host/src/protocol/client-capability.ts"], - "reason": "Pure refactor: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." + "epoch": 109, + "files": [ + "packages/runtime-host/src/protocol/client-capability.ts" + ], + "reason": "Pure additions: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder, and exports clientCapabilityEntityId for wire-safe identity normalization (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." } diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 994b5874fb..159a2050bc 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -508,10 +508,28 @@ describe('Host Client Capability coordinator', () => { 'done', sent, ); - await registerSessionTools(coordinator, 'connection-a', 'registration-mcp', 'desktop_mcp', [ - 'fixture_echo', - 'fixture_ping', - ]); + // Production shape: one offer per MCP server, descriptors carrying the + // real MCP identity. + const registered = await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-mcp', + offers: [ + { + offerId: 'desktop_mcp_fixture', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'MCP: fixture', + tools: [ + { serverId: 'fixture', name: 'echo', inputSchema: { type: 'object' } }, + { serverId: 'fixture', name: 'ping', inputSchema: { type: 'object' } }, + ], + }, + ], + }, + connectionContext('connection-a'), + ); + assert.equal(registered.ok, true, JSON.stringify(registered)); assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); const snapshot = coordinator.snapshotForSession('session-a'); assert.ok(snapshot); @@ -519,13 +537,13 @@ describe('Host Client Capability coordinator', () => { // accept -> approval -> admit -> execute: the provider is never admitted // before the approval resolves. - const preparedEcho = await prepare(tools.get('fixture_echo'), {}, 'tool-echo'); + const preparedEcho = await prepare(tools.get('echo'), {}, 'tool-echo'); assert.equal(approvalCount, 1); assert.equal(approvedTarget?.capability, 'desktop_mcp'); assert.deepEqual(approvedTarget?.scope, { kind: 'mcp_tool', - serverId: 'desktop_mcp', - toolName: 'fixture_echo', + serverId: 'fixture', + toolName: 'echo', }); assert.equal( sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.admitted'), @@ -542,7 +560,7 @@ describe('Host Client Capability coordinator', () => { // The persisted Session Grant covers the approved tool without a new // approval... - const preparedEchoAgain = await prepare(tools.get('fixture_echo'), {}, 'tool-echo-again'); + const preparedEchoAgain = await prepare(tools.get('echo'), {}, 'tool-echo-again'); assert.equal(approvalCount, 1); assert.deepEqual( await preparedEchoAgain.execute(managedContext('tool-echo-again')), @@ -550,12 +568,12 @@ describe('Host Client Capability coordinator', () => { ); // ...while a sibling tool under the same offer needs its own grant. - const preparedPing = await prepare(tools.get('fixture_ping'), {}, 'tool-ping'); + const preparedPing = await prepare(tools.get('ping'), {}, 'tool-ping'); assert.equal(approvalCount, 2); assert.deepEqual(approvedTarget?.scope, { kind: 'mcp_tool', - serverId: 'desktop_mcp', - toolName: 'fixture_ping', + serverId: 'fixture', + toolName: 'ping', }); assert.deepEqual(await preparedPing.execute(managedContext('tool-ping')), textResult('done')); @@ -581,9 +599,13 @@ describe('Host Client Capability coordinator', () => { 'done', sent, ); - await registerSessionTools(coordinator, 'connection-a', 'registration-mcp', 'desktop_mcp', [ - 'fixture_echo', - ]); + await registerSessionTools( + coordinator, + 'connection-a', + 'registration-mcp', + 'desktop_mcp_fixture', + ['echo'], + ); assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); const snapshot = coordinator.snapshotForSession('session-a'); assert.ok(snapshot); diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 7acc23d90a..2686ec4e50 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { TOOL_ACTIVITY_KINDS, type ToolActivityKind } from '@maka/core/events'; import { assertExactKeys, @@ -77,6 +78,19 @@ export type ClientCapabilityHostPathAccess = 'none' | 'cwd'; export const CLIENT_CAPABILITY_MAX_OFFERS = 32; export const CLIENT_CAPABILITY_MAX_SERVICES = 32; export const CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER = 64; + +/** + * Normalize an arbitrary Client Capability identity (an MCP server id or tool + * name from user configuration) into a wire-safe entity id: identities that + * already fit pass through unchanged, anything else becomes a readable label + * plus a collision-proof digest of the original value. + */ +export function clientCapabilityEntityId(value: string, maxLength = 128): string { + if (/^[A-Za-z0-9_-]+$/u.test(value) && value.length <= maxLength) return value; + const label = value.replace(/[^A-Za-z0-9_-]+/gu, '_').slice(0, maxLength - 25) || 'mcp'; + const digest = createHash('sha256').update(value).digest('hex').slice(0, 24); + return `${label}_${digest}`; +} export const CLIENT_CAPABILITY_MAX_TOOLS = 256; export const CLIENT_CAPABILITY_MAX_MANIFEST_BYTES = 56 * 1024; export const CLIENT_CAPABILITY_MAX_RESULT_BYTES = 24 * 1024 * 1024; diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 038a433151..aa0fce920a 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -66,7 +66,7 @@ import { clientCapabilityProviderId } from './client-capability-provider-id.js'; const DEFAULT_CALL_TIMEOUT_MS = 150_000; const DESKTOP_BROWSER_SERVER_ID = 'desktop_browser'; const DESKTOP_SETTINGS_SERVER_ID = 'desktop_settings'; -const DESKTOP_MCP_SERVER_ID = 'desktop_mcp'; +const DESKTOP_MCP_OFFER_PREFIX = 'desktop_mcp'; const DESKTOP_BROWSER_TOOLS = new Set([ 'browser_navigate', 'browser_snapshot', @@ -1523,10 +1523,13 @@ function managedClientCapabilityGrantTarget( scope: Object.freeze({ kind: 'browser_origin', origin: url.origin }), }); } - // Desktop MCP tools publish under a shared Desktop-owned server identity; - // chunking may spread one logical group across several offerIds, so the - // admission policy keys on the descriptor serverId rather than the offerId. - if (serverId === DESKTOP_MCP_SERVER_ID) { + // Desktop MCP tools publish one offer per MCP server (chunked past the + // single-offer tool limit), every offerId carrying the desktop_mcp prefix. + // The Session Grant scope takes the descriptor's real MCP server identity. + if ( + tool.offerId === DESKTOP_MCP_OFFER_PREFIX || + tool.offerId.startsWith(`${DESKTOP_MCP_OFFER_PREFIX}_`) + ) { if (evidence.kind !== 'none') { throw new Error('Desktop MCP admission does not accept scope evidence'); } diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index b3ffdeb2b3..48cd6292fd 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -27,7 +27,12 @@ import type { McpToolBinding, McpToolDescriptor, } from '@maka/core/mcp'; -import { buildMcpTools, mcpProxyToolName, type McpToolProvider } from '../mcp-tools.js'; +import { + buildMcpTools, + buildMcpToolsWithIdentities, + mcpProxyToolName, + type McpToolProvider, +} from '../mcp-tools.js'; import { selectCollaborationTools } from '../plan-mode.js'; test('buildMcpTools projects discovery, abort, and rich model output', async () => { @@ -271,6 +276,28 @@ test('mcpProxyToolName is stable, provider-safe, and bounded to 64 chars', () => ); }); +test('buildMcpToolsWithIdentities pairs each proxy tool with its source identity', () => { + const provider = fakeProvider( + [ + boundTool(descriptor('read server', 'read.item', true), binding('read-binding')), + boundTool(descriptor('write', 'mutate-item', undefined), binding('write-binding')), + ], + async () => ({ content: [] }), + ); + const identified = buildMcpToolsWithIdentities(provider); + assert.deepEqual( + identified.map(({ tool, serverId, toolName }) => [tool.name, serverId, toolName]), + [ + ['mcp__read_server__read_item', 'read server', 'read.item'], + ['mcp__write__mutate-item', 'write', 'mutate-item'], + ], + ); + assert.deepEqual( + buildMcpTools(provider).map((tool) => tool.name), + identified.map(({ tool }) => tool.name), + ); +}); + function descriptor(serverId: string, name: string, readOnlyHint?: boolean): McpToolDescriptor { return { serverId, diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index a7c48941db..5d67112ec5 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -87,10 +87,27 @@ export interface BuildMcpToolsOptions { activityKindForDescriptor?: (descriptor: McpToolDescriptor) => ToolActivityKind | undefined; } +export interface McpIdentifiedTool { + readonly tool: MakaTool; + readonly serverId: string; + readonly toolName: string; +} + export function buildMcpTools( provider: McpToolProvider, options: BuildMcpToolsOptions = {}, ): MakaTool[] { + return buildMcpToolsWithIdentities(provider, options).map(({ tool }) => tool); +} + +/** + * Build the proxy tools together with each tool's source MCP identity, read + * from a single snapshot so the pairing can never drift across a reconnect. + */ +export function buildMcpToolsWithIdentities( + provider: McpToolProvider, + options: BuildMcpToolsOptions = {}, +): McpIdentifiedTool[] { const names = new Map(); const snapshot = provider.toolSnapshot(); return snapshot.tools.map(({ descriptor, binding }) => { @@ -102,79 +119,83 @@ export function buildMcpTools( } names.set(name, identity); return { - name, - description: - descriptor.description?.trim() || - `MCP tool ${descriptor.name} provided by ${descriptor.serverId}`, - displayName: descriptor.annotations?.title?.trim() || descriptor.name, - activityKind: options.activityKindForDescriptor?.(descriptor) ?? 'tool', - // MCP annotations are advisory provider claims, not a security boundary. - // The trusted composition may select a stricter open-world category; - // ordinary MCP servers retain the side-effecting network default. - categoryHint: options.categoryHint ?? 'network_send', - ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), - ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - parameters: jsonSchema(descriptor.inputSchema), - ...(provider.prepareTool - ? { - prepareExecution: async (args: unknown, context) => { - const prepared = await provider.prepareTool!(binding, asArguments(args), { - signal: context.abortSignal, - timeoutMs: options.callTimeoutMs, - context: { - sessionId: context.sessionId, - runId: context.runId, - turnId: context.turnId, - toolCallId: context.toolCallId, - cwd: context.cwd, - executionBoundary: context.executionBoundary, - permissionMode: context.permissionMode, - }, - }); - return { - execute: (executionContext) => - prepared.execute({ - ...(executionContext.emitProgress - ? { emitProgress: executionContext.emitProgress } - : {}), - }), - cancel: () => prepared.cancel(), - }; - }, - } - : {}), - impl: async (args: unknown, context) => { - // Managed network authority applies equally to Direct and nested CodeMode dispatch. - if ( - options.executionLocation !== 'remote' && - context.executionBoundary?.kind === 'managed' && - context.executionBoundary.profile.network.kind !== 'enabled' - ) { - if (!context.requestSandboxBoundary) { - throw new Error('MCP network access requires sandbox boundary approval'); - } - const settlement = await context.requestSandboxBoundary( - { network: { enabled: true } }, - `Call MCP tool ${descriptor.serverId}/${descriptor.name}.`, - ); - if (settlement.request.status !== 'approved') { - throw new Error('MCP network access denied'); + serverId: descriptor.serverId, + toolName: descriptor.name, + tool: { + name, + description: + descriptor.description?.trim() || + `MCP tool ${descriptor.name} provided by ${descriptor.serverId}`, + displayName: descriptor.annotations?.title?.trim() || descriptor.name, + activityKind: options.activityKindForDescriptor?.(descriptor) ?? 'tool', + // MCP annotations are advisory provider claims, not a security boundary. + // The trusted composition may select a stricter open-world category; + // ordinary MCP servers retain the side-effecting network default. + categoryHint: options.categoryHint ?? 'network_send', + ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), + ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), + parameters: jsonSchema(descriptor.inputSchema), + ...(provider.prepareTool + ? { + prepareExecution: async (args: unknown, context) => { + const prepared = await provider.prepareTool!(binding, asArguments(args), { + signal: context.abortSignal, + timeoutMs: options.callTimeoutMs, + context: { + sessionId: context.sessionId, + runId: context.runId, + turnId: context.turnId, + toolCallId: context.toolCallId, + cwd: context.cwd, + executionBoundary: context.executionBoundary, + permissionMode: context.permissionMode, + }, + }); + return { + execute: (executionContext) => + prepared.execute({ + ...(executionContext.emitProgress + ? { emitProgress: executionContext.emitProgress } + : {}), + }), + cancel: () => prepared.cancel(), + }; + }, + } + : {}), + impl: async (args: unknown, context) => { + // Managed network authority applies equally to Direct and nested CodeMode dispatch. + if ( + options.executionLocation !== 'remote' && + context.executionBoundary?.kind === 'managed' && + context.executionBoundary.profile.network.kind !== 'enabled' + ) { + if (!context.requestSandboxBoundary) { + throw new Error('MCP network access requires sandbox boundary approval'); + } + const settlement = await context.requestSandboxBoundary( + { network: { enabled: true } }, + `Call MCP tool ${descriptor.serverId}/${descriptor.name}.`, + ); + if (settlement.request.status !== 'approved') { + throw new Error('MCP network access denied'); + } } - } - return provider.callTool(binding, asArguments(args), { - signal: context.abortSignal, - timeoutMs: options.callTimeoutMs, - context: { - sessionId: context.sessionId, - turnId: context.turnId, - toolCallId: context.toolCallId, - cwd: context.cwd, - }, - ...(context.emitProgress ? { emitProgress: context.emitProgress } : {}), - }); - }, - toModelOutput: ({ output }) => mcpResultToModelOutput(output), - } satisfies MakaTool; + return provider.callTool(binding, asArguments(args), { + signal: context.abortSignal, + timeoutMs: options.callTimeoutMs, + context: { + sessionId: context.sessionId, + turnId: context.turnId, + toolCallId: context.toolCallId, + cwd: context.cwd, + }, + ...(context.emitProgress ? { emitProgress: context.emitProgress } : {}), + }); + }, + toModelOutput: ({ output }) => mcpResultToModelOutput(output), + } satisfies MakaTool, + }; }); } From 04fc935f7030604238b5e29efad12e942a780456 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 02:16:36 +0800 Subject: [PATCH 13/13] style(runtime-host): format the decoder-share declaration --- .../client-capability-tool-decoder-share.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json index b6d207a44c..55a5e521c6 100644 --- a/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -1,7 +1,5 @@ { "epoch": 109, - "files": [ - "packages/runtime-host/src/protocol/client-capability.ts" - ], + "files": ["packages/runtime-host/src/protocol/client-capability.ts"], "reason": "Pure additions: extracts decodeClientCapabilityToolInputSchema and exports decodeClientCapabilityToolDescriptor so the desktop native capability path can share the same decoder, and exports clientCapabilityEntityId for wire-safe identity normalization (#4490). Wire shape, validation limits, and error behavior are unchanged; older and newer peers decode identical frames." }