From 8563d1653a772a51e865192a642b72bc346e3a26 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Wed, 2 Sep 2026 16:06:40 +0800 Subject: [PATCH 01/10] 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 89658d460a..199bcb9e69 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/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 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 404f6eac43..058757b003 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -829,6 +829,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'multipleOf', 'oneOf', 'pattern', + 'patternProperties', 'propertyNames', 'properties', 'required', @@ -896,7 +897,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 9664d99363550c40a1a0681dec3e1b8a8899dc43 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 10:20:28 +0800 Subject: [PATCH 02/10] 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 199bcb9e69..d567e8ef5c 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 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 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 058757b003..acc7019541 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -802,7 +802,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 6dc247ae5bffd2189285067b4b285658c6b4f468 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 14:18:29 +0800 Subject: [PATCH 03/10] 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 d5a69a4d1e..90c8c804aa 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 = 109 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 110 as const; +// 110: 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. // 109: accepted Client Capability invocations may carry one bounded nested form // Interaction request/result round trip. // 108: Session Interaction snapshots, forwarded Runtime events, and Agent Graph From 0ea7bd50eaf5fc5ec7eb3ed12bf0f56819fb7ab0 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:02:09 +0800 Subject: [PATCH 04/10] 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 8e0ce27338..b84876d34e 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.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 d567e8ef5c..b7d940e50b 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 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 cbbd7c34b8..bdd3c2be92 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.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 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 acc7019541..ac771b80b9 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -838,6 +838,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'); @@ -897,11 +975,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) || @@ -911,31 +984,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 769a972353f499cffedffacb8beb748ddbcbee71 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 15:43:13 +0800 Subject: [PATCH 05/10] 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 c27bf86dc5326dbe5f396aaddb89200184acde5a Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 3 Sep 2026 17:13:08 +0800 Subject: [PATCH 06/10] 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 b7d940e50b..1667d9effe 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/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 ac771b80b9..91ea3edfda 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -989,6 +989,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 126940d09fb3fec5d64a205f77444302bcbc978d Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 18:52:10 +0800 Subject: [PATCH 07/10] 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 1667d9effe..a6a9bdfcd5 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/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 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 91ea3edfda..a167b7da4c 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -916,7 +916,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'); } @@ -989,15 +989,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); @@ -1051,6 +1045,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 fe9cb46ee47f4dc4c229db488661ef38ae7df0aa Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 19:55:54 +0800 Subject: [PATCH 08/10] 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 a167b7da4c..9932a6a48d 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -989,7 +989,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 67c4b135e5d458b78a9dcf4c8720b6438b024bc1 Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Thu, 3 Sep 2026 20:20:24 +0800 Subject: [PATCH 09/10] 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 65ee0b6da9..92be2a3fed 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); }); From 5bd40930f05e0c8b51c2930219490918f4930f6f Mon Sep 17 00:00:00 2001 From: Shkin1 <240493030@qq.com> Date: Fri, 4 Sep 2026 08:26:04 +0800 Subject: [PATCH 10/10] fix: support MCP JSON Schema tools in Desktop --- apps/desktop/package.json | 2 - .../runtime-host-desktop-candidate.test.ts | 62 ++++++- .../runtime-host-native-capabilities.test.ts | 49 +++-- apps/desktop/src/main/runtime-host-boot.ts | 3 + .../main/runtime-host-native-capabilities.ts | 172 ++---------------- 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 | 13 +- 9 files changed, 164 insertions(+), 186 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b84876d34e..8e0ce27338 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", @@ -86,7 +85,6 @@ "@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-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 92be2a3fed..89743a84af 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,32 +544,86 @@ 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 () => { - // Per-tool isolation: one bad tool is skipped and the provider still - // constructs, so the Host connection stays alive. +test('isolates malformed untrusted MCP tools without dropping the Host connection', async () => { const ipc = ipcHarness(); const host = connectionHarness('invalid-capability'); + let healthyCalls = 0; const invalidTool = { ...nativeTool(), + name: 'invalid_mcp_tool', parameters: z.string(), } as unknown as MakaTool; + const healthyTool = { + ...nativeTool(), + name: 'healthy_mcp_tool', + impl: async () => { + healthyCalls += 1; + return 'healthy'; + }, + }; const candidate = await createDesktopRuntimeHostCandidate( host.connection, deps(ipc, { - browserTools: [invalidTool], + browserTools: [], resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + source: 'untrusted', + tools: [invalidTool, healthyTool], + }, + ], }), ); assert.equal(host.closeCalls, 0); + assert.deepEqual(host.capabilityRegistrations, 1); + assert.deepEqual( + await host.invokeCapability({ + ...capabilityFrame('invalid-capability'), + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'healthy_mcp_tool', + arguments: {}, + }), + { content: [{ type: 'text', text: 'healthy' }] }, + ); + assert.equal(healthyCalls, 1); await candidate.close(); assert.equal(host.closeCalls, 1); }); +test('fails fast when a trusted first-party tool schema is invalid', async () => { + const ipc = ipcHarness(); + const host = connectionHarness('invalid-first-party-capability'); + const invalidTool = { + ...nativeTool(), + parameters: z.string(), + } as unknown as MakaTool; + + await assert.rejects( + () => + createDesktopRuntimeHostCandidate( + host.connection, + deps(ipc, { + browserTools: [invalidTool], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: emptyComputerUseTools(), + releaseComputerUseSession() {}, + }), + ), + /schema must be an object/u, + ); + assert.equal(host.closeCalls, 1); +}); + test('does not release or report a Revision the Host retained during cleanup', async () => { const ipc = ipcHarness(); const host = connectionHarness('retained-revision', { revisionAbandon: 'retained' }); 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 a6a9bdfcd5..687930520e 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/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 74e68af3ee..309e428e78 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -980,6 +980,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( { offerId: "desktop_settings", label: "Client settings", + source: 'trusted' as const, description: "Read or update UI and operating-system settings owned by this Desktop client.", tools: clientSettingsTools, @@ -987,6 +988,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( { offerId: "desktop_rive", label: "Rive", + source: 'trusted' as const, description: "Use durable Rive workflows through this Desktop client.", tools: [riveWorkflowTool], @@ -997,6 +999,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( { offerId: "desktop_mcp", label: "MCP", + source: 'untrusted' as const, description: "Use MCP tools connected by this Desktop client.", tools: mcpTools, diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 9d5938ec61..388e7ed0e1 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -37,9 +37,9 @@ import { 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"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; +import { runtimeHostProcessLogBuffer } from './main-process-diagnostics.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; @@ -50,6 +50,7 @@ export interface DesktopCapabilityGroup { readonly offerId: string; readonly label: string; readonly description: string; + readonly source?: 'trusted' | 'untrusted'; readonly tools: readonly MakaTool[]; } @@ -298,6 +299,7 @@ function capabilityGroups( label: "Browser", description: "Operate the embedded browser owned by this Desktop client.", + source: 'trusted' as const, tools: input.browserTools, }, ] @@ -309,6 +311,7 @@ function capabilityGroups( label: "Computer Use", description: "Observe and operate the desktop through this Desktop client.", + source: 'trusted' as const, tools: input.computerUseTools, }, ] @@ -446,12 +449,10 @@ function buildPublishedCapabilities( 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)}`, - ); + if ((group.source ?? (group.offerId === 'desktop_mcp' ? 'untrusted' : 'trusted')) !== 'untrusted') throw error; + const diagnostic = `Skipping Desktop native capability tool ${group.offerId}/${tool.name}: ${error instanceof Error ? error.message : String(error)}`; + console.warn(diagnostic); + runtimeHostProcessLogBuffer.append('warn', diagnostic); continue; } bindings.set(key, { tool }); @@ -506,9 +507,10 @@ 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( - projectToolInputSchema(schema as Record), - ); + const root = Object.hasOwn(schema, 'type') + ? schema + : { ...schema, type: 'object' }; + return Object.freeze(projectToolInputSchema(root)); } } @@ -521,158 +523,20 @@ interface JsonSchemaWrapper { readonly jsonSchema?: Record; } -// -- 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. -// -// 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, - strict: false, - validateFormats: false, -} as const; -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(adapted 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; -} - async function parseNativeToolArguments( parameters: unknown, args: unknown, ): Promise { - if ( - !parameters || - (typeof parameters !== 'object' && typeof parameters !== 'function') - ) { - return args; - } - - // Zod schemas: validate and coerce with the schema itself. - if (parameters instanceof z.ZodType) { - 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 validator = compileJsonSchema(raw); - if (validator(args)) return args; - throw new Error( - `Invalid arguments: ${schemaErrorSummary(validator.errors)}`, - ); + if (parameters instanceof z.ZodType) return await 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'); } - return args; } -function schemaErrorSummary(error: unknown): string { - 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); - } - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : 'validation failed'; - return message.slice(0, 1000); -} - - - function bindingKey( frame: Pick, ): string { diff --git a/package-lock.json b/package-lock.json index bdd3c2be92..cbbd7c34b8 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.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/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index cb84ac0b7d..120a2e9f5a 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -97,6 +97,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 39ae42a93c..03851051ce 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -618,6 +618,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; @@ -625,11 +647,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 aedf9adae1..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; @@ -101,6 +106,7 @@ export function buildMcpTools( 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); @@ -121,7 +127,12 @@ export function buildMcpTools( categoryHint: options.categoryHint ?? 'network_send', ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), - parameters: jsonSchema(descriptor.inputSchema), + parameters: jsonSchema(inputSchema, { + validate: async (value) => { + const result = validateJsonSchemaInput(inputSchema, value); + return result; + }, + }), ...(provider.prepareTool ? { prepareExecution: async (args: unknown, context) => {