diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b diff --git a/CLAUDE.md b/CLAUDE.md index 8e084134..272c4d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ # MCP TypeScript SDK Guide ## Build & Test Commands -``` + +```sh npm run build # Build ESM and CJS versions npm run lint # Run ESLint npm test # Run all tests @@ -10,6 +11,7 @@ npx jest -t "test name" # Run tests matching pattern ``` ## Code Style Guidelines + - **TypeScript**: Strict type checking, ES modules, explicit return types - **Naming**: PascalCase for classes/types, camelCase for functions/variables - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix @@ -20,6 +22,7 @@ npx jest -t "test name" # Run tests matching pattern - **Comments**: JSDoc for public APIs, inline comments for complex logic ## Project Structure + - `/src`: Source code with client, server, and shared modules - Tests alongside source files with `.test.ts` suffix -- Node.js >= 18 required \ No newline at end of file +- Node.js >= 18 required diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 05c32c60..985a2856 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -mcp-coc@anthropic.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/README.md b/README.md index af3d2e29..9b149403 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk) ## Table of Contents + - [Overview](#overview) - [Installation](#installation) -- [Quickstart](#quickstart) +- [Quickstart](#quick-start) - [What is MCP?](#what-is-mcp) - [Core Concepts](#core-concepts) - [Server](#server) - [Resources](#resources) - [Tools](#tools) - [Prompts](#prompts) + - [Completions](#completions) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -18,11 +20,14 @@ - [Echo Server](#echo-server) - [SQLite Explorer](#sqlite-explorer) - [Advanced Usage](#advanced-usage) + - [Dynamic Servers](#dynamic-servers) - [Low-Level Server](#low-level-server) - [Writing MCP Clients](#writing-mcp-clients) - - [Server Capabilities](#server-capabilities) - - [Proxy OAuth Server](#proxy-authorization-requests-upstream) + - [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream) - [Backwards Compatibility](#backwards-compatibility) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) ## Overview @@ -52,22 +57,30 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ - name: "Demo", + name: "demo-server", version: "1.0.0" }); // Add an addition tool -server.tool("add", - { a: z.number(), b: z.number() }, +server.registerTool("add", + { + title: "Addition Tool", + description: "Add two numbers", + inputSchema: { a: z.number(), b: z.number() } + }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }) ); // Add a dynamic greeting resource -server.resource( +server.registerResource( "greeting", new ResourceTemplate("greeting://{name}", { list: undefined }), + { + title: "Greeting Resource", // Display name for UI + description: "Dynamic greeting generator" + }, async (uri, { name }) => ({ contents: [{ uri: uri.href, @@ -98,7 +111,7 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "My App", + name: "my-app", version: "1.0.0" }); ``` @@ -109,9 +122,14 @@ Resources are how you expose data to LLMs. They're similar to GET endpoints in a ```typescript // Static resource -server.resource( +server.registerResource( "config", "config://app", + { + title: "Application Config", + description: "Application configuration data", + mimeType: "text/plain" + }, async (uri) => ({ contents: [{ uri: uri.href, @@ -121,9 +139,13 @@ server.resource( ); // Dynamic resource with parameters -server.resource( +server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, async (uri, { userId }) => ({ contents: [{ uri: uri.href, @@ -131,6 +153,33 @@ server.resource( }] }) ); + +// Resource with context-aware completion +server.registerResource( + "repository", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + // Provide intelligent completions based on previously resolved parameters + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } + return ["default-repo"].filter(r => r.startsWith(value)); + } + } + }), + { + title: "GitHub Repository", + description: "Repository information" + }, + async (uri, { owner, repo }) => ({ + contents: [{ + uri: uri.href, + text: `Repository: ${owner}/${repo}` + }] + }) +); ``` ### Tools @@ -139,11 +188,15 @@ Tools let LLMs take actions through your server. Unlike resources, tools are exp ```typescript // Simple tool with parameters -server.tool( +server.registerTool( "calculate-bmi", { - weightKg: z.number(), - heightM: z.number() + title: "BMI Calculator", + description: "Calculate Body Mass Index", + inputSchema: { + weightKg: z.number(), + heightM: z.number() + } }, async ({ weightKg, heightM }) => ({ content: [{ @@ -154,9 +207,13 @@ server.tool( ); // Async tool with external API call -server.tool( +server.registerTool( "fetch-weather", - { city: z.string() }, + { + title: "Weather Fetcher", + description: "Get weather data for a city", + inputSchema: { city: z.string() } + }, async ({ city }) => { const response = await fetch(`https://api.weather.com/${city}`); const data = await response.text(); @@ -165,16 +222,56 @@ server.tool( }; } ); + +// Tool that returns ResourceLinks +server.registerTool( + "list-files", + { + title: "List Files", + description: "List project files", + inputSchema: { pattern: z.string() } + }, + async ({ pattern }) => ({ + content: [ + { type: "text", text: `Found files matching "${pattern}":` }, + // ResourceLinks let tools return references without file content + { + type: "resource_link", + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown", + description: 'A README file' + }, + { + type: "resource_link", + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript", + description: 'An index file' + } + ] + }) +); ``` +#### ResourceLinks + +Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. + ### Prompts Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript -server.prompt( +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +server.registerPrompt( "review-code", - { code: z.string() }, + { + title: "Code Review", + description: "Review code for best practices and potential issues", + argsSchema: { code: z.string() } + }, ({ code }) => ({ messages: [{ role: "user", @@ -185,6 +282,106 @@ server.prompt( }] }) ); + +// Prompt with context-aware completion +server.registerPrompt( + "team-greeting", + { + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + department: completable(z.string(), (value) => { + // Department suggestions + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + // Name suggestions based on selected department + const department = context?.arguments?.["department"]; + if (department === "engineering") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (department === "sales") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }) + } + }, + ({ department, name }) => ({ + messages: [{ + role: "assistant", + content: { + type: "text", + text: `Hello ${name}, welcome to the ${department} team!` + } + }] + }) +); +``` + +### Completions + +MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts). + +#### Client Usage + +```typescript +// Request completions for any argument +const result = await client.complete({ + ref: { + type: "ref/prompt", // or "ref/resource" + name: "example" // or uri: "template://..." + }, + argument: { + name: "argumentName", + value: "partial" // What the user has typed so far + }, + context: { // Optional: Include previously resolved arguments + arguments: { + previousArg: "value" + } + } +}); + +``` + +### Display Names and Metadata + +All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. + +**Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. + +#### Title Precedence for Tools + +For tools specifically, there are two ways to specify a title: +- `title` field in the tool configuration +- `annotations.title` field (when using the older `tool()` method with annotations) + +The precedence order is: `title` → `annotations.title` → `name` + +```typescript +// Using registerTool (recommended) +server.registerTool("my_tool", { + title: "My Tool", // This title takes precedence + annotations: { + title: "Annotation Title" // This is ignored if title is set + } +}, handler); + +// Using tool with annotations (older API) +server.tool("my_tool", "description", { + title: "Annotation Title" // This is used as title +}, handler); +``` + +When building clients, use the provided utility to get the appropriate display name: + +```typescript +import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; + +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server @@ -249,7 +446,11 @@ app.post('/mcp', async (req, res) => { onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = transport; - } + }, + // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server + // locally, make sure to set: + // enableDnsRebindingProtection: true, + // allowedHosts: ['127.0.0.1'], }); // Clean up transport when closed @@ -305,6 +506,21 @@ app.delete('/mcp', handleSessionRequest); app.listen(3000); ``` +> [!TIP] +> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. +> +> For example, in Node.js you can configure it like this: +> +> ```ts +> app.use( +> cors({ +> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], +> exposedHeaders: ['mcp-session-id'], +> allowedHeaders: ['Content-Type', 'mcp-session-id'], +> }) +> ); +> ``` + #### Without Session Management (Stateless) For simpler use cases where session management isn't needed: @@ -345,6 +561,7 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// SSE notifications not supported in stateless mode app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET MCP request'); res.writeHead(405).end(JSON.stringify({ @@ -357,6 +574,7 @@ app.get('/mcp', async (req: Request, res: Response) => { })); }); +// Session termination not needed in stateless mode app.delete('/mcp', async (req: Request, res: Response) => { console.log('Received DELETE MCP request'); res.writeHead(405).end(JSON.stringify({ @@ -379,10 +597,27 @@ app.listen(PORT, () => { ``` This stateless approach is useful for: + - Simple API wrappers - RESTful scenarios where each request is independent - Horizontally scaled deployments without shared session state +#### DNS Rebinding Protection + +The Streamable HTTP transport includes DNS rebinding protection to prevent security vulnerabilities. By default, this protection is **disabled** for backwards compatibility. + +**Important**: If you are running this server locally, enable DNS rebinding protection: + +```typescript +const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableDnsRebindingProtection: true, + + allowedHosts: ['127.0.0.1', ...], + allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] +}); +``` + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. @@ -398,13 +633,17 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc import { z } from "zod"; const server = new McpServer({ - name: "Echo", + name: "echo-server", version: "1.0.0" }); -server.resource( +server.registerResource( "echo", new ResourceTemplate("echo://{message}", { list: undefined }), + { + title: "Echo Resource", + description: "Echoes back messages as resources" + }, async (uri, { message }) => ({ contents: [{ uri: uri.href, @@ -413,17 +652,25 @@ server.resource( }) ); -server.tool( +server.registerTool( "echo", - { message: z.string() }, + { + title: "Echo Tool", + description: "Echoes back the provided message", + inputSchema: { message: z.string() } + }, async ({ message }) => ({ content: [{ type: "text", text: `Tool echo: ${message}` }] }) ); -server.prompt( +server.registerPrompt( "echo", - { message: z.string() }, + { + title: "Echo Prompt", + description: "Creates a prompt to process a message", + argsSchema: { message: z.string() } + }, ({ message }) => ({ messages: [{ role: "user", @@ -447,7 +694,7 @@ import { promisify } from "util"; import { z } from "zod"; const server = new McpServer({ - name: "SQLite Explorer", + name: "sqlite-explorer", version: "1.0.0" }); @@ -460,9 +707,14 @@ const getDb = () => { }; }; -server.resource( +server.registerResource( "schema", "schema://main", + { + title: "Database Schema", + description: "SQLite database schema", + mimeType: "text/plain" + }, async (uri) => { const db = getDb(); try { @@ -481,9 +733,13 @@ server.resource( } ); -server.tool( +server.registerTool( "query", - { sql: z.string() }, + { + title: "SQL Query", + description: "Execute SQL queries on the database", + inputSchema: { sql: z.string() } + }, async ({ sql }) => { const db = getDb(); try { @@ -514,7 +770,7 @@ server.tool( ### Dynamic Servers -If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notificaions: +If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -545,7 +801,7 @@ putMessageTool.disable() const upgradeAuthTool = server.tool( "upgradeAuth", - { permission: z.enum(["write', vadmin"])}, + { permission: z.enum(["write', admin"])}, // Any mutations here will automatically emit `listChanged` notifications async ({ permission }) => { const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) @@ -632,6 +888,109 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Eliciting User Input + +MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: + +```typescript +// Server-side: Restaurant booking tool that asks for alternatives +server.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await server.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } +); +``` + +Client-side: Handle elicitation requests + +```typescript +// This is a placeholder - implement based on your UI framework +async function getInputFromUser(message: string, schema: any): Promise<{ + action: "accept" | "reject" | "cancel"; + data?: Record; +}> { + // This should be implemented depending on the app + throw new Error("getInputFromUser must be implemented for your platform"); +} + +client.setRequestHandler(ElicitRequestSchema, async (request) => { + const userResponse = await getInputFromUser( + request.params.message, + request.params.requestedSchema + ); + + return { + action: userResponse.action, + content: userResponse.action === "accept" ? userResponse.data : undefined + }; +}); +``` + +**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. + ### Writing MCP Clients The SDK provides a high-level client interface: @@ -680,6 +1039,7 @@ const result = await client.callTool({ arg1: "value" } }); + ``` ### Proxy Authorization Requests Upstream @@ -688,7 +1048,8 @@ You can proxy OAuth requests to an external authorization provider: ```typescript import express from 'express'; -import { ProxyOAuthServerProvider, mcpAuthRouter } from '@modelcontextprotocol/sdk'; +import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; const app = express(); @@ -722,6 +1083,7 @@ app.use(mcpAuthRouter({ ``` This setup allows you to: + - Forward OAuth requests to an external provider - Add custom token validation logic - Manage client registrations @@ -835,7 +1197,7 @@ app.listen(3000); ## Contributing -Issues and pull requests are welcome on GitHub at https://github.com/modelcontextprotocol/typescript-sdk. +Issues and pull requests are welcome on GitHub at . ## License diff --git a/SECURITY.md b/SECURITY.md index 8c09400c..65451561 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,5 @@ # Security Policy + Thank you for helping us keep the SDKs and systems they interact with secure. ## Reporting Security Issues diff --git a/eslint.config.mjs b/eslint.config.mjs index 515114cf..d792f015 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,5 +15,12 @@ export default tseslint.config( { "argsIgnorePattern": "^_" } ] } + }, + { + files: ["src/client/**/*.ts", "src/server/**/*.ts"], + ignores: ["**/*.test.ts"], + rules: { + "no-console": "error" + } } ); diff --git a/package-lock.json b/package-lock.json index 1165b751..5f7cb0f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.13.2", "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -1557,6 +1559,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1592,6 +1607,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2201,7 +2226,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3450,8 +3475,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -3484,8 +3508,7 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -3689,16 +3712,19 @@ } }, "node_modules/formidable": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", - "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "hexoid": "^2.0.0", "once": "^1.4.0" }, + "engines": { + "node": ">=14.0.0" + }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -3953,16 +3979,6 @@ "node": ">= 0.4" } }, - "node_modules/hexoid": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", - "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4883,7 +4899,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5557,7 +5573,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6444,7 +6460,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index c6a65847..31bc3562 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.0", + "version": "1.13.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -36,8 +36,11 @@ ], "scripts": { "build": "npm run build:esm && npm run build:cjs", - "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", - "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", + "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json", + "build:cjs:w": "npm run build:cjs -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint src/", "test": "jest", @@ -46,10 +49,12 @@ "client": "tsx src/cli.ts client" }, "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 629feab7..8e77c0a5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1,9 +1,14 @@ +import { LATEST_PROTOCOL_VERSION } from '../types.js'; import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization, refreshAuthorization, registerClient, + discoverOAuthProtectedResourceMetadata, + extractResourceMetadataUrl, + auth, + type OAuthClientProvider, } from "./auth.js"; // Mock fetch globally @@ -15,6 +20,165 @@ describe("OAuth Authorization", () => { mockFetch.mockReset(); }); + describe("extractResourceMetadataUrl", () => { + it("returns resource metadata url when present", async () => { + const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl)); + }); + + it("returns undefined if not bearer", async () => { + const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it("returns undefined if resource_metadata not present", async () => { + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it("returns undefined on invalid url", async () => { + const resourceUrl = "invalid-url" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + }); + + describe("discoverOAuthProtectedResourceMetadata", () => { + const validMetadata = { + resource: "https://resource.example.com", + authorization_servers: ["https://auth.example.com"], + }; + + it("returns metadata when discovery succeeds", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + }); + + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call with MCP header - fail with TypeError (simulating CORS error) + // We need to use TypeError specifically because that's what the implementation checks for + return Promise.reject(new TypeError("Network error")); + } else { + // Second call without header - succeed + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validMetadata + }); + } + }); + + // Should succeed with the second call + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com"); + expect(metadata).toEqual(validMetadata); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify first call had MCP header + expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version"); + }); + + it("throws an error when all fetch attempts fail", async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call - fail with TypeError + return Promise.reject(new TypeError("First failure")); + } else { + // Second call - fail with different error + return Promise.reject(new Error("Second failure")); + } + }); + + // Should fail with the second error + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("Second failure"); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("throws on 404 errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + }); + + it("throws on non-404 errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("HTTP 500"); + }); + + it("validates metadata schema", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + scopes_supported: ["email", "mcp"], + }), + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow(); + }); + }); + describe("discoverOAuthMetadata", () => { const validMetadata = { issuer: "https://auth.example.com", @@ -39,7 +203,145 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-03-26" + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("returns metadata when discovery succeeds with path", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("falls back to root discovery when path-aware discovery returns 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(firstOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("returns undefined when both path-aware and root discovery return 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("does not fallback when the original URL is already at root path", async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("does not fallback when the original URL has no path", async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("falls back when path-aware discovery encounters CORS error", async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION }); }); @@ -158,8 +460,10 @@ describe("OAuth Authorization", () => { const { authorizationUrl, codeVerifier } = await startAuthorization( "https://auth.example.com", { + metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -174,9 +478,60 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:3000/callback" ); + expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); expect(codeVerifier).toBe("test_verifier"); }); + it("includes scope parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read write profile", + } + ); + + expect(authorizationUrl.searchParams.get("scope")).toBe("read write profile"); + }); + + it("excludes scope parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("scope")).toBe(false); + }); + + it("includes state parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + state: "foobar", + } + ); + + expect(authorizationUrl.searchParams.get("state")).toBe("foobar"); + }); + + it("excludes state parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("state")).toBe(false); + }); + it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -251,6 +606,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -273,6 +629,7 @@ describe("OAuth Authorization", () => { expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); it("validates token response schema", async () => { @@ -317,6 +674,9 @@ describe("OAuth Authorization", () => { access_token: "newaccess123", token_type: "Bearer", expires_in: 3600, + } + const validTokensWithNewRefreshToken = { + ...validTokens, refresh_token: "newrefresh123", }; @@ -331,15 +691,16 @@ describe("OAuth Authorization", () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => validTokens, + json: async () => validTokensWithNewRefreshToken, }); const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + resource: new URL("https://api.example.com/mcp-server"), }); - expect(tokens).toEqual(validTokens); + expect(tokens).toEqual(validTokensWithNewRefreshToken); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ href: "https://auth.example.com/token", @@ -357,6 +718,23 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const refreshToken = "refresh123"; + const tokens = await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken, + }); + + expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); }); it("validates token response schema", async () => { @@ -478,4 +856,612 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); }); + + describe("auth function", () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => { + // Setup: First call to protected resource metadata fails (404) + // Second call to auth server metadata succeeds + let callCount = 0; + mockFetch.mockImplementation((url) => { + callCount++; + + const urlString = url.toString(); + + if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) { + // First call - protected resource metadata fails with 404 + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) { + // Second call - auth server metadata succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (callCount === 3 && urlString.includes("/register")) { + // Third call - client registration succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: "test-client-id", + client_secret: "test-client-secret", + client_id_issued_at: 1612137600, + client_secret_expires_at: 1612224000, + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }), + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = jest.fn(); + + // Call the auth function + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + }); + + // Verify the result + expect(result).toBe("REDIRECT"); + + // Verify the sequence of calls + expect(mockFetch).toHaveBeenCalledTimes(3); + + // First call should be to protected resource metadata + expect(mockFetch.mock.calls[0][0].toString()).toBe( + "https://resource.example.com/.well-known/oauth-protected-resource" + ); + + // Second call should be to oauth metadata + expect(mockFetch.mock.calls[1][0].toString()).toBe( + "https://resource.example.com/.well-known/oauth-authorization-server" + ); + }); + + it("passes resource parameter through authorization flow", async () => { + // Mock successful metadata discovery - need to include protected resource metadata + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for authorization flow + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("includes resource in token exchange when authorization code is provided", async () => { + // Mock successful metadata discovery and token exchange - need protected resource metadata + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + authorizationCode: "auth-code-123", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + 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://api.example.com/mcp-server"); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("includes resource in token refresh", async () => { + // Mock successful metadata discovery and token refresh - need protected resource metadata + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + 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://api.example.com/mcp-server"); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + + it("skips default PRM resource validation when custom validateResourceURL is provided", async () => { + const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateResourceURL: mockValidateResourceURL, + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://different-resource.example.com/mcp-server", // Mismatched resource + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify custom validation method was called + expect(mockValidateResourceURL).toHaveBeenCalledWith( + new URL("https://api.example.com/mcp-server"), + "https://different-resource.example.com/mcp-server" + ); + }); + + it("uses prefix of server URL from PRM resource as resource parameter", async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: "https://api.example.com/", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server/endpoint", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter from PRM + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); + }); + + it("excludes resource parameter when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("excludes resource parameter in token exchange when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + authorizationCode: "auth-code-123", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("excludes resource parameter in token refresh when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index a279121b..37690574 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -1,11 +1,12 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; -import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js"; -import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; +import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. - * + * * This client relies upon a concept of an authorized "session," the exact * meaning of which is application-defined. Tokens, authorization codes, and * code verifiers should not cross different sessions. @@ -21,6 +22,11 @@ export interface OAuthClientProvider { */ get clientMetadata(): OAuthClientMetadata; + /** + * Returns a OAuth2 state parameter. + */ + state?(): string | Promise; + /** * Loads information about this OAuth client, as registered already with the * server, or returns `undefined` if the client is not registered with the @@ -32,7 +38,7 @@ export interface OAuthClientProvider { * If implemented, this permits the OAuth client to dynamically register with * the server. Client information saved this way should later be read via * `clientInformation()`. - * + * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). */ @@ -66,6 +72,15 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * If defined, overrides the selection and validation of the + * RFC 8707 Resource Indicator. If left undefined, default + * validation behavior will be used. + * + * Implementations must verify the returned resource matches the MCP server. + */ + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -78,14 +93,36 @@ export class UnauthorizedError extends Error { /** * Orchestrates the full auth flow with a server. - * + * * This can be used as a single entry point for all authorization functionality, * instead of linking together the other lower-level functions in this module. */ export async function auth( provider: OAuthClientProvider, - { serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise { - const metadata = await discoverOAuthMetadata(serverUrl); + { serverUrl, + authorizationCode, + scope, + resourceMetadataUrl + }: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL }): Promise { + + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl = serverUrl; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // Ignore errors and fall back to /.well-known/oauth-authorization-server + } + + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -98,7 +135,7 @@ export async function auth( throw new Error("OAuth client information must be saveable for dynamic registration"); } - const fullInformation = await registerClient(serverUrl, { + const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, }); @@ -110,12 +147,13 @@ export async function auth( // Exchange authorization code for tokens if (authorizationCode !== undefined) { const codeVerifier = await provider.codeVerifier(); - const tokens = await exchangeAuthorization(serverUrl, { + const tokens = await exchangeAuthorization(authorizationServerUrl, { metadata, clientInformation, authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, + resource, }); await provider.saveTokens(tokens); @@ -128,24 +166,30 @@ export async function auth( if (tokens?.refresh_token) { try { // Attempt to refresh the token - const newTokens = await refreshAuthorization(serverUrl, { + const newTokens = await refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken: tokens.refresh_token, + resource, }); await provider.saveTokens(newTokens); return "AUTHORIZED"; - } catch (error) { - console.error("Could not refresh OAuth tokens:", error); + } catch { + // Could not refresh OAuth tokens } } + const state = provider.state ? await provider.state() : undefined; + // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { + const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, - redirectUrl: provider.redirectUrl + state, + redirectUrl: provider.redirectUrl, + scope: scope || provider.clientMetadata.scope, + resource, }); await provider.saveCodeVerifier(codeVerifier); @@ -153,17 +197,73 @@ export async function auth( return "REDIRECT"; } +export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it + if (provider.validateResourceURL) { + return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); + } + + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); + } + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); +} + /** - * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + * Extract resource_metadata from response header. + */ +export function extractResourceMetadataUrl(res: Response): URL | undefined { + + const authenticateHeader = res.headers.get("WWW-Authenticate"); + if (!authenticateHeader) { + return undefined; + } + + const [type, scheme] = authenticateHeader.split(' '); + if (type.toLowerCase() !== 'bearer' || !scheme) { + return undefined; + } + const regex = /resource_metadata="([^"]*)"/; + const match = regex.exec(authenticateHeader); + + if (!match) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} + +/** + * Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata. * * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. */ -export async function discoverOAuthMetadata( +export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, - opts?: { protocolVersion?: string }, -): Promise { - const url = new URL("/.well-known/oauth-authorization-server", serverUrl); + opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, +): Promise { + + let url: URL + if (opts?.resourceMetadataUrl) { + url = new URL(opts?.resourceMetadataUrl); + } else { + url = new URL("/.well-known/oauth-protected-resource", serverUrl); + } + let response: Response; try { response = await fetch(url, { @@ -181,6 +281,99 @@ export async function discoverOAuthMetadata( } if (response.status === 404) { + throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + } + + if (!response.ok) { + throw new Error( + `HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`, + ); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + +/** + * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + */ +/** + * Helper function to handle fetch with CORS retry logic + */ +async function fetchWithCorsRetry( + url: URL, + headers: Record, +): Promise { + try { + return await fetch(url, { headers }); + } catch (error) { + // CORS errors come back as TypeError, retry without headers + if (error instanceof TypeError) { + return await fetch(url); + } + throw error; + } +} + +/** + * Constructs the well-known path for OAuth metadata discovery + */ +function buildWellKnownPath(pathname: string): string { + let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; + if (pathname.endsWith('/')) { + // Strip trailing slash from pathname to avoid double slashes + wellKnownPath = wellKnownPath.slice(0, -1); + } + return wellKnownPath; +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, +): Promise { + const headers = { + "MCP-Protocol-Version": protocolVersion + }; + return await fetchWithCorsRetry(url, headers); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback(response: Response, pathname: string): boolean { + return response.status === 404 && pathname !== '/'; +} + +export async function discoverOAuthMetadata( + authorizationServerUrl: string | URL, + opts?: { protocolVersion?: string }, +): Promise { + const issuer = new URL(authorizationServerUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + + // Try path-aware discovery first (RFC 8414 compliant) + const wellKnownPath = buildWellKnownPath(issuer.pathname); + const pathAwareUrl = new URL(wellKnownPath, issuer); + let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); + + // If path-aware discovery fails with 404, try fallback to root discovery + if (shouldAttemptFallback(response, issuer.pathname)) { + try { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + + if (response.status === 404) { + return undefined; + } + } catch { + // If fallback fails, return undefined + return undefined; + } + } else if (response.status === 404) { return undefined; } @@ -197,15 +390,21 @@ export async function discoverOAuthMetadata( * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ export async function startAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, redirectUrl, + scope, + state, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; + scope?: string; + state?: string; + resource?: URL; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -230,7 +429,7 @@ export async function startAuthorization( ); } } else { - authorizationUrl = new URL("/authorize", serverUrl); + authorizationUrl = new URL("/authorize", authorizationServerUrl); } // Generate PKCE challenge @@ -247,6 +446,18 @@ export async function startAuthorization( ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); + if (state) { + authorizationUrl.searchParams.set("state", state); + } + + if (scope) { + authorizationUrl.searchParams.set("scope", scope); + } + + if (resource) { + authorizationUrl.searchParams.set("resource", resource.href); + } + return { authorizationUrl, codeVerifier }; } @@ -254,19 +465,21 @@ export async function startAuthorization( * Exchanges an authorization code for an access token with the given server. */ export async function exchangeAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, authorizationCode, codeVerifier, redirectUri, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; redirectUri: string | URL; + resource?: URL; }, ): Promise { const grantType = "authorization_code"; @@ -284,7 +497,7 @@ export async function exchangeAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange code for tokens @@ -300,6 +513,10 @@ export async function exchangeAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { @@ -319,15 +536,17 @@ export async function exchangeAuthorization( * Exchange a refresh token for an updated access token. */ export async function refreshAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, refreshToken, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; + resource?: URL; }, ): Promise { const grantType = "refresh_token"; @@ -345,7 +564,7 @@ export async function refreshAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange refresh token @@ -359,6 +578,10 @@ export async function refreshAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { @@ -366,19 +589,18 @@ export async function refreshAuthorization( }, body: params, }); - if (!response.ok) { throw new Error(`Token refresh failed: HTTP ${response.status}`); } - return OAuthTokensSchema.parse(await response.json()); + return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); } /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ export async function registerClient( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientMetadata, @@ -396,7 +618,7 @@ export async function registerClient( registrationUrl = new URL(metadata.registration_endpoint); } else { - registrationUrl = new URL("/register", serverUrl); + registrationUrl = new URL("/register", authorizationServerUrl); } const response = await fetch(registrationUrl, { @@ -412,4 +634,4 @@ export async function registerClient( } return OAuthClientInformationFullSchema.parse(await response.json()); -} \ No newline at end of file +} diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 5b4f332f..abd0c34e 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -12,7 +12,9 @@ import { InitializeRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, + CallToolRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ErrorCode, } from "../types.js"; @@ -20,6 +22,9 @@ import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; +/*** + * Test: Initialize with Matching Protocol Version + */ test("should initialize with matching protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -75,6 +80,9 @@ test("should initialize with matching protocol version", async () => { expect(client.getInstructions()).toEqual("test instructions"); }); +/*** + * Test: Initialize with Supported Older Protocol Version + */ test("should initialize with supported older protocol version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const clientTransport: Transport = { @@ -123,6 +131,9 @@ test("should initialize with supported older protocol version", async () => { expect(client.getInstructions()).toBeUndefined(); }); +/*** + * Test: Reject Unsupported Protocol Version + */ test("should reject unsupported protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -165,6 +176,9 @@ test("should reject unsupported protocol version", async () => { expect(clientTransport.close).toHaveBeenCalled(); }); +/*** + * Test: Connect New Client to Old Supported Server Version + */ test("should connect new client to old, supported server version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -228,6 +242,9 @@ test("should connect new client to old, supported server version", async () => { }); }); +/*** + * Test: Version Negotiation with Old Client and Newer Server + */ test("should negotiate version when client is old, and newer server supports its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -291,6 +308,9 @@ test("should negotiate version when client is old, and newer server supports its }); }); +/*** + * Test: Throw when Old Client and Server Version Mismatch + */ test("should throw when client is old, and server doesn't support its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const FUTURE_VERSION = "FUTURE_VERSION"; @@ -353,6 +373,9 @@ test("should throw when client is old, and server doesn't support its version", }); +/*** + * Test: Respect Server Capabilities + */ test("should respect server capabilities", async () => { const server = new Server( { @@ -433,6 +456,9 @@ test("should respect server capabilities", async () => { ).rejects.toThrow("Server does not support completions"); }); +/*** + * Test: Respect Client Notification Capabilities + */ test("should respect client notification capabilities", async () => { const server = new Server( { @@ -489,6 +515,9 @@ test("should respect client notification capabilities", async () => { ); }); +/*** + * Test: Respect Server Notification Capabilities + */ test("should respect server notification capabilities", async () => { const server = new Server( { @@ -535,6 +564,9 @@ test("should respect server notification capabilities", async () => { ); }); +/*** + * Test: Only Allow setRequestHandler for Declared Capabilities + */ test("should only allow setRequestHandler for declared capabilities", () => { const client = new Client( { @@ -566,9 +598,47 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); -/* - Test that custom request/notification/result schemas can be used with the Client class. - */ +test("should allow setRequestHandler for declared elicitation capability", () => { + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: "test-model", + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + })); + }).toThrow("Client does not support sampling capability"); +}); + +/*** + * Test: Type Checking + * Test that custom request/notification/result schemas can be used with the Client class. + */ test("should typecheck", () => { const GetWeatherRequestSchema = RequestSchema.extend({ method: z.literal("weather/get"), @@ -645,6 +715,9 @@ test("should typecheck", () => { }); }); +/*** + * Test: Handle Client Cancelling a Request + */ test("should handle client cancelling a request", async () => { const server = new Server( { @@ -700,6 +773,9 @@ test("should handle client cancelling a request", async () => { await expect(listResourcesPromise).rejects.toBe("Cancelled by test"); }); +/*** + * Test: Handle Request Timeout + */ test("should handle request timeout", async () => { const server = new Server( { @@ -754,3 +830,474 @@ test("should handle request timeout", async () => { code: ErrorCode.RequestTimeout, }); }); + +describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ + test('should validate structuredContent against outputSchema', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + return { + structuredContent: { result: 'success', count: 42 }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'test-tool' }); + expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); + }); + + /*** + * Test: Throw Error when structuredContent Does Not Match Schema + */ + test('should throw error when structuredContent does not match schema', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: { result: 'success', count: 'not a number' }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + /*** + * Test: Throw Error when Tool with outputSchema Returns No structuredContent + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return content instead of structuredContent + return { + content: [{ type: 'text', text: 'This should be structured content' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool test-tool has an output schema but did not return structured content/ + ); + }); + + /*** + * Test: Handle Tools Without outputSchema Normally + */ + test('should handle tools without outputSchema normally', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + // No outputSchema + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return regular content + return { + content: [{ type: 'text', text: 'Normal response' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should work normally without validation + const result = await client.callTool({ name: 'test-tool' }); + expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); + }); + + /*** + * Test: Handle Complex JSON Schema Validation + */ + test('should handle complex JSON schema validation', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' }, + }, + required: ['created'], + }, + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'complex-tool') { + return { + structuredContent: { + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z', + }, + }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'complex-tool' }); + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + expect(structuredContent.age).toBe(30); + }); + + /*** + * Test: Fail Validation with Additional Properties When Not Allowed + */ + test('should fail validation with additional properties when not allowed', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'strict-tool') { + // Return structured content with extra property + return { + structuredContent: { + name: 'John', + extraField: 'not allowed', + }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error due to additional property + await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + +}); diff --git a/src/client/index.ts b/src/client/index.ts index a3edd0be..3e8d8ec8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -39,7 +39,12 @@ import { SubscribeRequest, SUPPORTED_PROTOCOL_VERSIONS, UnsubscribeRequest, + Tool, + ErrorCode, + McpError, } from "../types.js"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; export type ClientOptions = ProtocolOptions & { /** @@ -86,6 +91,8 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; + private _cachedToolOutputValidators: Map = new Map(); + private _ajv: InstanceType; /** * Initializes this client with the given name and version information. @@ -96,6 +103,7 @@ export class Client< ) { super(options); this._capabilities = options?.capabilities ?? {}; + this._ajv = new Ajv(); } /** @@ -157,6 +165,10 @@ export class Client< this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; + // HTTP transports must set the protocol version in each header after initialization. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } this._instructions = result.instructions; @@ -295,6 +307,14 @@ export class Client< } break; + case "elicitation/create": + if (!this._capabilities.elicitation) { + throw new Error( + `Client does not support elicitation capability (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._capabilities.roots) { throw new Error( @@ -413,22 +433,84 @@ export class Client< | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/call", params }, resultSchema, options, ); + + // Check if the tool has an outputSchema + const validator = this.getToolOutputValidator(params.name); + if (validator) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content (which is already an object) against the schema + const isValid = validator(result.structuredContent); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + return result; + } + + private cacheToolOutputSchemas(tools: Tool[]) { + this._cachedToolOutputValidators.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the Ajv validator + if (tool.outputSchema) { + try { + const validator = this._ajv.compile(tool.outputSchema); + this._cachedToolOutputValidators.set(tool.name, validator); + } catch { + // Ignore schema compilation errors + } + } + } + } + + private getToolOutputValidator(toolName: string): ValidateFunction | undefined { + return this._cachedToolOutputValidators.get(toolName); } async listTools( params?: ListToolsRequest["params"], options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/list", params }, ListToolsResultSchema, options, ); + + // Cache the tools and their output schemas for future validation + this.cacheToolOutputSchemas(result.tools); + + return result; } async sendRootsListChanged() { diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 77b28508..3cb4e8a3 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { OAuthTokens } from "../shared/auth.js"; describe("SSEClientTransport", () => { - let server: Server; + let resourceServer: Server; + let authServer: Server; let transport: SSEClientTransport; - let baseUrl: URL; + let resourceBaseUrl: URL; + let authBaseUrl: URL; let lastServerRequest: IncomingMessage; let sendServerMessage: ((message: string) => void) | null = null; @@ -17,8 +19,26 @@ describe("SSEClientTransport", () => { lastServerRequest = null as unknown as IncomingMessage; sendServerMessage = null; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(200, { + "Content-Type": "application/json" + }); + res.end(JSON.stringify({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + })); + return; + } + res.writeHead(401).end(); + }); + // Create a test server that will receive the EventSource connection - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { lastServerRequest = req; // Send SSE headers @@ -30,7 +50,7 @@ describe("SSEClientTransport", () => { // Send the endpoint event res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); // Store reference to send function for tests sendServerMessage = (message: string) => { @@ -51,9 +71,9 @@ describe("SSEClientTransport", () => { }); // Start server on random port - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); done(); }); @@ -62,14 +82,15 @@ describe("SSEClientTransport", () => { afterEach(async () => { await transport.close(); - await server.close(); + await resourceServer.close(); + await authServer.close(); jest.clearAllMocks(); }); describe("connection handling", () => { it("establishes SSE connection and receives endpoint", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); expect(lastServerRequest.headers.accept).toBe("text/event-stream"); @@ -78,27 +99,27 @@ describe("SSEClientTransport", () => { it("rejects if server returns non-200 status", async () => { // Create a server that returns 403 - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { res.writeHead(403); res.end(); }); await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await expect(transport.start()).rejects.toThrow(); }); it("closes EventSource connection on close()", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const closePromise = new Promise((resolve) => { @@ -113,7 +134,7 @@ describe("SSEClientTransport", () => { describe("message handling", () => { it("receives and parses JSON-RPC messages", async () => { const receivedMessages: JSONRPCMessage[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onmessage = (msg) => receivedMessages.push(msg); await transport.start(); @@ -136,7 +157,7 @@ describe("SSEClientTransport", () => { it("handles malformed JSON messages", async () => { const errors: Error[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onerror = (err) => errors.push(err); await transport.start(); @@ -151,7 +172,7 @@ describe("SSEClientTransport", () => { }); it("handles messages via POST requests", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -179,9 +200,9 @@ describe("SSEClientTransport", () => { it("handles POST request failures", async () => { // Create a server that returns 500 for POST - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { if (req.method === "GET") { res.writeHead(200, { "Content-Type": "text/event-stream", @@ -189,7 +210,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); } else { res.writeHead(500); res.end("Internal error"); @@ -197,14 +218,14 @@ describe("SSEClientTransport", () => { }); await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -229,7 +250,7 @@ describe("SSEClientTransport", () => { return fetch(url.toString(), { ...init, headers }); }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { eventSourceInit: { fetch: fetchWithAuth, }, @@ -247,7 +268,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: customHeaders, }, @@ -319,7 +340,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -335,7 +356,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -355,27 +376,50 @@ describe("SSEClientTransport", () => { }); it("attempts auth flow on 401 during SSE connection", async () => { - // Create server that returns 401s - await server.close(); - server = createServer((req, res) => { + // Create server that returns 401s + resourceServer.close(); + authServer.close(); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { - res.writeHead(404).end(); + res.writeHead(404).end(); } else { res.writeHead(401).end(); } }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -385,13 +429,33 @@ describe("SSEClientTransport", () => { it("attempts auth flow on 401 during POST request", async () => { // Create server that accepts SSE but returns 401 on POST - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { lastServerRequest = req; switch (req.method) { case "GET": + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -403,25 +467,25 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case "POST": - res.writeHead(401); - res.end(); + res.writeHead(401); + res.end(); break; } }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -448,7 +512,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, requestInit: { headers: customHeaders, @@ -483,11 +547,14 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for expired token, then accepts new token - await server.close(); + resourceServer.close(); + authServer.close(); - let connectionAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request @@ -512,16 +579,44 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let connectionAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { - res.writeHead(401).end(); - return; - } + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { + res.writeHead(401).end(); + return; + } if (auth === "Bearer new-token") { res.writeHead(200, { @@ -530,23 +625,23 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); connectionAttempts++; return; } - res.writeHead(401).end(); + res.writeHead(401).end(); }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -573,12 +668,15 @@ describe("SSEClientTransport", () => { currentTokens = tokens; }); - // Create server that accepts SSE but returns 401 on POST with expired token - await server.close(); + // Create server that returns 401 for expired token, then accepts new token + resourceServer.close(); + authServer.close(); - let postAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request @@ -603,6 +701,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let postAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + switch (req.method) { case "GET": if (req.url !== "/") { @@ -616,7 +742,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case "POST": { @@ -625,33 +751,33 @@ describe("SSEClientTransport", () => { return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { - res.writeHead(401).end(); - return; - } - - if (auth === "Bearer new-token") { - res.writeHead(200).end(); - postAttempts++; - return; - } - + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { res.writeHead(401).end(); + return; + } + + if (auth === "Bearer new-token") { + res.writeHead(200).end(); + postAttempts++; + return; + } + + res.writeHead(401).end(); break; } } }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -688,10 +814,14 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for all tokens - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request - always fail @@ -699,6 +829,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).end(); + + }); + + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -707,14 +865,14 @@ describe("SSEClientTransport", () => { }); await new Promise(resolve => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 5e9f0cf0..2546d508 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,7 +1,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; -import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; export class SseError extends Error { constructor( @@ -19,23 +19,23 @@ export class SseError extends Error { export type SSEClientTransportOptions = { /** * An OAuth client provider to use for authentication. - * + * * When an `authProvider` is specified and the SSE connection is started: * 1. The connection is attempted with any existing access token from the `authProvider`. * 2. If the access token has expired, the `authProvider` is used to refresh the token. * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * + * * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. - * + * * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * + * * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. */ authProvider?: OAuthClientProvider; /** * Customizes the initial SSE request to the server (the request that begins the stream). - * + * * NOTE: Setting this property will prevent an `Authorization` header from * being automatically attached to the SSE request, if an `authProvider` is * also given. This can be worked around by setting the `Authorization` header @@ -58,9 +58,11 @@ export class SSEClientTransport implements Transport { private _endpoint?: URL; private _abortController?: AbortController; private _url: URL; + private _resourceMetadataUrl?: URL; private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -71,6 +73,7 @@ export class SSEClientTransport implements Transport { opts?: SSEClientTransportOptions, ) { this._url = url; + this._resourceMetadataUrl = undefined; this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; @@ -83,7 +86,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -97,35 +100,52 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers = { + ...this._requestInit?.headers, + } as HeadersInit & Record; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { headers["Authorization"] = `Bearer ${tokens.access_token}`; } } + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; + } return headers; } private _startOrAuth(): Promise { + const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, - this._eventSourceInit ?? { - fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, { - ...init, - headers: { - ...headers, - Accept: "text/event-stream" + { + ...this._eventSourceInit, + fetch: async (url, init) => { + const headers = await this._commonHeaders() + const response = await fetchImpl(url, { + ...init, + headers: new Headers({ + ...headers, + Accept: "text/event-stream" + }) + }) + + if (response.status === 401 && response.headers.has('www-authenticate')) { + this._resourceMetadataUrl = extractResourceMetadataUrl(response); } - })), + + return response + }, }, ); this._abortController = new AbortController(); this._eventSource.onerror = (event) => { if (event.code === 401 && this._authProvider) { + this._authThenStart().then(resolve, reject); return; } @@ -193,7 +213,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -212,7 +232,7 @@ export class SSEClientTransport implements Transport { try { const commonHeaders = await this._commonHeaders(); - const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + const headers = new Headers(commonHeaders); headers.set("content-type", "application/json"); const init = { ...this._requestInit, @@ -225,7 +245,10 @@ export class SSEClientTransport implements Transport { const response = await fetch(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + this._resourceMetadataUrl = extractResourceMetadataUrl(response); + + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } @@ -244,4 +267,8 @@ export class SSEClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } } diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 646f9ea5..b2132446 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -59,3 +59,12 @@ test("should read messages", async () => { await client.close(); }); + +test("should return child process pid", async () => { + const client = new StdioClientTransport(serverParameters); + + await client.start(); + expect(client.pid).not.toBeNull(); + await client.close(); + expect(client.pid).toBeNull(); +}); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 9e35293d..e9c9fa8f 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -56,6 +56,7 @@ export const DEFAULT_INHERITED_ENV_VARS = "TEMP", "USERNAME", "USERPROFILE", + "PROGRAMFILES", ] : /* list inspired by the default env inheritance of sudo */ ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; @@ -184,6 +185,15 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } + /** + * The child process pid spawned by this transport. + * + * This is only available after the transport has been started. + */ + get pid(): number | null { + return this._process?.pid ?? null; + } + private processReadBuffer() { while (true) { try { diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index f748a2be..11dfe7d4 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -476,6 +476,37 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + it("should always send specified custom headers (Headers class)", async () => { + const requestInit = { + headers: new Headers({ + "X-Custom-Header": "CustomValue" + }) + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + ((global.fetch as jest.Mock)).mockImplementation( + async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + ); + + await transport.start(); + + await transport["_startOrAuthSse"]({}); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); + + (requestInit.headers as Headers).set("X-Custom-Header","SecondCustomValue"); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue"); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); it("should have exponential backoff with configurable maxRetries", () => { // This test verifies the maxRetries and backoff calculation directly diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 3462b2ab..73078442 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,6 +1,6 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; -import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; // Default reconnection options for StreamableHTTP connections @@ -119,10 +119,12 @@ export type StreamableHTTPClientTransportOptions = { export class StreamableHTTPClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; + private _resourceMetadataUrl?: URL; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -133,6 +135,7 @@ export class StreamableHTTPClientTransport implements Transport { opts?: StreamableHTTPClientTransportOptions, ) { this._url = url; + this._resourceMetadataUrl = undefined; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._sessionId = opts?.sessionId; @@ -146,7 +149,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -160,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -171,10 +174,16 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; + } - return new Headers( - { ...headers, ...this._requestInit?.headers } - ); + const extraHeaders = this._normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders, + }); } @@ -225,7 +234,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Calculates the next reconnection delay using backoff algorithm - * + * * @param attempt Current reconnection attempt count for the specific stream * @returns Time to wait in milliseconds before next reconnection attempt */ @@ -240,9 +249,23 @@ export class StreamableHTTPClientTransport implements Transport { } + private _normalizeHeaders(headers: HeadersInit | undefined): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return { ...headers as Record }; + } + /** * Schedule a reconnection attempt with exponential backoff - * + * * @param lastEventId The ID of the last received event for resumability * @param attemptCount Current reconnection attempt count for this specific stream */ @@ -356,7 +379,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -401,7 +424,10 @@ export class StreamableHTTPClientTransport implements Transport { if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + this._resourceMetadataUrl = extractResourceMetadataUrl(response); + + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } @@ -470,12 +496,12 @@ export class StreamableHTTPClientTransport implements Transport { /** * Terminates the current session by sending a DELETE request to the server. - * + * * Clients that no longer need a particular session * (e.g., because the user is leaving the client application) SHOULD send an * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly * terminate the session. - * + * * The server MAY respond with HTTP 405 Method Not Allowed, indicating that * the server does not allow clients to terminate sessions. */ @@ -511,4 +537,11 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + get protocolVersion(): string | undefined { + return this._protocolVersion; + } } diff --git a/src/examples/README.md b/src/examples/README.md index 611c081e..ac92e8de 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -33,6 +33,12 @@ A full-featured interactive client that connects to a Streamable HTTP server, de npx tsx src/examples/client/simpleStreamableHttp.ts ``` +Example client with OAuth: + +```bash +npx tsx src/examples/client/simpleOAuthClient.js +``` + ### Backwards Compatible Client A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: @@ -67,6 +73,12 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03 ```bash npx tsx src/examples/server/simpleStreamableHttp.ts + +# To add a demo of authentication to this example, use: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict ``` ##### JSON Response Mode Server diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts new file mode 100644 index 00000000..4531f4c2 --- /dev/null +++ b/src/examples/client/simpleOAuthClient.ts @@ -0,0 +1,422 @@ +#!/usr/bin/env node + +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { URL } from 'node:url'; +import { exec } from 'node:child_process'; +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { + CallToolRequest, + ListToolsRequest, + CallToolResultSchema, + ListToolsResultSchema +} from '../../types.js'; +import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js'; + +// Configuration +const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const CALLBACK_PORT = 8090; // Use different port than auth server (3001) +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + +/** + * In-memory OAuth client provider for demonstration purposes + * In production, you should persist tokens securely + */ +class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + onRedirect?: (url: URL) => void + ) { + this._onRedirect = onRedirect || ((url) => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + 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; + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} +/** + * Interactive MCP client with OAuth authentication + * Demonstrates the complete OAuth flow with browser-based authorization + */ +class InteractiveOAuthClient { + private client: Client | null = null; + private readonly rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + constructor(private serverUrl: string) { } + + /** + * Prompts user for input via readline + */ + private async question(query: string): Promise { + return new Promise((resolve) => { + this.rl.question(query, resolve); + }); + } + + /** + * Opens the authorization URL in the user's default browser + */ + private async openBrowser(url: string): 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 + */ + /** + * Starts a temporary HTTP server to receive the OAuth callback + */ + private async waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`📥 Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 3000); + } else if (error) { + console.log(`❌ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`❌ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`); + }); + }); + } + + private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { + console.log('🚢 Creating transport with OAuth provider...'); + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('🔌 Attempting connection (this will trigger OAuth redirect)...'); + await this.client!.connect(transport); + console.log('✅ Connected successfully'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('🔐 OAuth required - waiting for authorization...'); + const callbackPromise = this.waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + await this.attemptConnection(oauthProvider); + } else { + console.error('❌ Connection failed with non-auth error:', error); + throw error; + } + } + } + + /** + * Establishes connection to the MCP server with OAuth authentication + */ + async connect(): Promise { + console.log(`🔗 Attempting to connect to ${this.serverUrl}...`); + + const clientMetadata: OAuthClientMetadata = { + client_name: 'Simple OAuth MCP Client', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools' + }; + + console.log('🔐 Creating OAuth provider...'); + const oauthProvider = new InMemoryOAuthClientProvider( + 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('🔐 OAuth provider created'); + + console.log('👤 Creating MCP client...'); + this.client = new Client({ + name: 'simple-oauth-client', + version: '1.0.0', + }, { capabilities: {} }); + console.log('👤 Client created'); + + console.log('🔐 Starting OAuth flow...'); + + await this.attemptConnection(oauthProvider); + + // Start interactive loop + await this.interactiveLoop(); + } + + /** + * Main interactive loop for user commands + */ + async interactiveLoop(): Promise { + console.log('\n🎯 Interactive MCP Client with OAuth'); + console.log('Commands:'); + console.log(' list - List available tools'); + console.log(' call [args] - Call a tool'); + console.log(' quit - Exit the client'); + console.log(); + + while (true) { + try { + const command = await this.question('mcp> '); + + if (!command.trim()) { + continue; + } + + if (command === 'quit') { + break; + } else if (command === 'list') { + await this.listTools(); + } else if (command.startsWith('call ')) { + await this.handleCallTool(command); + } else { + console.log('❌ Unknown command. Try \'list\', \'call \', or \'quit\''); + } + } catch (error) { + if (error instanceof Error && error.message === 'SIGINT') { + console.log('\n\n👋 Goodbye!'); + break; + } + console.error('❌ Error:', error); + } + } + } + + private async listTools(): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const request: ListToolsRequest = { + method: 'tools/list', + params: {}, + }; + + const result = await this.client.request(request, ListToolsResultSchema); + + if (result.tools && result.tools.length > 0) { + console.log('\n📋 Available tools:'); + result.tools.forEach((tool, index) => { + console.log(`${index + 1}. ${tool.name}`); + if (tool.description) { + console.log(` Description: ${tool.description}`); + } + console.log(); + }); + } else { + console.log('No tools available'); + } + } catch (error) { + console.error('❌ Failed to list tools:', error); + } + } + + private async handleCallTool(command: string): Promise { + const parts = command.split(/\s+/); + const toolName = parts[1]; + + if (!toolName) { + console.log('❌ Please specify a tool name'); + return; + } + + // Parse arguments (simple JSON-like format) + let toolArgs: Record = {}; + if (parts.length > 2) { + const argsString = parts.slice(2).join(' '); + try { + toolArgs = JSON.parse(argsString); + } catch { + console.log('❌ Invalid arguments format (expected JSON)'); + return; + } + } + + await this.callTool(toolName, toolArgs); + } + + private async callTool(toolName: string, toolArgs: Record): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArgs, + }, + }; + + const result = await this.client.request(request, CallToolResultSchema); + + console.log(`\n🔧 Tool '${toolName}' result:`); + if (result.content) { + result.content.forEach((content) => { + if (content.type === 'text') { + console.log(content.text); + } else { + console.log(content); + } + }); + } else { + console.log(result); + } + } catch (error) { + console.error(`❌ Failed to call tool '${toolName}':`, error); + } + } + + close(): void { + this.rl.close(); + if (this.client) { + // Note: Client doesn't have a close method in the current implementation + // This would typically close the transport connection + } + } +} + +/** + * Main entry point + */ +async function main(): Promise { + const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL; + + console.log('🚀 Simple MCP OAuth Client'); + console.log(`Connecting to: ${serverUrl}`); + console.log(); + + const client = new InteractiveOAuthClient(serverUrl); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\n\n👋 Goodbye!'); + client.close(); + process.exit(0); + }); + + try { + await client.connect(); + } catch (error) { + console.error('Failed to start client:', error); + process.exit(1); + } finally { + client.close(); + } +} + +// Run if this file is executed directly +main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c1501a57..02db131e 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,7 +14,13 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ElicitRequestSchema, + ResourceLink, + ReadResourceRequest, + ReadResourceResultSchema, } from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; +import Ajv from "ajv"; // Create readline interface for user input const readline = createInterface({ @@ -54,10 +60,13 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); 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 elicitation with collect-user-info tool (contact/preferences/feedback)'); 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'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); + console.log(' read-resource - Read a specific resource by URI'); console.log(' help - Show this help'); console.log(' quit - Exit the program'); } @@ -114,6 +123,10 @@ function commandLoop(): void { await callMultiGreetTool(args[1] || 'MCP User'); break; + case 'collect-info': + await callCollectInfoTool(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; @@ -121,6 +134,13 @@ function commandLoop(): void { break; } + case 'run-notifications-tool-with-resumability': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 10; + await runNotificationsToolWithResumability(interval, count); + break; + } + case 'list-prompts': await listPrompts(); break; @@ -146,6 +166,14 @@ function commandLoop(): void { await listResources(); break; + case 'read-resource': + if (args.length < 2) { + console.log('Usage: read-resource '); + } else { + await readResource(args[1]); + } + break; + case 'help': printHelp(); break; @@ -183,15 +211,212 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client + // Create a new client with elicitation capability client = new Client({ name: 'example-client', version: '1.0.0' + }, { + capabilities: { + elicitation: {}, + }, }); client.onerror = (error) => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); } + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, async (request) => { + console.log('\n🔔 Elicitation Request Received:'); + console.log(`Message: ${request.params.message}`); + console.log('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + const schema = request.params.requestedSchema; + const properties = schema.properties; + const required = schema.required || []; + + // Set up AJV validator for the requested schema + const ajv = new Ajv(); + const validate = ajv.compile(schema); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`); + + const content: Record = {}; + let inputCancelled = false; + + // Collect input for each field + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const field = fieldSchema as { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + format?: string; + }; + + const isRequired = required.includes(fieldName); + let prompt = `${field.title || fieldName}`; + + // Add helpful information to the prompt + if (field.description) { + prompt += ` (${field.description})`; + } + if (field.enum) { + prompt += ` [options: ${field.enum.join(', ')}]`; + } + if (field.type === 'number' || field.type === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + prompt += ` [${field.minimum}-${field.maximum}]`; + } else if (field.minimum !== undefined) { + prompt += ` [min: ${field.minimum}]`; + } else if (field.maximum !== undefined) { + prompt += ` [max: ${field.maximum}]`; + } + } + if (field.type === 'string' && field.format) { + prompt += ` [format: ${field.format}]`; + } + if (isRequired) { + prompt += ' *required*'; + } + if (field.default !== undefined) { + prompt += ` [default: ${field.default}]`; + } + + prompt += ': '; + + const answer = await new Promise((resolve) => { + readline.question(prompt, (input) => { + resolve(input.trim()); + }); + }); + + // Check for cancellation + if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') { + inputCancelled = true; + break; + } + + // Parse and validate the input + try { + if (answer === '' && field.default !== undefined) { + content[fieldName] = field.default; + } else if (answer === '' && !isRequired) { + // Skip optional empty fields + continue; + } else if (answer === '') { + throw new Error(`${fieldName} is required`); + } else { + // Parse the value based on type + let parsedValue: unknown; + + if (field.type === 'boolean') { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + } else if (field.type === 'number') { + parsedValue = parseFloat(answer); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid number`); + } + } else if (field.type === 'integer') { + parsedValue = parseInt(answer, 10); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid integer`); + } + } else if (field.enum) { + if (!field.enum.includes(answer)) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } + parsedValue = answer; + } else { + parsedValue = answer; + } + + content[fieldName] = parsedValue; + } + } catch (error) { + console.log(`❌ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return { action: 'cancel' }; + } + + // If we didn't complete all fields due to an error, try again + if (Object.keys(content).length !== Object.keys(properties).filter(name => + required.includes(name) || content[name] !== undefined + ).length) { + if (attempts < maxAttempts) { + console.log('Please try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('❌ Validation errors:'); + validate.errors?.forEach(error => { + console.log(` - ${error.dataPath || 'root'}: ${error.message}`); + }); + + if (attempts < maxAttempts) { + console.log('Please correct the errors and try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + } + } + + // Show the collected data and ask for confirmation + console.log('\n✅ Collected data:'); + console.log(JSON.stringify(content, null, 2)); + + const confirmAnswer = await new Promise((resolve) => { + readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => { + resolve(input.trim().toLowerCase()); + }); + }); + + + 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: 'reject' }; + } + } + } + + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + }); + transport = new StreamableHTTPClientTransport( new URL(serverUrl), { @@ -264,12 +489,12 @@ async function terminateSession(): Promise { console.log('Terminating session with ID:', transport.sessionId); await transport.terminateSession(); console.log('Session terminated successfully'); - + // Check if sessionId was cleared after termination if (!transport.sessionId) { console.log('Session ID has been cleared'); sessionId = undefined; - + // Also close the transport and clear client objects await transport.close(); console.log('Transport closed after session termination'); @@ -309,7 +534,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); } } } catch (error) { @@ -333,11 +558,91 @@ async function callTool(name: string, args: Record): Promise { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else if (item.type === 'resource_link') { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); + } else { + console.log(` [Unknown content type]:`, item); + } + }); + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } + } catch (error) { + console.log(`Error calling tool ${name}: ${error}`); + } +} + + +async function callGreetTool(name: string): Promise { + await callTool('greet', { name }); +} + +async function callMultiGreetTool(name: string): Promise { + console.log('Calling multi-greet tool with notifications...'); + await callTool('multi-greet', { name }); +} + +async function callCollectInfoTool(infoType: string): Promise { + console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + await callTool('collect-user-info', { 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 }); +} + +async function runNotificationsToolWithResumability(interval: number, count: number): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`); + console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`); + + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { interval, count } + } + }; + const onLastEventIdUpdate = (event: string) => { notificationsToolLastEventId = event; + console.log(`Updated resumption token: ${event}`); }; + const result = await client.request(request, CallToolResultSchema, { - resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate + resumptionToken: notificationsToolLastEventId, + onresumptiontoken: onLastEventIdUpdate }); console.log('Tool result:'); @@ -349,24 +654,10 @@ async function callTool(name: string, args: Record): Promise { - await callTool('greet', { name }); -} - -async function callMultiGreetTool(name: string): Promise { - console.log('Calling multi-greet tool with notifications...'); - await callTool('multi-greet', { name }); -} - -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 }); -} - async function listPrompts(): Promise { if (!client) { console.log('Not connected to server.'); @@ -384,7 +675,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${prompt.name}: ${prompt.description}`); + console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`); } } } catch (error) { @@ -435,7 +726,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${resource.name}: ${resource.uri}`); + console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`); } } } catch (error) { @@ -443,6 +734,42 @@ async function listResources(): Promise { } } +async function readResource(uri: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: ReadResourceRequest = { + method: 'resources/read', + params: { uri } + }; + + console.log(`Reading resource: ${uri}`); + const result = await client.request(request, ReadResourceResultSchema); + + console.log('Resource contents:'); + for (const content of result.contents) { + console.log(` URI: ${content.uri}`); + if (content.mimeType) { + console.log(` Type: ${content.mimeType}`); + } + + if ('text' in content && typeof content.text === 'string') { + console.log(' Content:'); + console.log(' ---'); + console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n')); + console.log(' ---'); + } else if ('blob' in content && typeof content.blob === 'string') { + console.log(` [Binary data: ${content.blob.length} bytes]`); + } + } + } catch (error) { + console.log(`Error reading resource ${uri}: ${error}`); + } +} + async function cleanup(): Promise { if (client && transport) { try { @@ -456,7 +783,7 @@ async function cleanup(): Promise { console.error('Error terminating session:', error); } } - + // Then close the transport await transport.close(); } catch (error) { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts new file mode 100644 index 00000000..274a504a --- /dev/null +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto'; +import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; +import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; +import express, { Request, Response } from "express"; +import { AuthInfo } from '../../server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; +import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; + + +export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { + private clients = new Map(); + + async getClient(clientId: string) { + return this.clients.get(clientId); + } + + async registerClient(clientMetadata: OAuthClientInformationFull) { + this.clients.set(clientMetadata.client_id, clientMetadata); + return clientMetadata; + } +} + +/** + * 🚨 DEMO ONLY - NOT FOR PRODUCTION + * + * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, + * for example: + * - Persistent token storage + * - Rate limiting + */ +export class DemoInMemoryAuthProvider implements OAuthServerProvider { + clientsStore = new DemoInMemoryClientsStore(); + private codes = new Map(); + private tokens = new Map(); + + constructor(private validateResource?: (resource?: URL) => boolean) {} + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const code = randomUUID(); + + const searchParams = new URLSearchParams({ + code, + }); + if (params.state !== undefined) { + searchParams.set('state', params.state); + } + + this.codes.set(code, { + client, + params + }); + + const targetUrl = new URL(client.redirect_uris[0]); + targetUrl.search = searchParams.toString(); + res.redirect(targetUrl.toString()); + } + + async challengeForAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string + ): Promise { + + // Store the challenge with the code data + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + return codeData.params.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + // Note: code verifier is checked in token.ts by default + // it's unused here for that reason. + _codeVerifier?: string + ): Promise { + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + if (codeData.client.client_id !== client.client_id) { + throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); + } + + if (this.validateResource && !this.validateResource(codeData.params.resource)) { + throw new Error(`Invalid resource: ${codeData.params.resource}`); + } + + this.codes.delete(authorizationCode); + const token = randomUUID(); + + const tokenData = { + token, + clientId: client.client_id, + scopes: codeData.params.scopes || [], + expiresAt: Date.now() + 3600000, // 1 hour + resource: codeData.params.resource, + type: 'access', + }; + + this.tokens.set(token, tokenData); + + return { + access_token: token, + token_type: 'bearer', + expires_in: 3600, + scope: (codeData.params.scopes || []).join(' '), + }; + } + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + _refreshToken: string, + _scopes?: string[], + _resource?: URL + ): Promise { + throw new Error('Not implemented for example demo'); + } + + async verifyAccessToken(token: string): Promise { + const tokenData = this.tokens.get(token); + if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { + throw new Error('Invalid or expired token'); + } + + return { + token, + clientId: tokenData.clientId, + scopes: tokenData.scopes, + expiresAt: Math.floor(tokenData.expiresAt / 1000), + resource: tokenData.resource, + }; + } +} + + +export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {authServerUrl: URL, mcpServerUrl: URL, strictResource: boolean}): OAuthMetadata => { + // Create separate auth server app + // NOTE: This is a separate app on a separate port to illustrate + // how to separate an OAuth Authorization Server from a Resource + // server in the SDK. The SDK is not intended to be provide a standalone + // authorization server. + + const validateResource = strictResource ? (resource?: URL) => { + if (!resource) return false; + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + return resource.toString() === expectedResource.toString(); + } : undefined; + + const provider = new DemoInMemoryAuthProvider(validateResource); + const authApp = express(); + authApp.use(express.json()); + // For introspection requests + authApp.use(express.urlencoded()); + + // Add OAuth routes to the auth server + // NOTE: this will also add a protected resource metadata route, + // but it won't be used, so leave it. + authApp.use(mcpAuthRouter({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], + })); + + authApp.post('/introspect', async (req: Request, res: Response) => { + try { + const { token } = req.body; + if (!token) { + res.status(400).json({ error: 'Token is required' }); + return; + } + + const tokenInfo = await provider.verifyAccessToken(token); + res.json({ + active: true, + client_id: tokenInfo.clientId, + scope: tokenInfo.scopes.join(' '), + exp: tokenInfo.expiresAt, + aud: tokenInfo.resource, + }); + return + } catch (error) { + res.status(401).json({ + active: false, + error: 'Unauthorized', + error_description: `Invalid token: ${error}` + }); + } + }); + + const auth_port = authServerUrl.port; + // Start the auth server + authApp.listen(auth_port, () => { + console.log(`OAuth Authorization Server listening on port ${auth_port}`); + }); + + // Note: we could fetch this from the server, but then we end up + // with some top level async which gets annoying. + const oauthMetadata: OAuthMetadata = createOAuthMetadata({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], + }) + + oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href; + + return oauthMetadata; +} diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts new file mode 100644 index 00000000..de3b363e --- /dev/null +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Example MCP server using the high-level McpServer API with outputSchema + * This demonstrates how to easily create tools with structured output + */ + +import { McpServer } from "../../server/mcp.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer( + { + name: "mcp-output-schema-high-level-example", + version: "1.0.0", + } +); + +// Define a tool with structured output - Weather data +server.registerTool( + "get_weather", + { + description: "Get weather information for a city", + inputSchema: { + city: z.string().describe("City name"), + country: z.string().describe("Country code (e.g., US, UK)") + }, + outputSchema: { + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) + }, + }, + async ({ city, country }) => { + // Parameters are available but not used in this example + void city; + void country; + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditionCandidates = [ + "sunny", + "cloudy", + "rainy", + "stormy", + "snowy", + ] as const; + const conditions = conditionCandidates[Math.floor(Math.random() * conditionCandidates.length)]; + + const structuredContent = { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + }; + + return { + content: [{ + type: "text", + text: JSON.stringify(structuredContent, null, 2) + }], + structuredContent + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("High-level Output Schema Example Server running on stdio"); +} + +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 1933cc94..fea0eec0 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,22 +3,34 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { OAuthMetadata } from 'src/shared/auth.js'; +import { checkResourceAllowed } from 'src/shared/auth-utils.js'; + +// Check for OAuth flag +const useOAuth = process.argv.includes('--oauth'); +const strictOAuth = process.argv.includes('--oauth-strict'); // Create an MCP server with implementation details const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', - version: '1.0.0', + version: '1.0.0' }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting - server.tool( + server.registerTool( 'greet', - 'A simple greeting tool', { - name: z.string().describe('Name to greet'), + title: 'Greeting Tool', // Display name for UI + description: 'A simple greeting tool', + inputSchema: { + name: z.string().describe('Name to greet'), + }, }, async ({ name }): Promise => { return { @@ -40,7 +52,7 @@ const getServer = () => { name: z.string().describe('Name to greet'), }, { - title: 'Multiple Greeting Tool', + title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false }, @@ -76,13 +88,165 @@ const getServer = () => { }; } ); - - // Register a simple prompt - server.prompt( - 'greeting-template', - 'A simple greeting prompt template', + // Register a tool that demonstrates elicitation (user input collection) + // This creates a closure that captures the server instance + server.tool( + 'collect-user-info', + 'A tool that collects user information through elicitation', { - name: z.string().describe('Name to include in greeting'), + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'), + }, + async ({ infoType }): Promise => { + let message: string; + let requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + switch (infoType) { + case 'contact': + message = 'Please provide your contact information'; + requestedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + break; + case 'preferences': + message = 'Please set your preferences'; + requestedSchema = { + type: 'object', + properties: { + theme: { + type: 'string', + title: 'Theme', + description: 'Choose your preferred theme', + enum: ['light', 'dark', 'auto'], + enumNames: ['Light', 'Dark', 'Auto'], + }, + notifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Would you like to receive notifications?', + default: true, + }, + frequency: { + type: 'string', + title: 'Notification Frequency', + description: 'How often would you like notifications?', + enum: ['daily', 'weekly', 'monthly'], + enumNames: ['Daily', 'Weekly', 'Monthly'], + }, + }, + required: ['theme'], + }; + break; + case 'feedback': + message = 'Please provide your feedback'; + requestedSchema = { + type: 'object', + properties: { + rating: { + type: 'integer', + title: 'Rating', + description: 'Rate your experience (1-5)', + minimum: 1, + maximum: 5, + }, + comments: { + type: 'string', + title: 'Comments', + description: 'Additional comments (optional)', + maxLength: 500, + }, + recommend: { + type: 'boolean', + title: 'Would you recommend this?', + description: 'Would you recommend this to others?', + }, + }, + required: ['rating', 'recommend'], + }; + break; + default: + throw new Error(`Unknown info type: ${infoType}`); + } + + try { + // Use the underlying server instance to elicit input from the client + const result = await server.server.elicitInput({ + message, + requestedSchema, + }); + + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`, + }, + ], + }; + } else if (result.action === 'reject') { + return { + content: [ + { + type: 'text', + text: `No information was collected. User rejected ${infoType} information request.`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `Information collection was cancelled by the user.`, + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error collecting ${infoType} information: ${error}`, + }, + ], + }; + } + } + ); + + // Register a simple prompt with title + server.registerPrompt( + 'greeting-template', + { + title: 'Greeting Template', // Display name for UI + description: 'A simple greeting prompt template', + argsSchema: { + name: z.string().describe('Name to include in greeting'), + }, }, async ({ name }): Promise => { return { @@ -141,10 +305,14 @@ const getServer = () => { ); // Create a simple resource at a fixed URI - server.resource( + server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, + { + title: 'Default Greeting', // Display name for UI + description: 'A simple greeting resource', + mimeType: 'text/plain' + }, async (): Promise => { return { contents: [ @@ -156,22 +324,187 @@ const getServer = () => { }; } ); + + // Create additional resources for ResourceLink demonstration + server.registerResource( + 'example-file-1', + 'file:///example/file1.txt', + { + title: 'Example File 1', + description: 'First example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file1.txt', + text: 'This is the content of file 1', + }, + ], + }; + } + ); + + server.registerResource( + 'example-file-2', + 'file:///example/file2.txt', + { + title: 'Example File 2', + description: 'Second example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file2.txt', + text: 'This is the content of file 2', + }, + ], + }; + } + ); + + // Register a tool that returns ResourceLinks + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: { + includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'), + }, + }, + async ({ includeDescriptions = true }): Promise => { + const resourceLinks: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'https://example.com/greetings/default', + name: 'Default Greeting', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'A simple greeting resource' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file1.txt', + name: 'Example File 1', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file2.txt', + name: 'Example File 2', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) + } + ]; + + return { + content: [ + { + type: 'text', + text: 'Here are the available files as resource links:', + }, + ...resourceLinks, + { + type: 'text', + text: '\nYou can read any of these resources using their URI.', + } + ], + }; + } + ); + return server; }; +const MCP_PORT = 3000; +const AUTH_PORT = 3001; + const app = express(); app.use(express.json()); +// Set up OAuth if enabled +let authMiddleware = null; +if (useOAuth) { + // Create auth middleware for MCP endpoints + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); + const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + + const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth}); + + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + const endpoint = oauthMetadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + + if (!response.ok) { + throw new Error(`Invalid or expired token: ${await response.text()}`); + } + + const data = await response.json(); + + if (strictOAuth) { + if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); + } + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); + } + } + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp, + }; + } + } + // Add metadata routes to the main MCP server + app.use(mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server', + })); + + authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + }); +} + // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); +// MCP POST endpoint with optional auth +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body); + if (useOAuth && req.auth) { + console.log('Authenticated user:', req.auth); + } try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; @@ -234,16 +567,27 @@ app.post('/mcp', async (req: Request, res: Response) => { }); } } -}); +}; + +// Set up routes with conditional auth middleware +if (useOAuth && authMiddleware) { + app.post('/mcp', authMiddleware, mcpPostHandler); +} else { + app.post('/mcp', mcpPostHandler); +} // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { +const mcpGetHandler = 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; } + if (useOAuth && req.auth) { + console.log('Authenticated SSE connection from user:', req.auth); + } + // Check for Last-Event-ID header for resumability const lastEventId = req.headers['last-event-id'] as string | undefined; if (lastEventId) { @@ -254,10 +598,17 @@ app.get('/mcp', async (req: Request, res: Response) => { const transport = transports[sessionId]; await transport.handleRequest(req, res); -}); +}; + +// Set up GET route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.get('/mcp', authMiddleware, mcpGetHandler); +} else { + app.get('/mcp', mcpGetHandler); +} // Handle DELETE requests for session termination (according to MCP spec) -app.delete('/mcp', async (req: Request, res: Response) => { +const mcpDeleteHandler = 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'); @@ -275,12 +626,17 @@ app.delete('/mcp', async (req: Request, res: Response) => { res.status(500).send('Error processing session termination'); } } -}); +}; -// Start the server -const PORT = 3000; -app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); +// Set up DELETE route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.delete('/mcp', authMiddleware, mcpDeleteHandler); +} else { + app.delete('/mcp', mcpDeleteHandler); +} + +app.listen(MCP_PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); // Handle server shutdown diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index b7ff17e6..4a191134 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; import { z } from 'zod'; describe('Streamable HTTP Transport Session Management', () => { @@ -145,7 +145,7 @@ describe('Streamable HTTP Transport Session Management', () => { params: {} }, ListToolsResultSchema); - + }); it('should operate without session management', async () => { // Create and connect a client @@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => { // Clean up await transport.close(); }); + + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); + + await client.connect(transport); + + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + + // Clean up + await transport.close(); + }); }); describe('Stateful Mode', () => { diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index e921d5ea..438db6a6 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -276,6 +276,34 @@ describe('Authorization Handler', () => { }); }); + describe('Resource parameter validation', () => { + it('propagates resource parameter', async () => { + const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: new URL('https://api.example.com/resource'), + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + }); + describe('Successful authorization', () => { it('handles successful authorization with all parameters', async () => { const response = await supertest(app) diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 3e9a336b..126ce006 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({ code_challenge_method: z.literal("S256"), scope: z.string().optional(), state: z.string().optional(), + resource: z.string().url().optional(), }); export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { @@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error looking up client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } @@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A throw new InvalidRequestError(parseResult.error.message); } - const { scope, code_challenge } = parseResult.data; + const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; // Validate scopes @@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, + resource: resource ? new URL(resource) : undefined, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters if (error instanceof OAuthError) { res.redirect(302, createErrorRedirect(redirect_uri, error, state)); } else { - console.error("Unexpected error during authorization:", error); const serverError = new ServerError("Internal Server Error"); res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); } diff --git a/src/server/auth/handlers/metadata.ts b/src/server/auth/handlers/metadata.ts index 048a4d4a..444b8505 100644 --- a/src/server/auth/handlers/metadata.ts +++ b/src/server/auth/handlers/metadata.ts @@ -1,9 +1,9 @@ import express, { RequestHandler } from "express"; -import { OAuthMetadata } from "../../../shared/auth.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js"; import cors from 'cors'; import { allowedMethods } from "../middleware/allowedMethods.js"; -export function metadataHandler(metadata: OAuthMetadata): RequestHandler { +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler { }); return router; -} \ No newline at end of file +} diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 30b7cdf8..c3137348 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -104,7 +104,6 @@ export function clientRegistrationHandler({ const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error registering client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts index 95e8b4b3..0d1b30e0 100644 --- a/src/server/auth/handlers/revoke.ts +++ b/src/server/auth/handlers/revoke.ts @@ -9,7 +9,7 @@ import { InvalidRequestError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, } from "../errors.js"; export type RevocationHandlerOptions = { @@ -21,7 +21,10 @@ export type RevocationHandlerOptions = { rateLimit?: Partial | false; }; -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { +export function revocationHandler({ + provider, + rateLimit: rateLimitConfig, +}: RevocationHandlerOptions): RequestHandler { if (!provider.revokeToken) { throw new Error("Auth provider does not support revoking tokens"); } @@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo // Apply rate limiting unless explicitly disabled if (rateLimitConfig !== false) { - router.use(rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - })); + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError( + "You have exceeded the rate limit for token revocation requests" + ).toResponseObject(), + ...rateLimitConfig, + }) + ); } // Authenticate and extract client details router.use(authenticateClient({ clientsStore: provider.clientsStore })); router.post("/", async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); + res.setHeader("Cache-Control", "no-store"); try { const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); @@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error revoking token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index bf41b5eb..4b7fae02 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -264,12 +264,14 @@ describe('Token Handler', () => { }); it('returns tokens for valid code exchange', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'authorization_code', code: 'valid_code', code_verifier: 'valid_verifier' @@ -280,6 +282,13 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('mock_refresh_token'); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + new URL('https://api.example.com/resource') // resource parameter + ); }); it('passes through code verifier when using proxy provider', async () => { @@ -322,7 +331,8 @@ describe('Token Handler', () => { client_secret: 'valid-secret', grant_type: 'authorization_code', code: 'valid_code', - code_verifier: 'any_verifier' + code_verifier: 'any_verifier', + redirect_uri: 'https://example.com/callback' }); expect(response.status).toBe(200); @@ -342,6 +352,69 @@ describe('Token Handler', () => { global.fetch = originalFetch; } }); + + it('passes through redirect_uri when using proxy provider', async () => { + const originalFetch = global.fetch; + + try { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }) + }); + + const proxyProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: 'https://example.com/authorize', + tokenUrl: 'https://example.com/token' + }, + verifyAccessToken: async (token) => ({ + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }), + getClient: async (clientId) => clientId === 'valid-client' ? validClient : undefined + }); + + const proxyApp = express(); + const options: TokenHandlerOptions = { provider: proxyProvider }; + proxyApp.use('/token', tokenHandler(options)); + + const redirectUri = 'https://example.com/callback'; + const response = await supertest(proxyApp) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'any_verifier', + redirect_uri: redirectUri + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + } finally { + global.fetch = originalFetch; + } + }); }); describe('Refresh token grant', () => { @@ -376,12 +449,14 @@ describe('Token Handler', () => { }); it('returns new tokens for valid refresh token', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token' }); @@ -391,6 +466,12 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('new_mock_refresh_token'); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + new URL('https://api.example.com/resource') // resource parameter + ); }); it('respects requested scopes on refresh', async () => { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 28412a01..b2ab7439 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -31,11 +31,14 @@ const TokenRequestSchema = z.object({ const AuthorizationCodeGrantSchema = z.object({ code: z.string(), code_verifier: z.string(), + redirect_uri: z.string().optional(), + resource: z.string().url().optional(), }); const RefreshTokenGrantSchema = z.object({ refresh_token: z.string(), scope: z.string().optional(), + resource: z.string().url().optional(), }); export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { @@ -77,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -88,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { code, code_verifier } = parseResult.data; + const { code, code_verifier, redirect_uri, resource } = parseResult.data; const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -102,7 +104,13 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand } // Passes the code_verifier to the provider if PKCE validation didn't occur locally - const tokens = await provider.exchangeAuthorizationCode(client, code, skipLocalPkceValidation ? code_verifier : undefined); + const tokens = await provider.exchangeAuthorizationCode( + client, + code, + skipLocalPkceValidation ? code_verifier : undefined, + redirect_uri, + resource ? new URL(resource) : undefined + ); res.status(200).json(tokens); break; } @@ -113,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { refresh_token, scope } = parseResult.data; + const { refresh_token, scope, resource } = parseResult.data; const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); break; } @@ -134,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error exchanging token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 43cbfa0a..b8953e5c 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -2,17 +2,11 @@ import { Request, Response } from "express"; import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; -import { OAuthServerProvider } from "../provider.js"; -import { OAuthRegisteredClientsStore } from "../clients.js"; +import { OAuthTokenVerifier } from "../provider.js"; -// Mock provider +// Mock verifier const mockVerifyAccessToken = jest.fn(); -const mockProvider: OAuthServerProvider = { - clientsStore: {} as OAuthRegisteredClientsStore, - authorize: jest.fn(), - challengeForAuthorizationCode: jest.fn(), - exchangeAuthorizationCode: jest.fn(), - exchangeRefreshToken: jest.fn(), +const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken, }; @@ -50,7 +44,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer valid-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -59,7 +53,7 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.status).not.toHaveBeenCalled(); expect(mockResponse.json).not.toHaveBeenCalled(); }); - + it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", @@ -73,7 +67,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer expired-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); @@ -87,7 +81,7 @@ describe("requireBearerAuth middleware", () => { ); expect(nextFunction).not.toHaveBeenCalled(); }); - + it("should accept non-expired tokens", async () => { const nonExpiredAuthInfo: AuthInfo = { token: "valid-token", @@ -101,7 +95,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer valid-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -124,7 +118,7 @@ describe("requireBearerAuth middleware", () => { }; const middleware = requireBearerAuth({ - provider: mockProvider, + verifier: mockVerifier, requiredScopes: ["read", "write"] }); @@ -155,7 +149,7 @@ describe("requireBearerAuth middleware", () => { }; const middleware = requireBearerAuth({ - provider: mockProvider, + verifier: mockVerifier, requiredScopes: ["read", "write"] }); @@ -169,7 +163,7 @@ describe("requireBearerAuth middleware", () => { }); it("should return 401 when no Authorization header is present", async () => { - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).not.toHaveBeenCalled(); @@ -189,7 +183,7 @@ describe("requireBearerAuth middleware", () => { authorization: "InvalidFormat", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).not.toHaveBeenCalled(); @@ -214,7 +208,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); @@ -236,7 +230,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -258,7 +252,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -276,7 +270,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -294,7 +288,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -304,4 +298,125 @@ describe("requireBearerAuth middleware", () => { ); expect(nextFunction).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + describe("with resourceMetadataUrl", () => { + const resourceMetadataUrl = "https://api.example.com/.well-known/oauth-protected-resource"; + + it("should include resource_metadata in WWW-Authenticate header for 401 responses", async () => { + mockRequest.headers = {}; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata in WWW-Authenticate header when token verification fails", async () => { + mockRequest.headers = { + authorization: "Bearer invalid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata in WWW-Authenticate header for insufficient scope errors", async () => { + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: admin")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata when token is expired", async () => { + const expiredAuthInfo: AuthInfo = { + token: "expired-token", + clientId: "client-123", + scopes: ["read", "write"], + expiresAt: Math.floor(Date.now() / 1000) - 100, + }; + mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); + + mockRequest.headers = { + authorization: "Bearer expired-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata when scope check fails", async () => { + const authInfo: AuthInfo = { + token: "valid-token", + clientId: "client-123", + scopes: ["read"], + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ["read", "write"], + resourceMetadataUrl + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should not affect server errors (no WWW-Authenticate header)", async () => { + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.set).not.toHaveBeenCalledWith("WWW-Authenticate", expect.anything()); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index cd1b314a..91f763a9 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -1,18 +1,23 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; -import { OAuthServerProvider } from "../provider.js"; +import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; export type BearerAuthMiddlewareOptions = { /** * A provider used to verify tokens. */ - provider: OAuthServerProvider; + verifier: OAuthTokenVerifier; /** * Optional scopes that the token must have. */ requiredScopes?: string[]; + + /** + * Optional resource metadata URL to include in WWW-Authenticate header. + */ + resourceMetadataUrl?: string; }; declare module "express-serve-static-core" { @@ -26,10 +31,13 @@ declare module "express-serve-static-core" { /** * Middleware that requires a valid Bearer token in the Authorization header. - * + * * This will validate the token with the auth provider and add the resulting auth info to the request object. + * + * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header + * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. */ -export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler { +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -42,7 +50,7 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - const authInfo = await provider.verifyAccessToken(token); + const authInfo = await verifier.verifyAccessToken(token); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { @@ -64,20 +72,25 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM next(); } catch (error) { if (error instanceof InvalidTokenError) { - res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + const wwwAuthValue = resourceMetadataUrl + ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` + : `Bearer error="${error.errorCode}", error_description="${error.message}"`; + res.set("WWW-Authenticate", wwwAuthValue); res.status(401).json(error.toResponseObject()); } else if (error instanceof InsufficientScopeError) { - res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + const wwwAuthValue = resourceMetadataUrl + ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` + : `Bearer error="${error.errorCode}", error_description="${error.message}"`; + res.set("WWW-Authenticate", wwwAuthValue); res.status(403).json(error.toResponseObject()); } else if (error instanceof ServerError) { res.status(500).json(error.toResponseObject()); } else if (error instanceof OAuthError) { res.status(400).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating bearer token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } } }; -} \ No newline at end of file +} diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 76049c11..ecd9a7b6 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index dc186bca..18beb216 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,6 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; + resource?: URL; }; /** @@ -20,8 +21,8 @@ export interface OAuthServerProvider { get clientsStore(): OAuthRegisteredClientsStore; /** - * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. - * + * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. + * * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. @@ -36,12 +37,18 @@ export interface OAuthServerProvider { /** * Exchanges an authorization code for an access token. */ - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise; + exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; /** * Verifies an access token and returns information about it. @@ -50,17 +57,28 @@ export interface OAuthServerProvider { /** * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). - * + * * If the given token is invalid or already revoked, this method should do nothing. */ revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; /** * Whether to skip local PKCE validation. - * + * * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. - * + * * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. */ skipLocalPkceValidation?: boolean; -} \ No newline at end of file +} + + +/** + * Slim implementation useful for token verification + */ +export interface OAuthTokenVerifier { + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; +} diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 6e842ea3..4e98d0dc 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: "test-challenge", state: "test-state", scopes: ["read", "write"], + resource: new URL('https://api.example.com/resource'), }, mockResponse ); @@ -100,6 +101,7 @@ describe("Proxy OAuth Server Provider", () => { expectedUrl.searchParams.set("code_challenge_method", "S256"); expectedUrl.searchParams.set("state", "test-state"); expectedUrl.searchParams.set("scope", "read write"); + expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); @@ -142,6 +144,63 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it("includes redirect_uri in token request when provided", async () => { + const redirectUri = "https://example.com/callback"; + const tokens = await provider.exchangeAuthorizationCode( + validClient, + "test-code", + "test-verifier", + redirectUri + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/token", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes resource parameter in authorization code exchange', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier', + 'https://example.com/callback', + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + it("exchanges refresh token for new tokens", async () => { const tokens = await provider.exchangeRefreshToken( validClient, @@ -162,6 +221,26 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in refresh token exchange', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read', 'write'], + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); }); describe("client registration", () => { diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index be450305..de74862b 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); + if (params.resource) searchParams.set("resource", params.resource.href); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -151,7 +152,9 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeAuthorizationCode( client: OAuthClientInformationFull, authorizationCode: string, - codeVerifier?: string + codeVerifier?: string, + redirectUri?: string, + resource?: URL ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -167,6 +170,14 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("code_verifier", codeVerifier); } + if (redirectUri) { + params.append("redirect_uri", redirectUri); + } + + if (resource) { + params.append("resource", resource.href); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { @@ -187,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeRefreshToken( client: OAuthClientInformationFull, refreshToken: string, - scopes?: string[] + scopes?: string[], + resource?: URL ): Promise { const params = new URLSearchParams({ @@ -204,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("scope", scopes.join(" ")); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 86eda221..bcf0a51a 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -1,12 +1,13 @@ -import { mcpAuthRouter, AuthRouterOptions } from './router.js'; +import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from './router.js'; import { OAuthServerProvider, AuthorizationParams } from './provider.js'; import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; import express, { Response } from 'express'; import supertest from 'supertest'; import { AuthInfo } from './types.js'; import { InvalidTokenError } from './errors.js'; + describe('MCP Auth Router', () => { // Setup mock provider with full capabilities const mockClientStore: OAuthRegisteredClientsStore = { @@ -246,6 +247,29 @@ describe('MCP Auth Router', () => { expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); expect(response.body.service_documentation).toBeUndefined(); }); + + it('provides protected resource metadata', async () => { + // Setup router with draft protocol version + const draftApp = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://mcp.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + draftApp.use(mcpAuthRouter(options)); + + const response = await supertest(draftApp) + .get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://mcp.example.com/'); + expect(response.body.authorization_servers).toContain('https://mcp.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + }); }); describe('Endpoint routing', () => { @@ -358,4 +382,101 @@ describe('MCP Auth Router', () => { expect(revokeResponse.status).toBe(404); }); }); -}); \ No newline at end of file +}); + +describe('MCP Auth Metadata Router', () => { + + const mockOAuthMetadata : OAuthMetadata = { + issuer: 'https://auth.example.com/', + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + } + + describe('Router creation', () => { + it('successfully creates router with valid options', () => { + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + }; + + expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoints', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + serviceDocumentationUrl: new URL('https://docs.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + app.use(mcpAuthMetadataRouter(options)); + }); + + it('returns OAuth authorization server metadata', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify metadata points to authorization server + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + }); + + it('returns OAuth protected resource metadata', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://api.example.com/'); + expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + expect(response.body.resource_documentation).toBe('https://docs.example.com/'); + }); + + it('works with minimal configuration', async () => { + const minimalApp = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + }; + minimalApp.use(mcpAuthMetadataRouter(options)); + + const authResponse = await supertest(minimalApp) + .get('/.well-known/oauth-authorization-server'); + + expect(authResponse.status).toBe(200); + expect(authResponse.body.issuer).toBe('https://auth.example.com/'); + expect(authResponse.body.service_documentation).toBeUndefined(); + expect(authResponse.body.scopes_supported).toBeUndefined(); + + const resourceResponse = await supertest(minimalApp) + .get('/.well-known/oauth-protected-resource'); + + expect(resourceResponse.status).toBe(200); + expect(resourceResponse.body.resource).toBe('https://api.example.com/'); + expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(resourceResponse.body.scopes_supported).toBeUndefined(); + expect(resourceResponse.body.resource_name).toBeUndefined(); + expect(resourceResponse.body.resource_documentation).toBeUndefined(); + }); + }); +}); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 49d451c2..3e752e7a 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -5,6 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/au import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js"; import { metadataHandler } from "./handlers/metadata.js"; import { OAuthServerProvider } from "./provider.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../shared/auth.js"; export type AuthRouterOptions = { /** @@ -19,7 +20,7 @@ export type AuthRouterOptions = { /** * The base URL of the authorization server to use for the metadata endpoints. - * + * * If not provided, the issuer URL will be used as the base URL. */ baseUrl?: URL; @@ -29,6 +30,17 @@ export type AuthRouterOptions = { */ serviceDocumentationUrl?: URL; + /** + * An optional list of scopes supported by this authorization server + */ + scopesSupported?: string[]; + + + /** + * The resource name to be displayed in protected resource metadata + */ + resourceName?: string; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; @@ -36,37 +48,37 @@ export type AuthRouterOptions = { tokenOptions?: Omit; }; -/** - * Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. - * - * By default, rate limiting is applied to all endpoints to prevent abuse. - * - * This router MUST be installed at the application root, like so: - * - * const app = express(); - * app.use(mcpAuthRouter(...)); - */ -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { - const issuer = options.issuerUrl; - const baseUrl = options.baseUrl; - +const checkIssuerUrl = (issuer: URL): void => { // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") { throw new Error("Issuer URL must be HTTPS"); } if (issuer.hash) { - throw new Error("Issuer URL must not have a fragment"); + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); } if (issuer.search) { - throw new Error("Issuer URL must not have a query string"); + throw new Error(`Issuer URL must not have a query string: ${issuer}`); } +} + +export const createOAuthMetadata = (options: { + provider: OAuthServerProvider, + issuerUrl: URL, + baseUrl?: URL + serviceDocumentationUrl?: URL, + scopesSupported?: string[]; +}): OAuthMetadata => { + const issuer = options.issuerUrl; + const baseUrl = options.baseUrl; + + checkIssuerUrl(issuer); const authorization_endpoint = "/authorize"; const token_endpoint = "/token"; const registration_endpoint = options.provider.clientsStore.registerClient ? "/register" : undefined; const revocation_endpoint = options.provider.revokeToken ? "/revoke" : undefined; - const metadata = { + const metadata: OAuthMetadata = { issuer: issuer.href, service_documentation: options.serviceDocumentationUrl?.href, @@ -78,29 +90,56 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { token_endpoint_auth_methods_supported: ["client_secret_post"], grant_types_supported: ["authorization_code", "refresh_token"], + scopes_supported: options.scopesSupported, + revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, revocation_endpoint_auth_methods_supported: revocation_endpoint ? ["client_secret_post"] : undefined, registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, }; + return metadata +} + +/** + * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). + * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. + * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. + * + * By default, rate limiting is applied to all endpoints to prevent abuse. + * + * This router MUST be installed at the application root, like so: + * + * const app = express(); + * app.use(mcpAuthRouter(...)); + */ +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { + const oauthMetadata = createOAuthMetadata(options); + const router = express.Router(); router.use( - authorization_endpoint, + new URL(oauthMetadata.authorization_endpoint).pathname, authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) ); router.use( - token_endpoint, + new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }) ); - router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); + router.use(mcpAuthMetadataRouter({ + oauthMetadata, + // This router is used for AS+RS combo's, so the issuer is also the resource server + resourceServerUrl: new URL(oauthMetadata.issuer), + serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName + })); - if (registration_endpoint) { + if (oauthMetadata.registration_endpoint) { router.use( - registration_endpoint, + new URL(oauthMetadata.registration_endpoint).pathname, clientRegistrationHandler({ clientsStore: options.provider.clientsStore, ...options, @@ -108,12 +147,80 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { ); } - if (revocation_endpoint) { + if (oauthMetadata.revocation_endpoint) { router.use( - revocation_endpoint, + new URL(oauthMetadata.revocation_endpoint).pathname, revocationHandler({ provider: options.provider, ...options.revocationOptions }) ); } return router; -} \ No newline at end of file +} + +export type AuthMetadataOptions = { + /** + * OAuth Metadata as would be returned from the authorization server + * this MCP server relies on + */ + oauthMetadata: OAuthMetadata; + + /** + * The url of the MCP server, for use in protected resource metadata + */ + resourceServerUrl: URL; + + /** + * The url for documentation for the MCP server + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this MCP server + */ + scopesSupported?: string[]; + + /** + * An optional resource name to display in resource metadata + */ + resourceName?: string; +} + +export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { + checkIssuerUrl(new URL(options.oauthMetadata.issuer)); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.resourceServerUrl.href, + + authorization_servers: [ + options.oauthMetadata.issuer + ], + + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href, + }; + + router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata)); + + // Always add this for backwards compatibility + router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata)); + + return router; +} + +/** + * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL + * from a given server URL. This replaces the path with the standard metadata endpoint. + * + * @param serverUrl - The base URL of the protected resource server + * @returns The URL for the OAuth protected resource metadata endpoint + * + * @example + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource' + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { + return new URL('/.well-known/oauth-protected-resource', serverUrl).href; +} diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b60..0189e9ed 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -22,6 +22,12 @@ export interface AuthInfo { */ expiresAt?: number; + /** + * The RFC 8707 resource server identifier for which this token is valid. + * If set, this MUST match the MCP server's resource identifier (minus hash fragment). + */ + resource?: URL; + /** * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. diff --git a/src/server/completable.ts b/src/server/completable.ts index 3b5bc164..652eaf72 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -15,6 +15,9 @@ export enum McpZodTypeKind { export type CompleteCallback = ( value: T["_input"], + context?: { + arguments?: Record; + }, ) => T["_input"][] | Promise; export interface CompletableDef diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7c0fbc51..d91b90a9 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -10,11 +10,12 @@ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, CreateMessageRequestSchema, + ElicitRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, - ErrorCode, + ErrorCode } from "../types.js"; import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; @@ -267,6 +268,318 @@ test("should respect client capabilities", async () => { await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); }); +test("should respect client elicitation capabilities", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + client.setRequestHandler(ElicitRequestSchema, (params) => ({ + action: "accept", + content: { + username: params.params.message.includes("username") ? "test-user" : undefined, + confirmed: true, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: "Please provide your username", + requestedSchema: { + type: "object", + properties: { + username: { + type: "string", + title: "Username", + description: "Your username", + }, + confirmed: { + type: "boolean", + title: "Confirm", + description: "Please confirm", + default: false, + }, + }, + required: ["username"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10, + }), + ).rejects.toThrow(/^Client does not support/); +}); + +test("should validate elicitation response against requested schema", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with valid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + }); +}); + +test("should reject elicitation response with invalid data", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + email: "", // Invalid - too short + age: -5, // Invalid age + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with invalid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).rejects.toThrow(/does not match requested schema/); +}); + +test("should allow elicitation reject and cancel without validation", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, (request) => { + requestCount++; + if (requestCount === 1) { + return { action: "reject" }; + } else { + return { action: "cancel" }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const schema = { + type: "object" as const, + properties: { + name: { type: "string" as const }, + }, + required: ["name"], + }; + + // Test reject - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "reject", + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "cancel", + }); +}); + test("should respect server notification capabilities", async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..10ae2fad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,9 @@ import { ClientCapabilities, CreateMessageRequest, CreateMessageResultSchema, + ElicitRequest, + ElicitResult, + ElicitResultSchema, EmptyResultSchema, Implementation, InitializedNotificationSchema, @@ -18,6 +21,8 @@ import { ListRootsRequest, ListRootsResultSchema, LoggingMessageNotification, + McpError, + ErrorCode, Notification, Request, ResourceUpdatedNotification, @@ -28,6 +33,7 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; +import Ajv from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -129,6 +135,14 @@ export class Server< } break; + case "elicitation/create": + if (!this._clientCapabilities?.elicitation) { + throw new Error( + `Client does not support elicitation (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._clientCapabilities?.roots) { throw new Error( @@ -251,10 +265,12 @@ export class Server< this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - return { - protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion - : LATEST_PROTOCOL_VERSION, + : LATEST_PROTOCOL_VERSION; + + return { + protocolVersion, capabilities: this.getCapabilities(), serverInfo: this._serverInfo, ...(this._instructions && { instructions: this._instructions }), @@ -294,6 +310,44 @@ export class Server< ); } + async elicitInput( + params: ElicitRequest["params"], + options?: RequestOptions, + ): Promise { + const result = await this.request( + { method: "elicitation/create", params }, + ElicitResultSchema, + options, + ); + + // Validate the response content against the requested schema if action is "accept" + if (result.action === "accept" && result.content) { + try { + const ajv = new Ajv(); + + const validate = ajv.compile(params.requestedSchema); + const isValid = validate(result.content); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`, + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error}`, + ); + } + } + + return result; + } + async listRoots( params?: ListRootsRequest["params"], options?: RequestOptions, diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c9be5c76..dc96a1b0 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -13,12 +13,18 @@ import { CompleteResultSchema, LoggingMessageNotificationSchema, Notification, + TextContent, + ElicitRequestSchema } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { getDisplayName } from "../shared/metadataUtils.js"; describe("McpServer", () => { + /*** + * Test: Basic Server Instance + */ test("should expose underlying Server instance", () => { const mcpServer = new McpServer({ name: "test server", @@ -28,6 +34,9 @@ describe("McpServer", () => { expect(mcpServer.server).toBeDefined(); }); + /*** + * Test: Notification Sending via Server + */ test("should allow sending notifications via Server", async () => { const mcpServer = new McpServer( { @@ -72,9 +81,105 @@ describe("McpServer", () => { } ]) }); + + /*** + * Test: Progress Notification with Message Field + */ + test("should send progress notifications with message field", async () => { + const mcpServer = new McpServer( + { + name: "test server", + version: "1.0", + } + ); + + // Create a tool that sends progress updates + mcpServer.tool( + "long-operation", + "A long running operation with progress updates", + { + steps: z.number().min(1).describe("Number of steps to perform"), + }, + async ({ steps }, { sendNotification, _meta }) => { + const progressToken = _meta?.progressToken; + + if (progressToken) { + // Send progress notification for each step + for (let i = 1; i <= steps; i++) { + await sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: i, + total: steps, + message: `Completed step ${i} of ${steps}`, + }, + }); + } + } + + return { content: [{ type: "text" as const, text: `Operation completed with ${steps} steps` }] }; + } + ); + + const progressUpdates: Array<{ progress: number, total?: number, message?: string }> = []; + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool with progress tracking + await client.request( + { + method: "tools/call", + params: { + name: "long-operation", + arguments: { steps: 3 }, + _meta: { + progressToken: "progress-test-1" + } + } + }, + CallToolResultSchema, + { + onprogress: (progress) => { + progressUpdates.push(progress); + } + } + ); + + // Verify progress notifications were received with message field + expect(progressUpdates).toHaveLength(3); + expect(progressUpdates[0]).toMatchObject({ + progress: 1, + total: 3, + message: "Completed step 1 of 3", + }); + expect(progressUpdates[1]).toMatchObject({ + progress: 2, + total: 3, + message: "Completed step 2 of 3", + }); + expect(progressUpdates[2]).toMatchObject({ + progress: 3, + total: 3, + message: "Completed step 3 of 3", + }); + }); }); describe("ResourceTemplate", () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ test("should create ResourceTemplate with string pattern", () => { const template = new ResourceTemplate("test://{category}/{id}", { list: undefined, @@ -83,6 +188,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ test("should create ResourceTemplate with UriTemplate", () => { const uriTemplate = new UriTemplate("test://{category}/{id}"); const template = new ResourceTemplate(uriTemplate, { list: undefined }); @@ -90,6 +198,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate with List Callback + */ test("should create ResourceTemplate with list callback", async () => { const list = jest.fn().mockResolvedValue({ resources: [{ name: "Test", uri: "test://example" }], @@ -111,6 +222,9 @@ describe("ResourceTemplate", () => { }); describe("tool()", () => { + /*** + * Test: Zero-Argument Tool Registration + */ test("should register zero-argument tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -153,6 +267,7 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toEqual({ type: "object", + properties: {}, }); // Adding the tool before the connection was established means no notification was sent @@ -178,6 +293,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Updating Existing Tool + */ test("should update existing tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -244,6 +362,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Updating Tool with Schema + */ test("should update tool with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -280,7 +401,7 @@ describe("tool()", () => { name: z.string(), value: z.number(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ content: [ { type: "text", @@ -339,6 +460,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Tool List Changed Notifications + */ test("should send tool list changed notifications when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -404,6 +528,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Tool Registration with Parameters + */ test("should register tool with params", async () => { const mcpServer = new McpServer({ name: "test server", @@ -414,6 +541,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool( "test", { @@ -430,6 +558,17 @@ describe("tool()", () => { }), ); + // new api + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { name: z.string(), value: z.number() }, + }, + async ({ name, value }) => ({ + content: [{ type: "text", text: `${name}: ${value}` }], + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -445,7 +584,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", @@ -454,8 +593,13 @@ describe("tool()", () => { value: { type: "number" }, }, }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); }); + /*** + * Test: Tool Registration with Description + */ test("should register tool with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -466,6 +610,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool("test", "Test description", async () => ({ content: [ { @@ -475,6 +620,23 @@ describe("tool()", () => { ], })); + // new api + mcpServer.registerTool( + "test (new api)", + { + description: "Test description", + }, + async () => ({ + content: [ + { + type: "text" as const, + text: "Test response", + }, + ], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -490,11 +652,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("Test description"); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("Test description"); }); - + + /*** + * Test: Tool Registration with Annotations + */ test("should register tool with annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -514,6 +681,21 @@ describe("tool()", () => { ], })); + mcpServer.registerTool( + "test (new api)", + { + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async () => ({ + content: [ + { + type: "text" as const, + text: "Test response", + }, + ], + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -529,11 +711,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); }); - + + /*** + * Test: Tool Registration with Parameters and Annotations + */ test("should register tool with params and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -545,7 +732,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", { name: z.string() }, { title: "Test Tool", readOnlyHint: true }, async ({ name }) => ({ @@ -553,6 +740,17 @@ describe("tool()", () => { }) ); + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { name: z.string() }, + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -566,15 +764,21 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", properties: { name: { type: "string" } } }); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); - + + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ test("should register tool with description, params, and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -586,7 +790,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", "A tool with everything", { name: z.string() }, { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, @@ -595,6 +799,18 @@ describe("tool()", () => { }) ); + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything", + inputSchema: { name: z.string() }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -608,37 +824,102 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("A tool with everything"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", properties: { name: { type: "string" } } }); - expect(result.tools[0].annotations).toEqual({ - title: "Complete Test Tool", + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ + test("should register tool with description, empty params, and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + "A tool with everything but empty params", + {}, + { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + async () => ({ + content: [{ type: "text", text: "Test response" }] + }) + ); + + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything but empty params", + inputSchema: {}, + annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + }, + async () => ({ + content: [{ type: "text" as const, text: "Test response" }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].description).toBe("A tool with everything but empty params"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: {} + }); + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool with empty params", + readOnlyHint: true, + openWorldHint: false + }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything but empty params"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); + }); + + /*** + * Test: Tool Argument Validation + */ test("should validate tool args", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -656,6 +937,24 @@ describe("tool()", () => { }), ); + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { + name: z.string(), + value: z.number(), + }, + }, + async ({ name, value }) => ({ + content: [ + { + type: "text", + text: `${name}: ${value}`, + }, + ], + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -679,8 +978,27 @@ describe("tool()", () => { CallToolResultSchema, ), ).rejects.toThrow(/Invalid arguments/); + + await expect( + client.request( + { + method: "tools/call", + params: { + name: "test (new api)", + arguments: { + name: "test", + value: "not a number", + }, + }, + }, + CallToolResultSchema, + ), + ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Tool Registration + */ test("should prevent duplicate tool registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -708,6 +1026,9 @@ describe("tool()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Tool Registration + */ test("should allow registering multiple tools", () => { const mcpServer = new McpServer({ name: "test server", @@ -721,23 +1042,312 @@ describe("tool()", () => { mcpServer.tool("tool2", () => ({ content: [] })); }); + /*** + * Test: Tool with Output Schema and Structured Content + */ + test("should support tool with outputSchema and structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema + mcpServer.registerTool( + "test", + { + description: "Test tool with structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }), + }, + ] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].outputSchema).toMatchObject({ + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string" } + }, + required: ["processedInput", "resultType", "timestamp"] + }); + + // Call the tool and verify it returns valid structuredContent + const result = await client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + input: "hello", + }, + }, + }, + CallToolResultSchema, + ); + + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe("hello"); + expect(structuredContent.resultType).toBe("structured"); + expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z"); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: "text" }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); + }); + + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should throw error when tool with outputSchema returns no structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: "text", + text: `Processed: ${input}`, + }, + ], + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to throw an error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); + }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should skip outputSchema validation when isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + content: [ + { + type: "text", + text: `Processed: ${input}`, + }, + ], + isError: true, + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, + }); + }); + + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test("should fail schema validation when tool returns invalid structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema that returns invalid data + mcpServer.registerTool( + "test", + { + description: "Test tool with invalid structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + }, + async ({ input }) => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }), + }, + ], + structuredContent: { + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + } as unknown as { processedInput: string; resultType: string; timestamp: string }, // Type assertion to bypass TypeScript validation for testing purposes + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to throw a server-side validation error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).rejects.toThrow(/Invalid structured content for tool test/); + }); + + /*** + * Test: Pass Session ID to Tool Callback + */ test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedSessionId: string | undefined; mcpServer.tool("test-tool", async (extra) => { @@ -774,23 +1384,19 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + /*** + * Test: Pass Request ID to Tool Callback + */ test("should pass requestId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.tool("request-id-test", async (extra) => { @@ -824,9 +1430,12 @@ describe("tool()", () => { expect(receivedRequestId).toBeDefined(); expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content[0].text).toContain("Received request ID:"); + expect(result.content && result.content[0].text).toContain("Received request ID:"); }); + /*** + * Test: Send Notification within Tool Call + */ test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { @@ -836,17 +1445,10 @@ describe("tool()", () => { { capabilities: { logging: {} } }, ); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedLogMessage: string | undefined; const loggingMessage = "hello here is log message 1"; @@ -884,23 +1486,19 @@ describe("tool()", () => { expect(receivedLogMessage).toBe(loggingMessage); }); + /*** + * Test: Client to Server Tool Call + */ test("should allow client to call server tools", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -947,23 +1545,19 @@ describe("tool()", () => { ]); }); + /*** + * Test: Graceful Tool Error Handling + */ test("should handle server tool errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("error-test", async () => { throw new Error("Tool execution failed"); @@ -996,23 +1590,19 @@ describe("tool()", () => { ]); }); + /*** + * Test: McpError for Invalid Tool Name + */ test("should throw McpError for invalid tool name", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("test-tool", async () => ({ content: [ @@ -1046,6 +1636,9 @@ describe("tool()", () => { }); describe("resource()", () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ test("should register resource with uri and readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1085,6 +1678,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource"); }); + /*** + * Test: Update Resource with URI + */ test("should update resource with uri", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1147,6 +1743,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Update Resource Template + */ test("should update resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1213,6 +1812,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Resource List Changed Notification + */ test("should send resource list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1266,6 +1868,9 @@ describe("resource()", () => { ]); }); + /*** + * Test: Remove Resource and Send Notification + */ test("should remove resource and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1327,6 +1932,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource2"); }); + /*** + * Test: Remove Resource Template and Send Notification + */ test("should remove resource template and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1391,6 +1999,9 @@ describe("resource()", () => { expect(result2.resourceTemplates).toHaveLength(0); }); + /*** + * Test: Resource Registration with Metadata + */ test("should register resource with metadata", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1438,6 +2049,9 @@ describe("resource()", () => { expect(result.resources[0].mimeType).toBe("text/plain"); }); + /*** + * Test: Resource Template Registration + */ test("should register resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1483,6 +2097,9 @@ describe("resource()", () => { ); }); + /*** + * Test: Resource Template with List Callback + */ test("should register resource template with listCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1541,6 +2158,9 @@ describe("resource()", () => { expect(result.resources[1].uri).toBe("test://resource/2"); }); + /*** + * Test: Template Variables to Read Callback + */ test("should pass template variables to readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1587,6 +2207,9 @@ describe("resource()", () => { expect(result.contents[0].text).toBe("Category: books, ID: 123"); }); + /*** + * Test: Preventing Duplicate Resource Registration + */ test("should prevent duplicate resource registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1614,6 +2237,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Resource Registration + */ test("should allow registering multiple resources", () => { const mcpServer = new McpServer({ name: "test server", @@ -1641,6 +2267,9 @@ describe("resource()", () => { })); }); + /*** + * Test: Preventing Duplicate Resource Template Registration + */ test("should prevent duplicate resource template registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1676,6 +2305,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Graceful Resource Read Error Handling + */ test("should handle resource read errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1711,6 +2343,9 @@ describe("resource()", () => { ).rejects.toThrow(/Resource read failed/); }); + /*** + * Test: McpError for Invalid Resource URI + */ test("should throw McpError for invalid resource URI", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1751,23 +2386,61 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a resource template with a complete callback is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{category}", { + list: undefined, + complete: { + category: () => ["books", "movies", "music"], + }, + }), + async () => ({ + contents: [ + { + uri: "test://resource/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + + /*** + * Test: Resource Template Parameter Completion + */ test("should support completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -1816,23 +2489,19 @@ describe("resource()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Resource Template Parameter Completion + */ test("should support filtered completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -1884,23 +2553,19 @@ describe("resource()", () => { expect(result.completion.total).toBe(2); }); + /*** + * Test: Pass Request ID to Resource Callback + */ test("should pass requestId to resource callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { @@ -1939,6 +2604,9 @@ describe("resource()", () => { }); describe("prompt()", () => { + /*** + * Test: Zero-Argument Prompt Registration + */ test("should register zero-argument prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1980,6 +2648,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("test"); expect(result.prompts[0].arguments).toBeUndefined(); }); + /*** + * Test: Updating Existing Prompt + */ test("should update existing prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2048,6 +2719,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Updating Prompt with Schema + */ test("should update prompt with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2087,7 +2761,7 @@ describe("prompt()", () => { name: z.string(), value: z.string(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ messages: [ { role: "assistant", @@ -2141,6 +2815,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Prompt List Changed Notification + */ test("should send prompt list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2200,6 +2877,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Remove Prompt and Send Notification + */ test("should remove prompt and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2278,6 +2958,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("prompt2"); }); + /*** + * Test: Prompt Registration with Arguments Schema + */ test("should register prompt with args schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2330,6 +3013,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Prompt Registration with Description + */ test("should register prompt with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2372,23 +3058,19 @@ describe("prompt()", () => { expect(result.prompts[0].description).toBe("Test description"); }); + /*** + * Test: Prompt Argument Validation + */ test("should validate prompt args", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test", @@ -2434,6 +3116,9 @@ describe("prompt()", () => { ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Prompt Registration + */ test("should prevent duplicate prompt registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -2467,6 +3152,9 @@ describe("prompt()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Prompt Registration + */ test("should allow registering multiple prompts", () => { const mcpServer = new McpServer({ name: "test server", @@ -2500,6 +3188,9 @@ describe("prompt()", () => { })); }); + /*** + * Test: Prompt Registration with Arguments + */ test("should allow registering prompts with arguments", () => { const mcpServer = new McpServer({ name: "test server", @@ -2522,6 +3213,9 @@ describe("prompt()", () => { ); }); + /*** + * Test: Resources and Prompts with Completion Handlers + */ test("should allow registering both resources and prompts with completion handlers", () => { const mcpServer = new McpServer({ name: "test server", @@ -2563,23 +3257,19 @@ describe("prompt()", () => { ); }); + /*** + * Test: McpError for Invalid Prompt Name + */ test("should throw McpError for invalid prompt name", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt("test-prompt", async () => ({ messages: [ @@ -2614,23 +3304,62 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a prompt with a completable argument is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + + /*** + * Test: Prompt Argument Completion + */ test("should support completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -2679,23 +3408,19 @@ describe("prompt()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Prompt Argument Completion + */ test("should support filtered completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -2746,23 +3471,19 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); + /*** + * Test: Pass Request ID to Prompt Callback + */ test("should pass requestId to prompt callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.prompt("request-id-test", async (extra) => { @@ -2801,4 +3522,772 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); + + /*** + * Test: Resource Template Metadata Priority + */ + test("should prioritize individual resource metadata over template metadata", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{id}", { + list: async () => ({ + resources: [ + { + name: "Resource 1", + uri: "test://resource/1", + description: "Individual resource description", + mimeType: "text/plain", + }, + { + name: "Resource 2", + uri: "test://resource/2", + // This resource has no description or mimeType + }, + ], + }), + }), + { + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0].name).toBe("Resource 1"); + expect(result.resources[0].description).toBe("Individual resource description"); + expect(result.resources[0].mimeType).toBe("text/plain"); + + // Resource 2 should inherit template metadata + expect(result.resources[1].name).toBe("Resource 2"); + expect(result.resources[1].description).toBe("Template description"); + expect(result.resources[1].mimeType).toBe("application/json"); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test("should allow resource to override all template metadata fields", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{id}", { + list: async () => ({ + resources: [ + { + name: "Overridden Name", + uri: "test://resource/1", + description: "Overridden description", + mimeType: "text/markdown", + // Add any other metadata fields if they exist + }, + ], + }), + }), + { + name: "Template Name", + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe("Overridden Name"); + expect(result.resources[0].description).toBe("Overridden description"); + expect(result.resources[0].mimeType).toBe("text/markdown"); + }); +}); + +describe("Tool title precedence", () => { + test("should follow correct title precedence: title → annotations.title → name", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Tool 1: Only name + mcpServer.tool( + "tool_name_only", + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 2: Name and annotations.title + mcpServer.tool( + "tool_with_annotations_title", + "Tool with annotations title", + { + title: "Annotations Title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + "tool_with_title", + { + title: "Regular Title", + description: "Tool with regular title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + "tool_with_all_titles", + { + title: "Regular Title Wins", + description: "Tool with all titles", + annotations: { + title: "Annotations Title Should Not Show" + } + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === "tool_name_only"); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe("tool_name_only"); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === "tool_with_annotations_title"); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe("Annotations Title"); + expect(getDisplayName(tool2!)).toBe("Annotations Title"); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === "tool_with_title"); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe("Regular Title"); + expect(getDisplayName(tool3!)).toBe("Regular Title"); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === "tool_with_all_titles"); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe("Regular Title Wins"); + expect(tool4!.annotations?.title).toBe("Annotations Title Should Not Show"); + expect(getDisplayName(tool4!)).toBe("Regular Title Wins"); + }); + + test("getDisplayName unit tests for title precedence", () => { + + // Test 1: Only name + expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); + + // Test 2: Name and title - title wins + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" + })).toBe("Tool Title"); + + // Test 3: Name and annotations.title - annotations.title wins + expect(getDisplayName({ + name: "tool_name", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 4: All three - title wins (correct precedence) + expect(getDisplayName({ + name: "tool_name", + title: "Regular Title", + annotations: { title: "Annotations Title" } + })).toBe("Regular Title"); + + // Test 5: Empty title should not be used + expect(getDisplayName({ + name: "tool_name", + title: "", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 6: Undefined vs null handling + expect(getDisplayName({ + name: "tool_name", + title: undefined, + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + }); + + test("should support resource template completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerResource( + "test", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } else if (context?.arguments?.["owner"] === "org2") { + return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value)); + } + return []; + }, + }, + }), + { + title: "GitHub Repository", + description: "Repository information" + }, + async () => ({ + contents: [ + { + uri: "github://repos/test/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with microsoft owner + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "p", + }, + context: { + arguments: { + owner: "org1", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["project1", "project2", "project3"]); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "r", + }, + context: { + arguments: { + owner: "org2", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "t", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test("should support prompt argument completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerPrompt( + "test-prompt", + { + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + department: completable(z.string(), (value) => { + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + const department = context?.arguments?.["department"]; + if (department === "engineering") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (department === "sales") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + } + }, + async ({ department, name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}, welcome to the ${department} team!`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with engineering department + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "A", + }, + context: { + arguments: { + department: "engineering", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["Alice"]); + + // Test with sales department + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "D", + }, + context: { + arguments: { + department: "sales", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["David"]); + + // Test with marketing department + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + context: { + arguments: { + department: "marketing", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Grace"]); + + // Test with no resolved context + const result4 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result4.completion.values).toEqual(["Guest"]); + }); +}); + +describe("elicitInput()", () => { + + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + } + ); + }); + + test("should successfully elicit additional information", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); + + test("should handle user declining to elicitation request", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to reject alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 6204eb2a..a5624e15 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -21,7 +21,8 @@ import { CompleteRequest, CompleteResult, PromptReference, - ResourceReference, + ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -90,7 +91,7 @@ export class McpServer { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -111,16 +112,26 @@ export class McpServer { ([, tool]) => tool.enabled, ).map( ([name, tool]): Tool => { - return { + const toolDefinition: Tool = { name, + title: tool.title, description: tool.description, inputSchema: tool.inputSchema ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - }) as Tool["inputSchema"]) + strictUnions: true, + }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, }; + + if (tool.outputSchema) { + toolDefinition.outputSchema = zodToJsonSchema( + tool.outputSchema, + { strictUnions: true } + ) as Tool["outputSchema"]; + } + + return toolDefinition; }, ), }), @@ -144,6 +155,8 @@ export class McpServer { ); } + let result: CallToolResult; + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -156,11 +169,11 @@ export class McpServer { } const args = parseResult.data; - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)); + result = await Promise.resolve(cb(args, extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -171,11 +184,11 @@ export class McpServer { }; } } else { - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(extra)); + result = await Promise.resolve(cb(extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -186,6 +199,28 @@ export class McpServer { }; } } + + if (tool.outputSchema && !result.isError) { + if (!result.structuredContent) { + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} has an output schema but no structured content was provided`, + ); + } + + // if the tool has an output schema, validate structured content + const parseResult = await tool.outputSchema.safeParseAsync( + result.structuredContent, + ); + if (!parseResult.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Invalid structured content for tool ${request.params.name}: ${parseResult.error.message}`, + ); + } + } + + return result; }, ); @@ -203,6 +238,10 @@ export class McpServer { CompleteRequestSchema.shape.method.value, ); + this.server.registerCapabilities({ + completions: {}, + }); + this.server.setRequestHandler( CompleteRequestSchema, async (request): Promise => { @@ -254,13 +293,13 @@ export class McpServer { } const def: CompletableDef = field._def; - const suggestions = await def.complete(request.params.argument.value); + const suggestions = await def.complete(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } private async handleResourceCompletion( request: CompleteRequest, - ref: ResourceReference, + ref: ResourceTemplateReference, ): Promise { const template = Object.values(this._registeredResourceTemplates).find( (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, @@ -285,7 +324,7 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -336,8 +375,9 @@ export class McpServer { const result = await template.resourceTemplate.listCallback(extra); for (const resource of result.resources) { templateResources.push({ - ...resource, ...template.metadata, + // the defined resource metadata should override the template metadata if present + ...resource, }); } } @@ -398,7 +438,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -431,6 +471,7 @@ export class McpServer { ([name, prompt]): Prompt => { return { name, + title: prompt.title, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) @@ -481,7 +522,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -538,27 +579,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -568,27 +595,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -596,6 +609,206 @@ export class McpServer { } } + /** + * Registers a resource with a config object and callback. + * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. + */ + registerResource( + name: string, + uriOrTemplate: string, + config: ResourceMetadata, + readCallback: ReadResourceCallback + ): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; + registerResource( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === "string") { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource = this._createRegisteredResource( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; + } else { + if (this._registeredResourceTemplates[name]) { + throw new Error(`Resource template ${name} is already registered`); + } + + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const registeredResource: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uri) { + delete this._registeredResources[uri] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.title !== "undefined") registeredResource.title = updates.title + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uri] = registeredResource; + return registeredResource; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.title !== "undefined") registeredResourceTemplate.title = updates.title + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + return registeredResourceTemplate; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: PromptArgsRawShape | undefined, + callback: PromptCallback + ): RegisteredPrompt { + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + return registeredPrompt; + } + + private _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: ZodRawShape | undefined, + outputSchema: ZodRawShape | undefined, + annotations: ToolAnnotations | undefined, + callback: ToolCallback + ): RegisteredTool { + const registeredTool: RegisteredTool = { + title, + description, + inputSchema: + inputSchema === undefined ? undefined : z.object(inputSchema), + outputSchema: + outputSchema === undefined ? undefined : z.object(outputSchema), + annotations, + callback, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool + } + if (typeof updates.title !== "undefined") registeredTool.title = updates.title + if (typeof updates.description !== "undefined") registeredTool.description = updates.description + if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback + if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations + if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled + this.sendToolListChanged() + }, + }; + this._registeredTools[name] = registeredTool; + + this.setToolRequestHandlers(); + this.sendToolListChanged() + + return registeredTool + } + /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ @@ -633,7 +846,7 @@ export class McpServer { paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with both parameter schema and annotations. */ @@ -643,7 +856,7 @@ export class McpServer { annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with description, parameter schema, and annotations. */ @@ -655,36 +868,38 @@ export class McpServer { cb: ToolCallback, ): RegisteredTool; + + /** + * tool() implementation. Parses arguments passed to overrides defined above. + */ tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } - - // Helper to check if an object is a Zod schema (ZodRawShape) - const isZodRawShape = (obj: unknown): obj is ZodRawShape => { - if (typeof obj !== "object" || obj === null) return false; - // Check that at least one property is a ZodType instance - return Object.values(obj as object).some(v => v instanceof ZodType); - }; let description: string | undefined; + let inputSchema: ZodRawShape | undefined; + let outputSchema: ZodRawShape | undefined; + let annotations: ToolAnnotations | undefined; + + // Tool properties are passed as separate arguments, with omissions allowed. + // Support for this style is frozen as of protocol version 2025-03-26. Future additions + // to tool definition should *NOT* be added. + if (typeof rest[0] === "string") { description = rest.shift() as string; } - let paramsSchema: ZodRawShape | undefined; - let annotations: ToolAnnotations | undefined; - // Handle the different overload combinations if (rest.length > 1) { - // We have at least two more args before the callback + // We have at least one more arg before the callback const firstArg = rest[0]; - + if (isZodRawShape(firstArg)) { // We have a params schema as the first arg - paramsSchema = rest.shift() as ZodRawShape; - - // Check if the next arg is potentially annotations + inputSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { // Case: tool(name, paramsSchema, annotations, cb) // Or: tool(name, description, paramsSchema, annotations, cb) @@ -697,37 +912,40 @@ export class McpServer { annotations = rest.shift() as ToolAnnotations; } } + const callback = rest[0] as ToolCallback; - const cb = rest[0] as ToolCallback; - const registeredTool: RegisteredTool = { + return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback) + } + + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + }, + cb: ToolCallback + ): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } + + const { title, description, inputSchema, outputSchema, annotations } = config; + + return this._createRegisteredTool( + name, + title, description, - inputSchema: - paramsSchema === undefined ? undefined : z.object(paramsSchema), + inputSchema, + outputSchema, annotations, - callback: cb, - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredTools[name] - if (updates.name) this._registeredTools[updates.name] = registeredTool - } - if (typeof updates.description !== "undefined") registeredTool.description = updates.description - if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) - if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback - if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations - if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled - this.sendToolListChanged() - }, - }; - this._registeredTools[name] = registeredTool; - - this.setToolRequestHandlers(); - this.sendToolListChanged() - - return registeredTool + cb as ToolCallback + ); } /** @@ -775,27 +993,13 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, + undefined, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -803,6 +1007,38 @@ export class McpServer { return registeredPrompt } + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, argsSchema } = config; + + const registeredPrompt = this._createRegisteredPrompt( + name, + title, + description, + argsSchema, + cb as PromptCallback + ); + + this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -844,6 +1080,9 @@ export class McpServer { */ export type CompleteResourceTemplateCallback = ( value: string, + context?: { + arguments?: Record; + }, ) => string[] | Promise; /** @@ -899,35 +1138,91 @@ export class ResourceTemplate { } } +/** + * Type helper to create a strongly-typed CallToolResult with structuredContent + */ +type TypedCallToolResult = + OutputArgs extends ZodRawShape + ? CallToolResult & { + structuredContent?: z.objectOutputType; + } + : CallToolResult; + /** * Callback for a tool handler registered with Server.tool(). * * Parameters will include tool arguments, if applicable, as well as other request handler context. + * + * The callback should return: + * - `structuredContent` if the tool has an outputSchema defined + * - `content` if the tool does not have an outputSchema + * - Both fields are optional but typically one should be provided */ -export type ToolCallback = - Args extends ZodRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; +export type ToolCallback< + InputArgs extends undefined | ZodRawShape = undefined, + OutputArgs extends undefined | ZodRawShape = undefined +> = InputArgs extends ZodRawShape + ? ( + args: z.objectOutputType, + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise> + : ( + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise>; export type RegisteredTool = { + title?: string; description?: string; inputSchema?: AnyZodObject; + outputSchema?: AnyZodObject; annotations?: ToolAnnotations; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void - remove(): void + update< + InputArgs extends ZodRawShape, + OutputArgs extends ZodRawShape + >(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + callback?: ToolCallback + enabled?: boolean + }): void; + remove(): void; }; const EMPTY_OBJECT_JSON_SCHEMA = { type: "object" as const, + properties: {}, }; +// Helper to check if an object is a Zod schema (ZodRawShape) +function isZodRawShape(obj: unknown): obj is ZodRawShape { + if (typeof obj !== "object" || obj === null) return false; + + const isEmptyObject = Object.keys(obj).length === 0; + + // Check if object is empty or at least one property is a ZodType instance + // Note: use heuristic check to avoid instanceof failure across different Zod versions + return isEmptyObject || Object.values(obj as object).some(isZodTypeLike); +} + +function isZodTypeLike(value: unknown): value is ZodType { + return value !== null && + typeof value === 'object' && + 'parse' in value && typeof value.parse === 'function' && + 'safeParse' in value && typeof value.safeParse === 'function'; +} + /** * Additional, optional information for annotating a resource. */ @@ -950,12 +1245,13 @@ export type ReadResourceCallback = ( export type RegisteredResource = { name: string; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void + update(updates: { name?: string, title?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void remove(): void }; @@ -970,38 +1266,40 @@ export type ReadResourceTemplateCallback = ( export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; type PromptArgsRawShape = { [k: string]: - | ZodType - | ZodOptional>; + | ZodType + | ZodOptional>; }; export type PromptCallback< Args extends undefined | PromptArgsRawShape = undefined, > = Args extends PromptArgsRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { + title?: string; description?: string; argsSchema?: ZodObject; callback: PromptCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..a7f18096 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,20 +1,176 @@ import http from 'http'; import { jest } from '@jest/globals'; import { SSEServerTransport } from './sse.js'; +import { McpServer } from './mcp.js'; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { z } from 'zod'; +import { CallToolResult, JSONRPCMessage } from 'src/types.js'; const createMockResponse = () => { const res = { - writeHead: jest.fn(), - write: jest.fn().mockReturnValue(true), - on: jest.fn(), + writeHead: jest.fn().mockReturnThis(), + write: jest.fn().mockReturnThis(), + on: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), }; - res.writeHead.mockReturnThis(); - res.on.mockReturnThis(); - return res as unknown as http.ServerResponse; + return res as unknown as jest.Mocked; }; +const createMockRequest = ({ headers = {}, body }: { headers?: Record, body?: string } = {}) => { + const mockReq = { + headers, + body: body ? body : undefined, + auth: { + token: 'test-token', + }, + on: jest.fn().mockImplementation((event, listener) => { + const mockListener = listener as unknown as (...args: unknown[]) => void; + if (event === 'data') { + mockListener(Buffer.from(body || '') as unknown as Error); + } + if (event === 'error') { + mockListener(new Error('test')); + } + if (event === 'end') { + mockListener(); + } + if (event === 'close') { + setTimeout(listener, 100); + } + return mockReq; + }), + listeners: jest.fn(), + removeListener: jest.fn(), + } as unknown as http.IncomingMessage; + + return mockReq; +}; + +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServerWithSse(args: { + mockRes: http.ServerResponse; +}): Promise<{ + server: Server; + transport: SSEServerTransport; + mcpServer: McpServer; + baseUrl: URL; + sessionId: string + serverPort: number; +}> { + 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 endpoint = '/messages'; + + const transport = new SSEServerTransport(endpoint, args.mockRes); + const sessionId = transport.sessionId; + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + await transport.handlePostMessage(req, res); + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + const port = (server.address() as AddressInfo).port; + + return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; +} + +async function readAllSSEEvents(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('No readable stream'); + + const events: string[] = []; + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value) { + events.push(decoder.decode(value)); + } + } + } finally { + reader.releaseLock(); + } + + return events; +} + +/** + * Helper to send JSON-RPC request + */ +async function sendSsePostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...extraHeaders + }; + + if (sessionId) { + baseUrl.searchParams.set('sessionId', sessionId); + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + describe('SSEServerTransport', () => { + + async function initializeServer(baseUrl: URL): Promise { + const response = await sendSsePostRequest(baseUrl, { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + capabilities: { + }, + }, + + id: "init-1", + } as JSONRPCMessage); + + expect(response.status).toBe(202); + + const text = await readAllSSEEvents(response); + + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } + describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { const mockRes = createMockResponse(); @@ -105,5 +261,456 @@ describe('SSEServerTransport', () => { `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` ); }); + + /** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + const mockRes = createMockResponse(); + const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); + await initializeServer(baseUrl); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + + expect(response.status).toBe(202); + + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + + const expectedMessage = { + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", + }, + { + type: "text", + text: JSON.stringify({ + headers: { + host: `127.0.0.1:${serverPort}`, + connection: 'keep-alive', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'node', + 'accept-encoding': 'gzip, deflate', + 'content-length': '124' + }, + }) + }, + ], + }, + jsonrpc: "2.0", + id: "call-1", + }; + expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); + }); + }); + + describe('handlePostMessage method', () => { + it('should return 500 if server has not started', async () => { + const mockReq = createMockRequest(); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + + const error = 'SSE connection not established'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .rejects.toThrow(error); + expect(mockRes.writeHead).toHaveBeenCalledWith(500); + expect(mockRes.end).toHaveBeenCalledWith(error); + }); + + it('should return 400 if content-type is not application/json', async () => { + const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onerror = jest.fn(); + const error = 'Unsupported content-type: text/plain'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .resolves.toBe(undefined); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); + expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); + }); + + it('should return 400 if message has not a valid schema', async () => { + const invalidMessage = JSON.stringify({ + // missing jsonrpc field + method: 'call', + params: [1, 2, 3], + id: 1, + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: invalidMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(transport.onmessage).not.toHaveBeenCalled(); + expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); + }); + + it('should return 202 if message has a valid schema', async () => { + const validMessage = JSON.stringify({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: validMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(202); + expect(mockRes.end).toHaveBeenCalledWith('Accepted'); + expect(transport.onmessage).toHaveBeenCalledWith({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }, { + authInfo: { + token: 'test-token', + }, + requestInfo: { + headers: { + 'content-type': 'application/json', + }, + }, + }); + }); + }); + + describe('close method', () => { + it('should call onclose', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + transport.onclose = jest.fn(); + await transport.close(); + expect(transport.onclose).toHaveBeenCalled(); + }); + }); + + describe('send method', () => { + it('should call onsend', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining('event: endpoint')); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); + }); + }); + + describe('DNS rebinding protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000', 'example.com'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + }); + + it('should reject requests without host header when allowedHosts is configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); + }); + }); + + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + origin: 'http://evil.com', + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + }); + }); + + describe('Content-Type validation', () => { + it('should accept requests with application/json content-type', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json; charset=utf-8', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with non-application/json content-type when protection is enabled', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'text/plain', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); + }); + }); + + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: false, + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + origin: 'http://evil.com', + 'content-type': 'text/plain', + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + // Should pass even with invalid headers because protection is disabled + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + // The error should be from content-type parsing, not DNS rebinding protection + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); + }); + }); + + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, + }); + await transport.start(); + + // Valid host, invalid origin + const mockReq1 = createMockRequest({ + headers: { + host: 'localhost:3000', + origin: 'http://evil.com', + 'content-type': 'application/json', + } + }); + const mockHandleRes1 = createMockResponse(); + + await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + + // Invalid host, valid origin + const mockReq2 = createMockRequest({ + headers: { + host: 'evil.com', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } + }); + const mockHandleRes2 = createMockResponse(); + + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + + // Both valid + const mockReq3 = createMockRequest({ + headers: { + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } + }); + const mockHandleRes3 = createMockResponse(); + + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); + }); }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..e0725686 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { AuthInfo } from "./auth/types.js"; @@ -9,6 +9,29 @@ import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; +/** + * Configuration options for SSEServerTransport. + */ +export interface SSEServerTransportOptions { + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. + */ + enableDnsRebindingProtection?: boolean; +} + /** * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. * @@ -17,10 +40,10 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; - + private _options: SSEServerTransportOptions; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -28,8 +51,39 @@ export class SSEServerTransport implements Transport { constructor( private _endpoint: string, private res: ServerResponse, + options?: SSEServerTransportOptions, ) { this._sessionId = randomUUID(); + this._options = options || {enableDnsRebindingProtection: false}; + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is not enabled + if (!this._options.enableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; } /** @@ -86,13 +140,23 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(validationError); + this.onerror?.(new Error(validationError)); + return; + } + const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; let body: string | unknown; try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { @@ -106,7 +170,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -118,7 +182,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { + async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index a0f2e0bb..502435ea 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,10 +1,25 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; -import { AddressInfo } from "node:net"; +import { createServer as netCreateServer, AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; +import { AuthInfo } from "./auth/types.js"; + +async function getFreePort() { + return new Promise(res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()! + if (typeof address === "string") { + throw new Error("Unexpected address type: " + typeof address); + } + const port = (address as AddressInfo).port; + srv.close((_err) => res(port)) + }); + }) +} /** * Test server configuration for StreamableHTTPServerTransport tests @@ -70,6 +85,61 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: return { server, transport, mcpServer, baseUrl }; } +/** + * Helper to create and start authenticated test HTTP server with MCP setup + */ +async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: (() => randomUUID()) }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + mcpServer.tool( + "profile", + "A user profile data tool", + { active: z.boolean().describe("Profile status") }, + async ({ active }, { authInfo }): Promise => { + return { content: [{ type: "text", text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + req.auth = { token: req.headers["authorization"]?.split(" ")[1] } as AuthInfo; + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, transport, mcpServer, baseUrl }; +} + /** * Helper to stop test server */ @@ -120,14 +190,17 @@ async function readSSEEvent(response: Response): Promise { /** * Helper to send JSON-RPC request */ -async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise { +async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record): Promise { const headers: Record = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", + ...extraHeaders }; if (sessionId) { headers["mcp-session-id"] = sessionId; + // After initialization, include the protocol version header + headers["mcp-protocol-version"] = "2025-03-26"; } return fetch(baseUrl, { @@ -149,6 +222,7 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag describe("StreamableHTTPServerTransport", () => { let server: Server; + let mcpServer: McpServer; let transport: StreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -157,6 +231,7 @@ describe("StreamableHTTPServerTransport", () => { const result = await createTestServer(); server = result.server; transport = result.transport; + mcpServer = result.mcpServer; baseUrl = result.baseUrl; }); @@ -220,7 +295,7 @@ describe("StreamableHTTPServerTransport", () => { expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); }); - it("should pandle post requests via sse response correctly", async () => { + it("should handle post requests via sse response correctly", async () => { sessionId = await initializeServer(); const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); @@ -288,6 +363,69 @@ describe("StreamableHTTPServerTransport", () => { }); }); + /*** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + sessionId = await initializeServer(); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { type: "text", text: "Hello, Test User!" }, + { type: "text", text: expect.any(String) } + ], + }, + id: "call-1", + }); + + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String), + }, + }); + }); + it("should reject requests without a valid session ID", async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); @@ -319,6 +457,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -360,6 +499,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -391,6 +531,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -402,6 +543,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -420,6 +562,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "application/json", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -613,6 +756,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -648,7 +792,10 @@ describe("StreamableHTTPServerTransport", () => { // Now DELETE the session const deleteResponse = await fetch(tempUrl, { method: "DELETE", - headers: { "mcp-session-id": tempSessionId || "" }, + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, }); expect(deleteResponse.status).toBe(200); @@ -664,13 +811,223 @@ describe("StreamableHTTPServerTransport", () => { // Try to delete with invalid session ID const response = await fetch(baseUrl, { method: "DELETE", - headers: { "mcp-session-id": "invalid-session-id" }, + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, }); expect(response.status).toBe(404); const errorData = await response.json(); expectErrorResponse(errorData, -32001, /Session not found/); }); + + describe("protocol version header validation", () => { + it("should accept requests with matching protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + }); + + it("should accept requests without protocol version header", async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with unsupported protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "1999-01-01", // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + + it("should accept when protocol version differs from negotiated version", async () => { + sessionId = await initializeServer(); + + // Spy on console.warn to verify warning is logged + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Send request with different but supported protocol version + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "2024-11-05", // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + // Request should still succeed + expect(response.status).toBe(200); + + warnSpy.mockRestore(); + }); + + it("should handle protocol version validation for GET requests", async () => { + sessionId = await initializeServer(); + + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + + it("should handle protocol version validation for DELETE requests", async () => { + sessionId = await initializeServer(); + + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + }); +}); + +describe("StreamableHTTPServerTransport with AuthInfo", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestAuthServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get("mcp-session-id"); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it("should call a tool with authInfo", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "profile", + arguments: { active: true }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { 'authorization': 'Bearer test-token' }); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { + type: "text", + text: "Active profile from token: test-token!", + }, + ], + }, + id: "call-1", + }); + }); + + it("should calls tool without authInfo when it is optional", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "profile", + arguments: { active: false }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { + type: "text", + text: "Inactive profile from token: undefined!", + }, + ], + }, + id: "call-1", + }); + }); }); // Test JSON Response Mode @@ -964,6 +1321,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -1040,6 +1398,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", "last-event-id": firstEventId }, }); @@ -1126,15 +1485,292 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open first SSE stream const stream1 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream1.status).toBe(200); // Open second SSE stream - should still be rejected, stateless mode still only allows one const stream2 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); -}); \ No newline at end of file +}); + +// Test DNS rebinding protection +describe("StreamableHTTPServerTransport DNS rebinding protection", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe("Host header validation", () => { + it("should accept requests with allowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed host headers", async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain("Invalid Host header:"); + }); + + it("should reject GET requests with disallowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + }, + }); + + expect(response.status).toBe(403); + }); + }); + + describe("Origin header validation", () => { + it("should accept requests with allowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3000", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toBe("Invalid Origin header: http://evil.com"); + }); + }); + + describe("enableDnsRebindingProtection option", () => { + it("should skip all validations when enableDnsRebindingProtection is false", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Host: "evil.com", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); + }); + }); + + describe("Combined validations", () => { + it("should validate both host and origin when both are configured", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + expect(body1.error.message).toBe("Invalid Origin header: http://evil.com"); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3001", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response2.status).toBe(200); + }); + }); +}); + +/** + * Helper to create test server with DNS rebinding protection options + */ +async function createTestServerWithDnsProtection(config: { + sessionIdGenerator: (() => string) | undefined; + allowedHosts?: string[]; + allowedOrigins?: string[]; + enableDnsRebindingProtection?: boolean; +}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + const port = await getFreePort(); + + if (config.allowedHosts) { + config.allowedHosts = config.allowedHosts.map(host => { + if (host.includes(':')) { + return host; + } + return `localhost:${port}`; + }); + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + allowedHosts: config.allowedHosts, + allowedOrigins: config.allowedOrigins, + enableDnsRebindingProtection: config.enableDnsRebindingProtection, + }); + + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + if (req.method === "POST") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + const parsedBody = JSON.parse(body); + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); + }); + } else { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + } + }); + + await new Promise((resolve) => { + httpServer.listen(port, () => resolve()); + }); + + const serverUrl = new URL(`http://localhost:${port}/`); + + return { + server: httpServer, + transport, + mcpServer, + baseUrl: serverUrl, + }; +} \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 7112c52a..022d1a47 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,9 +1,10 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { MessageExtraInfo, RequestInfo, isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; +import { AuthInfo } from "./auth/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -60,6 +61,24 @@ export interface StreamableHTTPServerTransportOptions { * If provided, resumability will be enabled, allowing clients to reconnect and resume messages */ eventStore?: EventStore; + + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. + */ + enableDnsRebindingProtection?: boolean; } /** @@ -108,17 +127,23 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _allowedHosts?: string[]; + private _allowedOrigins?: string[]; + private _enableDnsRebindingProtection: boolean; - sessionId?: string | undefined; + sessionId?: string; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._allowedHosts = options.allowedHosts; + this._allowedOrigins = options.allowedOrigins; + this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; } /** @@ -132,10 +157,54 @@ export class StreamableHTTPServerTransport implements Transport { this._started = true; } + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is not enabled + if (!this._enableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._allowedHosts && this._allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._allowedOrigins && this._allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; + } + /** * Handles an incoming HTTP request, whether GET or POST */ - async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { + async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: validationError + }, + id: null + })); + this.onerror?.(new Error(validationError)); + return; + } + if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "GET") { @@ -171,6 +240,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } // Handle resumability: check for Last-Event-ID header if (this._eventStore) { const lastEventId = req.headers['last-event-id'] as string | undefined; @@ -286,7 +358,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles POST requests containing JSON-RPC messages */ - private async handlePostRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { + private async handlePostRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { try { // Validate the Accept header const acceptHeader = req.headers.accept; @@ -316,6 +388,9 @@ export class StreamableHTTPServerTransport implements Transport { return; } + const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; + let rawMessage; if (parsedBody !== undefined) { rawMessage = parsedBody; @@ -375,11 +450,17 @@ export class StreamableHTTPServerTransport implements Transport { } } - // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it - // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - if (!isInitializationRequest && !this.validateSession(req, res)) { - return; + if (!isInitializationRequest) { + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (!this.validateSession(req, res)) { + return; + } + // Mcp-Protocol-Version header is required for all requests after initialization. + if (!this.validateProtocolVersion(req, res)) { + return; + } } @@ -392,7 +473,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message); + this.onmessage?.(message, { authInfo, requestInfo }); } } else if (hasRequests) { // The default behavior is to use SSE streaming @@ -427,7 +508,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message); + this.onmessage?.(message, { authInfo, requestInfo }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready @@ -454,6 +535,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } await this.close(); res.writeHead(200).end(); } @@ -521,6 +605,25 @@ export class StreamableHTTPServerTransport implements Transport { return true; } + private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean { + let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})` + }, + id: null + })); + return false; + } + return true; + } async close(): Promise { // Close all SSE connections diff --git a/src/server/title.test.ts b/src/server/title.test.ts new file mode 100644 index 00000000..3f64570b --- /dev/null +++ b/src/server/title.test.ts @@ -0,0 +1,236 @@ +import { Server } from "./index.js"; +import { Client } from "../client/index.js"; +import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; +import { McpServer, ResourceTemplate } from "./mcp.js"; + +describe("Title field backwards compatibility", () => { + it("should work with tools that have title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool with title + server.registerTool( + "test-tool", + { + title: "Test Tool Display Name", + description: "A test tool", + inputSchema: { + value: z.string() + } + }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBe("Test Tool Display Name"); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with tools without title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool without title + server.tool( + "test-tool", + "A test tool", + { value: z.string() }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with prompts that have title using update", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title by updating after creation + const prompt = server.prompt( + "test-prompt", + "A test prompt", + async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) + ); + prompt.update({ title: "Test Prompt Display Name" }); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + }); + + it("should work with prompts using registerPrompt", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title using registerPrompt + server.registerPrompt( + "test-prompt", + { + title: "Test Prompt Display Name", + description: "A test prompt", + argsSchema: { input: z.string() } + }, + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it("should work with resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register resource with title using registerResource + server.registerResource( + "test-resource", + "https://example.com/test", + { + title: "Test Resource Display Name", + description: "A test resource", + mimeType: "text/plain" + }, + async () => ({ + contents: [{ + uri: "https://example.com/test", + text: "test content" + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe("test-resource"); + expect(resources.resources[0].title).toBe("Test Resource Display Name"); + expect(resources.resources[0].description).toBe("A test resource"); + expect(resources.resources[0].mimeType).toBe("text/plain"); + }); + + it("should work with dynamic resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register dynamic resource with title using registerResource + server.registerResource( + "user-profile", + new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, + async (uri, { userId }, _extra) => ({ + contents: [{ + uri: uri.href, + text: `Profile data for user ${userId}` + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile"); + expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile"); + expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information"); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile"); + + // Test reading the resource + const readResult = await client.readResource({ uri: "users://123/profile" }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents[0].text).toBe("Profile data for user 123"); + }); + + it("should support serverInfo with title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: "test-server", + version: "1.0.0", + title: "Test Server Display Name" + }, + { capabilities: {} } + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe("test-server"); + expect(serverInfo?.version).toBe("1.0.0"); + expect(serverInfo?.title).toBe("Test Server Display Name"); + }); +}); \ No newline at end of file diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts new file mode 100644 index 00000000..c1fa7bdf --- /dev/null +++ b/src/shared/auth-utils.test.ts @@ -0,0 +1,61 @@ +import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe('https://example.com/path?query=1'); + }); + + it('should return URL unchanged if no fragment', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); + // Ports preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe('https://example.com/?foo=bar&baz=qux'); + // Trailing slashes preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); + }); + }); + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true); + }); + + it('should not match URLs with different paths', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false); + }); + }); +}); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts new file mode 100644 index 00000000..97a77c01 --- /dev/null +++ b/src/shared/auth-utils.ts @@ -0,0 +1,54 @@ +/** + * Utilities for handling OAuth resource URIs. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + * Keeps everything else unchanged (scheme, domain, port, path, query). + */ +export function resourceUrlFromServerUrl(url: URL | string ): URL { + const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; +} + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param requestedResource The resource URL being requested + * @param configuredResource The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ + export function checkResourceAllowed( + { requestedResource, configuredResource }: { + requestedResource: URL | string; + configuredResource: URL | string + } + ): boolean { + const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href); + const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); + } diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 60a28b80..b906de3d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,5 +1,27 @@ import { z } from "zod"; +/** + * RFC 9728 OAuth Protected Resource Metadata + */ +export const OAuthProtectedResourceMetadataSchema = z + .object({ + resource: z.string().url(), + authorization_servers: z.array(z.string().url()).optional(), + jwks_uri: z.string().url().optional(), + scopes_supported: z.array(z.string()).optional(), + bearer_methods_supported: z.array(z.string()).optional(), + resource_signing_alg_values_supported: z.array(z.string()).optional(), + resource_name: z.string().optional(), + resource_documentation: z.string().optional(), + resource_policy_uri: z.string().url().optional(), + resource_tos_uri: z.string().url().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + dpop_bound_access_tokens_required: z.boolean().optional(), + }) + .passthrough(); + /** * RFC 8414 OAuth 2.0 Authorization Server Metadata */ @@ -76,6 +98,7 @@ export const OAuthClientMetadataSchema = z.object({ jwks: z.any().optional(), software_id: z.string().optional(), software_version: z.string().optional(), + software_statement: z.string().optional(), }).strip(); /** @@ -109,6 +132,7 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); + export type OAuthMetadata = z.infer; export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; @@ -116,4 +140,5 @@ export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; export type OAuthClientInformationFull = z.infer; export type OAuthClientRegistrationError = z.infer; -export type OAuthTokenRevocationRequest = z.infer; \ No newline at end of file +export type OAuthTokenRevocationRequest = z.infer; +export type OAuthProtectedResourceMetadata = z.infer; diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts new file mode 100644 index 00000000..0119a669 --- /dev/null +++ b/src/shared/metadataUtils.ts @@ -0,0 +1,29 @@ +import { BaseMetadata } from "../types.js"; + +/** + * Utilities for working with BaseMetadata objects. + */ + +/** + * Gets the display name for an object with BaseMetadata. + * For tools, the precedence is: title → annotations.title → name + * For other objects: title → name + * This implements the spec requirement: "if no title is provided, name should be used for display purposes" + */ +export function getDisplayName(metadata: BaseMetadata): string { + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata) { + const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; + if (metadataWithAnnotations.annotations?.title) { + return metadataWithAnnotations.annotations.title; + } + } + + // Finally fall back to name + return metadata.name; +} diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index fb5ecd13..b16db73f 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -27,9 +27,11 @@ class MockTransport implements Transport { describe("protocol tests", () => { let protocol: Protocol; let transport: MockTransport; + let sendSpy: jest.SpyInstance; beforeEach(() => { transport = new MockTransport(); + sendSpy = jest.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -63,6 +65,146 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + test("should not overwrite existing hooks when connecting transports", async () => { + const oncloseMock = jest.fn(); + const onerrorMock = jest.fn(); + const onmessageMock = jest.fn(); + transport.onclose = oncloseMock; + transport.onerror = onerrorMock; + transport.onmessage = onmessageMock; + await protocol.connect(transport); + transport.onclose(); + transport.onerror(new Error()); + transport.onmessage(""); + expect(oncloseMock).toHaveBeenCalled(); + expect(onerrorMock).toHaveBeenCalled(); + expect(onmessageMock).toHaveBeenCalled(); + }); + + describe("_meta preservation with onprogress", () => { + test("should preserve existing _meta when adding progressToken", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should create _meta with progressToken when no _meta exists", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test" + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should not modify _meta when onprogress is not provided", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + + protocol.request(request, mockSchema); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should handle params being undefined with onprogress", async () => { + await protocol.connect(transport); + const request = { + method: "example" + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + }); + describe("progress notification timeout behavior", () => { beforeEach(() => { jest.useFakeTimers(); @@ -255,6 +397,74 @@ describe("protocol tests", () => { await Promise.resolve(); await expect(requestPromise).resolves.toEqual({ result: "success" }); }); + + test("should handle progress notifications with message field", async () => { + await protocol.connect(transport); + const request = { method: "example", params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + const requestPromise = protocol.request(request, mockSchema, { + timeout: 1000, + onprogress: onProgressMock, + }); + + jest.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: 0, + progress: 25, + total: 100, + message: "Initializing process...", + }, + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 25, + total: 100, + message: "Initializing process...", + }); + + jest.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: 0, + progress: 75, + total: 100, + message: "Processing data...", + }, + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 75, + total: 100, + message: "Processing data...", + }); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + id: 0, + result: { result: "success" }, + }); + } + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: "success" }); + }); }); }); @@ -271,6 +481,7 @@ describe("mergeCapabilities", () => { experimental: { feature: true, }, + elicitation: {}, roots: { newProp: true, }, @@ -279,6 +490,7 @@ describe("mergeCapabilities", () => { const merged = mergeCapabilities(base, additional); expect(merged).toEqual({ sampling: {}, + elicitation: {}, roots: { listChanged: true, newProp: true, diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4694929d..50bdcc3c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,6 +22,8 @@ import { Result, ServerCapabilities, RequestMeta, + MessageExtraInfo, + RequestInfo, } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; @@ -127,6 +129,11 @@ export type RequestHandlerExtra { this._transport = transport; + const _onclose = this.transport?.onclose; this._transport.onclose = () => { + _onclose?.(); this._onclose(); }; + const _onerror = this.transport?.onerror; this._transport.onerror = (error: Error) => { + _onerror?.(error); this._onerror(error); }; + const _onmessage = this._transport?.onmessage; this._transport.onmessage = (message, extra) => { + _onmessage?.(message, extra); if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { @@ -295,7 +308,9 @@ export abstract class Protocol< } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { - this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + this._onerror( + new Error(`Unknown message type: ${JSON.stringify(message)}`), + ); } }; @@ -339,7 +354,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void { + private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -375,6 +390,7 @@ export abstract class Protocol< this.request(r, resultSchema, { ...options, relatedRequestId: request.id }), authInfo: extra?.authInfo, requestId: request.id, + requestInfo: extra?.requestInfo }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. @@ -541,7 +557,10 @@ export abstract class Protocol< this._progressHandlers.set(messageId, options.onprogress); jsonrpcRequest.params = { ...request.params, - _meta: { progressToken: messageId }, + _meta: { + ...(request.params?._meta || {}), + progressToken: messageId + }, }; } diff --git a/src/shared/transport.ts b/src/shared/transport.ts index fe0a60e6..96b291fa 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,4 @@ -import { AuthInfo } from "../server/auth/types.js"; -import { JSONRPCMessage, RequestId } from "../types.js"; +import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js"; /** * Options for sending a JSON-RPC message. @@ -66,13 +65,19 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. * - * Includes the authInfo if the transport is authenticated. + * Includes the requestInfo and authInfo if the transport is authenticated. * + * The requestInfo can be used to get the original request information (headers, etc.) */ - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * The session ID generated for this connection. */ sessionId?: string; + + /** + * Sets the protocol version used for the connection (called when the initialize response is received). + */ + setProtocolVersion?: (version: string) => void; } diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..0aee62a9 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,10 +1,18 @@ -import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; +import { + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + ResourceLinkSchema, + ContentBlockSchema, + PromptMessageSchema, + CallToolResultSchema, + CompleteRequestSchema +} from "./types.js"; describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-06-18"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); @@ -12,6 +20,296 @@ describe("Types", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-11-05"); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2025-03-26"); }); + describe("ResourceLink", () => { + test("should validate a minimal ResourceLink", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + expect(result.data.uri).toBe("file:///path/to/file.txt"); + expect(result.data.name).toBe("file.txt"); + } + }); + + test("should validate a ResourceLink with all optional fields", () => { + const resourceLink = { + type: "resource_link", + uri: "https://example.com/resource", + name: "Example Resource", + title: "A comprehensive example resource", + description: "This resource demonstrates all fields", + mimeType: "text/plain", + _meta: { custom: "metadata" } + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe("A comprehensive example resource"); + expect(result.data.description).toBe("This resource demonstrates all fields"); + expect(result.data.mimeType).toBe("text/plain"); + expect(result.data._meta).toEqual({ custom: "metadata" }); + } + }); + + test("should fail validation for invalid type", () => { + const invalidResourceLink = { + type: "invalid_type", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + + test("should fail validation for missing required fields", () => { + const invalidResourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt" + // missing name + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + }); + + describe("ContentBlock", () => { + test("should validate text content", () => { + const textContent = { + type: "text", + text: "Hello, world!" + }; + + const result = ContentBlockSchema.safeParse(textContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("text"); + } + }); + + test("should validate image content", () => { + const imageContent = { + type: "image", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "image/png" + }; + + const result = ContentBlockSchema.safeParse(imageContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("image"); + } + }); + + test("should validate audio content", () => { + const audioContent = { + type: "audio", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "audio/mp3" + }; + + const result = ContentBlockSchema.safeParse(audioContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("audio"); + } + }); + + test("should validate resource link content", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt", + mimeType: "text/plain" + }; + + const result = ContentBlockSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + } + }); + + test("should validate embedded resource content", () => { + const embeddedResource = { + type: "resource", + resource: { + uri: "file:///path/to/file.txt", + mimeType: "text/plain", + text: "File contents" + } + }; + + const result = ContentBlockSchema.safeParse(embeddedResource); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource"); + } + }); + }); + + describe("PromptMessage with ContentBlock", () => { + test("should validate prompt message with resource link", () => { + const promptMessage = { + role: "assistant", + content: { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + } + }; + + const result = PromptMessageSchema.safeParse(promptMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content.type).toBe("resource_link"); + } + }); + }); + + describe("CallToolResult with ContentBlock", () => { + test("should validate tool result with resource links", () => { + const toolResult = { + content: [ + { + type: "text", + text: "Found the following files:" + }, + { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + }, + { + type: "resource_link", + uri: "file:///project/src/lib.rs", + name: "lib.rs", + description: "Library exports", + mimeType: "text/x-rust" + } + ] + }; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toHaveLength(3); + expect(result.data.content[0].type).toBe("text"); + expect(result.data.content[1].type).toBe("resource_link"); + expect(result.data.content[2].type).toBe("resource_link"); + } + }); + + test("should validate empty content array with default", () => { + const toolResult = {}; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toEqual([]); + } + }); + }); + + describe("CompleteRequest", () => { + test("should validate a CompleteRequest without resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "greeting" }, + argument: { name: "name", value: "A" } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe("completion/complete"); + expect(result.data.params.ref.type).toBe("ref/prompt"); + expect(result.data.params.context).toBeUndefined(); + } + }); + + test("should validate a CompleteRequest with resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" }, + argument: { name: "repo", value: "t" }, + context: { + arguments: { + "{owner}": "microsoft" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{owner}": "microsoft" + }); + } + }); + + test("should validate a CompleteRequest with empty resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + context: { + arguments: {} + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({}); + } + }); + + test("should validate a CompleteRequest with multiple resolved variables", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" }, + argument: { name: "id", value: "123" }, + context: { + arguments: { + "{tenant}": "acme-corp", + "{resource}": "users" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{tenant}": "acme-corp", + "{resource}": "users" + }); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 2ee0f752..f66d2c4b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,11 @@ import { z, ZodTypeAny } from "zod"; +import { AuthInfo } from "./server/auth/types.js"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const LATEST_PROTOCOL_VERSION = "2025-06-18"; +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2025-03-26", "2024-11-05", "2024-10-07", ]; @@ -43,7 +46,8 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -57,7 +61,8 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -194,16 +199,33 @@ export const CancelledNotificationSchema = NotificationSchema.extend({ }), }); +/* Base Metadata */ +/** + * Base metadata interface for common properties across resources, tools, prompts, and implementations. + */ +export const BaseMetadataSchema = z + .object({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: z.string(), + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title: z.optional(z.string()), + }) + .passthrough(); + /* Initialization */ /** * Describes the name and version of an MCP implementation. */ -export const ImplementationSchema = z - .object({ - name: z.string(), - version: z.string(), - }) - .passthrough(); +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. @@ -218,6 +240,10 @@ export const ClientCapabilitiesSchema = z * Present if the client supports sampling from an LLM. */ sampling: z.optional(z.object({}).passthrough()), + /** + * Present if the client supports eliciting user input. + */ + elicitation: z.optional(z.object({}).passthrough()), /** * Present if the client supports listing roots. */ @@ -364,6 +390,10 @@ export const ProgressSchema = z * Total number of items to process (or total progress required), if known. */ total: z.optional(z.number()), + /** + * An optional message describing the current progress. + */ + message: z.optional(z.string()), }) .passthrough(); @@ -413,6 +443,11 @@ export const ResourceContentsSchema = z * The MIME type of this resource, if known. */ mimeType: z.optional(z.string()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -433,64 +468,58 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ /** * A known resource that the server is capable of reading. */ -export const ResourceSchema = z - .object({ - /** - * The URI of this resource. - */ - uri: z.string(), +export const ResourceSchema = BaseMetadataSchema.extend({ + /** + * The URI of this resource. + */ + uri: z.string(), - /** - * A human-readable name for this resource. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this resource represents. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type of this resource, if known. - */ - mimeType: z.optional(z.string()), - }) - .passthrough(); + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * A template description for resources available on the server. */ -export const ResourceTemplateSchema = z - .object({ - /** - * A URI template (according to RFC 6570) that can be used to construct resource URIs. - */ - uriTemplate: z.string(), +export const ResourceTemplateSchema = BaseMetadataSchema.extend({ + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: z.string(), - /** - * A human-readable name for the type of resource this template refers to. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this template is for. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - */ - mimeType: z.optional(z.string()), - }) - .passthrough(); + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of resources the server has. @@ -614,22 +643,21 @@ export const PromptArgumentSchema = z /** * A prompt or prompt template that the server offers. */ -export const PromptSchema = z - .object({ - /** - * The name of the prompt or prompt template. - */ - name: z.string(), - /** - * An optional description of what this prompt provides - */ - description: z.optional(z.string()), - /** - * A list of arguments to use for templating the prompt. - */ - arguments: z.optional(z.array(PromptArgumentSchema)), - }) - .passthrough(); +export const PromptSchema = BaseMetadataSchema.extend({ + /** + * An optional description of what this prompt provides + */ + description: z.optional(z.string()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of prompts and prompt templates the server has. @@ -672,6 +700,12 @@ export const TextContentSchema = z * The text content of the message. */ text: z.string(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -689,6 +723,12 @@ export const ImageContentSchema = z * The MIME type of the image. Different providers may support different image types. */ mimeType: z.string(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -706,6 +746,12 @@ export const AudioContentSchema = z * The MIME type of the audio. Different providers may support different audio types. */ mimeType: z.string(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -716,21 +762,41 @@ export const EmbeddedResourceSchema = z .object({ type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + */ +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal("resource_link"), +}); + +/** + * A content block that can be used in prompts and tool results. + */ +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema, +]); + /** * Describes a message returned as part of a prompt. */ export const PromptMessageSchema = z .object({ role: z.enum(["user", "assistant"]), - content: z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ]), + content: ContentBlockSchema, }) .passthrough(); @@ -812,31 +878,44 @@ export const ToolAnnotationsSchema = z /** * Definition for a tool the client can call. */ -export const ToolSchema = z - .object({ - /** - * The name of the tool. - */ - name: z.string(), - /** - * A human-readable description of the tool. - */ - description: z.optional(z.string()), - /** - * A JSON Schema object defining the expected parameters for the tool. - */ - inputSchema: z - .object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - }) - .passthrough(), - /** - * Optional additional tool information. - */ - annotations: z.optional(ToolAnnotationsSchema), - }) - .passthrough(); +export const ToolSchema = BaseMetadataSchema.extend({ + /** + * A human-readable description of the tool. + */ + description: z.optional(z.string()), + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: z + .object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + */ + outputSchema: z.optional( + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), + /** + * Optional additional tool information. + */ + annotations: z.optional(ToolAnnotationsSchema), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of tools the server has. @@ -856,10 +935,36 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({ * The server's response to a tool call. */ export const CallToolResultSchema = ResultSchema.extend({ - content: z.array( - z.union([TextContentSchema, ImageContentSchema, AudioContentSchema, EmbeddedResourceSchema]), - ), - isError: z.boolean().default(false).optional(), + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool does not define an outputSchema, this field MUST be present in the result. + * For backwards compatibility, this field is always present, but it may be empty. + */ + content: z.array(ContentBlockSchema).default([]), + + /** + * An object containing structured tool output. + * + * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: z.object({}).passthrough().optional(), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError: z.optional(z.boolean()), }); /** @@ -1039,11 +1144,112 @@ export const CreateMessageResultSchema = ResultSchema.extend({ ]), }); +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z + .object({ + type: z.literal("boolean"), + title: z.optional(z.string()), + description: z.optional(z.string()), + default: z.optional(z.boolean()), + }) + .passthrough(); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + minLength: z.optional(z.number()), + maxLength: z.optional(z.number()), + format: z.optional(z.enum(["email", "uri", "date", "date-time"])), + }) + .passthrough(); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z + .object({ + type: z.enum(["number", "integer"]), + title: z.optional(z.string()), + description: z.optional(z.string()), + minimum: z.optional(z.number()), + maximum: z.optional(z.number()), + }) + .passthrough(); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + enum: z.array(z.string()), + enumNames: z.optional(z.array(z.string())), + }) + .passthrough(); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([ + BooleanSchemaSchema, + StringSchemaSchema, + NumberSchemaSchema, + EnumSchemaSchema, +]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user. + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal("elicitation/create"), + params: BaseRequestParamsSchema.extend({ + /** + * The message to present to the user. + */ + message: z.string(), + /** + * The schema for the requested user input. + */ + requestedSchema: z + .object({ + type: z.literal("object"), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + }), +}); + +/** + * The client's response to an elicitation/create request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user's response action. + */ + action: z.enum(["accept", "reject", "cancel"]), + /** + * The collected user input content (only present if action is "accept"). + */ + content: z.optional(z.record(z.string(), z.unknown())), +}); + /* Autocomplete */ /** * A reference to a resource or resource template definition. */ -export const ResourceReferenceSchema = z +export const ResourceTemplateReferenceSchema = z .object({ type: z.literal("ref/resource"), /** @@ -1053,6 +1259,11 @@ export const ResourceReferenceSchema = z }) .passthrough(); +/** + * @deprecated Use ResourceTemplateReferenceSchema instead + */ +export const ResourceReferenceSchema = ResourceTemplateReferenceSchema; + /** * Identifies a prompt. */ @@ -1072,7 +1283,7 @@ export const PromptReferenceSchema = z export const CompleteRequestSchema = RequestSchema.extend({ method: z.literal("completion/complete"), params: BaseRequestParamsSchema.extend({ - ref: z.union([PromptReferenceSchema, ResourceReferenceSchema]), + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), /** * The argument's information */ @@ -1088,6 +1299,14 @@ export const CompleteRequestSchema = RequestSchema.extend({ value: z.string(), }) .passthrough(), + context: z.optional( + z.object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.optional(z.record(z.string(), z.string())), + }) + ), }), }); @@ -1127,6 +1346,12 @@ export const RootSchema = z * An optional name for the root. */ name: z.optional(z.string()), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -1178,6 +1403,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + ElicitResultSchema, ListRootsResultSchema, ]); @@ -1185,6 +1411,7 @@ export const ClientResultSchema = z.union([ export const ServerRequestSchema = z.union([ PingRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ]); @@ -1237,6 +1464,36 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo?: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; +} + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; @@ -1257,6 +1514,9 @@ export type EmptyResult = Infer; /* Cancellation */ export type CancelledNotification = Infer; +/* Base Metadata */ +export type BaseMetadata = Infer; + /* Initialization */ export type Implementation = Infer; export type ClientCapabilities = Infer; @@ -1303,6 +1563,8 @@ export type TextContent = Infer; export type ImageContent = Infer; export type AudioContent = Infer; export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; export type PromptMessage = Infer; export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; @@ -1327,8 +1589,21 @@ export type SamplingMessage = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +/* Elicitation */ +export type BooleanSchema = Infer; +export type StringSchema = Infer; +export type NumberSchema = Infer; +export type EnumSchema = Infer; +export type PrimitiveSchemaDefinition = Infer; +export type ElicitRequest = Infer; +export type ElicitResult = Infer; + /* Autocomplete */ -export type ResourceReference = Infer; +export type ResourceTemplateReference = Infer; +/** + * @deprecated Use ResourceTemplateReference instead + */ +export type ResourceReference = ResourceTemplateReference; export type PromptReference = Infer; export type CompleteRequest = Infer; export type CompleteResult = Infer;