diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts index 98fc4d7b..d3510475 100644 --- a/src/validation/wire-schema.test.ts +++ b/src/validation/wire-schema.test.ts @@ -117,6 +117,49 @@ describe('wireSchemaErrors', () => { ).toEqual([]); }); + it('accepts extension result types through the generic result envelope', () => { + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 3, + result: { + resultType: 'task', + taskId: 'task-1', + status: 'working' + } + }, + 'tools/call' + ) + ).toEqual([]); + }); + + it('names an unrecognised resultType when the result then fails its typed definition', () => { + const errors = wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { jsonrpc: '2.0', id: 3, result: { resultType: 'complet' } }, + 'tools/call' + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain('CallToolResult'); + expect(errors[0]).toContain("resultType 'complet' is not a core value"); + }); + + it('accepts an unrecognised resultType whose result satisfies the typed definition (open discriminator)', () => { + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 3, + result: { resultType: 'x-acme/streamed', content: [] } + }, + 'tools/call' + ) + ).toEqual([]); + }); + it('accepts a JSON-RPC batch under 2025-03-26 and reports per-element errors', () => { expect( wireSchemaErrors('2025-03-26', [ diff --git a/src/validation/wire-schema.ts b/src/validation/wire-schema.ts index 8b45849d..1cdbed09 100644 --- a/src/validation/wire-schema.ts +++ b/src/validation/wire-schema.ts @@ -61,6 +61,18 @@ const NON_CANONICAL_DEFS = new Set([ 'ServerMessage' ]); +/** resultType values defined by the core schema (SEP-2322). */ +const CORE_RESULT_TYPES: ReadonlySet = new Set([ + 'complete', + 'input_required' +]); +/** + * resultType values defined by known protocol extensions whose result schema + * is not vendored here yet; validated against the generic result envelope. + * - 'task': io.modelcontextprotocol/tasks CreateTaskResult (SEP-2663). + */ +const EXTENSION_RESULT_TYPES: ReadonlySet = new Set(['task']); + interface CompiledSpec { defsKey: '$defs' | 'definitions'; defs: Record>; @@ -261,18 +273,37 @@ export function wireSchemaErrors( if (msg.result !== undefined) { // SEP-2322 (MRTR): any request may be answered with an InputRequiredResult - // instead of its method's result type; discriminate on resultType. + // instead of its method's result type; discriminate on resultType. Results + // introduced by a known extension (e.g. the tasks extension's + // CreateTaskResult, resultType "task") use the same open discriminator and + // are checked against the generic result envelope until their schema is + // vendored here. Any other resultType is validated as the method's own + // result (the discriminator is open, so it may be a private extension), + // and a failure names the unrecognised value so the cause is obvious. + const resultType = (msg.result as Record | null) + ?.resultType; const inputRequired = - (msg.result as Record | null)?.resultType === - 'input_required' && 'InputRequiredResult' in spec.defs; - const resultDefName = inputRequired - ? 'InputRequiredResult' - : requestMethod !== undefined - ? spec.resultDefs.get(requestMethod) + resultType === 'input_required' && 'InputRequiredResult' in spec.defs; + const extensionResult = + typeof resultType === 'string' && EXTENSION_RESULT_TYPES.has(resultType); + const unrecognisedResultType = + typeof resultType === 'string' && + !CORE_RESULT_TYPES.has(resultType) && + !extensionResult + ? resultType : undefined; + let resultDefName: string | undefined; + if (inputRequired) { + resultDefName = 'InputRequiredResult'; + } else if (!extensionResult && requestMethod !== undefined) { + resultDefName = spec.resultDefs.get(requestMethod); + } if (resultDefName) { + const hint = unrecognisedResultType + ? `; resultType '${unrecognisedResultType}' is not a core value or a known extension result (${[...EXTENSION_RESULT_TYPES].map((t) => `'${t}'`).join(', ')}), so it was validated as ${resultDefName}` + : ''; const typed = validateAgainst(resultDefName, msg.result).map( - (e) => `${e} (result of '${requestMethod}')` + (e) => `${e} (result of '${requestMethod}')${hint}` ); if (typed.length > 0) return typed; }