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,20 @@
export type McpFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface UnauthorizedContext {
/** The 401 response, or a 403 response whose challenge reports `insufficient_scope`. */
response: Response;
serverUrl: URL;
fetch: McpFetch;
/** Access token the rejected request carried, if any. A different current token means another request already refreshed it. */
token?: string;
}
/** Supplies bearer tokens to an MCP HTTP transport and may refresh them after a 401 response. */
export interface AuthProvider {
/**
* Token for the next request. May refresh it over the network first, so the transport calls it only
* before requests whose answer it waits for. Closing a session reuses the token of the last request.
*/
token(): Promise<string | undefined>;
onUnauthorized?(context: UnauthorizedContext): Promise<void>;
}

View File

@@ -0,0 +1,621 @@
import type { CallToolResult } from "./protocol/content.ts";
import {
isJsonRpcId,
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcResponse,
isObject,
JSON_RPC_ERROR_CODES,
type JsonRpcId,
type JsonRpcMessage,
type JsonRpcRequest,
type JsonRpcResponse,
McpAbortError,
McpConnectionClosedError,
McpError,
McpTimeoutError,
toError,
} from "./protocol/jsonrpc.ts";
import {
type ClientCapabilities,
type Implementation,
type InitializeResult,
LATEST_PROTOCOL_VERSION,
type ListResourcesResult,
type ListResourceTemplatesResult,
type ProgressNotification,
type ReadResourceResult,
type Resource,
type ResourceTemplate,
type Root,
type ServerCapabilities,
SUPPORTED_PROTOCOL_VERSIONS,
type SupportedProtocolVersion,
type Tool,
} from "./protocol/types.ts";
import type { McpTransport } from "./transports/transport.ts";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const MAX_LIST_PAGES = 1_000;
type ClientState = "idle" | "connecting" | "connected" | "closed";
type NotificationListener = (params: unknown) => void;
type ErrorListener = (error: Error) => void;
type CloseListener = () => void;
type RequestHandler = (params: unknown, context: { signal: AbortSignal }) => unknown | Promise<unknown>;
export interface McpClientOptions extends Implementation {
capabilities?: ClientCapabilities;
protocolVersion?: SupportedProtocolVersion;
requestTimeoutMs?: number;
roots?: readonly Root[] | (() => readonly Root[] | Promise<readonly Root[]>);
}
export interface McpRequestOptions {
signal?: AbortSignal;
timeoutMs?: number;
onProgress?: (progress: ProgressNotification) => void;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
timeoutMs: number;
timer: ReturnType<typeof setTimeout> | undefined;
signal: AbortSignal | undefined;
onAbort: () => void;
/** Aborted when the request settles, so the transport does not dispatch (or retry) it afterwards. */
sendController: AbortController;
cancellable: boolean;
onProgress: ((progress: ProgressNotification) => void) | undefined;
progressToken: JsonRpcId | undefined;
}
function validateInitializeResult(value: unknown): InitializeResult {
if (
!isObject(value) ||
typeof value.protocolVersion !== "string" ||
!isObject(value.capabilities) ||
!isObject(value.serverInfo) ||
typeof value.serverInfo.name !== "string" ||
typeof value.serverInfo.version !== "string" ||
(value.instructions !== undefined && typeof value.instructions !== "string")
) {
throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP initialize result");
}
return value as unknown as InitializeResult;
}
function invalid(message: string): McpError {
return new McpError(JSON_RPC_ERROR_CODES.invalidRequest, message);
}
/** One page of a paginated list: the items under `key`, each checked by `isItem`. */
function validateListPage(
method: string,
key: string,
value: unknown,
isItem: (item: Record<string, unknown>) => boolean,
): { items: Record<string, unknown>[]; nextCursor?: string } {
const items = isObject(value) ? value[key] : undefined;
if (!isObject(value) || !Array.isArray(items)) throw invalid(`Invalid MCP ${method} result`);
for (const item of items) {
if (!isObject(item) || !isItem(item)) throw invalid(`Invalid entry in MCP ${method} result`);
}
// Some servers end pagination with `null` or `""` instead of omitting the cursor.
const nextCursor = value.nextCursor === null || value.nextCursor === "" ? undefined : value.nextCursor;
if (nextCursor !== undefined && typeof nextCursor !== "string") throw invalid(`Invalid MCP ${method} cursor`);
return { items, ...(nextCursor === undefined ? {} : { nextCursor }) };
}
const isTool = (tool: Record<string, unknown>) => typeof tool.name === "string" && isObject(tool.inputSchema);
// `name` is required by the spec, but some servers omit it; the URI stands in.
const isResource = (resource: Record<string, unknown>) =>
typeof resource.uri === "string" && (resource.name === undefined || typeof resource.name === "string");
const isResourceTemplate = (template: Record<string, unknown>) =>
typeof template.uriTemplate === "string" && (template.name === undefined || typeof template.name === "string");
function toResource(item: Record<string, unknown>): Resource {
return { ...item, name: item.name ?? item.uri } as Resource;
}
function toResourceTemplate(item: Record<string, unknown>): ResourceTemplate {
return { ...item, name: item.name ?? item.uriTemplate } as ResourceTemplate;
}
function pageCursor(page: { nextCursor?: string }): { nextCursor?: string } {
return page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor };
}
function validateReadResourceResult(value: unknown): ReadResourceResult {
if (!isObject(value) || !Array.isArray(value.contents)) throw invalid("Invalid MCP resources/read result");
for (const contents of value.contents) {
if (
!isObject(contents) ||
typeof contents.uri !== "string" ||
(typeof contents.text !== "string" && typeof contents.blob !== "string")
) {
throw invalid("Invalid contents in MCP resources/read result");
}
}
return value as unknown as ReadResourceResult;
}
/**
* `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too).
* `structuredContent` may be any JSON value, as MCP 2026-07-28 allows.
*/
function validateCallToolResult(value: unknown): CallToolResult {
if (!isObject(value) || (value.content !== undefined && !Array.isArray(value.content))) {
throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call result");
}
return (value.content === undefined ? { ...value, content: [] } : value) as unknown as CallToolResult;
}
export class McpClient {
readonly options: Readonly<McpClientOptions>;
private state: ClientState = "idle";
private transport: McpTransport | undefined;
private nextRequestId = 1;
private serverInfoValue: Implementation | undefined;
private serverCapabilitiesValue: ServerCapabilities | undefined;
private instructionsValue: string | undefined;
private protocolVersionValue: string | undefined;
private pending = new Map<JsonRpcId, PendingRequest>();
private progressRequests = new Map<JsonRpcId, JsonRpcId>();
private incoming = new Map<JsonRpcId, AbortController>();
private requestHandlers = new Map<string, RequestHandler>();
private notificationListeners = new Map<string, Set<NotificationListener>>();
private errorListeners = new Set<ErrorListener>();
private closeListeners = new Set<CloseListener>();
private disposers: (() => void)[] = [];
constructor(options: McpClientOptions) {
this.options = Object.freeze({ ...options });
this.requestHandlers.set("ping", () => ({}));
const roots = options.roots;
if (roots) {
this.requestHandlers.set("roots/list", async () => ({
roots: [...(typeof roots === "function" ? await roots() : roots)],
}));
}
}
get connectionState(): ClientState {
return this.state;
}
get serverInfo(): Implementation | undefined {
return this.serverInfoValue;
}
get serverCapabilities(): ServerCapabilities | undefined {
return this.serverCapabilitiesValue;
}
get instructions(): string | undefined {
return this.instructionsValue;
}
get protocolVersion(): string | undefined {
return this.protocolVersionValue;
}
async connect(transport: McpTransport): Promise<InitializeResult> {
if (this.state !== "idle") throw new Error(`Cannot connect MCP client in ${this.state} state`);
this.state = "connecting";
this.transport = transport;
this.disposers = [
transport.onMessage((message) => this.handleMessage(message)),
// Transport errors are reported only. Pending requests fail when the transport closes.
transport.onError((error) => this.emitError(error)),
transport.onClose(() => this.handleTransportClose()),
];
try {
await transport.start();
const capabilities: ClientCapabilities = { ...this.options.capabilities };
if (this.options.roots && capabilities.roots === undefined) capabilities.roots = {};
const result = validateInitializeResult(
await this.requestInternal(
"initialize",
{
protocolVersion: this.options.protocolVersion ?? LATEST_PROTOCOL_VERSION,
capabilities,
clientInfo: {
name: this.options.name,
version: this.options.version,
...(this.options.title === undefined ? {} : { title: this.options.title }),
},
},
{},
true,
),
);
if (!(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(result.protocolVersion)) {
throw new Error(`MCP server selected unsupported protocol version ${result.protocolVersion}`);
}
this.protocolVersionValue = result.protocolVersion;
this.serverInfoValue = result.serverInfo;
this.serverCapabilitiesValue = result.capabilities;
this.instructionsValue = result.instructions;
transport.setProtocolVersion?.(result.protocolVersion);
await this.notifyInternal("notifications/initialized", undefined, true);
this.state = "connected";
return result;
} catch (error) {
await this.close().catch(() => {});
throw error;
}
}
request<Result = unknown>(
method: string,
params?: Record<string, unknown>,
options: McpRequestOptions = {},
): Promise<Result> {
return this.requestInternal(method, params, options, false) as Promise<Result>;
}
notify(method: string, params?: Record<string, unknown>): Promise<void> {
return this.notifyInternal(method, params, false);
}
setRequestHandler(method: string, handler: RequestHandler): () => void {
this.requestHandlers.set(method, handler);
return () => {
if (this.requestHandlers.get(method) === handler) this.requestHandlers.delete(method);
};
}
onNotification(method: string, listener: NotificationListener): () => void {
const listeners = this.notificationListeners.get(method) ?? new Set<NotificationListener>();
this.notificationListeners.set(method, listeners);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) this.notificationListeners.delete(method);
};
}
onError(listener: ErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
/** Called once when the connection closes, whether the transport dropped or `close()` was called. */
onClose(listener: CloseListener): () => void {
this.closeListeners.add(listener);
return () => this.closeListeners.delete(listener);
}
async ping(options: McpRequestOptions = {}): Promise<void> {
await this.request("ping", undefined, options);
}
async listTools(options: McpRequestOptions = {}): Promise<Tool[]> {
return (await this.listAll("tools/list", "tools", isTool, options)) as unknown as Tool[];
}
/** Every resource, following `nextCursor` through all pages. */
async listResources(options: McpRequestOptions = {}): Promise<Resource[]> {
return (await this.listAll("resources/list", "resources", isResource, options)).map(toResource);
}
/** One page of resources, starting at `cursor`. */
async listResourcesPage(cursor?: string, options: McpRequestOptions = {}): Promise<ListResourcesResult> {
const page = await this.listPage("resources/list", "resources", isResource, cursor, options);
return { resources: page.items.map(toResource), ...pageCursor(page) };
}
/** Every resource template, following `nextCursor` through all pages. */
async listResourceTemplates(options: McpRequestOptions = {}): Promise<ResourceTemplate[]> {
const templates = await this.listAll(
"resources/templates/list",
"resourceTemplates",
isResourceTemplate,
options,
);
return templates.map(toResourceTemplate);
}
/** One page of resource templates, starting at `cursor`. */
async listResourceTemplatesPage(
cursor?: string,
options: McpRequestOptions = {},
): Promise<ListResourceTemplatesResult> {
const page = await this.listPage(
"resources/templates/list",
"resourceTemplates",
isResourceTemplate,
cursor,
options,
);
return { resourceTemplates: page.items.map(toResourceTemplate), ...pageCursor(page) };
}
async readResource(uri: string, options: McpRequestOptions = {}): Promise<ReadResourceResult> {
return validateReadResourceResult(await this.request("resources/read", { uri }, options));
}
private async listPage(
method: string,
key: string,
isItem: (item: Record<string, unknown>) => boolean,
cursor: string | undefined,
options: McpRequestOptions,
): Promise<{ items: Record<string, unknown>[]; nextCursor?: string }> {
return validateListPage(
method,
key,
await this.request(method, cursor === undefined ? undefined : { cursor }, options),
isItem,
);
}
/** Every item of a paginated list method. */
private async listAll(
method: string,
key: string,
isItem: (item: Record<string, unknown>) => boolean,
options: McpRequestOptions,
): Promise<Record<string, unknown>[]> {
const items: Record<string, unknown>[] = [];
const cursors = new Set<string>();
let cursor: string | undefined;
for (let pageNumber = 0; pageNumber < MAX_LIST_PAGES; pageNumber++) {
const page = await this.listPage(method, key, isItem, cursor, options);
items.push(...page.items);
if (page.nextCursor === undefined) return items;
if (cursors.has(page.nextCursor))
throw new Error(`MCP ${method} returned duplicate cursor: ${page.nextCursor}`);
cursors.add(page.nextCursor);
cursor = page.nextCursor;
}
throw new Error(`MCP ${method} exceeded ${MAX_LIST_PAGES} pages`);
}
async callTool(
name: string,
args?: Record<string, unknown>,
options: McpRequestOptions = {},
): Promise<CallToolResult> {
return validateCallToolResult(
await this.request("tools/call", { name, ...(args === undefined ? {} : { arguments: args }) }, options),
);
}
async close(): Promise<void> {
const transport = this.transport;
this.transport = undefined;
this.disposeTransportListeners();
this.markClosed(new McpConnectionClosedError());
await transport?.close();
}
private async requestInternal(
method: string,
params: Record<string, unknown> | undefined,
options: McpRequestOptions,
allowConnecting: boolean,
): Promise<unknown> {
const transport = this.requireTransport(allowConnecting);
if (options.signal?.aborted) throw new McpAbortError();
const id = this.nextRequestId++;
const progressToken = options.onProgress ? id : undefined;
const requestParams =
progressToken === undefined
? params
: { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } };
const message: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
...(requestParams === undefined ? {} : { params: requestParams }),
};
return new Promise<unknown>((resolve, reject) => {
const entry: PendingRequest = {
resolve,
reject,
timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
timer: undefined,
signal: options.signal,
// The spec forbids cancelling `initialize`.
onAbort: () =>
this.cancelPending(
id,
new McpAbortError(),
method !== "initialize",
String(options.signal?.reason ?? "Aborted"),
),
sendController: new AbortController(),
cancellable: method !== "initialize",
onProgress: options.onProgress,
progressToken,
};
this.pending.set(id, entry);
if (progressToken !== undefined) this.progressRequests.set(progressToken, id);
options.signal?.addEventListener("abort", entry.onAbort, { once: true });
this.armTimeout(id, entry);
transport
.send(message, { signal: entry.sendController.signal })
.catch((error) => this.cancelPending(id, error, false));
});
}
private async notifyInternal(
method: string,
params: Record<string, unknown> | undefined,
allowConnecting: boolean,
): Promise<void> {
await this.requireTransport(allowConnecting).send({
jsonrpc: "2.0",
method,
...(params === undefined ? {} : { params }),
});
}
private requireTransport(allowConnecting: boolean): McpTransport {
if (this.transport && (this.state === "connected" || (allowConnecting && this.state === "connecting"))) {
return this.transport;
}
throw new McpConnectionClosedError(`MCP client is ${this.state}`);
}
private handleMessage(message: JsonRpcMessage): void {
if (isJsonRpcResponse(message)) {
this.handleResponse(message);
return;
}
if (isJsonRpcRequest(message)) {
void this.handleRequest(message);
return;
}
if (isJsonRpcNotification(message)) {
this.handleNotification(message.method, message.params);
return;
}
this.emitError(new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Received invalid JSON-RPC message"));
}
private handleResponse(message: JsonRpcResponse): void {
const entry = this.pending.get(message.id);
if (!entry) {
this.emitError(new Error(`Received response for unknown MCP request ${String(message.id)}`));
return;
}
this.removePending(message.id, entry);
if ("error" in message) entry.reject(new McpError(message.error.code, message.error.message, message.error.data));
else entry.resolve(message.result);
}
private async handleRequest(message: JsonRpcRequest): Promise<void> {
const transport = this.transport;
if (!transport) return;
const handler = this.requestHandlers.get(message.method);
if (!handler) {
await transport
.send({
jsonrpc: "2.0",
id: message.id,
error: { code: JSON_RPC_ERROR_CODES.methodNotFound, message: `Method not found: ${message.method}` },
})
.catch((error) => this.emitError(error));
return;
}
const controller = new AbortController();
this.incoming.set(message.id, controller);
try {
const result = await handler(message.params, { signal: controller.signal });
await transport.send({ jsonrpc: "2.0", id: message.id, result: result ?? {} });
} catch (error) {
const responseError =
error instanceof McpError
? { code: error.code, message: error.message, data: error.data }
: { code: JSON_RPC_ERROR_CODES.internalError, message: toError(error).message };
await transport
.send({ jsonrpc: "2.0", id: message.id, error: responseError })
.catch((sendError) => this.emitError(sendError));
} finally {
this.incoming.delete(message.id);
}
}
private handleNotification(method: string, params: unknown): void {
if (method === "notifications/progress") this.handleProgress(params);
else if (method === "notifications/cancelled") this.handleCancelled(params);
for (const listener of this.notificationListeners.get(method) ?? []) {
try {
listener(params);
} catch (error) {
this.emitError(error);
}
}
}
private handleProgress(params: unknown): void {
if (!isObject(params) || !isJsonRpcId(params.progressToken) || typeof params.progress !== "number") return;
const requestId = this.progressRequests.get(params.progressToken);
const entry = requestId === undefined ? undefined : this.pending.get(requestId);
if (requestId === undefined || !entry) return;
this.armTimeout(requestId, entry);
try {
entry.onProgress?.(params as unknown as ProgressNotification);
} catch (error) {
this.emitError(error);
}
}
private handleCancelled(params: unknown): void {
if (isObject(params) && isJsonRpcId(params.requestId)) this.incoming.get(params.requestId)?.abort(params.reason);
}
private armTimeout(id: JsonRpcId, entry: PendingRequest): void {
if (entry.timer) clearTimeout(entry.timer);
if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0) return;
entry.timer = setTimeout(() => {
this.cancelPending(id, new McpTimeoutError(entry.timeoutMs), entry.cancellable, "Request timed out");
}, entry.timeoutMs);
}
private cancelPending(id: JsonRpcId, error: unknown, notifyServer: boolean, reason?: string): void {
const entry = this.pending.get(id);
if (!entry) return;
this.removePending(id, entry);
entry.reject(error);
if (notifyServer && this.transport) {
void this.transport
.send({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: { requestId: id, ...(reason ? { reason } : {}) },
})
.catch((sendError) => this.emitError(sendError));
}
}
private removePending(id: JsonRpcId, entry: PendingRequest): void {
this.pending.delete(id);
if (entry.timer) clearTimeout(entry.timer);
if (entry.progressToken !== undefined) this.progressRequests.delete(entry.progressToken);
entry.signal?.removeEventListener("abort", entry.onAbort);
entry.sendController.abort();
}
private rejectPending(error: unknown): void {
for (const [id, entry] of this.pending) {
this.removePending(id, entry);
entry.reject(error);
}
}
private handleTransportClose(): void {
this.markClosed(new McpConnectionClosedError());
}
/** Idempotent: rejects in-flight requests, aborts server requests we are serving, and flips the state. */
private markClosed(error: Error): void {
const wasClosed = this.state === "closed";
this.state = "closed";
this.rejectPending(error);
for (const controller of this.incoming.values()) controller.abort(error);
this.incoming.clear();
if (wasClosed) return;
for (const listener of [...this.closeListeners]) {
try {
listener();
} catch (listenerError) {
this.emitError(listenerError);
}
}
}
private emitError(error: unknown): void {
const normalized = toError(error);
for (const listener of this.errorListeners) listener(normalized);
}
private disposeTransportListeners(): void {
for (const dispose of this.disposers.splice(0)) dispose();
}
}

View File

@@ -0,0 +1,72 @@
export type { AuthProvider, McpFetch, UnauthorizedContext } from "./auth-provider.ts";
export { McpClient, type McpClientOptions, type McpRequestOptions } from "./client.ts";
export {
type AudioContent,
type BlobResourceContents,
type CallToolResult,
type ContentAnnotations,
type ContentBlock,
type EmbeddedResourceContent,
type ImageContent,
type LlmContent,
type ResourceLinkContent,
type TextContent,
type TextResourceContents,
toLlmContent,
} from "./protocol/content.ts";
export {
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcResponse,
JSON_RPC_ERROR_CODES,
type JsonRpcErrorObject,
type JsonRpcErrorResponse,
type JsonRpcId,
type JsonRpcMessage,
type JsonRpcNotification,
type JsonRpcRequest,
type JsonRpcResponse,
type JsonRpcSuccessResponse,
McpAbortError,
McpConnectionClosedError,
McpError,
McpTimeoutError,
parseJsonRpcMessage,
} from "./protocol/jsonrpc.ts";
export {
type CancelledNotification,
type ClientCapabilities,
type Implementation,
type InitializeParams,
type InitializeResult,
LATEST_PROTOCOL_VERSION,
type ListResourcesResult,
type ListResourceTemplatesResult,
type ListToolsResult,
type ProgressNotification,
type ReadResourceResult,
type Resource,
type ResourceTemplate,
type Root,
type ServerCapabilities,
SUPPORTED_PROTOCOL_VERSIONS,
type SupportedProtocolVersion,
type Tool,
type ToolAnnotations,
type ToolExecution,
} from "./protocol/types.ts";
export { StdioTransport, type StdioTransportOptions } from "./transports/stdio.ts";
export {
McpAuthRequiredError,
McpHttpError,
McpSessionExpiredError,
StreamableHttpTransport,
type StreamableHttpTransportOptions,
} from "./transports/streamable-http.ts";
export type {
McpTransport,
McpTransportCloseListener,
McpTransportErrorListener,
McpTransportMessageListener,
McpTransportSendOptions,
} from "./transports/transport.ts";

View File

@@ -0,0 +1,164 @@
import { createServer, type Server, type ServerResponse } from "node:http";
export interface OAuthCallback {
code: string;
state: string;
iss?: string;
}
/** Outcome shown on the browser page after the redirect. */
export type OAuthCallbackPage = { ok: true } | { ok: false; message: string; details?: string };
export interface OAuthCallbackServerOptions {
/** Address to listen on. Default: `127.0.0.1`. */
host?: string;
/**
* Host name in `redirectUrl`, for example `localhost` for a client registered with it while
* listening on `127.0.0.1`. Default: `host`.
*/
redirectHost?: string;
port?: number;
path?: string;
/** More paths that receive the callback, for example a server-specific path of a redirect URI. */
extraPaths?: string[];
timeoutMs?: number;
/** Render the browser page as HTML. Default: a plain-text message. */
renderPage?: (page: OAuthCallbackPage) => string;
}
function plainText(page: OAuthCallbackPage): string {
if (page.ok) return "Authorization complete. You may close this window.";
return page.details ? `${page.message}\n\n${page.details}` : page.message;
}
export class OAuthCallbackServer {
readonly redirectUrl: string;
private server: Server;
private paths: string[];
private timeoutMs: number;
private renderPage: ((page: OAuthCallbackPage) => string) | undefined;
private pending = new Map<
string,
{
resolve: (callback: OAuthCallback) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
path: string | undefined;
}
>();
private constructor(
server: Server,
redirectUrl: string,
paths: string[],
timeoutMs: number,
renderPage: ((page: OAuthCallbackPage) => string) | undefined,
) {
this.server = server;
this.redirectUrl = redirectUrl;
this.paths = paths;
this.timeoutMs = timeoutMs;
this.renderPage = renderPage;
}
static async listen(options: OAuthCallbackServerOptions = {}): Promise<OAuthCallbackServer> {
const host = options.host ?? "127.0.0.1";
const redirectHost = options.redirectHost ?? host;
const path = options.path ?? "/callback";
let instance: OAuthCallbackServer | undefined;
const server = createServer((request, response) => instance?.handle(request.url ?? "/", response));
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port ?? 0, host, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("OAuth callback server did not bind to TCP");
instance = new OAuthCallbackServer(
server,
`http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${path}`,
[path, ...(options.extraPaths ?? [])],
options.timeoutMs ?? 5 * 60_000,
options.renderPage,
);
return instance;
}
/**
* Wait for the authorization response with `state`. With `path`, a response on another path fails, so
* a server-specific redirect URI can tell authorization servers apart (RFC 9700 section 4.4.2.2).
*/
waitForCallback(state: string, path?: string): Promise<OAuthCallback> {
if (this.pending.has(state)) throw new Error("OAuth state is already pending");
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(state);
reject(new Error("OAuth callback timed out"));
}, this.timeoutMs);
this.pending.set(state, { resolve, reject, timer, path });
});
}
async close(): Promise<void> {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("OAuth callback server closed"));
}
this.pending.clear();
await new Promise<void>((resolve, reject) => {
this.server.close((error) => (error ? reject(error) : resolve()));
});
}
private reply(response: ServerResponse, status: number, page: OAuthCallbackPage): void {
if (this.renderPage) {
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
response.end(this.renderPage(page));
} else {
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(plainText(page));
}
}
private handle(rawUrl: string, response: ServerResponse): void {
const url = new URL(rawUrl, this.redirectUrl);
if (!this.paths.includes(url.pathname)) {
this.reply(response, 404, { ok: false, message: "Not found" });
return;
}
const state = url.searchParams.get("state");
const pending = state ? this.pending.get(state) : undefined;
if (!state || !pending) {
this.reply(response, 400, { ok: false, message: "Invalid or expired OAuth state" });
return;
}
clearTimeout(pending.timer);
this.pending.delete(state);
if (pending.path !== undefined && url.pathname !== pending.path) {
pending.reject(new Error("The authorization response arrived on another redirect URI"));
this.reply(response, 400, { ok: false, message: "Unexpected redirect URI" });
return;
}
const error = url.searchParams.get("error");
if (error) {
const description = url.searchParams.get("error_description") ?? error;
pending.reject(new Error(description));
this.reply(response, 200, {
ok: false,
message: "Authorization failed. You may close this window.",
details: description,
});
return;
}
const code = url.searchParams.get("code");
if (!code) {
pending.reject(new Error("OAuth callback did not include an authorization code"));
this.reply(response, 400, { ok: false, message: "Missing authorization code" });
return;
}
const iss = url.searchParams.get("iss");
pending.resolve({ code, state, ...(iss ? { iss } : {}) });
this.reply(response, 200, { ok: true });
}
}

View File

@@ -0,0 +1,215 @@
/*
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0 src/client/auth.ts.
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
* Modified to remove Zod/CORS shims and enforce authorization-server issuer validation.
*/
import type { McpFetch } from "../auth-provider.ts";
import { LATEST_PROTOCOL_VERSION } from "../protocol/types.ts";
import { OAuthIssuerMismatchError } from "./errors.ts";
import {
type AuthorizationServerMetadata,
type OAuthChallenge,
type OAuthProtectedResourceMetadata,
type OAuthServerInfo,
parseAuthorizationServerMetadata,
parseProtectedResourceMetadata,
} from "./types.ts";
function discard(response: Response | undefined): void {
void response?.body?.cancel().catch(() => {});
}
/** 4xx and 502 mean "not here", so discovery tries the next candidate URL. */
function isDiscoveryMiss(status: number): boolean {
return (status >= 400 && status < 500) || status === 502;
}
/** Path suffix for `/.well-known/<kind><path>`; empty for the root path. */
function pathSuffix(pathname: string): string {
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
function field(header: string, name: string): string | undefined {
const match = header.match(new RegExp(String.raw`(?:^|[,\s])${name}=(?:"([^"]*)"|([^\s,]+))`, "i"));
// An empty value (`scope=""`) carries no information, so it counts as absent.
return match?.[1] || match?.[2] || undefined;
}
export function parseWwwAuthenticate(header: string | null): OAuthChallenge {
if (!header) return {};
const scheme = header.trimStart().split(/\s+/, 1)[0]?.toLowerCase();
if (scheme !== "bearer" && scheme !== "dpop") return {};
const resourceMetadata = field(header, "resource_metadata");
let resourceMetadataUrl: URL | undefined;
if (resourceMetadata) {
try {
resourceMetadataUrl = new URL(resourceMetadata);
} catch {}
}
return {
resourceMetadataUrl,
scope: field(header, "scope"),
error: field(header, "error"),
errorDescription: field(header, "error_description"),
};
}
async function fetchMetadata(
url: URL,
fetch: McpFetch,
protocolVersion: string,
signal: AbortSignal | undefined,
): Promise<Response> {
return fetch(url, {
headers: { Accept: "application/json", "MCP-Protocol-Version": protocolVersion },
signal,
});
}
export async function discoverProtectedResourceMetadata(
serverUrl: string | URL,
options: {
resourceMetadataUrl?: string | URL;
protocolVersion?: string;
fetch?: McpFetch;
signal?: AbortSignal;
} = {},
): Promise<OAuthProtectedResourceMetadata> {
const server = new URL(serverUrl);
const fetch = options.fetch ?? globalThis.fetch;
const version = options.protocolVersion ?? LATEST_PROTOCOL_VERSION;
let response = await fetchMetadata(
options.resourceMetadataUrl
? new URL(options.resourceMetadataUrl)
: new URL(`/.well-known/oauth-protected-resource${pathSuffix(server.pathname)}`, server.origin),
fetch,
version,
options.signal,
);
if (!options.resourceMetadataUrl && server.pathname !== "/" && isDiscoveryMiss(response.status)) {
discard(response);
response = await fetchMetadata(
new URL("/.well-known/oauth-protected-resource", server.origin),
fetch,
version,
options.signal,
);
}
if (!response.ok) {
discard(response);
throw new Error(`HTTP ${response.status} loading OAuth protected resource metadata`);
}
return parseProtectedResourceMetadata(await response.json());
}
export function buildAuthorizationServerDiscoveryUrls(
authorizationServerUrl: string | URL,
): { url: URL; type: "oauth" | "oidc" }[] {
const issuer = new URL(authorizationServerUrl);
const path = pathSuffix(issuer.pathname);
const urls: { url: URL; type: "oauth" | "oidc" }[] = [
{ url: new URL(`/.well-known/oauth-authorization-server${path}`, issuer.origin), type: "oauth" },
{ url: new URL(`/.well-known/openid-configuration${path}`, issuer.origin), type: "oidc" },
];
if (path) urls.push({ url: new URL(`${path}/.well-known/openid-configuration`, issuer.origin), type: "oidc" });
return urls;
}
export async function discoverAuthorizationServerMetadata(
authorizationServerUrl: string | URL,
options: { fetch?: McpFetch; protocolVersion?: string; skipIssuerValidation?: boolean; signal?: AbortSignal } = {},
): Promise<AuthorizationServerMetadata | undefined> {
const fetch = options.fetch ?? globalThis.fetch;
for (const { url } of buildAuthorizationServerDiscoveryUrls(authorizationServerUrl)) {
const response = await fetchMetadata(
url,
fetch,
options.protocolVersion ?? LATEST_PROTOCOL_VERSION,
options.signal,
);
if (!response.ok) {
discard(response);
if (isDiscoveryMiss(response.status)) continue;
throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`);
}
const metadata = parseAuthorizationServerMetadata(await response.json());
if (!options.skipIssuerValidation) {
const expected = String(authorizationServerUrl);
// URL parsing adds a trailing slash to bare origins, so compare without one on either side.
const trim = (value: string) => (value.endsWith("/") ? value.slice(0, -1) : value);
if (trim(metadata.issuer) !== trim(expected)) throw new OAuthIssuerMismatchError(expected, metadata.issuer);
}
return metadata;
}
return undefined;
}
export async function discoverOAuthServerInfo(
serverUrl: string | URL,
options: {
resourceMetadataUrl?: URL;
/** Metadata document to use instead of discovery. It is trusted as configured, so its issuer is not checked. */
authorizationServerMetadataUrl?: URL;
fetch?: McpFetch;
skipIssuerValidation?: boolean;
signal?: AbortSignal;
} = {},
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
try {
resourceMetadata = await discoverProtectedResourceMetadata(serverUrl, {
resourceMetadataUrl: options.resourceMetadataUrl,
fetch: options.fetch,
signal: options.signal,
});
} catch (error) {
if (error instanceof TypeError) throw error;
}
if (options.authorizationServerMetadataUrl) {
const url = options.authorizationServerMetadataUrl;
const response = await fetchMetadata(
url,
options.fetch ?? globalThis.fetch,
LATEST_PROTOCOL_VERSION,
options.signal,
);
if (!response.ok) {
discard(response);
throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`);
}
const metadata = parseAuthorizationServerMetadata(await response.json());
return { authorizationServerUrl: metadata.issuer, authorizationServerMetadata: metadata, resourceMetadata };
}
const authorizationServerUrl = resourceMetadata?.authorization_servers?.[0] ?? String(new URL("/", serverUrl));
return {
authorizationServerUrl,
authorizationServerMetadata: await discoverAuthorizationServerMetadata(authorizationServerUrl, {
fetch: options.fetch,
skipIssuerValidation: options.skipIssuerValidation,
signal: options.signal,
}),
resourceMetadata,
};
}
export function resourceUrlFromServerUrl(value: string | URL): URL {
const url = new URL(value);
url.hash = "";
return url;
}
export function selectResource(serverUrl: string | URL, metadata?: OAuthProtectedResourceMetadata): string | undefined {
if (!metadata) return undefined;
const requested = resourceUrlFromServerUrl(serverUrl);
const configured = new URL(metadata.resource);
if (requested.origin !== configured.origin) {
throw new Error(`Protected resource ${metadata.resource} does not match MCP server ${requested}`);
}
const requestedPath = requested.pathname.endsWith("/") ? requested.pathname : `${requested.pathname}/`;
const configuredPath = configured.pathname.endsWith("/") ? configured.pathname : `${configured.pathname}/`;
if (!requestedPath.startsWith(configuredPath)) {
throw new Error(`Protected resource ${metadata.resource} does not match MCP server ${requested}`);
}
return metadata.resource;
}

View File

@@ -0,0 +1,55 @@
export class OAuthError extends Error {
readonly code: string;
readonly errorUri: string | undefined;
constructor(code: string, message: string, errorUri?: string) {
super(message || code);
this.name = "OAuthError";
this.code = code;
this.errorUri = errorUri;
}
}
export class OAuthIssuerMismatchError extends Error {
/** `undefined` when no authorization server metadata was discovered. */
readonly expected: string | undefined;
/** `undefined` when an authorization response lacks the `iss` parameter its server promised (RFC 9207). */
readonly received: string | undefined;
constructor(expected: string | undefined, received: string | undefined) {
const format = (issuer: string | undefined) => (issuer === undefined ? "none" : JSON.stringify(issuer));
super(`OAuth issuer mismatch: expected ${format(expected)}, received ${format(received)}`);
this.name = "OAuthIssuerMismatchError";
this.expected = expected;
this.received = received;
}
}
export class OAuthInsecureEndpointError extends Error {
readonly endpoint: string;
constructor(endpoint: string) {
super(`Refusing to send OAuth credentials to non-HTTPS endpoint ${endpoint}`);
this.name = "OAuthInsecureEndpointError";
this.endpoint = endpoint;
}
}
export class OAuthRegistrationError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(`OAuth dynamic client registration failed with status ${status}: ${body}`);
this.name = "OAuthRegistrationError";
this.status = status;
this.body = body;
}
}
export class McpOAuthAuthorizationRequiredError extends Error {
constructor() {
super("MCP OAuth authorization requires user interaction");
this.name = "McpOAuthAuthorizationRequiredError";
}
}

View File

@@ -0,0 +1,482 @@
/*
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0 src/client/auth.ts.
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
* Modified to remove SDK/Zod dependencies and use WebCrypto for PKCE.
*/
import type { AuthProvider, McpFetch, UnauthorizedContext } from "../auth-provider.ts";
import { isObject } from "../protocol/jsonrpc.ts";
import {
discoverAuthorizationServerMetadata,
discoverOAuthServerInfo,
parseWwwAuthenticate,
selectResource,
} from "./discovery.ts";
import {
McpOAuthAuthorizationRequiredError,
OAuthError,
OAuthInsecureEndpointError,
OAuthIssuerMismatchError,
OAuthRegistrationError,
} from "./errors.ts";
import {
type AuthorizationServerMetadata,
type OAuthClientInformation,
type OAuthClientInformationFull,
type OAuthClientInformationMixed,
type OAuthClientMetadata,
type OAuthDiscoveryState,
type OAuthTokens,
parseClientInformation,
parseOAuthTokens,
} from "./types.ts";
export type AddClientAuthentication = (
headers: Headers,
params: URLSearchParams,
url: string | URL,
metadata?: AuthorizationServerMetadata,
) => void | Promise<void>;
/** A Client ID Metadata Document: an https URL used as `client_id`, and a redirect URI it lists. */
export interface OAuthClientMetadataDocument {
url: string;
redirectUrl: string;
}
export interface OAuthClientProvider {
readonly redirectUrl: string | URL;
readonly clientMetadata: OAuthClientMetadata;
/**
* Client ID Metadata Document to identify as instead of registering dynamically, or `undefined` to
* register. Called when no client information is stored; the document is not stored. `metadata` is
* `undefined` when the authorization server has none; check `client_id_metadata_document_supported`.
*/
clientMetadataDocument?(metadata: AuthorizationServerMetadata | undefined): OAuthClientMetadataDocument | undefined;
state?(): string | Promise<string>;
clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;
saveClientInformation?(information: OAuthClientInformationMixed): void | Promise<void>;
tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>;
saveTokens(tokens: OAuthTokens): void | Promise<void>;
redirectToAuthorization(url: URL): void | Promise<void>;
saveCodeVerifier(verifier: string): void | Promise<void>;
codeVerifier(): string | Promise<string>;
addClientAuthentication?: AddClientAuthentication;
invalidateCredentials?(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): void | Promise<void>;
saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise<void>;
discoveryState?(): OAuthDiscoveryState | undefined | Promise<OAuthDiscoveryState | undefined>;
}
export interface OAuthFlowOptions {
serverUrl: string | URL;
authorizationCode?: string;
/** `iss` parameter of the authorization response that delivered `authorizationCode` (RFC 9207). */
iss?: string;
scope?: string;
resourceMetadataUrl?: URL;
/**
* Authorization server metadata document to use instead of discovery, for servers that advertise a
* wrong authorization server or none. It is trusted as configured. Must use https, except on loopback.
*/
authorizationServerMetadataUrl?: URL;
fetch?: McpFetch;
/** Aborts every request of the flow. Requests have no time limit of their own; combine with a timeout as needed. */
signal?: AbortSignal;
skipIssuerValidation?: boolean;
/**
* Go straight to the authorization redirect instead of refreshing stored tokens, for example when the
* server asks for scopes the current grant lacks (a refresh keeps the old scope).
*/
skipRefresh?: boolean;
}
export type OAuthFlowResult = "AUTHORIZED" | "REDIRECT";
type ClientAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
export interface TokenRequestOptions {
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
resource?: string;
addClientAuthentication?: AddClientAuthentication;
fetch?: McpFetch;
signal?: AbortSignal;
}
function loopback(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
}
/**
* The OpenID Connect `application_type` for `redirect_uris` (MCP SEP-837). Without one, OpenID Connect servers
* assume `web`, which rejects http loopback redirect URIs. Loopback hosts and custom schemes are native (RFC 8252).
*/
function applicationType(redirectUris: readonly string[]): "native" | "web" {
const native = redirectUris.some((uri) => {
if (!URL.canParse(uri)) return false;
const url = new URL(uri);
return (url.protocol !== "http:" && url.protocol !== "https:") || loopback(url.hostname);
});
return native ? "native" : "web";
}
function secureEndpoint(value: string | URL): URL {
const url = new URL(value);
if (url.protocol !== "https:" && !loopback(url.hostname)) throw new OAuthInsecureEndpointError(url.href);
return url;
}
function selectClientAuthMethod(information: OAuthClientInformationMixed, supported: string[]): ClientAuthMethod {
const hinted = "token_endpoint_auth_method" in information ? information.token_endpoint_auth_method : undefined;
if (
hinted &&
["client_secret_basic", "client_secret_post", "none"].includes(hinted) &&
(supported.length === 0 || supported.includes(hinted))
) {
return hinted as ClientAuthMethod;
}
if (supported.length === 0) return information.client_secret ? "client_secret_basic" : "none";
if (information.client_secret && supported.includes("client_secret_basic")) return "client_secret_basic";
if (information.client_secret && supported.includes("client_secret_post")) return "client_secret_post";
if (supported.includes("none")) return "none";
return information.client_secret ? "client_secret_post" : "none";
}
function applyClientAuthentication(
method: ClientAuthMethod,
information: OAuthClientInformation,
headers: Headers,
params: URLSearchParams,
): void {
if (method === "client_secret_basic") {
if (!information.client_secret) throw new Error("client_secret_basic requires a client secret");
headers.set(
"Authorization",
`Basic ${Buffer.from(`${information.client_id}:${information.client_secret}`).toString("base64")}`,
);
} else {
params.set("client_id", information.client_id);
if (method === "client_secret_post" && information.client_secret)
params.set("client_secret", information.client_secret);
}
}
async function pkce(): Promise<{ verifier: string; challenge: string }> {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = Buffer.from(bytes).toString("base64url");
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return { verifier, challenge: Buffer.from(digest).toString("base64url") };
}
export async function startAuthorization(
authorizationServerUrl: string | URL,
options: {
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
redirectUrl: string | URL;
scope?: string;
state?: string;
resource?: string;
},
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
const metadata = options.metadata;
if (metadata && !metadata.response_types_supported.includes("code")) {
throw new Error("Authorization server does not support authorization codes");
}
if (metadata?.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
throw new Error("Authorization server does not support PKCE S256");
}
const url = new URL(metadata?.authorization_endpoint ?? new URL("/authorize", authorizationServerUrl));
const { verifier, challenge } = await pkce();
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", options.clientInformation.client_id);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("redirect_uri", String(options.redirectUrl));
if (options.state) url.searchParams.set("state", options.state);
if (options.scope) url.searchParams.set("scope", options.scope);
if (options.scope?.split(/\s+/).includes("offline_access")) url.searchParams.set("prompt", "consent");
if (options.resource) url.searchParams.set("resource", options.resource);
return { authorizationUrl: url, codeVerifier: verifier };
}
async function tokenRequest(
authorizationServerUrl: string | URL,
options: TokenRequestOptions,
params: URLSearchParams,
): Promise<OAuthTokens> {
const url = secureEndpoint(options.metadata?.token_endpoint ?? new URL("/token", authorizationServerUrl));
const headers = new Headers({ Accept: "application/json", "content-type": "application/x-www-form-urlencoded" });
if (options.resource) params.set("resource", options.resource);
if (options.addClientAuthentication) {
await options.addClientAuthentication(headers, params, url, options.metadata);
} else {
applyClientAuthentication(
selectClientAuthMethod(
options.clientInformation,
options.metadata?.token_endpoint_auth_methods_supported ?? [],
),
options.clientInformation,
headers,
params,
);
}
const response = await (options.fetch ?? globalThis.fetch)(url, {
method: "POST",
headers,
body: params,
signal: options.signal,
});
const text = await response.text();
let value: unknown;
try {
value = JSON.parse(text);
} catch {}
// Servers may report OAuth errors with any status, so check the body before the status.
if (isObject(value) && typeof value.error === "string") {
throw new OAuthError(
value.error,
typeof value.error_description === "string" ? value.error_description : value.error,
typeof value.error_uri === "string" ? value.error_uri : undefined,
);
}
if (!response.ok) throw new OAuthError("server_error", `HTTP ${response.status}: ${text}`);
return parseOAuthTokens(value);
}
export async function registerClient(
authorizationServerUrl: string | URL,
options: {
metadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
scope?: string;
fetch?: McpFetch;
signal?: AbortSignal;
},
): Promise<OAuthClientInformationFull> {
const endpoint = options.metadata?.registration_endpoint;
if (options.metadata && !endpoint)
throw new Error("Authorization server does not support dynamic client registration");
const response = await (options.fetch ?? globalThis.fetch)(
new URL(endpoint ?? new URL("/register", authorizationServerUrl)),
{
method: "POST",
headers: { Accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({
...options.clientMetadata,
application_type:
options.clientMetadata.application_type ?? applicationType(options.clientMetadata.redirect_uris),
...(options.scope ? { scope: options.scope } : {}),
}),
signal: options.signal,
},
);
if (!response.ok) throw new OAuthRegistrationError(response.status, await response.text());
return parseClientInformation(await response.json());
}
export async function exchangeAuthorizationCode(
authorizationServerUrl: string | URL,
options: TokenRequestOptions & { code: string; codeVerifier: string; redirectUrl: string | URL },
): Promise<OAuthTokens> {
return tokenRequest(
authorizationServerUrl,
options,
new URLSearchParams({
grant_type: "authorization_code",
code: options.code,
code_verifier: options.codeVerifier,
redirect_uri: String(options.redirectUrl),
}),
);
}
export async function refreshAuthorization(
authorizationServerUrl: string | URL,
options: TokenRequestOptions & { refreshToken: string },
): Promise<OAuthTokens> {
const tokens = await tokenRequest(
authorizationServerUrl,
options,
new URLSearchParams({ grant_type: "refresh_token", refresh_token: options.refreshToken }),
);
return { refresh_token: options.refreshToken, ...tokens };
}
function withScope(tokens: OAuthTokens, scope: string | undefined): OAuthTokens {
return tokens.scope === undefined && scope ? { ...tokens, scope } : tokens;
}
/**
* Scopes for a step-up authorization: the challenged scopes plus the ones granted so far, since a
* challenge may list only the missing scopes and a token with just those would lose access the old
* one had (SEP-2350). Without challenged scopes, `undefined` lets the flow pick its default.
*/
export function stepUpScope(granted: string | undefined, challenged: string | undefined): string | undefined {
if (!challenged) return undefined;
const scopes = [granted, challenged].flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []);
return [...new Set(scopes)].join(" ");
}
async function runFlow(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise<OAuthFlowResult> {
const metadataUrl = options.authorizationServerMetadataUrl && secureEndpoint(options.authorizationServerMetadataUrl);
// With a configured metadata URL, discovery is not cached, so changing the URL applies at once.
const cached = metadataUrl ? undefined : await provider.discoveryState?.();
const discovered = cached?.authorizationServerUrl
? {
authorizationServerUrl: cached.authorizationServerUrl,
authorizationServerMetadata:
cached.authorizationServerMetadata ??
(await discoverAuthorizationServerMetadata(cached.authorizationServerUrl, {
fetch: options.fetch,
skipIssuerValidation: options.skipIssuerValidation,
signal: options.signal,
})),
resourceMetadata: cached.resourceMetadata,
}
: await discoverOAuthServerInfo(options.serverUrl, {
resourceMetadataUrl: options.resourceMetadataUrl,
authorizationServerMetadataUrl: metadataUrl,
fetch: options.fetch,
skipIssuerValidation: options.skipIssuerValidation,
signal: options.signal,
});
if (!metadataUrl) {
await provider.saveDiscoveryState?.({
...discovered,
...(options.resourceMetadataUrl ? { resourceMetadataUrl: options.resourceMetadataUrl.href } : {}),
});
}
const metadata = discovered.authorizationServerMetadata;
const resource = selectResource(options.serverUrl, discovered.resourceMetadata);
// `||`, not `??`: an empty scope (for example from `scopes_supported: []`) falls through to the next source.
const scope =
options.scope || discovered.resourceMetadata?.scopes_supported?.join(" ") || provider.clientMetadata.scope;
const stored = await provider.clientInformation();
const clientDocument = stored ? undefined : provider.clientMetadataDocument?.(metadata);
if (clientDocument) {
const url = new URL(clientDocument.url);
if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");
}
let client = stored ?? (clientDocument && { client_id: clientDocument.url });
if (!client) {
if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange");
if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted");
client = await registerClient(discovered.authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
scope,
fetch: options.fetch,
signal: options.signal,
});
await provider.saveClientInformation(client);
}
// The document's redirect URI may differ from the provider's, for example by a server-specific path.
const redirectUrl = clientDocument?.redirectUrl ?? provider.redirectUrl;
const tokenOptions: TokenRequestOptions = {
metadata,
clientInformation: client,
resource,
addClientAuthentication: provider.addClientAuthentication,
fetch: options.fetch,
signal: options.signal,
};
if (options.authorizationCode) {
// RFC 9207: never send a code from another authorization server to this one. Without metadata there
// is no issuer to compare with, so any `iss` is rejected.
const iss = options.iss;
if (iss !== undefined || metadata?.authorization_response_iss_parameter_supported) {
if (iss !== metadata?.issuer) throw new OAuthIssuerMismatchError(metadata?.issuer, iss);
}
const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, {
...tokenOptions,
code: options.authorizationCode,
codeVerifier: await provider.codeVerifier(),
redirectUrl,
});
// A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can
// keep it. Callers pass the options of the authorization request, so `scope` is what was requested.
await provider.saveTokens(withScope(tokens, scope));
return "AUTHORIZED";
}
const existing = options.skipRefresh ? undefined : await provider.tokens();
if (existing?.refresh_token) {
try {
const tokens = await refreshAuthorization(discovered.authorizationServerUrl, {
...tokenOptions,
refreshToken: existing.refresh_token,
});
// A refresh without `scope` keeps the scope of the grant (RFC 6749 §6).
await provider.saveTokens(withScope(tokens, existing.scope));
return "AUTHORIZED";
} catch (error) {
if (options.signal?.aborted || error instanceof OAuthInsecureEndpointError) throw error;
if (error instanceof OAuthError && error.code !== "server_error") throw error;
}
}
const state = await provider.state?.();
const authorization = await startAuthorization(discovered.authorizationServerUrl, {
metadata,
clientInformation: client,
redirectUrl,
scope,
state,
resource,
});
await provider.saveCodeVerifier(authorization.codeVerifier);
await provider.redirectToAuthorization(authorization.authorizationUrl);
return "REDIRECT";
}
export async function authorizeMcp(provider: OAuthClientProvider, options: OAuthFlowOptions): Promise<OAuthFlowResult> {
try {
return await runFlow(provider, options);
} catch (error) {
if (error instanceof OAuthError && ["invalid_client", "unauthorized_client"].includes(error.code)) {
await provider.invalidateCredentials?.("all");
return runFlow(provider, options);
}
if (error instanceof OAuthError && error.code === "invalid_grant") {
await provider.invalidateCredentials?.("tokens");
return runFlow(provider, options);
}
throw error;
}
}
/**
* Auth provider for `StreamableHttpTransport`. After a 401 it refreshes the tokens, or throws
* `McpOAuthAuthorizationRequiredError` when the user has to authorize (again). Concurrent 401s share
* one refresh, and a request whose token was already replaced is just retried: with rotating refresh
* tokens, a second refresh with the old refresh token would fail and discard the new grant.
*/
export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
let inFlight: Promise<void> | undefined;
return {
token: async () => (await provider.tokens())?.access_token,
onUnauthorized: async (context: UnauthorizedContext) => {
const challenge = parseWwwAuthenticate(context.response.headers.get("www-authenticate"));
const insufficientScope = challenge.error === "insufficient_scope";
if (!insufficientScope && !inFlight && context.token !== undefined) {
const current = (await provider.tokens())?.access_token;
if (current !== undefined && current !== context.token) return;
}
inFlight ??= Promise.resolve(insufficientScope ? provider.tokens() : undefined)
.then((granted) =>
authorizeMcp(provider, {
serverUrl: context.serverUrl,
resourceMetadataUrl: challenge.resourceMetadataUrl,
scope: insufficientScope ? stepUpScope(granted?.scope, challenge.scope) : challenge.scope,
fetch: context.fetch,
skipRefresh: insufficientScope,
}),
)
.then((result) => {
if (result === "REDIRECT") throw new McpOAuthAuthorizationRequiredError();
})
.finally(() => {
inFlight = undefined;
});
await inFlight;
},
};
}

View File

@@ -0,0 +1,56 @@
export {
type OAuthCallback,
type OAuthCallbackPage,
OAuthCallbackServer,
type OAuthCallbackServerOptions,
} from "./callback.ts";
export {
buildAuthorizationServerDiscoveryUrls,
discoverAuthorizationServerMetadata,
discoverOAuthServerInfo,
discoverProtectedResourceMetadata,
parseWwwAuthenticate,
resourceUrlFromServerUrl,
selectResource,
} from "./discovery.ts";
export {
McpOAuthAuthorizationRequiredError,
OAuthError,
OAuthInsecureEndpointError,
OAuthIssuerMismatchError,
OAuthRegistrationError,
} from "./errors.ts";
export {
type AddClientAuthentication,
adaptOAuthProvider,
authorizeMcp,
exchangeAuthorizationCode,
type OAuthClientMetadataDocument,
type OAuthClientProvider,
type OAuthFlowOptions,
type OAuthFlowResult,
refreshAuthorization,
registerClient,
startAuthorization,
stepUpScope,
type TokenRequestOptions,
} from "./flow.ts";
export {
McpOAuthProvider,
type McpOAuthProviderOptions,
type McpOAuthState,
type McpOAuthStateStore,
MemoryOAuthStateStore,
} from "./provider.ts";
export type {
AuthorizationServerMetadata,
OAuthChallenge,
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthDiscoveryState,
OAuthProtectedResourceMetadata,
OAuthServerInfo,
OAuthTokens,
} from "./types.ts";

View File

@@ -0,0 +1,168 @@
import type { OAuthClientMetadataDocument, OAuthClientProvider } from "./flow.ts";
import type {
AuthorizationServerMetadata,
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthDiscoveryState,
OAuthTokens,
} from "./types.ts";
export interface McpOAuthState {
serverUrl: string;
clientInformation?: OAuthClientInformationMixed;
tokens?: OAuthTokens;
/** When the access token expires, in milliseconds since the epoch, from `expires_in` at the time it was saved. */
tokensExpireAt?: number;
codeVerifier?: string;
oauthState?: string;
discovery?: OAuthDiscoveryState;
}
export interface McpOAuthStateStore {
load(): McpOAuthState | undefined | Promise<McpOAuthState | undefined>;
save(state: McpOAuthState): void | Promise<void>;
}
export interface McpOAuthProviderOptions {
serverUrl: string | URL;
redirectUrl: string | URL;
clientMetadata: Omit<OAuthClientMetadata, "redirect_uris"> & { redirect_uris?: string[] };
/** See `OAuthClientProvider.clientMetadataDocument`. */
clientMetadataDocument?: (
metadata: AuthorizationServerMetadata | undefined,
) => OAuthClientMetadataDocument | undefined;
clientId?: string;
clientSecret?: string;
store?: McpOAuthStateStore;
onRedirect(url: URL): void | Promise<void>;
}
export class MemoryOAuthStateStore implements McpOAuthStateStore {
private value: McpOAuthState | undefined;
load(): McpOAuthState | undefined {
return this.value === undefined ? undefined : structuredClone(this.value);
}
save(state: McpOAuthState): void {
this.value = structuredClone(state);
}
}
/** Default stateful provider for one exact MCP server URL. Applications inject durable storage if needed. */
export class McpOAuthProvider implements OAuthClientProvider {
readonly redirectUrl: string;
readonly clientMetadata: OAuthClientMetadata;
readonly clientMetadataDocument?: (
metadata: AuthorizationServerMetadata | undefined,
) => OAuthClientMetadataDocument | undefined;
private serverUrl: string;
private configuredClient: OAuthClientInformationMixed | undefined;
private store: McpOAuthStateStore;
private onRedirect: (url: URL) => void | Promise<void>;
private writes: Promise<void> = Promise.resolve();
constructor(options: McpOAuthProviderOptions) {
this.serverUrl = String(new URL(options.serverUrl));
this.redirectUrl = String(options.redirectUrl);
this.clientMetadata = {
...options.clientMetadata,
redirect_uris: options.clientMetadata.redirect_uris ?? [this.redirectUrl],
grant_types: options.clientMetadata.grant_types ?? ["authorization_code", "refresh_token"],
response_types: options.clientMetadata.response_types ?? ["code"],
token_endpoint_auth_method:
options.clientMetadata.token_endpoint_auth_method ?? (options.clientSecret ? "client_secret_post" : "none"),
};
this.clientMetadataDocument = options.clientMetadataDocument;
this.configuredClient = options.clientId
? { client_id: options.clientId, ...(options.clientSecret ? { client_secret: options.clientSecret } : {}) }
: undefined;
this.store = options.store ?? new MemoryOAuthStateStore();
this.onRedirect = options.onRedirect;
}
async state(): Promise<string> {
const existing = (await this.load()).oauthState;
if (existing) return existing;
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
await this.update((value) => ({ ...value, oauthState: state }));
return state;
}
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
return this.configuredClient ?? (await this.load()).clientInformation;
}
async saveClientInformation(information: OAuthClientInformationMixed): Promise<void> {
if (this.configuredClient) return;
await this.update((value) => ({ ...value, clientInformation: information }));
}
async tokens(): Promise<OAuthTokens | undefined> {
return (await this.load()).tokens;
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
const expiresAt = tokens.expires_in === undefined ? undefined : Date.now() + tokens.expires_in * 1000;
await this.update((value) => {
const next: McpOAuthState = { ...value, tokens };
if (expiresAt === undefined) delete next.tokensExpireAt;
else next.tokensExpireAt = expiresAt;
return next;
});
}
async redirectToAuthorization(url: URL): Promise<void> {
await this.onRedirect(url);
}
async saveCodeVerifier(verifier: string): Promise<void> {
await this.update((value) => ({ ...value, codeVerifier: verifier }));
}
async codeVerifier(): Promise<string> {
const verifier = (await this.load()).codeVerifier;
if (!verifier) throw new Error("No OAuth PKCE code verifier is stored");
return verifier;
}
async invalidateCredentials(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
await this.update((value) => {
const next = { ...value };
if (kind === "all" || kind === "client") delete next.clientInformation;
if (kind === "all" || kind === "tokens") {
delete next.tokens;
delete next.tokensExpireAt;
}
if (kind === "all" || kind === "verifier") delete next.codeVerifier;
if (kind === "all" || kind === "discovery") delete next.discovery;
if (kind === "all") delete next.oauthState;
return next;
});
}
async saveDiscoveryState(discovery: OAuthDiscoveryState): Promise<void> {
await this.update((value) => ({ ...value, discovery }));
}
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
return (await this.load()).discovery;
}
private async load(): Promise<McpOAuthState> {
await this.writes;
return this.own(await this.store.load());
}
private async update(update: (state: McpOAuthState) => McpOAuthState): Promise<void> {
this.writes = this.writes.then(async () => {
await this.store.save(update(this.own(await this.store.load())));
});
await this.writes;
}
/** Stored state for another server URL is ignored so credentials never leak across servers. */
private own(state: McpOAuthState | undefined): McpOAuthState {
return state?.serverUrl === this.serverUrl ? state : { serverUrl: this.serverUrl };
}
}

View File

@@ -0,0 +1,209 @@
/*
* Adapted from modelcontextprotocol/typescript-sdk v1.29.0.
* Copyright (c) 2024 Anthropic, PBC. Licensed under MIT; see LICENSES/.
* Modified to use dependency-free structural validation.
*/
import { isObject } from "../protocol/jsonrpc.ts";
export interface OAuthProtectedResourceMetadata {
resource: string;
authorization_servers?: string[];
scopes_supported?: string[];
[key: string]: unknown;
}
export interface AuthorizationServerMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string;
scopes_supported?: string[];
response_types_supported: string[];
grant_types_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
code_challenge_methods_supported?: string[];
client_id_metadata_document_supported?: boolean;
/** Whether authorization responses carry an `iss` parameter (RFC 9207). */
authorization_response_iss_parameter_supported?: boolean;
[key: string]: unknown;
}
export interface OAuthTokens {
access_token: string;
token_type: string;
expires_in?: number;
scope?: string;
refresh_token?: string;
id_token?: string;
}
export interface OAuthClientMetadata {
redirect_uris: string[];
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
/**
* OpenID Connect client type, `native` or `web`. Dynamic client registration derives it from
* `redirect_uris` when absent (MCP SEP-837).
*/
application_type?: string;
client_name?: string;
client_uri?: string;
logo_uri?: string;
scope?: string;
contacts?: string[];
tos_uri?: string;
policy_uri?: string;
jwks_uri?: string;
jwks?: unknown;
software_id?: string;
software_version?: string;
software_statement?: string;
}
export interface OAuthClientInformation {
client_id: string;
client_secret?: string;
client_id_issued_at?: number;
client_secret_expires_at?: number;
}
export type OAuthClientInformationFull = OAuthClientInformation & OAuthClientMetadata;
export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull;
export interface OAuthDiscoveryState {
authorizationServerUrl: string;
authorizationServerMetadata?: AuthorizationServerMetadata;
resourceMetadata?: OAuthProtectedResourceMetadata;
resourceMetadataUrl?: string;
}
export interface OAuthServerInfo {
authorizationServerUrl: string;
authorizationServerMetadata?: AuthorizationServerMetadata;
resourceMetadata?: OAuthProtectedResourceMetadata;
}
export interface OAuthChallenge {
resourceMetadataUrl?: URL;
scope?: string;
error?: string;
errorDescription?: string;
}
function object(value: unknown, name: string): Record<string, unknown> {
if (!isObject(value)) throw new Error(`Invalid ${name}`);
return value;
}
/** Drops `undefined` values so optional fields are absent rather than present-but-undefined. */
function compact<T extends object>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
}
function requiredString(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid ${name}`);
return value;
}
/** Treats `null` and `""` as absent: servers send them for fields they have no value for, like `scope: ""`. */
function absent(value: unknown): value is undefined | null | "" {
return value === undefined || value === null || value === "";
}
function optionalString(value: unknown, name: string): string | undefined {
if (absent(value)) return undefined;
return requiredString(value, name);
}
function optionalStrings(value: unknown, name: string): string[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`Invalid ${name}`);
return [...value];
}
function safeUrl(value: unknown, name: string): string {
const text = requiredString(value, name);
// URL parsing throws a `TypeError`, which discovery reserves for network failures.
if (!URL.canParse(text)) throw new Error(`Invalid ${name}`);
const url = new URL(text);
if (["javascript:", "data:", "vbscript:"].includes(url.protocol)) throw new Error(`Invalid ${name}`);
return text;
}
function optionalUrl(value: unknown, name: string): string | undefined {
return absent(value) ? undefined : safeUrl(value, name);
}
export function parseProtectedResourceMetadata(value: unknown): OAuthProtectedResourceMetadata {
const input = object(value, "OAuth protected resource metadata");
return compact({
...input,
resource: safeUrl(input.resource, "OAuth protected resource metadata resource"),
authorization_servers: optionalStrings(input.authorization_servers, "authorization_servers")?.map((url) =>
safeUrl(url, "authorization server URL"),
),
scopes_supported: optionalStrings(input.scopes_supported, "scopes_supported"),
});
}
export function parseAuthorizationServerMetadata(value: unknown): AuthorizationServerMetadata {
const input = object(value, "authorization server metadata");
const responseTypes = optionalStrings(input.response_types_supported, "response_types_supported");
if (!responseTypes) throw new Error("Invalid response_types_supported");
return compact({
...input,
issuer: safeUrl(input.issuer, "authorization server issuer"),
authorization_endpoint: safeUrl(input.authorization_endpoint, "authorization endpoint"),
token_endpoint: safeUrl(input.token_endpoint, "token endpoint"),
registration_endpoint: optionalUrl(input.registration_endpoint, "registration endpoint"),
scopes_supported: optionalStrings(input.scopes_supported, "scopes_supported"),
response_types_supported: responseTypes,
grant_types_supported: optionalStrings(input.grant_types_supported, "grant_types_supported"),
token_endpoint_auth_methods_supported: optionalStrings(
input.token_endpoint_auth_methods_supported,
"token_endpoint_auth_methods_supported",
),
code_challenge_methods_supported: optionalStrings(
input.code_challenge_methods_supported,
"code_challenge_methods_supported",
),
client_id_metadata_document_supported:
typeof input.client_id_metadata_document_supported === "boolean"
? input.client_id_metadata_document_supported
: undefined,
authorization_response_iss_parameter_supported:
typeof input.authorization_response_iss_parameter_supported === "boolean"
? input.authorization_response_iss_parameter_supported
: undefined,
});
}
export function parseOAuthTokens(value: unknown): OAuthTokens {
const input = object(value, "OAuth token response");
// `Number(null)` is 0, which would mark the token as expired at once.
const expires = absent(input.expires_in) ? undefined : Number(input.expires_in);
if (expires !== undefined && !Number.isFinite(expires)) throw new Error("Invalid expires_in");
return compact({
access_token: requiredString(input.access_token, "access_token"),
token_type: requiredString(input.token_type, "token_type"),
expires_in: expires,
scope: optionalString(input.scope, "scope"),
refresh_token: optionalString(input.refresh_token, "refresh_token"),
id_token: optionalString(input.id_token, "id_token"),
});
}
export function parseClientInformation(value: unknown): OAuthClientInformationFull {
const input = object(value, "OAuth client registration response");
return compact({
...(input as unknown as OAuthClientMetadata),
client_id: requiredString(input.client_id, "client_id"),
client_secret: optionalString(input.client_secret, "client_secret"),
client_id_issued_at: typeof input.client_id_issued_at === "number" ? input.client_id_issued_at : undefined,
client_secret_expires_at:
typeof input.client_secret_expires_at === "number" ? input.client_secret_expires_at : undefined,
redirect_uris: optionalStrings(input.redirect_uris, "redirect_uris") ?? [],
});
}

View File

@@ -0,0 +1,118 @@
export interface ContentAnnotations {
audience?: ("user" | "assistant")[];
priority?: number;
lastModified?: string;
}
export interface TextContent {
type: "text";
text: string;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export interface ImageContent {
type: "image";
data: string;
mimeType: string;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export interface AudioContent {
type: "audio";
data: string;
mimeType: string;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export interface ResourceLinkContent {
type: "resource_link";
uri: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
size?: number;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export interface TextResourceContents {
uri: string;
mimeType?: string;
text: string;
_meta?: Record<string, unknown>;
}
export interface BlobResourceContents {
uri: string;
mimeType?: string;
blob: string;
_meta?: Record<string, unknown>;
}
export interface EmbeddedResourceContent {
type: "resource";
resource: TextResourceContents | BlobResourceContents;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLinkContent | EmbeddedResourceContent;
export interface CallToolResult {
content: ContentBlock[];
/** Any JSON value, including arrays and `null` (MCP 2026-07-28; earlier revisions allowed only objects). */
structuredContent?: unknown;
isError?: boolean;
_meta?: Record<string, unknown>;
}
/**
* Tool result content in the shape LLM APIs accept: text and base64 images. Matches the
* `TextContent` and `ImageContent` types of `@earendil-works/pi-ai`.
*/
export type LlmContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
function blockToLlmContent(block: ContentBlock): LlmContent {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "image":
return { type: "image", data: block.data, mimeType: block.mimeType };
case "audio":
return { type: "text", text: `[audio ${block.mimeType} omitted]` };
case "resource_link":
return { type: "text", text: `${block.name}: ${block.uri}` };
case "resource": {
const resource = block.resource;
if ("text" in resource) return { type: "text", text: resource.text };
if (resource.mimeType?.startsWith("image/")) {
return { type: "image", data: resource.blob, mimeType: resource.mimeType };
}
return {
type: "text",
text: `[binary resource ${resource.uri} (${resource.mimeType ?? "unknown type"}) omitted]`,
};
}
default:
return { type: "text", text: `[unsupported MCP content ${(block as { type: string }).type}]` };
}
}
/**
* Convert a tool result to text and image content for a model. Text and images pass through,
* embedded text resources become text, embedded image resources become images, and other blocks
* (audio, resource links, binary resources) become a short text placeholder. A result without
* content blocks but with `structuredContent` becomes its JSON, since servers should, but do not
* always, mirror structured results as text.
*/
export function toLlmContent(result: Pick<CallToolResult, "content" | "structuredContent">): LlmContent[] {
const content = (result.content ?? []).map(blockToLlmContent);
if (content.length === 0 && result.structuredContent !== undefined) {
content.push({ type: "text", text: JSON.stringify(result.structuredContent, null, 2) });
}
return content;
}

View File

@@ -0,0 +1,113 @@
export type JsonRpcId = string | number;
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: JsonRpcId;
method: string;
params?: unknown;
}
export interface JsonRpcNotification {
jsonrpc: "2.0";
method: string;
params?: unknown;
}
export interface JsonRpcErrorObject {
code: number;
message: string;
data?: unknown;
}
export interface JsonRpcSuccessResponse {
jsonrpc: "2.0";
id: JsonRpcId;
result: unknown;
}
export interface JsonRpcErrorResponse {
jsonrpc: "2.0";
id: JsonRpcId;
error: JsonRpcErrorObject;
}
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
export const JSON_RPC_ERROR_CODES = {
parseError: -32700,
invalidRequest: -32600,
methodNotFound: -32601,
invalidParams: -32602,
internalError: -32603,
} as const;
export class McpError extends Error {
readonly code: number;
readonly data: unknown;
constructor(code: number, message: string, data?: unknown) {
super(message);
this.name = "McpError";
this.code = code;
this.data = data;
}
}
export class McpConnectionClosedError extends Error {
constructor(message = "MCP connection closed") {
super(message);
this.name = "McpConnectionClosedError";
}
}
export class McpTimeoutError extends Error {
readonly timeoutMs: number;
constructor(timeoutMs: number) {
super(`MCP request timed out after ${timeoutMs}ms`);
this.name = "McpTimeoutError";
this.timeoutMs = timeoutMs;
}
}
export class McpAbortError extends Error {
constructor(message = "MCP request aborted") {
super(message);
this.name = "AbortError";
}
}
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
export function isJsonRpcId(value: unknown): value is JsonRpcId {
return typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
}
export function isJsonRpcRequest(message: unknown): message is JsonRpcRequest {
return (
isObject(message) && message.jsonrpc === "2.0" && isJsonRpcId(message.id) && typeof message.method === "string"
);
}
export function isJsonRpcNotification(message: unknown): message is JsonRpcNotification {
return isObject(message) && message.jsonrpc === "2.0" && !("id" in message) && typeof message.method === "string";
}
export function isJsonRpcResponse(message: unknown): message is JsonRpcResponse {
if (!isObject(message) || message.jsonrpc !== "2.0" || !isJsonRpcId(message.id)) return false;
if ("result" in message) return !("error" in message);
if (!("error" in message) || !isObject(message.error)) return false;
return typeof message.error.code === "number" && typeof message.error.message === "string";
}
export function parseJsonRpcMessage(value: unknown): JsonRpcMessage {
if (isJsonRpcRequest(value) || isJsonRpcNotification(value) || isJsonRpcResponse(value)) return value;
throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid JSON-RPC message");
}

View File

@@ -0,0 +1,131 @@
import type { BlobResourceContents, ContentAnnotations, TextResourceContents } from "./content.ts";
import type { JsonRpcId } from "./jsonrpc.ts";
export const LATEST_PROTOCOL_VERSION = "2025-11-25";
/**
* Versions the client accepts from a server. Servers that do not support the requested version answer
* with their own latest one, so older versions stay accepted for servers built on older SDKs.
*/
export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05"] as const;
export type SupportedProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
export interface Implementation {
name: string;
version: string;
title?: string;
}
export interface Root {
uri: string;
name?: string;
}
export interface ClientCapabilities {
experimental?: Record<string, unknown>;
roots?: { listChanged?: boolean };
sampling?: Record<string, unknown>;
elicitation?: Record<string, unknown>;
}
export interface ServerCapabilities {
experimental?: Record<string, unknown>;
logging?: Record<string, unknown>;
prompts?: { listChanged?: boolean };
resources?: { subscribe?: boolean; listChanged?: boolean };
tools?: { listChanged?: boolean };
completions?: Record<string, unknown>;
}
export interface InitializeParams {
protocolVersion: string;
capabilities: ClientCapabilities;
clientInfo: Implementation;
}
export interface InitializeResult {
protocolVersion: string;
capabilities: ServerCapabilities;
serverInfo: Implementation;
instructions?: string;
}
export interface ProgressNotification {
progressToken: string | number;
progress: number;
total?: number;
message?: string;
}
export interface CancelledNotification {
requestId: JsonRpcId;
reason?: string;
}
export interface ToolAnnotations {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
}
export interface ToolExecution {
taskSupport?: "forbidden" | "optional" | "required";
}
export interface Tool {
name: string;
title?: string;
description?: string;
inputSchema: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
annotations?: ToolAnnotations;
execution?: ToolExecution;
_meta?: Record<string, unknown>;
}
export interface ListToolsResult {
tools: Tool[];
nextCursor?: string;
_meta?: Record<string, unknown>;
}
/** A resource a server lists in `resources/list`. */
export interface Resource {
uri: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
size?: number;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
/** A family of resources, addressed by an RFC 6570 URI template, from `resources/templates/list`. */
export interface ResourceTemplate {
uriTemplate: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
annotations?: ContentAnnotations;
_meta?: Record<string, unknown>;
}
export interface ListResourcesResult {
resources: Resource[];
nextCursor?: string;
_meta?: Record<string, unknown>;
}
export interface ListResourceTemplatesResult {
resourceTemplates: ResourceTemplate[];
nextCursor?: string;
_meta?: Record<string, unknown>;
}
export interface ReadResourceResult {
contents: (TextResourceContents | BlobResourceContents)[];
_meta?: Record<string, unknown>;
}

View File

@@ -0,0 +1 @@
export { createInMemoryTransportPair, InMemoryTransport } from "../transports/in-memory.ts";

View File

@@ -0,0 +1,51 @@
import { type JsonRpcMessage, McpConnectionClosedError } from "../protocol/jsonrpc.ts";
import { type McpTransport, TransportEvents } from "./transport.ts";
export class InMemoryTransport extends TransportEvents implements McpTransport {
private peer: InMemoryTransport | undefined;
private started = false;
private closed = false;
connectPeer(peer: InMemoryTransport): void {
if (this.peer) throw new Error("In-memory MCP transport already has a peer");
this.peer = peer;
}
async start(): Promise<void> {
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
}
async send(message: JsonRpcMessage): Promise<void> {
if (!this.started || this.closed) throw new McpConnectionClosedError();
const peer = this.peer;
if (!peer?.started || peer.closed) throw new McpConnectionClosedError("In-memory MCP peer is not connected");
const copy = structuredClone(message);
queueMicrotask(() => peer.deliver(copy));
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.emitClose();
await this.peer?.close();
}
/** Exposed so tests can simulate transport-level failures. */
override emitError(error: unknown): void {
super.emitError(error);
}
private deliver(message: JsonRpcMessage): void {
if (this.closed) return;
this.emitMessage(message);
}
}
export function createInMemoryTransportPair(): { client: InMemoryTransport; server: InMemoryTransport } {
const client = new InMemoryTransport();
const server = new InMemoryTransport();
client.connectPeer(server);
server.connectPeer(client);
return { client, server };
}

View File

@@ -0,0 +1,216 @@
import { type ChildProcess, spawn } from "node:child_process";
import process from "node:process";
import crossSpawn from "cross-spawn";
import { type JsonRpcMessage, McpConnectionClosedError, parseJsonRpcMessage } from "../protocol/jsonrpc.ts";
import { DEFAULT_MAX_MESSAGE_BYTES, type McpTransport, TransportEvents } from "./transport.ts";
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024;
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
/** How long a server gets to exit on its own after stdin closes, before it is sent SIGTERM. */
const STDIN_CLOSE_GRACE_MS = 500;
const USE_PROCESS_GROUPS = process.platform !== "win32";
/** Process groups of running servers, killed if the host exits without closing them. */
const liveProcessGroups = new Set<number>();
let exitHookInstalled = false;
function killProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
const pid = child.pid;
if (process.platform === "win32" && pid !== undefined) {
if (child.exitCode !== null) return;
// Windows has no graceful signals, and cross-spawn runs `.cmd` shims through cmd.exe; killing only
// that would leave the server running.
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on(
"error",
() => {},
);
return;
}
if (USE_PROCESS_GROUPS && pid !== undefined) {
try {
// Negative pid: the whole group, so wrappers like `npx` or `uvx` do not leave the server behind.
process.kill(-pid, signal);
return;
} catch {
// The group is gone or was never created; fall back to the direct child.
}
}
try {
child.kill(signal);
} catch {}
}
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
process.once("exit", () => {
for (const pid of liveProcessGroups) {
try {
process.kill(-pid, "SIGTERM");
} catch {}
}
});
}
export interface StdioTransportOptions {
command: string;
args?: readonly string[];
cwd?: string;
env?: Record<string, string>;
inheritEnv?: boolean;
stderr?: "pipe" | "inherit";
onStderr?: (chunk: string) => void;
maxMessageBytes?: number;
maxStderrBytes?: number;
/** Time to wait for the server to exit after SIGTERM before sending SIGKILL. Default: 2000. */
closeTimeoutMs?: number;
}
export class StdioTransport extends TransportEvents implements McpTransport {
readonly options: Readonly<StdioTransportOptions>;
private child: ChildProcess | undefined;
private stdoutBuffer = Buffer.alloc(0);
private stderrBuffer = Buffer.alloc(0);
private started = false;
private closed = false;
constructor(options: StdioTransportOptions) {
super();
this.options = Object.freeze({ ...options, args: options.args ? [...options.args] : undefined });
}
get pid(): number | undefined {
return this.child?.pid;
}
get stderr(): string {
return this.stderrBuffer.toString("utf8");
}
async start(): Promise<void> {
if (this.started) throw new Error("MCP stdio transport already started");
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
const env = this.options.inheritEnv === false ? { ...this.options.env } : { ...process.env, ...this.options.env };
const child = crossSpawn(this.options.command, this.options.args ?? [], {
cwd: this.options.cwd,
env,
stdio: ["pipe", "pipe", this.options.stderr === "inherit" ? "inherit" : "pipe"],
windowsHide: true,
// Own process group, so closing the transport can terminate the server's children too.
detached: USE_PROCESS_GROUPS,
});
this.child = child;
const pid = child.pid;
if (USE_PROCESS_GROUPS && pid !== undefined) {
installExitHook();
liveProcessGroups.add(pid);
child.once("exit", () => liveProcessGroups.delete(pid));
}
child.stdout?.on("data", (chunk: Buffer | string) => this.handleStdout(chunk));
child.stdout?.on("error", (error) => this.emitError(error));
child.stdin?.on("error", (error) => {
if (!this.closed) this.emitError(error);
});
child.stderr?.on("data", (chunk: Buffer | string) => this.handleStderr(chunk));
child.stderr?.on("error", (error) => this.emitError(error));
child.on("close", () => {
this.child = undefined;
if (this.stdoutBuffer.toString("utf8").trim()) {
this.emitError(new Error("MCP stdio server closed with an incomplete JSON-RPC message"));
}
this.stdoutBuffer = Buffer.alloc(0);
this.emitClose();
});
await new Promise<void>((resolve, reject) => {
const onSpawn = () => {
child.off("error", onError);
resolve();
};
const onError = (error: Error) => {
child.off("spawn", onSpawn);
reject(error);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
child.on("error", (error) => {
if (!this.closed) this.emitError(error);
});
}
async send(message: JsonRpcMessage): Promise<void> {
const stdin = this.child?.stdin;
if (!this.started || this.closed || !stdin?.writable) throw new McpConnectionClosedError();
const payload = `${JSON.stringify(message)}\n`;
await new Promise<void>((resolve, reject) => {
stdin.write(payload, (error) => (error ? reject(error) : resolve()));
});
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
const child = this.child;
if (!child) {
this.emitClose();
return;
}
if (child.exitCode !== null || child.signalCode !== null) {
child.stdin?.end();
return;
}
// Shutdown per the spec: close stdin and let the server exit, then SIGTERM, then SIGKILL.
const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
await new Promise<void>((resolve) => {
const timers: ReturnType<typeof setTimeout>[] = [];
child.once("close", () => {
for (const timer of timers) clearTimeout(timer);
// Children of the server that ignored stdin closing would otherwise outlive it.
killProcessTree(child, "SIGTERM");
resolve();
});
const grace = Math.min(STDIN_CLOSE_GRACE_MS, closeTimeoutMs);
timers.push(setTimeout(() => killProcessTree(child, "SIGTERM"), grace));
timers.push(setTimeout(() => killProcessTree(child, "SIGKILL"), grace + closeTimeoutMs));
child.stdin?.end();
});
}
private handleStdout(chunk: Buffer | string): void {
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
const maxMessageBytes = this.options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
while (true) {
const newline = this.stdoutBuffer.indexOf(0x0a);
if (newline < 0) {
if (this.stdoutBuffer.length > maxMessageBytes) {
this.stdoutBuffer = Buffer.alloc(0);
this.emitError(new Error(`MCP stdio message exceeds ${maxMessageBytes} bytes`));
}
return;
}
const line = this.stdoutBuffer.subarray(0, newline);
this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1);
if (line.length > maxMessageBytes) {
this.emitError(new Error(`MCP stdio message exceeds ${maxMessageBytes} bytes`));
continue;
}
const text = line.toString("utf8").replace(/\r$/, "");
if (!text.trim()) continue;
try {
this.emitMessage(parseJsonRpcMessage(JSON.parse(text)));
} catch (error) {
this.emitError(error);
}
}
}
private handleStderr(chunk: Buffer | string): void {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const maxStderrBytes = this.options.maxStderrBytes ?? DEFAULT_MAX_STDERR_BYTES;
this.stderrBuffer = Buffer.concat([this.stderrBuffer, buffer]);
if (this.stderrBuffer.length > maxStderrBytes) this.stderrBuffer = this.stderrBuffer.subarray(-maxStderrBytes);
this.options.onStderr?.(buffer.toString("utf8"));
}
}

View File

@@ -0,0 +1,521 @@
import type { AuthProvider, McpFetch } from "../auth-provider.ts";
import {
isJsonRpcRequest,
isJsonRpcResponse,
JSON_RPC_ERROR_CODES,
type JsonRpcId,
type JsonRpcMessage,
McpAbortError,
McpConnectionClosedError,
parseJsonRpcMessage,
toError,
} from "../protocol/jsonrpc.ts";
import {
DEFAULT_MAX_MESSAGE_BYTES,
type McpTransport,
type McpTransportSendOptions,
TransportEvents,
} from "./transport.ts";
const MAX_ERROR_BODY_BYTES = 8 * 1024;
const ERROR_MESSAGE_BODY_CHARS = 500;
const DEFAULT_RECONNECT_INITIAL_DELAY_MS = 1_000;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30_000;
const DEFAULT_RECONNECT_MAX_RETRIES = 5;
export interface SseEvent {
event?: string;
data: string;
id?: string;
}
export interface ConsumeSseOptions {
maxEventBytes?: number;
onEvent(event: SseEvent): void;
/** Called for every `id` field, including events without data (for example resumption priming events). */
onId?(id: string): void;
/** Called for every valid `retry` field, in milliseconds. */
onRetry?(delayMs: number): void;
}
export async function consumeSseStream(stream: ReadableStream<Uint8Array>, options: ConsumeSseOptions): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffered = "";
let eventName: string | undefined;
let eventId: string | undefined;
let dataLines: string[] = [];
// Bytes of the pending event's data, including the "\n" joins, so events streamed as many
// short `data:` lines without a terminating blank line cannot grow without bound.
let dataBytes = 0;
const maxEventBytes = options.maxEventBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
const dispatch = () => {
if (dataLines.length === 0) {
eventName = undefined;
eventId = undefined;
return;
}
const data = dataLines.join("\n");
options.onEvent({ ...(eventName ? { event: eventName } : {}), data, ...(eventId ? { id: eventId } : {}) });
eventName = undefined;
eventId = undefined;
dataLines = [];
dataBytes = 0;
};
const processLine = (rawLine: string) => {
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line === "") {
dispatch();
return;
}
if (line.startsWith(":")) return;
const colon = line.indexOf(":");
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") {
dataBytes += Buffer.byteLength(value) + (dataLines.length > 0 ? 1 : 0);
if (dataBytes > maxEventBytes) throw new Error(`MCP SSE event exceeds ${maxEventBytes} bytes`);
dataLines.push(value);
} else if (field === "event") eventName = value;
else if (field === "id" && !value.includes("\0")) {
eventId = value;
options.onId?.(value);
} else if (field === "retry" && /^\d+$/.test(value)) options.onRetry?.(Number(value));
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
let newline = buffered.indexOf("\n");
while (newline >= 0) {
processLine(buffered.slice(0, newline));
buffered = buffered.slice(newline + 1);
newline = buffered.indexOf("\n");
}
if (Buffer.byteLength(buffered) > maxEventBytes)
throw new Error(`MCP SSE event exceeds ${maxEventBytes} bytes`);
}
buffered += decoder.decode();
if (buffered) processLine(buffered);
dispatch();
} finally {
reader.releaseLock();
}
}
export interface StreamableHttpReconnectOptions {
/** Delay before the first reconnection attempt, unless the server sent a `retry` field. Default: 1000. */
initialDelayMs?: number;
/** Upper bound for the exponential backoff. Default: 30000. */
maxDelayMs?: number;
/** Consecutive failed attempts before giving up on a stream. Default: 5. */
maxRetries?: number;
}
export interface StreamableHttpTransportOptions {
url: string | URL;
headers?: Record<string, string>;
fetch?: McpFetch;
/** Open the server-to-client GET stream after initialization. Default: true. */
openGetStream?: boolean;
maxMessageBytes?: number;
authProvider?: AuthProvider;
/** Reconnection of dropped SSE streams (the GET stream, and response streams that carry event IDs). */
reconnect?: StreamableHttpReconnectOptions;
}
export class McpHttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, message: string, body = "") {
super(message);
this.name = "McpHttpError";
this.status = status;
this.body = body;
}
}
export class McpAuthRequiredError extends McpHttpError {
readonly wwwAuthenticate: string | null;
constructor(response: Response, body = "") {
super(401, "MCP server requires authentication", body);
this.name = "McpAuthRequiredError";
this.wwwAuthenticate = response.headers.get("www-authenticate");
}
}
export class McpSessionExpiredError extends McpHttpError {
constructor(body = "") {
super(404, "MCP session expired", body);
this.name = "McpSessionExpiredError";
}
}
function contentType(response: Response): string | undefined {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
}
/** 401, or 403 with an `insufficient_scope` bearer challenge (step-up authorization). */
function needsAuthorization(response: Response): boolean {
if (response.status === 401) return true;
if (response.status !== 403) return false;
return /(?:^|[\s,])error="?insufficient_scope"?/i.test(response.headers.get("www-authenticate") ?? "");
}
/** Statuses worth retrying when a stream fails to (re)open. */
function isTransientStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}
function discard(response: Response): Promise<void> {
return response.body?.cancel().catch(() => {}) ?? Promise.resolve();
}
function describeHttpFailure(status: number, body: string): string {
const text = body.trim();
const snippet = text.length > ERROR_MESSAGE_BODY_CHARS ? `${text.slice(0, ERROR_MESSAGE_BODY_CHARS - 3)}...` : text;
return `MCP HTTP request failed with status ${status}${snippet ? `: ${snippet}` : ""}`;
}
interface StreamCursor {
lastEventId: string | undefined;
retryMs: number | undefined;
/** Whether the stream delivered any event since it was (re)opened. */
received: boolean;
}
export class StreamableHttpTransport extends TransportEvents implements McpTransport {
readonly url: URL;
readonly options: Readonly<StreamableHttpTransportOptions>;
private fetch: McpFetch;
private controller = new AbortController();
private started = false;
private closed = false;
private sessionIdValue: string | undefined;
private protocolVersion: string | undefined;
private getStreamStarted = false;
/** Access token of the latest request, which `close()` reuses instead of asking the auth provider. */
private lastToken: string | undefined;
constructor(options: StreamableHttpTransportOptions) {
super();
this.options = Object.freeze({ ...options, headers: options.headers ? { ...options.headers } : undefined });
this.url = new URL(options.url);
const fetch = options.fetch ?? globalThis.fetch;
// Call fetch without a receiver. `this.fetch(...)` and `context.fetch(...)` would pass the transport or
// the auth context as `this`, which Cloudflare Workers reject for the platform fetch ("Illegal invocation").
this.fetch = (input, init) => fetch(input, init);
}
get sessionId(): string | undefined {
return this.sessionIdValue;
}
async start(): Promise<void> {
if (this.started) throw new Error("MCP Streamable HTTP transport already started");
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
}
setProtocolVersion(version: string): void {
this.protocolVersion = version;
}
async send(message: JsonRpcMessage, { signal }: McpTransportSendOptions = {}): Promise<void> {
if (!this.started || this.closed) throw new McpConnectionClosedError();
const response = await this.authorizedFetch(
"POST",
{
headers: { accept: "application/json, text/event-stream", "content-type": "application/json" },
body: JSON.stringify(message),
},
signal,
);
await this.checkResponse(response);
this.captureSession(response);
if (!isJsonRpcRequest(message)) {
// Notifications and responses are acknowledged with 202 and carry no reply; ignore any body.
await discard(response);
// The server-to-client stream may only open once the session is initialized.
if ("method" in message && message.method === "notifications/initialized") this.startGetStream();
return;
}
if (response.status === 202 || response.status === 204) {
throw new McpHttpError(response.status, `MCP server accepted request ${message.method} without a response`);
}
const type = contentType(response);
if (type === "application/json") {
const body: unknown = await response.json();
for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item));
return;
}
if (type === "text/event-stream" && response.body) {
void this.consumeResponseStream(response.body, message.id);
return;
}
await discard(response);
throw new McpHttpError(response.status, `Unsupported MCP response content type: ${type ?? "missing"}`);
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.controller.abort();
if (this.started && this.sessionIdValue) {
// Best effort: the session expires on the server anyway. The auth provider may refresh tokens
// over the network, so it is not asked here and closing never waits for a refresh.
try {
const response = await this.fetch(this.url, {
method: "DELETE",
headers: this.buildHeaders({}, this.lastToken),
signal: AbortSignal.timeout(1_000),
});
await discard(response);
} catch {}
}
this.emitClose();
}
/**
* Fetch with auth headers. A 401 (or a 403 asking for more scope) is handed to the auth provider
* once, and the request is retried with whatever credentials it left behind.
*
* `signal` only stops dispatch: getting a token or the authorization recovery can take a while, and a
* request its sender gave up on in the meantime must not reach the server. It does not abort the shared
* recovery, which other requests may wait for.
*/
private async authorizedFetch(
method: "GET" | "POST",
init: { headers: Record<string, string>; body?: string },
signal?: AbortSignal,
): Promise<Response> {
const onUnauthorized = this.options.authProvider?.onUnauthorized?.bind(this.options.authProvider);
for (let attempt = 0; ; attempt++) {
const { headers, token } = await this.headers(init.headers);
if (signal?.aborted) throw new McpAbortError();
const response = await this.fetch(this.url, {
method,
headers,
body: init.body,
signal: this.controller.signal,
});
if (attempt > 0 || !onUnauthorized || !needsAuthorization(response)) return response;
try {
await onUnauthorized({ response, serverUrl: this.url, fetch: this.fetch, token });
} finally {
await discard(response);
}
}
}
private async headers(extra: Record<string, string> = {}): Promise<{ headers: Headers; token?: string }> {
const token = await this.options.authProvider?.token();
this.lastToken = token;
return { headers: this.buildHeaders(extra, token), ...(token ? { token } : {}) };
}
private buildHeaders(extra: Record<string, string>, token: string | undefined): Headers {
const headers = new Headers(this.options.headers);
for (const [name, value] of Object.entries(extra)) headers.set(name, value);
if (this.sessionIdValue) headers.set("Mcp-Session-Id", this.sessionIdValue);
if (this.protocolVersion) headers.set("MCP-Protocol-Version", this.protocolVersion);
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
}
private captureSession(response: Response): void {
const sessionId = response.headers.get("mcp-session-id");
if (sessionId) this.sessionIdValue = sessionId;
}
private async checkResponse(response: Response): Promise<void> {
if (response.ok) return;
const body = (await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY_BYTES);
if (response.status === 401) throw new McpAuthRequiredError(response, body);
if (response.status === 404 && this.sessionIdValue) throw new McpSessionExpiredError(body);
throw new McpHttpError(response.status, describeHttpFailure(response.status, body), body);
}
private async consumeSse(
stream: ReadableStream<Uint8Array>,
cursor: StreamCursor,
onMessage?: (message: JsonRpcMessage) => void,
): Promise<void> {
await consumeSseStream(stream, {
maxEventBytes: this.options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
onId: (id) => {
cursor.lastEventId = id;
},
onRetry: (delayMs) => {
cursor.retryMs = delayMs;
},
onEvent: (event) => {
cursor.received = true;
// Events without data prime resumption; other event types are not JSON-RPC.
if (!event.data.trim() || (event.event !== undefined && event.event !== "message")) return;
let message: JsonRpcMessage;
try {
message = parseJsonRpcMessage(JSON.parse(event.data));
} catch (error) {
this.emitError(error);
return;
}
onMessage?.(message);
this.emitMessage(message);
},
});
}
/**
* Read the SSE stream answering one request. When the stream ends or breaks before the response
* arrives and the server assigned event IDs, resume it with GET and `Last-Event-ID`, as the
* server may close response streams at will. Otherwise only this request fails.
*/
private async consumeResponseStream(body: ReadableStream<Uint8Array>, requestId: JsonRpcId): Promise<void> {
const cursor: StreamCursor = { lastEventId: undefined, retryMs: undefined, received: false };
let answered = false;
const onMessage = (message: JsonRpcMessage) => {
if (isJsonRpcResponse(message) && message.id === requestId) answered = true;
};
let stream: ReadableStream<Uint8Array> | undefined = body;
let failure: unknown;
for (let attempt = 0; ; ) {
if (stream) {
try {
await this.consumeSse(stream, cursor, onMessage);
failure = undefined;
} catch (error) {
failure = error;
}
}
if (answered || this.closed) return;
if (failure !== undefined && !this.isRetryable(failure)) break;
if (cursor.lastEventId === undefined || attempt >= this.maxRetries()) break;
if (cursor.received) attempt = 0;
cursor.received = false;
if (!(await this.sleep(this.reconnectDelay(attempt++, cursor.retryMs)))) return;
try {
stream = await this.openSseStream(cursor.lastEventId);
} catch (error) {
failure = error;
if (!this.isRetryable(error)) break;
stream = undefined;
}
}
if (this.closed) return;
const reason = failure === undefined ? "stream ended without a response" : toError(failure).message;
this.emitMessage({
jsonrpc: "2.0",
id: requestId,
error: { code: JSON_RPC_ERROR_CODES.internalError, message: `MCP response stream failed: ${reason}` },
});
}
private startGetStream(): void {
if (this.options.openGetStream === false || this.getStreamStarted || this.closed) return;
this.getStreamStarted = true;
void this.runGetStream();
}
/** Keep the server-to-client stream open, reconnecting with backoff when it drops. */
private async runGetStream(): Promise<void> {
const cursor: StreamCursor = { lastEventId: undefined, retryMs: undefined, received: false };
for (let attempt = 0; !this.closed; ) {
try {
const stream = await this.openSseStream(cursor.lastEventId);
// The server does not offer a GET stream.
if (!stream) return;
const openedAt = Date.now();
await this.consumeSse(stream, cursor);
// A stream that stayed up for a while counts as healthy, even if it was idle.
if (cursor.received || Date.now() - openedAt > this.maxDelay()) attempt = 0;
} catch (error) {
if (this.closed) return;
if (!this.isRetryable(error)) {
this.emitError(error);
return;
}
}
cursor.received = false;
if (attempt >= this.maxRetries()) {
this.emitError(new Error("MCP server-to-client stream dropped and could not be reopened"));
return;
}
if (!(await this.sleep(this.reconnectDelay(attempt++, cursor.retryMs)))) return;
}
}
/** Open a GET SSE stream. Resolves to undefined when the server answers 405 (no GET stream). */
private async openSseStream(lastEventId: string | undefined): Promise<ReadableStream<Uint8Array> | undefined> {
const response = await this.authorizedFetch("GET", {
headers: {
accept: "text/event-stream",
...(lastEventId === undefined ? {} : { "last-event-id": lastEventId }),
},
});
if (response.status === 405) {
await discard(response);
return undefined;
}
await this.checkResponse(response);
this.captureSession(response);
const type = contentType(response);
if (type !== "text/event-stream" || !response.body) {
await discard(response);
throw new McpHttpError(response.status, `Unsupported MCP GET response content type: ${type ?? "missing"}`);
}
return response.body;
}
/**
* Network failures and transient statuses are retried; auth, session, and protocol errors are not.
* `fetch` reports network failures, including a connection dropped mid-body, as `TypeError`.
*/
private isRetryable(error: unknown): boolean {
if (error instanceof McpHttpError) return isTransientStatus(error.status);
if (error instanceof TypeError) return true;
const code = (error as { code?: unknown } | undefined)?.code;
return typeof code === "string" && (code.startsWith("E") || code.startsWith("UND_ERR"));
}
private reconnectDelay(attempt: number, serverDelayMs: number | undefined): number {
if (serverDelayMs !== undefined) return serverDelayMs;
const initial = this.options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT_INITIAL_DELAY_MS;
return Math.min(initial * 2 ** attempt, this.maxDelay());
}
private maxDelay(): number {
return this.options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
}
private maxRetries(): number {
return this.options.reconnect?.maxRetries ?? DEFAULT_RECONNECT_MAX_RETRIES;
}
/** Resolves false when the transport closed while waiting. */
private sleep(ms: number): Promise<boolean> {
const signal = this.controller.signal;
if (signal.aborted) return Promise.resolve(false);
return new Promise((resolve) => {
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(true);
}, ms);
// A reconnect wait alone does not keep the process alive.
timer.unref?.();
signal.addEventListener("abort", onAbort, { once: true });
});
}
}

View File

@@ -0,0 +1,64 @@
import { type JsonRpcMessage, toError } from "../protocol/jsonrpc.ts";
export const DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
export type McpTransportMessageListener = (message: JsonRpcMessage) => void;
export type McpTransportErrorListener = (error: Error) => void;
export type McpTransportCloseListener = () => void;
export interface McpTransportSendOptions {
/**
* Aborted once the sender no longer waits for a reply (the request was cancelled, timed out, answered,
* or the client closed). A transport must not dispatch the message after that, for example when
* retrying it after an authorization refresh. It does not undo a message already dispatched.
*/
signal?: AbortSignal;
}
export interface McpTransport {
start(): Promise<void>;
send(message: JsonRpcMessage, options?: McpTransportSendOptions): Promise<void>;
close(): Promise<void>;
onMessage(listener: McpTransportMessageListener): () => void;
onError(listener: McpTransportErrorListener): () => void;
onClose(listener: McpTransportCloseListener): () => void;
setProtocolVersion?(version: string): void;
}
/** Listener bookkeeping shared by transports. `emitClose` fires at most once per transport. */
export abstract class TransportEvents {
private messageListeners = new Set<McpTransportMessageListener>();
private errorListeners = new Set<McpTransportErrorListener>();
private closeListeners = new Set<McpTransportCloseListener>();
private closeEmitted = false;
onMessage(listener: McpTransportMessageListener): () => void {
this.messageListeners.add(listener);
return () => this.messageListeners.delete(listener);
}
onError(listener: McpTransportErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
onClose(listener: McpTransportCloseListener): () => void {
this.closeListeners.add(listener);
return () => this.closeListeners.delete(listener);
}
protected emitMessage(message: JsonRpcMessage): void {
for (const listener of this.messageListeners) listener(message);
}
protected emitError(error: unknown): void {
const normalized = toError(error);
for (const listener of this.errorListeners) listener(normalized);
}
protected emitClose(): void {
if (this.closeEmitted) return;
this.closeEmitted = true;
for (const listener of this.closeListeners) listener();
}
}