fix: address Copilot round 5 on the list-salvage path (#1909)

Both findings are the same shape: the fallback accepting what the strict path
would not.

- `ModernResultEnvelopeSchema` typed `ttlMs` as any number, where the codec
  requires a non-negative integer. `ttlMs: -1` (or `0.5`) plus one bad entry
  would salvage, reporting the entry and hiding the envelope violation. Tightened
  to `z.int().min(0)`.

- The item schema was the SDK's era-NEUTRAL `ToolSchema`, and the negotiated
  era's wire schema is stricter in ways the SDK does not export. Verified
  against SDK 2.0.0 on a real connection: on legacy, a tool with
  `outputSchema: { type: "array" }` is refused by the strict aggregate but
  accepted by the neutral schema; on modern, `execution` is STRIPPED by the
  strict path but kept by the neutral one. Either way a salvaged list could show
  — and let the user call — a tool the connection's era says isn't valid.

  `toolItemSchemaForEra` closes both. Replicating rules that live in the SDK's
  internals is a real cost; `listSalvage-era.test.ts` is what makes it safe to
  pay, pinning each rule to the strict path's OBSERVED behavior so a divergence
  that closes or widens fails loudly instead of rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:46:54 -04:00
commit 71cbe27d83
2 changed files with 278 additions and 5 deletions

View file

@ -132,6 +132,20 @@ describe("ModernResultEnvelopeSchema", () => {
).toBe(true);
});
it("requires ttlMs to be a non-negative integer, as the codec does", () => {
// A looser `z.number()` would accept `-1` / `0.5`, so a response with a bad
// envelope AND one bad entry would salvage and hide the envelope violation.
for (const ttlMs of [-1, 0.5]) {
expect(
ModernResultEnvelopeSchema.safeParse({
resultType: "complete",
ttlMs,
cacheScope: "public",
}).success,
).toBe(false);
}
});
it("rejects a modern result missing the envelope the codec would enforce", () => {
// The raw-wire salvage path goes around that codec, so a response missing
// its cache hints AND carrying a bad entry must not salvage.

View file

@ -0,0 +1,214 @@
import { describe, it, expect, afterEach } from "vitest";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import { ToolSchema } from "@modelcontextprotocol/core";
import { toolItemSchemaForEra } from "@inspector/core/mcp/listSalvage.js";
/**
* The era item contract the salvage fallback validates tools against (#1909).
*
* `toolItemSchemaForEra` replicates two rules that live in the SDK's internal
* per-era wire schemas, which are not exported. That is a real cost, and these
* tests are what makes it safe to pay: each one asserts the STRICT path's
* observed behavior on a real connection, so if a future SDK closes or widens a
* divergence, the test fails and the replicated rule gets revisited instead of
* silently rotting.
*
* The failure being prevented: the fallback keeping an entry the strict path
* refused, which the user would then see — and could call — on a connection
* whose era says it isn't valid.
*/
const ARRAY_OUTPUT_TOOL = {
name: "array_output",
inputSchema: { type: "object" },
outputSchema: { type: "array" },
};
const EXECUTION_TOOL = {
name: "with_execution",
inputSchema: { type: "object" },
execution: { taskSupport: "optional" },
};
function startToolsServer(
tools: unknown[],
modern: boolean,
): Promise<{ url: string; stop: () => Promise<void> }> {
const handler = async (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks).toString();
if (raw.length === 0) {
res.writeHead(405).end();
return;
}
const body = JSON.parse(raw) as { id?: unknown; method: string };
if (body.id === undefined) {
res.writeHead(202).end();
return;
}
const envelope = modern
? { resultType: "complete", ttlMs: 0, cacheScope: "public" }
: {};
const send = (result: object) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: { ...envelope, ...result },
}),
);
};
if (body.method === "initialize") {
send({
protocolVersion: modern ? "2026-07-28" : "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "era-contract-server", version: "1.0.0" },
});
return;
}
if (body.method === "server/discover") {
send({
supportedVersions: ["2026-07-28"],
capabilities: { tools: {} },
serverInfo: { name: "era-contract-server", version: "1.0.0" },
});
return;
}
if (body.method === "tools/list") {
send({ tools });
return;
}
send({});
};
const server: Server = createServer((req, res) => {
void handler(req, res);
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
/* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */
const port = typeof address === "object" && address ? address.port : 0;
resolve({
url: `http://127.0.0.1:${port}/mcp`,
stop: () =>
new Promise((done) => {
server.close(() => done());
}),
});
});
});
}
describe("salvage era item contract (#1909)", () => {
let client: InspectorClient | null = null;
let stop: (() => Promise<void>) | null = null;
afterEach(async () => {
try {
await client?.disconnect();
} catch {
// Teardown only.
}
client = null;
await stop?.();
stop = null;
});
async function connect(url: string, era: "legacy" | "modern") {
client = new InspectorClient(
{ type: "streamable-http", url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation(era),
},
);
await client.connect();
return client;
}
it("legacy: the era rule is what rejects a non-object outputSchema (pins it)", async () => {
// The rule lives in the SDK's high-level aggregate (the era codec), not in
// the neutral schema the single-page call uses — which is precisely the
// divergence `toolItemSchemaForEra` exists to close.
//
// This tool is the page's ONLY problem, so the observable outcome is
// entirely the SDK's rule talking: the strict aggregate refuses the page,
// the fallback runs, and our contract agrees the tool is not era-valid. If
// a future SDK accepts it, the aggregate succeeds instead and this fails —
// which is the signal to revisit the replicated rule.
const server = await startToolsServer([ARRAY_OUTPUT_TOOL], false);
stop = server.stop;
const connected = await connect(server.url, "legacy");
const { tools } = await connected.listAllTools();
expect(tools).toEqual([]);
expect(
connected.getMalformedListItems().map((entry) => entry.label),
).toEqual(["array_output"]);
});
it("modern: the era rule accepts a tool but strips execution (pins it)", async () => {
// The mirror image: here the strict aggregate SUCCEEDS, so no salvage runs
// and what comes back is the SDK's own normalization. If it ever stops
// stripping, `execution` reappears here and this fails.
const server = await startToolsServer([EXECUTION_TOOL], true);
stop = server.stop;
const connected = await connect(server.url, "modern");
const { tools } = await connected.listAllTools();
expect(tools).toHaveLength(1);
expect(tools[0]).not.toHaveProperty("execution");
expect(connected.getMalformedListItems()).toEqual([]);
});
it("the neutral ToolSchema is looser than both, which is why the contract exists", () => {
expect(ToolSchema.safeParse(ARRAY_OUTPUT_TOOL).success).toBe(true);
const neutral = ToolSchema.safeParse(EXECUTION_TOOL);
expect(neutral.success).toBe(true);
expect(neutral.success && neutral.data).toHaveProperty("execution");
});
it("the era contract matches the strict path on both divergences", () => {
// Legacy: rejected, exactly as the strict parse above rejected it.
expect(
toolItemSchemaForEra(false).safeParse(ARRAY_OUTPUT_TOOL).success,
).toBe(false);
// Modern: accepted, with `execution` stripped, exactly as above.
const modern = toolItemSchemaForEra(true).safeParse(EXECUTION_TOOL);
expect(modern.success).toBe(true);
expect(modern.success && modern.data).not.toHaveProperty("execution");
});
it("keeps only era-valid tools when salvaging beside a malformed one", async () => {
// The end-to-end shape of the defect: a malformed entry trips the
// fallback, and the array-output tool must NOT ride along on the strength
// of the neutral schema accepting it.
const server = await startToolsServer(
[
{ name: "ok_tool", inputSchema: { type: "object" } },
ARRAY_OUTPUT_TOOL,
{ name: "broken", inputSchema: "not-a-schema" },
],
false,
);
stop = server.stop;
const connected = await connect(server.url, "legacy");
const { tools } = await connected.listAllTools();
expect(tools.map((tool) => tool.name)).toEqual(["ok_tool"]);
expect(
connected.getMalformedListItems().map((entry) => entry.label),
).toEqual(["array_output", "broken"]);
});
});