diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6af22878..42d47524c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ ### Changed +- Isolated invalid optional MCP tools while publishing Desktop Client Capability + manifests: malformed tools are omitted with diagnostics, complete manifest + budgets include services, and valid JSON-Schema MCP arguments are validated + before admission. - Made typed `request()` the sole direct Runtime Host operation API; removed the 17 forwarding aliases from direct and reconnecting connections while preserving status validation, subscriptions, capabilities, listeners, lifecycle, and close behavior. diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index da520348ce..c08c04690c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -128,6 +128,118 @@ test('drives Desktop Session operations through a real Runtime Host connection', } }); +test('keeps the Desktop candidate usable when an optional MCP tool has an invalid schema', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-desktop-invalid-mcp-')); + let host: RuntimeHostKernel | undefined; + try { + const capability = await resolveStorageRoot({ path: base, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const projected = session('session-invalid-mcp'); + host = await RuntimeHostKernel.start({ + owner, + idleGraceMs: 10_000, + composition: defineInteractiveRuntimeHostComposition(async () => ({ + handlers: handlers({ + 'client.capability.replace': async (input) => { + const mcp = input.offers.find((offer) => offer.offerId === 'desktop_mcp'); + assert.deepEqual(mcp?.tools.map(({ name }) => name), ['mcp_valid']); + return { + ok: true, + result: { registrationId: input.registrationId, revision: 1 }, + }; + }, + 'client.capability.unregister': async (input) => ({ + ok: true, + result: { registrationId: input.registrationId, revision: 2 }, + }), + 'session.catalog.query': async (input) => ({ + ok: true, + result: + input.kind === 'get' + ? { kind: 'session', session: input.sessionId === projected.id ? projected : null } + : { + kind: 'page', + revision: catalogRevision('1'), + sessions: [projected], + nextCursor: null, + }, + }), + }), + beginDrain() {}, + async recover() {}, + async close() {}, + })), + }); + const ipc = ipcHarness(); + const invalidTool = { + ...nativeTool(), + name: 'mcp_invalid', + parameters: { jsonSchema: { type: 'string' } }, + } as unknown as MakaTool; + const validTool = { + ...nativeTool(), + name: 'mcp_valid', + parameters: { jsonSchema: { type: 'object', properties: {} } }, + } as unknown as MakaTool; + const started = await startDesktopRuntimeHostCandidate({ + rootPath: base, + candidateEntrypoint: new URL('file:///unused-runtime-host-candidate.js'), + ipcMain: ipc, + workspaceRoot: base, + attachmentApprovals: createAttachmentApprovalRegistry(), + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + nativeCapabilities: { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: Object.assign([], { + clearSession() {}, + }) as unknown as ComputerUseToolSet, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + dynamic: true, + tools: [invalidTool, validTool], + }, + ], + }, + botRegistry: {} as BotRegistry, + resolveBotCreateTarget: async () => ({ + workspace: { kind: 'host_path', path: base }, + }), + resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }), + emitSessionsChanged() {}, + completeComputerUseTurn() {}, + createSessionCopyCleanup: () => ({ + ownCreation: (_creation, operation) => operation(), + rejectCreation: async () => undefined, + cleanup: async () => undefined, + schedule: async () => undefined, + abandonOwner: async () => undefined, + recover: async () => ({ removed: [], failed: [] }), + }), + }); + assert.equal(started.kind, 'ready'); + if (started.kind !== 'ready') throw new Error('Desktop candidate did not start'); + const { candidate } = started; + ipc.setHost(candidate.client.hostId, ipc.epoch); + + assert.deepEqual( + ((await ipc.invoke('sessions:list')) as SessionCatalogProjection[]).map(({ id }) => id), + [projected.id], + ); + await candidate.close(); + } finally { + await host?.close().catch(() => undefined); + await rm(base, { recursive: true, force: true }); + } +}); + test('drives the renderer Session catalog facade through real UDS framing', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-desktop-host-ipc-')); let host: RuntimeHostKernel | undefined; diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 1b73728b91..eb1ce6c736 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; +import { buildMcpTools, type McpToolProvider } from '@maka/runtime/mcp-tools'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { @@ -180,6 +181,222 @@ test('publishes every production Desktop-owned tool schema through the protocol' ); }); +test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async () => { + let invocation: { args: Record; cwd: string } | undefined; + let accepted = false; + const mcpProvider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [ + { + binding: 'fixture-binding' as never, + descriptor: { + serverId: 'fixture', + name: 'lookup', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, + }, + }, + }, + ], + }), + async callTool(_binding, args, options) { + invocation = { args, cwd: options.context.cwd }; + return { content: [{ type: 'text', text: 'found' }] }; + }, + }; + const [mcpTool] = buildMcpTools(mcpProvider, { executionLocation: 'remote' }); + assert.ok(mcpTool); + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + dynamic: true, + tools: [mcpTool], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: mcpTool.name, + arguments: {}, + }), + () => { + accepted = true; + }, + ), + /Invalid arguments for tool/u, + ); + assert.equal(accepted, false); + assert.equal(invocation, undefined); + assert.deepEqual( + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: mcpTool.name, + arguments: { query: 'maka' }, + }), + () => { + accepted = true; + }, + ), + { content: [{ type: 'text', text: 'found' }] }, + ); + assert.deepEqual(invocation, { + args: { query: 'maka' }, + cwd: '/workspace', + }); + assert.equal(accepted, true); +}); + +test('chunks optional MCP tools that exceed one offer\'s tool limit', () => { + const diagnostics: string[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + dynamic: true, + tools: Array.from({ length: 65 }, (_, index) => + tool(`mcp_tool_${index + 1}`, z.object({}), async () => 'ok'), + ), + }, + ], + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + + assert.deepEqual( + provider.offers().map((offer) => [offer.offerId, offer.tools.length] as const), + [ + ['desktop_mcp', 64], + ['desktop_mcp_2', 1], + ], + ); + assert.equal(diagnostics.length, 0); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), + ); +}); + +test('omits optional MCP tools that would exceed the complete manifest byte limit', () => { + const diagnostics: string[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroup('desktop_mcp_first', 'mcp_first', 28 * 1024), + optionalMcpGroup('desktop_mcp_second', 'mcp_second', 28 * 1024), + ], + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + + assert.deepEqual(provider.offers().map(({ offerId }) => offerId), ['desktop_mcp_first']); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0] ?? '', /mcp_second/u); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), + ); +}); + +test('accounts for services when omitting optional MCP tools for the manifest budget', () => { + const diagnostics: string[] = []; + const offersOnlyProvider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroupWithTools('desktop_mcp', 'mcp_tool', 25 * 1024, 2), + ], + }); + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroupWithTools('desktop_mcp', 'mcp_tool', 25 * 1024, 2), + ], + additionalServices: () => + Array.from({ length: 32 }, (_, index) => ({ + serviceId: `service_${index}_${'x'.repeat(112)}`, + version: 'v'.repeat(64), + async call() { + return {}; + }, + })), + }, + { + targetScope: { hostId: 'host-1', targetEpoch: 'epoch-1' }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }, + ); + + assert.equal(offersOnlyProvider.offers()[0]?.tools.length, 2); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: offersOnlyProvider.offers(), + }), + ); + assert.throws(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: offersOnlyProvider.offers(), + services: provider.services?.(), + }), /manifest is too large/u); + assert.deepEqual(provider.offers()[0]?.tools.map(({ name }) => name), ['mcp_tool_1']); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0] ?? '', /mcp_tool_2/u); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + services: provider.services?.(), + }), + ); +}); + test('publishes and admits additional Desktop native-effect services', async () => { let admitted = false; const provider = createDesktopNativeCapabilityProvider( @@ -907,6 +1124,38 @@ function tool( }; } +function optionalMcpGroup(offerId: string, name: string, schemaDescriptionLength: number) { + return optionalMcpGroupWithTools(offerId, name, schemaDescriptionLength, 1); +} + +function optionalMcpGroupWithTools( + offerId: string, + name: string, + schemaDescriptionLength: number, + toolCount: number, +) { + return { + offerId, + label: 'MCP', + description: 'MCP tools', + dynamic: true as const, + tools: Array.from({ length: toolCount }, (_, index) => ({ + name: toolCount === 1 ? name : `${name}_${index + 1}`, + displayName: toolCount === 1 ? name : `${name}_${index + 1}`, + description: `${toolCount === 1 ? name : `${name}_${index + 1}`} description`, + parameters: { + jsonSchema: { + type: 'object', + description: 'x'.repeat(schemaDescriptionLength), + }, + }, + async impl() { + return 'ok'; + }, + }) as MakaTool), + }; +} + function serviceFrame(): ClientCapabilityServiceCallFrame { return { kind: 'client.capability.service_call', diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7caee1cf13..27ab9a2d89 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1038,8 +1038,8 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( serverId: identified.serverId, toolName: identified.toolName, })), - dynamic: true as const, - })), + dynamic: true as const, + })), ]; }, additionalServices: (scope) => [ diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a4df30f0f1..135f3c0abd 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -19,6 +19,10 @@ import { Buffer } from "node:buffer"; import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; +import { + jsonSchemaErrorSummary, + validateJsonSchemaInput, +} from '@maka/runtime/json-schema-validation'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import { createOAuthPresentationClientProvider, @@ -676,10 +680,20 @@ async function parseToolArguments(tool: MakaTool, args: unknown): Promise { 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..8836dd30aa 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -405,6 +405,7 @@ describe('Client Capability protocol', () => { coordinate: { type: 'array', items: [{ type: 'integer' }, { type: 'integer' }], + additionalItems: false, }, }, }, @@ -414,6 +415,32 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('invalid_additional_items', 'move'), + tools: [ + { + ...offer('invalid_additional_items', 'move').tools[0], + inputSchema: { + type: 'object', + properties: { + coordinate: { + type: 'array', + items: [{ type: 'integer' }], + additionalItems: { unsupportedKeyword: true }, + }, + }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); for (const items of [[], [{ type: 'integer' }, 'not-a-schema']]) { assert.throws( () => diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 8c7aef6717..1591258b42 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -815,6 +815,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ '$ref', 'additionalItems', 'additionalProperties', + 'additionalItems', 'allOf', 'anyOf', 'const', @@ -924,6 +925,9 @@ function validateToolInputSchema(root: Record): void { visit(schema[key]); } } + if (schema.additionalItems !== undefined && typeof schema.additionalItems !== 'boolean') { + visit(schema.additionalItems); + } if (schema.propertyNames !== undefined) { visit(schema.propertyNames); } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..e3e621c4a8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Client Capability schemas recursively validate draft-07 tuple +// `additionalItems` schemas before admission. Older peers may apply a +// different validation boundary to the same manifest. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6d44e393bc..55cf254bb9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -113,6 +113,7 @@ "./tool-result-archive-capability": "./dist/tool-result-archive-capability.js", "./tool-result-archive-resource": "./dist/tool-result-archive-resource.js", "./tool-runtime": "./dist/tool-runtime.js", + "./json-schema-validation": "./dist/json-schema-validation.js", "./web-fetch-tool": "./dist/web-fetch-tool.js", "./web-search-tool": "./dist/web-search-tool.js", "./xai-oauth-enrollment": "./dist/xai-oauth-enrollment.js" diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..5d0db70548 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -123,13 +123,11 @@ import type { UserContent, } from './model-protocol.js'; import type { ModelCallCommit } from '@maka/core/agent-run'; -import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; -import Ajv2019 from 'ajv/dist/2019.js'; -import Ajv2020 from 'ajv/dist/2020.js'; import { z } from 'zod'; import { AsyncEventQueue } from './async-queue.js'; import { AdmissionLimiter } from './admission-limiter.js'; +import { jsonSchemaErrorSummary, validateJsonSchemaInput } from './json-schema-validation.js'; import { type CodeModeExecutionResult, DEFAULT_CODE_MODE_EXECUTION_POLICY, @@ -571,16 +569,6 @@ function nestableToolSnapshot( ); } -const codeModeJsonSchemaOptions = { - allErrors: true, - strict: false, - validateFormats: false, -} as const; -const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); -const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); -const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); -const codeModeCompiledSchemas = new WeakMap(); - async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { const parameters = tool.parameters as { safeParseAsync?: ( @@ -612,29 +600,11 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis } const schema = await parameters.jsonSchema; - const validator = compileCodeModeJsonSchema(schema ?? tool.parameters); - if (!validator || validator(input)) return input; - throw invalidCodeModeToolArguments(tool.name, validator.errors); -} - -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; - const cached = codeModeCompiledSchemas.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') - ? 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; - const compiled = validator.compile(schemaForCompile as AnySchema); - codeModeCompiledSchemas.set(schema, compiled); - return compiled; + try { + return validateJsonSchemaInput(schema ?? tool.parameters, input); + } catch (error) { + throw invalidCodeModeToolArguments(tool.name, error); + } } function invalidCodeModeToolArguments(toolName: string, error: unknown): Error { @@ -654,17 +624,7 @@ function schemaErrorSummary(error: unknown): string { .join('; ') .slice(0, 1000); } - if (Array.isArray(error)) { - return (error as ErrorObject[]) - .slice(0, 5) - .map((issue) => { - const path = issue.instancePath || issue.schemaPath; - return `${path || 'input'} ${issue.message ?? 'is invalid'}`; - }) - .join('; ') - .slice(0, 1000); - } - return 'input does not match the declared schema'; + return jsonSchemaErrorSummary(error); } function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined { diff --git a/packages/runtime/src/json-schema-validation.ts b/packages/runtime/src/json-schema-validation.ts new file mode 100644 index 0000000000..10c3278619 --- /dev/null +++ b/packages/runtime/src/json-schema-validation.ts @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019.js'; +import Ajv2020 from 'ajv/dist/2020.js'; + +const jsonSchemaOptions = { + allErrors: true, + strict: false, + validateFormats: false, +} as const; +const draft7Validator = new Ajv(jsonSchemaOptions); +const draft2019Validator = new Ajv2019(jsonSchemaOptions); +const draft2020Validator = new Ajv2020(jsonSchemaOptions); +const compiledSchemas = new WeakMap(); + +/** Validate an input against a provider JSON Schema when the schema is compilable. */ +export function validateJsonSchemaInput(schema: unknown, input: unknown): unknown { + const validator = compileJsonSchema(schema); + if (!validator || validator(input)) return input; + throw validator.errors; +} + +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 schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') + ? { ...schema, $schema: dialect.replace('https://', 'http://') } + : schema; + const compiled = validator.compile(schemaForCompile as AnySchema); + compiledSchemas.set(schema, compiled); + return compiled; +} + +export function jsonSchemaErrorSummary(error: unknown): string { + if (!Array.isArray(error)) return 'input does not match the declared schema'; + return (error as ErrorObject[]) + .slice(0, 5) + .map((issue) => { + const path = issue.instancePath || issue.schemaPath; + return `${path || 'input'} ${issue.message ?? 'is invalid'}`; + }) + .join('; ') + .slice(0, 1000); +}