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,75 @@
# Changelog
## [1.3.0] - 2026-10-15
## [1.2.0] - 2026-10-12
### Breaking Changes
- Changed `CallToolResult.structuredContent` from `Record<string, unknown>` to `unknown`: `callTool()` now accepts any JSON value as structured content, including arrays and `null`, as MCP 2026-07-28 allows, instead of failing with "Invalid MCP tools/call structured content" ([#10416](https://github.com/earendil-works/pi/issues/10416))
### Fixed
- Fixed `authorizeMcp()` exchanging an authorization code whose `iss` parameter could not be checked because no authorization server metadata was discovered. It now throws `OAuthIssuerMismatchError`, whose `expected` is `undefined` in that case (RFC 9207). Reported by [@AizenvoltPrime](https://github.com/AizenvoltPrime)
- Fixed `StreamableHttpTransport` retrying a request after an authorization refresh even though the caller had already cancelled it or it had timed out, so the server still executed it. `McpTransport.send()` now takes an optional `signal`, which `McpClient` aborts once a request settles ([#10598](https://github.com/earendil-works/pi/issues/10598))
## [1.1.0] - 2026-10-07
### Added
- Added a `signal` option to `authorizeMcp()`, `registerClient()`, the token request functions, and the discovery functions, which aborts their requests. An aborted refresh no longer falls back to a new authorization ([#10565](https://github.com/earendil-works/pi/issues/10565))
### Fixed
- Fixed `StreamableHttpTransport.close()` calling `AuthProvider.token()`, which could refresh tokens over the network before the session DELETE and delay closing; the DELETE now reuses the token of the last request ([#10565](https://github.com/earendil-works/pi/issues/10565))
## [1.0.4] - 2026-10-05
### Fixed
- Fixed dynamic client registration failing on OpenID Connect authorization servers with `invalid_redirect_uri` for loopback redirect URIs. `registerClient()` now sends `application_type` (MCP SEP-837), derived from `redirect_uris` when `OAuthClientMetadata.application_type` is not set: `native` for loopback hosts and custom schemes, otherwise `web` ([#10493](https://github.com/earendil-works/pi/issues/10493))
## [1.0.3] - 2026-10-05
## [1.0.2] - 2026-10-04
## [1.0.1] - 2026-10-03
### Breaking Changes
- `OAuthClientProvider.clientMetadataUrl` is replaced by `clientMetadataDocument(metadata)`, which returns the document URL and its redirect URI per authorization server, or `undefined` to register dynamically. It is called whenever no client information is stored, also when the server does not advertise support, and the document is no longer stored as client information ([#10302](https://github.com/earendil-works/pi/issues/10302))
### Added
- `McpOAuthProviderOptions.clientMetadataDocument`, `OAuthCallbackServerOptions.extraPaths`, and a `path` argument for `OAuthCallbackServer.waitForCallback` that rejects responses on other paths ([#10302](https://github.com/earendil-works/pi/issues/10302))
## [1.0.0] - 2026-10-01
### Added
- Added an `authorizationServerMetadataUrl` option to `authorizeMcp()` to use a configured authorization server metadata document instead of discovery ([#10172](https://github.com/earendil-works/pi/issues/10172)).
- Added an `iss` option to `authorizeMcp()`. The authorization code is only exchanged when `iss` names the flow's authorization server, or is absent and the server's metadata does not set `authorization_response_iss_parameter_supported` (RFC 9207).
- Added `stepUpScope()`, which combines the scopes of an `insufficient_scope` challenge with the scopes granted so far.
### Fixed
- Fixed OAuth token and client registration responses with an empty or `null` optional field (such as `"scope": ""`) failing with `Invalid scope` and similar errors; such fields are now treated as absent ([#10266](https://github.com/earendil-works/pi/issues/10266)).
- Fixed `expires_in: null` in a token response marking the access token as already expired.
- Fixed invalid URLs in protected resource metadata aborting OAuth discovery instead of falling back to the MCP server's origin.
- Fixed an empty requested scope, from `scope=""` in a `WWW-Authenticate` challenge or an empty `scopes_supported`, overriding the next scope source.
- Fixed list pagination failing with a duplicate cursor error when a server ends pagination with `nextCursor: ""` or `null`.
- Fixed step-up authorization in `adaptOAuthProvider()` requesting only the scopes of the `insufficient_scope` challenge. The new token lost the scopes granted before, so other requests failed with `insufficient_scope` again. Tokens now record their granted scope, which is the requested scope when the token response omits `scope`.
## [0.99.2] - 2026-09-30
### Fixed
- Fixed `StreamableHttpTransport` failing every request on Cloudflare Workers with `Illegal invocation` by calling `fetch`, including `UnauthorizedContext.fetch`, without a receiver ([#10188](https://github.com/earendil-works/pi/issues/10188))
## [0.99.1] - 2026-09-29
## [0.99.0] - 2026-09-29
### Added
- Added a standalone MCP client with JSON-RPC lifecycle, tool discovery and calls, cancellation, progress, roots, stdio and Streamable HTTP transports, and an in-memory testing transport.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Anthropic, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

136
third_party/pi/packages/mcp/README.md vendored Normal file
View File

@@ -0,0 +1,136 @@
# @earendil-works/pi-mcp
A small, standalone Model Context Protocol client. It does not depend on the official MCP SDK or other pi packages.
The package provides a transport-neutral client core, stdio and Streamable HTTP transports, and an in-memory testing transport.
## Usage
```typescript
import { McpClient, StdioTransport } from "@earendil-works/pi-mcp";
const transport = new StdioTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
});
const client = new McpClient({
name: "my-client",
version: "1.0.0",
roots: [{ uri: "file:///workspace", name: "workspace" }],
});
await client.connect(transport);
const tools = await client.listTools();
const result = await client.callTool("search", { query: "MCP" });
await client.close();
```
For a remote server, use `new StreamableHttpTransport({ url, headers })`. Fetch can be injected for proxying or custom networking.
### Tools for an LLM
`toLlmContent(result)` converts a `CallToolResult` to text and image content for a model, in the shape of `@earendil-works/pi-ai`'s `TextContent` and `ImageContent`. Text and images pass through, embedded text and image resources are unwrapped, and audio, resource links, and binary resources become short text placeholders. A result without content blocks but with `structuredContent` becomes its JSON.
Wrapping an MCP tool as a `pi-agent-core` `AgentTool`:
```typescript
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { toLlmContent } from "@earendil-works/pi-mcp";
import { Type } from "typebox";
const tools: AgentTool[] = (await client.listTools()).map((tool) => ({
// Providers allow at most 64 characters of [A-Za-z0-9_-].
name: `mcp_${tool.name}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64),
label: tool.title ?? tool.name,
description: tool.description ?? tool.name,
// Providers require an object schema, and some reject one without `properties`.
parameters: Type.Unsafe({ ...tool.inputSchema, type: "object", properties: tool.inputSchema.properties ?? {} }),
execute: async (_toolCallId, params, signal) => {
const result = await client.callTool(tool.name, params as Record<string, unknown>, { signal });
// MCP reports tool failures in the result instead of as a protocol error.
return { content: toLlmContent(result), details: undefined, isError: result.isError === true };
},
}));
```
The [mcp-codemode example](https://github.com/earendil-works/pi/tree/main/packages/agent/examples/mcp-codemode) also forwards progress, passes `structuredContent` through, and lets `@earendil-works/pi-codemode` scripts call the tools.
### OAuth
`@earendil-works/pi-mcp/oauth` provides the MCP OAuth client subset without depending on the official SDK:
```typescript
import { McpClient, StreamableHttpTransport } from "@earendil-works/pi-mcp";
import {
adaptOAuthProvider,
authorizeMcp,
McpOAuthAuthorizationRequiredError,
McpOAuthProvider,
OAuthCallbackServer,
} from "@earendil-works/pi-mcp/oauth";
const serverUrl = "https://mcp.example.com/mcp";
const callback = await OAuthCallbackServer.listen();
let authorizationUrl: URL | undefined;
const oauth = new McpOAuthProvider({
serverUrl,
redirectUrl: callback.redirectUrl,
clientMetadata: { client_name: "My MCP client" },
onRedirect: (url) => {
authorizationUrl = url;
},
});
const connect = () => {
const client = new McpClient({ name: "my-client", version: "1.0.0" });
return {
client,
connected: client.connect(
new StreamableHttpTransport({ url: serverUrl, authProvider: adaptOAuthProvider(oauth) }),
),
};
};
const first = connect();
try {
await first.connected;
} catch (error) {
if (!(error instanceof McpOAuthAuthorizationRequiredError)) throw error;
const state = await oauth.state();
const result = callback.waitForCallback(state);
// Open authorizationUrl in the user's browser here.
const { code } = await result;
await authorizeMcp(oauth, { serverUrl, authorizationCode: code });
}
const { client, connected } = connect();
await connected;
```
Inject `McpOAuthStateStore` into `McpOAuthProvider` for durable credentials. The package does not open a browser or choose where credentials are stored.
The OAuth implementation is adapted from the MIT-licensed Model Context Protocol TypeScript SDK v1.29.0. Its license is included under `LICENSES/`.
An MCP transport owns framing and I/O. It delivers individual JSON-RPC messages to `McpClient`; the client owns request correlation, initialization, timeouts, cancellation, server requests, and protocol-level helpers.
## Supported protocol surface
- MCP protocol version `2025-11-25`, accepting servers that negotiate `2025-06-18`, `2025-03-26`, or `2024-11-05`
- initialization and `notifications/initialized`
- ping
- paginated `tools/list`
- `tools/call`, including structured content
- progress notifications and timeout renewal
- request cancellation
- Streamable HTTP sessions, the server-to-client GET stream with reconnection, and resumption of dropped response streams with `Last-Event-ID`
- stdio shutdown per the spec (close stdin, then SIGTERM, then SIGKILL), applied to the server's whole process group
- server `ping` and `roots/list` requests
- logging and tool-list-change notifications through the generic notification API
- OAuth protected-resource and authorization-server discovery
- PKCE authorization code flow, dynamic client registration, token refresh (one refresh shared by concurrent 401s), and step-up authorization for `insufficient_scope`
Batch JSON-RPC messages, legacy HTTP+SSE, servers, sampling, and tasks are outside the initial core.
## Testing
`@earendil-works/pi-mcp/testing` exports `createInMemoryTransportPair()` for client and adapter tests.

View File

@@ -0,0 +1,61 @@
{
"name": "@earendil-works/pi-mcp",
"version": "1.3.0",
"description": "Standalone Model Context Protocol client for pi and other applications",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./oauth": {
"source": "./src/oauth/index.ts",
"types": "./dist/oauth/index.d.ts",
"import": "./dist/oauth/index.js"
},
"./testing": {
"source": "./src/testing/index.ts",
"types": "./dist/testing/index.d.ts",
"import": "./dist/testing/index.js"
}
},
"sideEffects": false,
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSES"
],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"mcp",
"model-context-protocol",
"client"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/mcp"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"cross-spawn": "7.0.6"
},
"devDependencies": {
"@types/cross-spawn": "6.0.6",
"shx": "0.3.4",
"vitest": "4.1.11"
}
}

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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
},
resolve: { conditions: ["source"] },
ssr: { resolve: { conditions: ["source"] } },
});