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..fbb334be95 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,9 @@ 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'; const fixturePath = fileURLToPath(new URL('../../../../../packages/mcp/dist/__fixtures__/stdio-server.js', import.meta.url)); @@ -34,14 +36,116 @@ 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); - 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'); - const result = await echo.impl({ value: 'runtime-e2e' }, { + assert.equal(echo.tool.categoryHint, 'network_send'); + + 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.', + tools: identified.map(({ tool, serverId, toolName }) => ({ tool, serverId, toolName })), + dynamic: true, + }, + ], + }); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + // 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' } }, + }, + }); + 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_fixture', + serverId: 'fixture', + toolName: 'annotated', + arguments: {}, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'incompatible-capability-call', + cwd: process.cwd(), + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), + }, + ), + /not offered/u, + ); + let admissionEvidence: unknown; + assert.deepEqual( + await provider.call( + { + kind: 'client.capability.call', + invocationId: 'invocation-1', + registrationId: 'registration-1', + offerId: 'desktop_mcp_fixture', + serverId: 'fixture', + toolName: 'echo', + arguments: { value: 'desktop-capability' }, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'capability-call', + cwd: process.cwd(), + }, + { + signal: new AbortController().signal, + accept: async (evidence) => { + admissionEvidence = evidence; + }, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), + }, + ), + { + content: [ + { type: 'text', text: 'desktop-capability' }, + { type: 'text', text: '{"structuredContent":{"echoed":"desktop-capability"}}' }, + ], + }, + ); + // The Host managed admission policy for desktop_mcp requires this contract. + assert.deepEqual(admissionEvidence, { kind: 'none' }); + + const result = await echo.tool.impl({ value: 'runtime-e2e' }, { sessionId: 'session', turnId: 'turn', cwd: process.cwd(), toolCallId: 'call', abortSignal: new AbortController().signal, emitOutput() {}, }); @@ -55,7 +159,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', @@ -69,12 +173,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-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 65ee0b6da9..294ed24592 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); 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 89658d460a..1b73728b91 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,289 @@ 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, + dynamic: 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, + dynamic: 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, + dynamic: 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('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('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'), + ); + 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, + dynamic: 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-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 74e68af3ee..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,17 +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, - }, - ]), + // 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-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 bebe81db1c..ad6698a2b9 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -25,14 +25,22 @@ import { type ClientCapabilityProvider, type OAuthPresentationBackend, } from "@maka/runtime-host/client"; -import type { - ClientCapabilityCallFrame, - ClientCapabilityCallResult, - ClientCapabilityContentBlock, - ClientCapabilityHostPathAccess, - ClientCapabilityOffer, - ClientCapabilityServiceCallFrame, - ClientCapabilityServiceOffer, +import { + CLIENT_CAPABILITY_MAX_MANIFEST_BYTES, + CLIENT_CAPABILITY_MAX_OFFERS, + CLIENT_CAPABILITY_MAX_TOOLS, + CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, + clientCapabilityEntityId, + decodeClientCapabilityReplaceInput, + decodeClientCapabilityToolDescriptor, + 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'; @@ -41,18 +49,45 @@ 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"; + +/** 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 + * 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 NativeToolBinding { +interface PreparedDesktopCapabilityTool { readonly tool: MakaTool; + readonly descriptor: ClientCapabilityToolDescriptor; +} + +interface PreparedDesktopCapabilityGroup { + readonly offerId: string; + readonly label: string; + readonly description: string; + readonly tools: readonly PreparedDesktopCapabilityTool[]; + readonly dynamic?: boolean; } +type NativeToolBinding = Pick; + type DesktopToolModelOutput = Awaited< ReturnType> >; @@ -103,6 +138,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; } @@ -112,12 +149,7 @@ export function createDesktopNativeCapabilityProvider( input: DesktopNativeCapabilityProviderInput, providerOptions: DesktopNativeCapabilityProviderOptions = {}, ): DesktopNativeCapabilityProvider { - const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; - const offers = Object.freeze( - groups.map((group) => capabilityOffer(group, hostPathAccess)), - ); - const bindings = indexBindings(groups); const oauthPresentation = input.oauthPresentation ? createOAuthPresentationClientProvider(input.oauthPresentation) : undefined; @@ -125,6 +157,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), providerOptions.onDiagnostic), + 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, @@ -151,7 +208,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"); @@ -337,8 +394,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 @@ -420,7 +476,7 @@ function abortInvocations( } function capabilityOffer( - group: DesktopCapabilityGroup, + group: PreparedDesktopCapabilityGroup, hostPathAccess: ClientCapabilityHostPathAccess, ): ClientCapabilityOffer { return Object.freeze({ @@ -431,58 +487,217 @@ 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 { - const schema = toJSONSchema(requireZodSchema(tool), { - io: "input", - target: "draft-07", - unrepresentable: "any", - cycles: "ref", - reused: "inline", +function prepareCapabilityGroups( + groups: readonly DesktopCapabilityGroup[], + onDiagnostic?: (diagnostic: string) => void, +): PreparedDesktopCapabilityGroup[] { + return groups.flatMap((group) => { + 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, identity), + ), + ); + } catch (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.dynamic && tools.length === 0) return []; + return chunkPreparedGroup({ ...group, tools }); }); - delete schema.$schema; - if (schema.type !== "object") { - throw new Error( - `Desktop native capability tool schema must be an object: ${tool.name}`, +} + +/** + * 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 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.dynamic || + 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.dynamic || 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 Object.freeze(schema); + 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 requireZodSchema(tool: MakaTool): z.ZodType { - if (!(tool.parameters instanceof z.ZodType)) { +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: clientCapabilityEntityId(identity?.serverId ?? offerId), + name: clientCapabilityEntityId(identity?.toolName ?? 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", + target: "draft-07", + unrepresentable: "any", + cycles: "ref", + reused: "inline", + }) + : cloneDeclaredJsonSchema(tool); + delete schema.$schema; + return schema; +} + +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); + } + // 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } 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, 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( 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/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/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..55a5e521c6 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/client-capability-tool-decoder-share.json @@ -0,0 +1,5 @@ +{ + "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 adb437d2eb..5ac86bb6ca 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,163 @@ 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, + ); + // 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); + 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('echo'), {}, 'tool-echo'); + assert.equal(approvalCount, 1); + assert.equal(approvedTarget?.capability, 'desktop_mcp'); + assert.deepEqual(approvedTarget?.scope, { + kind: 'mcp_tool', + serverId: 'fixture', + toolName: '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('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('ping'), {}, 'tool-ping'); + assert.equal(approvalCount, 2); + assert.deepEqual(approvedTarget?.scope, { + kind: 'mcp_tool', + serverId: 'fixture', + toolName: '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/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 404f6eac43..9e9c43c535 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 { decodeInteractionAnswer, @@ -83,6 +84,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; @@ -726,7 +740,7 @@ function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { : { description: requireString(record.description, 'description', 1_024), }), - tools: record.tools.map(decodeToolDescriptor), + tools: record.tools.map(decodeClientCapabilityToolDescriptor), }; } @@ -751,7 +765,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, @@ -759,11 +775,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), @@ -786,6 +798,15 @@ function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { }; } +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; diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index d836f6d286..ba69bda1f6 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_OFFER_PREFIX = 'desktop_mcp'; const DESKTOP_BROWSER_TOOLS = new Set([ 'browser_navigate', 'browser_snapshot', @@ -1504,32 +1505,51 @@ 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 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'); + } + 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 { diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 878154d13f..ba1503989f 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 () => { @@ -359,6 +364,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 13a4ee5225..741a149ee5 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -93,10 +93,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 }) => { @@ -108,93 +125,97 @@ 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 } - : {}), - ...(executionContext.requestUserForm - ? { - requestInteraction: (form, interactionOptions) => - executionContext.requestUserForm!(form, interactionOptions), - } - : {}), - }), - 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 } + : {}), + ...(executionContext.requestUserForm + ? { + requestInteraction: (form, interactionOptions) => + executionContext.requestUserForm!(form, interactionOptions), + } + : {}), + }), + 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 } : {}), - ...(context.requestUserForm - ? { - requestInteraction: ( - form: InteractionFormInput, - interactionOptions?: { readonly cancellationSignal?: AbortSignal }, - ) => context.requestUserForm!(form, interactionOptions), - } - : {}), - }); - }, - 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 } : {}), + ...(context.requestUserForm + ? { + requestInteraction: ( + form: InteractionFormInput, + interactionOptions?: { readonly cancellationSignal?: AbortSignal }, + ) => context.requestUserForm!(form, interactionOptions), + } + : {}), + }); + }, + toModelOutput: ({ output }) => mcpResultToModelOutput(output), + } satisfies MakaTool, + }; }); }