diff --git a/packages/worker/src/mcp/capabilities/define-capability.node.test.ts b/packages/worker/src/mcp/capabilities/define-capability.node.test.ts new file mode 100644 index 0000000000..f505ec0ecd --- /dev/null +++ b/packages/worker/src/mcp/capabilities/define-capability.node.test.ts @@ -0,0 +1,76 @@ +import { expect, test, vi } from 'vitest' +import { z } from 'zod' +import { McpCallerError } from '#mcp/caller-error.ts' +import { createMcpCallerContext } from '#mcp/context.ts' +import { defineCapability } from './define-capability.ts' + +function createCapabilityContext() { + return { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { + userId: 'user-1', + email: 'user@example.com', + }, + }), + } +} + +test('Zod capability validation failures identify the capability, fields, and repair path', async () => { + const inputHandler = vi.fn(async () => ({ package_id: 'github' })) + const inputCapability = defineCapability({ + name: 'package_get', + domain: 'packages', + description: 'Get a package.', + inputSchema: z.object({ package_id: z.string() }), + outputSchema: z.object({ package_id: z.string() }), + handler: inputHandler, + }) + + const inputError = await inputCapability + .handler({ kody_id: 'github' }, createCapabilityContext()) + .catch((error: unknown) => error) + + expect(inputError).toBeInstanceOf(McpCallerError) + expect(inputError).toMatchObject({ + message: expect.stringContaining( + 'Invalid input for capability "package_get".', + ), + cause: expect.any(z.ZodError), + }) + expect(inputError.message).toContain('package_id') + expect(inputError.message).toContain( + 'Call search({ entity: "package_get:capability" }) for the exact input shape.', + ) + expect(inputHandler).not.toHaveBeenCalled() + + const outputHandler = vi.fn(async () => ({ package_id: 123 }) as never) + const outputCapability = defineCapability({ + name: 'package_save', + domain: 'packages', + description: 'Save a package.', + inputSchema: z.object({ package_id: z.string() }), + outputSchema: z.object({ package_id: z.string() }), + handler: outputHandler, + }) + + const outputError = await outputCapability + .handler({ package_id: 'github' }, createCapabilityContext()) + .catch((error: unknown) => error) + + expect(outputHandler).toHaveBeenCalledOnce() + expect(outputError).toBeInstanceOf(Error) + expect(outputError).not.toBeInstanceOf(McpCallerError) + expect(outputError).toMatchObject({ + message: expect.stringContaining( + 'Capability "package_save" returned an invalid output shape.', + ), + cause: expect.any(z.ZodError), + }) + expect(outputError.message).toContain('package_id') + expect(outputError.message).toContain( + 'This is a capability implementation error; changing the input shape will not repair it.', + ) + expect(outputError.message).not.toContain('Call search') +}) diff --git a/packages/worker/src/mcp/capabilities/define-capability.ts b/packages/worker/src/mcp/capabilities/define-capability.ts index 87c38f66ea..57958872e3 100644 --- a/packages/worker/src/mcp/capabilities/define-capability.ts +++ b/packages/worker/src/mcp/capabilities/define-capability.ts @@ -4,6 +4,7 @@ import { errorFields, logMcpEvent, } from '#mcp/observability.ts' +import { McpCallerError } from '#mcp/caller-error.ts' import { type Capability, type CapabilityDefinition, @@ -118,7 +119,12 @@ export function defineCapability< try { parsedArgs = inputParser(args) as InferCapabilitySchema } catch (error) { - const { errorName, errorMessage } = errorFields(error) + const callerError = createCapabilityValidationError( + error, + definition.name, + 'input', + ) + const { errorName, errorMessage } = errorFields(callerError) logMcpEvent({ category: 'mcp', tool: 'capability', @@ -131,9 +137,9 @@ export function defineCapability< failurePhase: 'parse_input', errorName, errorMessage, - cause: error, + cause: callerError, }) - throw error + throw callerError } let result: Awaited> @@ -172,7 +178,12 @@ export function defineCapability< }) return finalized } catch (error) { - const { errorName, errorMessage } = errorFields(error) + const validationError = createCapabilityValidationError( + error, + definition.name, + 'output', + ) + const { errorName, errorMessage } = errorFields(validationError) logMcpEvent({ category: 'mcp', tool: 'capability', @@ -185,14 +196,42 @@ export function defineCapability< failurePhase: 'parse_output', errorName, errorMessage, - cause: error, + cause: validationError, }) - throw error + throw validationError } }, } } +function createCapabilityValidationError( + error: unknown, + capabilityName: string, + valueKind: 'input' | 'output', +) { + if (!(error instanceof z.ZodError)) return error + + if (valueKind === 'input') { + return new McpCallerError( + [ + `Invalid input for capability "${capabilityName}".`, + z.prettifyError(error), + `Repair: Call search({ entity: "${capabilityName}:capability" }) for the exact input shape.`, + ].join('\n'), + { cause: error }, + ) + } + + return new Error( + [ + `Capability "${capabilityName}" returned an invalid output shape.`, + z.prettifyError(error), + 'This is a capability implementation error; changing the input shape will not repair it.', + ].join('\n'), + { cause: error }, + ) +} + function mergeKeywords( keywords: Array | undefined, tags: Array | undefined,