test: address Copilot review — tighten progress-token guard and de-flake relay tests (#2028)
- progressTokenOf now delegates to the SDK's ProgressTokenSchema instead of a bare typeof check, so fractional and past-MAX_SAFE_INTEGER numbers are rejected (matching isProgressToken in inspectorClient.ts); tests cover both. - Browser relay timeout tests use fake timers with advanceTimersByTimeAsync and flush the SSE consumer between pushes, replacing real-sleep windows that a loaded runner could race (#1596). - The log-only timeout test holds and awaits its rejection rather than swallowing it in an empty catch. - progressToken.test.ts gains a file header; the remote-session malformed-token fixture uses a single-cast helper instead of `as unknown as`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
cd31d76371
commit
fb28b0fe64
3 changed files with 108 additions and 40 deletions
|
|
@ -1,3 +1,11 @@
|
|||
/**
|
||||
* Unit tests for the shared `progressTokenOf` guard (#2028) — the string/safe
|
||||
* integer extractor both relay waits (browser `RemoteClientTransport` and Node
|
||||
* `RemoteSession`) use to correlate a `notifications/progress` with the request
|
||||
* whose timeout it should re-arm. Covers the protocol's accept set and, since
|
||||
* the guard delegates to the SDK's `ProgressTokenSchema`, the fractional and
|
||||
* past-`MAX_SAFE_INTEGER` values a bare `typeof` check would have let through.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { progressTokenOf } from "@inspector/core/mcp/remote/progressToken.js";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||
|
|
@ -70,4 +78,38 @@ describe("progressTokenOf (#2028)", () => {
|
|||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a fractional numeric token (the protocol allows only integers)", () => {
|
||||
expect(
|
||||
progressTokenOf(
|
||||
msg({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: 1.5, progress: 1 },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an integer past MAX_SAFE_INTEGER", () => {
|
||||
expect(
|
||||
progressTokenOf(
|
||||
msg({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: Number.MAX_SAFE_INTEGER + 1, progress: 1 },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a safe integer at the boundary", () => {
|
||||
expect(
|
||||
progressTokenOf({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: Number.MAX_SAFE_INTEGER, progress: 1 },
|
||||
}),
|
||||
).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
|
|
@ -727,12 +727,22 @@ describe("RemoteClientTransport", () => {
|
|||
return { fetchFn, getSse: () => sse, getSentId: () => sentId };
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
// Fake timers so the deadline is driven deterministically rather than
|
||||
// raced against a real `setTimeout` — a real-sleep test can let the timeout
|
||||
// fire before a loaded runner processes the enqueued SSE frame (#1596: a
|
||||
// race is fixed with fake timers, never with headroom). Each push is
|
||||
// followed by a 0ms async advance to flush the SSE consumer's stream read
|
||||
// so the frame is parsed before time moves.
|
||||
const TIMEOUT_MS = 1000;
|
||||
const flushSse = () => vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("re-arms the wait on each progress note, so a call outliving the timeout still resolves", async () => {
|
||||
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
||||
const transport = new RemoteClientTransport(
|
||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: 150 },
|
||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
|
||||
config,
|
||||
);
|
||||
await transport.start();
|
||||
|
|
@ -742,22 +752,25 @@ describe("RemoteClientTransport", () => {
|
|||
id: 1,
|
||||
method: "tools/call",
|
||||
});
|
||||
await flushSse();
|
||||
|
||||
// Emit progress every 100ms (< the 150ms window) for ~400ms total, then
|
||||
// the response. Without the re-arm the wait dies at 150ms.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await delay(100);
|
||||
// Three progress notes, each after 900ms (< the 1000ms window): without
|
||||
// the re-arm the wait dies at 1000ms, well before the third lands.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
getSse().pushMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: getSentId(), progress: i, total: 4 },
|
||||
params: { progressToken: getSentId(), progress: i, total: 3 },
|
||||
});
|
||||
await flushSse();
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
}
|
||||
getSse().pushMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: getSentId(),
|
||||
result: { ok: true },
|
||||
});
|
||||
await flushSse();
|
||||
|
||||
await expect(sent).resolves.toBeUndefined();
|
||||
await transport.close();
|
||||
|
|
@ -766,7 +779,7 @@ describe("RemoteClientTransport", () => {
|
|||
it("does not re-arm on notifications/message, so a log-only call still times out", async () => {
|
||||
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
||||
const transport = new RemoteClientTransport(
|
||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: 150 },
|
||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
|
||||
config,
|
||||
);
|
||||
await transport.start();
|
||||
|
|
@ -776,24 +789,23 @@ describe("RemoteClientTransport", () => {
|
|||
id: 1,
|
||||
method: "tools/call",
|
||||
});
|
||||
void sent.catch(() => {});
|
||||
|
||||
// Only log messages — these must not extend the deadline.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await delay(50);
|
||||
getSse().pushMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: { msg: `tick ${i}`, token: getSentId() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await expect(sent).rejects.toThrow(
|
||||
// Hold the expected rejection now (as the RemoteSession timeout tests do)
|
||||
// so it is asserted rather than suppressed by an empty catch.
|
||||
const rejection = expect(sent).rejects.toThrow(
|
||||
/Timed out waiting for MCP response on SSE/,
|
||||
);
|
||||
await flushSse();
|
||||
|
||||
// A log message before the deadline must not extend it.
|
||||
getSse().pushMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/message",
|
||||
params: { level: "info", data: { msg: "tick", token: getSentId() } },
|
||||
});
|
||||
await flushSse();
|
||||
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
||||
|
||||
await rejection;
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||
import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
|
||||
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
||||
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
// Stage a deliberately off-spec message as `unknown` and narrow with a single
|
||||
// cast — the guard branches these fixtures exercise are ones the typed
|
||||
// `JSONRPCMessage` shape forbids, and `unknown` is the safe top type to pass in.
|
||||
const asMsg = (m: unknown): JSONRPCMessage => m as JSONRPCMessage;
|
||||
|
||||
function makeFetchEntry(
|
||||
overrides: Partial<FetchRequestEntryBase> = {},
|
||||
): FetchRequestEntryBase {
|
||||
|
|
@ -289,11 +294,13 @@ describe("RemoteSession", () => {
|
|||
const wait = session.waitForRequestResponse(8, 1000);
|
||||
const rejection = expect(wait).rejects.toThrow(/timed out/);
|
||||
// A malformed token (object) must be ignored, not coerced to a key.
|
||||
session.onMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: { bad: true }, progress: 1 },
|
||||
} as unknown as Parameters<RemoteSession["onMessage"]>[0]);
|
||||
session.onMessage(
|
||||
asMsg({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: { bad: true }, progress: 1 },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await rejection;
|
||||
vi.useRealTimers();
|
||||
|
|
|
|||
Loading…
Reference in a new issue