Merge branch 'main' into dhodun/add-streamable-http-comments
This commit is contained in:
commit
17897b8ed7
24 changed files with 3743 additions and 487 deletions
368
README.md
368
README.md
|
|
@ -11,6 +11,7 @@
|
|||
- [Resources](#resources)
|
||||
- [Tools](#tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Completions](#completions)
|
||||
- [Running Your Server](#running-your-server)
|
||||
- [stdio](#stdio)
|
||||
- [Streamable HTTP](#streamable-http)
|
||||
|
|
@ -54,22 +55,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,
|
||||
|
|
@ -100,7 +109,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"
|
||||
});
|
||||
```
|
||||
|
|
@ -111,9 +120,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,
|
||||
|
|
@ -123,9 +137,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,
|
||||
|
|
@ -133,6 +151,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
|
||||
|
|
@ -141,11 +186,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: [{
|
||||
|
|
@ -156,9 +205,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();
|
||||
|
|
@ -167,16 +220,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",
|
||||
|
|
@ -187,6 +280,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
|
||||
|
|
@ -403,13 +596,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,
|
||||
|
|
@ -418,17 +615,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",
|
||||
|
|
@ -452,7 +657,7 @@ import { promisify } from "util";
|
|||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "SQLite Explorer",
|
||||
name: "sqlite-explorer",
|
||||
version: "1.0.0"
|
||||
});
|
||||
|
||||
|
|
@ -465,9 +670,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 {
|
||||
|
|
@ -486,9 +696,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 {
|
||||
|
|
@ -550,7 +764,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)
|
||||
|
|
@ -637,6 +851,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" | "decline" | "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:
|
||||
|
|
@ -685,6 +1002,7 @@ const result = await client.callTool({
|
|||
arg1: "value"
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### Proxy Authorization Requests Upstream
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/sdk",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.3",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ListRootsRequestSchema,
|
||||
ErrorCode,
|
||||
} from "../types.js";
|
||||
|
|
@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => {
|
|||
}).toThrow("Client does not support roots capability");
|
||||
});
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -165,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;
|
||||
|
||||
|
|
@ -303,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(
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport {
|
|||
private _eventSourceInit?: EventSourceInit;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _protocolVersion?: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -99,13 +100,18 @@ 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;
|
||||
}
|
||||
|
|
@ -214,7 +220,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,
|
||||
|
|
@ -249,4 +255,8 @@ export class SSEClientTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this._protocolVersion = version;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
private _authProvider?: OAuthClientProvider;
|
||||
private _sessionId?: string;
|
||||
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
|
||||
private _protocolVersion?: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -162,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) {
|
||||
|
|
@ -173,6 +174,9 @@ 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 }
|
||||
|
|
@ -516,4 +520,11 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this._protocolVersion = version;
|
||||
}
|
||||
get protocolVersion(): string | undefined {
|
||||
return this._protocolVersion;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import {
|
|||
ListResourcesResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
ResourceLink,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResultSchema,
|
||||
} from '../../types.js';
|
||||
import { getDisplayName } from '../../shared/metadataUtils.js';
|
||||
import Ajv from "ajv";
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
|
|
@ -54,11 +60,13 @@ function printHelp(): void {
|
|||
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
|
||||
console.log(' greet [name] - Call the greet tool');
|
||||
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
|
||||
console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)');
|
||||
console.log(' start-notifications [interval] [count] - Start periodic notifications');
|
||||
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
|
||||
console.log(' list-prompts - List available prompts');
|
||||
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
|
||||
console.log(' list-resources - List available resources');
|
||||
console.log(' read-resource <uri> - Read a specific resource by URI');
|
||||
console.log(' help - Show this help');
|
||||
console.log(' quit - Exit the program');
|
||||
}
|
||||
|
|
@ -115,6 +123,10 @@ function commandLoop(): void {
|
|||
await callMultiGreetTool(args[1] || 'MCP User');
|
||||
break;
|
||||
|
||||
case 'collect-info':
|
||||
await callCollectInfoTool(args[1] || 'contact');
|
||||
break;
|
||||
|
||||
case 'start-notifications': {
|
||||
const interval = args[1] ? parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? parseInt(args[2], 10) : 10;
|
||||
|
|
@ -154,6 +166,14 @@ function commandLoop(): void {
|
|||
await listResources();
|
||||
break;
|
||||
|
||||
case 'read-resource':
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: read-resource <uri>');
|
||||
} else {
|
||||
await readResource(args[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
printHelp();
|
||||
break;
|
||||
|
|
@ -191,15 +211,212 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log(`Connecting to ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
// Create a new client
|
||||
// Create a new client with elicitation capability
|
||||
client = new Client({
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
}, {
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
});
|
||||
client.onerror = (error) => {
|
||||
console.error('\x1b[31mClient error:', error, '\x1b[0m');
|
||||
}
|
||||
|
||||
// Set up elicitation request handler with proper validation
|
||||
client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
||||
console.log('\n🔔 Elicitation Request Received:');
|
||||
console.log(`Message: ${request.params.message}`);
|
||||
console.log('Requested Schema:');
|
||||
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
|
||||
|
||||
const schema = request.params.requestedSchema;
|
||||
const properties = schema.properties;
|
||||
const required = schema.required || [];
|
||||
|
||||
// Set up AJV validator for the requested schema
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`);
|
||||
|
||||
const content: Record<string, unknown> = {};
|
||||
let inputCancelled = false;
|
||||
|
||||
// Collect input for each field
|
||||
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
||||
const field = fieldSchema as {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const isRequired = required.includes(fieldName);
|
||||
let prompt = `${field.title || fieldName}`;
|
||||
|
||||
// Add helpful information to the prompt
|
||||
if (field.description) {
|
||||
prompt += ` (${field.description})`;
|
||||
}
|
||||
if (field.enum) {
|
||||
prompt += ` [options: ${field.enum.join(', ')}]`;
|
||||
}
|
||||
if (field.type === 'number' || field.type === 'integer') {
|
||||
if (field.minimum !== undefined && field.maximum !== undefined) {
|
||||
prompt += ` [${field.minimum}-${field.maximum}]`;
|
||||
} else if (field.minimum !== undefined) {
|
||||
prompt += ` [min: ${field.minimum}]`;
|
||||
} else if (field.maximum !== undefined) {
|
||||
prompt += ` [max: ${field.maximum}]`;
|
||||
}
|
||||
}
|
||||
if (field.type === 'string' && field.format) {
|
||||
prompt += ` [format: ${field.format}]`;
|
||||
}
|
||||
if (isRequired) {
|
||||
prompt += ' *required*';
|
||||
}
|
||||
if (field.default !== undefined) {
|
||||
prompt += ` [default: ${field.default}]`;
|
||||
}
|
||||
|
||||
prompt += ': ';
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
readline.question(prompt, (input) => {
|
||||
resolve(input.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// Check for cancellation
|
||||
if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') {
|
||||
inputCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse and validate the input
|
||||
try {
|
||||
if (answer === '' && field.default !== undefined) {
|
||||
content[fieldName] = field.default;
|
||||
} else if (answer === '' && !isRequired) {
|
||||
// Skip optional empty fields
|
||||
continue;
|
||||
} else if (answer === '') {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
} else {
|
||||
// Parse the value based on type
|
||||
let parsedValue: unknown;
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1';
|
||||
} else if (field.type === 'number') {
|
||||
parsedValue = parseFloat(answer);
|
||||
if (isNaN(parsedValue as number)) {
|
||||
throw new Error(`${fieldName} must be a valid number`);
|
||||
}
|
||||
} else if (field.type === 'integer') {
|
||||
parsedValue = parseInt(answer, 10);
|
||||
if (isNaN(parsedValue as number)) {
|
||||
throw new Error(`${fieldName} must be a valid integer`);
|
||||
}
|
||||
} else if (field.enum) {
|
||||
if (!field.enum.includes(answer)) {
|
||||
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
|
||||
}
|
||||
parsedValue = answer;
|
||||
} else {
|
||||
parsedValue = answer;
|
||||
}
|
||||
|
||||
content[fieldName] = parsedValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Error: ${error}`);
|
||||
// Continue to next attempt
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputCancelled) {
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
// If we didn't complete all fields due to an error, try again
|
||||
if (Object.keys(content).length !== Object.keys(properties).filter(name =>
|
||||
required.includes(name) || content[name] !== undefined
|
||||
).length) {
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please try again...');
|
||||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the complete object against the schema
|
||||
const isValid = validate(content);
|
||||
|
||||
if (!isValid) {
|
||||
console.log('❌ Validation errors:');
|
||||
validate.errors?.forEach(error => {
|
||||
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
|
||||
});
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please correct the errors and try again...');
|
||||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
// Show the collected data and ask for confirmation
|
||||
console.log('\n✅ Collected data:');
|
||||
console.log(JSON.stringify(content, null, 2));
|
||||
|
||||
const confirmAnswer = await new Promise<string>((resolve) => {
|
||||
readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => {
|
||||
resolve(input.trim().toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
|
||||
return {
|
||||
action: 'accept',
|
||||
content,
|
||||
};
|
||||
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
|
||||
return { action: 'cancel' };
|
||||
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please re-enter the information...');
|
||||
continue;
|
||||
} else {
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(serverUrl),
|
||||
{
|
||||
|
|
@ -317,7 +534,7 @@ async function listTools(): Promise<void> {
|
|||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -344,13 +561,37 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
|
|||
const result = await client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log('Tool result:');
|
||||
const resourceLinks: ResourceLink[] = [];
|
||||
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else if (item.type === 'resource_link') {
|
||||
const resourceLink = item as ResourceLink;
|
||||
resourceLinks.push(resourceLink);
|
||||
console.log(` 📁 Resource Link: ${resourceLink.name}`);
|
||||
console.log(` URI: ${resourceLink.uri}`);
|
||||
if (resourceLink.mimeType) {
|
||||
console.log(` Type: ${resourceLink.mimeType}`);
|
||||
}
|
||||
if (resourceLink.description) {
|
||||
console.log(` Description: ${resourceLink.description}`);
|
||||
}
|
||||
} else if (item.type === 'resource') {
|
||||
console.log(` [Embedded Resource: ${item.resource.uri}]`);
|
||||
} else if (item.type === 'image') {
|
||||
console.log(` [Image: ${item.mimeType}]`);
|
||||
} else if (item.type === 'audio') {
|
||||
console.log(` [Audio: ${item.mimeType}]`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
console.log(` [Unknown content type]:`, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Offer to read resource links
|
||||
if (resourceLinks.length > 0) {
|
||||
console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource <uri>' to read their content.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error calling tool ${name}: ${error}`);
|
||||
}
|
||||
|
|
@ -366,6 +607,11 @@ async function callMultiGreetTool(name: string): Promise<void> {
|
|||
await callTool('multi-greet', { name });
|
||||
}
|
||||
|
||||
async function callCollectInfoTool(infoType: string): Promise<void> {
|
||||
console.log(`Testing elicitation with collect-user-info tool (${infoType})...`);
|
||||
await callTool('collect-user-info', { infoType });
|
||||
}
|
||||
|
||||
async function startNotifications(interval: number, count: number): Promise<void> {
|
||||
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
await callTool('start-notification-stream', { interval, count });
|
||||
|
|
@ -380,7 +626,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num
|
|||
try {
|
||||
console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
|
||||
|
||||
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
|
|
@ -393,7 +639,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num
|
|||
notificationsToolLastEventId = event;
|
||||
console.log(`Updated resumption token: ${event}`);
|
||||
};
|
||||
|
||||
|
||||
const result = await client.request(request, CallToolResultSchema, {
|
||||
resumptionToken: notificationsToolLastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
|
|
@ -429,7 +675,7 @@ async function listPrompts(): Promise<void> {
|
|||
console.log(' No prompts available');
|
||||
} else {
|
||||
for (const prompt of promptsResult.prompts) {
|
||||
console.log(` - ${prompt.name}: ${prompt.description}`);
|
||||
console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -480,7 +726,7 @@ async function listResources(): Promise<void> {
|
|||
console.log(' No resources available');
|
||||
} else {
|
||||
for (const resource of resourcesResult.resources) {
|
||||
console.log(` - ${resource.name}: ${resource.uri}`);
|
||||
console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -488,6 +734,42 @@ async function listResources(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function readResource(uri: string): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ReadResourceRequest = {
|
||||
method: 'resources/read',
|
||||
params: { uri }
|
||||
};
|
||||
|
||||
console.log(`Reading resource: ${uri}`);
|
||||
const result = await client.request(request, ReadResourceResultSchema);
|
||||
|
||||
console.log('Resource contents:');
|
||||
for (const content of result.contents) {
|
||||
console.log(` URI: ${content.uri}`);
|
||||
if (content.mimeType) {
|
||||
console.log(` Type: ${content.mimeType}`);
|
||||
}
|
||||
|
||||
if ('text' in content && typeof content.text === 'string') {
|
||||
console.log(' Content:');
|
||||
console.log(' ---');
|
||||
console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n'));
|
||||
console.log(' ---');
|
||||
} else if ('blob' in content && typeof content.blob === 'string') {
|
||||
console.log(` [Binary data: ${content.blob.length} bytes]`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error reading resource ${uri}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js';
|
|||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
|
||||
import { OAuthMetadata } from 'src/shared/auth.js';
|
||||
|
|
@ -17,15 +17,18 @@ const useOAuth = process.argv.includes('--oauth');
|
|||
const getServer = () => {
|
||||
const server = new McpServer({
|
||||
name: 'simple-streamable-http-server',
|
||||
version: '1.0.0',
|
||||
version: '1.0.0'
|
||||
}, { capabilities: { logging: {} } });
|
||||
|
||||
// Register a simple tool that returns a greeting
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet'),
|
||||
title: 'Greeting Tool', // Display name for UI
|
||||
description: 'A simple greeting tool',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
},
|
||||
async ({ name }): Promise<CallToolResult> => {
|
||||
return {
|
||||
|
|
@ -83,13 +86,165 @@ const getServer = () => {
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a simple prompt
|
||||
server.prompt(
|
||||
'greeting-template',
|
||||
'A simple greeting prompt template',
|
||||
// Register a tool that demonstrates elicitation (user input collection)
|
||||
// This creates a closure that captures the server instance
|
||||
server.tool(
|
||||
'collect-user-info',
|
||||
'A tool that collects user information through elicitation',
|
||||
{
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
|
||||
},
|
||||
async ({ infoType }): Promise<CallToolResult> => {
|
||||
let message: string;
|
||||
let requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
switch (infoType) {
|
||||
case 'contact':
|
||||
message = 'Please provide your contact information';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Full Name',
|
||||
description: 'Your full name',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
title: 'Email Address',
|
||||
description: 'Your email address',
|
||||
format: 'email',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
title: 'Phone Number',
|
||||
description: 'Your phone number (optional)',
|
||||
},
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
};
|
||||
break;
|
||||
case 'preferences':
|
||||
message = 'Please set your preferences';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: {
|
||||
type: 'string',
|
||||
title: 'Theme',
|
||||
description: 'Choose your preferred theme',
|
||||
enum: ['light', 'dark', 'auto'],
|
||||
enumNames: ['Light', 'Dark', 'Auto'],
|
||||
},
|
||||
notifications: {
|
||||
type: 'boolean',
|
||||
title: 'Enable Notifications',
|
||||
description: 'Would you like to receive notifications?',
|
||||
default: true,
|
||||
},
|
||||
frequency: {
|
||||
type: 'string',
|
||||
title: 'Notification Frequency',
|
||||
description: 'How often would you like notifications?',
|
||||
enum: ['daily', 'weekly', 'monthly'],
|
||||
enumNames: ['Daily', 'Weekly', 'Monthly'],
|
||||
},
|
||||
},
|
||||
required: ['theme'],
|
||||
};
|
||||
break;
|
||||
case 'feedback':
|
||||
message = 'Please provide your feedback';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rating: {
|
||||
type: 'integer',
|
||||
title: 'Rating',
|
||||
description: 'Rate your experience (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
comments: {
|
||||
type: 'string',
|
||||
title: 'Comments',
|
||||
description: 'Additional comments (optional)',
|
||||
maxLength: 500,
|
||||
},
|
||||
recommend: {
|
||||
type: 'boolean',
|
||||
title: 'Would you recommend this?',
|
||||
description: 'Would you recommend this to others?',
|
||||
},
|
||||
},
|
||||
required: ['rating', 'recommend'],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown info type: ${infoType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the underlying server instance to elicit input from the client
|
||||
const result = await server.server.elicitInput({
|
||||
message,
|
||||
requestedSchema,
|
||||
});
|
||||
|
||||
if (result.action === 'accept') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (result.action === 'decline') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No information was collected. User declined to provide ${infoType} information.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Information collection was cancelled by the user.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error collecting ${infoType} information: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a simple prompt with title
|
||||
server.registerPrompt(
|
||||
'greeting-template',
|
||||
{
|
||||
title: 'Greeting Template', // Display name for UI
|
||||
description: 'A simple greeting prompt template',
|
||||
argsSchema: {
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
},
|
||||
},
|
||||
async ({ name }): Promise<GetPromptResult> => {
|
||||
return {
|
||||
|
|
@ -148,10 +303,14 @@ const getServer = () => {
|
|||
);
|
||||
|
||||
// Create a simple resource at a fixed URI
|
||||
server.resource(
|
||||
server.registerResource(
|
||||
'greeting-resource',
|
||||
'https://example.com/greetings/default',
|
||||
{ mimeType: 'text/plain' },
|
||||
{
|
||||
title: 'Default Greeting', // Display name for UI
|
||||
description: 'A simple greeting resource',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
|
|
@ -163,6 +322,99 @@ const getServer = () => {
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create additional resources for ResourceLink demonstration
|
||||
server.registerResource(
|
||||
'example-file-1',
|
||||
'file:///example/file1.txt',
|
||||
{
|
||||
title: 'Example File 1',
|
||||
description: 'First example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file1.txt',
|
||||
text: 'This is the content of file 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'example-file-2',
|
||||
'file:///example/file2.txt',
|
||||
{
|
||||
title: 'Example File 2',
|
||||
description: 'Second example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file2.txt',
|
||||
text: 'This is the content of file 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
'list-files',
|
||||
{
|
||||
title: 'List Files with ResourceLinks',
|
||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
||||
inputSchema: {
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
||||
},
|
||||
},
|
||||
async ({ includeDescriptions = true }): Promise<CallToolResult> => {
|
||||
const resourceLinks: ResourceLink[] = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/greetings/default',
|
||||
name: 'Default Greeting',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'A simple greeting resource' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file1.txt',
|
||||
name: 'Example File 1',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file2.txt',
|
||||
name: 'Example File 2',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' })
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the available files as resource links:',
|
||||
},
|
||||
...resourceLinks,
|
||||
{
|
||||
type: 'text',
|
||||
text: '\nYou can read any of these resources using their URI.',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Client } from '../client/index.js';
|
|||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js';
|
||||
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
|
|
@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => {
|
|||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should set protocol version after connecting', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Verify protocol version is not set before connecting
|
||||
expect(transport.protocolVersion).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify protocol version is set after connecting
|
||||
expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export enum McpZodTypeKind {
|
|||
|
||||
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
|
||||
value: T["_input"],
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
},
|
||||
) => T["_input"][] | Promise<T["_input"][]>;
|
||||
|
||||
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
LATEST_PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
|
|
@ -267,6 +268,318 @@ test("should respect client capabilities", async () => {
|
|||
await expect(server.listRoots()).rejects.toThrow(/^Client does not support/);
|
||||
});
|
||||
|
||||
test("should respect client elicitation capabilities", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, (params) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
username: params.params.message.includes("username") ? "test-user" : undefined,
|
||||
confirmed: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(server.getClientCapabilities()).toEqual({ elicitation: {} });
|
||||
|
||||
// This should work because elicitation is supported by the client
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your username",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: {
|
||||
type: "string",
|
||||
title: "Username",
|
||||
description: "Your username",
|
||||
},
|
||||
confirmed: {
|
||||
type: "boolean",
|
||||
title: "Confirm",
|
||||
description: "Please confirm",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ["username"],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {
|
||||
username: "test-user",
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
|
||||
// This should still throw because sampling is not supported by the client
|
||||
await expect(
|
||||
server.createMessage({
|
||||
messages: [],
|
||||
maxTokens: 10,
|
||||
}),
|
||||
).rejects.toThrow(/^Client does not support/);
|
||||
});
|
||||
|
||||
test("should validate elicitation response against requested schema", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
age: 30,
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 150,
|
||||
},
|
||||
},
|
||||
required: ["name", "email"],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject elicitation response with invalid data", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Set up client to return invalid response (missing required field, invalid age)
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
email: "", // Invalid - too short
|
||||
age: -5, // Invalid age
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// Test with invalid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 150,
|
||||
},
|
||||
},
|
||||
required: ["name", "email"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test("should allow elicitation decline and cancel without validation", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return { action: "decline" };
|
||||
} else {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
// Test decline - should not validate
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your name",
|
||||
requestedSchema: schema,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "decline",
|
||||
});
|
||||
|
||||
// Test cancel - should not validate
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your name",
|
||||
requestedSchema: schema,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
test("should respect server notification capabilities", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResultSchema,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
InitializedNotificationSchema,
|
||||
|
|
@ -18,6 +21,8 @@ import {
|
|||
ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
LoggingMessageNotification,
|
||||
McpError,
|
||||
ErrorCode,
|
||||
Notification,
|
||||
Request,
|
||||
ResourceUpdatedNotification,
|
||||
|
|
@ -28,6 +33,7 @@ import {
|
|||
ServerResult,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
} from "../types.js";
|
||||
import Ajv from "ajv";
|
||||
|
||||
export type ServerOptions = ProtocolOptions & {
|
||||
/**
|
||||
|
|
@ -129,6 +135,14 @@ export class Server<
|
|||
}
|
||||
break;
|
||||
|
||||
case "elicitation/create":
|
||||
if (!this._clientCapabilities?.elicitation) {
|
||||
throw new Error(
|
||||
`Client does not support elicitation (required for ${method})`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "roots/list":
|
||||
if (!this._clientCapabilities?.roots) {
|
||||
throw new Error(
|
||||
|
|
@ -251,10 +265,12 @@ export class Server<
|
|||
this._clientCapabilities = request.params.capabilities;
|
||||
this._clientVersion = request.params.clientInfo;
|
||||
|
||||
return {
|
||||
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
? requestedVersion
|
||||
: LATEST_PROTOCOL_VERSION,
|
||||
: LATEST_PROTOCOL_VERSION;
|
||||
|
||||
return {
|
||||
protocolVersion,
|
||||
capabilities: this.getCapabilities(),
|
||||
serverInfo: this._serverInfo,
|
||||
...(this._instructions && { instructions: this._instructions }),
|
||||
|
|
@ -294,6 +310,44 @@ export class Server<
|
|||
);
|
||||
}
|
||||
|
||||
async elicitInput(
|
||||
params: ElicitRequest["params"],
|
||||
options?: RequestOptions,
|
||||
): Promise<ElicitResult> {
|
||||
const result = await this.request(
|
||||
{ method: "elicitation/create", params },
|
||||
ElicitResultSchema,
|
||||
options,
|
||||
);
|
||||
|
||||
// Validate the response content against the requested schema if action is "accept"
|
||||
if (result.action === "accept" && result.content) {
|
||||
try {
|
||||
const ajv = new Ajv();
|
||||
|
||||
const validate = ajv.compile(params.requestedSchema);
|
||||
const isValid = validate(result.content);
|
||||
|
||||
if (!isValid) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error validating elicitation response: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async listRoots(
|
||||
params?: ListRootsRequest["params"],
|
||||
options?: RequestOptions,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,8 @@ import {
|
|||
CompleteRequest,
|
||||
CompleteResult,
|
||||
PromptReference,
|
||||
ResourceReference,
|
||||
ResourceTemplateReference,
|
||||
BaseMetadata,
|
||||
Resource,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
|
|
@ -113,6 +114,7 @@ export class McpServer {
|
|||
([name, tool]): Tool => {
|
||||
const toolDefinition: Tool = {
|
||||
name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
? (zodToJsonSchema(tool.inputSchema, {
|
||||
|
|
@ -124,7 +126,7 @@ export class McpServer {
|
|||
|
||||
if (tool.outputSchema) {
|
||||
toolDefinition.outputSchema = zodToJsonSchema(
|
||||
tool.outputSchema,
|
||||
tool.outputSchema,
|
||||
{ strictUnions: true }
|
||||
) as Tool["outputSchema"];
|
||||
}
|
||||
|
|
@ -236,6 +238,10 @@ export class McpServer {
|
|||
CompleteRequestSchema.shape.method.value,
|
||||
);
|
||||
|
||||
this.server.registerCapabilities({
|
||||
completions: {},
|
||||
});
|
||||
|
||||
this.server.setRequestHandler(
|
||||
CompleteRequestSchema,
|
||||
async (request): Promise<CompleteResult> => {
|
||||
|
|
@ -287,13 +293,13 @@ export class McpServer {
|
|||
}
|
||||
|
||||
const def: CompletableDef<ZodString> = field._def;
|
||||
const suggestions = await def.complete(request.params.argument.value);
|
||||
const suggestions = await def.complete(request.params.argument.value, request.params.context);
|
||||
return createCompletionResult(suggestions);
|
||||
}
|
||||
|
||||
private async handleResourceCompletion(
|
||||
request: CompleteRequest,
|
||||
ref: ResourceReference,
|
||||
ref: ResourceTemplateReference,
|
||||
): Promise<CompleteResult> {
|
||||
const template = Object.values(this._registeredResourceTemplates).find(
|
||||
(t) => t.resourceTemplate.uriTemplate.toString() === ref.uri,
|
||||
|
|
@ -318,7 +324,7 @@ export class McpServer {
|
|||
return EMPTY_COMPLETION_RESULT;
|
||||
}
|
||||
|
||||
const suggestions = await completer(request.params.argument.value);
|
||||
const suggestions = await completer(request.params.argument.value, request.params.context);
|
||||
return createCompletionResult(suggestions);
|
||||
}
|
||||
|
||||
|
|
@ -369,8 +375,9 @@ export class McpServer {
|
|||
const result = await template.resourceTemplate.listCallback(extra);
|
||||
for (const resource of result.resources) {
|
||||
templateResources.push({
|
||||
...resource,
|
||||
...template.metadata,
|
||||
// the defined resource metadata should override the template metadata if present
|
||||
...resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +471,7 @@ export class McpServer {
|
|||
([name, prompt]): Prompt => {
|
||||
return {
|
||||
name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.argsSchema
|
||||
? promptArgumentsFromSchema(prompt.argsSchema)
|
||||
|
|
@ -571,27 +579,13 @@ export class McpServer {
|
|||
throw new Error(`Resource ${uriOrTemplate} is already registered`);
|
||||
}
|
||||
|
||||
const registeredResource: RegisteredResource = {
|
||||
const registeredResource = this._createRegisteredResource(
|
||||
name,
|
||||
undefined,
|
||||
uriOrTemplate,
|
||||
metadata,
|
||||
readCallback: readCallback as ReadResourceCallback,
|
||||
enabled: true,
|
||||
disable: () => registeredResource.update({ enabled: false }),
|
||||
enable: () => registeredResource.update({ enabled: true }),
|
||||
remove: () => registeredResource.update({ uri: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) {
|
||||
delete this._registeredResources[uriOrTemplate]
|
||||
if (updates.uri) this._registeredResources[updates.uri] = registeredResource
|
||||
}
|
||||
if (typeof updates.name !== "undefined") registeredResource.name = updates.name
|
||||
if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata
|
||||
if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled
|
||||
this.sendResourceListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredResources[uriOrTemplate] = registeredResource;
|
||||
readCallback as ReadResourceCallback
|
||||
);
|
||||
|
||||
this.setResourceRequestHandlers();
|
||||
this.sendResourceListChanged();
|
||||
|
|
@ -601,27 +595,13 @@ export class McpServer {
|
|||
throw new Error(`Resource template ${name} is already registered`);
|
||||
}
|
||||
|
||||
const registeredResourceTemplate: RegisteredResourceTemplate = {
|
||||
resourceTemplate: uriOrTemplate,
|
||||
const registeredResourceTemplate = this._createRegisteredResourceTemplate(
|
||||
name,
|
||||
undefined,
|
||||
uriOrTemplate,
|
||||
metadata,
|
||||
readCallback: readCallback as ReadResourceTemplateCallback,
|
||||
enabled: true,
|
||||
disable: () => registeredResourceTemplate.update({ enabled: false }),
|
||||
enable: () => registeredResourceTemplate.update({ enabled: true }),
|
||||
remove: () => registeredResourceTemplate.update({ name: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.name !== "undefined" && updates.name !== name) {
|
||||
delete this._registeredResourceTemplates[name]
|
||||
if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate
|
||||
}
|
||||
if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template
|
||||
if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata
|
||||
if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled
|
||||
this.sendResourceListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredResourceTemplates[name] = registeredResourceTemplate;
|
||||
readCallback as ReadResourceTemplateCallback
|
||||
);
|
||||
|
||||
this.setResourceRequestHandlers();
|
||||
this.sendResourceListChanged();
|
||||
|
|
@ -629,8 +609,153 @@ export class McpServer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a resource with a config object and callback.
|
||||
* For static resources, use a URI string. For dynamic resources, use a ResourceTemplate.
|
||||
*/
|
||||
registerResource(
|
||||
name: string,
|
||||
uriOrTemplate: string | ResourceTemplate,
|
||||
config: ResourceMetadata,
|
||||
readCallback: ReadResourceCallback | ReadResourceTemplateCallback
|
||||
): RegisteredResource | RegisteredResourceTemplate {
|
||||
if (typeof uriOrTemplate === "string") {
|
||||
if (this._registeredResources[uriOrTemplate]) {
|
||||
throw new Error(`Resource ${uriOrTemplate} is already registered`);
|
||||
}
|
||||
|
||||
const registeredResource = this._createRegisteredResource(
|
||||
name,
|
||||
(config as BaseMetadata).title,
|
||||
uriOrTemplate,
|
||||
config,
|
||||
readCallback as ReadResourceCallback
|
||||
);
|
||||
|
||||
this.setResourceRequestHandlers();
|
||||
this.sendResourceListChanged();
|
||||
return registeredResource;
|
||||
} else {
|
||||
if (this._registeredResourceTemplates[name]) {
|
||||
throw new Error(`Resource template ${name} is already registered`);
|
||||
}
|
||||
|
||||
const registeredResourceTemplate = this._createRegisteredResourceTemplate(
|
||||
name,
|
||||
(config as BaseMetadata).title,
|
||||
uriOrTemplate,
|
||||
config,
|
||||
readCallback as ReadResourceTemplateCallback
|
||||
);
|
||||
|
||||
this.setResourceRequestHandlers();
|
||||
this.sendResourceListChanged();
|
||||
return registeredResourceTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
private _createRegisteredResource(
|
||||
name: string,
|
||||
title: string | undefined,
|
||||
uri: string,
|
||||
metadata: ResourceMetadata | undefined,
|
||||
readCallback: ReadResourceCallback
|
||||
): RegisteredResource {
|
||||
const registeredResource: RegisteredResource = {
|
||||
name,
|
||||
title,
|
||||
metadata,
|
||||
readCallback,
|
||||
enabled: true,
|
||||
disable: () => registeredResource.update({ enabled: false }),
|
||||
enable: () => registeredResource.update({ enabled: true }),
|
||||
remove: () => registeredResource.update({ uri: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.uri !== "undefined" && updates.uri !== uri) {
|
||||
delete this._registeredResources[uri]
|
||||
if (updates.uri) this._registeredResources[updates.uri] = registeredResource
|
||||
}
|
||||
if (typeof updates.name !== "undefined") registeredResource.name = updates.name
|
||||
if (typeof updates.title !== "undefined") registeredResource.title = updates.title
|
||||
if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata
|
||||
if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled
|
||||
this.sendResourceListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredResources[uri] = registeredResource;
|
||||
return registeredResource;
|
||||
}
|
||||
|
||||
private _createRegisteredResourceTemplate(
|
||||
name: string,
|
||||
title: string | undefined,
|
||||
template: ResourceTemplate,
|
||||
metadata: ResourceMetadata | undefined,
|
||||
readCallback: ReadResourceTemplateCallback
|
||||
): RegisteredResourceTemplate {
|
||||
const registeredResourceTemplate: RegisteredResourceTemplate = {
|
||||
resourceTemplate: template,
|
||||
title,
|
||||
metadata,
|
||||
readCallback,
|
||||
enabled: true,
|
||||
disable: () => registeredResourceTemplate.update({ enabled: false }),
|
||||
enable: () => registeredResourceTemplate.update({ enabled: true }),
|
||||
remove: () => registeredResourceTemplate.update({ name: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.name !== "undefined" && updates.name !== name) {
|
||||
delete this._registeredResourceTemplates[name]
|
||||
if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate
|
||||
}
|
||||
if (typeof updates.title !== "undefined") registeredResourceTemplate.title = updates.title
|
||||
if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template
|
||||
if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata
|
||||
if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled
|
||||
this.sendResourceListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredResourceTemplates[name] = registeredResourceTemplate;
|
||||
return registeredResourceTemplate;
|
||||
}
|
||||
|
||||
private _createRegisteredPrompt(
|
||||
name: string,
|
||||
title: string | undefined,
|
||||
description: string | undefined,
|
||||
argsSchema: PromptArgsRawShape | undefined,
|
||||
callback: PromptCallback<PromptArgsRawShape | undefined>
|
||||
): RegisteredPrompt {
|
||||
const registeredPrompt: RegisteredPrompt = {
|
||||
title,
|
||||
description,
|
||||
argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema),
|
||||
callback,
|
||||
enabled: true,
|
||||
disable: () => registeredPrompt.update({ enabled: false }),
|
||||
enable: () => registeredPrompt.update({ enabled: true }),
|
||||
remove: () => registeredPrompt.update({ name: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.name !== "undefined" && updates.name !== name) {
|
||||
delete this._registeredPrompts[name]
|
||||
if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt
|
||||
}
|
||||
if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title
|
||||
if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description
|
||||
if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema)
|
||||
if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled
|
||||
this.sendPromptListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredPrompts[name] = registeredPrompt;
|
||||
return registeredPrompt;
|
||||
}
|
||||
|
||||
private _createRegisteredTool(
|
||||
name: string,
|
||||
title: string | undefined,
|
||||
description: string | undefined,
|
||||
inputSchema: ZodRawShape | undefined,
|
||||
outputSchema: ZodRawShape | undefined,
|
||||
|
|
@ -638,6 +763,7 @@ export class McpServer {
|
|||
callback: ToolCallback<ZodRawShape | undefined>
|
||||
): RegisteredTool {
|
||||
const registeredTool: RegisteredTool = {
|
||||
title,
|
||||
description,
|
||||
inputSchema:
|
||||
inputSchema === undefined ? undefined : z.object(inputSchema),
|
||||
|
|
@ -654,6 +780,7 @@ export class McpServer {
|
|||
delete this._registeredTools[name]
|
||||
if (updates.name) this._registeredTools[updates.name] = registeredTool
|
||||
}
|
||||
if (typeof updates.title !== "undefined") registeredTool.title = updates.title
|
||||
if (typeof updates.description !== "undefined") registeredTool.description = updates.description
|
||||
if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema)
|
||||
if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback
|
||||
|
|
@ -775,7 +902,7 @@ export class McpServer {
|
|||
}
|
||||
const callback = rest[0] as ToolCallback<ZodRawShape | undefined>;
|
||||
|
||||
return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback)
|
||||
return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -784,6 +911,7 @@ export class McpServer {
|
|||
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
|
|
@ -795,16 +923,17 @@ export class McpServer {
|
|||
throw new Error(`Tool ${name} is already registered`);
|
||||
}
|
||||
|
||||
const { description, inputSchema, outputSchema, annotations } = config;
|
||||
const { title, description, inputSchema, outputSchema, annotations } = config;
|
||||
|
||||
return this._createRegisteredTool(
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
annotations,
|
||||
cb as ToolCallback<ZodRawShape | undefined>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -852,27 +981,13 @@ export class McpServer {
|
|||
}
|
||||
|
||||
const cb = rest[0] as PromptCallback<PromptArgsRawShape | undefined>;
|
||||
const registeredPrompt: RegisteredPrompt = {
|
||||
const registeredPrompt = this._createRegisteredPrompt(
|
||||
name,
|
||||
undefined,
|
||||
description,
|
||||
argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema),
|
||||
callback: cb,
|
||||
enabled: true,
|
||||
disable: () => registeredPrompt.update({ enabled: false }),
|
||||
enable: () => registeredPrompt.update({ enabled: true }),
|
||||
remove: () => registeredPrompt.update({ name: null }),
|
||||
update: (updates) => {
|
||||
if (typeof updates.name !== "undefined" && updates.name !== name) {
|
||||
delete this._registeredPrompts[name]
|
||||
if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt
|
||||
}
|
||||
if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description
|
||||
if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema)
|
||||
if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback
|
||||
if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled
|
||||
this.sendPromptListChanged()
|
||||
},
|
||||
};
|
||||
this._registeredPrompts[name] = registeredPrompt;
|
||||
argsSchema,
|
||||
cb
|
||||
);
|
||||
|
||||
this.setPromptRequestHandlers();
|
||||
this.sendPromptListChanged()
|
||||
|
|
@ -880,6 +995,38 @@ export class McpServer {
|
|||
return registeredPrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a prompt with a config object and callback.
|
||||
*/
|
||||
registerPrompt<Args extends PromptArgsRawShape>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
argsSchema?: Args;
|
||||
},
|
||||
cb: PromptCallback<Args>
|
||||
): RegisteredPrompt {
|
||||
if (this._registeredPrompts[name]) {
|
||||
throw new Error(`Prompt ${name} is already registered`);
|
||||
}
|
||||
|
||||
const { title, description, argsSchema } = config;
|
||||
|
||||
const registeredPrompt = this._createRegisteredPrompt(
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
argsSchema,
|
||||
cb as PromptCallback<PromptArgsRawShape | undefined>
|
||||
);
|
||||
|
||||
this.setPromptRequestHandlers();
|
||||
this.sendPromptListChanged()
|
||||
|
||||
return registeredPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server is connected to a transport.
|
||||
* @returns True if the server is connected
|
||||
|
|
@ -921,6 +1068,9 @@ export class McpServer {
|
|||
*/
|
||||
export type CompleteResourceTemplateCallback = (
|
||||
value: string,
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
},
|
||||
) => string[] | Promise<string[]>;
|
||||
|
||||
/**
|
||||
|
|
@ -995,6 +1145,7 @@ export type ToolCallback<Args extends undefined | ZodRawShape = undefined> =
|
|||
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => CallToolResult | Promise<CallToolResult>;
|
||||
|
||||
export type RegisteredTool = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: AnyZodObject;
|
||||
outputSchema?: AnyZodObject;
|
||||
|
|
@ -1004,15 +1155,16 @@ export type RegisteredTool = {
|
|||
enable(): void;
|
||||
disable(): void;
|
||||
update<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
|
||||
updates: {
|
||||
name?: string | null,
|
||||
description?: string,
|
||||
paramsSchema?: InputArgs,
|
||||
outputSchema?: OutputArgs,
|
||||
annotations?: ToolAnnotations,
|
||||
callback?: ToolCallback<InputArgs>,
|
||||
enabled?: boolean
|
||||
}): void
|
||||
updates: {
|
||||
name?: string | null,
|
||||
title?: string,
|
||||
description?: string,
|
||||
paramsSchema?: InputArgs,
|
||||
outputSchema?: OutputArgs,
|
||||
annotations?: ToolAnnotations,
|
||||
callback?: ToolCallback<InputArgs>,
|
||||
enabled?: boolean
|
||||
}): void
|
||||
remove(): void
|
||||
};
|
||||
|
||||
|
|
@ -1060,12 +1212,13 @@ export type ReadResourceCallback = (
|
|||
|
||||
export type RegisteredResource = {
|
||||
name: string;
|
||||
title?: string;
|
||||
metadata?: ResourceMetadata;
|
||||
readCallback: ReadResourceCallback;
|
||||
enabled: boolean;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void
|
||||
update(updates: { name?: string, title?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void
|
||||
remove(): void
|
||||
};
|
||||
|
||||
|
|
@ -1080,12 +1233,13 @@ export type ReadResourceTemplateCallback = (
|
|||
|
||||
export type RegisteredResourceTemplate = {
|
||||
resourceTemplate: ResourceTemplate;
|
||||
title?: string;
|
||||
metadata?: ResourceMetadata;
|
||||
readCallback: ReadResourceTemplateCallback;
|
||||
enabled: boolean;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void
|
||||
update(updates: { name?: string | null, title?: string, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void
|
||||
remove(): void
|
||||
};
|
||||
|
||||
|
|
@ -1105,13 +1259,14 @@ export type PromptCallback<
|
|||
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => GetPromptResult | Promise<GetPromptResult>;
|
||||
|
||||
export type RegisteredPrompt = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
argsSchema?: ZodObject<PromptArgsRawShape>;
|
||||
callback: PromptCallback<undefined | PromptArgsRawShape>;
|
||||
enabled: boolean;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
update<Args extends PromptArgsRawShape>(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback<Args>, enabled?: boolean }): void
|
||||
update<Args extends PromptArgsRawShape>(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback<Args>, enabled?: boolean }): void
|
||||
remove(): void
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const MAXIMUM_MESSAGE_SIZE = "4mb";
|
|||
export class SSEServerTransport implements Transport {
|
||||
private _sseResponse?: ServerResponse;
|
||||
private _sessionId: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe
|
|||
|
||||
if (sessionId) {
|
||||
headers["mcp-session-id"] = sessionId;
|
||||
// After initialization, include the protocol version header
|
||||
headers["mcp-protocol-version"] = "2025-03-26";
|
||||
}
|
||||
|
||||
return fetch(baseUrl, {
|
||||
|
|
@ -277,7 +279,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
|
||||
});
|
||||
|
||||
it("should pandle post requests via sse response correctly", async () => {
|
||||
it("should handle post requests via sse response correctly", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
|
||||
|
|
@ -376,6 +378,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -417,6 +420,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -448,6 +452,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -459,6 +464,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -477,6 +483,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "application/json",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -670,6 +677,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -705,7 +713,10 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
// Now DELETE the session
|
||||
const deleteResponse = await fetch(tempUrl, {
|
||||
method: "DELETE",
|
||||
headers: { "mcp-session-id": tempSessionId || "" },
|
||||
headers: {
|
||||
"mcp-session-id": tempSessionId || "",
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
|
@ -721,13 +732,124 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
// Try to delete with invalid session ID
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "DELETE",
|
||||
headers: { "mcp-session-id": "invalid-session-id" },
|
||||
headers: {
|
||||
"mcp-session-id": "invalid-session-id",
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32001, /Session not found/);
|
||||
});
|
||||
|
||||
describe("protocol version header validation", () => {
|
||||
it("should accept requests with matching protocol version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request with matching protocol version
|
||||
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should accept requests without protocol version header", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request without protocol version header
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
// No mcp-protocol-version header
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject requests with unsupported protocol version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "1999-01-01", // Unsupported version
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
|
||||
it("should accept when protocol version differs from negotiated version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Spy on console.warn to verify warning is logged
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
// Send request with different but supported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2024-11-05", // Different but supported version
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
// Request should still succeed
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle protocol version validation for GET requests", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// GET request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
|
||||
it("should handle protocol version validation for DELETE requests", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// DELETE request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamableHTTPServerTransport with AuthInfo", () => {
|
||||
|
|
@ -1120,6 +1242,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1196,6 +1319,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
"last-event-id": firstEventId
|
||||
},
|
||||
});
|
||||
|
|
@ -1282,14 +1406,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
|
|||
// Open first SSE stream
|
||||
const stream1 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
});
|
||||
expect(stream1.status).toBe(200);
|
||||
|
||||
// Open second SSE stream - should still be rejected, stateless mode still only allows one
|
||||
const stream2 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
});
|
||||
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js";
|
||||
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
|
||||
import getRawBody from "raw-body";
|
||||
import contentType from "content-type";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
|
@ -110,7 +110,7 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
private _eventStore?: EventStore;
|
||||
private _onsessioninitialized?: (sessionId: string) => void;
|
||||
|
||||
sessionId?: string | undefined;
|
||||
sessionId?: string;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
|
@ -172,6 +172,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
// Handle resumability: check for Last-Event-ID header
|
||||
if (this._eventStore) {
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
|
|
@ -378,11 +381,17 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
}
|
||||
|
||||
}
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
if (!isInitializationRequest && !this.validateSession(req, res)) {
|
||||
return;
|
||||
if (!isInitializationRequest) {
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
// Mcp-Protocol-Version header is required for all requests after initialization.
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -457,6 +466,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
await this.close();
|
||||
res.writeHead(200).end();
|
||||
}
|
||||
|
|
@ -524,6 +536,25 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
return true;
|
||||
}
|
||||
|
||||
private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
|
||||
if (Array.isArray(protocolVersion)) {
|
||||
protocolVersion = protocolVersion[protocolVersion.length - 1];
|
||||
}
|
||||
|
||||
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
||||
res.writeHead(400).end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})`
|
||||
},
|
||||
id: null
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all SSE connections
|
||||
|
|
|
|||
236
src/server/title.test.ts
Normal file
236
src/server/title.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { Server } from "./index.js";
|
||||
import { Client } from "../client/index.js";
|
||||
import { InMemoryTransport } from "../inMemory.js";
|
||||
import { z } from "zod";
|
||||
import { McpServer, ResourceTemplate } from "./mcp.js";
|
||||
|
||||
describe("Title field backwards compatibility", () => {
|
||||
it("should work with tools that have title", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register tool with title
|
||||
server.registerTool(
|
||||
"test-tool",
|
||||
{
|
||||
title: "Test Tool Display Name",
|
||||
description: "A test tool",
|
||||
inputSchema: {
|
||||
value: z.string()
|
||||
}
|
||||
},
|
||||
async () => ({ content: [{ type: "text", text: "result" }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0].name).toBe("test-tool");
|
||||
expect(tools.tools[0].title).toBe("Test Tool Display Name");
|
||||
expect(tools.tools[0].description).toBe("A test tool");
|
||||
});
|
||||
|
||||
it("should work with tools without title", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register tool without title
|
||||
server.tool(
|
||||
"test-tool",
|
||||
"A test tool",
|
||||
{ value: z.string() },
|
||||
async () => ({ content: [{ type: "text", text: "result" }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0].name).toBe("test-tool");
|
||||
expect(tools.tools[0].title).toBeUndefined();
|
||||
expect(tools.tools[0].description).toBe("A test tool");
|
||||
});
|
||||
|
||||
it("should work with prompts that have title using update", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register prompt with title by updating after creation
|
||||
const prompt = server.prompt(
|
||||
"test-prompt",
|
||||
"A test prompt",
|
||||
async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] })
|
||||
);
|
||||
prompt.update({ title: "Test Prompt Display Name" });
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0].name).toBe("test-prompt");
|
||||
expect(prompts.prompts[0].title).toBe("Test Prompt Display Name");
|
||||
expect(prompts.prompts[0].description).toBe("A test prompt");
|
||||
});
|
||||
|
||||
it("should work with prompts using registerPrompt", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register prompt with title using registerPrompt
|
||||
server.registerPrompt(
|
||||
"test-prompt",
|
||||
{
|
||||
title: "Test Prompt Display Name",
|
||||
description: "A test prompt",
|
||||
argsSchema: { input: z.string() }
|
||||
},
|
||||
async ({ input }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: { type: "text", text: `test: ${input}` }
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0].name).toBe("test-prompt");
|
||||
expect(prompts.prompts[0].title).toBe("Test Prompt Display Name");
|
||||
expect(prompts.prompts[0].description).toBe("A test prompt");
|
||||
expect(prompts.prompts[0].arguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should work with resources using registerResource", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register resource with title using registerResource
|
||||
server.registerResource(
|
||||
"test-resource",
|
||||
"https://example.com/test",
|
||||
{
|
||||
title: "Test Resource Display Name",
|
||||
description: "A test resource",
|
||||
mimeType: "text/plain"
|
||||
},
|
||||
async () => ({
|
||||
contents: [{
|
||||
uri: "https://example.com/test",
|
||||
text: "test content"
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources).toHaveLength(1);
|
||||
expect(resources.resources[0].name).toBe("test-resource");
|
||||
expect(resources.resources[0].title).toBe("Test Resource Display Name");
|
||||
expect(resources.resources[0].description).toBe("A test resource");
|
||||
expect(resources.resources[0].mimeType).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("should work with dynamic resources using registerResource", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: "test-server", version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Register dynamic resource with title using registerResource
|
||||
server.registerResource(
|
||||
"user-profile",
|
||||
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
|
||||
{
|
||||
title: "User Profile",
|
||||
description: "User profile information"
|
||||
},
|
||||
async (uri, { userId }, _extra) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
text: `Profile data for user ${userId}`
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resourceTemplates = await client.listResourceTemplates();
|
||||
expect(resourceTemplates.resourceTemplates).toHaveLength(1);
|
||||
expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile");
|
||||
expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile");
|
||||
expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information");
|
||||
expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile");
|
||||
|
||||
// Test reading the resource
|
||||
const readResult = await client.readResource({ uri: "users://123/profile" });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
expect(readResult.contents[0].text).toBe("Profile data for user 123");
|
||||
});
|
||||
|
||||
it("should support serverInfo with title", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test-server",
|
||||
version: "1.0.0",
|
||||
title: "Test Server Display Name"
|
||||
},
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const serverInfo = client.getServerVersion();
|
||||
expect(serverInfo?.name).toBe("test-server");
|
||||
expect(serverInfo?.version).toBe("1.0.0");
|
||||
expect(serverInfo?.title).toBe("Test Server Display Name");
|
||||
});
|
||||
});
|
||||
36
src/shared/metadataUtils.ts
Normal file
36
src/shared/metadataUtils.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { BaseMetadata } from "../types.js";
|
||||
|
||||
/**
|
||||
* Utilities for working with BaseMetadata objects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the display name for an object with BaseMetadata.
|
||||
* For tools, the precedence is: title → annotations.title → name
|
||||
* For other objects: title → name
|
||||
* This implements the spec requirement: "if no title is provided, name should be used for display purposes"
|
||||
*/
|
||||
export function getDisplayName(metadata: BaseMetadata): string {
|
||||
// First check for title (not undefined and not empty string)
|
||||
if (metadata.title !== undefined && metadata.title !== '') {
|
||||
return metadata.title;
|
||||
}
|
||||
|
||||
// Then check for annotations.title (only present in Tool objects)
|
||||
if ('annotations' in metadata) {
|
||||
const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } };
|
||||
if (metadataWithAnnotations.annotations?.title) {
|
||||
return metadataWithAnnotations.annotations.title;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally fall back to name
|
||||
return metadata.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an object has a custom title different from its name.
|
||||
*/
|
||||
export function hasCustomTitle(metadata: BaseMetadata): boolean {
|
||||
return metadata.title !== undefined && metadata.title !== metadata.name;
|
||||
}
|
||||
|
|
@ -27,9 +27,11 @@ class MockTransport implements Transport {
|
|||
describe("protocol tests", () => {
|
||||
let protocol: Protocol<Request, Notification, Result>;
|
||||
let transport: MockTransport;
|
||||
let sendSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
transport = new MockTransport();
|
||||
sendSpy = jest.spyOn(transport, 'send');
|
||||
protocol = new (class extends Protocol<Request, Notification, Result> {
|
||||
protected assertCapabilityForMethod(): void {}
|
||||
protected assertNotificationCapability(): void {}
|
||||
|
|
@ -63,6 +65,130 @@ describe("protocol tests", () => {
|
|||
expect(oncloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("_meta preservation with onprogress", () => {
|
||||
test("should preserve existing _meta when adding progressToken", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue",
|
||||
anotherField: 123
|
||||
}
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue",
|
||||
anotherField: 123,
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should create _meta with progressToken when no _meta exists", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test"
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should not modify _meta when onprogress is not provided", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue"
|
||||
}
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
|
||||
protocol.request(request, mockSchema);
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue"
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should handle params being undefined with onprogress", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example"
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
_meta: {
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress notification timeout behavior", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
|
@ -339,6 +465,7 @@ describe("mergeCapabilities", () => {
|
|||
experimental: {
|
||||
feature: true,
|
||||
},
|
||||
elicitation: {},
|
||||
roots: {
|
||||
newProp: true,
|
||||
},
|
||||
|
|
@ -347,6 +474,7 @@ describe("mergeCapabilities", () => {
|
|||
const merged = mergeCapabilities(base, additional);
|
||||
expect(merged).toEqual({
|
||||
sampling: {},
|
||||
elicitation: {},
|
||||
roots: {
|
||||
listChanged: true,
|
||||
newProp: true,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue