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:
339
third_party/pi/packages/mcp/test/client.test.ts
vendored
Normal file
339
third_party/pi/packages/mcp/test/client.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
39
third_party/pi/packages/mcp/test/content.test.ts
vendored
Normal file
39
third_party/pi/packages/mcp/test/content.test.ts
vendored
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
third_party/pi/packages/mcp/test/fixtures/stdio-server.mjs
vendored
Normal file
27
third_party/pi/packages/mcp/test/fixtures/stdio-server.mjs
vendored
Normal 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`);
|
||||
}
|
||||
22
third_party/pi/packages/mcp/test/fixtures/stubborn-server.mjs
vendored
Normal file
22
third_party/pi/packages/mcp/test/fixtures/stubborn-server.mjs
vendored
Normal 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`);
|
||||
});
|
||||
40
third_party/pi/packages/mcp/test/helpers.ts
vendored
Normal file
40
third_party/pi/packages/mcp/test/helpers.ts
vendored
Normal 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()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
639
third_party/pi/packages/mcp/test/oauth.test.ts
vendored
Normal file
639
third_party/pi/packages/mcp/test/oauth.test.ts
vendored
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
third_party/pi/packages/mcp/test/stdio.test.ts
vendored
Normal file
64
third_party/pi/packages/mcp/test/stdio.test.ts
vendored
Normal 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,
|
||||
);
|
||||
});
|
||||
486
third_party/pi/packages/mcp/test/streamable-http.test.ts
vendored
Normal file
486
third_party/pi/packages/mcp/test/streamable-http.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user