From bc25534e156bef3a775dde7627831ede4e8c288f Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 26 Aug 2026 20:57:09 +0800 Subject: [PATCH 1/2] fix(validation): accept extension result envelopes This fixes #424 Signed-off-by: King Star --- src/validation/wire-schema.test.ts | 18 ++++++++++++++++++ src/validation/wire-schema.ts | 24 ++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts index 98fc4d7b..0c8a321b 100644 --- a/src/validation/wire-schema.test.ts +++ b/src/validation/wire-schema.test.ts @@ -117,6 +117,24 @@ 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('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..bf3dc5f9 100644 --- a/src/validation/wire-schema.ts +++ b/src/validation/wire-schema.ts @@ -261,15 +261,23 @@ 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 extensions use the same open discriminator and are checked + // against the generic result envelope until their schema is available here. + 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) - : undefined; + resultType === 'input_required' && 'InputRequiredResult' in spec.defs; + const extensionResult = + typeof resultType === 'string' && + resultType !== 'complete' && + resultType !== 'input_required'; + let resultDefName: string | undefined; + if (inputRequired) { + resultDefName = 'InputRequiredResult'; + } else if (!extensionResult && requestMethod !== undefined) { + resultDefName = spec.resultDefs.get(requestMethod); + } if (resultDefName) { const typed = validateAgainst(resultDefName, msg.result).map( (e) => `${e} (result of '${requestMethod}')` From 49bea9e98a91fdf833e44c094cd62dad6d457335 Mon Sep 17 00:00:00 2001 From: triage Date: Sun, 6 Sep 2026 19:11:06 +0000 Subject: [PATCH 2/2] validation: only known extension resultTypes take the envelope-only path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict the generic-envelope fallback to resultType values a known extension defines (today: 'task', the tasks extension's CreateTaskResult). Any other non-core resultType is still validated as the request method's own result — the discriminator is open, so it may be a private extension — and a resulting violation names the unrecognised value so a typo or an unknown extension is obvious instead of silently passing envelope-only. Co-Authored-By: Claude --- src/validation/wire-schema.test.ts | 25 ++++++++++++++++++++++ src/validation/wire-schema.ts | 33 +++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts index 0c8a321b..d3510475 100644 --- a/src/validation/wire-schema.test.ts +++ b/src/validation/wire-schema.test.ts @@ -135,6 +135,31 @@ describe('wireSchemaErrors', () => { ).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 bf3dc5f9..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>; @@ -262,16 +274,24 @@ 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. Results - // introduced by extensions use the same open discriminator and are checked - // against the generic result envelope until their schema is available here. + // 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 = resultType === 'input_required' && 'InputRequiredResult' in spec.defs; const extensionResult = + typeof resultType === 'string' && EXTENSION_RESULT_TYPES.has(resultType); + const unrecognisedResultType = typeof resultType === 'string' && - resultType !== 'complete' && - resultType !== 'input_required'; + !CORE_RESULT_TYPES.has(resultType) && + !extensionResult + ? resultType + : undefined; let resultDefName: string | undefined; if (inputRequired) { resultDefName = 'InputRequiredResult'; @@ -279,8 +299,11 @@ export function wireSchemaErrors( 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; }