From 5562a002c9787f34976c273a769489a29df5bc8b Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Wed, 2 Sep 2026 16:06:40 +0800 Subject: [PATCH 01/15] 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 7177caf5172d435ee35c0a77cff490fe57719baa Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 10:20:28 +0800 Subject: [PATCH 02/15] 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 32c56efa68734b955574fb313650f80e9f9c1dec Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 14:18:29 +0800 Subject: [PATCH 03/15] 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 d2233a8228d6d21bbec29dfaa26cb416cb8fbebc Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:02:09 +0800 Subject: [PATCH 04/15] 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 56b73443ed3b90e9d692fa26db1977c1e33f48a4 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 3 Sep 2026 17:13:08 +0800 Subject: [PATCH 05/15] 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 90336fa4ee5d2ad2a2ced5d2142d05313b58087c Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 18:52:10 +0800 Subject: [PATCH 06/15] 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 64298268f8fce3ad781359ca0126ed3115622d84 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 19:55:54 +0800 Subject: [PATCH 07/15] 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 8670b95e80b961b135f961b0bc8ea9a872f450c8 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 20:20:24 +0800 Subject: [PATCH 08/15] 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 b52dbe897d9972810a18a295bbaf93fbdfedcff9 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 08:26:04 +0800 Subject: [PATCH 09/15] 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/ai-sdk-backend.ts | 32 ++- packages/runtime/src/mcp-tools.ts | 204 +++++++++--------- 6 files changed, 170 insertions(+), 133 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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..36cc170039 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -617,6 +617,28 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis throw invalidCodeModeToolArguments(tool.name, validator.errors); } +export function validateJsonSchemaInput( + schema: unknown, + input: unknown, +): + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: Error } { + const validator = compileCodeModeJsonSchema(schema); + if (!validator || validator(input)) return { success: true, value: input }; + return { + success: false, + error: new Error(schemaErrorSummary(validator.errors)), + }; +} + +function hasDraft7TupleItems(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) return value.some(hasDraft7TupleItems); + const schema = value as Record; + if (Array.isArray(schema.items)) return true; + return Object.values(schema).some(hasDraft7TupleItems); +} + function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; @@ -624,11 +646,13 @@ function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefine if (cached) return cached; const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; - const validator = dialect.includes('draft-07') + const validator = hasDraft7TupleItems(schema) ? codeModeDraft7Validator - : dialect.includes('2019-09') - ? codeModeDraft2019Validator - : codeModeDraft2020Validator; + : dialect.includes('draft-07') + ? codeModeDraft7Validator + : dialect.includes('2019-09') + ? codeModeDraft2019Validator + : codeModeDraft2020Validator; const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') ? { ...schema, $schema: dialect.replace('https://', 'http://') } : schema; 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 3cdf40e442857173949282c290d960c074b84901 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 11:55:01 +0800 Subject: [PATCH 10/15] 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 a284824a5afdab6ce36862d2eea7ad0f9b001f31 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 15:59:13 +0800 Subject: [PATCH 11/15] 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 a5da402aceabd6f1422fddec41329d51fa1d56f5 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 18:06:32 +0800 Subject: [PATCH 12/15] 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/ai-sdk-backend.ts | 32 +-- packages/runtime/src/mcp-tools.ts | 10 +- scripts/pre-push-check.ps1 | 85 -------- 9 files changed, 72 insertions(+), 424 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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 36cc170039..f314471fef 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -617,28 +617,6 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis throw invalidCodeModeToolArguments(tool.name, validator.errors); } -export function validateJsonSchemaInput( - schema: unknown, - input: unknown, -): - | { readonly success: true; readonly value: unknown } - | { readonly success: false; readonly error: Error } { - const validator = compileCodeModeJsonSchema(schema); - if (!validator || validator(input)) return { success: true, value: input }; - return { - success: false, - error: new Error(schemaErrorSummary(validator.errors)), - }; -} - -function hasDraft7TupleItems(value: unknown): boolean { - if (value === null || typeof value !== 'object') return false; - if (Array.isArray(value)) return value.some(hasDraft7TupleItems); - const schema = value as Record; - if (Array.isArray(schema.items)) return true; - return Object.values(schema).some(hasDraft7TupleItems); -} - function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; @@ -646,13 +624,11 @@ function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefine if (cached) return cached; const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; - const validator = hasDraft7TupleItems(schema) + const validator = dialect.includes('draft-07') ? codeModeDraft7Validator - : dialect.includes('draft-07') - ? codeModeDraft7Validator - : dialect.includes('2019-09') - ? codeModeDraft2019Validator - : codeModeDraft2020Validator; + : dialect.includes('2019-09') + ? codeModeDraft2019Validator + : codeModeDraft2020Validator; const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') ? { ...schema, $schema: dialect.replace('https://', 'http://') } : schema; 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 de83539134e3c3e1b8a3ad0f3f2d939b96046e81 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 4 Sep 2026 22:22:19 +0800 Subject: [PATCH 13/15] 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 830a8c6e628087fc014af5bb3176277c6ece9c2c Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 22:51:53 +0800 Subject: [PATCH 14/15] 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 0bbb45164f0059896adea4b7ac13a6b7c8248308 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 23:46:31 +0800 Subject: [PATCH 15/15] fix: restore safe MCP argument preflight --- apps/desktop/package.json | 1 - .../runtime/src/__tests__/mcp-tools.test.ts | 15 ++++ packages/runtime/src/ai-sdk-backend.ts | 71 +++++++++++++++++++ packages/runtime/src/mcp-tools.ts | 9 ++- 4 files changed, 92 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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..4f3ce2e8d8 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -580,6 +580,30 @@ const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); const codeModeCompiledSchemas = new WeakMap(); +const mcpCompiledSchemas = new WeakMap(); +const MCP_SCHEMA_CONTAINER_KEYS = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'definitions', + 'allOf', + 'anyOf', + 'oneOf', + 'items', + 'additionalItems', + 'additionalProperties', + 'propertyNames', + 'prefixItems', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'dependentSchemas', + 'dependencies', + 'unevaluatedItems', + 'unevaluatedProperties', +]); async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { const parameters = tool.parameters as { @@ -617,6 +641,53 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis throw invalidCodeModeToolArguments(tool.name, validator.errors); } +/** + * Run a bounded MCP preflight without executing server-supplied regexes in the + * Runtime process. The MCP server remains authoritative for the complete + * schema, including `pattern` and `patternProperties`. + */ +export function validateMcpJsonSchemaInput( + schema: unknown, + input: unknown, +): + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: Error } { + const validator = compileMcpJsonSchema(schema); + if (!validator || validator(input)) return { success: true, value: input }; + return { + success: false, + error: new Error(schemaErrorSummary(validator.errors)), + }; +} + +function compileMcpJsonSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; + if (mcpCompiledSchemas.has(schema)) return mcpCompiledSchemas.get(schema); + const localSchema = stripMcpRegexConstraints(schema); + let compiled: ValidateFunction | undefined; + try { + compiled = compileCodeModeJsonSchema(localSchema); + } catch { + // Unsupported or malformed schemas are left to the MCP endpoint. + compiled = undefined; + } + mcpCompiledSchemas.set(schema, compiled); + return compiled; +} + +function stripMcpRegexConstraints(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(stripMcpRegexConstraints); + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (key === 'pattern' || key === 'patternProperties') continue; + result[key] = MCP_SCHEMA_CONTAINER_KEYS.has(key) + ? stripMcpRegexConstraints(nested) + : nested; + } + return result; +} + function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 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) => {