From a47cdb280407f63d1a8d932ba233bad963ea0289 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Wed, 2 Sep 2026 16:06:40 +0800 Subject: [PATCH 1/9] fix: support MCP json schema tools --- .../runtime-host-native-capabilities.test.ts | 64 +++++++ .../main/runtime-host-native-capabilities.ts | 181 ++++++++++++++++-- .../client-capability-protocol.test.ts | 49 +++++ .../src/protocol/client-capability.ts | 3 +- 4 files changed, 275 insertions(+), 22 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 3e2f36007d..07487239a0 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'; @@ -133,6 +134,69 @@ test('publishes the real Computer Use schema through the Client Capability proto assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true); }); +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/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index bebe81db1c..985f99dc8b 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -337,8 +337,7 @@ async function invokeNativeTool( } const signal = AbortSignal.any([options.signal, invocation.signal]); signal.throwIfAborted(); - const parameters = requireZodSchema(binding.tool); - const args = await parameters.parseAsync(frame.arguments); + const args = await parseNativeToolArguments(binding.tool.parameters, frame.arguments); signal.throwIfAborted(); const sessionId = frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID @@ -448,29 +447,169 @@ function capabilityOffer( } function toolInputSchema(tool: MakaTool): Record { - const schema = toJSONSchema(requireZodSchema(tool), { - io: "input", - target: "draft-07", - unrepresentable: "any", - cycles: "ref", - reused: "inline", - }); - delete schema.$schema; - if (schema.type !== "object") { - throw new Error( - `Desktop native capability tool schema must be an object: ${tool.name}`, - ); + if (tool.parameters instanceof z.ZodType) { + const schema = toJSONSchema(tool.parameters, { + io: "input", + target: "draft-07", + unrepresentable: "any", + cycles: "ref", + reused: "inline", + }); + delete schema.$schema; + if (schema.type !== "object") { + throw new Error( + `Desktop native capability tool schema must be an object: ${tool.name}`, + ); + } + return Object.freeze(schema); + } + + const wrapper = tool.parameters as JsonSchemaWrapper | undefined; + if (wrapper && typeof wrapper.jsonSchema === "object" && wrapper.jsonSchema) { + const schema = wrapper.jsonSchema; + if (typeof schema === "object" && schema !== null && (schema as any).type === "object") { + return Object.freeze(cleanJsonSchemaForCapability(schema)); + } + } + + throw new Error( + `Desktop native capability tool has an unsupported schema type: ${tool.name}`, + ); +} + +const CAPABILITY_SCHEMA_KEYWORDS = new Set([ + "$defs", + "$ref", + "additionalProperties", + "allOf", + "anyOf", + "const", + "default", + "definitions", + "description", + "enum", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "items", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minItems", + "minLength", + "minProperties", + "minimum", + "multipleOf", + "oneOf", + "pattern", + "patternProperties", + "propertyNames", + "properties", + "required", + "title", + "type", + "uniqueItems", +]); + +function cleanJsonSchemaForCapability(schema: Record): Record { + const result: Record = {}; + for (const key of Object.keys(schema)) { + if (!CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; + result[key] = cleanSchemaKeywordValue(key, schema[key]); + } + return result; +} + +function cleanSchemaKeywordValue(key: string, value: unknown): unknown { + if (["properties", "patternProperties", "$defs", "definitions"].includes(key)) { + return cleanSchemaMap(value); } - return Object.freeze(schema); + if (key === "items" || key === "additionalProperties") { + if (value === true || value === false) return value; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return cleanJsonSchemaForCapability(value as Record); + } + } + return cleanSchemaValue(value); } -function requireZodSchema(tool: MakaTool): z.ZodType { - if (!(tool.parameters instanceof z.ZodType)) { - throw new Error( - `Desktop native capability tool has an invalid schema: ${tool.name}`, - ); +function cleanSchemaMap(value: unknown): unknown { + if (value === null || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (nested === null || typeof nested !== "object" || Array.isArray(nested)) { + result[key] = nested; + continue; + } + result[key] = cleanJsonSchemaForCapability(nested as Record); + } + return result; +} + +function cleanSchemaValue(value: unknown): unknown { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(cleanSchemaValue); + return cleanJsonSchemaForCapability(value as Record); +} + +interface JsonSchemaWrapper { + readonly jsonSchema?: Record; +} + +async function parseNativeToolArguments(parameters: unknown, args: unknown): Promise { + if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { + return args; + } + const schema = parameters as { + parseAsync?: (value: unknown) => PromiseLike; + safeParseAsync?: ( + value: unknown, + ) => PromiseLike<{ success: true; data: unknown } | { success: false; error: unknown }>; + safeParse?: ( + value: unknown, + ) => { success: true; data: unknown } | { success: false; error: unknown }; + validate?: ( + value: unknown, + ) => + | { success: true; value: unknown } + | { success: false; error: unknown } + | PromiseLike<{ success: true; value: unknown } | { success: false; error: unknown }>; + '~standard'?: { + validate?: ( + value: unknown, + ) => + | { value: unknown } + | { issues: readonly unknown[] } + | PromiseLike<{ value: unknown } | { issues: readonly unknown[] }>; + }; + }; + + if (typeof schema.parseAsync === 'function') { + return await schema.parseAsync(args); + } + if (typeof schema.safeParseAsync === 'function') { + const parsed = await schema.safeParseAsync(args); + if (parsed.success) return parsed.data; + throw parsed.error; + } + if (typeof schema.safeParse === 'function') { + const parsed = schema.safeParse(args); + if (parsed.success) return parsed.data; + throw parsed.error; + } + if (typeof schema.validate === 'function') { + const parsed = await schema.validate(args); + if (parsed.success) return parsed.value; + throw parsed.error; + } + if (typeof schema['~standard']?.validate === 'function') { + const parsed = await schema['~standard'].validate(args); + if ('value' in parsed) return parsed.value; + throw new Error('Tool arguments failed declared schema validation', { cause: parsed.issues }); } - return tool.parameters; + return args; } function indexBindings( 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 1f79a4b7a3..6150578bbf 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -256,6 +256,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 }, @@ -303,6 +323,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 ce51734621..8e586c6844 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -743,6 +743,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'multipleOf', 'oneOf', 'pattern', + 'patternProperties', 'propertyNames', 'properties', 'required', @@ -810,7 +811,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 684b8e98f207d15805ee1b8aecc1eda02c6962e1 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 10:20:28 +0800 Subject: [PATCH 2/9] fix: align MCP schema handling with protocol --- .../runtime-host-native-capabilities.test.ts | 15 +- .../main/runtime-host-native-capabilities.ts | 178 +++++------------- .../client-capability-protocol.test.ts | 25 +++ .../src/protocol/client-capability.ts | 2 +- 4 files changed, 90 insertions(+), 130 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 07487239a0..9fd0828210 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 @@ -156,7 +156,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' }, @@ -178,7 +184,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 985f99dc8b..27d07318b3 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -25,15 +25,17 @@ import { type ClientCapabilityProvider, type OAuthPresentationBackend, } from "@maka/runtime-host/client"; -import type { - ClientCapabilityCallFrame, - ClientCapabilityCallResult, - ClientCapabilityContentBlock, - ClientCapabilityHostPathAccess, - ClientCapabilityOffer, - ClientCapabilityServiceCallFrame, - ClientCapabilityServiceOffer, +import { + CLIENT_CAPABILITY_SCHEMA_KEYWORDS, + type ClientCapabilityCallFrame, + type ClientCapabilityCallResult, + type ClientCapabilityContentBlock, + type ClientCapabilityHostPathAccess, + type ClientCapabilityOffer, + type ClientCapabilityServiceCallFrame, + type ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; +import { validateTypes } from '@ai-sdk/provider-utils'; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; @@ -467,8 +469,8 @@ function toolInputSchema(tool: MakaTool): Record { const wrapper = tool.parameters as JsonSchemaWrapper | undefined; if (wrapper && typeof wrapper.jsonSchema === "object" && wrapper.jsonSchema) { const schema = wrapper.jsonSchema; - if (typeof schema === "object" && schema !== null && (schema as any).type === "object") { - return Object.freeze(cleanJsonSchemaForCapability(schema)); + if (typeof schema === "object" && schema !== null) { + return Object.freeze(projectClientCapabilitySchema(schema)); } } @@ -477,139 +479,59 @@ function toolInputSchema(tool: MakaTool): Record { ); } -const CAPABILITY_SCHEMA_KEYWORDS = new Set([ - "$defs", - "$ref", - "additionalProperties", - "allOf", - "anyOf", - "const", - "default", - "definitions", - "description", - "enum", - "examples", - "exclusiveMaximum", - "exclusiveMinimum", - "format", - "items", - "maxItems", - "maxLength", - "maxProperties", - "maximum", - "minItems", - "minLength", - "minProperties", - "minimum", - "multipleOf", - "oneOf", - "pattern", - "patternProperties", - "propertyNames", - "properties", - "required", - "title", - "type", - "uniqueItems", -]); - -function cleanJsonSchemaForCapability(schema: Record): Record { - const result: Record = {}; - for (const key of Object.keys(schema)) { - if (!CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; - result[key] = cleanSchemaKeywordValue(key, schema[key]); - } - return result; -} - -function cleanSchemaKeywordValue(key: string, value: unknown): unknown { - if (["properties", "patternProperties", "$defs", "definitions"].includes(key)) { - return cleanSchemaMap(value); - } - if (key === "items" || key === "additionalProperties") { - if (value === true || value === false) return value; - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - return cleanJsonSchemaForCapability(value as Record); - } - } - return cleanSchemaValue(value); +interface JsonSchemaWrapper { + readonly jsonSchema?: Record; } -function cleanSchemaMap(value: unknown): unknown { - if (value === null || typeof value !== "object" || Array.isArray(value)) return {}; +function projectClientCapabilitySchema(schema: Record): Record { const result: Record = {}; - for (const [key, nested] of Object.entries(value as Record)) { - if (nested === null || typeof nested !== "object" || Array.isArray(nested)) { - result[key] = nested; - continue; - } - result[key] = cleanJsonSchemaForCapability(nested as 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 cleanSchemaValue(value: unknown): unknown { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(cleanSchemaValue); - return cleanJsonSchemaForCapability(value as Record); +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; + } } -interface JsonSchemaWrapper { - readonly jsonSchema?: Record; +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); } async function parseNativeToolArguments(parameters: unknown, args: unknown): Promise { if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { return args; } - const schema = parameters as { - parseAsync?: (value: unknown) => PromiseLike; - safeParseAsync?: ( - value: unknown, - ) => PromiseLike<{ success: true; data: unknown } | { success: false; error: unknown }>; - safeParse?: ( - value: unknown, - ) => { success: true; data: unknown } | { success: false; error: unknown }; - validate?: ( - value: unknown, - ) => - | { success: true; value: unknown } - | { success: false; error: unknown } - | PromiseLike<{ success: true; value: unknown } | { success: false; error: unknown }>; - '~standard'?: { - validate?: ( - value: unknown, - ) => - | { value: unknown } - | { issues: readonly unknown[] } - | PromiseLike<{ value: unknown } | { issues: readonly unknown[] }>; - }; - }; - - if (typeof schema.parseAsync === 'function') { - return await schema.parseAsync(args); - } - if (typeof schema.safeParseAsync === 'function') { - const parsed = await schema.safeParseAsync(args); - if (parsed.success) return parsed.data; - throw parsed.error; - } - if (typeof schema.safeParse === 'function') { - const parsed = schema.safeParse(args); - if (parsed.success) return parsed.data; - throw parsed.error; - } - if (typeof schema.validate === 'function') { - const parsed = await schema.validate(args); - if (parsed.success) return parsed.value; - throw parsed.error; - } - if (typeof schema['~standard']?.validate === 'function') { - const parsed = await schema['~standard'].validate(args); - if ('value' in parsed) return parsed.value; - throw new Error('Tool arguments failed declared schema validation', { cause: parsed.issues }); - } - return args; + return await validateTypes({ value: args, schema: parameters as never }); } function indexBindings( 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 6150578bbf..f950941830 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -323,6 +323,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 8e586c6844..89ec73eb70 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -716,7 +716,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', 'additionalProperties', From f91552e4f1596126f24e21dc1f11d7195c827296 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 14:18:29 +0800 Subject: [PATCH 3/9] fix: bump runtime host protocol epoch --- packages/runtime-host/src/protocol/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9abf64b3a6..7aa30d36a2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,11 @@ 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 = 105 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +// 106: Client Capability tool schema vocabulary adds `patternProperties`; +// `validateToolInputSchema` recursion and `projectToolInputSchema` are now +// driven by a single per-keyword shape table exported alongside the keyword +// set. Older peers reject the unknown keyword and fail the handshake. // 105: Usage summaries may carry the recorded call-time total and per-Session // tool-invocation totals. Older Clients reject the unknown fields, so a newer // Host's usage summary is unreadable to them. From e7d29ebd7450381961aa0fe0ee2401962636cce1 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:02:09 +0800 Subject: [PATCH 4/9] 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 | 154 ++++++++++----- package-lock.json | 2 + .../client-capability-protocol.test.ts | 43 +---- .../src/protocol/client-capability.ts | 135 ++++++++++--- 6 files changed, 393 insertions(+), 122 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6240a1c749..e040893dc7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -61,6 +61,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", @@ -84,6 +85,7 @@ "@types/react-dom": "^19.2.4", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.1.0", + "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 9fd0828210..2783552503 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 @@ -134,8 +134,7 @@ test('publishes the real Computer Use schema through the Client Capability proto assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true); }); -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/', @@ -168,10 +167,7 @@ test('accepts jsonSchema-wrapped MCP proxy tool descriptors', async () => { '^x-': { type: 'string' }, }, }), - impl: async (args) => { - calls.push(args); - return 'ok'; - }, + impl: async () => 'ok', }, ], }, @@ -184,30 +180,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 27d07318b3..a0dab3e93f 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -26,7 +26,7 @@ import { type OAuthPresentationBackend, } from "@maka/runtime-host/client"; import { - CLIENT_CAPABILITY_SCHEMA_KEYWORDS, + projectToolInputSchema, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, type ClientCapabilityContentBlock, @@ -36,6 +36,9 @@ import { type ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; import { validateTypes } from '@ai-sdk/provider-utils'; +import Ajv, { type AnySchema, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019.js'; +import Ajv2020 from 'ajv/dist/2020.js'; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; @@ -470,7 +473,9 @@ function toolInputSchema(tool: MakaTool): Record { if (wrapper && typeof wrapper.jsonSchema === "object" && wrapper.jsonSchema) { const schema = wrapper.jsonSchema; if (typeof schema === "object" && schema !== null) { - return Object.freeze(projectClientCapabilitySchema(schema)); + return Object.freeze( + projectToolInputSchema(schema as Record), + ); } } @@ -483,55 +488,116 @@ 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; +// -- JSON Schema argument validation ------------------------------------------- + +const jsonSchemaValidatorOptions = { + allErrors: true, + strict: false, + validateFormats: false, +} as const; +const draft7Validator = new Ajv(jsonSchemaValidatorOptions); +const draft2019Validator = new Ajv2019(jsonSchemaValidatorOptions); +const draft2020Validator = new Ajv2020(jsonSchemaValidatorOptions); +const compiledSchemas = new WeakMap(); + +function compileJsonSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema === 'boolean') return draft2020Validator.compile(schema); + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) + return undefined; + const cached = compiledSchemas.get(schema); + if (cached) return cached; + const declaredDialect = ( + schema as { readonly $schema?: unknown } + ).$schema; + const dialect = + typeof declaredDialect === 'string' ? declaredDialect : ''; + const validator = dialect.includes('draft-07') + ? draft7Validator + : dialect.includes('2019-09') + ? draft2019Validator + : draft2020Validator; + const compiled = validator.compile(schema as AnySchema); + compiledSchemas.set(schema, compiled); + return compiled; } -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; +async function parseNativeToolArguments( + parameters: unknown, + args: unknown, +): Promise { + if ( + !parameters || + (typeof parameters !== 'object' && typeof parameters !== 'function') + ) { + return args; } -} -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); + // Zod schemas: the provider-utils path parses correctly. + if (parameters instanceof z.ZodType) { + return await validateTypes({ value: args, schema: parameters as never }); + } + + // jsonSchema() wrappers: compile the projected schema and validate. + const wrapper = parameters as JsonSchemaWrapper | undefined; + if (wrapper?.jsonSchema) { + const projected = projectToolInputSchema( + wrapper.jsonSchema as Record, + ); + const validator = compileJsonSchema(projected); + if (!validator || validator(args)) return args; + throw new Error( + `Invalid arguments: ${schemaErrorSummary(validator.errors)}`, + ); + } + + return args; } -async function parseNativeToolArguments(parameters: unknown, args: unknown): Promise { - if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { - return args; +function schemaErrorSummary(error: unknown): string { + if ( + error && + typeof error === 'object' && + Array.isArray((error as { issues?: unknown }).issues) + ) { + const issues = ( + error as { issues: Array<{ path?: unknown; message?: unknown }> } + ).issues; + return issues + .slice(0, 5) + .map((issue) => { + const path = Array.isArray(issue.path) + ? issue.path.join('.') + : ''; + const message = + typeof issue.message === 'string' + ? issue.message + : 'invalid value'; + return path ? `${path}: ${message}` : message; + }) + .join('; ') + .slice(0, 1000); + } + if (Array.isArray(error)) { + return (error as Array<{ message?: unknown }>) + .slice(0, 5) + .map( + (entry) => + (typeof entry?.message === 'string' ? entry.message : '') + + (entry && typeof entry === 'object' && 'instancePath' in entry + ? ` at ${(entry as { instancePath: unknown }).instancePath}` + : ''), + ) + .filter(Boolean) + .join('; ') + .slice(0, 1000); } - return await validateTypes({ value: args, schema: parameters as never }); + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'validation failed'; + return message.slice(0, 1000); } function indexBindings( diff --git a/package-lock.json b/package-lock.json index 670c94ab33..0b0f4465a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,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", @@ -78,6 +79,7 @@ "@vitejs/plugin-react": "^6.1.0", "@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 f950941830..e61f823449 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -327,19 +327,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' }, }, }, }, @@ -348,35 +346,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 89ec73eb70..2e44f53c35 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -752,6 +752,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'); @@ -811,11 +889,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) || @@ -825,31 +898,37 @@ function validateToolInputSchema(root: Record): void { throw invalidProtocolFrame('Invalid Client Capability tool schema required'); } } - if ( - schema.additionalProperties !== undefined && - typeof schema.additionalProperties !== 'boolean' - ) { - visit(schema.additionalProperties); - } - 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 50de4bf98870b4fe54f9884ce7d82b1a638e8939 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:43:13 +0800 Subject: [PATCH 5/9] fix: key Ajv compile cache on stable raw schema, drop dead dialect code --- .../main/runtime-host-native-capabilities.ts | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a0dab3e93f..a278fd41a3 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -36,9 +36,7 @@ import { type ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; import { validateTypes } from '@ai-sdk/provider-utils'; -import Ajv, { type AnySchema, type ValidateFunction } from 'ajv'; -import Ajv2019 from 'ajv/dist/2019.js'; -import Ajv2020 from 'ajv/dist/2020.js'; +import Ajv2020, { type AnySchema, type ValidateFunction } from 'ajv/dist/2020.js'; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; @@ -489,35 +487,30 @@ interface JsonSchemaWrapper { } // -- JSON Schema argument validation ------------------------------------------- +// +// Validation runs against the projected (advertised) schema, not the raw +// MCP schema, so any constraint expressed via non-whitelisted keywords +// (if/then/else, contains, dependentRequired, …) is dropped before Ajv +// sees it. The downstream MCP server re-validates against the full schema. const jsonSchemaValidatorOptions = { allErrors: true, strict: false, validateFormats: false, } as const; -const draft7Validator = new Ajv(jsonSchemaValidatorOptions); -const draft2019Validator = new Ajv2019(jsonSchemaValidatorOptions); -const draft2020Validator = new Ajv2020(jsonSchemaValidatorOptions); +const schemaValidator = new Ajv2020(jsonSchemaValidatorOptions); const compiledSchemas = new WeakMap(); -function compileJsonSchema(schema: unknown): ValidateFunction | undefined { - if (typeof schema === 'boolean') return draft2020Validator.compile(schema); - if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) - return undefined; - const cached = compiledSchemas.get(schema); +function compileJsonSchema( + rawSchema: object, + projected: Record, +): ValidateFunction | undefined { + const cached = compiledSchemas.get(rawSchema); if (cached) return cached; - const declaredDialect = ( - schema as { readonly $schema?: unknown } - ).$schema; - const dialect = - typeof declaredDialect === 'string' ? declaredDialect : ''; - const validator = dialect.includes('draft-07') - ? draft7Validator - : dialect.includes('2019-09') - ? draft2019Validator - : draft2020Validator; - const compiled = validator.compile(schema as AnySchema); - compiledSchemas.set(schema, compiled); + if (typeof projected !== 'object' || projected === null || Array.isArray(projected)) + return undefined; + const compiled = schemaValidator.compile(projected as AnySchema); + compiledSchemas.set(rawSchema, compiled); return compiled; } @@ -540,10 +533,9 @@ async function parseNativeToolArguments( // jsonSchema() wrappers: compile the projected schema and validate. const wrapper = parameters as JsonSchemaWrapper | undefined; if (wrapper?.jsonSchema) { - const projected = projectToolInputSchema( - wrapper.jsonSchema as Record, - ); - const validator = compileJsonSchema(projected); + const raw = wrapper.jsonSchema as Record; + const projected = projectToolInputSchema(raw); + const validator = compileJsonSchema(raw, projected); if (!validator || validator(args)) return args; throw new Error( `Invalid arguments: ${schemaErrorSummary(validator.errors)}`, From 9f1a4a02207f5797d22fb812d6fa9d2b730e9421 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 3 Sep 2026 17:13:08 +0800 Subject: [PATCH 6/9] 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 ++++++++++++++++++- .../main/runtime-host-native-capabilities.ts | 52 ++++------- .../src/protocol/client-capability.ts | 11 +++ 3 files changed, 115 insertions(+), 37 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 2783552503..de7b29eba1 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 @@ -323,7 +323,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({ @@ -363,6 +363,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/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a278fd41a3..4e722735a6 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -35,7 +35,6 @@ import { type ClientCapabilityServiceCallFrame, type ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; -import { validateTypes } from '@ai-sdk/provider-utils'; import Ajv2020, { type AnySchema, type ValidateFunction } from 'ajv/dist/2020.js'; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; @@ -501,15 +500,20 @@ const jsonSchemaValidatorOptions = { const schemaValidator = new Ajv2020(jsonSchemaValidatorOptions); const compiledSchemas = new WeakMap(); -function compileJsonSchema( - rawSchema: object, - projected: Record, -): ValidateFunction | undefined { +function compileJsonSchema(rawSchema: Record): ValidateFunction { const cached = compiledSchemas.get(rawSchema); if (cached) return cached; - if (typeof projected !== 'object' || projected === null || Array.isArray(projected)) - return undefined; - const compiled = schemaValidator.compile(projected as AnySchema); + const projected = projectToolInputSchema(rawSchema); + let compiled: ValidateFunction; + try { + compiled = schemaValidator.compile(projected as AnySchema); + } catch (error) { + throw new Error( + `Desktop native capability tool has an uncompilable schema: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } compiledSchemas.set(rawSchema, compiled); return compiled; } @@ -525,18 +529,17 @@ async function parseNativeToolArguments( return args; } - // Zod schemas: the provider-utils path parses correctly. + // Zod schemas: validate and coerce with the schema itself. if (parameters instanceof z.ZodType) { - return await validateTypes({ value: args, schema: parameters as never }); + return await parameters.parseAsync(args); } // jsonSchema() wrappers: compile the projected schema and validate. const wrapper = parameters as JsonSchemaWrapper | undefined; if (wrapper?.jsonSchema) { const raw = wrapper.jsonSchema as Record; - const projected = projectToolInputSchema(raw); - const validator = compileJsonSchema(raw, projected); - if (!validator || validator(args)) return args; + const validator = compileJsonSchema(raw); + if (validator(args)) return args; throw new Error( `Invalid arguments: ${schemaErrorSummary(validator.errors)}`, ); @@ -546,29 +549,6 @@ async function parseNativeToolArguments( } function schemaErrorSummary(error: unknown): string { - if ( - error && - typeof error === 'object' && - Array.isArray((error as { issues?: unknown }).issues) - ) { - const issues = ( - error as { issues: Array<{ path?: unknown; message?: unknown }> } - ).issues; - return issues - .slice(0, 5) - .map((issue) => { - const path = Array.isArray(issue.path) - ? issue.path.join('.') - : ''; - const message = - typeof issue.message === 'string' - ? issue.message - : 'invalid value'; - return path ? `${path}: ${message}` : message; - }) - .join('; ') - .slice(0, 1000); - } if (Array.isArray(error)) { return (error as Array<{ message?: unknown }>) .slice(0, 5) diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 2e44f53c35..4425a1756e 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -903,6 +903,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 c4639cef859c5fb3513aee06d5031f78cf367b36 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 18:52:10 +0800 Subject: [PATCH 7/9] fix: isolate bad MCP tools, skip regex in local validation, adapt tuple items --- .../runtime-host-native-capabilities.test.ts | 330 ++++++++++++++---- .../main/runtime-host-native-capabilities.ts | 163 ++++++--- .../client-capability-protocol.test.ts | 21 ++ .../src/protocol/client-capability.ts | 27 +- 4 files changed, 418 insertions(+), 123 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 de7b29eba1..5f456b0366 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 @@ -261,71 +261,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/', @@ -339,13 +490,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', @@ -355,17 +509,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/', @@ -379,15 +552,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', }, ], @@ -395,13 +578,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/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 4e722735a6..9d5938ec61 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -27,6 +27,7 @@ import { } from "@maka/runtime-host/client"; import { projectToolInputSchema, + validateToolInputSchema, type ClientCapabilityCallFrame, type ClientCapabilityCallResult, type ClientCapabilityContentBlock, @@ -34,6 +35,7 @@ import { type ClientCapabilityOffer, type ClientCapabilityServiceCallFrame, type ClientCapabilityServiceOffer, + type ClientCapabilityToolDescriptor, } from "@maka/runtime-host/protocol"; import Ajv2020, { type AnySchema, type ValidateFunction } from 'ajv/dist/2020.js'; import { toJSONSchema, z } from "zod"; @@ -116,10 +118,7 @@ export function createDesktopNativeCapabilityProvider( ): DesktopNativeCapabilityProvider { const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; - const offers = Object.freeze( - groups.map((group) => capabilityOffer(group, hostPathAccess)), - ); - const bindings = indexBindings(groups); + const { offers, bindings } = buildPublishedCapabilities(groups, hostPathAccess); const oauthPresentation = input.oauthPresentation ? createOAuthPresentationClientProvider(input.oauthPresentation) : undefined; @@ -420,32 +419,69 @@ function abortInvocations( return settling; } -function capabilityOffer( - group: DesktopCapabilityGroup, +function buildPublishedCapabilities( + groups: readonly DesktopCapabilityGroup[], hostPathAccess: ClientCapabilityHostPathAccess, -): ClientCapabilityOffer { - return Object.freeze({ - offerId: group.offerId, - version: CAPABILITY_VERSION, - affinity: "session", - hostPathAccess, - label: group.label, - description: group.description, - tools: Object.freeze( - group.tools.map((tool) => +): { + readonly offers: readonly ClientCapabilityOffer[]; + readonly bindings: Map; +} { + const offers: ClientCapabilityOffer[] = []; + const bindings = new Map(); + for (const group of groups) { + const tools: ClientCapabilityToolDescriptor[] = []; + for (const tool of group.tools) { + const key = bindingKey({ + offerId: group.offerId, + serverId: group.offerId, + toolName: tool.name, + }); + if (bindings.has(key)) { + throw new Error( + `Duplicate Desktop native capability tool: ${group.offerId}/${tool.name}`, + ); + } + let inputSchema: Record; + try { + inputSchema = toolInputSchema(tool); + validateToolInputSchema(inputSchema); + } catch (error) { + // One malformed MCP descriptor must not take down the whole offer set: + // skip and name the offending tool so Browser, Computer Use, settings, + // and other MCP tools keep publishing. + console.warn( + `Skipping Desktop native capability tool ${group.offerId}/${tool.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + bindings.set(key, { tool }); + tools.push( Object.freeze({ serverId: group.offerId, name: tool.name, description: tool.description, - inputSchema: toolInputSchema(tool), + inputSchema, ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), ...(tool.displayName ? { annotations: Object.freeze({ title: tool.displayName }) } : {}), }), - ), - ), - }); + ); + } + if (tools.length === 0) continue; + offers.push( + Object.freeze({ + offerId: group.offerId, + version: CAPABILITY_VERSION, + affinity: "session", + hostPathAccess, + label: group.label, + description: group.description, + tools: Object.freeze(tools), + }), + ); + } + return { offers: Object.freeze(offers), bindings }; } function toolInputSchema(tool: MakaTool): Record { @@ -491,6 +527,12 @@ interface JsonSchemaWrapper { // MCP schema, so any constraint expressed via non-whitelisted keywords // (if/then/else, contains, dependentRequired, …) is dropped before Ajv // sees it. The downstream MCP server re-validates against the full schema. +// +// Regex constraints (pattern / patternProperties) are also omitted from the +// local validator: the schema comes from an untrusted MCP server, and Ajv +// executes those expressions synchronously on the call path, so a +// pathological expression could block the Electron main process (ReDoS). +// The MCP endpoint enforces them against the full schema. const jsonSchemaValidatorOptions = { allErrors: true, @@ -500,13 +542,70 @@ const jsonSchemaValidatorOptions = { const schemaValidator = new Ajv2020(jsonSchemaValidatorOptions); const compiledSchemas = new WeakMap(); +function adaptSchemaForLocalValidation(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((entry) => adaptSchemaForLocalValidation(entry)); + const schema = value as Record; + const result: Record = {}; + for (const [key, val] of Object.entries(schema)) { + if (key === 'pattern' || key === 'patternProperties') continue; + if (key === 'items' && Array.isArray(val)) { + // draft-07 tuple `items` does not compile under Ajv 2020-12 (which + // requires a single schema or `prefixItems`), so translate it before + // compiling. The advertised schema keeps the original tuple shape. + if ((val as unknown[]).length === 0) continue; + result.prefixItems = (val as unknown[]).map((entry) => + adaptSchemaForLocalValidation(entry), + ); + result.items = false; + continue; + } + switch (key) { + case 'properties': + case '$defs': + case 'definitions': { + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + result[key] = val; + continue; + } + const entries: Record = {}; + for (const [nestedKey, nested] of Object.entries(val as Record)) { + entries[nestedKey] = adaptSchemaForLocalValidation(nested); + } + result[key] = entries; + continue; + } + case 'allOf': + case 'anyOf': + case 'oneOf': { + result[key] = Array.isArray(val) + ? val.map((entry) => adaptSchemaForLocalValidation(entry)) + : val; + continue; + } + case 'additionalProperties': + case 'propertyNames': { + result[key] = + val !== null && typeof val === 'object' && !Array.isArray(val) + ? adaptSchemaForLocalValidation(val) + : val; + continue; + } + default: + result[key] = val; + } + } + return result; +} + function compileJsonSchema(rawSchema: Record): ValidateFunction { const cached = compiledSchemas.get(rawSchema); if (cached) return cached; const projected = projectToolInputSchema(rawSchema); + const adapted = adaptSchemaForLocalValidation(projected); let compiled: ValidateFunction; try { - compiled = schemaValidator.compile(projected as AnySchema); + compiled = schemaValidator.compile(adapted as AnySchema); } catch (error) { throw new Error( `Desktop native capability tool has an uncompilable schema: ${ @@ -572,27 +671,7 @@ function schemaErrorSummary(error: unknown): string { return message.slice(0, 1000); } -function indexBindings( - groups: readonly DesktopCapabilityGroup[], -): Map { - const bindings = new Map(); - for (const group of groups) { - for (const tool of group.tools) { - const key = bindingKey({ - offerId: group.offerId, - serverId: group.offerId, - toolName: tool.name, - }); - if (bindings.has(key)) { - throw new Error( - `Duplicate Desktop native capability tool: ${group.offerId}/${tool.name}`, - ); - } - bindings.set(key, { tool }); - } - } - return bindings; -} + function bindingKey( frame: Pick, 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 e61f823449..12d587e433 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -346,6 +346,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 4425a1756e..76aefa0597 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -830,7 +830,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'); } @@ -903,15 +903,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); @@ -965,6 +959,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 3a0a1cf2087fcfba3251d93ae9ff7420afb50c7a Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 19:55:54 +0800 Subject: [PATCH 8/9] 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 76aefa0597..ede73c3cbd 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -903,7 +903,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 a5f980851608c3d8413fc649240aef8c57cde3a8 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 20:20:24 +0800 Subject: [PATCH 9/9] fix: update candidate test for per-tool schema isolation --- .../runtime-host-desktop-candidate.test.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 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 da5c76d48b..22a1f5a5cd 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 @@ -544,7 +544,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 = { @@ -552,22 +554,19 @@ test('closes the claimed Host connection when native capability construction fai parameters: z.string(), } as unknown as MakaTool; - await assert.rejects( - () => - createDesktopRuntimeHostCandidate( - host.connection, - deps(ipc, { - browserTools: [invalidTool], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, - }), - ), - /tool schema must be an object/, + const candidate = await createDesktopRuntimeHostCandidate( + host.connection, + deps(ipc, { + browserTools: [invalidTool], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: emptyComputerUseTools(), + releaseComputerUseSession() {}, + }), ); - assert.equal(ipc.size, 0); + assert.equal(host.closeCalls, 0); + await candidate.close(); assert.equal(host.closeCalls, 1); });