chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,339 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type JsonRpcMessage,
type JsonRpcRequest,
LATEST_PROTOCOL_VERSION,
McpAbortError,
McpClient,
McpError,
McpTimeoutError,
} from "../src/index.ts";
import { createInMemoryTransportPair, type InMemoryTransport } from "../src/testing/index.ts";
interface TestServer {
transport: InMemoryTransport;
messages: JsonRpcMessage[];
setHandler(method: string, handler: (request: JsonRpcRequest) => unknown | Promise<unknown>): void;
}
async function createServer(): Promise<{ clientTransport: InMemoryTransport; server: TestServer }> {
const pair = createInMemoryTransportPair();
const handlers = new Map<string, (request: JsonRpcRequest) => unknown | Promise<unknown>>();
const messages: JsonRpcMessage[] = [];
pair.server.onMessage((message) => {
messages.push(message);
if (!("id" in message) || !("method" in message)) return;
const request = message as JsonRpcRequest;
const handler = handlers.get(request.method);
queueMicrotask(async () => {
try {
if (!handler) throw new McpError(-32601, `Method not found: ${request.method}`);
await pair.server.send({ jsonrpc: "2.0", id: request.id, result: await handler(request) });
} catch (error) {
const mcpError = error instanceof McpError ? error : new McpError(-32603, String(error));
await pair.server.send({
jsonrpc: "2.0",
id: request.id,
error: { code: mcpError.code, message: mcpError.message, data: mcpError.data },
});
}
});
});
await pair.server.start();
const server: TestServer = {
transport: pair.server,
messages,
setHandler(method, handler) {
handlers.set(method, handler);
},
};
server.setHandler("initialize", () => ({
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: true } },
serverInfo: { name: "test-server", version: "1.0.0" },
instructions: "Use test tools.",
}));
return { clientTransport: pair.client, server };
}
async function connect(): Promise<{ client: McpClient; server: TestServer }> {
const { clientTransport, server } = await createServer();
const client = new McpClient({ name: "test-client", version: "2.0.0" });
await client.connect(clientTransport);
return { client, server };
}
afterEach(() => {
vi.useRealTimers();
});
describe("McpClient", () => {
it("initializes the connection before exposing server information", async () => {
const { client, server } = await connect();
expect(client.connectionState).toBe("connected");
expect(client.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
expect(client.serverInfo).toEqual({ name: "test-server", version: "1.0.0" });
expect(client.serverCapabilities).toEqual({ tools: { listChanged: true } });
expect(client.instructions).toBe("Use test tools.");
expect(server.messages).toEqual([
{
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "test-client", version: "2.0.0" },
},
},
{ jsonrpc: "2.0", method: "notifications/initialized" },
]);
await client.close();
});
it("paginates tools and preserves protocol tool definitions", async () => {
const { client, server } = await connect();
server.setHandler("tools/list", (request) => {
const cursor = (request.params as { cursor?: string } | undefined)?.cursor;
return cursor === undefined
? {
tools: [{ name: "search", description: "Search", inputSchema: { type: "object" } }],
nextCursor: "page-2",
}
: {
tools: [
{
name: "read",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
annotations: { readOnlyHint: true },
},
],
// Some servers end pagination with an empty cursor instead of omitting it.
nextCursor: "",
};
});
expect(await client.listTools()).toEqual([
{ name: "search", description: "Search", inputSchema: { type: "object" } },
{
name: "read",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
annotations: { readOnlyHint: true },
},
]);
await client.close();
});
it("lists and reads resources", async () => {
const { client, server } = await connect();
server.setHandler("resources/list", (request) =>
(request.params as { cursor?: string } | undefined)?.cursor === undefined
? { resources: [{ uri: "file:///a", name: "a", mimeType: "text/plain" }], nextCursor: "2" }
: { resources: [{ uri: "file:///b" }] },
);
server.setHandler("resources/templates/list", () => ({
resourceTemplates: [{ uriTemplate: "repo://{owner}/{repo}", name: "repo" }],
}));
server.setHandler("resources/read", (request) => ({
contents: [{ uri: (request.params as { uri: string }).uri, text: "hello" }],
}));
// A missing name falls back to the URI.
expect(await client.listResources()).toEqual([
{ uri: "file:///a", name: "a", mimeType: "text/plain" },
{ uri: "file:///b", name: "file:///b" },
]);
expect(await client.listResourceTemplates()).toEqual([{ uriTemplate: "repo://{owner}/{repo}", name: "repo" }]);
// Single pages pass the cursor through.
expect(await client.listResourcesPage()).toEqual({
resources: [{ uri: "file:///a", name: "a", mimeType: "text/plain" }],
nextCursor: "2",
});
expect(await client.listResourcesPage("2")).toEqual({ resources: [{ uri: "file:///b", name: "file:///b" }] });
expect(await client.readResource("file:///a")).toEqual({ contents: [{ uri: "file:///a", text: "hello" }] });
server.setHandler("resources/read", () => ({ contents: [{ uri: "file:///a" }] }));
await expect(client.readResource("file:///a")).rejects.toThrow("Invalid contents in MCP resources/read result");
server.setHandler("resources/list", () => ({ resources: [{ name: "no uri" }] }));
await expect(client.listResources()).rejects.toThrow("Invalid entry in MCP resources/list result");
await client.close();
});
it("returns structured tool content and surfaces JSON-RPC errors", async () => {
const { client, server } = await connect();
server.setHandler("tools/call", (request) => {
const params = request.params as { name: string; arguments?: Record<string, unknown> };
if (params.name === "fail") throw new McpError(1234, "tool failed", { retryable: false });
return {
content: [{ type: "text", text: "ok" }],
structuredContent: { count: params.arguments?.count },
};
});
expect(await client.callTool("count", { count: 3 })).toEqual({
content: [{ type: "text", text: "ok" }],
structuredContent: { count: 3 },
});
await expect(client.callTool("fail")).rejects.toMatchObject({
name: "McpError",
code: 1234,
message: "tool failed",
data: { retryable: false },
});
await client.close();
});
it("renews the timeout on progress", async () => {
vi.useFakeTimers();
const { client, server } = await connect();
server.setHandler("tools/call", async (request) => {
const token = ((request.params as Record<string, unknown>)._meta as Record<string, unknown>)
.progressToken as number;
setTimeout(() => {
void server.transport.send({
jsonrpc: "2.0",
method: "notifications/progress",
params: { progressToken: token, progress: 1, total: 2 },
});
}, 40);
await new Promise((resolve) => setTimeout(resolve, 80));
return { content: [{ type: "text", text: "done" }] };
});
const progress = vi.fn();
const result = client.callTool("slow", {}, { timeoutMs: 50, onProgress: progress });
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(40);
expect(await result).toEqual({ content: [{ type: "text", text: "done" }] });
expect(progress).toHaveBeenCalledWith({ progressToken: 2, progress: 1, total: 2 });
await client.close();
});
it("cancels aborted and timed-out requests", async () => {
const { client, server } = await connect();
server.setHandler("tools/call", () => new Promise(() => {}));
const controller = new AbortController();
const aborted = client.callTool("wait", {}, { signal: controller.signal });
controller.abort("stop");
await expect(aborted).rejects.toBeInstanceOf(McpAbortError);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(server.messages).toContainEqual({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: { requestId: 2, reason: "stop" },
});
await expect(client.callTool("wait", {}, { timeoutMs: 5 })).rejects.toBeInstanceOf(McpTimeoutError);
await client.close();
});
it("reports transport errors without failing pending requests", async () => {
const { clientTransport, server } = await createServer();
const client = new McpClient({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const errors: Error[] = [];
client.onError((error) => errors.push(error));
let respond = () => {};
server.setHandler(
"tools/call",
() =>
new Promise((resolve) => {
respond = () => resolve({ content: [] });
}),
);
const call = client.callTool("wait");
await new Promise((resolve) => setTimeout(resolve, 0));
clientTransport.emitError(new Error("stray log line"));
respond();
expect(await call).toEqual({ content: [] });
expect(errors.map((error) => error.message)).toEqual(["stray log line"]);
await client.close();
});
it("accepts servers that answer with an older protocol version", async () => {
const { clientTransport, server } = await createServer();
server.setHandler("initialize", () => ({
protocolVersion: "2024-11-05",
capabilities: {},
serverInfo: { name: "old-server", version: "0.1.0" },
}));
const client = new McpClient({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
expect(client.protocolVersion).toBe("2024-11-05");
await client.close();
const unsupported = await createServer();
unsupported.server.setHandler("initialize", () => ({
protocolVersion: "1999-01-01",
capabilities: {},
serverInfo: { name: "ancient-server", version: "0.1.0" },
}));
const rejected = new McpClient({ name: "test-client", version: "1.0.0" });
await expect(rejected.connect(unsupported.clientTransport)).rejects.toThrow("unsupported protocol version");
expect(rejected.connectionState).toBe("closed");
});
it("defaults missing tool result content to an empty list", async () => {
const { client, server } = await connect();
server.setHandler("tools/call", () => ({ structuredContent: { ok: true } }));
expect(await client.callTool("structured")).toEqual({ content: [], structuredContent: { ok: true } });
server.setHandler("tools/call", () => ({ content: "not a list" }));
await expect(client.callTool("broken")).rejects.toThrow("Invalid MCP tools/call result");
await client.close();
});
// #10416: MCP 2026-07-28 allows any JSON value as structured content.
it("accepts structured content that is not an object", async () => {
const { client, server } = await connect();
for (const structuredContent of [[{ id: 1 }, { id: 2 }], null, "text", 3, false]) {
server.setHandler("tools/call", () => ({ content: [], structuredContent }));
expect(await client.callTool("structured")).toEqual({ content: [], structuredContent });
}
await client.close();
});
it("does not send notifications/cancelled for a timed-out initialize", async () => {
const { clientTransport, server } = await createServer();
server.setHandler("initialize", () => new Promise(() => {}));
const client = new McpClient({ name: "test-client", version: "1.0.0", requestTimeoutMs: 5 });
await expect(client.connect(clientTransport)).rejects.toBeInstanceOf(McpTimeoutError);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
server.messages.some((message) => "method" in message && message.method === "notifications/cancelled"),
).toBe(false);
});
it("notifies close listeners once when the transport drops", async () => {
const { client, server } = await connect();
const closed = vi.fn();
client.onClose(closed);
server.setHandler("tools/call", () => new Promise(() => {}));
const pending = client.callTool("wait");
await new Promise((resolve) => setTimeout(resolve, 0));
await server.transport.close();
await expect(pending).rejects.toThrow("MCP connection closed");
expect(client.connectionState).toBe("closed");
await client.close();
expect(closed).toHaveBeenCalledTimes(1);
});
it("answers roots/list and dispatches notifications", async () => {
const { clientTransport, server } = await createServer();
const client = new McpClient({
name: "test-client",
version: "1.0.0",
roots: [{ uri: "file:///workspace", name: "workspace" }],
});
await client.connect(clientTransport);
const changed = vi.fn();
client.onNotification("notifications/tools/list_changed", changed);
await server.transport.send({ jsonrpc: "2.0", id: "roots", method: "roots/list" });
await server.transport.send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(server.messages).toContainEqual({
jsonrpc: "2.0",
id: "roots",
result: { roots: [{ uri: "file:///workspace", name: "workspace" }] },
});
expect(changed).toHaveBeenCalledWith(undefined);
await client.close();
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { type ContentBlock, toLlmContent } from "../src/index.ts";
describe("toLlmContent", () => {
it("passes text and images through and replaces other blocks with text", () => {
const blocks: ContentBlock[] = [
{ type: "text", text: "hello", annotations: { priority: 1 } },
{ type: "image", data: "aW1n", mimeType: "image/png", _meta: { x: 1 } },
{ type: "audio", data: "YXVk", mimeType: "audio/wav" },
{ type: "resource_link", uri: "file:///a.txt", name: "a.txt" },
{ type: "resource", resource: { uri: "file:///b.txt", text: "inline" } },
{ type: "resource", resource: { uri: "file:///c.png", mimeType: "image/png", blob: "Yw==" } },
{ type: "resource", resource: { uri: "file:///d.bin", blob: "ZA==" } },
];
expect(toLlmContent({ content: blocks })).toEqual([
{ type: "text", text: "hello" },
{ type: "image", data: "aW1n", mimeType: "image/png" },
{ type: "text", text: "[audio audio/wav omitted]" },
{ type: "text", text: "a.txt: file:///a.txt" },
{ type: "text", text: "inline" },
{ type: "image", data: "Yw==", mimeType: "image/png" },
{ type: "text", text: "[binary resource file:///d.bin (unknown type) omitted]" },
]);
});
it("falls back to the structured content as JSON when there are no blocks", () => {
// #10416: structured content may be any JSON value.
expect(toLlmContent({ content: [], structuredContent: [1, 2] })).toEqual([
{ type: "text", text: "[\n 1,\n 2\n]" },
]);
expect(toLlmContent({ content: [], structuredContent: null })).toEqual([{ type: "text", text: "null" }]);
expect(toLlmContent({ content: [], structuredContent: { n: 1 } })).toEqual([
{ type: "text", text: '{\n "n": 1\n}' },
]);
expect(toLlmContent({ content: [{ type: "text", text: "n=1" }], structuredContent: { n: 1 } })).toEqual([
{ type: "text", text: "n=1" },
]);
});
});

View File

@@ -0,0 +1,27 @@
import { createInterface } from "node:readline";
console.error("stdio fixture ready");
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
const message = JSON.parse(line);
if (!("id" in message)) continue;
let result;
if (message.method === "initialize") {
result = {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "stdio-fixture", version: "1.0.0" },
};
} else if (message.method === "tools/list") {
result = { tools: [{ name: "echo", inputSchema: { type: "object" } }] };
} else if (message.method === "tools/call") {
result = { content: [{ type: "text", text: String(message.params.arguments.text) }] };
} else if (message.method === "ping") {
result = {};
} else {
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "not found" } })}\n`);
continue;
}
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`);
}

View File

@@ -0,0 +1,22 @@
// Answers initialize, spawns a grandchild that outlives stdin, and ignores stdin EOF and SIGTERM.
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
process.on("SIGTERM", () => {});
const grandchild = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
console.error(`grandchild ${grandchild.pid}`);
setInterval(() => {}, 1000);
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method !== "initialize") return;
const result = {
protocolVersion: "2025-06-18",
capabilities: {},
serverInfo: { name: "stubborn-fixture", version: "1.0.0" },
};
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`);
});

View File

@@ -0,0 +1,40 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
export type HttpHandler = (request: IncomingMessage, response: ServerResponse, origin: string) => Promise<void>;
const servers: Server[] = [];
export async function readBody(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
/** Starts a loopback HTTP server and returns its origin. Call `closeServers` from `afterEach`. */
export async function listen(handler: HttpHandler): Promise<string> {
let origin = "";
const server = createServer((request, response) => {
void handler(request, response, origin).catch((error) => {
response.statusCode = 500;
response.end(String(error));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("HTTP test server did not bind to TCP");
origin = `http://127.0.0.1:${address.port}`;
servers.push(server);
return origin;
}
export async function closeServers(): Promise<void> {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => (error ? reject(error) : resolve()));
}),
),
);
}

View File

@@ -0,0 +1,639 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { McpClient, StreamableHttpTransport } from "../src/index.ts";
import {
adaptOAuthProvider,
authorizeMcp,
discoverAuthorizationServerMetadata,
McpOAuthAuthorizationRequiredError,
McpOAuthProvider,
MemoryOAuthStateStore,
type OAuthCallbackPage,
OAuthCallbackServer,
type OAuthClientInformationMixed,
type OAuthClientProvider,
type OAuthDiscoveryState,
OAuthInsecureEndpointError,
OAuthIssuerMismatchError,
type OAuthTokens,
registerClient,
} from "../src/oauth/index.ts";
import { closeServers, listen, readBody } from "./helpers.ts";
class TestOAuthProvider implements OAuthClientProvider {
readonly redirectUrl: string;
readonly clientMetadata;
client: OAuthClientInformationMixed | undefined;
tokenSet: OAuthTokens | undefined;
verifier: string | undefined;
discovery: OAuthDiscoveryState | undefined;
authorizationUrl: URL | undefined;
constructor(redirectUrl: string) {
this.redirectUrl = redirectUrl;
this.clientMetadata = {
redirect_uris: [redirectUrl],
client_name: "pi-mcp-test",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
};
}
state(): string {
return "expected-state";
}
clientInformation(): OAuthClientInformationMixed | undefined {
return this.client;
}
saveClientInformation(information: OAuthClientInformationMixed): void {
this.client = information;
}
tokens(): OAuthTokens | undefined {
return this.tokenSet;
}
saveTokens(tokens: OAuthTokens): void {
this.tokenSet = tokens;
}
redirectToAuthorization(url: URL): void {
this.authorizationUrl = url;
}
saveCodeVerifier(verifier: string): void {
this.verifier = verifier;
}
codeVerifier(): string {
if (!this.verifier) throw new Error("Missing code verifier");
return this.verifier;
}
invalidateCredentials(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
if (kind === "all" || kind === "client") this.client = undefined;
if (kind === "all" || kind === "tokens") this.tokenSet = undefined;
if (kind === "all" || kind === "verifier") this.verifier = undefined;
if (kind === "all" || kind === "discovery") this.discovery = undefined;
}
saveDiscoveryState(state: OAuthDiscoveryState): void {
this.discovery = state;
}
discoveryState(): OAuthDiscoveryState | undefined {
return this.discovery;
}
}
afterEach(closeServers);
describe("MCP OAuth", () => {
it("discovers, registers, authorizes with PKCE, and refreshes on 401", async () => {
let expectedChallenge: string | undefined;
let refreshes = 0;
const origin = await listen(async (request, response, serverOrigin) => {
const url = new URL(request.url ?? "/", serverOrigin);
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
resource: `${serverOrigin}/mcp`,
authorization_servers: [serverOrigin],
scopes_supported: ["org:read"],
}),
);
return;
}
if (url.pathname === "/.well-known/oauth-authorization-server") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
issuer: serverOrigin,
authorization_endpoint: `${serverOrigin}/authorize`,
token_endpoint: `${serverOrigin}/token`,
registration_endpoint: `${serverOrigin}/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["none"],
code_challenge_methods_supported: ["S256"],
}),
);
return;
}
if (url.pathname === "/register") {
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
response.writeHead(201, { "content-type": "application/json" });
// Empty and null optional fields count as absent (#10266).
response.end(JSON.stringify({ ...metadata, client_id: "test-client", client_secret: "" }));
return;
}
if (url.pathname === "/authorize") {
expectedChallenge = url.searchParams.get("code_challenge") ?? undefined;
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
redirect.searchParams.set("code", "test-code");
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
response.writeHead(302, { location: redirect.href }).end();
return;
}
if (url.pathname === "/token") {
const params = new URLSearchParams(await readBody(request));
if (params.get("grant_type") === "refresh_token") {
refreshes++;
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
access_token: "refreshed-token",
token_type: "Bearer",
refresh_token: "",
expires_in: null,
}),
);
return;
}
const challenge = createHash("sha256")
.update(params.get("code_verifier") ?? "")
.digest("base64url");
if (params.get("code") !== "test-code" || challenge !== expectedChallenge) {
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "invalid_grant" }));
return;
}
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
access_token: "first-token",
refresh_token: "refresh-token",
token_type: "Bearer",
scope: "",
}),
);
return;
}
if (url.pathname !== "/mcp") {
response.statusCode = 404;
response.end();
return;
}
if (request.method === "GET") {
response.statusCode = 405;
response.end();
return;
}
if (request.method === "DELETE") {
response.statusCode = 200;
response.end();
return;
}
const token = request.headers.authorization;
if (token !== "Bearer first-token" && token !== "Bearer refreshed-token") {
await readBody(request);
response.writeHead(401, {
// An empty scope falls through to the resource metadata's scopes_supported.
"www-authenticate": `Bearer resource_metadata="${serverOrigin}/.well-known/oauth-protected-resource/mcp", scope=""`,
});
response.end("Unauthorized");
return;
}
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (!("id" in message)) {
response.statusCode = 202;
response.end();
return;
}
const result =
message.method === "initialize"
? {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "oauth-test", version: "1.0.0" },
}
: { tools: [{ name: "issues", inputSchema: { type: "object" } }] };
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
});
const callback = await OAuthCallbackServer.listen();
const provider = new TestOAuthProvider(callback.redirectUrl);
const firstClient = new McpClient({ name: "oauth-test", version: "1.0.0" });
await expect(
firstClient.connect(
new StreamableHttpTransport({
url: `${origin}/mcp`,
headers: { Authorization: "Bearer caller-supplied-stale-token" },
authProvider: adaptOAuthProvider(provider),
openGetStream: false,
}),
),
).rejects.toBeInstanceOf(McpOAuthAuthorizationRequiredError);
expect(provider.authorizationUrl?.searchParams.get("scope")).toBe("org:read");
expect(provider.authorizationUrl?.searchParams.get("resource")).toBe(`${origin}/mcp`);
const callbackResult = callback.waitForCallback("expected-state");
const authorizationResponse = await fetch(provider.authorizationUrl as URL, { redirect: "manual" });
await fetch(authorizationResponse.headers.get("location") as string);
const { code } = await callbackResult;
expect(
await authorizeMcp(provider, {
serverUrl: `${origin}/mcp`,
authorizationCode: code,
fetch,
}),
).toBe("AUTHORIZED");
const client = new McpClient({ name: "oauth-test", version: "1.0.0" });
await client.connect(
new StreamableHttpTransport({
url: `${origin}/mcp`,
headers: { Authorization: "Bearer caller-supplied-stale-token" },
authProvider: adaptOAuthProvider(provider),
openGetStream: false,
}),
);
expect(await client.listTools()).toEqual([{ name: "issues", inputSchema: { type: "object" } }]);
await client.close();
provider.tokenSet = { ...provider.tokenSet!, access_token: "stale-token" };
const refreshedClient = new McpClient({ name: "oauth-test", version: "1.0.0" });
await refreshedClient.connect(
new StreamableHttpTransport({
url: `${origin}/mcp`,
headers: { Authorization: "Bearer caller-supplied-stale-token" },
authProvider: adaptOAuthProvider(provider),
openGetStream: false,
}),
);
// Neither token response names a scope, so the grant has the requested scope.
expect(provider.tokenSet).toEqual({
access_token: "refreshed-token",
refresh_token: "refresh-token",
token_type: "Bearer",
scope: "org:read",
});
expect(refreshes).toBe(1);
await refreshedClient.close();
await callback.close();
});
it("shares one refresh between concurrent 401s when refresh tokens rotate", async () => {
const grants: string[] = [];
const origin = await listen(async (request, response, serverOrigin) => {
const url = new URL(request.url ?? "/", serverOrigin);
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
// Invalid resource metadata falls back to the server origin instead of failing discovery.
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ resource: `${serverOrigin}/mcp`, authorization_servers: ["not a url"] }));
return;
}
if (url.pathname === "/.well-known/oauth-authorization-server") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
// Issuer without the trailing slash that URL parsing adds to the fallback server URL.
issuer: serverOrigin,
authorization_endpoint: `${serverOrigin}/authorize`,
token_endpoint: `${serverOrigin}/token`,
response_types_supported: ["code"],
}),
);
return;
}
if (url.pathname === "/token") {
const params = new URLSearchParams(await readBody(request));
const refreshToken = params.get("refresh_token") ?? "";
grants.push(refreshToken);
response.setHeader("content-type", "application/json");
if (refreshToken !== "r1") {
response.statusCode = 400;
response.end(JSON.stringify({ error: "invalid_grant" }));
return;
}
await new Promise((resolve) => setTimeout(resolve, 20));
response.end(
JSON.stringify({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 3600 }),
);
return;
}
response.statusCode = 404;
response.end();
});
const store = new MemoryOAuthStateStore();
const provider = new McpOAuthProvider({
serverUrl: `${origin}/mcp`,
redirectUrl: "http://127.0.0.1/callback",
clientMetadata: { client_name: "test" },
clientId: "client",
store,
onRedirect: () => {},
});
await provider.saveTokens({ access_token: "a1", refresh_token: "r1", token_type: "Bearer" });
const auth = adaptOAuthProvider(provider);
const unauthorized = () => ({
response: new Response(null, { status: 401, headers: { "www-authenticate": "Bearer" } }),
serverUrl: new URL(`${origin}/mcp`),
fetch,
token: "a1",
});
await Promise.all([auth.onUnauthorized?.(unauthorized()), auth.onUnauthorized?.(unauthorized())]);
// A late 401 for a request that still carried the old token must not refresh again.
await auth.onUnauthorized?.(unauthorized());
expect(grants).toEqual(["r1"]);
expect(await auth.token()).toBe("a2");
const state = await store.load();
expect(state?.tokens?.refresh_token).toBe("r2");
expect(state?.tokensExpireAt).toBeGreaterThan(Date.now() + 3_500_000);
});
it("asks for authorization instead of refreshing when the server needs more scope", async () => {
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
provider.client = { client_id: "client" };
provider.tokenSet = { access_token: "a1", refresh_token: "r1", token_type: "Bearer", scope: "repo read:org" };
const origin = await listen(async (request, response, serverOrigin) => {
const url = new URL(request.url ?? "/", serverOrigin);
if (url.pathname === "/.well-known/oauth-authorization-server") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
issuer: serverOrigin,
authorization_endpoint: `${serverOrigin}/authorize`,
token_endpoint: `${serverOrigin}/token`,
response_types_supported: ["code"],
}),
);
return;
}
response.statusCode = url.pathname === "/token" ? 500 : 404;
response.end();
});
const auth = adaptOAuthProvider(provider);
await expect(
auth.onUnauthorized?.({
response: new Response(null, {
status: 403,
headers: { "www-authenticate": 'Bearer error="insufficient_scope", scope="repo admin"' },
}),
serverUrl: new URL(`${origin}/mcp`),
fetch,
token: "a1",
}),
).rejects.toBeInstanceOf(McpOAuthAuthorizationRequiredError);
// The challenge may list only the missing scopes; the new grant keeps the old ones too.
expect(provider.authorizationUrl?.searchParams.get("scope")).toBe("repo read:org admin");
// The working grant is kept until the user authorizes the new scope.
expect(provider.tokenSet?.access_token).toBe("a1");
});
it("binds persisted credentials to the exact MCP server URL", async () => {
const store = new MemoryOAuthStateStore();
const first = new McpOAuthProvider({
serverUrl: "https://one.example/mcp",
redirectUrl: "http://127.0.0.1/callback",
clientMetadata: { client_name: "test" },
store,
onRedirect: () => {},
});
await first.saveTokens({ access_token: "secret", token_type: "Bearer" });
expect((await first.tokens())?.access_token).toBe("secret");
const second = new McpOAuthProvider({
serverUrl: "https://two.example/mcp",
redirectUrl: "http://127.0.0.1/callback",
clientMetadata: { client_name: "test" },
store,
onRedirect: () => {},
});
expect(await second.tokens()).toBeUndefined();
});
// #10493
it("registers with an application_type derived from the redirect URIs unless one is set", async () => {
const bodies: Record<string, unknown>[] = [];
const origin = await listen(async (request, response) => {
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
bodies.push(metadata);
response.writeHead(201, { "content-type": "application/json" });
response.end(JSON.stringify({ ...metadata, client_id: "client" }));
});
const register = (redirect_uris: string[], application_type?: string) =>
registerClient(origin, {
clientMetadata: { redirect_uris, ...(application_type ? { application_type } : {}) },
});
await register(["http://127.0.0.1:1234/callback"]);
await register(["http://[::1]/callback"]);
await register(["com.example.app:/callback"]);
await register(["https://app.example/callback"]);
await register(["http://localhost/callback"], "web");
expect(bodies.map((body) => body.application_type)).toEqual(["native", "native", "native", "web", "web"]);
});
it("rejects authorization metadata whose issuer does not match discovery", async () => {
const origin = await listen(async (request, response, serverOrigin) => {
const url = new URL(request.url ?? "/", serverOrigin);
if (url.pathname === "/.well-known/oauth-authorization-server") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
issuer: "https://attacker.example",
authorization_endpoint: `${serverOrigin}/authorize`,
token_endpoint: `${serverOrigin}/token`,
response_types_supported: ["code"],
}),
);
return;
}
response.statusCode = 404;
response.end();
});
await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError);
});
// #10172
it("uses a configured authorization server metadata document as is", async () => {
const origin = await listen(async (request, response, serverOrigin) => {
const url = new URL(request.url ?? "/", serverOrigin);
response.setHeader("content-type", "application/json");
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
// Names the MCP server itself, which serves no authorization server metadata.
response.end(JSON.stringify({ resource: `${serverOrigin}/mcp`, authorization_servers: [serverOrigin] }));
} else if (url.pathname === "/idp/metadata.json") {
response.end(
JSON.stringify({
// Not derivable from the document URL; a configured document is not checked.
issuer: "https://idp.example",
authorization_endpoint: `${serverOrigin}/idp/authorize`,
token_endpoint: `${serverOrigin}/idp/token`,
response_types_supported: ["code"],
}),
);
} else {
response.statusCode = 404;
response.end();
}
});
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
provider.client = { client_id: "client" };
const options = {
serverUrl: `${origin}/mcp`,
authorizationServerMetadataUrl: new URL(`${origin}/idp/metadata.json`),
};
expect(await authorizeMcp(provider, options)).toBe("REDIRECT");
const authorizationUrl = provider.authorizationUrl as URL;
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe(`${origin}/idp/authorize`);
expect(authorizationUrl.searchParams.get("resource")).toBe(`${origin}/mcp`);
const insecure = { ...options, authorizationServerMetadataUrl: new URL("http://idp.example/metadata.json") };
await expect(authorizeMcp(provider, insecure)).rejects.toBeInstanceOf(OAuthInsecureEndpointError);
});
it("exchanges a code only when its iss parameter names the authorization server", async () => {
const codes: string[] = [];
const origin = await listen(async (request, response) => {
if (request.method !== "POST") {
response.statusCode = 404;
response.end();
return;
}
codes.push(new URLSearchParams(await readBody(request)).get("code") ?? "");
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ access_token: "token", token_type: "Bearer" }));
});
const exchange = (code: string, iss: string | undefined, issParameterSupported: boolean, metadata = true) => {
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
provider.client = { client_id: "client" };
provider.verifier = "verifier";
provider.discovery = {
authorizationServerUrl: origin,
authorizationServerMetadata: metadata
? {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
response_types_supported: ["code"],
authorization_response_iss_parameter_supported: issParameterSupported,
}
: undefined,
};
return authorizeMcp(provider, { serverUrl: `${origin}/mcp`, authorizationCode: code, iss });
};
await expect(exchange("other", "https://attacker.example", false)).rejects.toBeInstanceOf(
OAuthIssuerMismatchError,
);
await expect(exchange("missing", undefined, true)).rejects.toBeInstanceOf(OAuthIssuerMismatchError);
expect(await exchange("matching", origin, true)).toBe("AUTHORIZED");
// Servers that do not promise the parameter may omit it.
expect(await exchange("omitted", undefined, false)).toBe("AUTHORIZED");
// Without metadata there is no issuer to compare with, so a server cannot skip the check by hiding it.
await expect(exchange("unverifiable", "https://attacker.example", false, false)).rejects.toBeInstanceOf(
OAuthIssuerMismatchError,
);
expect(await exchange("no-metadata", undefined, false, false)).toBe("AUTHORIZED");
expect(codes).toEqual(["matching", "omitted", "no-metadata"]);
});
// #10565
it("stops when its signal aborts, without falling back to a redirect", async () => {
const stalled: string[] = [];
// Accepts every request and never answers.
const origin = await listen(async (request, _response, serverOrigin) => {
stalled.push(new URL(request.url ?? "/", serverOrigin).pathname);
});
const run = async (provider: TestOAuthProvider) => {
const controller = new AbortController();
const count = stalled.length;
const flow = authorizeMcp(provider, { serverUrl: `${origin}/mcp`, signal: controller.signal });
const settled = flow.catch((error: unknown) => error);
await expect.poll(() => stalled.length).toBe(count + 1);
controller.abort();
expect(await settled).toMatchObject({ name: "AbortError" });
expect(provider.authorizationUrl).toBeUndefined();
return stalled.at(-1);
};
// Discovery.
expect(await run(new TestOAuthProvider("http://127.0.0.1/callback"))).toBe(
"/.well-known/oauth-protected-resource/mcp",
);
// A failed refresh otherwise falls back to a new authorization.
const refreshing = new TestOAuthProvider("http://127.0.0.1/callback");
refreshing.client = { client_id: "client" };
refreshing.tokenSet = { access_token: "a1", refresh_token: "r1", token_type: "Bearer" };
refreshing.discovery = {
authorizationServerUrl: origin,
authorizationServerMetadata: {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
response_types_supported: ["code"],
},
};
expect(await run(refreshing)).toBe("/token");
});
});
describe("OAuthCallbackServer pages", () => {
it("renders plain text by default", async () => {
const callback = await OAuthCallbackServer.listen();
try {
const pending = callback.waitForCallback("s1");
const response = await fetch(`${callback.redirectUrl}?code=abc&state=s1`);
expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(await response.text()).toBe("Authorization complete. You may close this window.");
expect((await pending).code).toBe("abc");
} finally {
await callback.close();
}
});
// #10302
it("rejects a response on another path than the expected one", async () => {
const callback = await OAuthCallbackServer.listen({ extraPaths: ["/callback/server-id"] });
try {
const origin = new URL(callback.redirectUrl).origin;
const mixedUp = callback.waitForCallback("s1", "/callback/server-id");
mixedUp.catch(() => undefined);
const wrong = await fetch(`${origin}/callback?code=abc&state=s1`);
expect(wrong.status).toBe(400);
await expect(mixedUp).rejects.toThrow("arrived on another redirect URI");
const pending = callback.waitForCallback("s2", "/callback/server-id");
const right = await fetch(`${origin}/callback/server-id?code=abc&state=s2`);
expect(right.status).toBe(200);
expect((await pending).code).toBe("abc");
} finally {
await callback.close();
}
});
it("renders pages through renderPage", async () => {
const pages: OAuthCallbackPage[] = [];
const callback = await OAuthCallbackServer.listen({
renderPage: (page) => {
pages.push(page);
return page.ok ? "<p>ok</p>" : `<p>${page.message}</p>`;
},
});
try {
const denied = callback.waitForCallback("s1");
denied.catch(() => undefined);
const failure = await fetch(`${callback.redirectUrl}?error=access_denied&error_description=Denied&state=s1`);
expect(failure.headers.get("content-type")).toBe("text/html; charset=utf-8");
await expect(denied).rejects.toThrow("Denied");
expect(pages.at(-1)).toEqual({
ok: false,
message: "Authorization failed. You may close this window.",
details: "Denied",
});
const pending = callback.waitForCallback("s2");
const success = await fetch(`${callback.redirectUrl}?code=abc&state=s2`);
expect(await success.text()).toBe("<p>ok</p>");
expect((await pending).code).toBe("abc");
} finally {
await callback.close();
}
});
});

View File

@@ -0,0 +1,64 @@
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { McpClient, StdioTransport } from "../src/index.ts";
const fixture = fileURLToPath(new URL("./fixtures/stdio-server.mjs", import.meta.url));
const stubborn = fileURLToPath(new URL("./fixtures/stubborn-server.mjs", import.meta.url));
describe("StdioTransport", () => {
it("connects to a newline-delimited MCP server and captures stderr", async () => {
const stderr: string[] = [];
const transport = new StdioTransport({
command: process.execPath,
args: [fixture],
onStderr: (chunk) => stderr.push(chunk),
});
const client = new McpClient({ name: "stdio-test", version: "1.0.0" });
await client.connect(transport);
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
expect(await client.callTool("echo", { text: "hello" })).toEqual({
content: [{ type: "text", text: "hello" }],
});
expect(transport.pid).toBeTypeOf("number");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(stderr.join("")).toContain("stdio fixture ready");
expect(transport.stderr).toContain("stdio fixture ready");
await client.close();
expect(client.connectionState).toBe("closed");
});
it.skipIf(process.platform === "win32")(
"kills a server that ignores shutdown, including its children",
async () => {
const transport = new StdioTransport({
command: process.execPath,
args: [stubborn],
closeTimeoutMs: 100,
});
const client = new McpClient({ name: "stdio-test", version: "1.0.0" });
await client.connect(transport);
let grandchild: number | undefined;
for (let i = 0; i < 100 && grandchild === undefined; i++) {
const match = /grandchild (\d+)/.exec(transport.stderr);
if (match) grandchild = Number(match[1]);
else await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(grandchild).toBeTypeOf("number");
const startedAt = Date.now();
await client.close();
expect(Date.now() - startedAt).toBeLessThan(5_000);
let alive = true;
for (let i = 0; i < 100 && alive; i++) {
try {
process.kill(grandchild as number, 0);
await new Promise((resolve) => setTimeout(resolve, 20));
} catch {
alive = false;
}
}
expect(alive).toBe(false);
},
10_000,
);
});

View File

@@ -0,0 +1,486 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LATEST_PROTOCOL_VERSION,
type McpAuthRequiredError,
McpClient,
type McpFetch,
McpSessionExpiredError,
StreamableHttpTransport,
} from "../src/index.ts";
import { consumeSseStream, type SseEvent } from "../src/transports/streamable-http.ts";
import { closeServers, listen, readBody } from "./helpers.ts";
interface RecordedRequest {
method: string;
headers: IncomingMessage["headers"];
message?: Record<string, unknown>;
}
async function startServer(
handler: (request: IncomingMessage, response: ServerResponse, requests: RecordedRequest[]) => Promise<void>,
): Promise<{ url: string; requests: RecordedRequest[] }> {
const requests: RecordedRequest[] = [];
const origin = await listen((request, response) => handler(request, response, requests));
return { url: `${origin}/mcp`, requests };
}
async function protocolHandler(
request: IncomingMessage,
response: ServerResponse,
requests: RecordedRequest[],
body?: Record<string, unknown>,
): Promise<void> {
if (request.method === "GET") {
requests.push({ method: "GET", headers: request.headers });
response.statusCode = 405;
response.end();
return;
}
if (request.method === "DELETE") {
requests.push({ method: "DELETE", headers: request.headers });
response.statusCode = 200;
response.end();
return;
}
const message = body ?? (JSON.parse(await readBody(request)) as Record<string, unknown>);
requests.push({ method: request.method ?? "", headers: request.headers, message });
if (!("id" in message)) {
response.statusCode = 202;
response.end();
return;
}
if (message.method === "initialize") {
response.writeHead(200, {
"content-type": "application/json",
"mcp-session-id": "session-1",
});
response.end(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "http-fixture", version: "1.0.0" },
},
}),
);
return;
}
if (message.method === "tools/list") {
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
}),
);
return;
}
response.writeHead(200, { "content-type": "text/event-stream" });
response.write("id: tool-result\n");
response.end(
`data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "hello" }] } })}\n\n`,
);
}
afterEach(closeServers);
describe("consumeSseStream", () => {
it("parses chunked CRLF events, comments, IDs, and multiline data", async () => {
const encoder = new TextEncoder();
const chunks = [': keepalive\r\nid: 7\r\ndata: {"one":\r\n', "data: 1}\r\n\r\n"];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
const events: SseEvent[] = [];
await consumeSseStream(stream, { onEvent: (event) => events.push(event) });
expect(events).toEqual([{ id: "7", data: '{"one":\n1}' }]);
});
it("rejects events whose data lines exceed the limit without a blank line", async () => {
const encoder = new TextEncoder();
let sent = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
// Never sends a blank line, so the event is never dispatched.
if (sent++ > 1000) controller.close();
else controller.enqueue(encoder.encode("data: xxxxxxxxxxxxxxxx\n"));
},
});
await expect(consumeSseStream(stream, { maxEventBytes: 256, onEvent: () => {} })).rejects.toThrow(
"MCP SSE event exceeds 256 bytes",
);
expect(sent).toBeLessThan(100);
});
});
describe("StreamableHttpTransport", () => {
it("handles JSON and SSE responses with session and protocol headers", async () => {
const { url, requests } = await startServer(protocolHandler);
const transport = new StreamableHttpTransport({ url });
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(transport);
expect(transport.sessionId).toBe("session-1");
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
expect(await client.callTool("echo", { text: "hello" })).toEqual({
content: [{ type: "text", text: "hello" }],
});
await client.close();
const listRequest = requests.find((entry) => entry.message?.method === "tools/list");
expect(listRequest?.headers["mcp-session-id"]).toBe("session-1");
expect(listRequest?.headers["mcp-protocol-version"]).toBe(LATEST_PROTOCOL_VERSION);
expect(requests.some((entry) => entry.method === "GET")).toBe(true);
expect(requests.some((entry) => entry.method === "DELETE")).toBe(true);
});
it("classifies authentication failures", async () => {
const { url } = await startServer(async (request, response) => {
await readBody(request);
response.writeHead(401, { "www-authenticate": 'Bearer resource_metadata="https://example.com/meta"' });
response.end("login required");
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toMatchObject({
name: "McpAuthRequiredError",
status: 401,
body: "login required",
wwwAuthenticate: 'Bearer resource_metadata="https://example.com/meta"',
} satisfies Partial<McpAuthRequiredError>);
});
it("fails only the request whose SSE stream breaks", async () => {
let releaseSlow = () => {};
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
const name = (message.params as { name?: string } | undefined)?.name;
if (name === "broken") {
response.writeHead(200, { "content-type": "text/event-stream" });
response.end("data: not json\n\n");
return;
}
if (name === "slow") await slowGate;
await protocolHandler(request, response, requests, message);
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
const errors: Error[] = [];
client.onError((error) => errors.push(error));
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
const slow = client.callTool("slow");
await expect(client.callTool("broken")).rejects.toThrow("MCP response stream failed");
releaseSlow();
expect(await slow).toEqual({ content: [{ type: "text", text: "hello" }] });
expect(errors).toHaveLength(1);
await client.close();
});
it("opens the GET stream after initialization and sends Last-Event-ID only when resuming", async () => {
const order: string[] = [];
const { url, requests } = await startServer(async (request, response, requests) => {
if (request.method === "POST") {
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
order.push(String(message.method));
return protocolHandler(request, response, requests, message);
}
order.push(request.method ?? "");
return protocolHandler(request, response, requests);
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(new StreamableHttpTransport({ url }));
await client.callTool("echo");
await client.listTools();
await client.close();
expect(order.indexOf("GET")).toBeGreaterThan(order.indexOf("notifications/initialized"));
expect(requests.every((entry) => entry.headers["last-event-id"] === undefined)).toBe(true);
});
it("resumes a response stream the server closed before answering", async () => {
const resumeHeaders: (string | undefined)[] = [];
const { url } = await startServer(async (request, response, requests) => {
if (request.method === "GET" && request.headers["last-event-id"]) {
resumeHeaders.push(request.headers["last-event-id"] as string);
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(
`id: 2\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 2, result: { content: [{ type: "text", text: "resumed" }] } })}\n\n`,
);
return;
}
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
// Priming event (ID, no data) and a retry hint, then the server drops the stream.
response.writeHead(200, { "content-type": "text/event-stream" });
response.end("id: 1\nretry: 5\ndata:\n\n");
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
const errors: Error[] = [];
client.onError((error) => errors.push(error));
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "resumed" }] });
expect(resumeHeaders).toEqual(["1"]);
expect(errors).toEqual([]);
await client.close();
});
it("fails a request whose response stream ends without an answer", async () => {
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(": nothing here\n\n");
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
await expect(client.callTool("echo", {}, { timeoutMs: 5_000 })).rejects.toThrow(
"MCP response stream failed: stream ended without a response",
);
await client.close();
});
it("reconnects the GET stream after it drops", async () => {
let gets = 0;
const lastEventIds: (string | undefined)[] = [];
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "GET") return protocolHandler(request, response, requests);
gets++;
lastEventIds.push(request.headers["last-event-id"] as string | undefined);
response.writeHead(200, { "content-type": "text/event-stream" });
const notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" };
if (gets === 1) {
response.end(`id: g1\ndata: ${JSON.stringify(notification)}\n\n`);
return;
}
response.write(`id: g2\ndata: ${JSON.stringify(notification)}\n\n`);
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
let changes = 0;
const secondChange = new Promise<void>((resolve) => {
client.onNotification("notifications/tools/list_changed", () => {
if (++changes === 2) resolve();
});
});
await client.connect(new StreamableHttpTransport({ url, reconnect: { initialDelayMs: 1 } }));
await secondChange;
expect(lastEventIds).toEqual([undefined, "g1"]);
await client.close();
});
it("rejects a request the server accepts without a response", async () => {
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
response.statusCode = 202;
response.end();
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
await expect(client.callTool("echo")).rejects.toThrow("without a response");
await client.close();
});
it("includes the response body in HTTP errors", async () => {
const { url } = await startServer(async (request, response) => {
await readBody(request);
response.statusCode = 400;
response.end("Invalid Accept header");
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toThrow(
"MCP HTTP request failed with status 400: Invalid Accept header",
);
});
it("hands 401 and insufficient-scope 403 responses to the auth provider with the rejected token", async () => {
const seen: { status: number; token?: string }[] = [];
let token = "old";
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (request.headers.authorization === "Bearer old") {
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
return;
}
if (message.method === "tools/call" && request.headers.authorization === "Bearer new") {
response.writeHead(403, { "www-authenticate": 'Bearer error="insufficient_scope", scope="admin"' }).end();
return;
}
return protocolHandler(request, response, requests, message);
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(
new StreamableHttpTransport({
url,
openGetStream: false,
authProvider: {
token: async () => token,
onUnauthorized: async ({ response, token: rejected }) => {
seen.push({ status: response.status, token: rejected });
if (response.status === 401) token = "new";
else token = "admin";
},
},
}),
);
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "hello" }] });
expect(seen).toEqual([
{ status: 401, token: "old" },
{ status: 403, token: "new" },
]);
await client.close();
});
// #10598: a request cancelled or timed out while waiting for a shared refresh must not be retried.
it("does not retry requests that were cancelled while authorization was recovering", async () => {
let token = "old";
const executed: unknown[] = [];
const { url } = await startServer(async (request, response, requests) => {
if (request.method !== "POST") return protocolHandler(request, response, requests);
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
if (message.method === "tools/call" && request.headers.authorization === "Bearer old") {
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
return;
}
if (message.method === "tools/call") executed.push((message.params as { name: string }).name);
return protocolHandler(request, response, requests, message);
});
let releaseRefresh = () => {};
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
let refresh: Promise<void> | undefined;
let refreshes = 0;
let waiting = 0;
let allWaiting = () => {};
const waitingGate = new Promise<void>((resolve) => {
allWaiting = resolve;
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(
new StreamableHttpTransport({
url,
openGetStream: false,
authProvider: {
token: async () => token,
onUnauthorized: async () => {
if (!refresh) {
refreshes++;
refresh = refreshGate.then(() => {
token = "new";
});
}
if (++waiting === 3) allWaiting();
await refresh;
},
},
}),
);
const controller = new AbortController();
const aborted = client.callTool("aborted", {}, { signal: controller.signal });
const timedOut = client.callTool("timed-out", {}, { timeoutMs: 200 });
const kept = client.callTool("kept");
await waitingGate;
controller.abort();
await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
await expect(timedOut).rejects.toMatchObject({ name: "McpTimeoutError" });
releaseRefresh();
expect(await kept).toEqual({ content: [{ type: "text", text: "hello" }] });
expect(executed).toEqual(["kept"]);
expect(refreshes).toBe(1);
await client.close();
});
// #10565: the auth provider may refresh over the network, which closing must not wait for.
it("closes the session with the last request's token, without asking the auth provider", async () => {
const { url, requests } = await startServer(protocolHandler);
let calls = 0;
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(
new StreamableHttpTransport({
url,
openGetStream: false,
authProvider: { token: async () => `token-${++calls}` },
}),
);
const before = calls;
await client.close();
expect(calls).toBe(before);
const deletes = requests.filter((entry) => entry.method === "DELETE");
expect(deletes.map((entry) => entry.headers.authorization)).toEqual([`Bearer token-${before}`]);
expect(deletes[0]?.headers["mcp-session-id"]).toBe("session-1");
});
// #10188: Cloudflare Workers reject the platform fetch when called with a receiver other than globalThis.
it("calls fetch without a receiver", async () => {
const realFetch = globalThis.fetch;
const strictFetch = function (this: unknown, input: string | URL, init?: RequestInit) {
if (this !== undefined && this !== globalThis) throw new TypeError("Illegal invocation");
return realFetch(input, init);
} satisfies McpFetch;
const { url } = await startServer(async (request, response, requests) => {
if (request.method === "POST" && request.headers.authorization === undefined) {
await readBody(request);
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
return;
}
return protocolHandler(request, response, requests);
});
const connect = async (fetch: McpFetch | undefined) => {
let token: string | undefined;
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(
new StreamableHttpTransport({
url,
fetch,
openGetStream: false,
authProvider: {
token: async () => token,
onUnauthorized: async (context) => {
await (await context.fetch(url)).body?.cancel();
token = "token";
},
},
}),
);
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
await client.close();
};
await connect(strictFetch);
vi.stubGlobal("fetch", strictFetch);
try {
await connect(undefined);
} finally {
vi.unstubAllGlobals();
}
});
it("classifies an expired established session", async () => {
let posts = 0;
const { url } = await startServer(async (request, response, requests) => {
if (request.method === "POST" && posts++ >= 2) {
await readBody(request);
response.statusCode = 404;
response.end("gone");
return;
}
await protocolHandler(request, response, requests);
});
const client = new McpClient({ name: "http-test", version: "1.0.0" });
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
await expect(client.listTools()).rejects.toBeInstanceOf(McpSessionExpiredError);
await client.close();
});
});