diff --git a/.changeset/add-extensions-capability.md b/.changeset/add-extensions-capability.md new file mode 100644 index 000000000..c5d4d200f --- /dev/null +++ b/.changeset/add-extensions-capability.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': minor +--- + +Add `extensions` field to `ClientCapabilities` and `ServerCapabilities` to allow servers and clients to advertise extension support per SEP-2133 diff --git a/.changeset/add-resource-size-field.md b/.changeset/add-resource-size-field.md new file mode 100644 index 000000000..92064689b --- /dev/null +++ b/.changeset/add-resource-size-field.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add missing `size` field to `ResourceSchema` to match the MCP specification diff --git a/.changeset/add-types-export-condition.md b/.changeset/add-types-export-condition.md new file mode 100644 index 000000000..3ea969ea1 --- /dev/null +++ b/.changeset/add-types-export-condition.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add `"types"` condition to `exports` map for subpath imports (`.`, `./client`, `./server`, `./*`), enabling TypeScript to resolve type declarations with `moduleResolution: "bundler"` or `"node16"` without requiring manual `tsconfig.json` `paths` workarounds. diff --git a/.changeset/add-url-to-request-info.md b/.changeset/add-url-to-request-info.md new file mode 100644 index 000000000..dd3b1d252 --- /dev/null +++ b/.changeset/add-url-to-request-info.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add `url` property to `RequestInfo` interface as a `URL` type, exposing the full request URL to server handlers. The URL is unified across all HTTP transports (SSE and Streamable HTTP) to always provide the complete URL including protocol, host, and path. diff --git a/.changeset/fix-stdio-windows-hide.md b/.changeset/fix-stdio-windows-hide.md new file mode 100644 index 000000000..250e0a45d --- /dev/null +++ b/.changeset/fix-stdio-windows-hide.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Always set windowsHide to true when spawning stdio server processes on Windows, not just in Electron environments. diff --git a/.changeset/fix-zod-error-message-priority.md b/.changeset/fix-zod-error-message-priority.md new file mode 100644 index 000000000..eac0efd2b --- /dev/null +++ b/.changeset/fix-zod-error-message-priority.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Prioritize `error.issues[].message` over `error.message` in `getParseErrorMessage` so custom Zod error messages surface correctly. In Zod v4, `error.message` is a JSON blob of all issues, not a readable string. diff --git a/.changeset/rfc8252-loopback-port-relaxation.md b/.changeset/rfc8252-loopback-port-relaxation.md new file mode 100644 index 000000000..83ce27aa6 --- /dev/null +++ b/.changeset/rfc8252-loopback-port-relaxation.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native +clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1b0059283 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: monthly + groups: + github-actions: + patterns: + - '*' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..9d049ec3b --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,40 @@ +name: Conformance Tests + +on: + push: + branches: [v1.x] + pull_request: + branches: [v1.x] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:client:all + + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:server diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 60144add1..453a5f8e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,7 +1,7 @@ on: push: branches: - - main + - v1.x pull_request: workflow_dispatch: release: @@ -59,10 +59,12 @@ jobs: with: node-version: 24 cache: npm - registry-url: 'https://registry.npmjs.org' - run: npm ci + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@11.5.1 + - name: Determine npm tag id: npm-tag run: | @@ -70,8 +72,8 @@ jobs: # Check if this is a beta release if [[ "$VERSION" == *"-beta"* ]]; then echo "tag=--tag beta" >> $GITHUB_OUTPUT - # Check if this release is from a non-main branch (patch/maintenance release) - elif [[ "${{ github.event.release.target_commitish }}" != "main" ]]; then + # Check if this release is from a non-primary branch (patch/maintenance release) + elif [[ "${{ github.event.release.target_commitish }}" != "main" && "${{ github.event.release.target_commitish }}" != "v1.x" ]]; then # Use "release-X.Y" as tag for old branch releases (e.g., "release-1.23" for 1.23.x) # npm tags are mutable pointers to versions (like "latest" pointing to 1.24.3). # Using "release-1.23" means users can `npm install @modelcontextprotocol/sdk@release-1.23` @@ -85,5 +87,3 @@ jobs: fi - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index a1b83bc4f..81be15073 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ dist/ # IDE .idea/ +test/conformance/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 6e768e559..2cbb850df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ npm run typecheck # Type-check without emitting - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix - **Imports**: ES module style, include `.js` extension, group imports logically - **Formatting**: 2-space indentation, semicolons required, single quotes preferred -- **Testing**: Co-locate tests with source files, use descriptive test names +- **Testing**: Co-locate tests with source files, use descriptive test names. Use `vi.useFakeTimers()` instead of real `setTimeout`/`await` delays in tests - **Comments**: JSDoc for public APIs, inline comments for complex logic ## Architecture Overview diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..6091066cf --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,29 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP TypeScript SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts). +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Node.js version the SDK still targets. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run scheduled version bumps for npm dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for npm packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`). + +## Pinning and Ranges + +Production dependencies use caret ranges (`^`) to allow compatible updates within a major version. Exact versions are pinned only when necessary to work around a specific issue. diff --git a/README.md b/README.md index e0d3f200f..2d2f19ae3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk) +# MCP TypeScript SDK [![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk)](https://www.npmjs.com/package/@modelcontextprotocol/sdk) [![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk)](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/LICENSE)
Table of Contents @@ -154,8 +154,11 @@ For more details on how to run these examples (including recommended commands an - [docs/server.md](docs/server.md) – building and running MCP servers, transports, tools/resources/prompts, CORS, DNS rebinding, and multi-node deployment. - [docs/client.md](docs/client.md) – using the high-level client, transports, backwards compatibility, and OAuth helpers. - [docs/capabilities.md](docs/capabilities.md) – sampling, elicitation (form and URL), and experimental task-based execution. + - [docs/protocol.md](docs/protocol.md) – protocol features: ping, progress, cancellation, pagination, capability negotiation, and JSON Schema. - [docs/faq.md](docs/faq.md) – environment and troubleshooting FAQs (including Node.js Web Crypto support). - External references: + - [V1 API reference](https://modelcontextprotocol.github.io/typescript-sdk/) + - [V2 API reference](https://modelcontextprotocol.github.io/typescript-sdk/v2/) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [MCP Specification](https://spec.modelcontextprotocol.io) - [Example Servers](https://github.com/modelcontextprotocol/servers) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..9f9bb31e0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,22 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). + +## Current Focus Areas + +### Next Spec Revision + +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports. + +The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision. + +### v2 + +A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones: + +- **Alpha**: ~mid-March 2026 +- **Beta**: ~May 2026 + +The v2 release is planned to align with the next spec release, expected around mid-2026. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..5384f09ef --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,40 @@ +# Versioning Policy + +The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) follows [Semantic Versioning 2.0.0](https://semver.org/). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API export (class, function, type, or constant). +- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public type or interface field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Node.js LTS version. +- Removing support for a transport type. +- Changes to the MCP protocol version that require client/server code changes. + +The following are **not** considered breaking: + +- Adding new optional parameters to existing functions. +- Adding new exports, types, or interfaces. +- Adding new optional fields to existing types. +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec features. +- Changes to dev dependencies or build tooling. + +## How Breaking Changes Are Communicated + +1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `@deprecated` JSDoc annotations, which surface warnings through TypeScript tooling and editors. +3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. diff --git a/docs/capabilities.md b/docs/capabilities.md index 301e850fe..d436a00cd 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -27,6 +27,99 @@ Runnable example: The `simpleStreamableHttp` server also includes a `collect-user-info` tool that demonstrates how to drive elicitation from a tool and handle the response. +#### Schema validation + +Elicitation schemas support validation constraints on each field. The server validates responses automatically against the `requestedSchema` using the SDK's JSON Schema validator. + +```typescript +const result = await server.server.elicitInput({ + mode: 'form', + message: 'Enter your details:', + requestedSchema: { + type: 'object', + properties: { + email: { + type: 'string', + title: 'Email', + format: 'email', + minLength: 5 + }, + age: { + type: 'integer', + title: 'Age', + minimum: 0, + maximum: 150 + } + }, + required: ['email'] + } +}); +``` + +String fields support `minLength`, `maxLength`, and `format` (`'email'`, `'uri'`, `'date'`, `'date-time'`). Number fields support `minimum` and `maximum`. + +#### Default values + +Schema properties can include `default` values. When the client declares the `applyDefaults` capability, the SDK automatically fills in defaults for fields the user doesn't provide. + +> **Note:** `applyDefaults` is a TypeScript SDK extension — it is not part of the MCP protocol specification. + +```typescript +// Client declares applyDefaults: +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { capabilities: { elicitation: { form: { applyDefaults: true } } } } +); + +// Server schema with defaults: +requestedSchema: { + type: 'object', + properties: { + newsletter: { type: 'boolean', title: 'Newsletter', default: false }, + theme: { type: 'string', title: 'Theme', default: 'dark' } + } +} +``` + +#### Enum values + +Elicitation schemas support several enum patterns for single-select and multi-select fields: + +```typescript +requestedSchema: { + type: 'object', + properties: { + // Simple enum (untitled options) + color: { + type: 'string', + title: 'Favorite Color', + enum: ['red', 'green', 'blue'], + default: 'blue' + }, + // Titled enum with display labels + priority: { + type: 'string', + title: 'Priority', + oneOf: [ + { const: 'low', title: 'Low Priority' }, + { const: 'medium', title: 'Medium Priority' }, + { const: 'high', title: 'High Priority' } + ] + }, + // Multi-select + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string', enum: ['frontend', 'backend', 'docs'] }, + minItems: 1, + maxItems: 3 + } + } +} +``` + +For a full example with validation, defaults, and enums, see [`elicitationFormExample.ts`](../src/examples/server/elicitationFormExample.ts). + ### URL elicitation URL elicitation is designed for sensitive data and secure web‑based flows (e.g., collecting an API key, confirming a payment, or doing third‑party OAuth). Instead of returning form data, the server asks the client to open a URL and the rest of the flow happens in the browser. @@ -46,6 +139,23 @@ Key points: Sensitive information **must not** be collected via form elicitation; always use URL elicitation or out‑of‑band flows for secrets. +#### Complete notification + +When a URL elicitation flow finishes (the user completes the browser-based action), the server sends a `notifications/elicitation/complete` notification to the client. This tells the client the out-of-band flow is done and any pending UI can be dismissed. + +Use `createElicitationCompletionNotifier` on the low-level server to create a callback that sends this notification: + +```typescript +// Create a notifier for a specific elicitation: +const notifyComplete = server.server.createElicitationCompletionNotifier('setup-123'); + +// Later, when the browser flow completes (e.g. via webhook): +await notifyComplete(); +// Client receives: { method: 'notifications/elicitation/complete', params: { elicitationId: 'setup-123' } } +``` + +See [`elicitationUrlExample.ts`](../src/examples/server/elicitationUrlExample.ts) for a full working example. + ## Task-based execution (experimental) Task-based execution enables “call-now, fetch-later” patterns for long-running operations. Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. @@ -70,7 +180,7 @@ For a runnable example that uses the in-memory store shipped with the SDK, see: On the client, you use: - `client.experimental.tasks.callToolStream(...)` to start a tool call that may create a task and emit status updates over time. -- `client.getTask(...)` and `client.getTaskResult(...)` to check status and fetch results after reconnecting. +- `client.experimental.tasks.getTask(...)` and `client.experimental.tasks.getTaskResult(...)` to check status and fetch results after reconnecting. The interactive client in: diff --git a/docs/client.md b/docs/client.md index d28765fd0..8f1c65327 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,3 +58,53 @@ These examples show how to: - Perform dynamic client registration if needed. - Acquire access tokens. - Attach OAuth credentials to Streamable HTTP requests. + +## stdio transport + +Use `StdioClientTransport` to connect to a server that runs as a local child process: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'production' }, + cwd: '/path/to/server' +}); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); +await client.connect(transport); +// connect() calls transport.start() automatically, spawning the child process +``` + +The transport communicates over the child process's stdin/stdout using JSON-RPC. The `stderr` option controls where the child's stderr goes (defaults to `'inherit'`). + +## Roots + +Roots let a client expose filesystem locations to the server, so the server knows which directories or files are relevant. Declare the `roots` capability and register a handler: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); + +client.setRequestHandler(ListRootsRequestSchema, async () => { + return { + roots: [ + { uri: 'file:///home/user/project', name: 'My Project' }, + { uri: 'file:///home/user/data', name: 'Data Directory' } + ] + }; +}); +``` + +When the set of roots changes, notify the server so it can re-query: + +```typescript +await client.sendRootsListChanged(); +``` + +Root URIs must use the `file://` scheme. The `listChanged: true` capability flag is required to send change notifications. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 000000000..2773e5e8d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,200 @@ +## Protocol features + +This page covers cross-cutting protocol mechanics that apply to both clients and servers. + +## Ping + +Both client and server expose a `ping()` method for health checks. The remote side responds automatically — no handler registration is needed. + +```typescript +// Client pinging the server: +await client.ping(); + +// With a timeout (milliseconds): +await client.ping({ timeout: 5000 }); + +// Server pinging the client (via the low-level server, no timeout option): +await server.server.ping(); +``` + +## Progress notifications + +Long-running requests can report progress to the caller. The SDK handles `progressToken` assignment automatically when you provide an `onprogress` callback. + +**Receiving progress** (client side): + +```typescript +const result = await client.callTool({ name: 'long-task', arguments: {} }, CallToolResultSchema, { + onprogress: progress => { + // progress has: { progress: number, total?: number, message?: string } + console.log(`${progress.progress}/${progress.total}: ${progress.message}`); + }, + timeout: 30000, + resetTimeoutOnProgress: true +}); +``` + +**Sending progress** (server side, from a tool handler): + +```typescript +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number() } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); +``` + +For a runnable example, see [`progressExample.ts`](../src/examples/server/progressExample.ts). + +## Cancellation + +Requests can be cancelled by the caller using an `AbortSignal`. The SDK sends a `notifications/cancelled` message to the remote side and aborts the handler via its `signal`. + +**Client cancelling a request**: + +```typescript +const controller = new AbortController(); + +const resultPromise = client.callTool({ name: 'slow-tool', arguments: {} }, CallToolResultSchema, { signal: controller.signal }); + +// Cancel after 5 seconds: +setTimeout(() => controller.abort('User cancelled'), 5000); +``` + +**Server handler responding to cancellation**: + +```typescript +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + for (let i = 0; i < 100; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: 'Cancelled' }], isError: true }; + } + await doWork(); + } + return { content: [{ type: 'text', text: 'Done' }] }; +}); +``` + +## Pagination + +All list methods (`listTools`, `listPrompts`, `listResources`, `listResourceTemplates`) support cursor-based pagination. Pass `cursor` from the previous response's `nextCursor` to fetch the next page. + +```typescript +let cursor: string | undefined; +const allTools: Tool[] = []; + +do { + const result = await client.listTools({ cursor }); + allTools.push(...result.tools); + cursor = result.nextCursor; +} while (cursor); +``` + +The same pattern applies to `listPrompts`, `listResources`, and `listResourceTemplates`. + +## Capability negotiation + +Both client and server declare their capabilities during the `initialize` handshake. The SDK enforces these — attempting to use an undeclared capability throws an error. + +**Client capabilities** are set at construction time: + +```typescript +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + roots: { listChanged: true }, + sampling: {}, + elicitation: { form: {} } + } + } +); +``` + +After connecting, inspect what the server supports: + +```typescript +await client.connect(transport); + +const caps = client.getServerCapabilities(); +if (caps?.tools) { + const tools = await client.listTools(); +} +if (caps?.resources?.subscribe) { + // server supports resource subscriptions +} +``` + +**Server capabilities** are inferred from registered handlers. When using `McpServer`, capabilities are set automatically based on what you register (tools, resources, prompts). With the low-level `Server`, you declare them in the constructor. + +## Protocol version negotiation + +The SDK automatically negotiates protocol versions during `initialize`. The client sends `LATEST_PROTOCOL_VERSION` and the server responds with the highest mutually supported version. + +Supported versions are defined in `SUPPORTED_PROTOCOL_VERSIONS` (currently `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07`). If the server responds with an unsupported version, the client throws an error. + +Version negotiation is handled automatically by `client.connect()`. After connecting, you can inspect the result: + +```typescript +await client.connect(transport); + +const serverVersion = client.getServerVersion(); +// { name: 'my-server', version: '1.0.0' } + +const serverCaps = client.getServerCapabilities(); +// { tools: { listChanged: true }, resources: { subscribe: true }, ... } +``` + +## JSON Schema 2020-12 + +MCP uses JSON Schema 2020-12 for tool input and output schemas. When using `McpServer` with Zod, schemas are converted to JSON Schema automatically: + +```typescript +server.registerTool( + 'calculate', + { + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() } + }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) +); +``` + +With the low-level `Server`, you provide JSON Schema directly: + +```typescript +{ + name: 'calculate', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + required: ['a', 'b'] + } +} +``` + +The SDK validates tool outputs against `outputSchema` (when provided) using a pluggable JSON Schema validator. The default validator uses Ajv; a Cloudflare Workers-compatible alternative is available via `CfWorkerJsonSchemaValidator`. diff --git a/docs/server.md b/docs/server.md index fb0766d5b..7dbf64290 100644 --- a/docs/server.md +++ b/docs/server.md @@ -45,6 +45,23 @@ Examples: - Stateless Streamable HTTP: [`simpleStatelessStreamableHttp.ts`](../src/examples/server/simpleStatelessStreamableHttp.ts) - Stateful with resumability: [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) +### stdio + +For local integrations where the client spawns the server as a child process, use `StdioServerTransport`. Communication happens over stdin/stdout using JSON-RPC: + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +// ... register tools, resources, prompts ... + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This is the simplest transport — no HTTP server setup required. The client uses `StdioClientTransport` to spawn and communicate with the server process (see [docs/client.md](client.md#stdio-transport)). + ### Deprecated HTTP + SSE The older HTTP+SSE transport (protocol version 2024‑11‑05) is supported only for backwards compatibility. New implementations should prefer Streamable HTTP. @@ -128,11 +145,91 @@ This snippet is illustrative only; for runnable servers that expose tools, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) - [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) +#### Image and audio results + +Tools can return image and audio content alongside text. Use base64-encoded data with the appropriate MIME type: + +```typescript +// e.g. const chartPngBase64 = fs.readFileSync('chart.png').toString('base64'); +server.registerTool('generate-chart', { description: 'Generate a chart image' }, async () => ({ + content: [ + { + type: 'image', + data: chartPngBase64, + mimeType: 'image/png' + } + ] +})); + +// e.g. const audioBase64 = fs.readFileSync('speech.wav').toString('base64'); +server.registerTool( + 'text-to-speech', + { + description: 'Convert text to speech', + inputSchema: { text: z.string() } + }, + async ({ text }) => ({ + content: [ + { + type: 'audio', + data: audioBase64, + mimeType: 'audio/wav' + } + ] + }) +); +``` + +#### Embedded resource results + +Tools can return embedded resources, allowing the tool to attach full resource objects in its response: + +```typescript +server.registerTool('fetch-data', { description: 'Fetch and return data as a resource' }, async () => ({ + content: [ + { + type: 'resource', + resource: { + uri: 'data://result', + mimeType: 'application/json', + text: JSON.stringify({ key: 'value' }) + } + } + ] +})); +``` + +#### Error handling + +To indicate that a tool call failed, set `isError: true` in the result. The content describes what went wrong: + +```typescript +server.registerTool('risky-operation', { description: 'An operation that might fail' }, async () => { + try { + const result = await doSomething(); + return { content: [{ type: 'text', text: result }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true + }; + } +}); +``` + +#### Tool change notifications + +When tools are added, removed, or updated at runtime, the server automatically notifies connected clients. This happens when you call `registerTool()`, or use `remove()`, `enable()`, `disable()`, or `update()` on a `RegisteredTool`. You can also trigger it manually: + +```typescript +server.sendToolListChanged(); +``` + #### ResourceLink outputs Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need. -The README’s `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. +The README's `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. ### Resources @@ -155,7 +252,70 @@ server.registerResource( ); ``` -Dynamic resources use `ResourceTemplate` and can support completions on path parameters. For full runnable examples of resources: +#### Binary resources + +Resources can return binary data using `blob` (base64-encoded) instead of `text`: + +```typescript +server.registerResource('logo', 'images://logo.png', { title: 'Logo', mimeType: 'image/png' }, async uri => ({ + contents: [{ uri: uri.href, blob: logoPngBase64 }] +})); +``` + +#### Resource templates + +Dynamic resources use `ResourceTemplate` to match URI patterns. The template parameters are passed to the read callback: + +```typescript +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +server.registerResource('user-profile', new ResourceTemplate('users://{userId}/profile', { list: undefined }), { title: 'User Profile', mimeType: 'application/json' }, async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify(await getUser(userId)) + } + ] +})); +``` + +#### Subscribing and unsubscribing + +Clients can subscribe to resource changes. The server declares subscription support via the `resources.subscribe` capability, which `McpServer` enables automatically when resources are registered. + +To handle subscriptions, register handlers on the low-level server for `SubscribeRequestSchema` and `UnsubscribeRequestSchema`: + +```typescript +import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const subscriptions = new Set(); + +server.server.setRequestHandler(SubscribeRequestSchema, async request => { + subscriptions.add(request.params.uri); + return {}; +}); + +server.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + subscriptions.delete(request.params.uri); + return {}; +}); +``` + +When a subscribed resource changes, notify the client: + +```typescript +if (subscriptions.has(resourceUri)) { + await server.server.sendResourceUpdated({ uri: resourceUri }); +} +``` + +Resource list changes (adding/removing resources) are notified automatically when using `registerResource()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendResourceListChanged(); +``` + +For full runnable examples of resources: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) @@ -187,15 +347,150 @@ server.registerPrompt( ); ``` +#### Image content in prompts + +Prompts can include image content in their messages: + +```typescript +server.registerPrompt( + 'analyze-image', + { + title: 'Analyze Image', + description: 'Analyze an image', + argsSchema: { imageBase64: z.string() } + }, + ({ imageBase64 }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'image', + data: imageBase64, + mimeType: 'image/png' + } + } + ] + }) +); +``` + +#### Embedded resources in prompts + +Prompts can embed resource content in their messages: + +```typescript +server.registerPrompt( + 'summarize-doc', + { + title: 'Summarize Document', + description: 'Summarize a document resource' + }, + () => ({ + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: 'docs://readme', + mimeType: 'text/plain', + text: 'Document content here...' + } + } + } + ] + }) +); +``` + +#### Prompt change notifications + +Like tools, prompt list changes are notified automatically when using `registerPrompt()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendPromptListChanged(); +``` + For prompts integrated into a full server, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) ### Completions -Both prompts and resources can support argument completions. On the client side, you use `client.complete()` with a reference to the prompt or resource and the partially‑typed argument. +Both prompts and resources can support argument completions using the `completable` wrapper. This lets clients offer autocomplete suggestions as users type. + +```typescript +import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; + +server.registerPrompt( + 'greet', + { + title: 'Greeting', + description: 'Generate a greeting', + argsSchema: { + name: completable(z.string(), value => { + // Return suggestions matching the partial input + const names = ['Alice', 'Bob', 'Charlie']; + return names.filter(n => n.toLowerCase().startsWith(value.toLowerCase())); + }) + } + }, + ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello, ${name}!` } }] + }) +); +``` + +Resource templates also support completions on their path parameters via `completable`. On the client side, use `client.complete()` with a reference to the prompt or resource and the partially-typed argument: + +```typescript +const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'greet' }, + argument: { name: 'name', value: 'Al' } +}); +console.log(result.completion.values); // ['Alice'] +``` + +### Logging -See the MCP spec sections on prompts and resources for complete details, and [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) for client‑side usage patterns. +The server can send log messages to the client using `server.sendLoggingMessage()`. Clients can request a minimum log level via the `logging/setLevel` request, which `McpServer` handles automatically — messages below the requested level are suppressed. + +```typescript +// Send a log message from a tool handler: +server.registerTool( + 'process-data', + { + description: 'Process some data', + inputSchema: { data: z.string() } + }, + async ({ data }, extra) => { + await server.sendLoggingMessage({ level: 'info', data: `Processing: ${data}` }, extra.sessionId); + // ... do work ... + return { content: [{ type: 'text', text: 'Done' }] }; + } +); +``` + +For a full example, see [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) which uses `sendLoggingMessage` throughout. + +Log levels in order: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. + +#### Log level filtering + +Clients can request a minimum log level via `logging/setLevel`. The low-level `Server` handles this automatically when the `logging` capability is enabled — it stores the requested level per session and suppresses messages below it. You can also send log messages directly using +`sendLoggingMessage`: + +```typescript +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + +// Client requests: only show 'warning' and above +// (handled automatically by the Server) + +// These will be sent or suppressed based on the client's requested level: +await server.sendLoggingMessage({ level: 'debug', data: 'verbose detail' }); // suppressed +await server.sendLoggingMessage({ level: 'warning', data: 'something is off' }); // sent +await server.sendLoggingMessage({ level: 'error', data: 'something broke' }); // sent +``` ### Display names and metadata diff --git a/package-lock.json b/package-lock.json index 64bc6f21d..21e19cb40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.29.0", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -17,18 +17,20 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -604,9 +606,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -665,9 +667,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -734,6 +736,68 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/conformance": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.15.tgz", + "integrity": "sha512-B1eNYpv5kas9YFC40su7SWhKHR2DwYzJRpiX6dfWzDWMcDr71myKVj//PwyqUHC7oucs3EJqcqnwvSuUwvenoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@octokit/rest": "^22.0.0", + "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", + "express": "^5.1.0", + "jose": "^6.1.2", + "undici": "^7.19.0", + "yaml": "^2.8.2", + "zod": "^3.25.76" + }, + "bin": { + "conformance": "dist/index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -747,6 +811,172 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -758,9 +988,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -772,9 +1002,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -786,9 +1016,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -800,9 +1030,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -814,9 +1044,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -828,9 +1058,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -842,13 +1072,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -856,13 +1089,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -870,13 +1106,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -884,13 +1123,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -898,13 +1140,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -912,13 +1174,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -926,13 +1208,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -940,13 +1225,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -954,13 +1242,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -968,13 +1259,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -982,23 +1276,40 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1010,9 +1321,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1024,9 +1335,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -1038,9 +1349,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -1052,9 +1363,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1476,9 +1787,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1486,13 +1797,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1781,9 +2092,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1865,10 +2176,17 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -1877,7 +2195,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -1890,9 +2208,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1922,13 +2240,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -2004,6 +2322,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -2484,9 +2812,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2600,9 +2928,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2662,10 +2990,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, "engines": { "node": ">= 16" }, @@ -2676,6 +3007,23 @@ "express": ">= 4.11" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2779,10 +3127,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/form-data": { "version": "4.0.4", @@ -2882,17 +3231,17 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -3037,11 +3386,10 @@ } }, "node_modules/hono": { - "version": "4.10.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.8.tgz", - "integrity": "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3122,6 +3470,15 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3163,9 +3520,9 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/jose": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.1.tgz", - "integrity": "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3339,9 +3696,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3540,6 +3897,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -3626,9 +3996,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3693,9 +4063,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3709,28 +4079,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -4084,19 +4457,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -4151,19 +4511,6 @@ "typescript": ">=4.0.0" } }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -4247,6 +4594,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4254,6 +4611,13 @@ "dev": true, "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4403,19 +4767,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/vite": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", @@ -4557,6 +4908,22 @@ } } }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4579,9 +4946,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 41d850e15..22d26ca56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.29.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -20,38 +20,47 @@ ], "exports": { ".": { + "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }, "./client": { + "types": "./dist/esm/client/index.d.ts", "import": "./dist/esm/client/index.js", "require": "./dist/cjs/client/index.js" }, "./server": { + "types": "./dist/esm/server/index.d.ts", "import": "./dist/esm/server/index.js", "require": "./dist/cjs/server/index.js" }, "./validation": { + "types": "./dist/esm/validation/index.d.ts", "import": "./dist/esm/validation/index.js", "require": "./dist/cjs/validation/index.js" }, "./validation/ajv": { + "types": "./dist/esm/validation/ajv-provider.d.ts", "import": "./dist/esm/validation/ajv-provider.js", "require": "./dist/cjs/validation/ajv-provider.js" }, "./validation/cfworker": { + "types": "./dist/esm/validation/cfworker-provider.d.ts", "import": "./dist/esm/validation/cfworker-provider.js", "require": "./dist/cjs/validation/cfworker-provider.js" }, "./experimental": { + "types": "./dist/esm/experimental/index.d.ts", "import": "./dist/esm/experimental/index.js", "require": "./dist/cjs/experimental/index.js" }, "./experimental/tasks": { + "types": "./dist/esm/experimental/tasks/index.d.ts", "import": "./dist/esm/experimental/tasks/index.js", "require": "./dist/cjs/experimental/tasks/index.js" }, "./*": { + "types": "./dist/esm/*.d.ts", "import": "./dist/esm/*", "require": "./dist/cjs/*" } @@ -83,10 +92,15 @@ "test:watch": "vitest", "start": "npm run server", "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "client": "tsx scripts/cli.ts client", + "test:conformance:server": "test/conformance/scripts/run-server-conformance.sh --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:all": "test/conformance/scripts/run-server-conformance.sh --suite all --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:run": "npx tsx test/conformance/src/everythingServer.ts", + "test:conformance:client": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:client:all": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --suite all --expected-failures test/conformance/conformance-baseline.yml" }, "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -94,14 +108,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", @@ -118,6 +133,7 @@ "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -141,5 +157,8 @@ }, "resolutions": { "strip-ansi": "6.0.1" + }, + "overrides": { + "qs": "6.14.1" } } diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts index f3908d2c2..c90404e94 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -108,6 +108,11 @@ export interface ClientCredentialsProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -140,7 +145,8 @@ export class ClientCredentialsProvider implements OAuthClientProvider { client_name: options.clientName ?? 'client-credentials-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'client_secret_basic' + token_endpoint_auth_method: 'client_secret_basic', + scope: options.scope }; } @@ -216,6 +222,11 @@ export interface PrivateKeyJwtProviderOptions { * Optional JWT lifetime in seconds (default: 300). */ jwtLifetimeSeconds?: number; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -249,7 +260,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; this.addClientAuthentication = createPrivateKeyJwtAuth({ issuer: options.clientId, @@ -324,6 +336,11 @@ export interface StaticPrivateKeyJwtProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -347,7 +364,8 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'static-private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; const assertion = options.jwtBearerAssertion; diff --git a/src/client/auth.ts b/src/client/auth.ts index 4c82b5114..47ae4290b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -150,7 +150,7 @@ export interface OAuthClientProvider { * credentials, in the case where the server has indicated that they are no longer valid. * This avoids requiring the user to intervene manually. */ - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -189,6 +189,46 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the OAuth discovery state after RFC 9728 and authorization server metadata + * discovery. Providers can persist this state to avoid redundant discovery requests + * on subsequent {@linkcode auth} calls. + * + * This state can also be provided out-of-band (e.g., from a previous session or + * external configuration) to bootstrap the OAuth flow without discovery. + * + * Called by {@linkcode auth} after successful discovery. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * Returns previously saved discovery state, or `undefined` if none is cached. + * + * When available, {@linkcode auth} restores the discovery state (authorization server + * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing + * latency on subsequent calls. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. + * + * Contains the results of RFC 9728 protected resource metadata discovery and + * authorization server metadata discovery. Persisting this state avoids + * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -199,6 +239,52 @@ export class UnauthorizedError extends Error { } } +/** + * Thrown when RFC 9207 `iss` parameter validation fails. The authorization + * code MUST NOT be sent to any token endpoint after this is thrown. + */ +export class IssuerMismatchError extends UnauthorizedError { + constructor(message: string) { + super(message); + this.name = 'IssuerMismatchError'; + } +} + +/** + * Validates the `iss` parameter from an authorization response against the + * authorization server's metadata, per RFC 9207 §2.4. The four cases are + * keyed on whether the AS advertises `authorization_response_iss_parameter_supported`: + * + * | advertised | iss present | outcome | + * |---|---|---| + * | true | yes, matches | accept | + * | true | yes, mismatch | reject | + * | true | no | reject (server promised it) | + * | false/undefined | yes | compare (lenient vs RFC 9207 — accommodates servers that emit iss before advertising) | + * | false/undefined | no | accept (server doesn't support 9207) | + * + * Comparison is simple string comparison per RFC 3986 §6.2.1 — no + * normalization of case, ports, or trailing slashes. + */ +export function validateAuthorizationResponseIssuer( + receivedIss: string | undefined, + metadata: AuthorizationServerMetadata | undefined +): void { + const supported = metadata?.authorization_response_iss_parameter_supported === true; + const expectedIssuer = metadata?.issuer; + + if (supported && receivedIss === undefined) { + throw new IssuerMismatchError( + 'Authorization server advertises authorization_response_iss_parameter_supported but no iss parameter was received' + ); + } + if (receivedIss !== undefined && receivedIss !== expectedIssuer) { + throw new IssuerMismatchError( + `Authorization response iss "${receivedIss}" does not match expected issuer "${expectedIssuer}"` + ); + } +} + type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; function isClientAuthMethod(method: string): method is ClientAuthMethod { @@ -223,21 +309,25 @@ const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod { const hasClientSecret = clientInformation.client_secret !== undefined; - // If server doesn't specify supported methods, use RFC 6749 defaults - if (supportedMethods.length === 0) { - return hasClientSecret ? 'client_secret_post' : 'none'; - } - - // Prefer the method returned by the server during client registration if valid and supported + // Prefer the method returned by the server during client registration, if valid. + // When server metadata is present we also require the method to be listed as supported; + // when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone. if ( 'token_endpoint_auth_method' in clientInformation && clientInformation.token_endpoint_auth_method && isClientAuthMethod(clientInformation.token_endpoint_auth_method) && - supportedMethods.includes(clientInformation.token_endpoint_auth_method) + (supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method)) ) { return clientInformation.token_endpoint_auth_method; } + // If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the + // default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP + // Basic authentication for clients with a secret, making it the safest default. + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_basic' : 'none'; + } + // Try methods in priority order (most secure first) if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { return 'client_secret_basic'; @@ -359,6 +449,13 @@ export async function auth( options: { serverUrl: string | URL; authorizationCode?: string; + /** + * The `iss` parameter from the authorization response redirect URI + * (RFC 9207). When provided alongside `authorizationCode`, it is + * validated against the authorization server metadata before the + * code is exchanged. + */ + authorizationResponseIssuer?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; @@ -386,42 +483,89 @@ async function authInternal( { serverUrl, authorizationCode, + authorizationResponseIssuer, scope, resourceMetadataUrl, fetchFn }: { serverUrl: string | URL; authorizationCode?: string; + authorizationResponseIssuer?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; } ): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist discovery state for future use + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); + // Apply scope selection strategy (SEP-835): + // 1. WWW-Authenticate scope (passed via `scope` param) + // 2. PRM scopes_supported + // 3. Client metadata scope (user-configured fallback) + // The resolved scope is used consistently for both DCR and the authorization request. + const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -456,6 +600,7 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + scope: resolvedScope, fetchFn }); @@ -469,6 +614,9 @@ async function authInternal( // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows if (authorizationCode !== undefined || nonInteractiveFlow) { + if (authorizationCode !== undefined) { + validateAuthorizationResponseIssuer(authorizationResponseIssuer, metadata); + } const tokens = await fetchToken(provider, authorizationServerUrl, { metadata, resource, @@ -516,7 +664,7 @@ async function authInternal( clientInformation, state, redirectUrl: provider.redirectUrl, - scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope, + scope: resolvedScope, resource }); @@ -937,6 +1085,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ @@ -1257,16 +1486,22 @@ export async function fetchToken( /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. + * + * If `scope` is provided, it overrides `clientMetadata.scope` in the registration + * request body. This allows callers to apply the Scope Selection Strategy (SEP-835) + * consistently across both DCR and the subsequent authorization request. */ export async function registerClient( authorizationServerUrl: string | URL, { metadata, clientMetadata, + scope, fetchFn }: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; + scope?: string; fetchFn?: FetchLike; } ): Promise { @@ -1287,7 +1522,10 @@ export async function registerClient( headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(clientMetadata) + body: JSON.stringify({ + ...clientMetadata, + ...(scope !== undefined ? { scope } : {}) + }) }); if (!response.ok) { diff --git a/src/client/index.ts b/src/client/index.ts index 28c0e6253..03a6b40b5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -40,6 +40,7 @@ import { CreateTaskResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, @@ -452,8 +453,10 @@ export class Client< return taskValidationResult.data; } - // For non-task requests, validate against CreateMessageResultSchema - const validationResult = safeParse(CreateMessageResultSchema, result); + // For non-task requests, validate against appropriate schema based on tools presence + const hasTools = params.tools || params.toolChoice; + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const validationResult = safeParse(resultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); diff --git a/src/client/sse.ts b/src/client/sse.ts index f0e91ff25..9f7bead1c 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -216,8 +216,11 @@ export class SSEClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The `code` parameter from the redirect URI. + * @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, iss?: string): Promise { if (!this._authProvider) { throw new UnauthorizedError('No auth provider'); } @@ -225,6 +228,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, + authorizationResponseIssuer: iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/src/client/stdio.ts b/src/client/stdio.ts index e488dcd24..f6a71a30e 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -125,7 +125,7 @@ export class StdioClientTransport implements Transport { }, stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], shell: false, - windowsHide: process.platform === 'win32' && isElectron(), + windowsHide: process.platform === 'win32', cwd: this._serverParams.cwd }); @@ -257,7 +257,3 @@ export class StdioClientTransport implements Transport { }); } } - -function isElectron() { - return 'type' in process; -} diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 736587973..92078f586 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -421,8 +421,11 @@ export class StreamableHTTPClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The `code` parameter from the redirect URI. + * @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, iss?: string): Promise { if (!this._authProvider) { throw new UnauthorizedError('No auth provider'); } @@ -430,6 +433,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, + authorizationResponseIssuer: iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts index b57927e3f..5e31fdaa7 100644 --- a/src/examples/client/elicitationUrlExample.ts +++ b/src/examples/client/elicitationUrlExample.ts @@ -25,7 +25,6 @@ import { } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { OAuthClientMetadata } from '../../shared/auth.js'; -import { exec } from 'node:child_process'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; import { UnauthorizedError } from '../../client/auth.js'; import { createServer } from 'node:http'; @@ -45,8 +44,7 @@ const clientMetadata: OAuthClientMetadata = { scope: 'mcp:tools' }; oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }); // Create readline interface for user input @@ -259,17 +257,6 @@ async function elicitationLoop(): Promise { } } -async function openBrowser(url: string): Promise { - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); -} - /** * Enqueues an elicitation request and returns the result. * @@ -402,9 +389,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - console.log(`🌐 Opening browser for authorization: ${url}`); - - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); - } /** * Example OAuth callback handler - in production, use a more robust approach * for handling callbacks and storing tokens @@ -166,9 +150,7 @@ class InteractiveOAuthClient { CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`📌 OAuth redirect handler called - opening browser`); - console.log(`Opening browser to: ${redirectUrl.toString()}`); - this.openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }, this.clientMetadataUrl ); diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 21ab4f556..19eb5577a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, ElicitRequestSchema, + ElicitResult, ResourceLink, ReadResourceRequest, ReadResourceResultSchema, @@ -22,6 +23,7 @@ import { ErrorCode, McpError } from '../../types.js'; +import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { Ajv } from 'ajv'; @@ -65,6 +67,7 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); + console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -131,6 +134,11 @@ function commandLoop(): void { await callCollectInfoTool(args[1] || 'contact'); break; + case 'collect-info-task': { + await callCollectInfoWithTask(args[1] || 'contact'); + break; + } + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -232,7 +240,10 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client with form elicitation capability + // Create task store for client-side task support + const clientTaskStore = new InMemoryTaskStore(); + + // Create a new client with form elicitation capability and task support client = new Client( { name: 'example-client', @@ -242,25 +253,46 @@ async function connect(url?: string): Promise { capabilities: { elicitation: { form: {} + }, + tasks: { + requests: { + elicitation: { + create: {} + } + } } - } + }, + taskStore: clientTaskStore } ); client.onerror = error => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); }; - // Set up elicitation request handler with proper validation - client.setRequestHandler(ElicitRequestSchema, async request => { + // Set up elicitation request handler with proper validation and task support + client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { if (request.params.mode !== 'form') { throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); } console.log('\n🔔 Elicitation (form) Request Received:'); console.log(`Message: ${request.params.message}`); console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`); + console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`); console.log('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + // Helper to return result, optionally creating a task if requested + const returnResult = async (result: ElicitResult) => { + if (request.params.task && extra.taskStore) { + // Create a task and store the result + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + console.log(`📋 Created client-side task: ${task.taskId}`); + return { task }; + } + return result; + }; + const schema = request.params.requestedSchema; const properties = schema.properties; const required = schema.required || []; @@ -381,7 +413,7 @@ async function connect(url?: string): Promise { } if (inputCancelled) { - return { action: 'cancel' }; + return returnResult({ action: 'cancel' }); } // If we didn't complete all fields due to an error, try again @@ -394,7 +426,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -412,7 +444,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -426,25 +458,34 @@ async function connect(url?: string): Promise { }); }); - if (confirmAnswer === 'yes' || confirmAnswer === 'y') { - return { - action: 'accept', - content - }; - } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { - return { action: 'cancel' }; - } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { - if (attempts < maxAttempts) { - console.log('Please re-enter the information...'); - continue; - } else { - return { action: 'decline' }; + switch (confirmAnswer) { + case 'yes': + case 'y': { + return returnResult({ + action: 'accept', + content: content as ElicitResult['content'] + }); + } + case 'cancel': + case 'c': { + return returnResult({ action: 'cancel' }); + } + case 'no': + case 'n': { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return returnResult({ action: 'decline' }); + } + + break; } } } console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); }); transport = new StreamableHTTPClientTransport(new URL(serverUrl), { @@ -641,6 +682,12 @@ async function callCollectInfoTool(infoType: string): Promise { await callTool('collect-user-info', { infoType }); } +async function callCollectInfoWithTask(infoType: string): Promise { + console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`); + console.log('This will create a task on the server, which will elicit input and create a task on the client.\n'); + await callToolTask('collect-user-info-task', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index 6c0800949..0ea9c1934 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -14,308 +14,337 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest } from '../../types.js'; import { createMcpExpressApp } from '../../server/express.js'; -// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults -// The validator supports format validation (email, date, etc.) if ajv-formats is installed -const mcpServer = new McpServer( - { - name: 'form-elicitation-example-server', - version: '1.0.0' - }, - { - capabilities: {} - } -); - -/** - * Example 1: Simple user registration tool - * Collects username, email, and password from the user - */ -mcpServer.registerTool( - 'register_user', - { - description: 'Register a new user account by collecting their information', - inputSchema: {} - }, - async () => { - try { - // Request user information through form elicitation - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults + // The validator supports format validation (email, date, etc.) if ajv-formats is installed + const mcpServer = new McpServer( + { + name: 'form-elicitation-example-server', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + /** + * Example 1: Simple user registration tool + * Collects username, email, and password from the user + */ + mcpServer.registerTool( + 'register_user', + { + description: 'Register a new user account by collecting their information', + inputSchema: {} + }, + async () => { + try { + // Request user information through form elicitation + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your desired username (3-20 characters)', + minLength: 3, + maxLength: 20 + }, + email: { + type: 'string', + title: 'Email', + description: 'Your email address', + format: 'email' + }, + password: { + type: 'string', + title: 'Password', + description: 'Your password (min 8 characters)', + minLength: 8 + }, + newsletter: { + type: 'boolean', + title: 'Newsletter', + description: 'Subscribe to newsletter?', + default: false + }, + role: { + type: 'string', + title: 'Role', + description: 'Your primary role', + oneOf: [ + { const: 'developer', title: 'Developer' }, + { const: 'designer', title: 'Designer' }, + { const: 'manager', title: 'Manager' }, + { const: 'other', title: 'Other' } + ], + default: 'developer' + }, + interests: { + type: 'array', + title: 'Interests', + description: 'Select your areas of interest', + items: { + type: 'string', + enum: ['frontend', 'backend', 'mobile', 'devops', 'ai'] + }, + minItems: 1, + maxItems: 3 + } }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } - }); - - // Handle the different possible actions - if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; + required: ['username', 'email', 'password'] + } + }); + // Handle the different possible actions + if (result.action === 'accept' && result.content) { + const { username, email, newsletter } = result.content as { + username: string; + email: string; + password: string; + newsletter?: boolean; + }; + + return { + content: [ + { + type: 'text', + text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: 'Registration cancelled by user.' + } + ] + }; + } else { + return { + content: [ + { + type: 'text', + text: 'Registration was cancelled.' + } + ] + }; + } + } catch (error) { return { content: [ { type: 'text', - text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` + text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` } - ] + ], + isError: true + }; + } + } + ); + + /** + * Example 2: Multi-step workflow with multiple form elicitation requests + * Demonstrates how to collect information in multiple steps + */ + mcpServer.registerTool( + 'create_event', + { + description: 'Create a calendar event by collecting event details', + inputSchema: {} + }, + async () => { + try { + // Step 1: Collect basic event information + const basicInfo = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 1: Enter basic event information', + requestedSchema: { + type: 'object', + properties: { + title: { + type: 'string', + title: 'Event Title', + description: 'Name of the event', + minLength: 1 + }, + description: { + type: 'string', + title: 'Description', + description: 'Event description (optional)' + } + }, + required: ['title'] + } + }); + + if (basicInfo.action !== 'accept' || !basicInfo.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Step 2: Collect date and time + const dateTime = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 2: Enter date and time', + requestedSchema: { + type: 'object', + properties: { + date: { + type: 'string', + title: 'Date', + description: 'Event date', + format: 'date' + }, + startTime: { + type: 'string', + title: 'Start Time', + description: 'Event start time (HH:MM)' + }, + duration: { + type: 'integer', + title: 'Duration', + description: 'Duration in minutes', + minimum: 15, + maximum: 480 + } + }, + required: ['date', 'startTime', 'duration'] + } + }); + + if (dateTime.action !== 'accept' || !dateTime.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Combine all collected information + const event = { + ...basicInfo.content, + ...dateTime.content }; - } else if (result.action === 'decline') { + return { content: [ { type: 'text', - text: 'Registration cancelled by user.' + text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` } ] }; - } else { + } catch (error) { return { content: [ { type: 'text', - text: 'Registration was cancelled.' + text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` } - ] + ], + isError: true }; } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; } - } -); - -/** - * Example 2: Multi-step workflow with multiple form elicitation requests - * Demonstrates how to collect information in multiple steps - */ -mcpServer.registerTool( - 'create_event', - { - description: 'Create a calendar event by collecting event details', - inputSchema: {} - }, - async () => { - try { - // Step 1: Collect basic event information - const basicInfo = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 1: Enter basic event information', - requestedSchema: { - type: 'object', - properties: { - title: { - type: 'string', - title: 'Event Title', - description: 'Name of the event', - minLength: 1 - }, - description: { - type: 'string', - title: 'Description', - description: 'Event description (optional)' - } - }, - required: ['title'] - } - }); - - if (basicInfo.action !== 'accept' || !basicInfo.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Step 2: Collect date and time - const dateTime = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 2: Enter date and time', - requestedSchema: { - type: 'object', - properties: { - date: { - type: 'string', - title: 'Date', - description: 'Event date', - format: 'date' - }, - startTime: { - type: 'string', - title: 'Start Time', - description: 'Event start time (HH:MM)' + ); + + /** + * Example 3: Collecting address information + * Demonstrates validation with patterns and optional fields + */ + mcpServer.registerTool( + 'update_shipping_address', + { + description: 'Update shipping address with validation', + inputSchema: {} + }, + async () => { + try { + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your shipping address:', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Recipient name', + minLength: 1 + }, + street: { + type: 'string', + title: 'Street Address', + minLength: 1 + }, + city: { + type: 'string', + title: 'City', + minLength: 1 + }, + state: { + type: 'string', + title: 'State/Province', + minLength: 2, + maxLength: 2 + }, + zipCode: { + type: 'string', + title: 'ZIP/Postal Code', + description: '5-digit ZIP code' + }, + phone: { + type: 'string', + title: 'Phone Number (optional)', + description: 'Contact phone number' + } }, - duration: { - type: 'integer', - title: 'Duration', - description: 'Duration in minutes', - minimum: 15, - maximum: 480 - } - }, - required: ['date', 'startTime', 'duration'] - } - }); - - if (dateTime.action !== 'accept' || !dateTime.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Combine all collected information - const event = { - ...basicInfo.content, - ...dateTime.content - }; - - return { - content: [ - { - type: 'text', - text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` + required: ['name', 'street', 'city', 'state', 'zipCode'] } - ], - isError: true - }; - } - } -); - -/** - * Example 3: Collecting address information - * Demonstrates validation with patterns and optional fields - */ -mcpServer.registerTool( - 'update_shipping_address', - { - description: 'Update shipping address with validation', - inputSchema: {} - }, - async () => { - try { - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your shipping address:', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Recipient name', - minLength: 1 - }, - street: { - type: 'string', - title: 'Street Address', - minLength: 1 - }, - city: { - type: 'string', - title: 'City', - minLength: 1 - }, - state: { - type: 'string', - title: 'State/Province', - minLength: 2, - maxLength: 2 - }, - zipCode: { - type: 'string', - title: 'ZIP/Postal Code', - description: '5-digit ZIP code' - }, - phone: { - type: 'string', - title: 'Phone Number (optional)', - description: 'Contact phone number' - } - }, - required: ['name', 'street', 'city', 'state', 'zipCode'] - } - }); + }); - if (result.action === 'accept' && result.content) { + if (result.action === 'accept' && result.content) { + return { + content: [ + { + type: 'text', + text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [{ type: 'text', text: 'Address update cancelled by user.' }] + }; + } else { + return { + content: [{ type: 'text', text: 'Address update was cancelled.' }] + }; + } + } catch (error) { return { content: [ { type: 'text', - text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` + text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` } - ] - }; - } else if (result.action === 'decline') { - return { - content: [{ type: 'text', text: 'Address update cancelled by user.' }] - }; - } else { - return { - content: [{ type: 'text', text: 'Address update was cancelled.' }] + ], + isError: true }; } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; } - } -); + ); + + return mcpServer; +}; async function main() { const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; @@ -357,7 +386,8 @@ async function main() { } }; - // Connect the transport to the MCP server BEFORE handling the request + // Create a new server per session and connect it to the transport + const mcpServer = getServer(); await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); diff --git a/src/examples/server/honoWebStandardStreamableHttp.ts b/src/examples/server/honoWebStandardStreamableHttp.ts index ba8805eae..1a19ee8a4 100644 --- a/src/examples/server/honoWebStandardStreamableHttp.ts +++ b/src/examples/server/honoWebStandardStreamableHttp.ts @@ -15,29 +15,30 @@ import { McpServer } from '../../server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '../../server/webStandardStreamableHttp.js'; import { CallToolResult } from '../../types.js'; -// Create the MCP server -const server = new McpServer({ - name: 'hono-webstandard-mcp-server', - version: '1.0.0' -}); +// Factory function to create a new MCP server per request (stateless mode) +const getServer = () => { + const server = new McpServer({ + name: 'hono-webstandard-mcp-server', + version: '1.0.0' + }); -// Register a simple greeting tool -server.registerTool( - 'greet', - { - title: 'Greeting Tool', - description: 'A simple greeting tool', - inputSchema: { name: z.string().describe('Name to greet') } - }, - async ({ name }): Promise => { - return { - content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] - }; - } -); + // Register a simple greeting tool + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'A simple greeting tool', + inputSchema: { name: z.string().describe('Name to greet') } + }, + async ({ name }): Promise => { + return { + content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] + }; + } + ); -// Create a stateless transport (no options = no session management) -const transport = new WebStandardStreamableHTTPServerTransport(); + return server; +}; // Create the Hono app const app = new Hono(); @@ -56,19 +57,22 @@ app.use( // Health check endpoint app.get('/health', c => c.json({ status: 'ok' })); -// MCP endpoint -app.all('/mcp', c => transport.handleRequest(c.req.raw)); +// MCP endpoint - create a fresh transport and server per request (stateless) +app.all('/mcp', async c => { + const transport = new WebStandardStreamableHTTPServerTransport(); + const server = getServer(); + await server.connect(transport); + return transport.handleRequest(c.req.raw); +}); // Start the server const PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; -server.connect(transport).then(() => { - console.log(`Starting Hono MCP server on port ${PORT}`); - console.log(`Health check: http://localhost:${PORT}/health`); - console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); +console.log(`Starting Hono MCP server on port ${PORT}`); +console.log(`Health check: http://localhost:${PORT}/health`); +console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - serve({ - fetch: app.fetch, - port: PORT - }); +serve({ + fetch: app.fetch, + port: PORT }); diff --git a/src/examples/server/progressExample.ts b/src/examples/server/progressExample.ts new file mode 100644 index 000000000..da50c84eb --- /dev/null +++ b/src/examples/server/progressExample.ts @@ -0,0 +1,58 @@ +/** + * Example: Progress notifications over stdio. + * + * Demonstrates a tool that reports progress to the client while processing. + * + * Run: + * npx tsx src/examples/server/progressExample.ts + * + * Then connect a client with an `onprogress` callback (see docs/protocol.md). + */ + +import { McpServer } from '../../server/mcp.js'; +import { StdioServerTransport } from '../../server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ name: 'progress-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number().int().min(1).max(100) } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: `Cancelled at ${i}` }], isError: true }; + } + + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + + // Simulate work + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index e3b754fa6..e25c67986 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -8,6 +8,7 @@ import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; import { createMcpExpressApp } from '../../server/express.js'; import { CallToolResult, + ElicitResult, ElicitResultSchema, GetPromptResult, isInitializeRequest, @@ -280,6 +281,114 @@ const getServer = () => { } ); + // Register a tool that demonstrates bidirectional task support: + // Server creates a task, then elicits input from client using elicitInputStream + // Using the experimental tasks API - WARNING: may change without notice + server.experimental.tasks.registerToolTask( + 'collect-user-info-task', + { + title: 'Collect Info with Task', + description: 'Collects user info via elicitation with task support using elicitInputStream', + inputSchema: { + infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact') + } + }, + { + async createTask({ infoType }, { taskStore: createTaskStore, taskRequestedTtl }) { + // Create the server-side task + const task = await createTaskStore.createTask({ + ttl: taskRequestedTtl + }); + + // Perform async work that makes a nested elicitation request using elicitInputStream + (async () => { + try { + const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences'; + + // Define schemas with proper typing for PrimitiveSchemaDefinition + const contactSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name', description: 'Your full name' }, + email: { type: 'string', title: 'Email', description: 'Your email address' } + }, + required: ['name', 'email'] + }; + + const preferencesSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + }; + + const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema; + + // Use elicitInputStream to elicit input from client + // This demonstrates the streaming elicitation API + // Access via server.server to get the underlying Server instance + const stream = server.server.experimental.tasks.elicitInputStream({ + mode: 'form', + message, + requestedSchema + }); + + let elicitResult: ElicitResult | undefined; + for await (const msg of stream) { + if (msg.type === 'result') { + elicitResult = msg.result as ElicitResult; + } else if (msg.type === 'error') { + throw msg.error; + } + } + + if (!elicitResult) { + throw new Error('No result received from elicitation'); + } + + let resultText: string; + if (elicitResult.action === 'accept') { + resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`; + } else if (elicitResult.action === 'decline') { + resultText = `User declined to provide ${infoType} information`; + } else { + resultText = 'User cancelled the request'; + } + + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: resultText }] + }); + } catch (error) { + console.error('Error in collect-user-info-task:', error); + await taskStore.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } + })(); + + return { task }; + }, + async getTask(_args, { taskId, taskStore: getTaskStore }) { + return await getTaskStore.getTask(taskId); + }, + async getTaskResult(_args, { taskId, taskStore: getResultTaskStore }) { + const result = await getResultTaskStore.getTaskResult(taskId); + return result as CallToolResult; + } + } + ); + // Register a simple prompt with title server.registerPrompt( 'greeting-template', diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts index bbecf2fdb..04d9f7751 100644 --- a/src/examples/server/ssePollingExample.ts +++ b/src/examples/server/ssePollingExample.ts @@ -12,7 +12,7 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import { Request, Response } from 'express'; +import { type Request, type Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { createMcpExpressApp } from '../../server/express.js'; @@ -21,87 +21,92 @@ import { CallToolResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import cors from 'cors'; -// Create the MCP server -const server = new McpServer( - { - name: 'sse-polling-example', - version: '1.0.0' - }, - { - capabilities: { logging: {} } - } -); - -// Register a long-running tool that demonstrates server-initiated disconnect -server.tool( - 'long-task', - 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', - {}, - async (_args, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - console.log(`[${extra.sessionId}] Starting long-task...`); - - // Send first progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 25% - Starting work...' - }, - extra.sessionId - ); - await sleep(1000); - - // Send second progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 50% - Halfway there...' - }, - extra.sessionId - ); - await sleep(1000); - - // Server decides to disconnect the client to free resources - // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval - // Use extra.closeSSEStream callback - available when eventStore is configured - if (extra.closeSSEStream) { - console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); - extra.closeSSEStream(); +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + const server = new McpServer( + { + name: 'sse-polling-example', + version: '1.0.0' + }, + { + capabilities: { logging: {} } } + ); + + // Register a long-running tool that demonstrates server-initiated disconnect + server.tool( + 'long-task', + 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting long-task...`); - // Continue processing while client is disconnected - // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 75% - Almost done (sent while client disconnected)...' - }, - extra.sessionId - ); - - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 100% - Complete!' - }, - extra.sessionId - ); - - console.log(`[${extra.sessionId}] Task complete`); - - return { - content: [ + // Send first progress notification + await server.sendLoggingMessage( { - type: 'text', - text: 'Long task completed successfully!' - } - ] - }; - } -); + level: 'info', + data: 'Progress: 25% - Starting work...' + }, + extra.sessionId + ); + await sleep(1000); + + // Send second progress notification + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 50% - Halfway there...' + }, + extra.sessionId + ); + await sleep(1000); + + // Server decides to disconnect the client to free resources + // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval + // Use extra.closeSSEStream callback - available when eventStore is configured + if (extra.closeSSEStream) { + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + extra.closeSSEStream(); + } + + // Continue processing while client is disconnected + // Events are stored in eventStore and will be replayed on reconnect + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 75% - Almost done (sent while client disconnected)...' + }, + extra.sessionId + ); + + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 100% - Complete!' + }, + extra.sessionId + ); + + console.log(`[${extra.sessionId}] Task complete`); + + return { + content: [ + { + type: 'text', + text: 'Long task completed successfully!' + } + ] + }; + } + ); + + return server; +}; // Set up Express app const app = createMcpExpressApp(); @@ -131,7 +136,8 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); - // Connect the MCP server to the transport + // Create a new server per session and connect it to the transport + const server = getServer(); await server.connect(transport); } diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 225ef1f34..97882874d 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -5,35 +5,46 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest, ReadResourceResult } from '../../types.js'; import { createMcpExpressApp } from '../../server/express.js'; -// Create an MCP server with implementation details -const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' -}); +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0' + }); -// Store transports by session ID to send notifications -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + const addResource = (name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.registerResource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }] + }; + } + ); + }; -const addResource = (name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.registerResource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; - } - ); -}; + addResource('example-resource', 'Initial content for example-resource'); -addResource('example-resource', 'Initial content for example-resource'); + // Periodically add new resources to demonstrate notifications + const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + addResource(name, `Content for ${name}`); + }, 5000); -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - addResource(name, `Content for ${name}`); -}, 5000); // Change resources every 5 seconds for testing + // Clean up the interval when the server closes + server.server.onclose = () => { + clearInterval(resourceChangeInterval); + }; + + return server; +}; + +// Store transports by session ID to send notifications +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const app = createMcpExpressApp(); @@ -59,7 +70,8 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); - // Connect the transport to the MCP server + // Create a new server per session and connect it to the transport + const server = getServer(); await server.connect(transport); // Handle the request - the onsessioninitialized callback will store the transport @@ -121,7 +133,9 @@ app.listen(PORT, error => { // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - await server.close(); + for (const sessionId in transports) { + await transports[sessionId].close(); + delete transports[sessionId]; + } process.exit(0); }); diff --git a/src/experimental/tasks/server.ts b/src/experimental/tasks/server.ts index a4150a8d7..e77ad2582 100644 --- a/src/experimental/tasks/server.ts +++ b/src/experimental/tasks/server.ts @@ -9,7 +9,21 @@ import type { Server } from '../../server/index.js'; import type { RequestOptions } from '../../shared/protocol.js'; import type { ResponseMessage } from '../../shared/responseMessage.js'; import type { AnySchema, SchemaOutput } from '../../server/zod-compat.js'; -import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '../../types.js'; +import type { + ServerRequest, + Notification, + Request, + Result, + GetTaskResult, + ListTasksResult, + CancelTaskResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult +} from '../../types.js'; +import { CreateMessageResultSchema, ElicitResultSchema } from '../../types.js'; /** * Experimental task features for low-level MCP servers. @@ -60,6 +74,188 @@ export class ExperimentalServerTasks< return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); } + /** + * Sends a sampling request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages + * before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.createMessageStream({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + * maxTokens: 100 + * }, { + * onprogress: (progress) => { + * // Handle streaming tokens via progress notifications + * console.log('Progress:', progress.message); + * } + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The sampling request parameters + * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + createMessageStream( + params: CreateMessageRequestParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + + // Capability check - only required when tools/toolChoice are provided + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new Error('Client does not support sampling tools capability.'); + } + + // Message structure validation - always validate tool_use/tool_result pairs. + // These may appear even without tools/toolChoice in the current request when + // a previous sampling request returned tool_use and this is a follow-up with results. + if (params.messages.length > 0) { + const lastMessage = params.messages[params.messages.length - 1]; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + + const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new Error('The last message must contain only tool_result content if any is present'); + } + if (!hasPreviousToolUse) { + throw new Error('tool_result blocks are not matching any tool_use from the previous message'); + } + } + if (hasPreviousToolUse) { + // Extract tool_use IDs from previous message and tool_result IDs from current message + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as { id: string }).id)); + const toolResultIds = new Set( + lastContent.filter(c => c.type === 'tool_result').map(c => (c as { toolUseId: string }).toolUseId) + ); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match'); + } + } + } + + return this.requestStream( + { + method: 'sampling/createMessage', + params + }, + CreateMessageResultSchema, + options + ); + } + + /** + * Sends an elicitation request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' + * and 'taskStatus' messages before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.elicitInputStream({ + * mode: 'url', + * message: 'Please authenticate', + * elicitationId: 'auth-123', + * url: 'https://example.com/auth' + * }, { + * task: { ttl: 300000 } // Task-augmented for long-running auth flow + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('User action:', message.result.action); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The elicitation request parameters + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + elicitInputStream( + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + const mode = params.mode ?? 'form'; + + // Capability check based on mode + switch (mode) { + case 'url': { + if (!clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support url elicitation.'); + } + break; + } + case 'form': { + if (!clientCapabilities?.elicitation?.form) { + throw new Error('Client does not support form elicitation.'); + } + break; + } + } + + // Normalize params to ensure mode is set for form mode (defaults to 'form' per spec) + const normalizedParams = mode === 'form' && params.mode === undefined ? { ...params, mode: 'form' as const } : params; + + // Cast to ServerRequest needed because TypeScript can't narrow the union type + // based on the discriminated 'method' field when constructing the object literal + return this.requestStream( + { + method: 'elicitation/create', + params: normalizedParams + } as ServerRequest, + ElicitResultSchema, + options + ); + } + /** * Gets the current status of a task. * diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index dcb6c03ec..4b9f3b327 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -15,6 +15,39 @@ export type AuthorizationHandlerOptions = { rateLimit?: Partial | false; }; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Validates a requested redirect_uri against a registered one. + * + * Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST + * allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to + * accommodate native clients that obtain an ephemeral port from the OS. For + * non-loopback URIs, exact match is required. + * + * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + */ +export function redirectUriMatches(requested: string, registered: string): boolean { + if (requested === registered) { + return true; + } + let req: URL, reg: URL; + try { + req = new URL(requested); + reg = new URL(registered); + } catch { + return false; + } + // Port relaxation only applies when both URIs target a loopback host. + if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) { + return false; + } + // RFC 8252 relaxes the port only — scheme, host, path, and query must + // still match exactly. Note: hostname must match exactly too (the RFC + // does not allow localhost↔127.0.0.1 cross-matching). + return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search; +} + // Parameters that must be validated in order to issue redirects. const ClientAuthorizationParamsSchema = z.object({ client_id: z.string(), @@ -78,7 +111,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A } if (redirect_uri !== undefined) { - if (!client.redirect_uris.includes(redirect_uri)) { + const requested = redirect_uri; + if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) { throw new InvalidRequestError('Unregistered redirect_uri'); } } else if (client.redirect_uris.length === 1) { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7e61b4364..9fe0ed549 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1024,9 +1024,10 @@ export class McpServer { annotations = rest.shift() as ToolAnnotations; } } else if (typeof firstArg === 'object' && firstArg !== null) { - // Not a ZodRawShapeCompat, so must be annotations in this position - // Case: tool(name, annotations, cb) - // Or: tool(name, description, annotations, cb) + // ToolAnnotations values are primitives. Nested objects indicate a misplaced schema + if (Object.values(firstArg).some(v => typeof v === 'object' && v !== null)) { + throw new Error(`Tool ${name} expected a Zod schema or ToolAnnotations, but received an unrecognized object`); + } annotations = rest.shift() as ToolAnnotations; } } @@ -1386,7 +1387,7 @@ function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { /** * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, - * otherwise returns the schema as is. + * otherwise returns the schema as is. Throws if the value is not a valid Zod schema. */ function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): AnySchema | undefined { if (!schema) { @@ -1397,6 +1398,10 @@ function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): return objectFromShape(schema); } + if (!isZodSchemaInstance(schema as object)) { + throw new Error('inputSchema must be a Zod schema or raw shape, received an unrecognized object'); + } + return schema; } diff --git a/src/server/sse.ts b/src/server/sse.ts index b7450a09e..4931beae6 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { IncomingMessage, ServerResponse } from 'node:http'; +import { TLSSocket } from 'node:tls'; import { Transport } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; import getRawBody from 'raw-body'; @@ -149,7 +150,15 @@ export class SSEServerTransport implements Transport { } const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; + + const host = req.headers.host; + const protocol = req.socket instanceof TLSSocket ? 'https' : 'http'; + const fullUrl = host && req.url ? new URL(req.url, `${protocol}://${host}`) : undefined; + + const requestInfo: RequestInfo = { + headers: req.headers, + url: fullUrl + }; let body: string | unknown; try { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bc310d98e..83801fd2c 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -78,14 +78,19 @@ export class StreamableHTTPServerTransport implements Transport { // Create a request listener that wraps the web standard transport // getRequestListener converts Node.js HTTP to Web Standard and properly handles SSE streaming - this._requestListener = getRequestListener(async (webRequest: Request) => { - // Get context if available (set during handleRequest) - const context = this._requestContext.get(webRequest); - return this._webStandardTransport.handleRequest(webRequest, { - authInfo: context?.authInfo, - parsedBody: context?.parsedBody - }); - }); + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + this._requestListener = getRequestListener( + async (webRequest: Request) => { + // Get context if available (set during handleRequest) + const context = this._requestContext.get(webRequest); + return this._webStandardTransport.handleRequest(webRequest, { + authInfo: context?.authInfo, + parsedBody: context?.parsedBody + }); + }, + { overrideGlobalObjects: false } + ); } /** @@ -166,12 +171,17 @@ export class StreamableHTTPServerTransport implements Transport { const authInfo = req.auth; // Create a custom handler that includes our context - const handler = getRequestListener(async (webRequest: Request) => { - return this._webStandardTransport.handleRequest(webRequest, { - authInfo, - parsedBody - }); - }); + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + const handler = getRequestListener( + async (webRequest: Request) => { + return this._webStandardTransport.handleRequest(webRequest, { + authInfo, + parsedBody + }); + }, + { overrideGlobalObjects: false } + ); // Delegate to the request listener which handles all the Node.js <-> Web Standard conversion // including proper SSE streaming support diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 3ae9846c2..1f528427c 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -210,6 +210,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private sessionIdGenerator: (() => string) | undefined; private _started: boolean = false; + private _hasHandledRequest: boolean = false; private _streamMapping: Map = new Map(); private _requestToStreamMapping: Map = new Map(); private _requestResponseMap: Map = new Map(); @@ -319,6 +320,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Returns a Response object (Web Standard) */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { + // In stateless mode (no sessionIdGenerator), each request must use a fresh transport. + // Reusing a stateless transport causes message ID collisions between clients. + if (!this.sessionIdGenerator && this._hasHandledRequest) { + throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.'); + } + this._hasHandledRequest = true; + // Validate request headers for DNS rebinding protection const validationError = this.validateRequestHeaders(req); if (validationError) { @@ -375,6 +383,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // The client MUST include an Accept header, listing text/event-stream as a supported content type. const acceptHeader = req.headers.get('accept'); if (!acceptHeader?.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); return this.createJsonErrorResponse(406, -32000, 'Not Acceptable: Client must accept text/event-stream'); } @@ -401,6 +410,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Check if there's already an active standalone SSE stream for this session if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { // Only one GET SSE stream is allowed per session + this.onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Only one SSE stream is allowed per session'); } @@ -452,6 +462,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { */ private async replayEvents(lastEventId: string): Promise { if (!this._eventStore) { + this.onerror?.(new Error('Event store not configured')); return this.createJsonErrorResponse(400, -32000, 'Event store not configured'); } @@ -462,11 +473,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamId = await this._eventStore.getStreamIdForEventId(lastEventId); if (!streamId) { + this.onerror?.(new Error('Invalid event ID format')); return this.createJsonErrorResponse(400, -32000, 'Invalid event ID format'); } // Check conflict with the SAME streamId we'll use for mapping if (this._streamMapping.get(streamId) !== undefined) { + this.onerror?.(new Error('Conflict: Stream already has an active connection')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Stream already has an active connection'); } } @@ -548,7 +561,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { eventData += `data: ${JSON.stringify(message)}\n\n`; controller.enqueue(encoder.encode(eventData)); return true; - } catch { + } catch (error) { + this.onerror?.(error as Error); return false; } } @@ -557,6 +571,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Handles unsupported requests (PUT, PATCH, etc.) */ private handleUnsupportedRequest(): Response { + this.onerror?.(new Error('Method not allowed.')); return new Response( JSON.stringify({ jsonrpc: '2.0', @@ -585,6 +600,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const acceptHeader = req.headers.get('accept'); // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( 406, -32000, @@ -594,12 +610,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const ct = req.headers.get('content-type'); if (!ct || !ct.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json'); } - // Build request info from headers + // Build request info from headers and URL const requestInfo: RequestInfo = { - headers: Object.fromEntries(req.headers.entries()) + headers: Object.fromEntries(req.headers.entries()), + url: new URL(req.url) }; let rawMessage; @@ -609,6 +627,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { try { rawMessage = await req.json(); } catch { + this.onerror?.(new Error('Parse error: Invalid JSON')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON'); } } @@ -623,6 +642,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } } catch { + this.onerror?.(new Error('Parse error: Invalid JSON-RPC message')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON-RPC message'); } @@ -633,9 +653,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. if (this._initialized && this.sessionId !== undefined) { + this.onerror?.(new Error('Invalid Request: Server already initialized')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Server already initialized'); } if (messages.length > 1) { + this.onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Only one initialization request is allowed'); } this.sessionId = this.sessionIdGenerator?.(); @@ -815,6 +837,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } if (!this._initialized) { // If the server has not been initialized yet, reject all requests + this.onerror?.(new Error('Bad Request: Server not initialized')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Server not initialized'); } @@ -822,11 +845,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { if (!sessionId) { // Non-initialization requests without a session ID should return 400 Bad Request + this.onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Mcp-Session-Id header is required'); } if (sessionId !== this.sessionId) { // Reject requests with invalid session ID with 404 Not Found + this.onerror?.(new Error('Session not found')); return this.createJsonErrorResponse(404, -32001, 'Session not found'); } @@ -850,6 +875,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const protocolVersion = req.headers.get('mcp-protocol-version'); if (protocolVersion !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + this.onerror?.( + new Error( + `Bad Request: Unsupported protocol version: ${protocolVersion}` + + ` (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + ) + ); return this.createJsonErrorResponse( 400, -32000, diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index 9d25a5efc..d95ee7908 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -188,6 +188,21 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un return undefined; } +function getDotPath(path: (string | number)[]) { + if (path.length === 0) { + return 'object root'; + } + return path.reduce((acc, seg, index) => { + if (index === 0) { + return String(seg); + } + if (typeof seg === 'number') { + return `${acc}[${seg}]`; + } + return `${acc}.${seg}`; + }, ''); +} + // --- Error message extraction --- /** * Safely extracts an error message from a parse result error. @@ -195,16 +210,21 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un */ export function getParseErrorMessage(error: unknown): string { if (error && typeof error === 'object') { + // When present, prioritize zod issues and format as a message and path + if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { + return error.issues + .map((i: { message: string; path?: (string | number)[] }) => { + if (!i.path?.length) { + return i.message; + } + return `${i.message} at ${getDotPath(i.path)}`; + }) + .join('\n'); + } // Try common error structures if ('message' in error && typeof error.message === 'string') { return error.message; } - if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { - const firstIssue = error.issues[0]; - if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) { - return String(firstIssue.message); - } - } // Fallback: try to stringify the error try { return JSON.stringify(error); diff --git a/src/shared/auth.ts b/src/shared/auth.ts index c546c8608..fb794d069 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -66,7 +66,8 @@ export const OAuthMetadataSchema = z.looseObject({ introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** @@ -109,7 +110,8 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index aa242a647..2637be65b 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -265,7 +265,7 @@ export type RequestHandlerExtra { + if (this._transport) { + throw new Error( + 'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.' + ); + } + this._transport = transport; const _onclose = this.transport?.onclose; this._transport.onclose = () => { @@ -642,6 +648,17 @@ export abstract class Protocol { + if (abortController.signal.aborted) return; // Include related-task metadata if this request is part of a task const notificationOptions: NotificationOptions = { relatedRequestId: request.id }; if (relatedTaskId) { @@ -727,6 +745,9 @@ export abstract class Protocol { + if (abortController.signal.aborted) { + throw new McpError(ErrorCode.ConnectionClosed, 'Request was cancelled'); + } // Include related-task metadata if this request is part of a task const requestOptions: RequestOptions = { ...options, relatedRequestId: request.id }; if (relatedTaskId && !requestOptions.relatedTask) { @@ -824,7 +845,11 @@ export abstract class Protocol this._onerror(new Error(`Failed to send response: ${error}`))) .finally(() => { - this._requestHandlerAbortControllers.delete(request.id); + // Only delete if the stored controller is still ours; after close()+connect(), + // a new connection may have reused the same request ID with a different controller. + if (this._requestHandlerAbortControllers.get(request.id) === abortController) { + this._requestHandlerAbortControllers.delete(request.id); + } }); } diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts index 1dd57f56f..a47a64c97 100644 --- a/src/shared/uriTemplate.ts +++ b/src/shared/uriTemplate.ts @@ -225,7 +225,7 @@ export class UriTemplate { switch (part.operator) { case '': - pattern = part.exploded ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'; + pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'; break; case '+': case '#': @@ -235,7 +235,7 @@ export class UriTemplate { pattern = '\\.([^/,]+)'; break; case '/': - pattern = '/' + (part.exploded ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'); + pattern = '/' + (part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'); break; default: pattern = '([^/]+)'; diff --git a/src/spec.types.ts b/src/spec.types.ts index 07a1cceff..2640152a2 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -383,6 +383,10 @@ export interface ClientCapabilities { }; }; }; + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions?: { [key: string]: object }; } /** @@ -461,6 +465,10 @@ export interface ServerCapabilities { }; }; }; + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions?: { [key: string]: object }; } /** diff --git a/src/types.ts b/src/types.ts index 6bec5190c..835eac89f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,10 +35,9 @@ export const CursorSchema = z.string(); */ export const TaskCreationParamsSchema = z.looseObject({ /** - * Time in milliseconds to keep task results available after completion. - * If null, the task has unlimited lifetime until manually cleaned up. + * Requested duration in milliseconds to retain task from creation. */ - ttl: z.union([z.number(), z.null()]).optional(), + ttl: z.number().optional(), /** * Time in milliseconds to wait between task status requests. @@ -512,7 +511,11 @@ export const ClientCapabilitiesSchema = z.object({ /** * Present if the client supports task creation. */ - tasks: ClientTasksCapabilitySchema.optional() + tasks: ClientTasksCapabilitySchema.optional(), + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), AssertObjectSchema).optional() }); export const InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ @@ -590,7 +593,11 @@ export const ServerCapabilitiesSchema = z.object({ /** * Present if the server supports task creation. */ - tasks: ServerTasksCapabilitySchema.optional() + tasks: ServerTasksCapabilitySchema.optional(), + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), AssertObjectSchema).optional() }); /** @@ -901,6 +908,13 @@ export const ResourceSchema = z.object({ */ mimeType: z.optional(z.string()), + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size: z.optional(z.number()), + /** * Optional annotations for the client. */ @@ -2361,6 +2375,10 @@ export interface RequestInfo { * The headers of the request. */ headers: IsomorphicHeaders; + /** + * The full URL of the request. + */ + url?: URL; } /** diff --git a/test/client/auth-extensions.test.ts b/test/client/auth-extensions.test.ts index a7217307d..623d5e4da 100644 --- a/test/client/auth-extensions.test.ts +++ b/test/client/auth-extensions.test.ts @@ -49,6 +49,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens?.access_token).toBe('test-access-token'); }); + it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client', + scope: 'read write' + }); + + expect(provider.clientMetadata.scope).toBe('read write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('read write'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -92,6 +121,38 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(assertionFromRequest).toBeTruthy(); }); + it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client', + scope: 'openid profile' + }); + + expect(provider.clientMetadata.scope).toBe('openid profile'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -149,6 +210,39 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); }); + + it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client', + scope: 'api:read api:write' + }); + + expect(provider.clientMetadata.scope).toBe('api:read api:write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('api:read api:write'); + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); }); describe('createPrivateKeyJwtAuth', () => { @@ -304,7 +398,7 @@ describe('createPrivateKeyJwtAuth', () => { const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( - /Invalid character/ + /Invalid character|cannot be part of a valid base64/ ); }); diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index d6e7e8684..b524b7acc 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -8,15 +8,18 @@ import { refreshAuthorization, registerClient, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, extractWWWAuthenticateParams, auth, type OAuthClientProvider, selectClientAuthMethod, - isHttpsUrl + isHttpsUrl, + validateAuthorizationResponseIssuer, + IssuerMismatchError } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; -import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js'; +import { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '../../src/shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; // Mock pkce-challenge @@ -916,6 +919,369 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = { @@ -933,6 +1299,27 @@ describe('OAuth Authorization', () => { const authMethod = selectClientAuthMethod(clientInfo, supportedMethods); expect(authMethod).toBe('none'); }); + it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => { + // RFC 8414 §2: if omitted, the default is client_secret_basic. + // RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret. + const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_basic'); + }); + it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => { + const clientInfo = { client_id: 'test-client-id' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('none'); + }); + it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => { + const clientInfo = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_endpoint_auth_method: 'client_secret_post' + }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_post'); + }); }); describe('startAuthorization', () => { @@ -1149,8 +1536,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1188,8 +1577,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1311,8 +1702,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1371,8 +1764,10 @@ describe('OAuth Authorization', () => { const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1509,6 +1904,43 @@ describe('OAuth Authorization', () => { ); }); + it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => { + const clientMetadataWithScope: OAuthClientMetadata = { + ...validClientMetadata, + scope: 'should-be-overridden' + }; + + const expectedClientInfo = { + ...validClientInfo, + scope: 'openid profile' + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedClientInfo + }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: clientMetadataWithScope, + scope: 'openid profile' + }); + + expect(clientInfo).toEqual(expectedClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/register' + }), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' }) + }) + ); + }); + it('validates client information response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -2382,6 +2814,12 @@ describe('OAuth Authorization', () => { const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; const authUrl: URL = redirectCall[0]; expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin'); + + // Verify the same scope was also used in the DCR request body + const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register')); + expect(registerCall).toBeDefined(); + const registerBody = JSON.parse(registerCall![1].body); + expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin'); }); it('prefers explicit scope parameter over scopes_supported from PRM', async () => { @@ -2726,7 +3164,7 @@ describe('OAuth Authorization', () => { expect(body.get('client_secret')).toBeNull(); }); - it('defaults to client_secret_post when no auth methods specified', async () => { + it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -2743,13 +3181,15 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual(validTokens); const request = mockFetch.mock.calls[0][1]; - // Check headers - expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); - expect(request.headers.get('Authorization')).toBeNull(); + // RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted, + // the default is client_secret_basic (HTTP Basic auth, not body params) + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); const body = request.body as URLSearchParams; - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); }); }); @@ -3244,4 +3684,68 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('validateAuthorizationResponseIssuer (RFC 9207)', () => { + const issuer = 'https://auth.example.com'; + const baseMetadata: AuthorizationServerMetadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'] + }; + + it('accepts matching iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer(issuer, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).not.toThrow(); + }); + + it('rejects mismatched iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer('https://attacker.example.com', { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + + it('rejects absent iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer(undefined, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + + it('accepts matching iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer(issuer, baseMetadata)).not.toThrow(); + }); + + it('rejects mismatched iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer('https://attacker.example.com', baseMetadata)).toThrow( + IssuerMismatchError + ); + }); + + it('accepts absent iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, baseMetadata)).not.toThrow(); + }); + + it('accepts absent iss when metadata is undefined', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + }); + + it('uses simple string comparison without normalization', () => { + expect(() => + validateAuthorizationResponseIssuer(`${issuer}/`, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + }); }); diff --git a/test/client/cross-spawn.test.ts b/test/client/cross-spawn.test.ts index 26ae682fe..5a2671195 100644 --- a/test/client/cross-spawn.test.ts +++ b/test/client/cross-spawn.test.ts @@ -150,4 +150,54 @@ describe('StdioClientTransport using cross-spawn', () => { // verify message is sent correctly expect(mockProcess.stdin.write).toHaveBeenCalled(); }); + + describe('windowsHide', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform + }); + }); + + test('should set windowsHide to true on Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: true + }) + ); + }); + + test('should set windowsHide to false on non-Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: false + }) + ); + }); + }); }); diff --git a/test/client/index.test.ts b/test/client/index.test.ts index 9735eb2ba..f5c6a348d 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -4137,3 +4137,129 @@ describe('getSupportedElicitationModes', () => { expect(result.supportsUrlMode).toBe(false); }); }); + +describe('Client sampling validation with tools', () => { + test('should validate array content with tool_use when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use - should validate with CreateMessageResultWithToolsSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: { arg: 'value' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + expect((result.content as Array<{ type: string }>)[0].type).toBe('tool_use'); + }); + + test('should validate single content when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns single content (text) - should still validate with CreateMessageResultWithToolsSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'No tool needed' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should validate single content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns single content - should validate with CreateMessageResultSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should reject array content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns array content - should fail validation with CreateMessageResultSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: [{ type: 'text', text: 'Array response' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }) + ).rejects.toThrow('Invalid sampling result'); + }); + + test('should validate array content when request includes toolChoice', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }], + toolChoice: { mode: 'auto' } + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + }); +}); diff --git a/test/client/sse.test.ts b/test/client/sse.test.ts index 6574b60b8..92ca5a883 100644 --- a/test/client/sse.test.ts +++ b/test/client/sse.test.ts @@ -8,6 +8,21 @@ import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; import { listenOnRandomPort } from '../helpers/http.js'; import { AddressInfo } from 'node:net'; +/** + * Parses HTTP Basic auth from a request's Authorization header. + * Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed. + * client_secret_basic is the default client auth method when server metadata omits + * token_endpoint_auth_methods_supported (RFC 8414 §2). + */ +function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Basic ')) return undefined; + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + if (sep === -1) return undefined; + return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) }; +} + describe('SSEClientTransport', () => { let resourceServer: Server; let authServer: Server; @@ -668,11 +683,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -796,11 +812,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -1230,10 +1247,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'authorization_code' && params.get('code') === 'test-auth-code' && - params.get('client_id') === 'test-client-id' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( diff --git a/test/conformance/conformance-baseline.yml b/test/conformance/conformance-baseline.yml new file mode 100644 index 000000000..e4ae46cb0 --- /dev/null +++ b/test/conformance/conformance-baseline.yml @@ -0,0 +1,8 @@ +# Known conformance test failures for v1.x +# These are tracked and should be removed as they're fixed. +# +# auth/cross-app-access-complete-flow: SEP-990 Enterprise Managed OAuth +# scenario added in conformance 0.1.14. Requires implementing token +# exchange (RFC 8693) and JWT bearer grant (RFC 7523) in the client. +client: + - auth/cross-app-access-complete-flow diff --git a/test/conformance/scripts/run-server-conformance.sh b/test/conformance/scripts/run-server-conformance.sh new file mode 100755 index 000000000..5105d64f7 --- /dev/null +++ b/test/conformance/scripts/run-server-conformance.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Script to run server conformance tests +# Starts the conformance server, runs conformance tests, then stops the server + +set -e + +PORT="${PORT:-3000}" +SERVER_URL="http://localhost:${PORT}/mcp" + +# Navigate to repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start the server in the background +echo "Starting conformance test server on port ${PORT}..." +npx tsx test/conformance/src/everythingServer.ts & +SERVER_PID=$! + +# Function to cleanup on exit +cleanup() { + echo "Stopping server (PID: ${SERVER_PID})..." + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for server to be ready +echo "Waiting for server to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "${SERVER_URL}" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} attempts" + exit 1 + fi + sleep 0.5 +done + +echo "Server is ready. Running conformance tests..." + +# Run conformance tests - pass through all arguments +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@" + +echo "Conformance tests completed." diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts new file mode 100644 index 000000000..002449f29 --- /dev/null +++ b/test/conformance/src/everythingClient.ts @@ -0,0 +1,413 @@ +#!/usr/bin/env node + +/** + * Everything client (v1.x) - a single conformance test client that handles all scenarios. + * + * Usage: everything-client + * + * The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, + * which is set by the conformance test runner. + */ + +import { Client } from '../../../src/client/index.js'; +import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../../src/client/auth-extensions.js'; +import { ElicitRequestSchema } from '../../../src/types.js'; +import { z } from 'zod'; + +import { logger } from './helpers/logger.js'; +import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider.js'; +import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; + +/** + * Fixed client metadata URL for CIMD conformance tests. + */ +const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json'; + +/** + * Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT. + */ +const ClientConformanceContextSchema = z.discriminatedUnion('name', [ + z.object({ + name: z.literal('auth/client-credentials-jwt'), + client_id: z.string(), + private_key_pem: z.string(), + signing_algorithm: z.string().optional() + }), + z.object({ + name: z.literal('auth/client-credentials-basic'), + client_id: z.string(), + client_secret: z.string() + }), + z.object({ + name: z.literal('auth/pre-registration'), + client_id: z.string(), + client_secret: z.string() + }) +]); + +/** + * Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var. + */ +function parseContext() { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) { + throw new Error('MCP_CONFORMANCE_CONTEXT not set'); + } + return ClientConformanceContextSchema.parse(JSON.parse(raw)); +} + +// Scenario handler type +type ScenarioHandler = (serverUrl: string) => Promise; + +// Registry of scenario handlers +const scenarioHandlers: Record = {}; + +// Helper to register a scenario handler +function registerScenario(name: string, handler: ScenarioHandler): void { + scenarioHandlers[name] = handler; +} + +// Helper to register multiple scenarios with the same handler +function registerScenarios(names: string[], handler: ScenarioHandler): void { + for (const name of names) { + scenarioHandlers[name] = handler; + } +} + +// ============================================================================ +// Basic scenarios (initialize, tools_call) +// ============================================================================ + +async function runBasicClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// tools_call scenario needs to actually call a tool +async function runToolsCallClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('initialize', runBasicClient); +registerScenario('tools_call', runToolsCallClient); + +// ============================================================================ +// Auth scenarios - well-behaved client +// ============================================================================ + +async function runAuthClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} }); + + const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// Register all auth scenarios that should use the well-behaved auth client +registerScenarios( + [ + 'auth/basic-cimd', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + 'auth/2025-03-26-oauth-metadata-backcompat', + 'auth/2025-03-26-oauth-endpoint-fallback', + 'auth/scope-from-www-authenticate', + 'auth/scope-from-scopes-supported', + 'auth/scope-omitted-when-undefined', + 'auth/scope-step-up', + 'auth/scope-retry-limit', + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none' + ], + runAuthClient +); + +// ============================================================================ +// Client Credentials scenarios +// ============================================================================ + +async function runClientCredentialsJwt(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-jwt') { + throw new Error(`Expected jwt context, got ${ctx.name}`); + } + + const provider = new PrivateKeyJwtProvider({ + clientId: ctx.client_id, + privateKey: ctx.private_key_pem, + algorithm: ctx.signing_algorithm || 'ES256' + }); + + const client = new Client({ name: 'conformance-client-credentials-jwt', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with private_key_jwt auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt); + +async function runClientCredentialsBasic(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-basic') { + throw new Error(`Expected basic context, got ${ctx.name}`); + } + + const provider = new ClientCredentialsProvider({ + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + const client = new Client({ name: 'conformance-client-credentials-basic', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with client_secret_basic auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); + +// ============================================================================ +// Pre-registration scenario (no dynamic client registration) +// ============================================================================ + +async function runPreRegistrationClient(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + // Create a provider pre-populated with registered credentials, + // so the SDK skips dynamic client registration. + const provider = new ConformanceOAuthProvider('http://localhost:3000/callback', { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + }); + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] + }); + + const oauthFetch = withOAuthRetry('conformance-pre-registration', new URL(serverUrl), handle401, undefined, provider)(fetch); + + const client = new Client({ name: 'conformance-pre-registration', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + await client.listTools(); + await client.callTool({ name: 'test-tool', arguments: {} }); + await transport.close(); +} + +registerScenario('auth/pre-registration', runPreRegistrationClient); + +// ============================================================================ +// Elicitation defaults scenario +// ============================================================================ + +async function runElicitationDefaultsClient(serverUrl: string): Promise { + const client = new Client( + { name: 'elicitation-defaults-test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + // Register elicitation handler that returns empty content + // The SDK should fill in defaults for all omitted fields + client.setRequestHandler(ElicitRequestSchema, async request => { + logger.debug('Received elicitation request:', JSON.stringify(request.params, null, 2)); + logger.debug('Accepting with empty content - SDK should apply defaults'); + + return { + action: 'accept' as const, + content: {} + }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const testTool = tools.tools.find(t => t.name === 'test_client_elicitation_defaults'); + if (!testTool) { + throw new Error('Test tool not found: test_client_elicitation_defaults'); + } + + logger.debug('Calling test_client_elicitation_defaults tool...'); + const result = await client.callTool({ + name: 'test_client_elicitation_defaults', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsClient); + +// ============================================================================ +// SSE retry scenario +// ============================================================================ + +async function runSSERetryClient(serverUrl: string): Promise { + const client = new Client({ name: 'sse-retry-test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const testTool = tools.tools.find(t => t.name === 'test_reconnection'); + if (!testTool) { + throw new Error('Test tool not found: test_reconnection'); + } + + logger.debug('Calling test_reconnection tool...'); + const result = await client.callTool({ + name: 'test_reconnection', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sse-retry', runSSERetryClient); + +// ============================================================================ +// Main entry point +// ============================================================================ + +async function main(): Promise { + const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO; + const serverUrl = process.argv[2]; + + if (!scenarioName || !serverUrl) { + logger.error('Usage: MCP_CONFORMANCE_SCENARIO= everything-client '); + logger.error('\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.'); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + const handler = scenarioHandlers[scenarioName]; + if (!handler) { + logger.error(`Unknown scenario: ${scenarioName}`); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + try { + await handler(serverUrl); + process.exit(0); + } catch (error) { + logger.error('Error:', error); + process.exit(1); + } +} + +try { + await main(); +} catch (error) { + logger.error('Error:', error); + process.exit(1); +} diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts new file mode 100644 index 000000000..05f4cb174 --- /dev/null +++ b/test/conformance/src/everythingServer.ts @@ -0,0 +1,953 @@ +#!/usr/bin/env node + +/** + * MCP Conformance Test Server (v1.x) + * + * Server implementing all MCP features for conformance testing. + * Adapted from the main branch version for the v1.x single-package SDK. + */ + +import { randomUUID } from 'node:crypto'; + +import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js'; +import type { EventId, EventStore, StreamId } from '../../../src/server/streamableHttp.js'; +import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js'; +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '../../../src/types.js'; +import { + CompleteRequestSchema, + CreateMessageResultSchema, + ElicitResultSchema, + isInitializeRequest, + SetLevelRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema +} from '../../../src/types.js'; +import { localhostHostValidation } from '../../../src/server/middleware/hostHeaderValidation.js'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import { z } from 'zod'; + +// Server state +const resourceSubscriptions = new Set(); +const watchedResourceContent = 'Watched resource content'; + +// Session management +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: unknown) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0] || lastEventId; + const eventsToReplay: Array<[string, { message: unknown }]> = []; + for (const [eventId, data] of eventStoreData.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (message && typeof message === 'object' && Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + +// Sample base64 encoded 1x1 red PNG pixel for testing +const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +// Sample base64 encoded minimal WAV file for testing +const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + +// Function to create a new MCP server instance (one per session) +function createMcpServer() { + const mcpServer = new McpServer( + { + name: 'mcp-conformance-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + }, + resources: { + subscribe: true, + listChanged: true + }, + prompts: { + listChanged: true + }, + logging: {}, + completions: {} + } + } + ); + + // Helper to send log messages using the underlying server + function sendLog( + level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', + message: string, + _data?: unknown + ) { + mcpServer.server + .notification({ + method: 'notifications/message', + params: { + level, + logger: 'conformance-test-server', + data: _data || message + } + }) + .catch(() => { + // Ignore error if no client is connected + }); + } + + // ===== TOOLS ===== + + // Simple text tool + mcpServer.tool('test_simple_text', 'Tests simple text content response', async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + }); + + // Image content tool + mcpServer.tool('test_image_content', 'Tests image content response', async (): Promise => { + return { + content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] + }; + }); + + // Audio content tool + mcpServer.tool('test_audio_content', 'Tests audio content response', async (): Promise => { + return { + content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] + }; + }); + + // Embedded resource tool + mcpServer.tool('test_embedded_resource', 'Tests embedded resource content response', async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.' + } + } + ] + }; + }); + + // Multiple content types tool + mcpServer.tool( + 'test_multiple_content_types', + 'Tests response with multiple content types (text, image, resource)', + async (): Promise => { + return { + content: [ + { type: 'text', text: 'Multiple content types test:' }, + { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: JSON.stringify({ test: 'data', value: 123 }) + } + } + ] + }; + } + ); + + // Tool with logging + mcpServer.tool( + 'test_tool_with_logging', + 'Tests tool that emits log messages during execution', + {}, + async (_args, extra): Promise => { + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution started' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool processing data' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution completed' + } + }); + return { + content: [{ type: 'text', text: 'Tool with logging executed successfully' }] + }; + } + ); + + // Tool with progress + mcpServer.tool( + 'test_tool_with_progress', + 'Tests tool that reports progress notifications', + {}, + async (_args, extra): Promise => { + const progressToken = extra._meta?.progressToken ?? 0; + console.log('Progress token:', progressToken); + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 0, + total: 100, + message: `Completed step ${0} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100, + message: `Completed step ${50} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 100, + total: 100, + message: `Completed step ${100} of ${100}` + } + }); + + return { + content: [{ type: 'text', text: String(progressToken) }] + }; + } + ); + + // Error handling tool + mcpServer.tool('test_error_handling', 'Tests error response handling', async (): Promise => { + throw new Error('This tool intentionally returns an error for testing'); + }); + + // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection + mcpServer.tool( + 'test_reconnection', + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting test_reconnection tool...`); + + // Get the transport for this session + const transport = extra.sessionId ? transports[extra.sessionId] : undefined; + if (transport && extra.requestId) { + // Close the SSE stream to trigger client reconnection + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + transport.closeSSEStream(extra.requestId); + } + + // Wait for client to reconnect (should respect retry field) + await sleep(100); + + console.log(`[${extra.sessionId}] test_reconnection tool complete`); + + return { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' + } + ] + }; + } + ); + + // Sampling tool - requests LLM completion from client + mcpServer.tool( + 'test_sampling', + 'Tests server-initiated sampling (LLM completion request)', + { + prompt: z.string() + }, + async (args: { prompt: string }, extra): Promise => { + try { + // Request sampling from client + const result = (await extra.sendRequest( + { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: args.prompt + } + } + ], + maxTokens: 100 + } + }, + CreateMessageResultSchema + )) as { content?: { text?: string }; message?: { content?: { text?: string } } }; + + const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; + + return { + content: [ + { + type: 'text', + text: `LLM response: ${modelResponse}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // Elicitation tool - requests user input from client + mcpServer.tool( + 'test_elicitation', + 'Tests server-initiated elicitation (user input request)', + { + message: z.string().describe('The message to show the user') + }, + async (args: { message: string }, extra): Promise => { + try { + // Request user input from client + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: args.message, + requestedSchema: { + type: 'object', + properties: { + response: { + type: 'string', + description: "User's response" + } + }, + required: ['response'] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1034: Elicitation with default values for all primitive types + mcpServer.tool( + 'test_elicitation_sep1034_defaults', + 'Tests elicitation with default values per SEP-1034', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: 'Please review and update the form fields with defaults', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'User name', + default: 'John Doe' + }, + age: { + type: 'integer', + description: 'User age', + default: 30 + }, + score: { + type: 'number', + description: 'User score', + default: 95.5 + }, + status: { + type: 'string', + description: 'User status', + enum: ['active', 'inactive', 'pending'], + default: 'active' + }, + verified: { + type: 'boolean', + description: 'Verification status', + default: true + } + }, + required: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1330: Elicitation with enum schema improvements + mcpServer.tool( + 'test_elicitation_sep1330_enums', + 'Tests elicitation with enum schema improvements per SEP-1330', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + titledSingle: { + type: 'string', + description: 'Select one option with titles', + oneOf: [ + { const: 'value1', title: 'First Option' }, + { const: 'value2', title: 'Second Option' }, + { const: 'value3', title: 'Third Option' } + ] + }, + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['option1', 'option2', 'option3'] + } + }, + titledMulti: { + type: 'array', + description: 'Select multiple options with titles', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'value1', title: 'First Choice' }, + { const: 'value2', title: 'Second Choice' }, + { const: 'value3', title: 'Third Choice' } + ] + } + } + }, + required: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1613: JSON Schema 2020-12 conformance test tool + const addressSchema = z.object({ + street: z.string().optional(), + city: z.string().optional() + }); + mcpServer.tool( + 'json_schema_2020_12_tool', + 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + { + name: z.string().optional(), + address: addressSchema.optional() + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + return { + content: [ + { + type: 'text', + text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` + } + ] + }; + } + ); + + // ===== RESOURCES ===== + + // Static text resource + mcpServer.registerResource( + 'static-text', + 'test://static-text', + { + title: 'Static Text Resource', + description: 'A static text resource for testing', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'This is the content of the static text resource.' + } + ] + }; + } + ); + + // Static binary resource + mcpServer.registerResource( + 'static-binary', + 'test://static-binary', + { + title: 'Static Binary Resource', + description: 'A static binary resource (image) for testing', + mimeType: 'image/png' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-binary', + mimeType: 'image/png', + blob: TEST_IMAGE_BASE64 + } + ] + }; + } + ); + + // Resource template + mcpServer.registerResource( + 'template', + new ResourceTemplate('test://template/{id}/data', { list: undefined }), + { + title: 'Resource Template', + description: 'A resource template with parameter substitution', + mimeType: 'application/json' + }, + async (uri, variables): Promise => { + const id = variables.id; + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify({ + id, + templateTest: true, + data: `Data for ID: ${id}` + }) + } + ] + }; + } + ); + + // Watched resource + mcpServer.registerResource( + 'watched-resource', + 'test://watched-resource', + { + title: 'Watched Resource', + description: 'A resource that auto-updates every 3 seconds', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://watched-resource', + mimeType: 'text/plain', + text: watchedResourceContent + } + ] + }; + } + ); + + // Subscribe/Unsubscribe handlers + mcpServer.server.setRequestHandler(SubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.add(uri); + sendLog('info', `Subscribed to resource: ${uri}`); + return {}; + }); + + mcpServer.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.delete(uri); + sendLog('info', `Unsubscribed from resource: ${uri}`); + return {}; + }); + + // ===== PROMPTS ===== + + // Simple prompt + mcpServer.prompt('test_simple_prompt', 'A simple prompt without arguments', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' + } + } + ] + }; + }); + + // Prompt with arguments + mcpServer.prompt( + 'test_prompt_with_arguments', + 'A prompt with required arguments', + { + arg1: z.string().describe('First test argument'), + arg2: z.string().describe('Second test argument') + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` + } + } + ] + }; + } + ); + + // Prompt with embedded resource + mcpServer.prompt( + 'test_prompt_with_embedded_resource', + 'A prompt that includes an embedded resource', + { + resourceUri: z.string().describe('URI of the resource to embed') + }, + async (args: { resourceUri: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: args.resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.' + } + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'Please process the embedded resource above.' + } + } + ] + }; + } + ); + + // Prompt with image + mcpServer.prompt('test_prompt_with_image', 'A prompt that includes image content', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'image', + data: TEST_IMAGE_BASE64, + mimeType: 'image/png' + } + }, + { + role: 'user', + content: { type: 'text', text: 'Please analyze the image above.' } + } + ] + }; + }); + + // ===== LOGGING ===== + + mcpServer.server.setRequestHandler(SetLevelRequestSchema, async request => { + const level = request.params.level; + sendLog('info', `Log level set to: ${level}`); + return {}; + }); + + // ===== COMPLETION ===== + + mcpServer.server.setRequestHandler(CompleteRequestSchema, async () => { + return { + completion: { + values: [], + total: 0, + hasMore: false + } + }; + }); + + return mcpServer; +} + +// ===== EXPRESS APP ===== + +const app = express(); +app.use(express.json()); +app.use(localhostHostValidation()); + +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id'] + }) +); + +// Handle POST requests - stateful mode +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Invalid or missing session ID' + }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests - SSE streams for sessions +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing SSE stream for session ${sessionId}`); + } + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Handle DELETE requests - session termination +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); +}); diff --git a/test/conformance/src/helpers/conformanceOAuthProvider.ts b/test/conformance/src/helpers/conformanceOAuthProvider.ts new file mode 100644 index 000000000..7623fcc55 --- /dev/null +++ b/test/conformance/src/helpers/conformanceOAuthProvider.ts @@ -0,0 +1,87 @@ +import type { OAuthClientProvider } from '../../../../src/client/auth.js'; +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../../../src/shared/auth.js'; + +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + private _authCodePromise?: Promise; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadataUrl?: string | URL + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + get clientMetadataUrl(): string | undefined { + return this._clientMetadataUrl?.toString(); + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' + }); + + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } else { + throw new Error('No auth code in redirect URL'); + } + } else { + throw new Error(`No redirect location received, from '${authorizationUrl.toString()}'`); + } + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/test/conformance/src/helpers/logger.ts b/test/conformance/src/helpers/logger.ts new file mode 100644 index 000000000..8de9342bd --- /dev/null +++ b/test/conformance/src/helpers/logger.ts @@ -0,0 +1,27 @@ +/** + * Simple logger with configurable log levels. + * Set to 'error' in tests to suppress debug output. + */ + +export type LogLevel = 'debug' | 'error'; + +let currentLogLevel: LogLevel = 'debug'; + +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +export const logger = { + debug: (...args: unknown[]): void => { + if (currentLogLevel === 'debug') { + console.log(...args); + } + }, + error: (...args: unknown[]): void => { + console.error(...args); + } +}; diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts new file mode 100644 index 000000000..1112bb710 --- /dev/null +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -0,0 +1,84 @@ +import type { FetchLike } from '../../../../src/shared/transport.js'; +import type { Middleware } from '../../../../src/client/middleware.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '../../../../src/client/auth.js'; + +import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; + +export const handle401 = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL +): Promise => { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(`Authentication failed with result: ${result}`); + } + } +}; + +export const withOAuthRetry = ( + clientName: string, + baseUrl?: string | URL, + handle401Fn: typeof handle401 = handle401, + clientMetadataUrl?: string, + existingProvider?: ConformanceOAuthProvider +): Middleware => { + const provider = + existingProvider ?? + new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + }, + clientMetadataUrl + ); + return (next: FetchLike) => { + return async (input: string | URL, init?: RequestInit): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + if (response.status === 401 || response.status === 403) { + const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); + await handle401Fn(response, provider, next, serverUrl); + + response = await makeRequest(); + } + + if (response.status === 401 || response.status === 403) { + const url = typeof input === 'string' ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; +}; diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json new file mode 100644 index 000000000..5d51831c5 --- /dev/null +++ b/test/conformance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/test/experimental/tasks/server-streaming.test.ts b/test/experimental/tasks/server-streaming.test.ts new file mode 100644 index 000000000..938005b64 --- /dev/null +++ b/test/experimental/tasks/server-streaming.test.ts @@ -0,0 +1,570 @@ +/** + * Tests for experimental server streaming methods: createMessageStream and elicitInputStream. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Client } from '../../../src/client/index.js'; +import { Server } from '../../../src/server/index.js'; +import { InMemoryTransport } from '../../../src/inMemory.js'; +import { InMemoryTaskStore } from '../../../src/experimental/tasks/stores/in-memory.js'; +import { toArrayAsync } from '../../../src/shared/responseMessage.js'; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + type CreateMessageResult, + type ElicitResult, + type Task +} from '../../../src/types.js'; + +describe('createMessageStream', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + }).toThrow('Client does not support sampling tools capability'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [ + { role: 'user', content: { type: 'text', text: 'Hello' } }, + { + role: 'user', + content: [{ type: 'tool_result', toolUseId: 'test-id', content: [{ type: 'text', text: 'result' }] }] + } + ], + maxTokens: 100 + }); + }).toThrow('tool_result blocks are not matching any tool_use from the previous message'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + describe('terminal message guarantees', () => { + test('should yield exactly one terminal message for successful request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('result'); + + const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield error as terminal message when client returns error', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => { + throw new Error('Simulated client error'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield exactly one terminal message with result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).content).toBeDefined(); + } + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('non-task request minimality', () => { + test('should yield only result message for non-task request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + expect(messages.length).toBe(1); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + sampling: {}, + tasks: { + requests: { + sampling: { createMessage: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { + const result = { + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Task response' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream( + { + messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }], + maxTokens: 100 + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).model).toBe('test-model'); + } + + clientTaskStore.cleanup(); + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); +}); + +describe('elicitInputStream', () => { + let server: Server; + let client: Client; + let clientTransport: ReturnType[0]; + let serverTransport: ReturnType[1]; + + beforeEach(async () => { + server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {}, + url: {} + } + } + } + ); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await server.close().catch(() => {}); + await client.close().catch(() => {}); + }); + + test('should throw when client does not support form elicitation', async () => { + // Create client without form elicitation capability + const noFormClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const [noFormClientTransport, noFormServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noFormClient.connect(noFormClientTransport), server.connect(noFormServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { type: 'object', properties: {} } + }); + }).toThrow('Client does not support form elicitation.'); + + await noFormClient.close().catch(() => {}); + }); + + test('should throw when client does not support url elicitation', async () => { + // Create client without url elicitation capability + const noUrlClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [noUrlClientTransport, noUrlServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noUrlClient.connect(noUrlClientTransport), server.connect(noUrlServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'url', + message: 'Open URL', + elicitationId: 'test-123', + url: 'https://example.com/auth' + }); + }).toThrow('Client does not support url elicitation.'); + + await noUrlClient.close().catch(() => {}); + }); + + test('should default to form mode when mode is not specified', async () => { + const requestStreamSpy = vi.spyOn(server.experimental.tasks, 'requestStream'); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { value: 'test' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call without explicit mode - need to cast because TypeScript expects mode + const params = { + message: 'Enter value', + requestedSchema: { + type: 'object' as const, + properties: { value: { type: 'string' as const } } + } + }; + + const stream = server.experimental.tasks.elicitInputStream( + params as Parameters[0] + ); + await toArrayAsync(stream); + + // Verify mode was normalized to 'form' + expect(requestStreamSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'elicitation/create', + params: expect.objectContaining({ mode: 'form' }) + }), + expect.anything(), + undefined + ); + }); + + test('should yield error as terminal message when client returns error', async () => { + client.setRequestHandler(ElicitRequestSchema, () => { + throw new Error('Simulated client error'); + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + }); + + // For any streaming elicitation request, the AsyncGenerator yields exactly one terminal + // message (either 'result' or 'error') as its final message. + describe('terminal message guarantees', () => { + test.each([ + { action: 'accept' as const, content: { data: 'test-value' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield exactly one terminal message for action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Test message', + requestedSchema: { + type: 'object', + properties: { data: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Count terminal messages (result or error) + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + // Verify terminal message is the last message + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + // Verify result content matches expected action + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe(action); + } + }); + }); + + // For any non-task elicitation request, the generator yields exactly one 'result' message + // (or 'error' if the request fails), with no 'taskCreated' or 'taskStatus' messages. + describe('non-task request minimality', () => { + test.each([ + { action: 'accept' as const, content: { value: 'test' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield only result message for non-task request with action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Non-task request (no task option) + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Non-task request', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Verify no taskCreated or taskStatus messages + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + // Verify exactly one result message + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + // Verify total message count is 1 + expect(messages.length).toBe(1); + }); + }); + + // For any task-augmented elicitation request, the generator should yield at least one + // 'taskCreated' message followed by 'taskStatus' messages before yielding the final + // result or error. + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const taskClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { form: {} }, + tasks: { + requests: { + elicitation: { create: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + taskClient.setRequestHandler(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept' as const, + content: { username: 'task-user' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); + + const stream = server.experimental.tasks.elicitInputStream( + { + mode: 'form', + message: 'Task-augmented request', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'] + } + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe('accept'); + expect((lastMessage.result as ElicitResult).content).toEqual({ username: 'task-user' }); + } + + clientTaskStore.cleanup(); + await taskClient.close().catch(() => {}); + }); + }); +}); diff --git a/test/experimental/tasks/stores/in-memory.test.ts b/test/experimental/tasks/stores/in-memory.test.ts index ceef6c6d8..fd6bddc93 100644 --- a/test/experimental/tasks/stores/in-memory.test.ts +++ b/test/experimental/tasks/stores/in-memory.test.ts @@ -487,17 +487,16 @@ describe('InMemoryTaskStore', () => { expect(task).toBeNull(); }); - it('should support null TTL for unlimited lifetime', async () => { - // Test that null TTL means unlimited lifetime - const taskParams: TaskCreationParams = { - ttl: null - }; + it('should support omitted TTL for unlimited lifetime', async () => { + // Test that omitting TTL means unlimited lifetime (server returns null) + // Per spec: clients omit ttl to let server decide, server returns null for unlimited + const taskParams: TaskCreationParams = {}; const createdTask = await store.createTask(taskParams, 2222, { method: 'tools/call', params: {} }); - // The returned task should have null TTL + // The returned task should have null TTL (unlimited) expect(createdTask.ttl).toBeNull(); // Task should not be cleaned up even after a long time diff --git a/test/experimental/tasks/task.test.ts b/test/experimental/tasks/task.test.ts index 37e3938d2..de613a325 100644 --- a/test/experimental/tasks/task.test.ts +++ b/test/experimental/tasks/task.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { isTerminal } from '../../../src/experimental/tasks/interfaces.js'; import type { Task } from '../../../src/types.js'; +import { TaskCreationParamsSchema } from '../../../src/types.js'; describe('Task utility functions', () => { describe('isTerminal', () => { @@ -115,3 +116,30 @@ describe('Task Schema Validation', () => { }); }); }); + +describe('TaskCreationParams Schema Validation', () => { + it('should accept ttl as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60000 }); + expect(result.success).toBe(true); + }); + + it('should accept missing ttl (optional)', () => { + const result = TaskCreationParamsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('should reject null ttl (not allowed in request, only response)', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: null }); + expect(result.success).toBe(false); + }); + + it('should accept pollInterval as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ pollInterval: 1000 }); + expect(result.success).toBe(true); + }); + + it('should accept both ttl and pollInterval', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60000, pollInterval: 1000 }); + expect(result.success).toBe(true); + }); +}); diff --git a/test/integration-tests/stateManagementStreamableHttp.test.ts b/test/integration-tests/stateManagementStreamableHttp.test.ts index d79d95c75..672bfb92f 100644 --- a/test/integration-tests/stateManagementStreamableHttp.test.ts +++ b/test/integration-tests/stateManagementStreamableHttp.test.ts @@ -17,9 +17,11 @@ import { listenOnRandomPort } from '../helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; describe('Streamable HTTP Transport Session Management', () => { - // Function to set up the server with optional session management - async function setupServer(withSessionManagement: boolean) { - const server: Server = createServer(); + /** + * Helper to create and configure a fresh McpServer instance with standard + * resources, prompts, and tools for testing. + */ + function createMcpServer(): McpServer { const mcpServer = new McpServer( { name: 'test-server', version: '1.0.0' }, { @@ -67,43 +69,67 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } ); - // Create transport with or without session management - const serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: withSessionManagement - ? () => randomUUID() // With session management, generate UUID - : undefined // Without session management, return undefined - }); + return mcpServer; + } - await mcpServer.connect(serverTransport); + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean): Promise<{ + server: Server; + mcpServer?: McpServer; + serverTransport?: StreamableHTTPServerTransport; + baseUrl: URL; + }> { + const server: Server = createServer(); - server.on('request', async (req, res) => { - await serverTransport.handleRequest(req, res); - }); + if (withSessionManagement) { + // Stateful mode: single transport + server for the session + const mcpServer = createMcpServer(); + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await mcpServer.connect(serverTransport); - // Start the server on a random port - const baseUrl = await listenOnRandomPort(server); + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await listenOnRandomPort(server); + + return { server, mcpServer, serverTransport, baseUrl }; + } else { + // Stateless mode: create a fresh transport + server per request + // to comply with the guard that stateless transports cannot be reused. + server.on('request', async (req, res) => { + const mcpServer = createMcpServer(); + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await mcpServer.connect(serverTransport); + await serverTransport.handleRequest(req, res); + // Close the per-request mcpServer after handling to avoid leaks + await mcpServer.close(); + }); - return { server, mcpServer, serverTransport, baseUrl }; + // Start the server on a random port + const baseUrl = await listenOnRandomPort(server); + + return { server, baseUrl }; + } } describe('Stateless Mode', () => { let server: Server; - let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; let baseUrl: URL; beforeEach(async () => { const setup = await setupServer(false); server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; baseUrl = setup.baseUrl; }); afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); server.close(); }); @@ -259,8 +285,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { beforeEach(async () => { const setup = await setupServer(true); server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; + mcpServer = setup.mcpServer!; + serverTransport = setup.serverTransport!; baseUrl = setup.baseUrl; }); diff --git a/test/server/auth/handlers/authorize.test.ts b/test/server/auth/handlers/authorize.test.ts index 0f831ae7d..f4d68d4df 100644 --- a/test/server/auth/handlers/authorize.test.ts +++ b/test/server/auth/handlers/authorize.test.ts @@ -1,4 +1,4 @@ -import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js'; +import { authorizationHandler, AuthorizationHandlerOptions, redirectUriMatches } from '../../../../src/server/auth/handlers/authorize.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js'; @@ -23,6 +23,14 @@ describe('Authorization Handler', () => { scope: 'profile email' }; + // Native app client with a portless loopback redirect (e.g., from CIMD / SEP-991) + const loopbackClient: OAuthClientInformationFull = { + client_id: 'loopback-client', + client_secret: 'valid-secret', + redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'], + scope: 'profile email' + }; + // Mock client store const mockClientStore: OAuthRegisteredClientsStore = { async getClient(clientId: string): Promise { @@ -30,6 +38,8 @@ describe('Authorization Handler', () => { return validClient; } else if (clientId === 'multi-redirect-client') { return multiRedirectClient; + } else if (clientId === 'loopback-client') { + return loopbackClient; } return undefined; } @@ -172,6 +182,102 @@ describe('Authorization Handler', () => { const location = new URL(response.header.location); expect(location.origin + location.pathname).toBe('https://example.com/callback'); }); + + // RFC 8252 §7.3: authorization servers MUST allow any port for loopback + // redirect URIs. Native apps obtain ephemeral ports from the OS. + it('accepts loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.hostname).toBe('localhost'); + expect(location.port).toBe('53428'); + expect(location.pathname).toBe('/callback'); + }); + + it('accepts 127.0.0.1 loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://127.0.0.1:9000/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + }); + + it('rejects loopback redirect_uri with different path', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/evil', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('does not relax port for non-loopback redirect_uri', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com:8443/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + }); + + describe('redirectUriMatches (RFC 8252 §7.3)', () => { + it('exact match passes', () => { + expect(redirectUriMatches('https://example.com/cb', 'https://example.com/cb')).toBe(true); + }); + + it('loopback: any port matches portless registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost/callback')).toBe(true); + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:9000/cb', 'http://[::1]/cb')).toBe(true); + }); + + it('loopback: any port matches ported registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost:3118/callback')).toBe(true); + }); + + it('loopback: different path rejected', () => { + expect(redirectUriMatches('http://localhost:53428/evil', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: different scheme rejected', () => { + expect(redirectUriMatches('https://localhost:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: localhost↔127.0.0.1 cross-match rejected', () => { + // RFC 8252 relaxes port only, not host + expect(redirectUriMatches('http://127.0.0.1:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('non-loopback: port must match exactly', () => { + expect(redirectUriMatches('https://example.com:8443/cb', 'https://example.com/cb')).toBe(false); + }); + + it('non-loopback: no relaxation for private IPs', () => { + expect(redirectUriMatches('http://192.168.1.1:8080/cb', 'http://192.168.1.1/cb')).toBe(false); + }); + + it('malformed URIs rejected', () => { + expect(redirectUriMatches('not a url', 'http://localhost/cb')).toBe(false); + expect(redirectUriMatches('http://localhost/cb', 'not a url')).toBe(false); + }); }); describe('Authorization request validation', () => { diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index f6c2124e1..575d6a300 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -198,6 +198,60 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { message: 'Completed step 3 of 3' }); }); + + /*** + * Test: Extensions capability registration + */ + test('should register and advertise server extensions capability', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.server.registerCapabilities({ + extensions: { + 'io.modelcontextprotocol/test-extension': { listChanged: true } + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ listChanged: true }); + }); + + test('should advertise client extensions capability to server', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + extensions: { + 'io.modelcontextprotocol/test-extension': { streaming: true } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = mcpServer.server.getClientCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ streaming: true }); + }); }); describe('ResourceTemplate', () => { @@ -2050,6 +2104,37 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Clean up spies warnSpy.mockRestore(); }); + + test('should reject plain JSON Schema objects passed as inputSchema', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const jsonSchema = { + type: 'object', + properties: { + directory_id: { + type: 'string', + format: 'uuid', + description: 'The UUID of the directory' + } + }, + required: ['directory_id'] + } as any; + + const cb = async ({ directory_id }: any) => ({ + content: [{ type: 'text' as const, text: `Got: ${directory_id}` }] + }); + + expect(() => { + mcpServer.tool('test', 'A tool', jsonSchema, cb); + }).toThrow(/unrecognized object/); + + expect(() => { + mcpServer.registerTool('test', { description: 'A tool', inputSchema: jsonSchema }, cb); + }).toThrow(/unrecognized object/); + }); }); describe('resource()', () => { diff --git a/test/server/sse.test.ts b/test/server/sse.test.ts index 4686f2ba9..0e996d1d6 100644 --- a/test/server/sse.test.ts +++ b/test/server/sse.test.ts @@ -19,10 +19,15 @@ const createMockResponse = () => { return res as unknown as Mocked; }; -const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { +const createMockRequest = ({ + headers = {}, + body, + url = '/messages' +}: { headers?: Record; body?: string; url?: string } = {}) => { const mockReq = { headers, body: body ? body : undefined, + url, auth: { token: 'test-token' }, @@ -312,7 +317,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': 'node', 'accept-encoding': 'gzip, deflate', 'content-length': '124' - } + }, + url: `http://127.0.0.1:${serverPort}/?sessionId=${sessionId}` }) } ] @@ -387,7 +393,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { id: 1 }); const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, + headers: { host: 'localhost', 'content-type': 'application/json' }, body: validMessage }); const mockRes = createMockResponse(); @@ -416,8 +422,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, requestInfo: { headers: { + host: 'localhost', 'content-type': 'application/json' - } + }, + url: new URL('http://localhost/messages') } } ); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 36a12ca9c..4a4f7d824 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -2,6 +2,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from 'node import { AddressInfo, createServer as netCreateServer } from 'node:net'; import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from '../../src/server/streamableHttp.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/webStandardStreamableHttp.js'; import { McpServer } from '../../src/server/mcp.js'; import { CallToolResult, JSONRPCMessage } from '../../src/types.js'; import { AuthInfo } from '../../src/server/auth/types.js'; @@ -443,7 +444,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': expect.any(String), 'accept-encoding': expect.any(String), 'content-length': expect.any(String) - } + }, + url: baseUrl.toString() }); }); @@ -1529,20 +1531,56 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Test stateless mode describe('StreamableHTTPServerTransport in stateless mode', () => { let server: Server; - let transport: StreamableHTTPServerTransport; let baseUrl: URL; + // In stateless mode, each request must use a fresh transport + server pair. + // The HTTP server creates these per-request and delegates accordingly. beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + server = createServer(async (req, res) => { + try { + const { transport, mcpServer } = await createStatelessHandler(); + await transport.handleRequest(req, res); + // Close the per-request mcpServer after handling to avoid leaks + await mcpServer.close(); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + baseUrl = await listenOnRandomPort(server); }); afterEach(async () => { - await stopTestServer({ server, transport }); + server.close(); }); + /** + * Creates a fresh transport + mcpServer pair for a single stateless request. + */ + async function createStatelessHandler(): Promise<{ + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { name: z.string().describe('Name to greet') }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + await mcpServer.connect(transport); + + return { transport, mcpServer }; + } + it('should operate without session ID validation', async () => { // Initialize the server first const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); @@ -1552,6 +1590,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(initResponse.headers.get('mcp-session-id')).toBeNull(); // Try request without session ID - should work in stateless mode + // (a fresh transport is created per request) const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); expect(toolsResponse.status).toBe(200); @@ -1585,14 +1624,14 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(response2.status).toBe(200); }); - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time + it('should allow multiple SSE streams in stateless mode with per-request transports', async () => { + // Each request gets its own transport, so multiple SSE streams can + // coexist since they are handled by separate transport instances // Initialize the server first await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Open first SSE stream + // Open first SSE stream - this uses its own per-request transport const stream1 = await fetch(baseUrl, { method: 'GET', headers: { @@ -1602,7 +1641,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); expect(stream1.status).toBe(200); - // Open second SSE stream - should still be rejected, stateless mode still only allows one + // Open second SSE stream - also gets its own per-request transport, + // so it should also succeed (each transport only handles one request) const stream2 = await fetch(baseUrl, { method: 'GET', headers: { @@ -1610,7 +1650,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'mcp-protocol-version': '2025-11-25' } }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed + // With per-request transports in stateless mode, each GET gets its own + // transport, so the second one also succeeds + expect(stream2.status).toBe(200); }); }); @@ -2868,17 +2910,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { describe('Combined validations', () => { it('should validate both host and origin when both are configured', async () => { - const result = await createTestServerWithDnsProtection({ + // In stateless mode, each request needs a fresh transport, so we + // test invalid and valid origins with separate server instances. + + // Test with invalid origin + const result1 = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3001'], enableDnsRebindingProtection: true }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + server = result1.server; + transport = result1.transport; + baseUrl = result1.baseUrl; - // Test with invalid origin (host will be automatically correct via fetch) const response1 = await fetch(baseUrl, { method: 'POST', headers: { @@ -2893,7 +2938,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const body1 = await response1.json(); expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); - // Test with valid origin + // Clean up first server + await stopTestServer({ server, transport }); + + // Test with valid origin using a fresh server+transport + const result2 = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true + }); + server = result2.server; + transport = result2.transport; + baseUrl = result2.baseUrl; + const response2 = await fetch(baseUrl, { method: 'POST', headers: { @@ -2910,6 +2968,89 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); +describe('StreamableHTTPServerTransport global Response preservation', () => { + it('should not override the global Response object', () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response (similar to Next.js's NextResponse) + class CustomResponse extends Response { + customProperty = 'test'; + } + + // Verify instanceof works before creating transport + const customResponseBefore = new CustomResponse('test body'); + expect(customResponseBefore instanceof Response).toBe(true); + expect(customResponseBefore instanceof OriginalResponse).toBe(true); + + // Create the transport - this should NOT override globalThis.Response + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Verify the global Response is still the original + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works after creating transport + const customResponseAfter = new CustomResponse('test body'); + expect(customResponseAfter instanceof Response).toBe(true); + expect(customResponseAfter instanceof OriginalResponse).toBe(true); + + // Verify that instances created before transport initialization still work + expect(customResponseBefore instanceof Response).toBe(true); + + // Clean up + transport.close(); + }); + + it('should not override the global Response object when calling handleRequest', async () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response + class CustomResponse extends Response { + customProperty = 'test'; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Create a mock server to test handleRequest + const port = await getFreePort(); + const httpServer = createServer(async (req, res) => { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + }); + + await new Promise(resolve => { + httpServer.listen(port, () => resolve()); + }); + + try { + // Make a request to trigger handleRequest + await fetch(`http://localhost:${port}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Verify the global Response is still the original after handleRequest + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works + const customResponse = new CustomResponse('test body'); + expect(customResponse instanceof Response).toBe(true); + expect(customResponse instanceof OriginalResponse).toBe(true); + } finally { + await transport.close(); + httpServer.close(); + } + }); +}); + /** * Helper to create test server with DNS rebinding protection options */ @@ -2972,3 +3113,162 @@ async function createTestServerWithDnsProtection(config: { baseUrl: serverUrl }; } + +describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let onerrorSpy: ReturnType void>>; + + /** Shorthand to build a Web Standard Request for direct transport testing. */ + function req(method: string, opts?: { body?: unknown; headers?: Record }): Request { + const headers: Record = { ...opts?.headers }; + if (method === 'POST') { + headers['Accept'] ??= 'application/json, text/event-stream'; + headers['Content-Type'] ??= 'application/json'; + } else if (method === 'GET') { + headers['Accept'] ??= 'text/event-stream'; + } + return new Request('http://localhost/mcp', { + method, + headers, + body: opts?.body !== undefined ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined + }); + } + + function withSession(sessionId: string, extra?: Record): Record { + return { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', ...extra }; + } + + beforeEach(async () => { + onerrorSpy = vi.fn<(error: Error) => void>(); + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + transport.onerror = onerrorSpy; + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + onerrorSpy.mockClear(); + const response = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(response.status).toBe(200); + return response.headers.get('mcp-session-id') as string; + } + + it('should call onerror for invalid JSON in POST', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: 'not valid json' })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON/); + }); + + it('should call onerror for invalid JSON-RPC message', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('POST', { body: { not: 'valid' }, headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON-RPC message/); + }); + + it('should call onerror for missing Accept header on POST', async () => { + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for unsupported Content-Type', async () => { + await transport.handleRequest( + req('POST', { + body: TEST_MESSAGES.initialize, + headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'text/plain' } + }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported Media Type/); + }); + + it('should call onerror when server is not initialized', async () => { + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList })); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server not initialized/); + }); + + it('should call onerror for invalid session ID', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession('invalid-session-id') })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Session not found/); + }); + + it('should call onerror for re-initialization attempt', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server already initialized/); + }); + + it('should call onerror for missing Accept header on GET', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('GET', { headers: { Accept: 'application/json', ...withSession(sid) } })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for concurrent SSE streams', async () => { + const sid = await initializeServer(); + const response1 = await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(response1.status).toBe(200); + await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Only one SSE stream/); + }); + + it('should call onerror for unsupported protocol version', async () => { + const sid = await initializeServer(); + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession(sid, { 'mcp-protocol-version': 'unsupported-version' }) }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported protocol version/); + }); + + it('should call onerror for unsupported HTTP methods', async () => { + await transport.handleRequest(req('PUT')); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Method not allowed/); + }); + + it('should call onerror for invalid event ID in replay', async () => { + const eventStore: EventStore = { + async storeEvent(): Promise { + return 'evt-1'; + }, + async getStreamIdForEventId(): Promise { + return undefined; + }, + async replayEventsAfter(): Promise { + return 'stream-1'; + } + }; + const storeTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore }); + const storeSpy = vi.fn<(error: Error) => void>(); + storeTransport.onerror = storeSpy; + await new McpServer({ name: 'test', version: '1.0.0' }).connect(storeTransport); + + const initResp = await storeTransport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + const sid = initResp.headers.get('mcp-session-id') as string; + storeSpy.mockClear(); + + const response = await storeTransport.handleRequest( + req('GET', { headers: { ...withSession(sid), 'Last-Event-ID': 'unknown-event-id' } }) + ); + expect(response.status).toBe(400); + expect(storeSpy).toHaveBeenCalledTimes(1); + expect(storeSpy.mock.calls[0]![0]!.message).toMatch(/Invalid event ID format/); + await storeTransport.close(); + }); +}); diff --git a/test/shared/protocol-transport-handling.test.ts b/test/shared/protocol-transport-handling.test.ts index 60eff5c2e..1e698481f 100644 --- a/test/shared/protocol-transport-handling.test.ts +++ b/test/shared/protocol-transport-handling.test.ts @@ -27,29 +27,39 @@ class MockTransport implements Transport { } } -describe('Protocol transport handling bug', () => { - let protocol: Protocol; +function createProtocol(): Protocol { + return new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); +} + +describe('Protocol transport handling', () => { let transportA: MockTransport; let transportB: MockTransport; beforeEach(() => { - protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - transportA = new MockTransport('A'); transportB = new MockTransport('B'); }); - test('should send response to the correct transport when multiple clients are connected', async () => { - // Set up a request handler that simulates processing time - let resolveHandler: (value: Result) => void; - const handlerPromise = new Promise(resolve => { - resolveHandler = resolve; + test('should send response to the correct transport when using separate protocol instances', async () => { + const protocolA = createProtocol(); + const protocolB = createProtocol(); + + // Each protocol gets its own resolver so we can verify responses route correctly + let resolveA: (value: Result) => void; + let resolveB: (value: Result) => void; + let handlerAEnteredResolve: () => void; + let handlerBEnteredResolve: () => void; + const handlerAEntered = new Promise(resolve => { + handlerAEnteredResolve = resolve; + }); + const handlerBEntered = new Promise(resolve => { + handlerBEnteredResolve = resolve; }); const TestRequestSchema = z.object({ @@ -61,13 +71,22 @@ describe('Protocol transport handling bug', () => { .optional() }); - protocol.setRequestHandler(TestRequestSchema, async request => { - console.log(`Processing request from ${request.params?.from}`); - return handlerPromise; + protocolA.setRequestHandler(TestRequestSchema, async () => { + return new Promise(resolve => { + resolveA = resolve; + handlerAEnteredResolve(); + }); + }); + + protocolB.setRequestHandler(TestRequestSchema, async () => { + return new Promise(resolve => { + resolveB = resolve; + handlerBEnteredResolve(); + }); }); // Client A connects and sends a request - await protocol.connect(transportA); + await protocolA.connect(transportA); const requestFromA = { jsonrpc: '2.0' as const, @@ -79,9 +98,8 @@ describe('Protocol transport handling bug', () => { // Simulate client A sending a request transportA.onmessage?.(requestFromA); - // While A's request is being processed, client B connects - // This overwrites the transport reference in the protocol - await protocol.connect(transportB); + // Client B connects to a separate protocol instance + await protocolB.connect(transportB); const requestFromB = { jsonrpc: '2.0' as const, @@ -93,19 +111,18 @@ describe('Protocol transport handling bug', () => { // Client B sends its own request transportB.onmessage?.(requestFromB); - // Now complete A's request - resolveHandler!({ data: 'responseForA' } as Result); + // Wait for both handlers to be invoked so resolvers are captured + await handlerAEntered; + await handlerBEntered; - // Wait for async operations to complete - await new Promise(resolve => setTimeout(resolve, 10)); + // Resolve each handler with distinct data + resolveA!({ data: 'responseForA' } as Result); + resolveB!({ data: 'responseForB' } as Result); - // Check where the responses went - console.log('Transport A received:', transportA.sentMessages); - console.log('Transport B received:', transportB.sentMessages); - - // FIXED: Each transport now receives its own response + // Wait for response delivery (transport.send is async) + await new Promise(resolve => setTimeout(resolve, 10)); - // Transport A should receive response for request ID 1 + // Each transport receives its own response expect(transportA.sentMessages.length).toBe(1); expect(transportA.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', @@ -113,18 +130,17 @@ describe('Protocol transport handling bug', () => { result: { data: 'responseForA' } }); - // Transport B should only receive its own response (when implemented) expect(transportB.sentMessages.length).toBe(1); expect(transportB.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', id: 2, - result: { data: 'responseForA' } // Same handler result in this test + result: { data: 'responseForB' } }); }); - test('demonstrates the timing issue with multiple rapid connections', async () => { - const delays: number[] = []; - const results: { transport: string; response: JSONRPCMessage[] }[] = []; + test('demonstrates isolation with separate protocol instances for rapid connections', async () => { + const protocolA = createProtocol(); + const protocolB = createProtocol(); const DelayedRequestSchema = z.object({ method: z.literal('test/delayed'), @@ -136,21 +152,20 @@ describe('Protocol transport handling bug', () => { .optional() }); - // Set up handler with variable delay - protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { - const delay = request.params?.delay || 0; - delays.push(delay); - - await new Promise(resolve => setTimeout(resolve, delay)); - - return { - processedBy: `handler-${extra.requestId}`, - delay: delay - } as Result; - }); + // Set up handler with variable delay on each protocol + for (const protocol of [protocolA, protocolB]) { + protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { + const delay = request.params?.delay || 0; + await new Promise(resolve => setTimeout(resolve, delay)); + return { + processedBy: `handler-${extra.requestId}`, + delay: delay + } as Result; + }); + } - // Rapid succession of connections and requests - await protocol.connect(transportA); + // Connect and send requests + await protocolA.connect(transportA); transportA.onmessage?.({ jsonrpc: '2.0' as const, method: 'test/delayed', @@ -160,7 +175,7 @@ describe('Protocol transport handling bug', () => { // Connect B while A is processing setTimeout(async () => { - await protocol.connect(transportB); + await protocolB.connect(transportB); transportB.onmessage?.({ jsonrpc: '2.0' as const, method: 'test/delayed', @@ -172,18 +187,81 @@ describe('Protocol transport handling bug', () => { // Wait for all processing await new Promise(resolve => setTimeout(resolve, 100)); - // Collect results - if (transportA.sentMessages.length > 0) { - results.push({ transport: 'A', response: transportA.sentMessages }); - } - if (transportB.sentMessages.length > 0) { - results.push({ transport: 'B', response: transportB.sentMessages }); - } - - console.log('Timing test results:', results); - - // FIXED: Each transport receives its own responses + // Each transport receives its own responses expect(transportA.sentMessages.length).toBe(1); expect(transportB.sentMessages.length).toBe(1); }); + + test('connect guard throws when calling connect() twice without closing', async () => { + const protocol = createProtocol(); + + await protocol.connect(transportA); + + await expect(protocol.connect(transportB)).rejects.toThrow( + 'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.' + ); + }); + + test('connect succeeds after calling close() first', async () => { + const protocol = createProtocol(); + + await protocol.connect(transportA); + await protocol.close(); + + // Should succeed without error + await expect(protocol.connect(transportB)).resolves.toBeUndefined(); + }); + + test('close() aborts in-flight request handlers', async () => { + const protocol = createProtocol(); + + const SlowRequestSchema = z.object({ + method: z.literal('test/slow') + }); + + let capturedSignal: AbortSignal | undefined; + let capturedSendNotification: ((notification: Notification) => Promise) | undefined; + let resolveHandler: () => void; + const handlerBlocking = new Promise(resolve => { + resolveHandler = resolve; + }); + + protocol.setRequestHandler(SlowRequestSchema, async (_request, extra) => { + capturedSignal = extra.signal; + capturedSendNotification = extra.sendNotification; + // Block the handler until we release it + await handlerBlocking; + return {} as Result; + }); + + await protocol.connect(transportA); + + // Send a request to trigger the handler + transportA.onmessage?.({ + jsonrpc: '2.0' as const, + method: 'test/slow', + id: 1 + }); + + // Wait for the handler to start and capture the signal + await new Promise(resolve => setTimeout(resolve, 10)); + expect(capturedSignal).toBeDefined(); + expect(capturedSignal!.aborted).toBe(false); + + // Close the protocol while the handler is still in-flight + await protocol.close(); + + // The signal should now be aborted + expect(capturedSignal!.aborted).toBe(true); + + // sendNotification should be a no-op after close (no error thrown) + await expect(capturedSendNotification!({ method: 'notifications/test' } as Notification)).resolves.toBeUndefined(); + + // No notification should have been sent to the transport + const notifications = transportA.sentMessages.filter((m: JSONRPCMessage) => 'method' in m && m.method === 'notifications/test'); + expect(notifications).toHaveLength(0); + + // Release the handler so the promise chain completes + resolveHandler!(); + }); }); diff --git a/test/shared/protocol.test.ts b/test/shared/protocol.test.ts index 886dcbb21..733146f29 100644 --- a/test/shared/protocol.test.ts +++ b/test/shared/protocol.test.ts @@ -5556,3 +5556,162 @@ describe('Error handling for missing resolvers', () => { }); }); }); + +describe('_onclose cleanup', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + vi.useFakeTimers(); + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('should clear pending timeouts in _onclose to prevent spurious cancellation after reconnect', async () => { + await protocol.connect(transport); + + // Start a request with a long timeout + const request = { method: 'example', params: {} }; + const mockSchema = z.object({ result: z.string() }); + + const requestPromise = protocol + .request(request, mockSchema, { + timeout: 60000 + }) + .catch(() => { + /* expected ConnectionClosed error */ + }); + + // Verify the request was sent + expect(sendSpy).toHaveBeenCalled(); + + // Spy on clearTimeout to verify it gets called during close + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + // Close the transport - this should clear timeouts + await transport.close(); + + // Verify clearTimeout was called (at least once for our timeout) + expect(clearTimeoutSpy).toHaveBeenCalled(); + + // Now reconnect with a new transport + const transport2 = new MockTransport(); + const sendSpy2 = vi.spyOn(transport2, 'send'); + await protocol.connect(transport2); + + // Advance past the original timeout - if not cleared, this would fire the callback + await vi.advanceTimersByTimeAsync(60000); + + // Verify no spurious cancellation notification was sent to the new transport + const cancellationCalls = sendSpy2.mock.calls.filter(call => { + const msg = call[0] as Record; + return msg.method === 'notifications/cancelled'; + }); + expect(cancellationCalls).toHaveLength(0); + + await transport2.close(); + await requestPromise; + clearTimeoutSpy.mockRestore(); + }); + + test('should not let stale .finally() delete a new connections abort controller after reconnect', async () => { + await protocol.connect(transport); + + const TestRequestSchema = z.object({ + method: z.literal('test/longRunning'), + params: z.optional(z.record(z.unknown())) + }); + + // Set up a handler with a deferred resolution we control + let resolveHandler!: () => void; + const handlerStarted = new Promise(resolve => { + protocol.setRequestHandler(TestRequestSchema, async () => { + resolve(); // signal that handler has started + // Wait for explicit resolution + await new Promise(r => { + resolveHandler = r; + }); + return { _meta: {} } as Result; + }); + }); + + // Simulate an incoming request with id=1 on the first connection + const requestId = 1; + transport.onmessage!({ + jsonrpc: '2.0', + id: requestId, + method: 'test/longRunning', + params: {} + }); + + // Wait for handler to start + await handlerStarted; + + // Close the connection (aborts the controller and clears the map) + await transport.close(); + + // Reconnect with a new transport + const transport2 = new MockTransport(); + await protocol.connect(transport2); + + // Set up a new handler for the second connection that we can verify cancellation on + let wasAborted = false; + let resolveHandler2!: () => void; + const handler2Started = new Promise(resolve => { + protocol.setRequestHandler(TestRequestSchema, async (_request, extra) => { + resolve(); + await new Promise(r => { + resolveHandler2 = r; + }); + wasAborted = extra.signal.aborted; + return { _meta: {} } as Result; + }); + }); + + // Simulate same request id=1 on the new connection + transport2.onmessage!({ + jsonrpc: '2.0', + id: requestId, + method: 'test/longRunning', + params: {} + }); + + await handler2Started; + + // Resolve the OLD handler so its .finally() runs + resolveHandler(); + // Flush microtasks so .finally() executes + await vi.advanceTimersByTimeAsync(0); + + // Send cancellation for request id=1 on the new connection. + // If the old .finally() incorrectly deleted the new controller, this won't work. + transport2.onmessage!({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: requestId, + reason: 'test cancel' + } + }); + + // Resolve handler2 so it can check the abort signal + resolveHandler2(); + await vi.advanceTimersByTimeAsync(0); + + expect(wasAborted).toBe(true); + + await transport2.close(); + }); +}); diff --git a/test/shared/uriTemplate.test.ts b/test/shared/uriTemplate.test.ts index ec913c0db..5bd54d2cf 100644 --- a/test/shared/uriTemplate.test.ts +++ b/test/shared/uriTemplate.test.ts @@ -284,5 +284,32 @@ describe('UriTemplate', () => { vars[longName] = 'value'; expect(() => template.expand(vars)).not.toThrow(); }); + + it('should not be vulnerable to ReDoS with exploded path patterns', () => { + // Test for ReDoS vulnerability (CVE-2026-0621) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/965 + const template = new UriTemplate('{/id*}'); + const maliciousPayload = '/' + ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); + + it('should not be vulnerable to ReDoS with exploded simple patterns', () => { + // Test for ReDoS vulnerability with simple exploded operator + const template = new UriTemplate('{id*}'); + const maliciousPayload = ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); }); }); diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 4b712da77..29b41d1d5 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -6,5 +6,5 @@ "outDir": "./dist/cjs" }, "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*", "src/examples/**/*"] } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 82710bd6a..7c4ab9928 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -4,5 +4,5 @@ "outDir": "./dist/esm" }, "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*", "src/examples/**/*"] }