fix(core): restore the v1 content default for tools/call results on the legacy era (#2456)
This commit is contained in:
parent
3834921074
commit
44797d7779
3 changed files with 347 additions and 96 deletions
|
|
@ -4,7 +4,8 @@ import {
|
|||
ClientResultSchema,
|
||||
ServerNotificationSchema,
|
||||
ServerRequestSchema,
|
||||
ServerResultSchema
|
||||
ServerResultSchema,
|
||||
TOOL_RESULT_FOREIGN_FAMILY_KEYS
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
import type { Transport } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
|
|
@ -16,6 +17,10 @@ import {
|
|||
isSpecType
|
||||
} from '@modelcontextprotocol/server';
|
||||
|
||||
function isPlainRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
export type WireParty = 'client' | 'server';
|
||||
|
||||
export interface SnifferOptions {
|
||||
|
|
@ -100,7 +105,28 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO
|
|||
// (InputRequiredResultSchema lives alongside, never widening it).
|
||||
// Era-gated: only cells wired for the modern era opt in, so an
|
||||
// input_required on a 2025-era cell's wire is still flagged.
|
||||
if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return;
|
||||
if (party === 'server' && isInputRequiredResult(result)) {
|
||||
if (opts.allowInputRequiredResults === true) return;
|
||||
fail(party, `input_required result on a cell not opted into multi-round-trip`, msg);
|
||||
}
|
||||
// With content.default([]) restored, the neutral union accepts any
|
||||
// object via the CallToolResult member — so union conformance below
|
||||
// is a weak check, and the other result families are asserted here
|
||||
// explicitly, by vocabulary. Cells that opt into vendor-extension
|
||||
// methods skip this check (the sniffer is method-blind for results;
|
||||
// before the default was restored these bodies failed the union parse
|
||||
// and were excused by the same flag).
|
||||
if (party === 'server' && !opts.allowCustomMethods && isPlainRecord(result) && result.content === undefined) {
|
||||
for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) {
|
||||
if (key in result) {
|
||||
fail(
|
||||
party,
|
||||
`content-less result carrying '${key}' — another result family must not read as an empty tools/call success`,
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const r = schemas.result.safeParse(result);
|
||||
if (!r.success) {
|
||||
// A result for a vendor-extension request legitimately won't match the spec union.
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
* ABSENT `resultType` is a spec violation surfaced as a typed error
|
||||
* naming it.
|
||||
* - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary —
|
||||
* strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the
|
||||
* pre-split era-blind rejection — changeset: codec-split-wire-break). The
|
||||
* stripped body then fails the (default-free) result schema loudly
|
||||
* because it has no content.
|
||||
* strip-on-lift (Q1-SD3 ii). The stripped husk still carries the
|
||||
* input_required family keys, so the wire-seam schema rejects it loudly
|
||||
* (a content-less body without family keys would default to content: []
|
||||
* — changeset: calltoolresult-content-default).
|
||||
*
|
||||
* Either way the V-1 invariant holds: never an empty-content success.
|
||||
*/
|
||||
|
|
@ -115,8 +115,8 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr
|
|||
|
||||
try {
|
||||
const outcome = await callToolOutcome(client);
|
||||
// Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is
|
||||
// dropped; the body has no content, so validation fails LOUDLY.
|
||||
// Strip-on-lift: the foreign resultType is dropped; the husk's
|
||||
// input_required family keys fail the wire-seam schema LOUDLY.
|
||||
// Never an empty-content success.
|
||||
expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false);
|
||||
const rejection = (outcome as { rejected: unknown }).rejected;
|
||||
|
|
|
|||
|
|
@ -1828,8 +1828,6 @@ describe('outputSchema validation', () => {
|
|||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name === 'test-tool') {
|
||||
return {
|
||||
// content is spec-required (the wire default([]) was removed
|
||||
// - ledgered; changeset codec-split-wire-break)
|
||||
content: [],
|
||||
structuredContent: { result: 'success', count: 42 }
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue