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([]);
|
||||
});
|
||||
|
||||
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', [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue