From 93f65f5db1194dfe6e41398b4df96a855e061283 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 05:46:27 +0800 Subject: [PATCH 1/5] fix(desktop): accept jsonSchema() tool params in native capability offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tools built by buildMcpTools() carry AI SDK jsonSchema() parameters, not Zod schemas. The Desktop native-capability provider offered and validated tools through requireZodSchema (instanceof z.ZodType), so it threw "Desktop native capability tool has an invalid schema" on the first MCP tool — no MCP tool from any configured server was ever offered to sessions on Desktop (MCP added in the TUI was unusable in the Desktop app). toolInputSchema now reads the JSON Schema directly from an AI SDK Schema (cloning it before deleting $schema / freezing, so the MCP descriptor's shared inputSchema is never mutated), and call-time validation goes through a new parseToolArguments helper that mirrors the runtime's validateDeclaredToolArgs precedence: Zod parse, AI SDK validate, or pass-through for JSON-schema-only MCP tools. Zod-based tools are unchanged. Adds a test that runs real buildMcpTools() output through the provider, covering both the offer and the call paths; it fails without the fix. Fixes #4591 Generated-by: Claude Code --- .../runtime-host-native-capabilities.test.ts | 81 +++++++++++++++++ .../main/runtime-host-native-capabilities.ts | 89 ++++++++++++++++--- 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 3e2f36007d..3040f0a200 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 @@ -22,6 +22,8 @@ import test from 'node:test'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; +import { buildMcpTools, type McpToolProvider } from '@maka/runtime/mcp-tools'; +import type { McpToolBinding } from '@maka/core/mcp'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { decodeClientCapabilityReplaceInput, @@ -133,6 +135,85 @@ test('publishes the real Computer Use schema through the Client Capability proto assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true); }); +test('offers and dispatches MCP tools whose parameters are JSON Schema, not Zod', async () => { + let receivedArgs: unknown; + const mcpProvider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [ + { + descriptor: { + serverId: 'filesystem', + name: 'read_file', + description: 'Read a file', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + binding: 'binding-1' as McpToolBinding, + }, + ], + }), + callTool: async (_binding, args) => { + receivedArgs = args; + return { content: [{ type: 'text', text: JSON.stringify(args) }] }; + }, + }; + // Real production projection: parameters become jsonSchema(...), not Zod. + const mcpTools = buildMcpTools(mcpProvider); + const mcpTool = mcpTools[0]; + assert.ok(mcpTool, 'expected buildMcpTools to project one tool'); + + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: mcpTools, + }, + ], + }); + + // Offer path: the JSON Schema is advertised verbatim and survives protocol encoding. + const offer = provider.offers().find((entry) => entry.offerId === 'desktop_mcp'); + assert.ok(offer, 'expected a desktop_mcp offer'); + const inputSchema = offer.tools[0]?.inputSchema; + assert.equal(inputSchema?.type, 'object'); + assert.deepEqual( + Object.keys((inputSchema?.properties as object | undefined) ?? {}), + ['value'], + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + + // Call path: args flow through to callTool without a Zod parser, and the MCP + // result is projected back over the protocol. + const result = await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: mcpTool.name, + arguments: { value: 'hi' }, + }), + ); + assert.deepEqual(receivedArgs, { value: 'hi' }); + assert.deepEqual(result, { + content: [{ type: 'text', text: JSON.stringify({ value: 'hi' }) }], + }); +}); + 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..9fb479ba61 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 parseToolArguments(binding.tool, frame.arguments); signal.throwIfAborted(); const sessionId = frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID @@ -448,13 +447,17 @@ function capabilityOffer( } function toolInputSchema(tool: MakaTool): Record { - const schema = toJSONSchema(requireZodSchema(tool), { - io: "input", - target: "draft-07", - unrepresentable: "any", - cycles: "ref", - reused: "inline", - }); + const parameters = tool.parameters; + const schema: Record = + parameters instanceof z.ZodType + ? (toJSONSchema(parameters, { + io: "input", + target: "draft-07", + unrepresentable: "any", + cycles: "ref", + reused: "inline", + }) as Record) + : cloneNativeToolJsonSchema(tool); delete schema.$schema; if (schema.type !== "object") { throw new Error( @@ -464,13 +467,75 @@ function toolInputSchema(tool: MakaTool): Record { return Object.freeze(schema); } -function requireZodSchema(tool: MakaTool): z.ZodType { - if (!(tool.parameters instanceof z.ZodType)) { +function cloneNativeToolJsonSchema(tool: MakaTool): Record { + const json = aiSchemaJson(tool.parameters); + if (!json) { throw new Error( `Desktop native capability tool has an invalid schema: ${tool.name}`, ); } - return tool.parameters; + // Shallow clone so deleting $schema / freezing the offer never mutates the + // MCP descriptor's shared inputSchema object. + return { ...json }; +} + +/** + * Read the JSON Schema from an AI SDK `Schema` (e.g. an MCP tool built through + * `jsonSchema()`), when it is synchronously available. Desktop offers are built + * eagerly and frozen, so an async (thenable) schema cannot be resolved here. + */ +function aiSchemaJson( + parameters: unknown, +): Record | undefined { + if (!parameters || typeof parameters !== "object") return undefined; + const json = (parameters as { jsonSchema?: unknown }).jsonSchema; + if ( + json && + typeof json === "object" && + typeof (json as { then?: unknown }).then !== "function" + ) { + return json as Record; + } + return undefined; +} + +type NativeToolValidation = + | { readonly success: true; readonly value: unknown } + | { readonly success: false; readonly error: unknown }; + +/** + * Coerce/validate incoming call arguments against a tool's declared parameters, + * mirroring the runtime's `validateDeclaredToolArgs` precedence. Zod schemas + * parse (applying defaults/transforms); AI SDK schemas validate when they carry + * a `validate` member; JSON-schema-only tools (MCP) pass their arguments + * through unchanged. + */ +async function parseToolArguments( + tool: MakaTool, + rawArgs: unknown, +): Promise { + const parameters = tool.parameters; + if (parameters instanceof z.ZodType) { + return parameters.parseAsync(rawArgs); + } + if (parameters && typeof parameters === "object") { + const validate = ( + parameters as { + validate?: ( + value: unknown, + ) => NativeToolValidation | PromiseLike; + } + ).validate; + if (typeof validate === "function") { + const result = await validate(rawArgs); + if (result.success) return result.value; + throw result.error; + } + if (aiSchemaJson(parameters)) return rawArgs; + } + throw new Error( + `Desktop native capability tool has an invalid schema: ${tool.name}`, + ); } function indexBindings( From 8cb1f53f68cb06969c5ed0fd136e6a437c3a62ac Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 12:56:19 +0800 Subject: [PATCH 2/5] fix(desktop): isolate per-tool capability offers so one bad schema is survivable Review on #4592 surfaced two gaps the jsonSchema() fix left open. The offer frame is built and sent as a unit (client-capability-channel replace()), so a single tool whose schema cannot be expressed threw for the whole registration -- dropping Browser, Computer Use, Client settings, Rive and MCP together, not just the offending tool. That is most of the #4591 severity, and it still bit any MCP tool whose schema uses a keyword outside the Client Capability allowlist (prefixItems, not, patternProperties, contentEncoding, if/then/else, deprecated) -- e.g. a pydantic tuple[...]. capabilityOffer now builds each tool's descriptor independently and probes it through the same decodeClientCapabilityReplaceInput the Runtime Host runs. An unrepresentable tool is logged and skipped; the rest of that capability -- and every other capability -- still registers. Widening the protocol allowlist is left as a separate change that needs its own protocol review. parseToolArguments had a validate branch with no producer: the only non-Zod source, buildMcpTools, calls jsonSchema() with one argument, so validate is always undefined. Dropped it, the thenable guard and the NativeToolValidation type; call-time parsing is now Zod parseAsync, else JSON-schema pass-through, else throw with a distinct message so its logs stay distinguishable from the offer path. Corrected the cloneNativeToolJsonSchema comment: MCP inputSchema is already $schema-stripped and deep-frozen upstream (packages/mcp), so the copy is defensive, not load-bearing. Adds a test: an MCP tool whose schema uses prefixItems is dropped while a valid sibling tool and every other Desktop capability still register and the frame still encodes. Fails without the isolation. Generated-by: Claude Code --- .../runtime-host-native-capabilities.test.ts | 88 ++++++++++ .../main/runtime-host-native-capabilities.ts | 154 +++++++++++------- 2 files changed, 184 insertions(+), 58 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 3040f0a200..9efd66ff5d 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 @@ -214,6 +214,94 @@ test('offers and dispatches MCP tools whose parameters are JSON Schema, not Zod' }); }); +test('drops one unrepresentable tool instead of failing every Desktop capability', () => { + const mcpProvider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [ + { + descriptor: { + serverId: 'filesystem', + name: 'read_file', + description: 'Read a file', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + binding: 'binding-read' as McpToolBinding, + }, + { + descriptor: { + serverId: 'filesystem', + name: 'read_tuple', + // `prefixItems` (a pydantic `tuple[...]` produces it) is outside the + // Client Capability schema allowlist, so this tool cannot be offered. + description: 'Uses a JSON Schema keyword the protocol rejects', + inputSchema: { + type: 'object', + properties: { + pair: { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'number' }], + }, + }, + }, + }, + binding: 'binding-tuple' as McpToolBinding, + }, + ], + }), + callTool: async () => ({ content: [] }), + }; + const mcpTools = buildMcpTools(mcpProvider); + const survivingToolName = mcpTools[0]?.name; + const droppedToolName = mcpTools[1]?.name; + assert.ok(survivingToolName && droppedToolName); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(String(args[0])); + }; + let provider: ReturnType; + try { + provider = createDesktopNativeCapabilityProvider({ + browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { offerId: 'desktop_mcp', label: 'MCP', description: 'MCP tools', tools: mcpTools }, + ], + }); + } finally { + console.warn = originalWarn; + } + + // The unrepresentable tool is dropped, yet its server and every other Desktop + // capability still register — the #4591 outage took all of them down at once. + assert.deepEqual( + provider.offers().map((offer) => offer.offerId), + ['desktop_browser', 'desktop_mcp'], + ); + const mcpOffer = provider.offers().find((offer) => offer.offerId === 'desktop_mcp'); + assert.deepEqual( + mcpOffer?.tools.map((descriptor) => descriptor.name), + [survivingToolName], + ); + assert.equal(warnings.some((line) => line.includes(droppedToolName)), true); + + // The surviving frame still encodes cleanly over the protocol. + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); +}); + 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 9fb479ba61..71f5fc1c3f 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -25,14 +25,16 @@ import { type ClientCapabilityProvider, type OAuthPresentationBackend, } from "@maka/runtime-host/client"; -import type { - ClientCapabilityCallFrame, - ClientCapabilityCallResult, - ClientCapabilityContentBlock, - ClientCapabilityHostPathAccess, - ClientCapabilityOffer, - ClientCapabilityServiceCallFrame, - ClientCapabilityServiceOffer, +import { + decodeClientCapabilityReplaceInput, + type ClientCapabilityCallFrame, + type ClientCapabilityCallResult, + type ClientCapabilityContentBlock, + type ClientCapabilityHostPathAccess, + type ClientCapabilityOffer, + type ClientCapabilityServiceCallFrame, + type ClientCapabilityServiceOffer, + type ClientCapabilityToolDescriptor, } from "@maka/runtime-host/protocol"; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; @@ -41,6 +43,9 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; const BROWSER_OFFER_ID = "desktop_browser"; const COMPUTER_USE_OFFER_ID = "desktop_computer_use"; +// Registration id used only to validate a single tool's descriptor against the +// protocol before advertising it; never sent to a Runtime Host. +const CAPABILITY_PROBE_REGISTRATION_ID = "desktop-capability-offer-probe"; export interface DesktopCapabilityGroup { readonly offerId: string; @@ -115,7 +120,11 @@ export function createDesktopNativeCapabilityProvider( const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; const offers = Object.freeze( - groups.map((group) => capabilityOffer(group, hostPathAccess)), + groups + .map((group) => capabilityOffer(group, hostPathAccess)) + .filter( + (offer): offer is ClientCapabilityOffer => offer !== undefined, + ), ); const bindings = indexBindings(groups); const oauthPresentation = input.oauthPresentation @@ -421,29 +430,76 @@ function abortInvocations( function capabilityOffer( group: DesktopCapabilityGroup, hostPathAccess: ClientCapabilityHostPathAccess, -): ClientCapabilityOffer { - return Object.freeze({ +): ClientCapabilityOffer | undefined { + const base = { offerId: group.offerId, version: CAPABILITY_VERSION, - affinity: "session", + affinity: "session" as const, hostPathAccess, label: group.label, description: group.description, - tools: Object.freeze( - group.tools.map((tool) => - Object.freeze({ - serverId: group.offerId, - name: tool.name, - description: tool.description, - inputSchema: toolInputSchema(tool), - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - ...(tool.displayName - ? { annotations: Object.freeze({ title: tool.displayName }) } - : {}), - }), - ), - ), - }); + }; + const tools = group.tools + .map((tool) => offerableToolDescriptor(group, tool, base)) + .filter( + (tool): tool is ClientCapabilityToolDescriptor => tool !== undefined, + ); + if (tools.length === 0) { + console.warn( + `[capabilities] Desktop capability ${group.offerId} has no representable tools; not advertising it`, + ); + return undefined; + } + return Object.freeze({ ...base, tools: Object.freeze(tools) }); +} + +/** + * Build one tool's protocol descriptor, or skip it. `replace()` sends the whole + * registration as a single frame (`client-capability-channel.ts`), so a tool + * whose schema cannot be expressed — an unrepresentable Zod type, or a JSON + * Schema keyword outside the protocol's allowlist — must not throw from here: + * that would drop every Desktop capability at once (Browser, Computer Use, + * Client settings, Rive and MCP), which is exactly the #4591 outage. Each tool + * is validated on its own through the same decoder the Runtime Host runs, so an + * unrepresentable tool costs only itself and is logged rather than fatal. + */ +function offerableToolDescriptor( + group: DesktopCapabilityGroup, + tool: MakaTool, + base: { + readonly offerId: string; + readonly version: string; + readonly affinity: "session"; + readonly hostPathAccess: ClientCapabilityHostPathAccess; + readonly label: string; + readonly description: string; + }, +): ClientCapabilityToolDescriptor | undefined { + try { + const descriptor: ClientCapabilityToolDescriptor = Object.freeze({ + serverId: group.offerId, + name: tool.name, + description: tool.description, + inputSchema: toolInputSchema(tool), + ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), + ...(tool.displayName + ? { annotations: Object.freeze({ title: tool.displayName }) } + : {}), + }); + // Validate this single tool exactly as the receiving side will, so an + // unrepresentable descriptor is dropped now instead of rejecting the frame. + decodeClientCapabilityReplaceInput({ + registrationId: CAPABILITY_PROBE_REGISTRATION_ID, + offers: [{ ...base, tools: [descriptor] }], + }); + return descriptor; + } catch (error) { + console.warn( + `[capabilities] Skipping Desktop tool ${group.offerId}/${tool.name}: its schema is not representable over the Client Capability protocol`, + error, + ); + return undefined; + } } function toolInputSchema(tool: MakaTool): Record { @@ -474,41 +530,37 @@ function cloneNativeToolJsonSchema(tool: MakaTool): Record { `Desktop native capability tool has an invalid schema: ${tool.name}`, ); } - // Shallow clone so deleting $schema / freezing the offer never mutates the - // MCP descriptor's shared inputSchema object. + // Copy defensively before the offer deletes `$schema` and freezes the result, + // so we never mutate the source object. Today the only non-Zod source is an + // MCP tool, whose `inputSchema` is already `$schema`-stripped and deep-frozen + // upstream (`packages/mcp/src/index.ts`), so this copy is belt-and-suspenders + // rather than load-bearing — but Desktop must not depend on that invariant. return { ...json }; } /** * Read the JSON Schema from an AI SDK `Schema` (e.g. an MCP tool built through * `jsonSchema()`), when it is synchronously available. Desktop offers are built - * eagerly and frozen, so an async (thenable) schema cannot be resolved here. + * eagerly and frozen; an async (thenable) schema is not resolved here and is + * rejected downstream by `toolInputSchema`'s object-shape check. */ function aiSchemaJson( parameters: unknown, ): Record | undefined { if (!parameters || typeof parameters !== "object") return undefined; const json = (parameters as { jsonSchema?: unknown }).jsonSchema; - if ( - json && - typeof json === "object" && - typeof (json as { then?: unknown }).then !== "function" - ) { + if (json && typeof json === "object") { return json as Record; } return undefined; } -type NativeToolValidation = - | { readonly success: true; readonly value: unknown } - | { readonly success: false; readonly error: unknown }; - /** * Coerce/validate incoming call arguments against a tool's declared parameters, * mirroring the runtime's `validateDeclaredToolArgs` precedence. Zod schemas - * parse (applying defaults/transforms); AI SDK schemas validate when they carry - * a `validate` member; JSON-schema-only tools (MCP) pass their arguments - * through unchanged. + * parse (applying defaults/transforms); JSON-schema-only tools (MCP) carry no + * client-side validator, so their arguments pass through unchanged and are + * validated by the receiving Runtime Host against the same schema. */ async function parseToolArguments( tool: MakaTool, @@ -518,23 +570,9 @@ async function parseToolArguments( if (parameters instanceof z.ZodType) { return parameters.parseAsync(rawArgs); } - if (parameters && typeof parameters === "object") { - const validate = ( - parameters as { - validate?: ( - value: unknown, - ) => NativeToolValidation | PromiseLike; - } - ).validate; - if (typeof validate === "function") { - const result = await validate(rawArgs); - if (result.success) return result.value; - throw result.error; - } - if (aiSchemaJson(parameters)) return rawArgs; - } + if (aiSchemaJson(parameters)) return rawArgs; throw new Error( - `Desktop native capability tool has an invalid schema: ${tool.name}`, + `Desktop native capability tool cannot parse call arguments: ${tool.name}`, ); } From f357b53edb546a269d03f8f42bb61ff87f3fa54e Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 13:15:12 +0800 Subject: [PATCH 3/5] test(desktop): update candidate cleanup test for per-tool offer isolation "closes the claimed Host connection when native capability construction fails" triggered the failure with an unrepresentable tool schema. With per-tool offer isolation such a tool is now skipped and warned rather than fatal, so construction succeeds and the expected rejection never fired. Switch the trigger to a genuine construction error -- two tools colliding on one name -- so the connection-cleanup contract is still exercised. Generated-by: Claude Code --- .../runtime-host-desktop-candidate.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index da5c76d48b..4a487444fd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -547,24 +547,25 @@ test('rolls back only candidate-owned IPC after a registration collision', async test('closes the claimed Host connection when native capability construction fails', async () => { const ipc = ipcHarness(); const host = connectionHarness('invalid-capability'); - const invalidTool = { - ...nativeTool(), - parameters: z.string(), - } as unknown as MakaTool; + // A native-capability construction failure must still tear down the claimed + // Host connection. A tool whose *schema* is unrepresentable no longer fails + // construction — it is skipped and warned per-tool so one bad tool cannot + // drop every Desktop capability (see runtime-host-native-capabilities.test.ts) + // — so trigger a genuine construction error: two tools colliding on one name. await assert.rejects( () => createDesktopRuntimeHostCandidate( host.connection, deps(ipc, { - browserTools: [invalidTool], + browserTools: [nativeTool(), nativeTool()], resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, }), ), - /tool schema must be an object/, + /Duplicate Desktop native capability tool/, ); assert.equal(ipc.size, 0); From 42b74335860ecf0decd6422ce0973448fe640839 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 18:55:55 +0800 Subject: [PATCH 4/5] fix(desktop): bind only advertised tools; validate capability metadata separately Addresses two gaps in the per-tool offer isolation (review of #4592). P2: bindings were built from the raw groups, so a tool dropped from its offer (unrepresentable schema) stayed dispatchable via provider.call() even though it was never advertised. Build bindings from the resolved offers instead, so the provider only dispatches a tool it actually advertised. P3: the per-tool probe validated group metadata (offerId, label) together with the tool, so a misconfigured group reported every tool as having an unrepresentable schema. Validate offer-level metadata once, separately, and probe each tool against constant known-valid metadata, so group-level and tool-level failures are diagnosed distinctly. Adds tests: a dropped tool is not dispatchable; a capability with invalid metadata is skipped with a metadata-level diagnostic, not a tool-schema one. Aggregate protocol limits (>64 tools/offer, 256 total, 56 KiB manifest) can still fail the whole registration for a large MCP config; tracked in #4652. Generated-by: Claude Code --- .../runtime-host-native-capabilities.test.ts | 114 ++++++++++++++ .../main/runtime-host-native-capabilities.ts | 145 +++++++++++++----- 2 files changed, 220 insertions(+), 39 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 9efd66ff5d..411c5e8e6c 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 @@ -302,6 +302,120 @@ test('drops one unrepresentable tool instead of failing every Desktop capability ); }); +test('does not dispatch a tool that was dropped from its offer', async () => { + const mcpProvider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [ + { + descriptor: { + serverId: 'filesystem', + name: 'read_file', + description: 'Read a file', + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + }, + binding: 'binding-ok' as McpToolBinding, + }, + { + descriptor: { + serverId: 'filesystem', + name: 'read_tuple', + description: 'Uses a JSON Schema keyword the protocol rejects', + inputSchema: { + type: 'object', + properties: { pair: { type: 'array', prefixItems: [{ type: 'string' }] } }, + }, + }, + binding: 'binding-bad' as McpToolBinding, + }, + ], + }), + callTool: async () => ({ content: [{ type: 'text', text: 'should not run' }] }), + }; + const mcpTools = buildMcpTools(mcpProvider); + const droppedName = mcpTools[1]?.name; + assert.ok(droppedName); + + const originalWarn = console.warn; + console.warn = () => {}; + let provider: ReturnType; + try { + provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { offerId: 'desktop_mcp', label: 'MCP', description: 'MCP tools', tools: mcpTools }, + ], + }); + } finally { + console.warn = originalWarn; + } + + const mcpOffer = provider.offers().find((offer) => offer.offerId === 'desktop_mcp'); + assert.equal(mcpOffer?.tools.some((descriptor) => descriptor.name === droppedName), false); + // A tool that was never advertised must not be dispatchable, even though the + // MakaTool still exists in the source group — bindings track the advertised + // snapshot, not the raw group. + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: droppedName, + arguments: { pair: ['x'] }, + }), + ), + /not offered/u, + ); +}); + +test('skips a capability with invalid metadata without blaming its tool schemas', () => { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(String(args[0])); + }; + let provider: ReturnType; + try { + provider = createDesktopNativeCapabilityProvider({ + browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + // Invalid offerId — a caller misconfiguration, not a tool-schema + // problem. Its one tool has a perfectly valid schema. + { + offerId: 'bad offer id!', + label: 'Bad', + description: 'bad', + tools: [tool('fine_tool', z.object({}), async () => 'ok')], + }, + ], + }); + } finally { + console.warn = originalWarn; + } + + // The misconfigured capability is dropped; Browser still registers. + assert.deepEqual( + provider.offers().map((offer) => offer.offerId), + ['desktop_browser'], + ); + // The diagnostic blames the capability's metadata, not the tool's schema. + assert.equal( + warnings.some((line) => line.includes('bad offer id!') && line.includes('offer metadata')), + true, + ); + assert.equal(warnings.some((line) => line.includes('fine_tool')), 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 71f5fc1c3f..dbfca992a5 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -43,9 +43,11 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; const BROWSER_OFFER_ID = "desktop_browser"; const COMPUTER_USE_OFFER_ID = "desktop_computer_use"; -// Registration id used only to validate a single tool's descriptor against the -// protocol before advertising it; never sent to a Runtime Host. +// Identifiers used only to validate a capability's metadata or a single tool's +// descriptor against the protocol before advertising it; never sent to a Host. const CAPABILITY_PROBE_REGISTRATION_ID = "desktop-capability-offer-probe"; +const CAPABILITY_PROBE_OFFER_ID = "desktop-capability-tool-probe"; +const CAPABILITY_PROBE_LABEL = "probe"; export interface DesktopCapabilityGroup { readonly offerId: string; @@ -58,6 +60,26 @@ interface NativeToolBinding { readonly tool: MakaTool; } +/** Offer-level metadata shared by a capability's tools (no tool descriptors). */ +interface OfferMetadata { + readonly offerId: string; + readonly version: string; + readonly affinity: "session"; + readonly hostPathAccess: ClientCapabilityHostPathAccess; + readonly label: string; + readonly description: string; +} + +/** + * An advertised offer paired with the Maka tools that survived validation, in + * the same order as `offer.tools`. Bindings are built from this so the provider + * only ever dispatches a tool it actually advertised. + */ +interface ResolvedCapability { + readonly offer: ClientCapabilityOffer; + readonly tools: readonly MakaTool[]; +} + type DesktopToolModelOutput = Awaited< ReturnType> >; @@ -119,14 +141,11 @@ export function createDesktopNativeCapabilityProvider( ): DesktopNativeCapabilityProvider { const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; - const offers = Object.freeze( - groups - .map((group) => capabilityOffer(group, hostPathAccess)) - .filter( - (offer): offer is ClientCapabilityOffer => offer !== undefined, - ), - ); - const bindings = indexBindings(groups); + const resolved = groups + .map((group) => capabilityOffer(group, hostPathAccess)) + .filter((entry): entry is ResolvedCapability => entry !== undefined); + const offers = Object.freeze(resolved.map((entry) => entry.offer)); + const bindings = indexBindings(resolved); const oauthPresentation = input.oauthPresentation ? createOAuthPresentationClientProvider(input.oauthPresentation) : undefined; @@ -430,27 +449,75 @@ function abortInvocations( function capabilityOffer( group: DesktopCapabilityGroup, hostPathAccess: ClientCapabilityHostPathAccess, -): ClientCapabilityOffer | undefined { - const base = { +): ResolvedCapability | undefined { + const base: OfferMetadata = { offerId: group.offerId, version: CAPABILITY_VERSION, - affinity: "session" as const, + affinity: "session", hostPathAccess, label: group.label, description: group.description, }; - const tools = group.tools - .map((tool) => offerableToolDescriptor(group, tool, base)) - .filter( - (tool): tool is ClientCapabilityToolDescriptor => tool !== undefined, - ); - if (tools.length === 0) { + // Validate the offer-level metadata once, apart from any tool, so a + // misconfigured group (a caller bug — bad offerId, over-long label, …) is + // reported as such instead of blaming every tool's schema. + if (!capabilityMetadataValid(group, base)) return undefined; + + const surviving: { + readonly descriptor: ClientCapabilityToolDescriptor; + readonly tool: MakaTool; + }[] = []; + for (const tool of group.tools) { + const descriptor = offerableToolDescriptor(group, tool); + if (descriptor) surviving.push({ descriptor, tool }); + } + if (surviving.length === 0) { console.warn( `[capabilities] Desktop capability ${group.offerId} has no representable tools; not advertising it`, ); return undefined; } - return Object.freeze({ ...base, tools: Object.freeze(tools) }); + const offer = Object.freeze({ + ...base, + tools: Object.freeze(surviving.map((entry) => entry.descriptor)), + }); + return { offer, tools: surviving.map((entry) => entry.tool) }; +} + +/** + * Validate a capability's offer-level metadata (offerId, label, version, Host + * path access) in isolation, using one synthetic always-valid tool. Keeps a + * misconfigured group from being misreported as an unrepresentable tool schema + * on every tool it holds. + */ +function capabilityMetadataValid( + group: DesktopCapabilityGroup, + base: OfferMetadata, +): boolean { + try { + decodeClientCapabilityReplaceInput({ + registrationId: CAPABILITY_PROBE_REGISTRATION_ID, + offers: [ + { + ...base, + tools: [ + { + serverId: group.offerId, + name: "probe", + inputSchema: { type: "object" }, + }, + ], + }, + ], + }); + return true; + } catch (error) { + console.warn( + `[capabilities] Skipping Desktop capability ${group.offerId}: its offer metadata is not representable over the Client Capability protocol`, + error, + ); + return false; + } } /** @@ -460,20 +527,13 @@ function capabilityOffer( * Schema keyword outside the protocol's allowlist — must not throw from here: * that would drop every Desktop capability at once (Browser, Computer Use, * Client settings, Rive and MCP), which is exactly the #4591 outage. Each tool - * is validated on its own through the same decoder the Runtime Host runs, so an - * unrepresentable tool costs only itself and is logged rather than fatal. + * is validated on its own against constant, known-valid offer metadata (group + * metadata is checked separately) so an unrepresentable tool costs only itself + * and is logged rather than fatal. */ function offerableToolDescriptor( group: DesktopCapabilityGroup, tool: MakaTool, - base: { - readonly offerId: string; - readonly version: string; - readonly affinity: "session"; - readonly hostPathAccess: ClientCapabilityHostPathAccess; - readonly label: string; - readonly description: string; - }, ): ClientCapabilityToolDescriptor | undefined { try { const descriptor: ClientCapabilityToolDescriptor = Object.freeze({ @@ -486,11 +546,18 @@ function offerableToolDescriptor( ? { annotations: Object.freeze({ title: tool.displayName }) } : {}), }); - // Validate this single tool exactly as the receiving side will, so an - // unrepresentable descriptor is dropped now instead of rejecting the frame. decodeClientCapabilityReplaceInput({ registrationId: CAPABILITY_PROBE_REGISTRATION_ID, - offers: [{ ...base, tools: [descriptor] }], + offers: [ + { + offerId: CAPABILITY_PROBE_OFFER_ID, + version: CAPABILITY_VERSION, + affinity: "session", + hostPathAccess: "cwd", + label: CAPABILITY_PROBE_LABEL, + tools: [descriptor], + }, + ], }); return descriptor; } catch (error) { @@ -577,19 +644,19 @@ async function parseToolArguments( } function indexBindings( - groups: readonly DesktopCapabilityGroup[], + resolved: readonly ResolvedCapability[], ): Map { const bindings = new Map(); - for (const group of groups) { - for (const tool of group.tools) { + for (const { offer, tools } of resolved) { + for (const tool of tools) { const key = bindingKey({ - offerId: group.offerId, - serverId: group.offerId, + offerId: offer.offerId, + serverId: offer.offerId, toolName: tool.name, }); if (bindings.has(key)) { throw new Error( - `Duplicate Desktop native capability tool: ${group.offerId}/${tool.name}`, + `Duplicate Desktop native capability tool: ${offer.offerId}/${tool.name}`, ); } bindings.set(key, { tool }); From e4ab066a761c004caaf8c065bc4812110591b1c1 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Fri, 4 Sep 2026 04:24:00 +0800 Subject: [PATCH 5/5] refactor(desktop): drop offer-metadata probe; pin offered tool names in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the remaining review notes on #4592 while keeping the per-tool isolation boundary (aggregate protocol limits stay tracked in #4652, so this now reads Refs #4591 rather than Fixes). P3: the separate offer-metadata probe (`capabilityMetadataValid`, the `OfferMetadata` type and two probe constants, ~45 lines) only bought a distinct log line for a Desktop coding error that cannot reach production — every production offerId is a literal. Drop it and probe each tool inside its group's real offer metadata, as the first round did: a group whose metadata is invalid still drops all of its tools and is shed whole, just with N warnings instead of one. P2: `publishes every production Desktop-owned tool schema through the protocol` asserted only `doesNotThrow`, which a silently dropped tool now passes. Assert the offered tool names per offer so a future Desktop-owned tool with an unrepresentable schema fails CI instead of vanishing green. P3: `parseToolArguments` claimed MCP arguments are "validated by the receiving Runtime Host against the same schema". They are not — the Host rebuilds these with `buildMcpTools` (no client-side validator); the owning MCP server is the validator, as for the TUI. Corrected the comment. Generated-by: Claude Code --- .../runtime-host-native-capabilities.test.ts | 41 ++++--- .../main/runtime-host-native-capabilities.ts | 102 +++++------------- 2 files changed, 53 insertions(+), 90 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 411c5e8e6c..04be65170a 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 @@ -374,12 +374,9 @@ test('does not dispatch a tool that was dropped from its offer', async () => { ); }); -test('skips a capability with invalid metadata without blaming its tool schemas', () => { - const warnings: string[] = []; +test('sheds a capability whose offer metadata is invalid without failing the rest', () => { const originalWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(String(args[0])); - }; + console.warn = () => {}; let provider: ReturnType; try { provider = createDesktopNativeCapabilityProvider({ @@ -389,8 +386,9 @@ test('skips a capability with invalid metadata without blaming its tool schemas' computerUseTools: computerTools(), releaseComputerUseSession() {}, additionalGroups: () => [ - // Invalid offerId — a caller misconfiguration, not a tool-schema - // problem. Its one tool has a perfectly valid schema. + // Invalid offerId — a caller misconfiguration. Its one tool has a + // perfectly valid schema, yet the group cannot be advertised, so the + // whole group is dropped while every other capability still registers. { offerId: 'bad offer id!', label: 'Bad', @@ -403,17 +401,18 @@ test('skips a capability with invalid metadata without blaming its tool schemas' console.warn = originalWarn; } - // The misconfigured capability is dropped; Browser still registers. + // The misconfigured capability is dropped; Browser still registers, and the + // surviving frame encodes cleanly over the protocol. assert.deepEqual( provider.offers().map((offer) => offer.offerId), ['desktop_browser'], ); - // The diagnostic blames the capability's metadata, not the tool's schema. - assert.equal( - warnings.some((line) => line.includes('bad offer id!') && line.includes('offer metadata')), - true, + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), ); - assert.equal(warnings.some((line) => line.includes('fine_tool')), false); }); test('publishes every production Desktop-owned tool schema through the protocol', () => { @@ -428,6 +427,7 @@ test('publishes every production Desktop-owned tool schema through the protocol' return false; }, }); + const riveTools = [buildRiveWorkflowTool()]; const provider = createDesktopNativeCapabilityProvider({ browserTools: [], resolveBrowserUrl: () => 'https://example.com/', @@ -445,7 +445,7 @@ test('publishes every production Desktop-owned tool schema through the protocol' offerId: 'desktop_rive', label: 'Rive', description: 'Rive workflows', - tools: [buildRiveWorkflowTool()], + tools: riveTools, }, ], }); @@ -456,6 +456,19 @@ test('publishes every production Desktop-owned tool schema through the protocol' offers: provider.offers(), }), ); + // Assert the tools by name, not just `doesNotThrow`: a tool dropped for an + // unrepresentable schema now vanishes silently rather than throwing, so this + // is what makes such a regression fail CI instead of passing green. + assert.deepEqual( + provider.offers().map((offer) => ({ + offerId: offer.offerId, + toolNames: offer.tools.map((descriptor) => descriptor.name), + })), + [ + { offerId: 'desktop_settings', toolNames: settingsTools.map((entry) => entry.name) }, + { offerId: 'desktop_rive', toolNames: riveTools.map((entry) => entry.name) }, + ], + ); }); test('publishes and admits additional Desktop native-effect services', async () => { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index dbfca992a5..def1b4faf0 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -43,11 +43,9 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; const BROWSER_OFFER_ID = "desktop_browser"; const COMPUTER_USE_OFFER_ID = "desktop_computer_use"; -// Identifiers used only to validate a capability's metadata or a single tool's -// descriptor against the protocol before advertising it; never sent to a Host. -const CAPABILITY_PROBE_REGISTRATION_ID = "desktop-capability-offer-probe"; -const CAPABILITY_PROBE_OFFER_ID = "desktop-capability-tool-probe"; -const CAPABILITY_PROBE_LABEL = "probe"; +// A registration id used only to validate a single tool's descriptor against +// the protocol before advertising it; never sent to a Host. +const CAPABILITY_PROBE_REGISTRATION_ID = "desktop-capability-tool-probe"; export interface DesktopCapabilityGroup { readonly offerId: string; @@ -60,16 +58,6 @@ interface NativeToolBinding { readonly tool: MakaTool; } -/** Offer-level metadata shared by a capability's tools (no tool descriptors). */ -interface OfferMetadata { - readonly offerId: string; - readonly version: string; - readonly affinity: "session"; - readonly hostPathAccess: ClientCapabilityHostPathAccess; - readonly label: string; - readonly description: string; -} - /** * An advertised offer paired with the Maka tools that survived validation, in * the same order as `offer.tools`. Bindings are built from this so the provider @@ -450,25 +438,12 @@ function capabilityOffer( group: DesktopCapabilityGroup, hostPathAccess: ClientCapabilityHostPathAccess, ): ResolvedCapability | undefined { - const base: OfferMetadata = { - offerId: group.offerId, - version: CAPABILITY_VERSION, - affinity: "session", - hostPathAccess, - label: group.label, - description: group.description, - }; - // Validate the offer-level metadata once, apart from any tool, so a - // misconfigured group (a caller bug — bad offerId, over-long label, …) is - // reported as such instead of blaming every tool's schema. - if (!capabilityMetadataValid(group, base)) return undefined; - const surviving: { readonly descriptor: ClientCapabilityToolDescriptor; readonly tool: MakaTool; }[] = []; for (const tool of group.tools) { - const descriptor = offerableToolDescriptor(group, tool); + const descriptor = offerableToolDescriptor(group, tool, hostPathAccess); if (descriptor) surviving.push({ descriptor, tool }); } if (surviving.length === 0) { @@ -477,49 +452,18 @@ function capabilityOffer( ); return undefined; } - const offer = Object.freeze({ - ...base, + const offer: ClientCapabilityOffer = Object.freeze({ + offerId: group.offerId, + version: CAPABILITY_VERSION, + affinity: "session" as const, + hostPathAccess, + label: group.label, + description: group.description, tools: Object.freeze(surviving.map((entry) => entry.descriptor)), }); return { offer, tools: surviving.map((entry) => entry.tool) }; } -/** - * Validate a capability's offer-level metadata (offerId, label, version, Host - * path access) in isolation, using one synthetic always-valid tool. Keeps a - * misconfigured group from being misreported as an unrepresentable tool schema - * on every tool it holds. - */ -function capabilityMetadataValid( - group: DesktopCapabilityGroup, - base: OfferMetadata, -): boolean { - try { - decodeClientCapabilityReplaceInput({ - registrationId: CAPABILITY_PROBE_REGISTRATION_ID, - offers: [ - { - ...base, - tools: [ - { - serverId: group.offerId, - name: "probe", - inputSchema: { type: "object" }, - }, - ], - }, - ], - }); - return true; - } catch (error) { - console.warn( - `[capabilities] Skipping Desktop capability ${group.offerId}: its offer metadata is not representable over the Client Capability protocol`, - error, - ); - return false; - } -} - /** * Build one tool's protocol descriptor, or skip it. `replace()` sends the whole * registration as a single frame (`client-capability-channel.ts`), so a tool @@ -527,13 +471,16 @@ function capabilityMetadataValid( * Schema keyword outside the protocol's allowlist — must not throw from here: * that would drop every Desktop capability at once (Browser, Computer Use, * Client settings, Rive and MCP), which is exactly the #4591 outage. Each tool - * is validated on its own against constant, known-valid offer metadata (group - * metadata is checked separately) so an unrepresentable tool costs only itself - * and is logged rather than fatal. + * is validated on its own, inside its group's real offer metadata, so an + * unrepresentable tool costs only itself and is logged rather than fatal. A + * group whose metadata is itself invalid (a caller bug: every production + * offerId is a literal) drops all of its tools the same way, so the group is + * still shed rather than advertised. */ function offerableToolDescriptor( group: DesktopCapabilityGroup, tool: MakaTool, + hostPathAccess: ClientCapabilityHostPathAccess, ): ClientCapabilityToolDescriptor | undefined { try { const descriptor: ClientCapabilityToolDescriptor = Object.freeze({ @@ -550,11 +497,12 @@ function offerableToolDescriptor( registrationId: CAPABILITY_PROBE_REGISTRATION_ID, offers: [ { - offerId: CAPABILITY_PROBE_OFFER_ID, + offerId: group.offerId, version: CAPABILITY_VERSION, affinity: "session", - hostPathAccess: "cwd", - label: CAPABILITY_PROBE_LABEL, + hostPathAccess, + label: group.label, + description: group.description, tools: [descriptor], }, ], @@ -562,7 +510,7 @@ function offerableToolDescriptor( return descriptor; } catch (error) { console.warn( - `[capabilities] Skipping Desktop tool ${group.offerId}/${tool.name}: its schema is not representable over the Client Capability protocol`, + `[capabilities] Skipping Desktop tool ${group.offerId}/${tool.name}: it cannot be offered over the Client Capability protocol`, error, ); return undefined; @@ -626,8 +574,10 @@ function aiSchemaJson( * Coerce/validate incoming call arguments against a tool's declared parameters, * mirroring the runtime's `validateDeclaredToolArgs` precedence. Zod schemas * parse (applying defaults/transforms); JSON-schema-only tools (MCP) carry no - * client-side validator, so their arguments pass through unchanged and are - * validated by the receiving Runtime Host against the same schema. + * client-side validator, so their arguments pass through unchanged — the + * receiving Runtime Host rebuilds these tools with `buildMcpTools` and likewise + * does not validate them, so the owning MCP server is the validator, as it is + * for the TUI. */ async function parseToolArguments( tool: MakaTool,