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:
parent
3507f94150
commit
cf022cbb54
2 changed files with 82 additions and 8 deletions
|
|
@ -117,6 +117,49 @@ describe('wireSchemaErrors', () => {
|
||||||
).toEqual([]);
|
).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', () => {
|
it('accepts a JSON-RPC batch under 2025-03-26 and reports per-element errors', () => {
|
||||||
expect(
|
expect(
|
||||||
wireSchemaErrors('2025-03-26', [
|
wireSchemaErrors('2025-03-26', [
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,18 @@ const NON_CANONICAL_DEFS = new Set([
|
||||||
'ServerMessage'
|
'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 {
|
interface CompiledSpec {
|
||||||
defsKey: '$defs' | 'definitions';
|
defsKey: '$defs' | 'definitions';
|
||||||
defs: Record<string, Record<string, unknown>>;
|
defs: Record<string, Record<string, unknown>>;
|
||||||
|
|
@ -261,18 +273,37 @@ export function wireSchemaErrors(
|
||||||
|
|
||||||
if (msg.result !== undefined) {
|
if (msg.result !== undefined) {
|
||||||
// SEP-2322 (MRTR): any request may be answered with an InputRequiredResult
|
// 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 =
|
const inputRequired =
|
||||||
(msg.result as Record<string, unknown> | null)?.resultType ===
|
resultType === 'input_required' && 'InputRequiredResult' in spec.defs;
|
||||||
'input_required' && 'InputRequiredResult' in spec.defs;
|
const extensionResult =
|
||||||
const resultDefName = inputRequired
|
typeof resultType === 'string' && EXTENSION_RESULT_TYPES.has(resultType);
|
||||||
? 'InputRequiredResult'
|
const unrecognisedResultType =
|
||||||
: requestMethod !== undefined
|
typeof resultType === 'string' &&
|
||||||
? spec.resultDefs.get(requestMethod)
|
!CORE_RESULT_TYPES.has(resultType) &&
|
||||||
|
!extensionResult
|
||||||
|
? resultType
|
||||||
: undefined;
|
: undefined;
|
||||||
|
let resultDefName: string | undefined;
|
||||||
|
if (inputRequired) {
|
||||||
|
resultDefName = 'InputRequiredResult';
|
||||||
|
} else if (!extensionResult && requestMethod !== undefined) {
|
||||||
|
resultDefName = spec.resultDefs.get(requestMethod);
|
||||||
|
}
|
||||||
if (resultDefName) {
|
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(
|
const typed = validateAgainst(resultDefName, msg.result).map(
|
||||||
(e) => `${e} (result of '${requestMethod}')`
|
(e) => `${e} (result of '${requestMethod}')${hint}`
|
||||||
);
|
);
|
||||||
if (typed.length > 0) return typed;
|
if (typed.length > 0) return typed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue