fix(validation): accept extension result envelopes (#468)

* fix(validation): accept extension result envelopes

This fixes #424

Signed-off-by: King Star <mcxin.y@gmail.com>

* validation: only known extension resultTypes take the envelope-only path

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 <noreply@anthropic.com>

---------

Signed-off-by: King Star <mcxin.y@gmail.com>
Co-authored-by: triage <paulc@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
King Star 2026-09-07 04:27:49 +08:00 • committed by GitHub
commit cf022cbb54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 82 additions and 8 deletions

View file

@ -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', [

View file

@ -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<string> = 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<string> = new Set(['task']);
interface CompiledSpec {
defsKey: '$defs' | 'definitions';
defs: Record<string, Record<string, unknown>>;
@ -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<string, unknown> | null)
?.resultType;
const inputRequired =
(msg.result as Record<string, unknown> | 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;
}