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
4 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 { describe, it, expect } from "vitest";
|
||||||
import { progressTokenOf } from "@inspector/core/mcp/remote/progressToken.js";
|
import { progressTokenOf } from "@inspector/core/mcp/remote/progressToken.js";
|
||||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||||
|
|
@ -70,4 +78,38 @@ describe("progressTokenOf (#2028)", () => {
|
||||||
),
|
),
|
||||||
).toBeUndefined();
|
).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 { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
|
||||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
|
@ -727,12 +727,22 @@ describe("RemoteClientTransport", () => {
|
||||||
return { fetchFn, getSse: () => sse, getSentId: () => sentId };
|
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 () => {
|
it("re-arms the wait on each progress note, so a call outliving the timeout still resolves", async () => {
|
||||||
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
||||||
const transport = new RemoteClientTransport(
|
const transport = new RemoteClientTransport(
|
||||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: 150 },
|
{ baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
@ -742,22 +752,25 @@ describe("RemoteClientTransport", () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
method: "tools/call",
|
method: "tools/call",
|
||||||
});
|
});
|
||||||
|
await flushSse();
|
||||||
|
|
||||||
// Emit progress every 100ms (< the 150ms window) for ~400ms total, then
|
// Three progress notes, each after 900ms (< the 1000ms window): without
|
||||||
// the response. Without the re-arm the wait dies at 150ms.
|
// the re-arm the wait dies at 1000ms, well before the third lands.
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await delay(100);
|
|
||||||
getSse().pushMessage({
|
getSse().pushMessage({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "notifications/progress",
|
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({
|
getSse().pushMessage({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id: getSentId(),
|
id: getSentId(),
|
||||||
result: { ok: true },
|
result: { ok: true },
|
||||||
});
|
});
|
||||||
|
await flushSse();
|
||||||
|
|
||||||
await expect(sent).resolves.toBeUndefined();
|
await expect(sent).resolves.toBeUndefined();
|
||||||
await transport.close();
|
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 () => {
|
it("does not re-arm on notifications/message, so a log-only call still times out", async () => {
|
||||||
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
const { fetchFn, getSse, getSentId } = backendHoldingSend();
|
||||||
const transport = new RemoteClientTransport(
|
const transport = new RemoteClientTransport(
|
||||||
{ baseUrl, fetchFn, sseResponseTimeoutMs: 150 },
|
{ baseUrl, fetchFn, sseResponseTimeoutMs: TIMEOUT_MS },
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
@ -776,24 +789,23 @@ describe("RemoteClientTransport", () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
method: "tools/call",
|
method: "tools/call",
|
||||||
});
|
});
|
||||||
void sent.catch(() => {});
|
// Hold the expected rejection now (as the RemoteSession timeout tests do)
|
||||||
|
// so it is asserted rather than suppressed by an empty catch.
|
||||||
// Only log messages — these must not extend the deadline.
|
const rejection = expect(sent).rejects.toThrow(
|
||||||
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(
|
|
||||||
/Timed out waiting for MCP response on SSE/,
|
/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();
|
await transport.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
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 { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
|
||||||
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
||||||
import { AuthChallengeError } from "@inspector/core/auth/challenge.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(
|
function makeFetchEntry(
|
||||||
overrides: Partial<FetchRequestEntryBase> = {},
|
overrides: Partial<FetchRequestEntryBase> = {},
|
||||||
): FetchRequestEntryBase {
|
): FetchRequestEntryBase {
|
||||||
|
|
@ -289,11 +294,13 @@ describe("RemoteSession", () => {
|
||||||
const wait = session.waitForRequestResponse(8, 1000);
|
const wait = session.waitForRequestResponse(8, 1000);
|
||||||
const rejection = expect(wait).rejects.toThrow(/timed out/);
|
const rejection = expect(wait).rejects.toThrow(/timed out/);
|
||||||
// A malformed token (object) must be ignored, not coerced to a key.
|
// A malformed token (object) must be ignored, not coerced to a key.
|
||||||
session.onMessage({
|
session.onMessage(
|
||||||
jsonrpc: "2.0",
|
asMsg({
|
||||||
method: "notifications/progress",
|
jsonrpc: "2.0",
|
||||||
params: { progressToken: { bad: true }, progress: 1 },
|
method: "notifications/progress",
|
||||||
} as unknown as Parameters<RemoteSession["onMessage"]>[0]);
|
params: { progressToken: { bad: true }, progress: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
await rejection;
|
await rejection;
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,20 @@
|
||||||
* and the Node `RemoteSession` import it rather than each carrying a copy.
|
* and the Node `RemoteSession` import it rather than each carrying a copy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ProgressTokenSchema } from "@modelcontextprotocol/core";
|
||||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id of the request a `notifications/progress` belongs to, or `undefined`
|
* The token identifying the request a `notifications/progress` belongs to, or
|
||||||
* for any other message. The SDK stamps `progressToken: messageId` on the
|
* `undefined` for any other message. The SDK stamps `progressToken: messageId`
|
||||||
* outbound request, so a progress notification's `progressToken` is the id of
|
* on the outbound request, so a progress notification's `progressToken` is the
|
||||||
* the request whose wait should be re-armed. A JSON-RPC progress token is a
|
* id of the request whose wait should be re-armed.
|
||||||
* string or a number; anything else is ignored so a malformed token can't be
|
*
|
||||||
* coerced into a map key.
|
* Validation is delegated to the SDK's own `ProgressTokenSchema` rather than a
|
||||||
|
* hand-rolled `typeof` check — the protocol allows only a string or a *safe*
|
||||||
|
* integer, and zod's `.int()` rejects a fractional or past-`MAX_SAFE_INTEGER`
|
||||||
|
* value that a bare `typeof token === "number"` would wave through (the same
|
||||||
|
* drift `isProgressToken` in `core/mcp/inspectorClient.ts` exists to avoid).
|
||||||
*/
|
*/
|
||||||
export function progressTokenOf(
|
export function progressTokenOf(
|
||||||
message: JSONRPCMessage,
|
message: JSONRPCMessage,
|
||||||
|
|
@ -24,9 +29,11 @@ export function progressTokenOf(
|
||||||
typeof message.params === "object" &&
|
typeof message.params === "object" &&
|
||||||
message.params !== null
|
message.params !== null
|
||||||
) {
|
) {
|
||||||
const token = (message.params as { progressToken?: unknown }).progressToken;
|
const parsed = ProgressTokenSchema.safeParse(
|
||||||
if (typeof token === "string" || typeof token === "number") {
|
(message.params as { progressToken?: unknown }).progressToken,
|
||||||
return token;
|
);
|
||||||
|
if (parsed.success) {
|
||||||
|
return parsed.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue