Merge branch 'main' into junhan/http-doc
This commit is contained in:
commit
a02704cd32
68 changed files with 12566 additions and 3838 deletions
0
.git-blame-ignore-revs
Normal file
0
.git-blame-ignore-revs
Normal file
|
|
@ -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
|
||||
- Node.js >= 18 required
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<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.
|
||||
<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.
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
|
|
|
|||
465
README.md
465
README.md
|
|
@ -1,15 +1,17 @@
|
|||
# MCP TypeScript SDK  
|
||||
|
||||
## 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
|
||||
|
||||
|
|
@ -39,6 +44,8 @@ The Model Context Protocol allows applications to provide context for LLMs in a
|
|||
npm install @modelcontextprotocol/sdk
|
||||
```
|
||||
|
||||
> ⚠️ MCP requires Node v18.x up to work fine.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Let's create a simple MCP server that exposes a calculator tool and some data:
|
||||
|
|
@ -50,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,
|
||||
|
|
@ -96,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"
|
||||
});
|
||||
```
|
||||
|
|
@ -107,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,
|
||||
|
|
@ -119,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,
|
||||
|
|
@ -129,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
|
||||
|
|
@ -137,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: [{
|
||||
|
|
@ -152,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();
|
||||
|
|
@ -163,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",
|
||||
|
|
@ -183,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
|
||||
|
|
@ -221,7 +420,8 @@ import express from "express";
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
|
||||
|
||||
const app = express();
|
||||
|
|
@ -241,14 +441,16 @@ app.post('/mcp', async (req, res) => {
|
|||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
// New initialization request
|
||||
const eventStore = new InMemoryEventStore();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
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
|
||||
|
|
@ -304,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:
|
||||
|
|
@ -312,19 +529,23 @@ For simpler use cases where session management isn't needed:
|
|||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // set to undefined for stateless servers
|
||||
});
|
||||
|
||||
// Setup routes for the server
|
||||
const setupServer = async () => {
|
||||
await server.connect(transport);
|
||||
};
|
||||
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
console.log('Received MCP request:', req.body);
|
||||
// In stateless mode, create a new instance of transport and server for each request
|
||||
// to ensure complete isolation. A single instance would cause request ID collisions
|
||||
// when multiple clients connect concurrently.
|
||||
|
||||
try {
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
const server = getServer();
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on('close', () => {
|
||||
console.log('Request closed');
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
|
|
@ -340,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({
|
||||
|
|
@ -352,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({
|
||||
|
|
@ -364,6 +587,7 @@ app.delete('/mcp', async (req: Request, res: Response) => {
|
|||
}));
|
||||
});
|
||||
|
||||
|
||||
// Start the server
|
||||
const PORT = 3000;
|
||||
setupServer().then(() => {
|
||||
|
|
@ -372,7 +596,7 @@ setupServer().then(() => {
|
|||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
|
||||
console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`);
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('Failed to set up the server:', error);
|
||||
|
|
@ -382,10 +606,27 @@ setupServer().then(() => {
|
|||
```
|
||||
|
||||
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.
|
||||
|
|
@ -401,13 +642,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,
|
||||
|
|
@ -416,17 +661,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",
|
||||
|
|
@ -450,7 +703,7 @@ import { promisify } from "util";
|
|||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "SQLite Explorer",
|
||||
name: "sqlite-explorer",
|
||||
version: "1.0.0"
|
||||
});
|
||||
|
||||
|
|
@ -463,9 +716,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 {
|
||||
|
|
@ -484,9 +742,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 {
|
||||
|
|
@ -517,7 +779,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";
|
||||
|
|
@ -548,7 +810,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)
|
||||
|
|
@ -635,6 +897,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<string, any>;
|
||||
}> {
|
||||
// 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:
|
||||
|
|
@ -683,6 +1048,7 @@ const result = await client.callTool({
|
|||
arg1: "value"
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### Proxy Authorization Requests Upstream
|
||||
|
|
@ -691,7 +1057,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();
|
||||
|
||||
|
|
@ -725,6 +1092,7 @@ app.use(mcpAuthRouter({
|
|||
```
|
||||
|
||||
This setup allows you to:
|
||||
|
||||
- Forward OAuth requests to an external provider
|
||||
- Add custom token validation logic
|
||||
- Manage client registrations
|
||||
|
|
@ -777,7 +1145,6 @@ import express from "express";
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "backwards-compatible-server",
|
||||
|
|
@ -820,7 +1187,7 @@ app.post('/messages', async (req, res) => {
|
|||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.sse[sessionId];
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
} else {
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
|
|
@ -839,7 +1206,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 <https://github.com/modelcontextprotocol/typescript-sdk>.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# Security Policy
|
||||
|
||||
Thank you for helping us keep the SDKs and systems they interact with secure.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
|
|
|||
|
|
@ -15,5 +15,12 @@ export default tseslint.config(
|
|||
{ "argsIgnorePattern": "^_" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["src/client/**/*.ts", "src/server/**/*.ts"],
|
||||
ignores: ["**/*.test.ts"],
|
||||
rules: {
|
||||
"no-console": "error"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
66
package-lock.json
generated
66
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/sdk",
|
||||
"version": "1.10.1",
|
||||
"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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
|
||||
/**
|
||||
* 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<URL | undefined>;
|
||||
}
|
||||
|
||||
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<AuthResult> {
|
||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||
{ serverUrl,
|
||||
authorizationCode,
|
||||
scope,
|
||||
resourceMetadataUrl
|
||||
}: {
|
||||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
scope?: string;
|
||||
resourceMetadataUrl?: URL }): Promise<AuthResult> {
|
||||
|
||||
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<URL | undefined> {
|
||||
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<OAuthMetadata | undefined> {
|
||||
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
|
||||
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
|
||||
): Promise<OAuthProtectedResourceMetadata> {
|
||||
|
||||
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<string, string>,
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
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<OAuthMetadata | undefined> {
|
||||
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<OAuthTokens> {
|
||||
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<OAuthTokens> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,206 @@ 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(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, (_request) => ({
|
||||
protocolVersion: OLD_VERSION,
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: "old server",
|
||||
version: "1.0",
|
||||
},
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
resources: [],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: [],
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "new client",
|
||||
version: "1.0",
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(client.getServerVersion()).toEqual({
|
||||
name: "old server",
|
||||
version: "1.0",
|
||||
});
|
||||
});
|
||||
|
||||
/***
|
||||
* 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(
|
||||
{
|
||||
name: "new server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, (_request) => ({
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: "new server",
|
||||
version: "1.0",
|
||||
},
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
resources: [],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: [],
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "old client",
|
||||
version: "1.0",
|
||||
protocolVersion: OLD_VERSION,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(client.getServerVersion()).toEqual({
|
||||
name: "new server",
|
||||
version: "1.0",
|
||||
});
|
||||
});
|
||||
|
||||
/***
|
||||
* 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";
|
||||
const server = new Server(
|
||||
{
|
||||
name: "new server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(InitializeRequestSchema, (_request) => ({
|
||||
protocolVersion: FUTURE_VERSION,
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: "new server",
|
||||
version: "1.0",
|
||||
},
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
resources: [],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: [],
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "old client",
|
||||
version: "1.0",
|
||||
protocolVersion: OLD_VERSION,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
expect(client.connect(clientTransport)).rejects.toThrow(
|
||||
"Server's protocol version is not supported: FUTURE_VERSION"
|
||||
),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Respect Server Capabilities
|
||||
*/
|
||||
test("should respect server capabilities", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
|
|
@ -245,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(
|
||||
{
|
||||
|
|
@ -301,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(
|
||||
{
|
||||
|
|
@ -347,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(
|
||||
{
|
||||
|
|
@ -378,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"),
|
||||
|
|
@ -457,6 +715,9 @@ test("should typecheck", () => {
|
|||
});
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Handle Client Cancelling a Request
|
||||
*/
|
||||
test("should handle client cancelling a request", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
|
|
@ -512,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(
|
||||
{
|
||||
|
|
@ -566,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/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, ValidateFunction> = new Map();
|
||||
private _ajv: InstanceType<typeof Ajv>;
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HeadersInit> {
|
||||
const headers: HeadersInit = {};
|
||||
const headers = {
|
||||
...this._requestInit?.headers,
|
||||
} as HeadersInit & Record<string, string>;
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ChildProcess, IOType } from "node:child_process";
|
||||
import spawn from "cross-spawn";
|
||||
import process from "node:process";
|
||||
import { Stream } from "node:stream";
|
||||
import { Stream, PassThrough } from "node:stream";
|
||||
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage } from "../types.js";
|
||||
|
|
@ -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"];
|
||||
|
|
@ -93,6 +94,7 @@ export class StdioClientTransport implements Transport {
|
|||
private _abortController: AbortController = new AbortController();
|
||||
private _readBuffer: ReadBuffer = new ReadBuffer();
|
||||
private _serverParams: StdioServerParameters;
|
||||
private _stderrStream: PassThrough | null = null;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -100,6 +102,9 @@ export class StdioClientTransport implements Transport {
|
|||
|
||||
constructor(server: StdioServerParameters) {
|
||||
this._serverParams = server;
|
||||
if (server.stderr === "pipe" || server.stderr === "overlapped") {
|
||||
this._stderrStream = new PassThrough();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -158,18 +163,37 @@ export class StdioClientTransport implements Transport {
|
|||
this._process.stdout?.on("error", (error) => {
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
if (this._stderrStream && this._process.stderr) {
|
||||
this._process.stderr.pipe(this._stderrStream);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped".
|
||||
*
|
||||
* This is only available after the process has been started.
|
||||
* If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to
|
||||
* attach listeners before the start method is invoked. This prevents loss of any early
|
||||
* error output emitted by the child process.
|
||||
*/
|
||||
get stderr(): Stream | null {
|
||||
if (this._stderrStream) {
|
||||
return this._stderrStream;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
|
||||
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { JSONRPCMessage } from "../types.js";
|
||||
|
||||
|
||||
describe("StreamableHTTPClientTransport", () => {
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
let mockAuthProvider: jest.Mocked<OAuthClientProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
|
||||
mockAuthProvider = {
|
||||
get redirectUrl() { return "http://localhost/callback"; },
|
||||
get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; },
|
||||
clientInformation: jest.fn(() => ({ client_id: "test-client-id", client_secret: "test-client-secret" })),
|
||||
tokens: jest.fn(),
|
||||
saveTokens: jest.fn(),
|
||||
redirectToAuthorization: jest.fn(),
|
||||
saveCodeVerifier: jest.fn(),
|
||||
codeVerifier: jest.fn(),
|
||||
};
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider });
|
||||
jest.spyOn(global, "fetch");
|
||||
});
|
||||
|
||||
|
|
@ -464,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
|
||||
|
|
@ -497,4 +540,27 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
expect(getDelay(10)).toBe(5000);
|
||||
});
|
||||
|
||||
it("attempts auth flow on 401 during POST request", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
params: {},
|
||||
id: "test-id"
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers()
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
});
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Headers> {
|
||||
const headers: HeadersInit = {};
|
||||
const headers: HeadersInit & Record<string, string> = {};
|
||||
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<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
return { ...headers as Record<string, string> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
160
src/examples/client/multipleClientsParallel.ts
Normal file
160
src/examples/client/multipleClientsParallel.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { Client } from '../../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
|
||||
import {
|
||||
CallToolRequest,
|
||||
CallToolResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
CallToolResult,
|
||||
} from '../../types.js';
|
||||
|
||||
/**
|
||||
* Multiple Clients MCP Example
|
||||
*
|
||||
* This client demonstrates how to:
|
||||
* 1. Create multiple MCP clients in parallel
|
||||
* 2. Each client calls a single tool
|
||||
* 3. Track notifications from each client independently
|
||||
*/
|
||||
|
||||
// Command line args processing
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3000/mcp';
|
||||
|
||||
interface ClientConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
toolName: string;
|
||||
toolArguments: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> {
|
||||
console.log(`[${config.id}] Creating client: ${config.name}`);
|
||||
|
||||
const client = new Client({
|
||||
name: config.name,
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
// Set up client-specific error handler
|
||||
client.onerror = (error) => {
|
||||
console.error(`[${config.id}] Client error:`, error);
|
||||
};
|
||||
|
||||
// Set up client-specific notification handler
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
||||
console.log(`[${config.id}] Notification: ${notification.params.data}`);
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect to the server
|
||||
await client.connect(transport);
|
||||
console.log(`[${config.id}] Connected to MCP server`);
|
||||
|
||||
// Call the specified tool
|
||||
console.log(`[${config.id}] Calling tool: ${config.toolName}`);
|
||||
const toolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: config.toolName,
|
||||
arguments: {
|
||||
...config.toolArguments,
|
||||
// Add client ID to arguments for identification in notifications
|
||||
caller: config.id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await client.request(toolRequest, CallToolResultSchema);
|
||||
console.log(`[${config.id}] Tool call completed`);
|
||||
|
||||
// Keep the connection open for a bit to receive notifications
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Disconnect
|
||||
await transport.close();
|
||||
console.log(`[${config.id}] Disconnected from MCP server`);
|
||||
|
||||
return { id: config.id, result };
|
||||
} catch (error) {
|
||||
console.error(`[${config.id}] Error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('MCP Multiple Clients Example');
|
||||
console.log('============================');
|
||||
console.log(`Server URL: ${serverUrl}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Define client configurations
|
||||
const clientConfigs: ClientConfig[] = [
|
||||
{
|
||||
id: 'client1',
|
||||
name: 'basic-client-1',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 3, // 1 second between notifications
|
||||
count: 5 // Send 5 notifications
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'client2',
|
||||
name: 'basic-client-2',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 2, // 2 seconds between notifications
|
||||
count: 3 // Send 3 notifications
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'client3',
|
||||
name: 'basic-client-3',
|
||||
toolName: 'start-notification-stream',
|
||||
toolArguments: {
|
||||
interval: 1, // 0.5 second between notifications
|
||||
count: 8 // Send 8 notifications
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Start all clients in parallel
|
||||
console.log(`Starting ${clientConfigs.length} clients in parallel...`);
|
||||
console.log('');
|
||||
|
||||
const clientPromises = clientConfigs.map(config => createAndRunClient(config));
|
||||
const results = await Promise.all(clientPromises);
|
||||
|
||||
// Display results from all clients
|
||||
console.log('\n=== Final Results ===');
|
||||
results.forEach(({ id, result }) => {
|
||||
console.log(`\n[${id}] Tool result:`);
|
||||
if (Array.isArray(result.content)) {
|
||||
result.content.forEach((item: { type: string; text?: string }) => {
|
||||
if (item.type === 'text' && item.text) {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(` Unexpected result format:`, result);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== All clients completed successfully ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error running multiple clients:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the example
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Error running MCP multiple clients example:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue