fix: let the aggregate own the tools/list report, and harden the scan (#1909)
Copilot round 9. The exclusion scan is a separate, uncached tools/list exchange, so its entries need not be the ones rendered — the server can answer differently, and a cache-served aggregate is a different list version. It was overwriting the aggregate's report wholesale, replacing accurate indices with indices into a list nobody is looking at. The aggregate now owns the report; the scan publishes only when the aggregate left nothing, which is the cache-served case that made it publish at all (round 2). A conforming re-fetch no longer swallows the mark: the list can change between the strict page and the lenient one, and a clean second response says nothing about the first, which was refused. It is marked with the original decode error when there is nothing per-item to name. The scan's fallback re-fetch now goes through invokeMcpClient, like the strict call beside it and like salvageList's walk, so an auth challenge on that request can still be recovered instead of being swallowed by listAllTools's best-effort scan catch. Also fixes listTools dropping an empty-string cursor (truthiness rather than !== undefined) — its own fallback leg already preserved it, so the two legs of one scan page disagreed about which page they had fetched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
7db807b0de
commit
efd0bf2b7c
1 changed files with 160 additions and 21 deletions
|
|
@ -610,6 +610,107 @@ describe("InspectorClient list salvage (#1909)", () => {
|
|||
expect(connected.getMalformedListItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not let the scan overwrite the aggregate's report", async () => {
|
||||
// Both walks run on a modern connection and they are separate, uncached
|
||||
// exchanges. The aggregate is the list being RENDERED, so its report owns
|
||||
// the indices; the scan must not replace them with its own — the server can
|
||||
// answer the second request differently, and then the warning would point
|
||||
// into a list nobody is looking at.
|
||||
const server = await startMalformedServer(
|
||||
{
|
||||
tools: [
|
||||
{
|
||||
items: [
|
||||
{ name: "ok_tool", inputSchema: { type: "object" } },
|
||||
{ name: "broken", inputSchema: "not-a-schema" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
stopServer = server.stop;
|
||||
const connected = await connectTo(server.url, "modern");
|
||||
|
||||
// Serve a DIFFERENT malformed shape to the scan's walk, which runs after
|
||||
// the aggregate — so an overwrite is visible as a changed index/label.
|
||||
let swapped = false;
|
||||
const original = connected.listTools.bind(connected);
|
||||
connected.listTools = async (cursor?: string, meta?: object) => {
|
||||
if (!swapped) {
|
||||
swapped = true;
|
||||
server.setPages({
|
||||
tools: [
|
||||
{
|
||||
items: [
|
||||
{ name: "a", inputSchema: { type: "object" } },
|
||||
{ name: "b", inputSchema: { type: "object" } },
|
||||
{ name: "later_break", inputSchema: 42 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return original(cursor, meta as Record<string, string> | undefined);
|
||||
};
|
||||
|
||||
await connected.listAllTools();
|
||||
|
||||
// The aggregate's finding, at the aggregate's index — not the scan's.
|
||||
expect(connected.getMalformedListItems()).toEqual([
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 1,
|
||||
label: "broken",
|
||||
reason: expect.stringMatching(/^inputSchema/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the scan's refused response even when the re-fetch is clean", async () => {
|
||||
// The list can change between the strict page and the lenient re-fetch. A
|
||||
// conforming re-fetch says nothing about the response that was refused, so
|
||||
// leaving it unmarked would render a rejected exchange as a clean success.
|
||||
const server = await startMalformedServer(
|
||||
{ tools: [{ items: [{ name: "broken", inputSchema: 42 }] }] },
|
||||
true,
|
||||
);
|
||||
stopServer = server.stop;
|
||||
const connected = await connectTo(server.url, "modern");
|
||||
const log = new MessageLogState(connected);
|
||||
|
||||
// Let the strict call fail, then serve a conforming list to every later
|
||||
// request — so the scan's re-fetch finds nothing per-item wrong.
|
||||
const original = connected.listTools.bind(connected);
|
||||
connected.listTools = async (cursor?: string, meta?: object) => {
|
||||
try {
|
||||
return await original(
|
||||
cursor,
|
||||
meta as Record<string, string> | undefined,
|
||||
);
|
||||
} finally {
|
||||
server.setPages({
|
||||
tools: [
|
||||
{ items: [{ name: "fixed", inputSchema: { type: "object" } }] },
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await connected.refreshExcludedTools();
|
||||
|
||||
const marked = log
|
||||
.getMessages()
|
||||
.filter(
|
||||
(entry) =>
|
||||
"method" in entry.message &&
|
||||
entry.message.method === "tools/list" &&
|
||||
entry.clientError !== undefined,
|
||||
);
|
||||
expect(marked).toHaveLength(1);
|
||||
log.destroy();
|
||||
});
|
||||
|
||||
it("stops the salvage walk at the page cap instead of paging forever", async () => {
|
||||
// A server whose `nextCursor` never converges but never REPEATS slips the
|
||||
// repeated-cursor guard: every cursor is fresh, so the walk had no reason
|
||||
|
|
|
|||
Loading…
Reference in a new issue