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 fbb334be95..654d89aecc 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -75,10 +75,13 @@ test('MCP tools stay bound to the connection generation that advertised them', a ); // 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, + const publishedEcho = provider.offers() + .flatMap(({ tools }) => tools) + .find(({ serverId, name }) => serverId === 'fixture' && name === 'echo'); + assert.deepEqual(publishedEcho && { + serverId: publishedEcho.serverId, + name: publishedEcho.name, + inputSchema: publishedEcho.inputSchema, }, { serverId: 'fixture', name: 'echo', @@ -87,6 +90,23 @@ test('MCP tools stay bound to the connection generation that advertised them', a properties: { value: { type: 'string' } }, }, }); + const publishedAnnotated = provider.offers() + .flatMap(({ tools }) => tools) + .find(({ serverId, name }) => serverId === 'fixture' && name === 'annotated'); + assert.deepEqual(publishedAnnotated && { + serverId: publishedAnnotated.serverId, + name: publishedAnnotated.name, + inputSchema: publishedAnnotated.inputSchema, + }, { + serverId: 'fixture', + name: 'annotated', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + patternProperties: { '^tag:': { type: 'string' } }, + additionalItems: { type: 'integer' }, + }, + }); if (!provider.call) throw new Error('Expected a callable Desktop capability provider'); assert.throws( () => provider.call!( @@ -96,7 +116,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a registrationId: 'registration-1', offerId: 'desktop_mcp_fixture', serverId: 'fixture', - toolName: 'annotated', + toolName: 'missing', arguments: {}, sessionId: 'session', turnId: 'turn', 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 5e0d464f0e..b272f32291 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 @@ -547,32 +547,53 @@ test('rolls back only candidate-owned IPC after a registration collision', async assert.equal(host.closeCalls, 1); }); -test('closes the claimed Host connection when native capability construction fails', async () => { +test('isolates an invalid dynamic MCP tool without dropping the Host connection', async () => { + // Per-tool isolation: one bad tool is skipped and the provider still + // constructs, so the Host connection stays alive. const ipc = ipcHarness(); const host = connectionHarness('invalid-capability'); const invalidTool = { ...nativeTool(), parameters: z.string(), } as unknown as MakaTool; + const healthyTool = { + ...nativeTool(), + name: 'healthy_mcp', + impl: async () => 'healthy', + }; - await assert.rejects( - () => - createDesktopRuntimeHostCandidate( - host.connection, - deps(ipc, { - browserTools: [invalidTool], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, - }), - ), - // 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/, + const candidate = await createDesktopRuntimeHostCandidate( + host.connection, + deps(ipc, { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: emptyComputerUseTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [invalidTool, healthyTool], + }, + ], + }), ); - assert.equal(ipc.size, 0); + assert.equal(host.capabilityRegistrations, 1); + assert.equal(host.closeCalls, 0); + assert.deepEqual( + await host.invokeCapability({ + ...capabilityFrame('session-invalid-capability'), + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'healthy_mcp', + }), + { content: [{ type: 'text', text: 'healthy' }] }, + ); + + await candidate.close(); assert.equal(host.closeCalls, 1); }); 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 1b73728b91..be3ee16961 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 @@ -35,6 +35,12 @@ import { browserOriginAdmission } from '../browser/browser-origin-admission.js'; import { buildRiveWorkflowTool } from '../rive-workflow-tool.js'; import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; +function jsonSchema(schema: Record): { + jsonSchema: Record; +} { + return { jsonSchema: schema }; +} + test('publishes self-described session-affine Browser and Computer Use offers', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [tool('browser_snapshot', z.object({ includeHidden: z.boolean().optional() }), async () => 'ok')], @@ -138,6 +144,410 @@ test('publishes the real Computer Use schema through the Client Capability proto ); }); +test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'fixture_tool', + displayName: 'fixture_tool', + description: 'fixture_tool description', + parameters: jsonSchema({ + $id: 'https://example.com/tool.schema.json', + type: 'object', + properties: { + prefix: { + type: 'string', + default: 'ready', + enum: ['ready', 'done'], + examples: ['ready'], + pattern: '^[a-z]+$', + }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + const published = provider.offers()[0]?.tools[0]?.inputSchema; + const properties = published?.properties as + | Record + | undefined; + const prefixSchema = properties?.prefix; + assert.equal(published?.$id, undefined); + assert.equal(prefixSchema?.default, 'ready'); + assert.deepEqual(prefixSchema?.enum, ['ready', 'done']); + assert.deepEqual(prefixSchema?.examples, ['ready']); + assert.deepEqual(published?.patternProperties, { '^x-': { type: 'string' } }); +}); + +test('skips non-object root jsonSchema tools without dropping the offer', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'string', + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); +}); + +test('skips malformed record-shaped schemas without dropping healthy MCP tools', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ type: 'object', properties: [] as never }), + impl: async () => 'bad', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'good', + }, + ], + }, + ], + }); + + assert.deepEqual( + provider.offers().flatMap((offer) => offer.tools).map((tool) => tool.name), + ['good_tool'], + ); +}); + +test('preserves a JSON Schema property named __proto__ during projection', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'proto_tool', + displayName: 'proto_tool', + description: 'proto_tool description', + parameters: jsonSchema({ + type: 'object', + properties: JSON.parse( + '{"__proto__":{"type":"string"},"safe":{"type":"number"}}', + ), + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const properties = provider.offers()[0]?.tools[0]?.inputSchema.properties as + | Record> + | undefined; + assert.ok(properties && Object.hasOwn(properties, '__proto__')); + assert.deepEqual(properties?.['__proto__'], { type: 'string' }); + assert.deepEqual(properties?.safe, { type: 'number' }); +}); + +test('skips unsupported schema type tools without dropping the offer', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: 42, + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); +}); + +test('skips a malformed MCP tool without dropping the other offers', async () => { + let healthyCalls = 0; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [ + tool('browser_snapshot', z.object({}), async () => { + healthyCalls += 1; + return 'snapshot'; + }), + ], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + patternProperties: { '(': { type: 'string' } }, + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + // The malformed tool is skipped; the healthy tool stays published and + // callable, and the empty-offer case never poisons the registration. + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['browser_snapshot', 'good_tool'], + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'good_tool', + arguments: { value: 'hello' }, + }), + ); + await call( + provider, + capabilityFrame({ + offerId: 'desktop_browser', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + arguments: {}, + }), + ); + assert.equal(healthyCalls, 1); +}); + +test('an invalid patternProperties regex key is isolated at the provider boundary', () => { + // An unparseable regex key is rejected by the protocol boundary when the + // provider is built, so the offending tool is skipped before publication. + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'object', + patternProperties: { + '(': { type: 'string' }, + }, + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); +}); + +test('empty allOf/anyOf/oneOf are projected away so the schema still publishes', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'fixture_tool', + displayName: 'fixture_tool', + description: 'fixture_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + x: { type: 'string', allOf: [], anyOf: [], oneOf: [] }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + const published = provider.offers()[0]?.tools[0]?.inputSchema as + | { properties?: { x?: Record } } + | undefined; + const x = published?.properties?.x; + assert.deepEqual(x, { type: 'string' }); + assert.equal(x !== undefined && 'allOf' in x, false); + assert.equal(x !== undefined && 'anyOf' in x, false); + assert.equal(x !== undefined && 'oneOf' in x, false); +}); + test('publishes every production Desktop-owned tool schema through the protocol', () => { const settingsTools = buildClientSettingsTools({ async read() { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a4df30f0f1..2871a7ab42 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -32,6 +32,7 @@ import { CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, + projectToolInputSchema, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, type ClientCapabilityContentBlock, @@ -394,7 +395,7 @@ async function invokeNativeTool( } const signal = AbortSignal.any([options.signal, invocation.signal]); signal.throwIfAborted(); - const args = await parseToolArguments(binding.tool, frame.arguments); + const args = await parseNativeToolArguments(binding.tool.parameters, frame.arguments); signal.throwIfAborted(); const sessionId = frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID @@ -502,16 +503,17 @@ function prepareCapabilityGroups( const identity = isIdentifiedEntry(entry) ? { serverId: entry.serverId, toolName: entry.toolName } : undefined; - const declaredSchema = declaredToolInputSchema(tool); let descriptor: ClientCapabilityToolDescriptor; try { + const declaredSchema = declaredToolInputSchema(tool); descriptor = Object.freeze( decodeClientCapabilityToolDescriptor( capabilityToolDescriptor(group.offerId, tool, declaredSchema, identity), ), ); } catch (error) { - if (!group.dynamic) throw error; + const dynamic = group.dynamic || group.offerId === 'desktop_mcp'; + if (!dynamic) throw error; onDiagnostic?.( `Desktop omitted ${group.offerId} tool ${tool.name}: ${error instanceof Error ? error.message : String(error)}`, ); @@ -658,7 +660,7 @@ function declaredToolInputSchema(tool: MakaTool): Record { }) : cloneDeclaredJsonSchema(tool); delete schema.$schema; - return schema; + return Object.freeze(projectToolInputSchema(schema)); } function cloneDeclaredJsonSchema(tool: MakaTool): Record { @@ -669,16 +671,20 @@ function cloneDeclaredJsonSchema(tool: MakaTool): Record { `Desktop native capability tool has an invalid schema: ${tool.name}`, ); } - return structuredClone(schema); + const projected = Object.hasOwn(schema, 'type') + ? schema + : { ...schema, type: 'object' }; + return structuredClone(projected); } -async function parseToolArguments(tool: MakaTool, args: unknown): Promise { - if (tool.parameters instanceof z.ZodType) { - return tool.parameters.parseAsync(args); +async function parseNativeToolArguments(parameters: unknown, args: unknown): Promise { + if (parameters instanceof z.ZodType) { + return 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. + // MCP servers remain the authority for their full JSON Schema. The Client + // Capability publication is a deliberately smaller protocol projection, so + // compiling the external schema again here would duplicate that authority + // and execute untrusted regular expressions on the main thread. return args; } diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index b5cfcdf54a..f9a5a55381 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -344,6 +344,26 @@ describe('Client Capability protocol', () => { ), (error: unknown) => error instanceof RuntimeHostProtocolError, ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('pattern_properties', 'tool'), + tools: [ + { + ...offer('pattern_properties', 'tool').tools[0], + inputSchema: { + type: 'object', + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + ); for (const inputSchema of [ { type: 'string' }, { type: 'object', unsupportedKeyword: true }, @@ -391,6 +411,52 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('pattern_properties', 'tool'), + tools: [ + { + ...offer('pattern_properties', 'tool').tools[0], + inputSchema: { + type: 'object', + properties: { + prefix: { type: 'string', pattern: '^[a-z]+$' }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('bad_pattern_property', 'tool'), + tools: [ + { + ...offer('bad_pattern_property', 'tool').tools[0], + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + patternProperties: { '(': { type: 'string' } }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => + error instanceof RuntimeHostProtocolError && + /patternProperties key is not a valid pattern/u.test(error.message), + ); assert.doesNotThrow(() => decodeClientFrame( replaceFrame([ diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 8c7aef6717..d70341ffcc 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -810,7 +810,7 @@ const CLIENT_CAPABILITY_SCHEMA_TYPES = new Set([ 'object', 'string', ]); -const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ +export const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ '$defs', '$ref', 'additionalItems', @@ -838,6 +838,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'multipleOf', 'oneOf', 'pattern', + 'patternProperties', 'propertyNames', 'properties', 'required', @@ -846,7 +847,88 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'uniqueItems', ]); -function validateToolInputSchema(root: Record): void { +const CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES: Record< + string, + 'record' | 'array' | 'single_or_array' | 'single' +> = { + properties: 'record', + patternProperties: 'record', + $defs: 'record', + definitions: 'record', + allOf: 'array', + anyOf: 'array', + oneOf: 'array', + items: 'single_or_array', + additionalProperties: 'single', + propertyNames: 'single', +}; + +/** + * Project an external JSON Schema (e.g. from an MCP tool) down to exactly the + * keywords the Client Capability protocol admits, recursing into nested schemas + * via the same shape table that {@link validateToolInputSchema} uses. + * + * `$ref` is retained when it resolves locally inside `$defs`/`definitions`; + * otherwise upstream callers should omit it first. + * + * Empty `items`, `allOf`, `anyOf`, and `oneOf` are dropped so the projected + * schema never emits a shape the protocol boundary rejects. + */ +export function projectToolInputSchema(schema: Record): Record { + if (!Object.hasOwn(schema, 'type') || schema.type !== 'object') { + throw new Error('Client Capability tool schema root must be an object'); + } + return projectSchemaNode(schema) as Record; +} + +function projectSchemaNode(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((entry) => projectSchemaNode(entry)); + const schema = value as Record; + const result: Record = {}; + for (const [key, val] of Object.entries(schema)) { + if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; + const projected = projectSchemaKeyword(key, val); + if (projected !== undefined) { + result[key] = projected; + } + } + return result; +} + +function projectSchemaKeyword(key: string, value: unknown): unknown { + const shape = CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES[key]; + if (shape === undefined) return value; + switch (shape) { + case 'record': { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Client Capability tool schema ${key} must be an object`); + } + return Object.fromEntries( + Object.entries(value as Record).map(([nestedKey, nestedValue]) => [ + nestedKey, + projectSchemaNode(nestedValue), + ]), + ); + } + case 'array': { + if (!Array.isArray(value) || value.length === 0) return undefined; + return value.map((entry) => projectSchemaNode(entry)); + } + case 'single_or_array': { + if (Array.isArray(value)) { + if (value.length === 0) return undefined; + return value.map((entry) => projectSchemaNode(entry)); + } + return projectSchemaNode(value); + } + case 'single': { + return projectSchemaNode(value); + } + } +} + +export function validateToolInputSchema(root: Record): void { if (!Object.hasOwn(root, 'type') || root.type !== 'object') { throw invalidProtocolFrame('Client Capability tool schema root must be an object'); } @@ -905,11 +987,6 @@ function validateToolInputSchema(root: Record): void { if (schema.uniqueItems !== undefined && typeof schema.uniqueItems !== 'boolean') { throw invalidProtocolFrame('Invalid Client Capability tool schema uniqueItems'); } - for (const key of ['properties', '$defs', 'definitions'] as const) { - if (schema[key] === undefined) continue; - const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); - for (const nested of Object.values(entries)) visit(nested); - } if (schema.required !== undefined) { if ( !Array.isArray(schema.required) || @@ -919,30 +996,42 @@ function validateToolInputSchema(root: Record): void { throw invalidProtocolFrame('Invalid Client Capability tool schema required'); } } - for (const key of ['additionalItems', 'additionalProperties'] as const) { - if (schema[key] !== undefined && typeof schema[key] !== 'boolean') { - visit(schema[key]); - } - } - if (schema.propertyNames !== undefined) { - visit(schema.propertyNames); - } - if (schema.items !== undefined) { - if (Array.isArray(schema.items)) { - if (schema.items.length === 0) { - throw invalidProtocolFrame('Invalid Client Capability tool schema items'); - } - for (const nested of schema.items) visit(nested); - } else { - visit(schema.items); - } - } - for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + for (const [key, shape] of Object.entries(CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES)) { if (schema[key] === undefined) continue; - if (!Array.isArray(schema[key]) || schema[key].length === 0) { - throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + switch (shape) { + case 'record': { + const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); + if (key === 'patternProperties') { + for (const patternKey of Object.keys(entries)) { + validateSchemaPattern(patternKey); + } + } + for (const nested of Object.values(entries)) visit(nested); + break; + } + case 'array': { + if (!Array.isArray(schema[key]) || (schema[key] as unknown[]).length === 0) { + throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + } + for (const nested of schema[key] as unknown[]) visit(nested); + break; + } + case 'single_or_array': { + if (Array.isArray(schema[key])) { + if ((schema[key] as unknown[]).length === 0) { + throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + } + for (const nested of schema[key] as unknown[]) visit(nested); + } else { + visit(schema[key]); + } + break; + } + case 'single': { + visit(schema[key]); + break; + } } - for (const nested of schema[key]) visit(nested); } if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) { throw invalidProtocolFrame('Invalid Client Capability tool schema enum'); @@ -968,6 +1057,21 @@ function validateToolInputSchema(root: Record): void { } } +function validateSchemaPattern(value: unknown): void { + if (typeof value !== 'string') { + throw invalidProtocolFrame( + 'Client Capability tool schema patternProperties key must be a string', + ); + } + try { + new RegExp(value); + } catch { + throw invalidProtocolFrame( + 'Client Capability tool schema patternProperties key is not a valid pattern', + ); + } +} + function validateSchemaType(value: unknown): void { const values = Array.isArray(value) ? value : [value]; if ( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..57953277b6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Client Capability tool schemas add `patternProperties` and draft-07 tuple +// `additionalItems`; validation and projection share one per-keyword shape table. +// Older peers reject these keywords and fail the handshake. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index f4d8657420..a03687eba5 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -102,6 +102,21 @@ test('buildMcpTools projects discovery, abort, and rich model output', async () assert.match(model?.value[2]?.type === 'text' ? model.value[2].text : '', /structuredContent/u); }); +test('buildMcpTools installs bounded MCP schema preflight validation', async () => { + const [tool] = buildMcpTools( + fakeProvider( + [boundTool(descriptor('server', 'validated'), binding('validated-binding'))], + async () => ({ content: [] }), + ), + ); + const parameters = tool?.parameters as { + validate?: (value: unknown) => Promise<{ success: boolean }>; + }; + assert.equal(typeof parameters.validate, 'function'); + assert.equal((await parameters.validate?.({ value: 'ok' }))?.success, true); + assert.equal((await parameters.validate?.({ value: 42 }))?.success, false); +}); + test('buildMcpTools carries the Runtime-owned form callback to the provider', async () => { const cancellation = new AbortController(); const provider = fakeProvider( diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..4f3ce2e8d8 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -580,6 +580,30 @@ const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); const codeModeCompiledSchemas = new WeakMap(); +const mcpCompiledSchemas = new WeakMap(); +const MCP_SCHEMA_CONTAINER_KEYS = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'definitions', + 'allOf', + 'anyOf', + 'oneOf', + 'items', + 'additionalItems', + 'additionalProperties', + 'propertyNames', + 'prefixItems', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'dependentSchemas', + 'dependencies', + 'unevaluatedItems', + 'unevaluatedProperties', +]); async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { const parameters = tool.parameters as { @@ -617,6 +641,53 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis throw invalidCodeModeToolArguments(tool.name, validator.errors); } +/** + * Run a bounded MCP preflight without executing server-supplied regexes in the + * Runtime process. The MCP server remains authoritative for the complete + * schema, including `pattern` and `patternProperties`. + */ +export function validateMcpJsonSchemaInput( + schema: unknown, + input: unknown, +): + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: Error } { + const validator = compileMcpJsonSchema(schema); + if (!validator || validator(input)) return { success: true, value: input }; + return { + success: false, + error: new Error(schemaErrorSummary(validator.errors)), + }; +} + +function compileMcpJsonSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; + if (mcpCompiledSchemas.has(schema)) return mcpCompiledSchemas.get(schema); + const localSchema = stripMcpRegexConstraints(schema); + let compiled: ValidateFunction | undefined; + try { + compiled = compileCodeModeJsonSchema(localSchema); + } catch { + // Unsupported or malformed schemas are left to the MCP endpoint. + compiled = undefined; + } + mcpCompiledSchemas.set(schema, compiled); + return compiled; +} + +function stripMcpRegexConstraints(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(stripMcpRegexConstraints); + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (key === 'pattern' || key === 'patternProperties') continue; + result[key] = MCP_SCHEMA_CONTAINER_KEYS.has(key) + ? stripMcpRegexConstraints(nested) + : nested; + } + return result; +} + function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 0c343f0d40..7703f388af 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -19,6 +19,7 @@ import { createHash } from 'node:crypto'; import { jsonSchema } from 'ai'; +import { validateMcpJsonSchemaInput } from './ai-sdk-backend.js'; import type { ToolActivityKind } from '@maka/core/events'; import type { McpCallResult, @@ -36,6 +37,10 @@ import type { MakaTool } from './tool-runtime.js'; const MAX_PROVIDER_TOOL_NAME = 64; const HASH_CHARS = 10; + +function normalizeMcpInputSchema(schema: Record): Record { + return Object.hasOwn(schema, 'type') ? schema : { ...schema, type: 'object' }; +} const MAX_NATIVE_IMAGE_BASE64_CHARS = 20_000_000; const MAX_NATIVE_IMAGES = 4; const MAX_MODEL_TEXT_CHARS = 200_000; @@ -107,10 +112,6 @@ export function buildMcpTools( 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 = {}, @@ -118,6 +119,7 @@ export function buildMcpToolsWithIdentities( const names = new Map(); const snapshot = provider.toolSnapshot(); return snapshot.tools.map(({ descriptor, binding }) => { + const inputSchema = normalizeMcpInputSchema(descriptor.inputSchema); const identity = `${descriptor.serverId}\0${descriptor.name}`; const name = mcpProxyToolName(descriptor.serverId, descriptor.name); const collision = names.get(name); @@ -141,7 +143,11 @@ export function buildMcpToolsWithIdentities( categoryHint: options.categoryHint ?? 'network_send', ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - parameters: jsonSchema(descriptor.inputSchema), + parameters: jsonSchema(inputSchema, { + // Local preflight covers bounded structural constraints; the MCP + // server remains authoritative for the complete schema. + validate: async (value) => validateMcpJsonSchemaInput(inputSchema, value), + }), ...(provider.prepareTool ? { prepareExecution: async (args: unknown, context) => {