From dc2430c61e3569341f0d822bead1a8a406abedf9 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Wed, 2 Sep 2026 16:06:40 +0800 Subject: [PATCH 01/19] fix: align MCP schema handling with protocol --- .../runtime-host-native-capabilities.test.ts | 64 +++++++++++++++++++ .../client-capability-protocol.test.ts | 49 ++++++++++++++ .../src/protocol/client-capability.ts | 3 +- 3 files changed, 115 insertions(+), 1 deletion(-) 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..01aeed0b2b 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 @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { jsonSchema } from 'ai'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; @@ -138,6 +139,69 @@ test('publishes the real Computer Use schema through the Client Capability proto ); }); +test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { + const calls: unknown[] = []; + 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', pattern: '^[a-z]+$' }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }), + impl: async (args) => { + calls.push(args); + return 'ok'; + }, + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + assert.equal(provider.offers()[0]?.tools[0]?.inputSchema.$id, undefined); + assert.deepEqual( + provider.offers()[0]?.tools[0]?.inputSchema.patternProperties, + { '^x-': { type: 'string' } }, + ); + + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'fixture_tool', + arguments: { prefix: 'abc', 'x-test': 'value' }, + }), + ); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { prefix: 'abc', 'x-test': 'value' }); +}); + test('publishes every production Desktop-owned tool schema through the protocol', () => { const settingsTools = buildClientSettingsTools({ async read() { 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..95894df3f6 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,35 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('annotated_schema', 'tool'), + tools: [ + { + ...offer('annotated_schema', 'tool').tools[0], + inputSchema: { + $id: 'https://example.com/tool.schema.json', + type: 'object', + properties: { + prefix: { + type: 'string', + pattern: '^[a-z]+$', + }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); 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..54aadcca57 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -838,6 +838,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'multipleOf', 'oneOf', 'pattern', + 'patternProperties', 'propertyNames', 'properties', 'required', @@ -905,7 +906,7 @@ 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) { + for (const key of ['properties', 'patternProperties', '$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); From 8abd809dd89609d114165f0aba17bb120612e4bd Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 10:20:28 +0800 Subject: [PATCH 02/19] fix: align MCP schema handling with protocol --- .../runtime-host-native-capabilities.test.ts | 15 +++- .../main/runtime-host-native-capabilities.ts | 74 ++++++++++++++++--- .../client-capability-protocol.test.ts | 25 +++++++ .../src/protocol/client-capability.ts | 2 +- 4 files changed, 105 insertions(+), 11 deletions(-) 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 01aeed0b2b..25e94b82f5 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 @@ -161,7 +161,13 @@ test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { $id: 'https://example.com/tool.schema.json', type: 'object', properties: { - prefix: { type: 'string', pattern: '^[a-z]+$' }, + prefix: { + type: 'string', + default: 'ready', + enum: ['ready', 'done'], + examples: ['ready'], + pattern: '^[a-z]+$', + }, }, patternProperties: { '^x-': { type: 'string' }, @@ -183,7 +189,14 @@ test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { offers: provider.offers(), }), ); + const properties = provider.offers()[0]?.tools[0]?.inputSchema.properties as + | Record + | undefined; + const prefixSchema = properties?.prefix; assert.equal(provider.offers()[0]?.tools[0]?.inputSchema.$id, undefined); + assert.equal(prefixSchema?.default, 'ready'); + assert.deepEqual(prefixSchema?.enum, ['ready', 'done']); + assert.deepEqual(prefixSchema?.examples, ['ready']); assert.deepEqual( provider.offers()[0]?.tools[0]?.inputSchema.patternProperties, { '^x-': { type: 'string' } }, diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a4df30f0f1..5b25bc7e5e 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -30,6 +30,8 @@ import { CLIENT_CAPABILITY_MAX_OFFERS, CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, + CLIENT_CAPABILITY_SCHEMA_KEYWORDS, + clientCapabilityEntityId, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, type ClientCapabilityCallFrame, @@ -394,7 +396,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 @@ -658,7 +660,7 @@ function declaredToolInputSchema(tool: MakaTool): Record { }) : cloneDeclaredJsonSchema(tool); delete schema.$schema; - return schema; + return Object.freeze(projectClientCapabilitySchema(schema)); } function cloneDeclaredJsonSchema(tool: MakaTool): Record { @@ -669,19 +671,73 @@ 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); + } + const wrapper = parameters as { validate?: (value: unknown) => PromiseLike<{ success: true; value?: unknown } | { success: false; error: Error }> }; + if (typeof wrapper.validate === 'function') { + const result = await wrapper.validate(args); + if (result.success) return result.value ?? args; + throw result.error ?? new Error('Invalid arguments'); } - // 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; } +interface JsonSchemaWrapper { + readonly jsonSchema?: Record; +} + +function projectClientCapabilitySchema(schema: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; + result[key] = projectClientCapabilitySchemaKeyword(key, value); + } + return result; +} + +function projectClientCapabilitySchemaKeyword(key: string, value: unknown): unknown { + switch (key) { + case 'properties': + case 'patternProperties': + case '$defs': + case 'definitions': { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + result[nestedKey] = projectClientCapabilitySchemaNode(nestedValue); + } + return result; + } + case 'items': + return Array.isArray(value) + ? value.map((entry) => projectClientCapabilitySchemaNode(entry)) + : projectClientCapabilitySchemaNode(value); + case 'allOf': + case 'anyOf': + case 'oneOf': + return Array.isArray(value) ? value.map((entry) => projectClientCapabilitySchemaNode(entry)) : []; + case 'additionalProperties': + case 'propertyNames': + return projectClientCapabilitySchemaNode(value); + default: + return value; + } +} + +function projectClientCapabilitySchemaNode(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((entry) => projectClientCapabilitySchemaNode(entry)); + return projectClientCapabilitySchema(value as Record); +} + function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); 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 95894df3f6..ff10297847 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -411,6 +411,31 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('annotated_values', 'tool'), + tools: [ + { + ...offer('annotated_values', 'tool').tools[0], + inputSchema: { + type: 'object', + properties: { + value: { + type: 'string', + default: 'ready', + enum: ['ready', 'done'], + examples: ['ready'], + }, + }, + }, + }, + ], + }, + ]), + ), + ); assert.throws( () => decodeClientFrame( diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 54aadcca57..1e6797621d 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', From 9f0cbf7f1bd3828cf69903a6a07dfa81d821d376 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 14:18:29 +0800 Subject: [PATCH 03/19] fix: bump runtime host protocol epoch --- packages/runtime-host/src/protocol/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 862a5cc79eea86aa1889410ec32473cf8a612ed0 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:02:09 +0800 Subject: [PATCH 04/19] fix: unify MCP schema projection and add argument validation Move schema projection to the protocol layer as `projectToolInputSchema`, driven by a shared per-keyword shape table that both projection and `validateToolInputSchema` use for recursion. Desktop imports the single authority instead of maintaining a duplicate. Add Ajv-based argument validation for jsonSchema-wrapped MCP tools so that enum/pattern/required constraints are enforced at call time. Also: - Drop empty `items` / `allOf` / `anyOf` / `oneOf` during projection so one malformed MCP schema cannot poison the entire registration. - Reject non-object root schemas with a per-tool error (addresses the root-type asymmetry with Zod path). - Remove non-causal protocol tests; add projection and validation coverage to desktop tests. --- apps/desktop/package.json | 2 + .../runtime-host-native-capabilities.test.ts | 179 ++++++++++++++++-- .../main/runtime-host-native-capabilities.ts | 11 +- package-lock.json | 2 + .../client-capability-protocol.test.ts | 43 +---- .../src/protocol/client-capability.ts | 134 ++++++++++--- 6 files changed, 284 insertions(+), 87 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0c07196fe2..9b81a39454 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,6 +62,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -85,6 +86,7 @@ "@types/react-dom": "^19.2.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.1.1", + "ai": "7.0.70", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.4.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 25e94b82f5..22294a816e 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 @@ -139,8 +139,7 @@ test('publishes the real Computer Use schema through the Client Capability proto ); }); -test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { - const calls: unknown[] = []; +test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -173,10 +172,7 @@ test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { '^x-': { type: 'string' }, }, }), - impl: async (args) => { - calls.push(args); - return 'ok'; - }, + impl: async () => 'ok', }, ], }, @@ -189,30 +185,187 @@ test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { offers: provider.offers(), }), ); - const properties = provider.offers()[0]?.tools[0]?.inputSchema.properties as + const published = provider.offers()[0]?.tools[0]?.inputSchema; + const properties = published?.properties as | Record | undefined; const prefixSchema = properties?.prefix; - assert.equal(provider.offers()[0]?.tools[0]?.inputSchema.$id, undefined); + assert.equal(published?.$id, undefined); assert.equal(prefixSchema?.default, 'ready'); assert.deepEqual(prefixSchema?.enum, ['ready', 'done']); assert.deepEqual(prefixSchema?.examples, ['ready']); - assert.deepEqual( - provider.offers()[0]?.tools[0]?.inputSchema.patternProperties, - { '^x-': { type: 'string' } }, + assert.deepEqual(published?.patternProperties, { '^x-': { type: 'string' } }); +}); + +test('validates jsonSchema-wrapped tool arguments and rejects invalid input', async () => { + const calls: unknown[] = []; + 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: { + prefix: { + type: 'string', + enum: ['ready', 'done'], + }, + }, + required: ['prefix'], + additionalProperties: false, + }), + impl: async (args) => { + calls.push(args); + return 'ok'; + }, + }, + ], + }, + ], + }); + + // Reject enum-violating values. + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'fixture_tool', + arguments: { prefix: 'abc' }, + }), + ), + /Invalid arguments/, ); + assert.equal(calls.length, 0); + // Accept a valid enum value. await call( provider, capabilityFrame({ offerId: 'desktop_mcp', serverId: 'desktop_mcp', toolName: 'fixture_tool', - arguments: { prefix: 'abc', 'x-test': 'value' }, + arguments: { prefix: 'ready' }, }), ); assert.equal(calls.length, 1); - assert.deepEqual(calls[0], { prefix: 'abc', 'x-test': 'value' }); + assert.deepEqual(calls[0], { prefix: 'ready' }); +}); + +test('rejects non-object root jsonSchema at provider construction', () => { + assert.throws( + () => + 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: 'string', + }), + impl: async () => 'ok', + }, + ], + }, + ], + }), + /root must be an object/, + ); +}); + +test('rejects unsupported schema type', () => { + assert.throws( + () => + 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: 42, + impl: async () => 'ok', + }, + ], + }, + ], + }), + /unsupported schema type/, + ); +}); + +test('one bad MCP schema is named and does not block other tools', () => { + // Empty `items` array is invalid at the protocol boundary, but the + // projection drops it, so the schema is published successfully. + 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: { + arr: { type: 'array', items: [] }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); }); test('publishes every production Desktop-owned tool schema through the protocol', () => { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 5b25bc7e5e..1a6b92a576 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -30,10 +30,10 @@ import { CLIENT_CAPABILITY_MAX_OFFERS, CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, - CLIENT_CAPABILITY_SCHEMA_KEYWORDS, clientCapabilityEntityId, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, + projectToolInputSchema, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, type ClientCapabilityContentBlock, @@ -694,15 +694,6 @@ interface JsonSchemaWrapper { readonly jsonSchema?: Record; } -function projectClientCapabilitySchema(schema: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(schema)) { - if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; - result[key] = projectClientCapabilitySchemaKeyword(key, value); - } - return result; -} - function projectClientCapabilitySchemaKeyword(key: string, value: unknown): unknown { switch (key) { case 'properties': diff --git a/package-lock.json b/package-lock.json index 321c4a5390..2f27d0656f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -80,6 +81,7 @@ "@vitejs/plugin-react": "^6.1.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "ai": "7.0.70", "electron": "43.4.1", "electron-builder": "26.15.3", "esbuild": "^0.28.1", 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 ff10297847..64a2470fe3 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -415,19 +415,17 @@ describe('Client Capability protocol', () => { decodeClientFrame( replaceFrame([ { - ...offer('annotated_values', 'tool'), + ...offer('pattern_properties', 'tool'), tools: [ { - ...offer('annotated_values', 'tool').tools[0], + ...offer('pattern_properties', 'tool').tools[0], inputSchema: { type: 'object', properties: { - value: { - type: 'string', - default: 'ready', - enum: ['ready', 'done'], - examples: ['ready'], - }, + prefix: { type: 'string', pattern: '^[a-z]+$' }, + }, + patternProperties: { + '^x-': { type: 'string' }, }, }, }, @@ -436,35 +434,6 @@ describe('Client Capability protocol', () => { ]), ), ); - assert.throws( - () => - decodeClientFrame( - replaceFrame([ - { - ...offer('annotated_schema', 'tool'), - tools: [ - { - ...offer('annotated_schema', 'tool').tools[0], - inputSchema: { - $id: 'https://example.com/tool.schema.json', - type: 'object', - properties: { - prefix: { - type: 'string', - pattern: '^[a-z]+$', - }, - }, - patternProperties: { - '^x-': { type: 'string' }, - }, - }, - }, - ], - }, - ]), - ), - (error: unknown) => error instanceof RuntimeHostProtocolError, - ); 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 1e6797621d..c29418952a 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -847,6 +847,84 @@ export const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'uniqueItems', ]); +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)) return {}; + const result: Record = {}; + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + result[nestedKey] = projectSchemaNode(nestedValue); + } + return result; + } + 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); + } + } +} + function validateToolInputSchema(root: Record): void { if (!Object.hasOwn(root, 'type') || root.type !== 'object') { throw invalidProtocolFrame('Client Capability tool schema root must be an object'); @@ -906,11 +984,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', 'patternProperties', '$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) || @@ -920,30 +993,37 @@ 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}`); + 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'); From 24192f71b1e6c2b708f6173b17d1cb1a802a4966 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 3 Sep 2026 17:13:08 +0800 Subject: [PATCH 05/19] fix: harden MCP jsonSchema tool projection follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review follow-ups on the MCP jsonSchema tool support: - Reject invalid `patternProperties` regex keys at the protocol boundary (`validateToolInputSchema`), mirroring the existing `pattern` check, so a malformed key from an untrusted MCP server is refused at decode instead of crashing `Ajv.compile` with a raw SyntaxError on every tool invocation. - Guard `schemaValidator.compile` with try/catch and surface a clean error. - Drop the undeclared `@ai-sdk/provider-utils` production import; validate Zod schemas with their native `parseAsync` (simpler, no hoisting dependency). - Fold projection into `compileJsonSchema` so it runs only on a cache miss (was recomputed on every call); remove the now-unreachable guard and the dead `!validator` branch. - Remove the dead Zod `.issues` branch in `schemaErrorSummary` (only Ajv error arrays reach it now). - Rename the misnamed "one bad MCP schema is named…" test to describe what it actually checks, and add negative coverage for the patternProperties regex rejection and empty allOf/anyOf/oneOf projection drop. Verified: `@maka/runtime-host` build + protocol suite (5/5) and `@maka/desktop` build:test + native-capabilities suite (21/21) pass. Co-Authored-By: Claude Opus 4.8 --- .../runtime-host-native-capabilities.test.ts | 89 ++++++++++++++++++- .../src/protocol/client-capability.ts | 11 +++ 2 files changed, 99 insertions(+), 1 deletion(-) 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 22294a816e..b08be994dd 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 @@ -328,7 +328,7 @@ test('rejects unsupported schema type', () => { ); }); -test('one bad MCP schema is named and does not block other tools', () => { +test('empty items array is projected away so the schema still publishes', () => { // Empty `items` array is invalid at the protocol boundary, but the // projection drops it, so the schema is published successfully. const provider = createDesktopNativeCapabilityProvider({ @@ -368,6 +368,93 @@ test('one bad MCP schema is named and does not block other tools', () => { ); }); +test('rejects an invalid patternProperties regex key at the protocol boundary', () => { + // An unparseable regex key survives projection (keys are copied verbatim) + // but must be rejected at decode so it never reaches Ajv.compile at call time. + 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', + patternProperties: { + '(': { type: 'string' }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.throws( + () => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + /patternProperties/, + ); +}); + +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.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/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index c29418952a..c193b106ca 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -998,6 +998,17 @@ function validateToolInputSchema(root: Record): void { switch (shape) { case 'record': { const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); + if (key === 'patternProperties') { + for (const patternKey of Object.keys(entries)) { + try { + new RegExp(patternKey); + } catch { + throw invalidProtocolFrame( + 'Invalid Client Capability tool schema patternProperties', + ); + } + } + } for (const nested of Object.values(entries)) visit(nested); break; } From ddb611378b73374f82f2b5abb7504d553dcef4f4 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 18:52:10 +0800 Subject: [PATCH 06/19] fix: isolate bad MCP tools, skip regex in local validation, adapt tuple items --- .../runtime-host-native-capabilities.test.ts | 330 ++++++++++++++---- .../client-capability-protocol.test.ts | 21 ++ .../src/protocol/client-capability.ts | 27 +- 3 files changed, 297 insertions(+), 81 deletions(-) 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 b08be994dd..51ca045836 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 @@ -266,71 +266,222 @@ test('validates jsonSchema-wrapped tool arguments and rejects invalid input', as assert.deepEqual(calls[0], { prefix: 'ready' }); }); -test('rejects non-object root jsonSchema at provider construction', () => { - assert.throws( - () => - createDesktopNativeCapabilityProvider({ - browserTools: [], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: computerTools(), - releaseComputerUseSession() {}, - additionalGroups: () => [ +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: [ { - offerId: 'desktop_mcp', - label: 'MCP', - description: 'MCP tools', - tools: [ - { - name: 'fixture_tool', - displayName: 'fixture_tool', - description: 'fixture_tool description', - parameters: jsonSchema({ - type: 'string', - }), - impl: async () => 'ok', - }, - ], + 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 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'; }), - /root must be an object/, + ], + 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('rejects unsupported schema type', () => { - assert.throws( - () => - createDesktopNativeCapabilityProvider({ - browserTools: [], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: computerTools(), - releaseComputerUseSession() {}, - additionalGroups: () => [ +test('does not enforce regex constraints locally (MCP endpoint re-validates)', async () => { + const calls: unknown[] = []; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ { - offerId: 'desktop_mcp', - label: 'MCP', - description: 'MCP tools', - tools: [ - { - name: 'fixture_tool', - displayName: 'fixture_tool', - description: 'fixture_tool description', - parameters: 42, - impl: async () => 'ok', + name: 'prefix_tool', + displayName: 'prefix_tool', + description: 'prefix_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + prefix: { type: 'string', pattern: '^[a-z]+$' }, }, - ], + }), + impl: async (args) => { + calls.push(args); + return 'ok'; + }, }, ], - }), - /unsupported schema type/, + }, + ], + }); + + // Pattern-violating value is accepted locally; the regex is enforced by + // the MCP endpoint (guards against ReDoS in the Electron main process). + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'prefix_tool', + arguments: { prefix: '123' }, + }), ); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { prefix: '123' }); }); -test('empty items array is projected away so the schema still publishes', () => { - // Empty `items` array is invalid at the protocol boundary, but the - // projection drops it, so the schema is published successfully. +test('validates tuple items against Ajv 2020 semantics', async () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -344,13 +495,16 @@ test('empty items array is projected away so the schema still publishes', () => description: 'MCP tools', tools: [ { - name: 'fixture_tool', - displayName: 'fixture_tool', - description: 'fixture_tool description', + name: 'tuple_tool', + displayName: 'tuple_tool', + description: 'tuple_tool description', parameters: jsonSchema({ type: 'object', properties: { - arr: { type: 'array', items: [] }, + coordinate: { + type: 'array', + items: [{ type: 'integer' }, { type: 'integer' }], + }, }, }), impl: async () => 'ok', @@ -360,17 +514,36 @@ test('empty items array is projected away so the schema still publishes', () => ], }); - assert.doesNotThrow(() => - decodeClientCapabilityReplaceInput({ - registrationId: 'registration-1', - offers: provider.offers(), + // A valid draft-07 tuple compiles as prefixItems under Ajv 2020 and passes. + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'tuple_tool', + arguments: { coordinate: [1, 2] }, }), ); + // A tuple violation is still rejected. + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'tuple_tool', + arguments: { coordinate: [1, 'x'] }, + }), + ), + /Invalid arguments/, + ); }); -test('rejects an invalid patternProperties regex key at the protocol boundary', () => { - // An unparseable regex key survives projection (keys are copied verbatim) - // but must be rejected at decode so it never reaches Ajv.compile at call time. +test('an invalid patternProperties regex key is isolated at the provider boundary', () => { + // An unparseable regex key is rejected by the per-tool validation when the + // provider is built, so the offending tool is skipped instead of reaching + // Ajv.compile or the protocol decode. const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -384,15 +557,25 @@ test('rejects an invalid patternProperties regex key at the protocol boundary', description: 'MCP tools', tools: [ { - name: 'fixture_tool', - displayName: 'fixture_tool', - description: 'fixture_tool description', + 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', }, ], @@ -400,13 +583,16 @@ test('rejects an invalid patternProperties regex key at the protocol boundary', ], }); - assert.throws( - () => - decodeClientCapabilityReplaceInput({ - registrationId: 'registration-1', - offers: provider.offers(), - }), - /patternProperties/, + 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(), + }), ); }); 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 64a2470fe3..611bb396ee 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -434,6 +434,27 @@ describe('Client Capability protocol', () => { ]), ), ); + 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, + ); 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 c193b106ca..8dd765d8df 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -925,7 +925,7 @@ function projectSchemaKeyword(key: string, value: unknown): unknown { } } -function validateToolInputSchema(root: Record): void { +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'); } @@ -998,15 +998,9 @@ function validateToolInputSchema(root: Record): void { switch (shape) { case 'record': { const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); - if (key === 'patternProperties') { +if (key === 'patternProperties') { for (const patternKey of Object.keys(entries)) { - try { - new RegExp(patternKey); - } catch { - throw invalidProtocolFrame( - 'Invalid Client Capability tool schema patternProperties', - ); - } + validateSchemaPattern(patternKey); } } for (const nested of Object.values(entries)) visit(nested); @@ -1060,6 +1054,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 ( From 56359a658a0d61494ebc51abf03c457e7d5311ea Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 19:55:54 +0800 Subject: [PATCH 07/19] fix: restore indentation of patternProperties key validation --- packages/runtime-host/src/protocol/client-capability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 8dd765d8df..7b5a82b974 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -998,7 +998,7 @@ export function validateToolInputSchema(root: Record): void { switch (shape) { case 'record': { const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); -if (key === 'patternProperties') { + if (key === 'patternProperties') { for (const patternKey of Object.keys(entries)) { validateSchemaPattern(patternKey); } From 3881819c332ca6f2a37d783ae3b99d97a00d3dda Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 20:20:24 +0800 Subject: [PATCH 08/19] fix: update candidate test for per-tool schema isolation --- .../main/__tests__/runtime-host-desktop-candidate.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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..4dcca2a155 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,7 +547,9 @@ 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('does not drop the Host connection when a native tool schema is invalid', 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 = { @@ -567,12 +569,8 @@ test('closes the claimed Host connection when native capability construction fai 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/, ); - - assert.equal(ipc.size, 0); assert.equal(host.closeCalls, 1); }); From 6c91e7a1fb84bdb17e4d2f94b52fb297d2ced0cb Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 08:26:04 +0800 Subject: [PATCH 09/19] fix: support MCP JSON Schema tools in Desktop --- apps/desktop/package.json | 1 - .../runtime-host-native-capabilities.test.ts | 49 +++-- package-lock.json | 2 - .../runtime/src/__tests__/mcp-tools.test.ts | 15 ++ packages/runtime/src/mcp-tools.ts | 204 +++++++++--------- 5 files changed, 142 insertions(+), 129 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9b81a39454..31cbccc8cc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,7 +62,6 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", - "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", 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 51ca045836..580589620a 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 @@ -19,9 +19,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { jsonSchema } from 'ai'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; +import { validateJsonSchemaInput } from '@maka/runtime/ai-sdk-backend'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { @@ -36,6 +36,16 @@ 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; + validate: (value: unknown) => ReturnType; +} { + return { + jsonSchema: schema, + validate: async (value) => validateJsonSchemaInput(schema, value), + }; +} + 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')], @@ -248,7 +258,7 @@ test('validates jsonSchema-wrapped tool arguments and rejects invalid input', as arguments: { prefix: 'abc' }, }), ), - /Invalid arguments/, + /prefix must be equal to one of the allowed values/, ); assert.equal(calls.length, 0); @@ -432,7 +442,7 @@ test('skips a malformed MCP tool without dropping the other offers', async () => assert.equal(healthyCalls, 1); }); -test('does not enforce regex constraints locally (MCP endpoint re-validates)', async () => { +test('enforces regex constraints through the Runtime JSON Schema validator', async () => { const calls: unknown[] = []; const provider = createDesktopNativeCapabilityProvider({ browserTools: [], @@ -466,22 +476,23 @@ test('does not enforce regex constraints locally (MCP endpoint re-validates)', a ], }); - // Pattern-violating value is accepted locally; the regex is enforced by - // the MCP endpoint (guards against ReDoS in the Electron main process). - await call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'prefix_tool', - arguments: { prefix: '123' }, - }), + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'prefix_tool', + arguments: { prefix: '123' }, + }), + ), + /prefix must match pattern/, ); - assert.equal(calls.length, 1); - assert.deepEqual(calls[0], { prefix: '123' }); + assert.equal(calls.length, 0); }); -test('validates tuple items against Ajv 2020 semantics', async () => { +test('validates tuple items through the Runtime JSON Schema validator', async () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -514,14 +525,14 @@ test('validates tuple items against Ajv 2020 semantics', async () => { ], }); - // A valid draft-07 tuple compiles as prefixItems under Ajv 2020 and passes. + // Draft-07 tuples allow trailing items unless additionalItems is false. await call( provider, capabilityFrame({ offerId: 'desktop_mcp', serverId: 'desktop_mcp', toolName: 'tuple_tool', - arguments: { coordinate: [1, 2] }, + arguments: { coordinate: [1, 2, 3] }, }), ); // A tuple violation is still rejected. @@ -536,7 +547,7 @@ test('validates tuple items against Ajv 2020 semantics', async () => { arguments: { coordinate: [1, 'x'] }, }), ), - /Invalid arguments/, + /coordinate\/1 must be integer/, ); }); diff --git a/package-lock.json b/package-lock.json index 2f27d0656f..321c4a5390 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,6 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", - "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -81,7 +80,6 @@ "@vitejs/plugin-react": "^6.1.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "ai": "7.0.70", "electron": "43.4.1", "electron-builder": "26.15.3", "esbuild": "^0.28.1", diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index f4d8657420..696358b2bb 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 and invokes the Runtime JSON Schema validator wrapper', 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/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 0c343f0d40..cb9d145adb 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 { validateJsonSchemaInput } 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; @@ -94,30 +99,14 @@ 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 }) => { + const inputSchema = normalizeMcpInputSchema(descriptor.inputSchema); const identity = `${descriptor.serverId}\0${descriptor.name}`; const name = mcpProxyToolName(descriptor.serverId, descriptor.name); const collision = names.get(name); @@ -126,97 +115,98 @@ export function buildMcpToolsWithIdentities( } names.set(name, identity); return { - 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), - } - : {}), - }); + 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(inputSchema, { + validate: async (value) => { + const result = validateJsonSchemaInput(inputSchema, value); + return result; }, - toModelOutput: ({ output }) => mcpResultToModelOutput(output), - } satisfies MakaTool, - }; + }), + ...(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; }); } From c39bc0f2592e71d597096dbb5534531ab7abb4c0 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 11:55:01 +0800 Subject: [PATCH 10/19] fix: complete MCP JSON Schema capability support --- .../main/runtime-host-native-capabilities.ts | 20 +- packages/runtime/src/mcp-tools.ts | 199 ++++++++++-------- 2 files changed, 124 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 1a6b92a576..d8f92ceca4 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -30,7 +30,7 @@ import { CLIENT_CAPABILITY_MAX_OFFERS, CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, - clientCapabilityEntityId, + CLIENT_CAPABILITY_SCHEMA_KEYWORDS, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, projectToolInputSchema, @@ -504,16 +504,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)}`, ); @@ -694,6 +695,16 @@ interface JsonSchemaWrapper { readonly jsonSchema?: Record; } +function projectClientCapabilitySchema(schema: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; + const projected = projectClientCapabilitySchemaKeyword(key, value); + if (projected !== undefined) result[key] = projected; + } + return result; +} + function projectClientCapabilitySchemaKeyword(key: string, value: unknown): unknown { switch (key) { case 'properties': @@ -714,7 +725,8 @@ function projectClientCapabilitySchemaKeyword(key: string, value: unknown): unkn case 'allOf': case 'anyOf': case 'oneOf': - return Array.isArray(value) ? value.map((entry) => projectClientCapabilitySchemaNode(entry)) : []; + if (!Array.isArray(value) || value.length === 0) return undefined; + return value.map((entry) => projectClientCapabilitySchemaNode(entry)); case 'additionalProperties': case 'propertyNames': return projectClientCapabilitySchemaNode(value); diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index cb9d145adb..b5485a2773 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -99,10 +99,23 @@ 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); +} + +export function buildMcpToolsWithIdentities( + provider: McpToolProvider, + options: BuildMcpToolsOptions = {}, +): McpIdentifiedTool[] { const names = new Map(); const snapshot = provider.toolSnapshot(); return snapshot.tools.map(({ descriptor, binding }) => { @@ -115,98 +128,102 @@ 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(inputSchema, { - validate: async (value) => { - const result = validateJsonSchemaInput(inputSchema, value); - return result; - }, - }), - ...(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, + 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(inputSchema, { + validate: async (value) => { + const result = validateJsonSchemaInput(inputSchema, value); + return result; }, - ...(context.emitProgress ? { emitProgress: context.emitProgress } : {}), - ...(context.requestUserForm - ? { - requestInteraction: ( - form: InteractionFormInput, - interactionOptions?: { readonly cancellationSignal?: AbortSignal }, - ) => context.requestUserForm!(form, interactionOptions), - } - : {}), - }); - }, - toModelOutput: ({ output }) => mcpResultToModelOutput(output), - } satisfies MakaTool; + }), + ...(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, + }; }); } From 8ebd44f0e0ea2e43c4ac71782abde7e55cfd8786 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 15:59:13 +0800 Subject: [PATCH 11/19] fix: make MCP CI checks deterministic --- .../main/__tests__/mcp-runtime-e2e.test.ts | 13 +-- .../client-capability-protocol.test.ts | 29 +++++++ scripts/pre-push-check.ps1 | 85 +++++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 scripts/pre-push-check.ps1 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..73ec9e8b3f 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', @@ -96,7 +99,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/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 611bb396ee..1affbcff4e 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -411,6 +411,35 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('annotated_schema', 'tool'), + tools: [ + { + ...offer('annotated_schema', 'tool').tools[0], + inputSchema: { + $id: 'https://example.com/tool.schema.json', + type: 'object', + properties: { + prefix: { + type: 'string', + pattern: '^[a-z]+$', + }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); assert.doesNotThrow(() => decodeClientFrame( replaceFrame([ diff --git a/scripts/pre-push-check.ps1 b/scripts/pre-push-check.ps1 new file mode 100644 index 0000000000..092b4f249a --- /dev/null +++ b/scripts/pre-push-check.ps1 @@ -0,0 +1,85 @@ +#!/usr/bin/env pwsh +<## +.SYNOPSIS + Pre-push checks for Windows. The commands mirror the CI checks that are + executable on Windows and include the PR's relevant dist test suites. +#> + +$ErrorActionPreference = "Continue" +$repoRoot = Split-Path -Parent $PSScriptRoot +$failed = $false + +function Run-Check { + param( + [string]$Label, + [scriptblock]$Command + ) + + Write-Host (">>> " + $Label + " ...") -ForegroundColor Cyan + & $Command + if ($LASTEXITCODE -ne 0) { + Write-Host ("FAIL: " + $Label) -ForegroundColor Red + $script:failed = $true + } else { + Write-Host ("OK: " + $Label) -ForegroundColor Green + } +} + +Push-Location $repoRoot +try { + Write-Host ">>> Fetch upstream/main ..." -ForegroundColor Cyan + git fetch upstream main + if ($LASTEXITCODE -ne 0) { + throw "Cannot fetch upstream/main" + } + + $behind = [int](git rev-list --count HEAD..upstream/main) + if ($behind -gt 0) { + Write-Host ("FAIL: branch is behind upstream/main by " + $behind + " commit(s); rebase first") -ForegroundColor Red + exit 1 + } + Write-Host "OK: branch is not behind upstream/main" -ForegroundColor Green + + Run-Check "Protocol epoch guard" { + node scripts/protocol-epoch-check.mjs --base upstream/main + } + Run-Check "Build test artifacts" { + npm run build:test + } + Run-Check "Lint" { + npm run lint + } + Run-Check "Format check" { + npm run format:check + } + Run-Check "Typecheck" { + npm run typecheck + } + Run-Check "Desktop MCP and capability dist tests" { + node --test "apps/desktop/dist/main/__tests__/mcp-runtime-e2e.test.js" "apps/desktop/dist/main/__tests__/runtime-host-native-capabilities.test.js" "apps/desktop/dist/main/__tests__/runtime-host-desktop-candidate.test.js" + } + Run-Check "Runtime MCP dist tests" { + node --test "packages/runtime/dist/__tests__/mcp-tools.test.js" + } + Run-Check "Runtime Host Client Capability protocol dist tests" { + node --test "packages/runtime-host/dist/__tests__/client-capability-protocol.test.js" + } + Run-Check "Diff check" { + git diff --check + } + Run-Check "Unresolved conflict check" { + $conflicts = git diff --name-only --diff-filter=U + if ($conflicts) { + $conflicts + exit 1 + } + } + + if ($failed) { + Write-Host "PRE-PUSH CHECKS FAILED" -ForegroundColor Red + exit 1 + } + Write-Host "ALL PRE-PUSH CHECKS PASSED" -ForegroundColor Green +} finally { + Pop-Location +} From 4b7d1e6294ecadf0c1ecf6dcd9b26e65530dc61f Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 18:06:32 +0800 Subject: [PATCH 12/19] fix: remove unsafe local MCP schema validation --- .../main/__tests__/mcp-runtime-e2e.test.ts | 16 ++ .../runtime-host-desktop-candidate.test.ts | 52 +++-- .../runtime-host-native-capabilities.test.ts | 190 +----------------- .../main/runtime-host-native-capabilities.ts | 63 +----- .../client-capability-protocol.test.ts | 33 +-- .../runtime/src/__tests__/mcp-tools.test.ts | 15 -- packages/runtime/src/mcp-tools.ts | 10 +- scripts/pre-push-check.ps1 | 85 -------- 8 files changed, 68 insertions(+), 396 deletions(-) delete mode 100644 scripts/pre-push-check.ps1 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 73ec9e8b3f..976981f2e4 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -90,6 +90,22 @@ 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' } }, + }, + }); if (!provider.call) throw new Error('Expected a callable Desktop capability provider'); assert.throws( () => provider.call!( 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 4dcca2a155..8df66cb24e 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,7 +547,7 @@ test('rolls back only candidate-owned IPC after a registration collision', async assert.equal(host.closeCalls, 1); }); -test('does not drop the Host connection when a native tool schema is invalid', 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(); @@ -556,21 +556,45 @@ test('does not drop the Host connection when a native tool schema is invalid', a ...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() {}, - }), - ), - /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], + dynamic: true, + }, + ], + }), ); + + 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 580589620a..37cbf8f7ad 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 @@ -21,7 +21,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; -import { validateJsonSchemaInput } from '@maka/runtime/ai-sdk-backend'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { @@ -38,12 +37,8 @@ import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-ca function jsonSchema(schema: Record): { jsonSchema: Record; - validate: (value: unknown) => ReturnType; } { - return { - jsonSchema: schema, - validate: async (value) => validateJsonSchemaInput(schema, value), - }; + return { jsonSchema: schema }; } test('publishes self-described session-affine Browser and Computer Use offers', () => { @@ -207,75 +202,6 @@ test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () assert.deepEqual(published?.patternProperties, { '^x-': { type: 'string' } }); }); -test('validates jsonSchema-wrapped tool arguments and rejects invalid input', async () => { - const calls: unknown[] = []; - 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: { - prefix: { - type: 'string', - enum: ['ready', 'done'], - }, - }, - required: ['prefix'], - additionalProperties: false, - }), - impl: async (args) => { - calls.push(args); - return 'ok'; - }, - }, - ], - }, - ], - }); - - // Reject enum-violating values. - await assert.rejects( - () => - call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'fixture_tool', - arguments: { prefix: 'abc' }, - }), - ), - /prefix must be equal to one of the allowed values/, - ); - assert.equal(calls.length, 0); - - // Accept a valid enum value. - await call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'fixture_tool', - arguments: { prefix: 'ready' }, - }), - ); - assert.equal(calls.length, 1); - assert.deepEqual(calls[0], { prefix: 'ready' }); -}); - test('skips non-object root jsonSchema tools without dropping the offer', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], @@ -442,119 +368,9 @@ test('skips a malformed MCP tool without dropping the other offers', async () => assert.equal(healthyCalls, 1); }); -test('enforces regex constraints through the Runtime JSON Schema validator', async () => { - const calls: unknown[] = []; - const provider = createDesktopNativeCapabilityProvider({ - browserTools: [], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: computerTools(), - releaseComputerUseSession() {}, - additionalGroups: () => [ - { - offerId: 'desktop_mcp', - label: 'MCP', - description: 'MCP tools', - tools: [ - { - name: 'prefix_tool', - displayName: 'prefix_tool', - description: 'prefix_tool description', - parameters: jsonSchema({ - type: 'object', - properties: { - prefix: { type: 'string', pattern: '^[a-z]+$' }, - }, - }), - impl: async (args) => { - calls.push(args); - return 'ok'; - }, - }, - ], - }, - ], - }); - - await assert.rejects( - () => - call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'prefix_tool', - arguments: { prefix: '123' }, - }), - ), - /prefix must match pattern/, - ); - assert.equal(calls.length, 0); -}); - -test('validates tuple items through the Runtime JSON Schema validator', async () => { - const provider = createDesktopNativeCapabilityProvider({ - browserTools: [], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: computerTools(), - releaseComputerUseSession() {}, - additionalGroups: () => [ - { - offerId: 'desktop_mcp', - label: 'MCP', - description: 'MCP tools', - tools: [ - { - name: 'tuple_tool', - displayName: 'tuple_tool', - description: 'tuple_tool description', - parameters: jsonSchema({ - type: 'object', - properties: { - coordinate: { - type: 'array', - items: [{ type: 'integer' }, { type: 'integer' }], - }, - }, - }), - impl: async () => 'ok', - }, - ], - }, - ], - }); - - // Draft-07 tuples allow trailing items unless additionalItems is false. - await call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'tuple_tool', - arguments: { coordinate: [1, 2, 3] }, - }), - ); - // A tuple violation is still rejected. - await assert.rejects( - () => - call( - provider, - capabilityFrame({ - offerId: 'desktop_mcp', - serverId: 'desktop_mcp', - toolName: 'tuple_tool', - arguments: { coordinate: [1, 'x'] }, - }), - ), - /coordinate\/1 must be integer/, - ); -}); - test('an invalid patternProperties regex key is isolated at the provider boundary', () => { - // An unparseable regex key is rejected by the per-tool validation when the - // provider is built, so the offending tool is skipped instead of reaching - // Ajv.compile or the protocol decode. + // 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/', diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index d8f92ceca4..2871a7ab42 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -30,7 +30,6 @@ import { CLIENT_CAPABILITY_MAX_OFFERS, CLIENT_CAPABILITY_MAX_TOOLS, CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER, - CLIENT_CAPABILITY_SCHEMA_KEYWORDS, decodeClientCapabilityReplaceInput, decodeClientCapabilityToolDescriptor, projectToolInputSchema, @@ -661,7 +660,7 @@ function declaredToolInputSchema(tool: MakaTool): Record { }) : cloneDeclaredJsonSchema(tool); delete schema.$schema; - return Object.freeze(projectClientCapabilitySchema(schema)); + return Object.freeze(projectToolInputSchema(schema)); } function cloneDeclaredJsonSchema(tool: MakaTool): Record { @@ -682,65 +681,13 @@ async function parseNativeToolArguments(parameters: unknown, args: unknown): Pro if (parameters instanceof z.ZodType) { return parameters.parseAsync(args); } - const wrapper = parameters as { validate?: (value: unknown) => PromiseLike<{ success: true; value?: unknown } | { success: false; error: Error }> }; - if (typeof wrapper.validate === 'function') { - const result = await wrapper.validate(args); - if (result.success) return result.value ?? args; - throw result.error ?? new Error('Invalid arguments'); - } + // 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; } -interface JsonSchemaWrapper { - readonly jsonSchema?: Record; -} - -function projectClientCapabilitySchema(schema: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(schema)) { - if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; - const projected = projectClientCapabilitySchemaKeyword(key, value); - if (projected !== undefined) result[key] = projected; - } - return result; -} - -function projectClientCapabilitySchemaKeyword(key: string, value: unknown): unknown { - switch (key) { - case 'properties': - case 'patternProperties': - case '$defs': - case 'definitions': { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; - const result: Record = {}; - for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { - result[nestedKey] = projectClientCapabilitySchemaNode(nestedValue); - } - return result; - } - case 'items': - return Array.isArray(value) - ? value.map((entry) => projectClientCapabilitySchemaNode(entry)) - : projectClientCapabilitySchemaNode(value); - case 'allOf': - case 'anyOf': - case 'oneOf': - if (!Array.isArray(value) || value.length === 0) return undefined; - return value.map((entry) => projectClientCapabilitySchemaNode(entry)); - case 'additionalProperties': - case 'propertyNames': - return projectClientCapabilitySchemaNode(value); - default: - return value; - } -} - -function projectClientCapabilitySchemaNode(value: unknown): unknown { - if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map((entry) => projectClientCapabilitySchemaNode(entry)); - return projectClientCapabilitySchema(value as Record); -} - function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); 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 1affbcff4e..f9a5a55381 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -411,35 +411,6 @@ describe('Client Capability protocol', () => { ]), ), ); - assert.throws( - () => - decodeClientFrame( - replaceFrame([ - { - ...offer('annotated_schema', 'tool'), - tools: [ - { - ...offer('annotated_schema', 'tool').tools[0], - inputSchema: { - $id: 'https://example.com/tool.schema.json', - type: 'object', - properties: { - prefix: { - type: 'string', - pattern: '^[a-z]+$', - }, - }, - patternProperties: { - '^x-': { type: 'string' }, - }, - }, - }, - ], - }, - ]), - ), - (error: unknown) => error instanceof RuntimeHostProtocolError, - ); assert.doesNotThrow(() => decodeClientFrame( replaceFrame([ @@ -482,7 +453,9 @@ describe('Client Capability protocol', () => { }, ]), ), - (error: unknown) => error instanceof RuntimeHostProtocolError, + (error: unknown) => + error instanceof RuntimeHostProtocolError && + /patternProperties key is not a valid pattern/u.test(error.message), ); assert.doesNotThrow(() => decodeClientFrame( diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 696358b2bb..f4d8657420 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -102,21 +102,6 @@ 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 and invokes the Runtime JSON Schema validator wrapper', 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/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index b5485a2773..0ee04a2b2f 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -19,7 +19,6 @@ import { createHash } from 'node:crypto'; import { jsonSchema } from 'ai'; -import { validateJsonSchemaInput } from './ai-sdk-backend.js'; import type { ToolActivityKind } from '@maka/core/events'; import type { McpCallResult, @@ -143,12 +142,9 @@ export function buildMcpToolsWithIdentities( categoryHint: options.categoryHint ?? 'network_send', ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - parameters: jsonSchema(inputSchema, { - validate: async (value) => { - const result = validateJsonSchemaInput(inputSchema, value); - return result; - }, - }), + // The MCP server owns the complete JSON Schema. Keeping the wrapper + // schema-only avoids duplicating validation in the Runtime main thread. + parameters: jsonSchema(inputSchema), ...(provider.prepareTool ? { prepareExecution: async (args: unknown, context) => { diff --git a/scripts/pre-push-check.ps1 b/scripts/pre-push-check.ps1 deleted file mode 100644 index 092b4f249a..0000000000 --- a/scripts/pre-push-check.ps1 +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env pwsh -<## -.SYNOPSIS - Pre-push checks for Windows. The commands mirror the CI checks that are - executable on Windows and include the PR's relevant dist test suites. -#> - -$ErrorActionPreference = "Continue" -$repoRoot = Split-Path -Parent $PSScriptRoot -$failed = $false - -function Run-Check { - param( - [string]$Label, - [scriptblock]$Command - ) - - Write-Host (">>> " + $Label + " ...") -ForegroundColor Cyan - & $Command - if ($LASTEXITCODE -ne 0) { - Write-Host ("FAIL: " + $Label) -ForegroundColor Red - $script:failed = $true - } else { - Write-Host ("OK: " + $Label) -ForegroundColor Green - } -} - -Push-Location $repoRoot -try { - Write-Host ">>> Fetch upstream/main ..." -ForegroundColor Cyan - git fetch upstream main - if ($LASTEXITCODE -ne 0) { - throw "Cannot fetch upstream/main" - } - - $behind = [int](git rev-list --count HEAD..upstream/main) - if ($behind -gt 0) { - Write-Host ("FAIL: branch is behind upstream/main by " + $behind + " commit(s); rebase first") -ForegroundColor Red - exit 1 - } - Write-Host "OK: branch is not behind upstream/main" -ForegroundColor Green - - Run-Check "Protocol epoch guard" { - node scripts/protocol-epoch-check.mjs --base upstream/main - } - Run-Check "Build test artifacts" { - npm run build:test - } - Run-Check "Lint" { - npm run lint - } - Run-Check "Format check" { - npm run format:check - } - Run-Check "Typecheck" { - npm run typecheck - } - Run-Check "Desktop MCP and capability dist tests" { - node --test "apps/desktop/dist/main/__tests__/mcp-runtime-e2e.test.js" "apps/desktop/dist/main/__tests__/runtime-host-native-capabilities.test.js" "apps/desktop/dist/main/__tests__/runtime-host-desktop-candidate.test.js" - } - Run-Check "Runtime MCP dist tests" { - node --test "packages/runtime/dist/__tests__/mcp-tools.test.js" - } - Run-Check "Runtime Host Client Capability protocol dist tests" { - node --test "packages/runtime-host/dist/__tests__/client-capability-protocol.test.js" - } - Run-Check "Diff check" { - git diff --check - } - Run-Check "Unresolved conflict check" { - $conflicts = git diff --name-only --diff-filter=U - if ($conflicts) { - $conflicts - exit 1 - } - } - - if ($failed) { - Write-Host "PRE-PUSH CHECKS FAILED" -ForegroundColor Red - exit 1 - } - Write-Host "ALL PRE-PUSH CHECKS PASSED" -ForegroundColor Green -} finally { - Pop-Location -} From 6655126a29fe2807ab4805425122f1762bf2047e Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 4 Sep 2026 22:22:19 +0800 Subject: [PATCH 13/19] test(desktop): expect forwarded additionalItems schema --- apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts | 1 + 1 file changed, 1 insertion(+) 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 976981f2e4..654d89aecc 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -104,6 +104,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a 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'); From d7f8e40ed27b96741fa32a66f4fe652363b7510b Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 22:51:53 +0800 Subject: [PATCH 14/19] fix: harden MCP schema projection tests --- .../runtime-host-desktop-candidate.test.ts | 1 - .../runtime-host-native-capabilities.test.ts | 80 +++++++++++++++++++ .../src/protocol/client-capability.ts | 13 +-- 3 files changed, 88 insertions(+), 6 deletions(-) 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 8df66cb24e..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 @@ -576,7 +576,6 @@ test('isolates an invalid dynamic MCP tool without dropping the Host connection' label: 'MCP', description: 'MCP tools', tools: [invalidTool, healthyTool], - dynamic: true, }, ], }), diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 37cbf8f7ad..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 @@ -246,6 +246,85 @@ test('skips non-object root jsonSchema tools without dropping the offer', () => ); }); +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: [], @@ -463,6 +542,7 @@ test('empty allOf/anyOf/oneOf are projected away so the schema still publishes', | { 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); diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 7b5a82b974..d70341ffcc 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -901,12 +901,15 @@ function projectSchemaKeyword(key: string, value: unknown): unknown { if (shape === undefined) return value; switch (shape) { case 'record': { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; - const result: Record = {}; - for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { - result[nestedKey] = projectSchemaNode(nestedValue); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Client Capability tool schema ${key} must be an object`); } - return result; + 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; From 93a7bed4d808e02113c5282208ee61de3db4b258 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 23:46:31 +0800 Subject: [PATCH 15/19] fix: restore safe MCP argument preflight --- apps/desktop/package.json | 1 - packages/runtime/src/__tests__/mcp-tools.test.ts | 15 +++++++++++++++ packages/runtime/src/mcp-tools.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 31cbccc8cc..0c07196fe2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -85,7 +85,6 @@ "@types/react-dom": "^19.2.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.1.1", - "ai": "7.0.70", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.4.1", 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/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 0ee04a2b2f..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, @@ -142,9 +143,11 @@ export function buildMcpToolsWithIdentities( categoryHint: options.categoryHint ?? 'network_send', ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - // The MCP server owns the complete JSON Schema. Keeping the wrapper - // schema-only avoids duplicating validation in the Runtime main thread. - parameters: jsonSchema(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) => { From a85e141dcb719423e66124fa0a73a7b1fd0ab25f Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 5 Sep 2026 11:47:14 +0800 Subject: [PATCH 16/19] fix: move MCP preflight behind runtime boundary --- .../runtime/src/__tests__/mcp-tools.test.ts | 26 ++++ packages/runtime/src/mcp-schema-preflight.ts | 118 ++++++++++++++++++ packages/runtime/src/mcp-tools.ts | 2 +- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/mcp-schema-preflight.ts diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index a03687eba5..9d0ebcda0e 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -117,6 +117,32 @@ test('buildMcpTools installs bounded MCP schema preflight validation', async () assert.equal((await parameters.validate?.({ value: 42 }))?.success, false); }); +test('MCP schema preflight skips server regexes without dropping structural validation', async () => { + const toolDescriptor: McpToolDescriptor = { + ...descriptor('server', 'regex'), + inputSchema: { + type: 'object', + properties: { value: { type: 'string', pattern: '(' } }, + patternProperties: { '^x-': { type: 'string' } }, + additionalProperties: false, + }, + }; + const [tool] = buildMcpTools( + fakeProvider([boundTool(toolDescriptor, binding('regex-binding'))], async () => ({ + content: [], + })), + ); + const parameters = tool?.parameters as { + validate?: (value: unknown) => Promise<{ success: boolean }>; + }; + + assert.equal( + (await parameters.validate?.({ value: 'ok', 'x-tag': 'server-owned' }))?.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/mcp-schema-preflight.ts b/packages/runtime/src/mcp-schema-preflight.ts new file mode 100644 index 0000000000..7bb9757bf1 --- /dev/null +++ b/packages/runtime/src/mcp-schema-preflight.ts @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; + +const validator = new Ajv({ allErrors: true, strict: false, validateFormats: false }); +const compiledSchemas = new WeakMap(); + +export function validateMcpJsonSchemaInput( + schema: unknown, + input: unknown, +): + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: Error } { + const compiled = compileMcpSchema(schema); + if (!compiled || compiled(input)) return { success: true, value: input }; + return { + success: false, + error: new Error(schemaErrorSummary(compiled.errors)), + }; +} + +function compileMcpSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; + if (compiledSchemas.has(schema)) return compiledSchemas.get(schema); + let compiled: ValidateFunction | undefined; + try { + compiled = validator.compile(stripUnsafeRegexConstraints(schema) as AnySchema); + } catch { + // The MCP endpoint remains authoritative for unsupported schema dialects. + compiled = undefined; + } + compiledSchemas.set(schema, compiled); + return compiled; +} + +function stripUnsafeRegexConstraints(value: unknown): unknown { + if (!isRecord(value)) return value; + const hasPatternProperties = isRecord(value.patternProperties); + const entries: Array<[string, unknown]> = []; + for (const [key, nested] of Object.entries(value)) { + if (key === 'pattern' || key === 'patternProperties') continue; + if (key === 'additionalProperties' && hasPatternProperties) continue; + entries.push([key, projectSchemaKeyword(key, nested)]); + } + return Object.fromEntries(entries); +} + +function projectSchemaKeyword(key: string, value: unknown): unknown { + if (key === 'properties' || key === '$defs' || key === 'definitions') { + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([name, schema]) => [name, stripUnsafeRegexConstraints(schema)]), + ); + } + if (key === 'dependencies' || key === 'dependentSchemas') { + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([name, schema]) => [ + name, + Array.isArray(schema) ? schema : stripUnsafeRegexConstraints(schema), + ]), + ); + } + if (key === 'allOf' || key === 'anyOf' || key === 'oneOf' || key === 'prefixItems') { + return Array.isArray(value) ? value.map(stripUnsafeRegexConstraints) : value; + } + if ( + key === 'items' || + key === 'additionalItems' || + key === 'additionalProperties' || + key === 'unevaluatedItems' || + key === 'unevaluatedProperties' || + key === 'propertyNames' || + key === 'contains' || + key === 'not' || + key === 'if' || + key === 'then' || + key === 'else' + ) { + return Array.isArray(value) + ? value.map(stripUnsafeRegexConstraints) + : stripUnsafeRegexConstraints(value); + } + return value; +} + +function schemaErrorSummary(errors: ErrorObject[] | null | undefined): string { + if (!errors) return 'input does not match the declared schema'; + return errors + .slice(0, 5) + .map( + (issue) => + `${issue.instancePath || issue.schemaPath || 'input'} ${issue.message ?? 'is invalid'}`, + ) + .join('; ') + .slice(0, 1000); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 7703f388af..f09e5eb27c 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'; import { jsonSchema } from 'ai'; -import { validateMcpJsonSchemaInput } from './ai-sdk-backend.js'; +import { validateMcpJsonSchemaInput } from './mcp-schema-preflight.js'; import type { ToolActivityKind } from '@maka/core/events'; import type { McpCallResult, From 30310b9005111721bbe4406888555671fb9ab399 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 5 Sep 2026 12:34:46 +0800 Subject: [PATCH 17/19] fix: preserve MCP preflight validation across code mode --- packages/runtime/src/mcp-schema-preflight.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/mcp-schema-preflight.ts b/packages/runtime/src/mcp-schema-preflight.ts index 7bb9757bf1..3268aaca89 100644 --- a/packages/runtime/src/mcp-schema-preflight.ts +++ b/packages/runtime/src/mcp-schema-preflight.ts @@ -55,7 +55,7 @@ function stripUnsafeRegexConstraints(value: unknown): unknown { const hasPatternProperties = isRecord(value.patternProperties); const entries: Array<[string, unknown]> = []; for (const [key, nested] of Object.entries(value)) { - if (key === 'pattern' || key === 'patternProperties') continue; + if (key === '$schema' || key === 'pattern' || key === 'patternProperties') continue; if (key === 'additionalProperties' && hasPatternProperties) continue; entries.push([key, projectSchemaKeyword(key, nested)]); } From b4d3e6538726839cbb63986b447f6f59a37c637a Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 5 Sep 2026 14:28:35 +0800 Subject: [PATCH 18/19] fix: validate tuple additionalItems schemas --- packages/runtime-host/src/protocol/client-capability.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index d70341ffcc..f50025487c 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -853,6 +853,7 @@ const CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES: Record< > = { properties: 'record', patternProperties: 'record', + additionalItems: 'single', $defs: 'record', definitions: 'record', allOf: 'array', From 105a79fc79fe11b2971e90fb89260b82c80b5be0 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sat, 5 Sep 2026 17:01:16 +0800 Subject: [PATCH 19/19] fix: keep MCP schema validation server-owned --- .../main/__tests__/mcp-runtime-e2e.test.ts | 29 ++++ .../runtime-host-desktop-candidate.test.ts | 27 ++++ .../runtime-host-native-capabilities.test.ts | 139 ++++++------------ packages/mcp/src/__fixtures__/stdio-server.ts | 5 + .../runtime/src/__tests__/mcp-tools.test.ts | 78 ++++++---- packages/runtime/src/mcp-schema-preflight.ts | 118 --------------- packages/runtime/src/mcp-tools.ts | 9 +- 7 files changed, 157 insertions(+), 248 deletions(-) delete mode 100644 packages/runtime/src/mcp-schema-preflight.ts 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 654d89aecc..19bb633282 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -131,6 +131,35 @@ test('MCP tools stay bound to the connection generation that advertised them', a ), /not offered/u, ); + let annotatedAdmissionEvidence: unknown; + assert.deepEqual( + await provider.call( + { + kind: 'client.capability.call', + invocationId: 'annotated-invocation', + registrationId: 'registration-1', + offerId: 'desktop_mcp_fixture', + serverId: 'fixture', + toolName: 'annotated', + arguments: { fallback: 'desktop-capability' }, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'annotated-capability-call', + cwd: process.cwd(), + }, + { + signal: new AbortController().signal, + accept: async (evidence) => { + annotatedAdmissionEvidence = evidence; + }, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), + }, + ), + { + content: [{ type: 'text', text: 'annotated:desktop-capability' }], + }, + ); + assert.deepEqual(annotatedAdmissionEvidence, { kind: 'none' }); let admissionEvidence: unknown; assert.deepEqual( await provider.call( 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 b272f32291..0081f5ac7f 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,6 +547,33 @@ 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 () => { + const ipc = ipcHarness(); + const host = connectionHarness('invalid-capability'); + const invalidTool = { + ...nativeTool(), + parameters: z.string(), + } as unknown as MakaTool; + + await assert.rejects( + () => + createDesktopRuntimeHostCandidate( + host.connection, + deps(ipc, { + browserTools: [invalidTool], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: emptyComputerUseTools(), + releaseComputerUseSession() {}, + }), + ), + /tool schema root must be an object/, + ); + + assert.equal(ipc.size, 0); + assert.equal(host.closeCalls, 1); +}); + 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. 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 be3ee16961..96833d86ee 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 @@ -202,7 +202,8 @@ test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () assert.deepEqual(published?.patternProperties, { '^x-': { type: 'string' } }); }); -test('skips non-object root jsonSchema tools without dropping the offer', () => { +test('forwards JSON Schema native capability arguments to the MCP authority', async () => { + let receivedArguments: unknown; const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -216,37 +217,40 @@ test('skips non-object root jsonSchema tools without dropping the offer', () => 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', + name: 'server_validated', + displayName: 'server_validated', + description: 'server_validated description', parameters: jsonSchema({ type: 'object', - properties: { value: { type: 'string' } }, + required: ['token'], + properties: { token: { type: 'string' } }, }), - impl: async () => 'ok', + impl: async (args: unknown) => { + receivedArguments = args; + return 'server result'; + }, }, ], }, ], }); - const tools = provider.offers().flatMap((offer) => offer.tools); assert.deepEqual( - tools.map((descriptor) => descriptor.name), - ['good_tool'], + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'server_validated', + arguments: {}, + }), + ), + { content: [{ type: 'text', text: 'server result' }] }, ); + assert.deepEqual(receivedArguments, {}); }); -test('skips malformed record-shaped schemas without dropping healthy MCP tools', () => { +test('skips non-object root jsonSchema tools without dropping the offer', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -263,8 +267,10 @@ test('skips malformed record-shaped schemas without dropping healthy MCP tools', name: 'bad_tool', displayName: 'bad_tool', description: 'bad_tool description', - parameters: jsonSchema({ type: 'object', properties: [] as never }), - impl: async () => 'bad', + parameters: jsonSchema({ + type: 'string', + }), + impl: async () => 'nope', }, { name: 'good_tool', @@ -274,20 +280,21 @@ test('skips malformed record-shaped schemas without dropping healthy MCP tools', type: 'object', properties: { value: { type: 'string' } }, }), - impl: async () => 'good', + impl: async () => 'ok', }, ], }, ], }); + const tools = provider.offers().flatMap((offer) => offer.tools); assert.deepEqual( - provider.offers().flatMap((offer) => offer.tools).map((tool) => tool.name), + tools.map((descriptor) => descriptor.name), ['good_tool'], ); }); -test('preserves a JSON Schema property named __proto__ during projection', () => { +test('skips malformed record-shaped schemas without dropping healthy MCP tools', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -301,28 +308,31 @@ test('preserves a JSON Schema property named __proto__ during projection', () => description: 'MCP tools', tools: [ { - name: 'proto_tool', - displayName: 'proto_tool', - description: 'proto_tool description', + 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: JSON.parse( - '{"__proto__":{"type":"string"},"safe":{"type":"number"}}', - ), + properties: { value: { type: 'string' } }, }), - impl: async () => 'ok', + impl: async () => 'good', }, ], }, ], }); - 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' }); + assert.deepEqual( + provider.offers().flatMap((offer) => offer.tools).map((tool) => tool.name), + ['good_tool'], + ); }); test('skips unsupported schema type tools without dropping the offer', () => { @@ -447,61 +457,6 @@ test('skips a malformed MCP tool without dropping the other offers', async () => 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: [], diff --git a/packages/mcp/src/__fixtures__/stdio-server.ts b/packages/mcp/src/__fixtures__/stdio-server.ts index d12dd7267d..62a1af6608 100644 --- a/packages/mcp/src/__fixtures__/stdio-server.ts +++ b/packages/mcp/src/__fixtures__/stdio-server.ts @@ -163,6 +163,11 @@ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => { structuredContent: { echoed: params.arguments?.value }, }; } + if (params.name === 'annotated') { + return { + content: [{ type: 'text', text: `annotated:${String(params.arguments?.fallback ?? '')}` }], + }; + } if (params.name === 'rich') { return { content: [ diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 9d0ebcda0e..6259d44c6d 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -102,45 +102,59 @@ 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('MCP schema preflight skips server regexes without dropping structural validation', async () => { - const toolDescriptor: McpToolDescriptor = { - ...descriptor('server', 'regex'), - inputSchema: { - type: 'object', - properties: { value: { type: 'string', pattern: '(' } }, - patternProperties: { '^x-': { type: 'string' } }, - additionalProperties: false, +test('buildMcpTools leaves MCP JSON Schema validation to the server', async () => { + const inputSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + values: { + type: 'array', + prefixItems: [{ type: 'string' }], + items: { type: 'number' }, + }, }, + required: ['values'], }; + let invocationArgs: unknown; const [tool] = buildMcpTools( - fakeProvider([boundTool(toolDescriptor, binding('regex-binding'))], async () => ({ - content: [], - })), + fakeProvider( + [ + boundTool( + { + ...descriptor('server', 'validated'), + inputSchema, + }, + binding('validated-binding'), + ), + ], + async (_binding, args) => { + invocationArgs = args; + return { content: [] }; + }, + ), ); const parameters = tool?.parameters as { - validate?: (value: unknown) => Promise<{ success: boolean }>; + jsonSchema?: unknown; + validate?: unknown; }; - - assert.equal( - (await parameters.validate?.({ value: 'ok', 'x-tag': 'server-owned' }))?.success, - true, + assert.deepEqual(parameters.jsonSchema, inputSchema); + assert.equal(parameters.validate, undefined); + if (!tool) throw new Error('expected MCP tool'); + assert.deepEqual( + await tool.impl( + { values: ['head', 42] }, + { + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + toolCallId: 'tool-call', + abortSignal: new AbortController().signal, + emitOutput() {}, + }, + ), + { content: [] }, ); - assert.equal((await parameters.validate?.({ value: 42 }))?.success, false); + assert.deepEqual(invocationArgs, { values: ['head', 42] }); }); test('buildMcpTools carries the Runtime-owned form callback to the provider', async () => { diff --git a/packages/runtime/src/mcp-schema-preflight.ts b/packages/runtime/src/mcp-schema-preflight.ts deleted file mode 100644 index 3268aaca89..0000000000 --- a/packages/runtime/src/mcp-schema-preflight.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; - -const validator = new Ajv({ allErrors: true, strict: false, validateFormats: false }); -const compiledSchemas = new WeakMap(); - -export function validateMcpJsonSchemaInput( - schema: unknown, - input: unknown, -): - | { readonly success: true; readonly value: unknown } - | { readonly success: false; readonly error: Error } { - const compiled = compileMcpSchema(schema); - if (!compiled || compiled(input)) return { success: true, value: input }; - return { - success: false, - error: new Error(schemaErrorSummary(compiled.errors)), - }; -} - -function compileMcpSchema(schema: unknown): ValidateFunction | undefined { - if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; - if (compiledSchemas.has(schema)) return compiledSchemas.get(schema); - let compiled: ValidateFunction | undefined; - try { - compiled = validator.compile(stripUnsafeRegexConstraints(schema) as AnySchema); - } catch { - // The MCP endpoint remains authoritative for unsupported schema dialects. - compiled = undefined; - } - compiledSchemas.set(schema, compiled); - return compiled; -} - -function stripUnsafeRegexConstraints(value: unknown): unknown { - if (!isRecord(value)) return value; - const hasPatternProperties = isRecord(value.patternProperties); - const entries: Array<[string, unknown]> = []; - for (const [key, nested] of Object.entries(value)) { - if (key === '$schema' || key === 'pattern' || key === 'patternProperties') continue; - if (key === 'additionalProperties' && hasPatternProperties) continue; - entries.push([key, projectSchemaKeyword(key, nested)]); - } - return Object.fromEntries(entries); -} - -function projectSchemaKeyword(key: string, value: unknown): unknown { - if (key === 'properties' || key === '$defs' || key === 'definitions') { - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value).map(([name, schema]) => [name, stripUnsafeRegexConstraints(schema)]), - ); - } - if (key === 'dependencies' || key === 'dependentSchemas') { - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value).map(([name, schema]) => [ - name, - Array.isArray(schema) ? schema : stripUnsafeRegexConstraints(schema), - ]), - ); - } - if (key === 'allOf' || key === 'anyOf' || key === 'oneOf' || key === 'prefixItems') { - return Array.isArray(value) ? value.map(stripUnsafeRegexConstraints) : value; - } - if ( - key === 'items' || - key === 'additionalItems' || - key === 'additionalProperties' || - key === 'unevaluatedItems' || - key === 'unevaluatedProperties' || - key === 'propertyNames' || - key === 'contains' || - key === 'not' || - key === 'if' || - key === 'then' || - key === 'else' - ) { - return Array.isArray(value) - ? value.map(stripUnsafeRegexConstraints) - : stripUnsafeRegexConstraints(value); - } - return value; -} - -function schemaErrorSummary(errors: ErrorObject[] | null | undefined): string { - if (!errors) return 'input does not match the declared schema'; - return errors - .slice(0, 5) - .map( - (issue) => - `${issue.instancePath || issue.schemaPath || 'input'} ${issue.message ?? 'is invalid'}`, - ) - .join('; ') - .slice(0, 1000); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index f09e5eb27c..2e73a7f759 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -19,7 +19,6 @@ import { createHash } from 'node:crypto'; import { jsonSchema } from 'ai'; -import { validateMcpJsonSchemaInput } from './mcp-schema-preflight.js'; import type { ToolActivityKind } from '@maka/core/events'; import type { McpCallResult, @@ -143,11 +142,9 @@ export function buildMcpToolsWithIdentities( categoryHint: options.categoryHint ?? 'network_send', ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - parameters: jsonSchema(inputSchema, { - // Local preflight covers bounded structural constraints; the MCP - // server remains authoritative for the complete schema. - validate: async (value) => validateMcpJsonSchemaInput(inputSchema, value), - }), + // The MCP server remains the sole authority for the complete JSON + // Schema. Runtime only carries the declaration to the AI SDK. + parameters: jsonSchema(inputSchema), ...(provider.prepareTool ? { prepareExecution: async (args: unknown, context) => {