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:
75
third_party/pi/packages/mcp/CHANGELOG.md
vendored
Normal file
75
third_party/pi/packages/mcp/CHANGELOG.md
vendored
Normal 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.
|
||||
21
third_party/pi/packages/mcp/LICENSES/modelcontextprotocol-typescript-sdk.txt
vendored
Normal file
21
third_party/pi/packages/mcp/LICENSES/modelcontextprotocol-typescript-sdk.txt
vendored
Normal 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
136
third_party/pi/packages/mcp/README.md
vendored
Normal 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.
|
||||
61
third_party/pi/packages/mcp/package.json
vendored
Normal file
61
third_party/pi/packages/mcp/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
20
third_party/pi/packages/mcp/src/auth-provider.ts
vendored
Normal file
20
third_party/pi/packages/mcp/src/auth-provider.ts
vendored
Normal 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>;
|
||||
}
|
||||
621
third_party/pi/packages/mcp/src/client.ts
vendored
Normal file
621
third_party/pi/packages/mcp/src/client.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
72
third_party/pi/packages/mcp/src/index.ts
vendored
Normal file
72
third_party/pi/packages/mcp/src/index.ts
vendored
Normal 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";
|
||||
164
third_party/pi/packages/mcp/src/oauth/callback.ts
vendored
Normal file
164
third_party/pi/packages/mcp/src/oauth/callback.ts
vendored
Normal 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 });
|
||||
}
|
||||
}
|
||||
215
third_party/pi/packages/mcp/src/oauth/discovery.ts
vendored
Normal file
215
third_party/pi/packages/mcp/src/oauth/discovery.ts
vendored
Normal 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;
|
||||
}
|
||||
55
third_party/pi/packages/mcp/src/oauth/errors.ts
vendored
Normal file
55
third_party/pi/packages/mcp/src/oauth/errors.ts
vendored
Normal 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";
|
||||
}
|
||||
}
|
||||
482
third_party/pi/packages/mcp/src/oauth/flow.ts
vendored
Normal file
482
third_party/pi/packages/mcp/src/oauth/flow.ts
vendored
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
56
third_party/pi/packages/mcp/src/oauth/index.ts
vendored
Normal file
56
third_party/pi/packages/mcp/src/oauth/index.ts
vendored
Normal 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";
|
||||
168
third_party/pi/packages/mcp/src/oauth/provider.ts
vendored
Normal file
168
third_party/pi/packages/mcp/src/oauth/provider.ts
vendored
Normal 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 };
|
||||
}
|
||||
}
|
||||
209
third_party/pi/packages/mcp/src/oauth/types.ts
vendored
Normal file
209
third_party/pi/packages/mcp/src/oauth/types.ts
vendored
Normal 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") ?? [],
|
||||
});
|
||||
}
|
||||
118
third_party/pi/packages/mcp/src/protocol/content.ts
vendored
Normal file
118
third_party/pi/packages/mcp/src/protocol/content.ts
vendored
Normal 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;
|
||||
}
|
||||
113
third_party/pi/packages/mcp/src/protocol/jsonrpc.ts
vendored
Normal file
113
third_party/pi/packages/mcp/src/protocol/jsonrpc.ts
vendored
Normal 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");
|
||||
}
|
||||
131
third_party/pi/packages/mcp/src/protocol/types.ts
vendored
Normal file
131
third_party/pi/packages/mcp/src/protocol/types.ts
vendored
Normal 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>;
|
||||
}
|
||||
1
third_party/pi/packages/mcp/src/testing/index.ts
vendored
Normal file
1
third_party/pi/packages/mcp/src/testing/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { createInMemoryTransportPair, InMemoryTransport } from "../transports/in-memory.ts";
|
||||
51
third_party/pi/packages/mcp/src/transports/in-memory.ts
vendored
Normal file
51
third_party/pi/packages/mcp/src/transports/in-memory.ts
vendored
Normal 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 };
|
||||
}
|
||||
216
third_party/pi/packages/mcp/src/transports/stdio.ts
vendored
Normal file
216
third_party/pi/packages/mcp/src/transports/stdio.ts
vendored
Normal 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"));
|
||||
}
|
||||
}
|
||||
521
third_party/pi/packages/mcp/src/transports/streamable-http.ts
vendored
Normal file
521
third_party/pi/packages/mcp/src/transports/streamable-http.ts
vendored
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
64
third_party/pi/packages/mcp/src/transports/transport.ts
vendored
Normal file
64
third_party/pi/packages/mcp/src/transports/transport.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
339
third_party/pi/packages/mcp/test/client.test.ts
vendored
Normal file
339
third_party/pi/packages/mcp/test/client.test.ts
vendored
Normal file
@@ -0,0 +1,339 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type JsonRpcMessage,
|
||||
type JsonRpcRequest,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
McpAbortError,
|
||||
McpClient,
|
||||
McpError,
|
||||
McpTimeoutError,
|
||||
} from "../src/index.ts";
|
||||
import { createInMemoryTransportPair, type InMemoryTransport } from "../src/testing/index.ts";
|
||||
|
||||
interface TestServer {
|
||||
transport: InMemoryTransport;
|
||||
messages: JsonRpcMessage[];
|
||||
setHandler(method: string, handler: (request: JsonRpcRequest) => unknown | Promise<unknown>): void;
|
||||
}
|
||||
|
||||
async function createServer(): Promise<{ clientTransport: InMemoryTransport; server: TestServer }> {
|
||||
const pair = createInMemoryTransportPair();
|
||||
const handlers = new Map<string, (request: JsonRpcRequest) => unknown | Promise<unknown>>();
|
||||
const messages: JsonRpcMessage[] = [];
|
||||
pair.server.onMessage((message) => {
|
||||
messages.push(message);
|
||||
if (!("id" in message) || !("method" in message)) return;
|
||||
const request = message as JsonRpcRequest;
|
||||
const handler = handlers.get(request.method);
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
if (!handler) throw new McpError(-32601, `Method not found: ${request.method}`);
|
||||
await pair.server.send({ jsonrpc: "2.0", id: request.id, result: await handler(request) });
|
||||
} catch (error) {
|
||||
const mcpError = error instanceof McpError ? error : new McpError(-32603, String(error));
|
||||
await pair.server.send({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
error: { code: mcpError.code, message: mcpError.message, data: mcpError.data },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
await pair.server.start();
|
||||
const server: TestServer = {
|
||||
transport: pair.server,
|
||||
messages,
|
||||
setHandler(method, handler) {
|
||||
handlers.set(method, handler);
|
||||
},
|
||||
};
|
||||
server.setHandler("initialize", () => ({
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
serverInfo: { name: "test-server", version: "1.0.0" },
|
||||
instructions: "Use test tools.",
|
||||
}));
|
||||
return { clientTransport: pair.client, server };
|
||||
}
|
||||
|
||||
async function connect(): Promise<{ client: McpClient; server: TestServer }> {
|
||||
const { clientTransport, server } = await createServer();
|
||||
const client = new McpClient({ name: "test-client", version: "2.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
return { client, server };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("McpClient", () => {
|
||||
it("initializes the connection before exposing server information", async () => {
|
||||
const { client, server } = await connect();
|
||||
expect(client.connectionState).toBe("connected");
|
||||
expect(client.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
expect(client.serverInfo).toEqual({ name: "test-server", version: "1.0.0" });
|
||||
expect(client.serverCapabilities).toEqual({ tools: { listChanged: true } });
|
||||
expect(client.instructions).toBe("Use test tools.");
|
||||
expect(server.messages).toEqual([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "2.0.0" },
|
||||
},
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("paginates tools and preserves protocol tool definitions", async () => {
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("tools/list", (request) => {
|
||||
const cursor = (request.params as { cursor?: string } | undefined)?.cursor;
|
||||
return cursor === undefined
|
||||
? {
|
||||
tools: [{ name: "search", description: "Search", inputSchema: { type: "object" } }],
|
||||
nextCursor: "page-2",
|
||||
}
|
||||
: {
|
||||
tools: [
|
||||
{
|
||||
name: "read",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { type: "object" },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
],
|
||||
// Some servers end pagination with an empty cursor instead of omitting it.
|
||||
nextCursor: "",
|
||||
};
|
||||
});
|
||||
expect(await client.listTools()).toEqual([
|
||||
{ name: "search", description: "Search", inputSchema: { type: "object" } },
|
||||
{
|
||||
name: "read",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { type: "object" },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("lists and reads resources", async () => {
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("resources/list", (request) =>
|
||||
(request.params as { cursor?: string } | undefined)?.cursor === undefined
|
||||
? { resources: [{ uri: "file:///a", name: "a", mimeType: "text/plain" }], nextCursor: "2" }
|
||||
: { resources: [{ uri: "file:///b" }] },
|
||||
);
|
||||
server.setHandler("resources/templates/list", () => ({
|
||||
resourceTemplates: [{ uriTemplate: "repo://{owner}/{repo}", name: "repo" }],
|
||||
}));
|
||||
server.setHandler("resources/read", (request) => ({
|
||||
contents: [{ uri: (request.params as { uri: string }).uri, text: "hello" }],
|
||||
}));
|
||||
// A missing name falls back to the URI.
|
||||
expect(await client.listResources()).toEqual([
|
||||
{ uri: "file:///a", name: "a", mimeType: "text/plain" },
|
||||
{ uri: "file:///b", name: "file:///b" },
|
||||
]);
|
||||
expect(await client.listResourceTemplates()).toEqual([{ uriTemplate: "repo://{owner}/{repo}", name: "repo" }]);
|
||||
// Single pages pass the cursor through.
|
||||
expect(await client.listResourcesPage()).toEqual({
|
||||
resources: [{ uri: "file:///a", name: "a", mimeType: "text/plain" }],
|
||||
nextCursor: "2",
|
||||
});
|
||||
expect(await client.listResourcesPage("2")).toEqual({ resources: [{ uri: "file:///b", name: "file:///b" }] });
|
||||
expect(await client.readResource("file:///a")).toEqual({ contents: [{ uri: "file:///a", text: "hello" }] });
|
||||
|
||||
server.setHandler("resources/read", () => ({ contents: [{ uri: "file:///a" }] }));
|
||||
await expect(client.readResource("file:///a")).rejects.toThrow("Invalid contents in MCP resources/read result");
|
||||
server.setHandler("resources/list", () => ({ resources: [{ name: "no uri" }] }));
|
||||
await expect(client.listResources()).rejects.toThrow("Invalid entry in MCP resources/list result");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("returns structured tool content and surfaces JSON-RPC errors", async () => {
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("tools/call", (request) => {
|
||||
const params = request.params as { name: string; arguments?: Record<string, unknown> };
|
||||
if (params.name === "fail") throw new McpError(1234, "tool failed", { retryable: false });
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
structuredContent: { count: params.arguments?.count },
|
||||
};
|
||||
});
|
||||
expect(await client.callTool("count", { count: 3 })).toEqual({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
structuredContent: { count: 3 },
|
||||
});
|
||||
await expect(client.callTool("fail")).rejects.toMatchObject({
|
||||
name: "McpError",
|
||||
code: 1234,
|
||||
message: "tool failed",
|
||||
data: { retryable: false },
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("renews the timeout on progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("tools/call", async (request) => {
|
||||
const token = ((request.params as Record<string, unknown>)._meta as Record<string, unknown>)
|
||||
.progressToken as number;
|
||||
setTimeout(() => {
|
||||
void server.transport.send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: token, progress: 1, total: 2 },
|
||||
});
|
||||
}, 40);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
return { content: [{ type: "text", text: "done" }] };
|
||||
});
|
||||
const progress = vi.fn();
|
||||
const result = client.callTool("slow", {}, { timeoutMs: 50, onProgress: progress });
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
expect(await result).toEqual({ content: [{ type: "text", text: "done" }] });
|
||||
expect(progress).toHaveBeenCalledWith({ progressToken: 2, progress: 1, total: 2 });
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("cancels aborted and timed-out requests", async () => {
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("tools/call", () => new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const aborted = client.callTool("wait", {}, { signal: controller.signal });
|
||||
controller.abort("stop");
|
||||
await expect(aborted).rejects.toBeInstanceOf(McpAbortError);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(server.messages).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/cancelled",
|
||||
params: { requestId: 2, reason: "stop" },
|
||||
});
|
||||
|
||||
await expect(client.callTool("wait", {}, { timeoutMs: 5 })).rejects.toBeInstanceOf(McpTimeoutError);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("reports transport errors without failing pending requests", async () => {
|
||||
const { clientTransport, server } = await createServer();
|
||||
const client = new McpClient({ name: "test-client", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
const errors: Error[] = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
let respond = () => {};
|
||||
server.setHandler(
|
||||
"tools/call",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
respond = () => resolve({ content: [] });
|
||||
}),
|
||||
);
|
||||
const call = client.callTool("wait");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
clientTransport.emitError(new Error("stray log line"));
|
||||
respond();
|
||||
expect(await call).toEqual({ content: [] });
|
||||
expect(errors.map((error) => error.message)).toEqual(["stray log line"]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("accepts servers that answer with an older protocol version", async () => {
|
||||
const { clientTransport, server } = await createServer();
|
||||
server.setHandler("initialize", () => ({
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "old-server", version: "0.1.0" },
|
||||
}));
|
||||
const client = new McpClient({ name: "test-client", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
expect(client.protocolVersion).toBe("2024-11-05");
|
||||
await client.close();
|
||||
|
||||
const unsupported = await createServer();
|
||||
unsupported.server.setHandler("initialize", () => ({
|
||||
protocolVersion: "1999-01-01",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "ancient-server", version: "0.1.0" },
|
||||
}));
|
||||
const rejected = new McpClient({ name: "test-client", version: "1.0.0" });
|
||||
await expect(rejected.connect(unsupported.clientTransport)).rejects.toThrow("unsupported protocol version");
|
||||
expect(rejected.connectionState).toBe("closed");
|
||||
});
|
||||
|
||||
it("defaults missing tool result content to an empty list", async () => {
|
||||
const { client, server } = await connect();
|
||||
server.setHandler("tools/call", () => ({ structuredContent: { ok: true } }));
|
||||
expect(await client.callTool("structured")).toEqual({ content: [], structuredContent: { ok: true } });
|
||||
server.setHandler("tools/call", () => ({ content: "not a list" }));
|
||||
await expect(client.callTool("broken")).rejects.toThrow("Invalid MCP tools/call result");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
// #10416: MCP 2026-07-28 allows any JSON value as structured content.
|
||||
it("accepts structured content that is not an object", async () => {
|
||||
const { client, server } = await connect();
|
||||
for (const structuredContent of [[{ id: 1 }, { id: 2 }], null, "text", 3, false]) {
|
||||
server.setHandler("tools/call", () => ({ content: [], structuredContent }));
|
||||
expect(await client.callTool("structured")).toEqual({ content: [], structuredContent });
|
||||
}
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("does not send notifications/cancelled for a timed-out initialize", async () => {
|
||||
const { clientTransport, server } = await createServer();
|
||||
server.setHandler("initialize", () => new Promise(() => {}));
|
||||
const client = new McpClient({ name: "test-client", version: "1.0.0", requestTimeoutMs: 5 });
|
||||
await expect(client.connect(clientTransport)).rejects.toBeInstanceOf(McpTimeoutError);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(
|
||||
server.messages.some((message) => "method" in message && message.method === "notifications/cancelled"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("notifies close listeners once when the transport drops", async () => {
|
||||
const { client, server } = await connect();
|
||||
const closed = vi.fn();
|
||||
client.onClose(closed);
|
||||
server.setHandler("tools/call", () => new Promise(() => {}));
|
||||
const pending = client.callTool("wait");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await server.transport.close();
|
||||
await expect(pending).rejects.toThrow("MCP connection closed");
|
||||
expect(client.connectionState).toBe("closed");
|
||||
await client.close();
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("answers roots/list and dispatches notifications", async () => {
|
||||
const { clientTransport, server } = await createServer();
|
||||
const client = new McpClient({
|
||||
name: "test-client",
|
||||
version: "1.0.0",
|
||||
roots: [{ uri: "file:///workspace", name: "workspace" }],
|
||||
});
|
||||
await client.connect(clientTransport);
|
||||
const changed = vi.fn();
|
||||
client.onNotification("notifications/tools/list_changed", changed);
|
||||
await server.transport.send({ jsonrpc: "2.0", id: "roots", method: "roots/list" });
|
||||
await server.transport.send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(server.messages).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: "roots",
|
||||
result: { roots: [{ uri: "file:///workspace", name: "workspace" }] },
|
||||
});
|
||||
expect(changed).toHaveBeenCalledWith(undefined);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
39
third_party/pi/packages/mcp/test/content.test.ts
vendored
Normal file
39
third_party/pi/packages/mcp/test/content.test.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type ContentBlock, toLlmContent } from "../src/index.ts";
|
||||
|
||||
describe("toLlmContent", () => {
|
||||
it("passes text and images through and replaces other blocks with text", () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: "text", text: "hello", annotations: { priority: 1 } },
|
||||
{ type: "image", data: "aW1n", mimeType: "image/png", _meta: { x: 1 } },
|
||||
{ type: "audio", data: "YXVk", mimeType: "audio/wav" },
|
||||
{ type: "resource_link", uri: "file:///a.txt", name: "a.txt" },
|
||||
{ type: "resource", resource: { uri: "file:///b.txt", text: "inline" } },
|
||||
{ type: "resource", resource: { uri: "file:///c.png", mimeType: "image/png", blob: "Yw==" } },
|
||||
{ type: "resource", resource: { uri: "file:///d.bin", blob: "ZA==" } },
|
||||
];
|
||||
expect(toLlmContent({ content: blocks })).toEqual([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", data: "aW1n", mimeType: "image/png" },
|
||||
{ type: "text", text: "[audio audio/wav omitted]" },
|
||||
{ type: "text", text: "a.txt: file:///a.txt" },
|
||||
{ type: "text", text: "inline" },
|
||||
{ type: "image", data: "Yw==", mimeType: "image/png" },
|
||||
{ type: "text", text: "[binary resource file:///d.bin (unknown type) omitted]" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the structured content as JSON when there are no blocks", () => {
|
||||
// #10416: structured content may be any JSON value.
|
||||
expect(toLlmContent({ content: [], structuredContent: [1, 2] })).toEqual([
|
||||
{ type: "text", text: "[\n 1,\n 2\n]" },
|
||||
]);
|
||||
expect(toLlmContent({ content: [], structuredContent: null })).toEqual([{ type: "text", text: "null" }]);
|
||||
expect(toLlmContent({ content: [], structuredContent: { n: 1 } })).toEqual([
|
||||
{ type: "text", text: '{\n "n": 1\n}' },
|
||||
]);
|
||||
expect(toLlmContent({ content: [{ type: "text", text: "n=1" }], structuredContent: { n: 1 } })).toEqual([
|
||||
{ type: "text", text: "n=1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
third_party/pi/packages/mcp/test/fixtures/stdio-server.mjs
vendored
Normal file
27
third_party/pi/packages/mcp/test/fixtures/stdio-server.mjs
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
console.error("stdio fixture ready");
|
||||
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of lines) {
|
||||
const message = JSON.parse(line);
|
||||
if (!("id" in message)) continue;
|
||||
let result;
|
||||
if (message.method === "initialize") {
|
||||
result = {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "stdio-fixture", version: "1.0.0" },
|
||||
};
|
||||
} else if (message.method === "tools/list") {
|
||||
result = { tools: [{ name: "echo", inputSchema: { type: "object" } }] };
|
||||
} else if (message.method === "tools/call") {
|
||||
result = { content: [{ type: "text", text: String(message.params.arguments.text) }] };
|
||||
} else if (message.method === "ping") {
|
||||
result = {};
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "not found" } })}\n`);
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`);
|
||||
}
|
||||
22
third_party/pi/packages/mcp/test/fixtures/stubborn-server.mjs
vendored
Normal file
22
third_party/pi/packages/mcp/test/fixtures/stubborn-server.mjs
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// Answers initialize, spawns a grandchild that outlives stdin, and ignores stdin EOF and SIGTERM.
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
process.on("SIGTERM", () => {});
|
||||
const grandchild = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
console.error(`grandchild ${grandchild.pid}`);
|
||||
setInterval(() => {}, 1000);
|
||||
|
||||
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method !== "initialize") return;
|
||||
const result = {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "stubborn-fixture", version: "1.0.0" },
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`);
|
||||
});
|
||||
40
third_party/pi/packages/mcp/test/helpers.ts
vendored
Normal file
40
third_party/pi/packages/mcp/test/helpers.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
|
||||
export type HttpHandler = (request: IncomingMessage, response: ServerResponse, origin: string) => Promise<void>;
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
export async function readBody(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
/** Starts a loopback HTTP server and returns its origin. Call `closeServers` from `afterEach`. */
|
||||
export async function listen(handler: HttpHandler): Promise<string> {
|
||||
let origin = "";
|
||||
const server = createServer((request, response) => {
|
||||
void handler(request, response, origin).catch((error) => {
|
||||
response.statusCode = 500;
|
||||
response.end(String(error));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("HTTP test server did not bind to TCP");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
servers.push(server);
|
||||
return origin;
|
||||
}
|
||||
|
||||
export async function closeServers(): Promise<void> {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.closeAllConnections();
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
639
third_party/pi/packages/mcp/test/oauth.test.ts
vendored
Normal file
639
third_party/pi/packages/mcp/test/oauth.test.ts
vendored
Normal file
@@ -0,0 +1,639 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { McpClient, StreamableHttpTransport } from "../src/index.ts";
|
||||
import {
|
||||
adaptOAuthProvider,
|
||||
authorizeMcp,
|
||||
discoverAuthorizationServerMetadata,
|
||||
McpOAuthAuthorizationRequiredError,
|
||||
McpOAuthProvider,
|
||||
MemoryOAuthStateStore,
|
||||
type OAuthCallbackPage,
|
||||
OAuthCallbackServer,
|
||||
type OAuthClientInformationMixed,
|
||||
type OAuthClientProvider,
|
||||
type OAuthDiscoveryState,
|
||||
OAuthInsecureEndpointError,
|
||||
OAuthIssuerMismatchError,
|
||||
type OAuthTokens,
|
||||
registerClient,
|
||||
} from "../src/oauth/index.ts";
|
||||
import { closeServers, listen, readBody } from "./helpers.ts";
|
||||
|
||||
class TestOAuthProvider implements OAuthClientProvider {
|
||||
readonly redirectUrl: string;
|
||||
readonly clientMetadata;
|
||||
client: OAuthClientInformationMixed | undefined;
|
||||
tokenSet: OAuthTokens | undefined;
|
||||
verifier: string | undefined;
|
||||
discovery: OAuthDiscoveryState | undefined;
|
||||
authorizationUrl: URL | undefined;
|
||||
|
||||
constructor(redirectUrl: string) {
|
||||
this.redirectUrl = redirectUrl;
|
||||
this.clientMetadata = {
|
||||
redirect_uris: [redirectUrl],
|
||||
client_name: "pi-mcp-test",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
};
|
||||
}
|
||||
|
||||
state(): string {
|
||||
return "expected-state";
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformationMixed | undefined {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
saveClientInformation(information: OAuthClientInformationMixed): void {
|
||||
this.client = information;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this.tokenSet;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this.tokenSet = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(url: URL): void {
|
||||
this.authorizationUrl = url;
|
||||
}
|
||||
|
||||
saveCodeVerifier(verifier: string): void {
|
||||
this.verifier = verifier;
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this.verifier) throw new Error("Missing code verifier");
|
||||
return this.verifier;
|
||||
}
|
||||
|
||||
invalidateCredentials(kind: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
|
||||
if (kind === "all" || kind === "client") this.client = undefined;
|
||||
if (kind === "all" || kind === "tokens") this.tokenSet = undefined;
|
||||
if (kind === "all" || kind === "verifier") this.verifier = undefined;
|
||||
if (kind === "all" || kind === "discovery") this.discovery = undefined;
|
||||
}
|
||||
|
||||
saveDiscoveryState(state: OAuthDiscoveryState): void {
|
||||
this.discovery = state;
|
||||
}
|
||||
|
||||
discoveryState(): OAuthDiscoveryState | undefined {
|
||||
return this.discovery;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(closeServers);
|
||||
|
||||
describe("MCP OAuth", () => {
|
||||
it("discovers, registers, authorizes with PKCE, and refreshes on 401", async () => {
|
||||
let expectedChallenge: string | undefined;
|
||||
let refreshes = 0;
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
resource: `${serverOrigin}/mcp`,
|
||||
authorization_servers: [serverOrigin],
|
||||
scopes_supported: ["org:read"],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
issuer: serverOrigin,
|
||||
authorization_endpoint: `${serverOrigin}/authorize`,
|
||||
token_endpoint: `${serverOrigin}/token`,
|
||||
registration_endpoint: `${serverOrigin}/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/register") {
|
||||
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
// Empty and null optional fields count as absent (#10266).
|
||||
response.end(JSON.stringify({ ...metadata, client_id: "test-client", client_secret: "" }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/authorize") {
|
||||
expectedChallenge = url.searchParams.get("code_challenge") ?? undefined;
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
|
||||
redirect.searchParams.set("code", "test-code");
|
||||
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
|
||||
response.writeHead(302, { location: redirect.href }).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/token") {
|
||||
const params = new URLSearchParams(await readBody(request));
|
||||
if (params.get("grant_type") === "refresh_token") {
|
||||
refreshes++;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
access_token: "refreshed-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "",
|
||||
expires_in: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const challenge = createHash("sha256")
|
||||
.update(params.get("code_verifier") ?? "")
|
||||
.digest("base64url");
|
||||
if (params.get("code") !== "test-code" || challenge !== expectedChallenge) {
|
||||
response.writeHead(400, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "invalid_grant" }));
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
access_token: "first-token",
|
||||
refresh_token: "refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== "/mcp") {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
response.statusCode = 405;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const token = request.headers.authorization;
|
||||
if (token !== "Bearer first-token" && token !== "Bearer refreshed-token") {
|
||||
await readBody(request);
|
||||
response.writeHead(401, {
|
||||
// An empty scope falls through to the resource metadata's scopes_supported.
|
||||
"www-authenticate": `Bearer resource_metadata="${serverOrigin}/.well-known/oauth-protected-resource/mcp", scope=""`,
|
||||
});
|
||||
response.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (!("id" in message)) {
|
||||
response.statusCode = 202;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
message.method === "initialize"
|
||||
? {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "oauth-test", version: "1.0.0" },
|
||||
}
|
||||
: { tools: [{ name: "issues", inputSchema: { type: "object" } }] };
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
|
||||
});
|
||||
|
||||
const callback = await OAuthCallbackServer.listen();
|
||||
const provider = new TestOAuthProvider(callback.redirectUrl);
|
||||
const firstClient = new McpClient({ name: "oauth-test", version: "1.0.0" });
|
||||
await expect(
|
||||
firstClient.connect(
|
||||
new StreamableHttpTransport({
|
||||
url: `${origin}/mcp`,
|
||||
headers: { Authorization: "Bearer caller-supplied-stale-token" },
|
||||
authProvider: adaptOAuthProvider(provider),
|
||||
openGetStream: false,
|
||||
}),
|
||||
),
|
||||
).rejects.toBeInstanceOf(McpOAuthAuthorizationRequiredError);
|
||||
expect(provider.authorizationUrl?.searchParams.get("scope")).toBe("org:read");
|
||||
expect(provider.authorizationUrl?.searchParams.get("resource")).toBe(`${origin}/mcp`);
|
||||
|
||||
const callbackResult = callback.waitForCallback("expected-state");
|
||||
const authorizationResponse = await fetch(provider.authorizationUrl as URL, { redirect: "manual" });
|
||||
await fetch(authorizationResponse.headers.get("location") as string);
|
||||
const { code } = await callbackResult;
|
||||
expect(
|
||||
await authorizeMcp(provider, {
|
||||
serverUrl: `${origin}/mcp`,
|
||||
authorizationCode: code,
|
||||
fetch,
|
||||
}),
|
||||
).toBe("AUTHORIZED");
|
||||
|
||||
const client = new McpClient({ name: "oauth-test", version: "1.0.0" });
|
||||
await client.connect(
|
||||
new StreamableHttpTransport({
|
||||
url: `${origin}/mcp`,
|
||||
headers: { Authorization: "Bearer caller-supplied-stale-token" },
|
||||
authProvider: adaptOAuthProvider(provider),
|
||||
openGetStream: false,
|
||||
}),
|
||||
);
|
||||
expect(await client.listTools()).toEqual([{ name: "issues", inputSchema: { type: "object" } }]);
|
||||
await client.close();
|
||||
|
||||
provider.tokenSet = { ...provider.tokenSet!, access_token: "stale-token" };
|
||||
const refreshedClient = new McpClient({ name: "oauth-test", version: "1.0.0" });
|
||||
await refreshedClient.connect(
|
||||
new StreamableHttpTransport({
|
||||
url: `${origin}/mcp`,
|
||||
headers: { Authorization: "Bearer caller-supplied-stale-token" },
|
||||
authProvider: adaptOAuthProvider(provider),
|
||||
openGetStream: false,
|
||||
}),
|
||||
);
|
||||
// Neither token response names a scope, so the grant has the requested scope.
|
||||
expect(provider.tokenSet).toEqual({
|
||||
access_token: "refreshed-token",
|
||||
refresh_token: "refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "org:read",
|
||||
});
|
||||
expect(refreshes).toBe(1);
|
||||
await refreshedClient.close();
|
||||
await callback.close();
|
||||
});
|
||||
|
||||
it("shares one refresh between concurrent 401s when refresh tokens rotate", async () => {
|
||||
const grants: string[] = [];
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
||||
// Invalid resource metadata falls back to the server origin instead of failing discovery.
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ resource: `${serverOrigin}/mcp`, authorization_servers: ["not a url"] }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
// Issuer without the trailing slash that URL parsing adds to the fallback server URL.
|
||||
issuer: serverOrigin,
|
||||
authorization_endpoint: `${serverOrigin}/authorize`,
|
||||
token_endpoint: `${serverOrigin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/token") {
|
||||
const params = new URLSearchParams(await readBody(request));
|
||||
const refreshToken = params.get("refresh_token") ?? "";
|
||||
grants.push(refreshToken);
|
||||
response.setHeader("content-type", "application/json");
|
||||
if (refreshToken !== "r1") {
|
||||
response.statusCode = 400;
|
||||
response.end(JSON.stringify({ error: "invalid_grant" }));
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
response.end(
|
||||
JSON.stringify({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 3600 }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
});
|
||||
const store = new MemoryOAuthStateStore();
|
||||
const provider = new McpOAuthProvider({
|
||||
serverUrl: `${origin}/mcp`,
|
||||
redirectUrl: "http://127.0.0.1/callback",
|
||||
clientMetadata: { client_name: "test" },
|
||||
clientId: "client",
|
||||
store,
|
||||
onRedirect: () => {},
|
||||
});
|
||||
await provider.saveTokens({ access_token: "a1", refresh_token: "r1", token_type: "Bearer" });
|
||||
const auth = adaptOAuthProvider(provider);
|
||||
const unauthorized = () => ({
|
||||
response: new Response(null, { status: 401, headers: { "www-authenticate": "Bearer" } }),
|
||||
serverUrl: new URL(`${origin}/mcp`),
|
||||
fetch,
|
||||
token: "a1",
|
||||
});
|
||||
await Promise.all([auth.onUnauthorized?.(unauthorized()), auth.onUnauthorized?.(unauthorized())]);
|
||||
// A late 401 for a request that still carried the old token must not refresh again.
|
||||
await auth.onUnauthorized?.(unauthorized());
|
||||
expect(grants).toEqual(["r1"]);
|
||||
expect(await auth.token()).toBe("a2");
|
||||
const state = await store.load();
|
||||
expect(state?.tokens?.refresh_token).toBe("r2");
|
||||
expect(state?.tokensExpireAt).toBeGreaterThan(Date.now() + 3_500_000);
|
||||
});
|
||||
|
||||
it("asks for authorization instead of refreshing when the server needs more scope", async () => {
|
||||
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
|
||||
provider.client = { client_id: "client" };
|
||||
provider.tokenSet = { access_token: "a1", refresh_token: "r1", token_type: "Bearer", scope: "repo read:org" };
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
issuer: serverOrigin,
|
||||
authorization_endpoint: `${serverOrigin}/authorize`,
|
||||
token_endpoint: `${serverOrigin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.statusCode = url.pathname === "/token" ? 500 : 404;
|
||||
response.end();
|
||||
});
|
||||
const auth = adaptOAuthProvider(provider);
|
||||
await expect(
|
||||
auth.onUnauthorized?.({
|
||||
response: new Response(null, {
|
||||
status: 403,
|
||||
headers: { "www-authenticate": 'Bearer error="insufficient_scope", scope="repo admin"' },
|
||||
}),
|
||||
serverUrl: new URL(`${origin}/mcp`),
|
||||
fetch,
|
||||
token: "a1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(McpOAuthAuthorizationRequiredError);
|
||||
// The challenge may list only the missing scopes; the new grant keeps the old ones too.
|
||||
expect(provider.authorizationUrl?.searchParams.get("scope")).toBe("repo read:org admin");
|
||||
// The working grant is kept until the user authorizes the new scope.
|
||||
expect(provider.tokenSet?.access_token).toBe("a1");
|
||||
});
|
||||
|
||||
it("binds persisted credentials to the exact MCP server URL", async () => {
|
||||
const store = new MemoryOAuthStateStore();
|
||||
const first = new McpOAuthProvider({
|
||||
serverUrl: "https://one.example/mcp",
|
||||
redirectUrl: "http://127.0.0.1/callback",
|
||||
clientMetadata: { client_name: "test" },
|
||||
store,
|
||||
onRedirect: () => {},
|
||||
});
|
||||
await first.saveTokens({ access_token: "secret", token_type: "Bearer" });
|
||||
expect((await first.tokens())?.access_token).toBe("secret");
|
||||
|
||||
const second = new McpOAuthProvider({
|
||||
serverUrl: "https://two.example/mcp",
|
||||
redirectUrl: "http://127.0.0.1/callback",
|
||||
clientMetadata: { client_name: "test" },
|
||||
store,
|
||||
onRedirect: () => {},
|
||||
});
|
||||
expect(await second.tokens()).toBeUndefined();
|
||||
});
|
||||
|
||||
// #10493
|
||||
it("registers with an application_type derived from the redirect URIs unless one is set", async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const origin = await listen(async (request, response) => {
|
||||
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
bodies.push(metadata);
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ...metadata, client_id: "client" }));
|
||||
});
|
||||
const register = (redirect_uris: string[], application_type?: string) =>
|
||||
registerClient(origin, {
|
||||
clientMetadata: { redirect_uris, ...(application_type ? { application_type } : {}) },
|
||||
});
|
||||
await register(["http://127.0.0.1:1234/callback"]);
|
||||
await register(["http://[::1]/callback"]);
|
||||
await register(["com.example.app:/callback"]);
|
||||
await register(["https://app.example/callback"]);
|
||||
await register(["http://localhost/callback"], "web");
|
||||
expect(bodies.map((body) => body.application_type)).toEqual(["native", "native", "native", "web", "web"]);
|
||||
});
|
||||
|
||||
it("rejects authorization metadata whose issuer does not match discovery", async () => {
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
issuer: "https://attacker.example",
|
||||
authorization_endpoint: `${serverOrigin}/authorize`,
|
||||
token_endpoint: `${serverOrigin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
});
|
||||
await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError);
|
||||
});
|
||||
|
||||
// #10172
|
||||
it("uses a configured authorization server metadata document as is", async () => {
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
response.setHeader("content-type", "application/json");
|
||||
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
||||
// Names the MCP server itself, which serves no authorization server metadata.
|
||||
response.end(JSON.stringify({ resource: `${serverOrigin}/mcp`, authorization_servers: [serverOrigin] }));
|
||||
} else if (url.pathname === "/idp/metadata.json") {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
// Not derivable from the document URL; a configured document is not checked.
|
||||
issuer: "https://idp.example",
|
||||
authorization_endpoint: `${serverOrigin}/idp/authorize`,
|
||||
token_endpoint: `${serverOrigin}/idp/token`,
|
||||
response_types_supported: ["code"],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
|
||||
provider.client = { client_id: "client" };
|
||||
const options = {
|
||||
serverUrl: `${origin}/mcp`,
|
||||
authorizationServerMetadataUrl: new URL(`${origin}/idp/metadata.json`),
|
||||
};
|
||||
expect(await authorizeMcp(provider, options)).toBe("REDIRECT");
|
||||
const authorizationUrl = provider.authorizationUrl as URL;
|
||||
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe(`${origin}/idp/authorize`);
|
||||
expect(authorizationUrl.searchParams.get("resource")).toBe(`${origin}/mcp`);
|
||||
|
||||
const insecure = { ...options, authorizationServerMetadataUrl: new URL("http://idp.example/metadata.json") };
|
||||
await expect(authorizeMcp(provider, insecure)).rejects.toBeInstanceOf(OAuthInsecureEndpointError);
|
||||
});
|
||||
|
||||
it("exchanges a code only when its iss parameter names the authorization server", async () => {
|
||||
const codes: string[] = [];
|
||||
const origin = await listen(async (request, response) => {
|
||||
if (request.method !== "POST") {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
codes.push(new URLSearchParams(await readBody(request)).get("code") ?? "");
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ access_token: "token", token_type: "Bearer" }));
|
||||
});
|
||||
const exchange = (code: string, iss: string | undefined, issParameterSupported: boolean, metadata = true) => {
|
||||
const provider = new TestOAuthProvider("http://127.0.0.1/callback");
|
||||
provider.client = { client_id: "client" };
|
||||
provider.verifier = "verifier";
|
||||
provider.discovery = {
|
||||
authorizationServerUrl: origin,
|
||||
authorizationServerMetadata: metadata
|
||||
? {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
authorization_response_iss_parameter_supported: issParameterSupported,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
return authorizeMcp(provider, { serverUrl: `${origin}/mcp`, authorizationCode: code, iss });
|
||||
};
|
||||
await expect(exchange("other", "https://attacker.example", false)).rejects.toBeInstanceOf(
|
||||
OAuthIssuerMismatchError,
|
||||
);
|
||||
await expect(exchange("missing", undefined, true)).rejects.toBeInstanceOf(OAuthIssuerMismatchError);
|
||||
expect(await exchange("matching", origin, true)).toBe("AUTHORIZED");
|
||||
// Servers that do not promise the parameter may omit it.
|
||||
expect(await exchange("omitted", undefined, false)).toBe("AUTHORIZED");
|
||||
// Without metadata there is no issuer to compare with, so a server cannot skip the check by hiding it.
|
||||
await expect(exchange("unverifiable", "https://attacker.example", false, false)).rejects.toBeInstanceOf(
|
||||
OAuthIssuerMismatchError,
|
||||
);
|
||||
expect(await exchange("no-metadata", undefined, false, false)).toBe("AUTHORIZED");
|
||||
expect(codes).toEqual(["matching", "omitted", "no-metadata"]);
|
||||
});
|
||||
|
||||
// #10565
|
||||
it("stops when its signal aborts, without falling back to a redirect", async () => {
|
||||
const stalled: string[] = [];
|
||||
// Accepts every request and never answers.
|
||||
const origin = await listen(async (request, _response, serverOrigin) => {
|
||||
stalled.push(new URL(request.url ?? "/", serverOrigin).pathname);
|
||||
});
|
||||
const run = async (provider: TestOAuthProvider) => {
|
||||
const controller = new AbortController();
|
||||
const count = stalled.length;
|
||||
const flow = authorizeMcp(provider, { serverUrl: `${origin}/mcp`, signal: controller.signal });
|
||||
const settled = flow.catch((error: unknown) => error);
|
||||
await expect.poll(() => stalled.length).toBe(count + 1);
|
||||
controller.abort();
|
||||
expect(await settled).toMatchObject({ name: "AbortError" });
|
||||
expect(provider.authorizationUrl).toBeUndefined();
|
||||
return stalled.at(-1);
|
||||
};
|
||||
|
||||
// Discovery.
|
||||
expect(await run(new TestOAuthProvider("http://127.0.0.1/callback"))).toBe(
|
||||
"/.well-known/oauth-protected-resource/mcp",
|
||||
);
|
||||
|
||||
// A failed refresh otherwise falls back to a new authorization.
|
||||
const refreshing = new TestOAuthProvider("http://127.0.0.1/callback");
|
||||
refreshing.client = { client_id: "client" };
|
||||
refreshing.tokenSet = { access_token: "a1", refresh_token: "r1", token_type: "Bearer" };
|
||||
refreshing.discovery = {
|
||||
authorizationServerUrl: origin,
|
||||
authorizationServerMetadata: {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
};
|
||||
expect(await run(refreshing)).toBe("/token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthCallbackServer pages", () => {
|
||||
it("renders plain text by default", async () => {
|
||||
const callback = await OAuthCallbackServer.listen();
|
||||
try {
|
||||
const pending = callback.waitForCallback("s1");
|
||||
const response = await fetch(`${callback.redirectUrl}?code=abc&state=s1`);
|
||||
expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
||||
expect(await response.text()).toBe("Authorization complete. You may close this window.");
|
||||
expect((await pending).code).toBe("abc");
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #10302
|
||||
it("rejects a response on another path than the expected one", async () => {
|
||||
const callback = await OAuthCallbackServer.listen({ extraPaths: ["/callback/server-id"] });
|
||||
try {
|
||||
const origin = new URL(callback.redirectUrl).origin;
|
||||
const mixedUp = callback.waitForCallback("s1", "/callback/server-id");
|
||||
mixedUp.catch(() => undefined);
|
||||
const wrong = await fetch(`${origin}/callback?code=abc&state=s1`);
|
||||
expect(wrong.status).toBe(400);
|
||||
await expect(mixedUp).rejects.toThrow("arrived on another redirect URI");
|
||||
|
||||
const pending = callback.waitForCallback("s2", "/callback/server-id");
|
||||
const right = await fetch(`${origin}/callback/server-id?code=abc&state=s2`);
|
||||
expect(right.status).toBe(200);
|
||||
expect((await pending).code).toBe("abc");
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders pages through renderPage", async () => {
|
||||
const pages: OAuthCallbackPage[] = [];
|
||||
const callback = await OAuthCallbackServer.listen({
|
||||
renderPage: (page) => {
|
||||
pages.push(page);
|
||||
return page.ok ? "<p>ok</p>" : `<p>${page.message}</p>`;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const denied = callback.waitForCallback("s1");
|
||||
denied.catch(() => undefined);
|
||||
const failure = await fetch(`${callback.redirectUrl}?error=access_denied&error_description=Denied&state=s1`);
|
||||
expect(failure.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
await expect(denied).rejects.toThrow("Denied");
|
||||
expect(pages.at(-1)).toEqual({
|
||||
ok: false,
|
||||
message: "Authorization failed. You may close this window.",
|
||||
details: "Denied",
|
||||
});
|
||||
|
||||
const pending = callback.waitForCallback("s2");
|
||||
const success = await fetch(`${callback.redirectUrl}?code=abc&state=s2`);
|
||||
expect(await success.text()).toBe("<p>ok</p>");
|
||||
expect((await pending).code).toBe("abc");
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
third_party/pi/packages/mcp/test/stdio.test.ts
vendored
Normal file
64
third_party/pi/packages/mcp/test/stdio.test.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { McpClient, StdioTransport } from "../src/index.ts";
|
||||
|
||||
const fixture = fileURLToPath(new URL("./fixtures/stdio-server.mjs", import.meta.url));
|
||||
const stubborn = fileURLToPath(new URL("./fixtures/stubborn-server.mjs", import.meta.url));
|
||||
|
||||
describe("StdioTransport", () => {
|
||||
it("connects to a newline-delimited MCP server and captures stderr", async () => {
|
||||
const stderr: string[] = [];
|
||||
const transport = new StdioTransport({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
onStderr: (chunk) => stderr.push(chunk),
|
||||
});
|
||||
const client = new McpClient({ name: "stdio-test", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
|
||||
expect(await client.callTool("echo", { text: "hello" })).toEqual({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
});
|
||||
expect(transport.pid).toBeTypeOf("number");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(stderr.join("")).toContain("stdio fixture ready");
|
||||
expect(transport.stderr).toContain("stdio fixture ready");
|
||||
await client.close();
|
||||
expect(client.connectionState).toBe("closed");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"kills a server that ignores shutdown, including its children",
|
||||
async () => {
|
||||
const transport = new StdioTransport({
|
||||
command: process.execPath,
|
||||
args: [stubborn],
|
||||
closeTimeoutMs: 100,
|
||||
});
|
||||
const client = new McpClient({ name: "stdio-test", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
let grandchild: number | undefined;
|
||||
for (let i = 0; i < 100 && grandchild === undefined; i++) {
|
||||
const match = /grandchild (\d+)/.exec(transport.stderr);
|
||||
if (match) grandchild = Number(match[1]);
|
||||
else await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(grandchild).toBeTypeOf("number");
|
||||
|
||||
const startedAt = Date.now();
|
||||
await client.close();
|
||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
||||
let alive = true;
|
||||
for (let i = 0; i < 100 && alive; i++) {
|
||||
try {
|
||||
process.kill(grandchild as number, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} catch {
|
||||
alive = false;
|
||||
}
|
||||
}
|
||||
expect(alive).toBe(false);
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
486
third_party/pi/packages/mcp/test/streamable-http.test.ts
vendored
Normal file
486
third_party/pi/packages/mcp/test/streamable-http.test.ts
vendored
Normal file
@@ -0,0 +1,486 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
type McpAuthRequiredError,
|
||||
McpClient,
|
||||
type McpFetch,
|
||||
McpSessionExpiredError,
|
||||
StreamableHttpTransport,
|
||||
} from "../src/index.ts";
|
||||
import { consumeSseStream, type SseEvent } from "../src/transports/streamable-http.ts";
|
||||
import { closeServers, listen, readBody } from "./helpers.ts";
|
||||
|
||||
interface RecordedRequest {
|
||||
method: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
message?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
handler: (request: IncomingMessage, response: ServerResponse, requests: RecordedRequest[]) => Promise<void>,
|
||||
): Promise<{ url: string; requests: RecordedRequest[] }> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const origin = await listen((request, response) => handler(request, response, requests));
|
||||
return { url: `${origin}/mcp`, requests };
|
||||
}
|
||||
|
||||
async function protocolHandler(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requests: RecordedRequest[],
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (request.method === "GET") {
|
||||
requests.push({ method: "GET", headers: request.headers });
|
||||
response.statusCode = 405;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
requests.push({ method: "DELETE", headers: request.headers });
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const message = body ?? (JSON.parse(await readBody(request)) as Record<string, unknown>);
|
||||
requests.push({ method: request.method ?? "", headers: request.headers, message });
|
||||
if (!("id" in message)) {
|
||||
response.statusCode = 202;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialize") {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"mcp-session-id": "session-1",
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "http-fixture", version: "1.0.0" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/list") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.write("id: tool-result\n");
|
||||
response.end(
|
||||
`data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "hello" }] } })}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(closeServers);
|
||||
|
||||
describe("consumeSseStream", () => {
|
||||
it("parses chunked CRLF events, comments, IDs, and multiline data", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [': keepalive\r\nid: 7\r\ndata: {"one":\r\n', "data: 1}\r\n\r\n"];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const events: SseEvent[] = [];
|
||||
await consumeSseStream(stream, { onEvent: (event) => events.push(event) });
|
||||
expect(events).toEqual([{ id: "7", data: '{"one":\n1}' }]);
|
||||
});
|
||||
|
||||
it("rejects events whose data lines exceed the limit without a blank line", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
// Never sends a blank line, so the event is never dispatched.
|
||||
if (sent++ > 1000) controller.close();
|
||||
else controller.enqueue(encoder.encode("data: xxxxxxxxxxxxxxxx\n"));
|
||||
},
|
||||
});
|
||||
await expect(consumeSseStream(stream, { maxEventBytes: 256, onEvent: () => {} })).rejects.toThrow(
|
||||
"MCP SSE event exceeds 256 bytes",
|
||||
);
|
||||
expect(sent).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamableHttpTransport", () => {
|
||||
it("handles JSON and SSE responses with session and protocol headers", async () => {
|
||||
const { url, requests } = await startServer(protocolHandler);
|
||||
const transport = new StreamableHttpTransport({ url });
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toBe("session-1");
|
||||
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
|
||||
expect(await client.callTool("echo", { text: "hello" })).toEqual({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
});
|
||||
await client.close();
|
||||
|
||||
const listRequest = requests.find((entry) => entry.message?.method === "tools/list");
|
||||
expect(listRequest?.headers["mcp-session-id"]).toBe("session-1");
|
||||
expect(listRequest?.headers["mcp-protocol-version"]).toBe(LATEST_PROTOCOL_VERSION);
|
||||
expect(requests.some((entry) => entry.method === "GET")).toBe(true);
|
||||
expect(requests.some((entry) => entry.method === "DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies authentication failures", async () => {
|
||||
const { url } = await startServer(async (request, response) => {
|
||||
await readBody(request);
|
||||
response.writeHead(401, { "www-authenticate": 'Bearer resource_metadata="https://example.com/meta"' });
|
||||
response.end("login required");
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toMatchObject({
|
||||
name: "McpAuthRequiredError",
|
||||
status: 401,
|
||||
body: "login required",
|
||||
wwwAuthenticate: 'Bearer resource_metadata="https://example.com/meta"',
|
||||
} satisfies Partial<McpAuthRequiredError>);
|
||||
});
|
||||
|
||||
it("fails only the request whose SSE stream breaks", async () => {
|
||||
let releaseSlow = () => {};
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
const name = (message.params as { name?: string } | undefined)?.name;
|
||||
if (name === "broken") {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end("data: not json\n\n");
|
||||
return;
|
||||
}
|
||||
if (name === "slow") await slowGate;
|
||||
await protocolHandler(request, response, requests, message);
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
const errors: Error[] = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
||||
|
||||
const slow = client.callTool("slow");
|
||||
await expect(client.callTool("broken")).rejects.toThrow("MCP response stream failed");
|
||||
releaseSlow();
|
||||
expect(await slow).toEqual({ content: [{ type: "text", text: "hello" }] });
|
||||
expect(errors).toHaveLength(1);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("opens the GET stream after initialization and sends Last-Event-ID only when resuming", async () => {
|
||||
const order: string[] = [];
|
||||
const { url, requests } = await startServer(async (request, response, requests) => {
|
||||
if (request.method === "POST") {
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
order.push(String(message.method));
|
||||
return protocolHandler(request, response, requests, message);
|
||||
}
|
||||
order.push(request.method ?? "");
|
||||
return protocolHandler(request, response, requests);
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHttpTransport({ url }));
|
||||
await client.callTool("echo");
|
||||
await client.listTools();
|
||||
await client.close();
|
||||
expect(order.indexOf("GET")).toBeGreaterThan(order.indexOf("notifications/initialized"));
|
||||
expect(requests.every((entry) => entry.headers["last-event-id"] === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("resumes a response stream the server closed before answering", async () => {
|
||||
const resumeHeaders: (string | undefined)[] = [];
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method === "GET" && request.headers["last-event-id"]) {
|
||||
resumeHeaders.push(request.headers["last-event-id"] as string);
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end(
|
||||
`id: 2\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 2, result: { content: [{ type: "text", text: "resumed" }] } })}\n\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
||||
// Priming event (ID, no data) and a retry hint, then the server drops the stream.
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end("id: 1\nretry: 5\ndata:\n\n");
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
const errors: Error[] = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
||||
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "resumed" }] });
|
||||
expect(resumeHeaders).toEqual(["1"]);
|
||||
expect(errors).toEqual([]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("fails a request whose response stream ends without an answer", async () => {
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end(": nothing here\n\n");
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
||||
await expect(client.callTool("echo", {}, { timeoutMs: 5_000 })).rejects.toThrow(
|
||||
"MCP response stream failed: stream ended without a response",
|
||||
);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("reconnects the GET stream after it drops", async () => {
|
||||
let gets = 0;
|
||||
const lastEventIds: (string | undefined)[] = [];
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "GET") return protocolHandler(request, response, requests);
|
||||
gets++;
|
||||
lastEventIds.push(request.headers["last-event-id"] as string | undefined);
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" };
|
||||
if (gets === 1) {
|
||||
response.end(`id: g1\ndata: ${JSON.stringify(notification)}\n\n`);
|
||||
return;
|
||||
}
|
||||
response.write(`id: g2\ndata: ${JSON.stringify(notification)}\n\n`);
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
let changes = 0;
|
||||
const secondChange = new Promise<void>((resolve) => {
|
||||
client.onNotification("notifications/tools/list_changed", () => {
|
||||
if (++changes === 2) resolve();
|
||||
});
|
||||
});
|
||||
await client.connect(new StreamableHttpTransport({ url, reconnect: { initialDelayMs: 1 } }));
|
||||
await secondChange;
|
||||
expect(lastEventIds).toEqual([undefined, "g1"]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("rejects a request the server accepts without a response", async () => {
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
||||
response.statusCode = 202;
|
||||
response.end();
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
||||
await expect(client.callTool("echo")).rejects.toThrow("without a response");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("includes the response body in HTTP errors", async () => {
|
||||
const { url } = await startServer(async (request, response) => {
|
||||
await readBody(request);
|
||||
response.statusCode = 400;
|
||||
response.end("Invalid Accept header");
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toThrow(
|
||||
"MCP HTTP request failed with status 400: Invalid Accept header",
|
||||
);
|
||||
});
|
||||
|
||||
it("hands 401 and insufficient-scope 403 responses to the auth provider with the rejected token", async () => {
|
||||
const seen: { status: number; token?: string }[] = [];
|
||||
let token = "old";
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (request.headers.authorization === "Bearer old") {
|
||||
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/call" && request.headers.authorization === "Bearer new") {
|
||||
response.writeHead(403, { "www-authenticate": 'Bearer error="insufficient_scope", scope="admin"' }).end();
|
||||
return;
|
||||
}
|
||||
return protocolHandler(request, response, requests, message);
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(
|
||||
new StreamableHttpTransport({
|
||||
url,
|
||||
openGetStream: false,
|
||||
authProvider: {
|
||||
token: async () => token,
|
||||
onUnauthorized: async ({ response, token: rejected }) => {
|
||||
seen.push({ status: response.status, token: rejected });
|
||||
if (response.status === 401) token = "new";
|
||||
else token = "admin";
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "hello" }] });
|
||||
expect(seen).toEqual([
|
||||
{ status: 401, token: "old" },
|
||||
{ status: 403, token: "new" },
|
||||
]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
// #10598: a request cancelled or timed out while waiting for a shared refresh must not be retried.
|
||||
it("does not retry requests that were cancelled while authorization was recovering", async () => {
|
||||
let token = "old";
|
||||
const executed: unknown[] = [];
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
||||
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
if (message.method === "tools/call" && request.headers.authorization === "Bearer old") {
|
||||
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/call") executed.push((message.params as { name: string }).name);
|
||||
return protocolHandler(request, response, requests, message);
|
||||
});
|
||||
let releaseRefresh = () => {};
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
let refresh: Promise<void> | undefined;
|
||||
let refreshes = 0;
|
||||
let waiting = 0;
|
||||
let allWaiting = () => {};
|
||||
const waitingGate = new Promise<void>((resolve) => {
|
||||
allWaiting = resolve;
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(
|
||||
new StreamableHttpTransport({
|
||||
url,
|
||||
openGetStream: false,
|
||||
authProvider: {
|
||||
token: async () => token,
|
||||
onUnauthorized: async () => {
|
||||
if (!refresh) {
|
||||
refreshes++;
|
||||
refresh = refreshGate.then(() => {
|
||||
token = "new";
|
||||
});
|
||||
}
|
||||
if (++waiting === 3) allWaiting();
|
||||
await refresh;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const aborted = client.callTool("aborted", {}, { signal: controller.signal });
|
||||
const timedOut = client.callTool("timed-out", {}, { timeoutMs: 200 });
|
||||
const kept = client.callTool("kept");
|
||||
await waitingGate;
|
||||
controller.abort();
|
||||
await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
|
||||
await expect(timedOut).rejects.toMatchObject({ name: "McpTimeoutError" });
|
||||
releaseRefresh();
|
||||
expect(await kept).toEqual({ content: [{ type: "text", text: "hello" }] });
|
||||
expect(executed).toEqual(["kept"]);
|
||||
expect(refreshes).toBe(1);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
// #10565: the auth provider may refresh over the network, which closing must not wait for.
|
||||
it("closes the session with the last request's token, without asking the auth provider", async () => {
|
||||
const { url, requests } = await startServer(protocolHandler);
|
||||
let calls = 0;
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(
|
||||
new StreamableHttpTransport({
|
||||
url,
|
||||
openGetStream: false,
|
||||
authProvider: { token: async () => `token-${++calls}` },
|
||||
}),
|
||||
);
|
||||
const before = calls;
|
||||
await client.close();
|
||||
expect(calls).toBe(before);
|
||||
const deletes = requests.filter((entry) => entry.method === "DELETE");
|
||||
expect(deletes.map((entry) => entry.headers.authorization)).toEqual([`Bearer token-${before}`]);
|
||||
expect(deletes[0]?.headers["mcp-session-id"]).toBe("session-1");
|
||||
});
|
||||
|
||||
// #10188: Cloudflare Workers reject the platform fetch when called with a receiver other than globalThis.
|
||||
it("calls fetch without a receiver", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const strictFetch = function (this: unknown, input: string | URL, init?: RequestInit) {
|
||||
if (this !== undefined && this !== globalThis) throw new TypeError("Illegal invocation");
|
||||
return realFetch(input, init);
|
||||
} satisfies McpFetch;
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method === "POST" && request.headers.authorization === undefined) {
|
||||
await readBody(request);
|
||||
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
||||
return;
|
||||
}
|
||||
return protocolHandler(request, response, requests);
|
||||
});
|
||||
const connect = async (fetch: McpFetch | undefined) => {
|
||||
let token: string | undefined;
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(
|
||||
new StreamableHttpTransport({
|
||||
url,
|
||||
fetch,
|
||||
openGetStream: false,
|
||||
authProvider: {
|
||||
token: async () => token,
|
||||
onUnauthorized: async (context) => {
|
||||
await (await context.fetch(url)).body?.cancel();
|
||||
token = "token";
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
|
||||
await client.close();
|
||||
};
|
||||
|
||||
await connect(strictFetch);
|
||||
vi.stubGlobal("fetch", strictFetch);
|
||||
try {
|
||||
await connect(undefined);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies an expired established session", async () => {
|
||||
let posts = 0;
|
||||
const { url } = await startServer(async (request, response, requests) => {
|
||||
if (request.method === "POST" && posts++ >= 2) {
|
||||
await readBody(request);
|
||||
response.statusCode = 404;
|
||||
response.end("gone");
|
||||
return;
|
||||
}
|
||||
await protocolHandler(request, response, requests);
|
||||
});
|
||||
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
||||
await expect(client.listTools()).rejects.toBeInstanceOf(McpSessionExpiredError);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
9
third_party/pi/packages/mcp/tsconfig.build.json
vendored
Normal file
9
third_party/pi/packages/mcp/tsconfig.build.json
vendored
Normal 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"]
|
||||
}
|
||||
11
third_party/pi/packages/mcp/vitest.config.ts
vendored
Normal file
11
third_party/pi/packages/mcp/vitest.config.ts
vendored
Normal 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"] } },
|
||||
});
|
||||
Reference in New Issue
Block a user