inspector/clients/web/src/test/integration/mcp/remote/remote-session.test.ts
cliffhall ddfce0f7ac fix: reject a non-string protocolVersion, and drop the test double casts
Copilot review follow-ups:

- `applyProtocolVersion` took the value straight from an unvalidated JSON
  body and went to `RegExp.test`, which coerces — `123` and `true` both
  matched the token pattern and reached `Transport.setProtocolVersion` with
  the wrong type. It now takes `unknown` and checks `typeof === "string"`
  first, with cases for the coercible values.
- Replace `as unknown as Transport` in the new session fixtures with a
  `makeTransport()` helper returning a structurally valid `Transport`, so an
  interface change stays visible to the tests.
- Drop `as unknown as typeof fetch` in the new transport test —
  `vi.fn<typeof fetch>()` is already the right type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84
2026-08-10 20:55:51 -04:00

392 lines
15 KiB
TypeScript

/**
* Unit-level tests for RemoteSession's event queue + transport-death wiring.
*
* Lives under integration/ because RemoteSession imports SDK types that
* pull in node-only modules at runtime; the file is otherwise pure (no I/O,
* no network) so the integration runner is comfortable for it.
*/
import { describe, it, expect, vi } from "vitest";
import type { Transport } 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";
function makeFetchEntry(
overrides: Partial<FetchRequestEntryBase> = {},
): FetchRequestEntryBase {
return {
id: "req-1",
timestamp: new Date("2026-01-01T00:00:00Z"),
method: "GET",
url: "http://example.com/",
requestHeaders: {},
...overrides,
};
}
/** A structurally valid `Transport`, so a fixture needs no cast. */
function makeTransport(overrides: Partial<Transport> = {}): Transport {
return {
start: async () => {},
send: async () => {},
close: async () => {},
...overrides,
};
}
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
const session = new RemoteSession("s1");
session.onStderr({ timestamp: new Date(), message: "line one" });
session.onStderr({ timestamp: new Date(), message: "line two" });
const received: { type: string }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type });
});
expect(received.map((e) => e.type)).toEqual(["stdio_log", "stdio_log"]);
});
it("queues the transport_error event when markTransportDead fires before a consumer attaches", () => {
// Regression: the previous behavior dropped the event when no consumer
// was attached, so a process that crashed during startup vanished into
// a bare "Session not found" 404 on the next /api/mcp/events poll.
const session = new RemoteSession("s2");
session.onStderr({
timestamp: new Date(),
message: "Error: Cannot find module 'bogus.js'",
});
session.markTransportDead("Transport closed - process may have exited");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type, data: event.data });
});
expect(received.map((e) => e.type)).toEqual([
"stdio_log",
"transport_error",
]);
const err = received[1]?.data as { error: string; code: number };
expect(err.error).toMatch(/Transport closed/);
expect(err.code).toBe(-32000);
});
it("delivers transport_error live when a consumer is already attached", () => {
const session = new RemoteSession("s3");
const received: { type: string }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type });
});
session.markTransportDead("transport closed");
expect(received.map((e) => e.type)).toEqual(["transport_error"]);
});
it("isTransportDead + getTransportError reflect the marked state", () => {
const session = new RemoteSession("s4");
expect(session.isTransportDead()).toBe(false);
expect(session.getTransportError()).toBeNull();
session.markTransportDead("boom");
expect(session.isTransportDead()).toBe(true);
expect(session.getTransportError()).toBe("boom");
});
it("clearEventConsumer signals cleanup-needed when the transport is dead", () => {
const session = new RemoteSession("s5");
session.setEventConsumer(() => {});
expect(session.clearEventConsumer()).toBe(false);
session.setEventConsumer(() => {});
session.markTransportDead("boom");
expect(session.clearEventConsumer()).toBe(true);
});
it("flushes a non-empty queue in order on consumer attach", () => {
const session = new RemoteSession("s6");
session.onStderr({ timestamp: new Date(), message: "first" });
session.onMessage({ jsonrpc: "2.0", id: 1, method: "ping" });
session.onFetchResponseBody("req-1", "body");
const received: string[] = [];
session.setEventConsumer((event) => received.push(event.type));
expect(received).toEqual([
"stdio_log",
"message",
"fetch_request_body_update",
]);
});
it("setEventConsumer with an empty queue flushes nothing", () => {
const session = new RemoteSession("s7");
const consumer = vi.fn();
session.setEventConsumer(consumer);
expect(consumer).not.toHaveBeenCalled();
});
it("setTransport stores the transport", () => {
const session = new RemoteSession("s8");
const transport = { foo: "bar" } as unknown as Transport;
session.setTransport(transport);
expect(session.transport).toBe(transport);
});
// #1935: the browser's Client negotiates the version, so the backend has to
// be told before it can stamp `Mcp-Protocol-Version` on upstream requests.
it("applyProtocolVersion forwards a new version to the transport once", () => {
const session = new RemoteSession("s8a");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion("2025-11-25");
session.applyProtocolVersion("2025-11-25");
expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]);
// A renegotiated version (e.g. after a reconnect) is re-applied.
session.applyProtocolVersion("2026-07-28");
expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28");
});
it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => {
const session = new RemoteSession("s8b");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion(undefined);
// Header injection attempt — must never reach the upstream transport.
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
session.applyProtocolVersion("");
// The value arrives from an unvalidated JSON body, so a non-string must be
// rejected on its type — `RegExp.test` would coerce these into a match.
session.applyProtocolVersion(123);
session.applyProtocolVersion(true);
session.applyProtocolVersion(null);
expect(setProtocolVersion).not.toHaveBeenCalled();
});
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
const session = new RemoteSession("s8c");
session.setTransport(makeTransport());
expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow();
});
it("hasEventConsumer reflects whether a consumer is attached", () => {
const session = new RemoteSession("s9");
expect(session.hasEventConsumer()).toBe(false);
session.setEventConsumer(() => {});
expect(session.hasEventConsumer()).toBe(true);
});
it("delivers events live to an attached consumer (pushEvent direct path)", () => {
const session = new RemoteSession("s10");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) =>
received.push({ type: event.type, data: event.data }),
);
session.onMessage({ jsonrpc: "2.0", id: 1, result: {} });
session.onFetchResponseBody("req-1", "the-body");
expect(received.map((e) => e.type)).toEqual([
"message",
"fetch_request_body_update",
]);
expect(received[1]?.data).toEqual({
id: "req-1",
responseBody: "the-body",
});
});
it("onFetchRequest serializes a Date timestamp to an ISO string", () => {
const session = new RemoteSession("s11");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) =>
received.push({ type: event.type, data: event.data }),
);
session.onFetchRequest(
makeFetchEntry({ timestamp: new Date("2026-01-01T00:00:00Z") }),
);
expect(received[0]?.type).toBe("fetch_request");
expect((received[0]?.data as { timestamp: string }).timestamp).toBe(
"2026-01-01T00:00:00.000Z",
);
});
it("onFetchRequest leaves a non-Date timestamp untouched", () => {
const session = new RemoteSession("s12");
const received: { data: unknown }[] = [];
session.setEventConsumer((event) => received.push({ data: event.data }));
// A pre-serialized (string) timestamp exercises the ternary's else branch.
session.onFetchRequest(
makeFetchEntry({
timestamp: "2026-01-01T00:00:00.000Z" as unknown as Date,
}),
);
expect((received[0]?.data as { timestamp: string }).timestamp).toBe(
"2026-01-01T00:00:00.000Z",
);
});
it("waitForRequestResponse resolves when a matching JSON-RPC response arrives", async () => {
const session = new RemoteSession("s-wait");
const wait = session.waitForRequestResponse(42);
session.onMessage({ jsonrpc: "2.0", id: 42, result: { tools: [] } });
await expect(wait).resolves.toBeUndefined();
});
it("endSend is a no-op when no send is active", () => {
const session = new RemoteSession("s-endsend-noop");
expect(session.hasActiveSend()).toBe(false);
// No matching beginSend() — activeSendCount is already 0.
session.endSend();
expect(session.hasActiveSend()).toBe(false);
});
it("waitForRequestResponse with timeoutMs=0 never schedules a timer and still resolves", async () => {
const session = new RemoteSession("s-no-timeout");
const wait = session.waitForRequestResponse(7, 0);
session.onMessage({ jsonrpc: "2.0", id: 7, result: {} });
await expect(wait).resolves.toBeUndefined();
});
it("cancelRequestWait rejects a timeoutMs=0 wait without a timer to clear", async () => {
const session = new RemoteSession("s-no-timeout-cancel");
const wait = session.waitForRequestResponse(8, 0);
const rejection = expect(wait).rejects.toThrow(/cancelled/);
session.cancelRequestWait(8);
await rejection;
});
it("handleTransportAuthError rejects active request waits during send", async () => {
const session = new RemoteSession("s-auth");
session.beginSend();
const wait = session.waitForRequestResponse(1);
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
expect(session.handleTransportAuthError(err)).toBe(true);
await expect(wait).rejects.toBe(err);
session.endSend();
});
it("handleTransportAuthError pushes ambient auth when no send is active", () => {
const session = new RemoteSession("s-ambient");
const received: unknown[] = [];
session.setEventConsumer((event) => {
if (event.type === "auth_challenge") received.push(event.data);
});
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(1);
});
it("does not push SSE auth while a send is active (command path owns delivery)", () => {
const session = new RemoteSession("s-active");
const received: unknown[] = [];
session.setEventConsumer((event) => {
if (event.type === "auth_challenge") received.push(event.data);
});
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
session.beginSend();
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(0);
session.endSend();
});
it("does not duplicate on SSE until the HTTP echo suppress window expires", () => {
vi.useFakeTimers();
const session = new RemoteSession("s-echo");
const received: unknown[] = [];
session.setEventConsumer((event) => {
if (event.type === "auth_challenge") received.push(event.data);
});
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
session.beginSend();
session.noteAuthChallengeDeliveredViaHttp();
session.endSend();
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(0);
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(0);
session.beginSend();
session.endSend();
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(0);
vi.advanceTimersByTime(RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS + 1);
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(1);
vi.useRealTimers();
});
it("does not clear HTTP auth suppression when a concurrent send starts", () => {
const session = new RemoteSession("s-concurrent");
const received: unknown[] = [];
session.setEventConsumer((event) => {
if (event.type === "auth_challenge") received.push(event.data);
});
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
session.beginSend();
session.noteAuthChallengeDeliveredViaHttp();
session.endSend();
session.beginSend();
expect(session.handleTransportAuthError(err)).toBe(true);
expect(received).toHaveLength(0);
session.endSend();
});
it("waitForRequestResponse rejects after timeout", async () => {
vi.useFakeTimers();
const session = new RemoteSession("s-timeout");
const wait = session.waitForRequestResponse(99, 1000);
const rejection = expect(wait).rejects.toThrow(/timed out/);
await vi.advanceTimersByTimeAsync(1000);
await rejection;
vi.useRealTimers();
});
it("cancelRequestWait clears the timeout for a pending wait", async () => {
vi.useFakeTimers();
const session = new RemoteSession("s-cancel");
const wait = session.waitForRequestResponse(42, 5000);
const rejection = expect(wait).rejects.toThrow(/cancelled/);
session.cancelRequestWait(42);
await rejection;
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
vi.useRealTimers();
});
it("setAuthState throws when no auth provider handle is set", () => {
const session = new RemoteSession("no-auth-provider");
expect(() =>
session.setAuthState({
oauthTokens: { access_token: "x", token_type: "Bearer" },
}),
).toThrow(/Session has no OAuth auth provider/);
});
it("cancelRequestWait is a no-op when the requestId has no pending wait", () => {
const session = new RemoteSession("s-cancel-noop");
// No wait was ever registered for this id — should not throw.
expect(() => session.cancelRequestWait(123)).not.toThrow();
});
it("handleTransportAuthError returns false for a non-AuthChallengeError", () => {
const session = new RemoteSession("s-not-auth-error");
expect(session.handleTransportAuthError(new Error("plain error"))).toBe(
false,
);
});
it("setAuthState updates the session auth provider", async () => {
const { createRemoteAuthProvider } =
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
const handle = createRemoteAuthProvider({
oauthTokens: { access_token: "old", token_type: "Bearer" },
})!;
const session = new RemoteSession("auth-state");
session.setAuthProviderHandle(handle);
session.setAuthState({
oauthTokens: { access_token: "new", token_type: "Bearer" },
});
await expect(handle.provider.tokens()).resolves.toEqual({
access_token: "new",
token_type: "Bearer",
});
});
});