Merge branch 'main' into ihrpr/elicitation

This commit is contained in:
ihrpr 2025-06-16 19:11:57 +01:00
commit 54c30109fb
No known key found for this signature in database
20 changed files with 2212 additions and 271 deletions

263
README.md
View file

@ -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
@ -401,13 +594,17 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
import { z } from "zod";
const server = new McpServer({
name: "Echo",
name: "echo-server",
version: "1.0.0"
});
server.resource(
server.registerResource(
"echo",
new ResourceTemplate("echo://{message}", { list: undefined }),
{
title: "Echo Resource",
description: "Echoes back messages as resources"
},
async (uri, { message }) => ({
contents: [{
uri: uri.href,
@ -416,17 +613,25 @@ server.resource(
})
);
server.tool(
server.registerTool(
"echo",
{ message: z.string() },
{
title: "Echo Tool",
description: "Echoes back the provided message",
inputSchema: { message: z.string() }
},
async ({ message }) => ({
content: [{ type: "text", text: `Tool echo: ${message}` }]
})
);
server.prompt(
server.registerPrompt(
"echo",
{ message: z.string() },
{
title: "Echo Prompt",
description: "Creates a prompt to process a message",
argsSchema: { message: z.string() }
},
({ message }) => ({
messages: [{
role: "user",
@ -450,7 +655,7 @@ import { promisify } from "util";
import { z } from "zod";
const server = new McpServer({
name: "SQLite Explorer",
name: "sqlite-explorer",
version: "1.0.0"
});
@ -463,9 +668,14 @@ const getDb = () => {
};
};
server.resource(
server.registerResource(
"schema",
"schema://main",
{
title: "Database Schema",
description: "SQLite database schema",
mimeType: "text/plain"
},
async (uri) => {
const db = getDb();
try {
@ -484,9 +694,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 {
@ -785,6 +999,7 @@ const result = await client.callTool({
arg1: "value"
}
});
```
### Proxy Authorization Requests Upstream

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.12.2",
"version": "1.12.3",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -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;

View file

@ -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 = { ...this._requestInit?.headers };
const headers = {
...this._requestInit?.headers,
} as HeadersInit & Record<string, string>;
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
(headers as Record<string, string>)["Authorization"] = `Bearer ${tokens.access_token}`;
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;
}
}

View file

@ -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;
}
}

View file

@ -15,7 +15,11 @@ import {
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
@ -62,6 +66,7 @@ function printHelp(): void {
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');
}
@ -161,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;
@ -521,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) {
@ -548,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}`);
}
@ -589,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: {
@ -602,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
@ -638,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) {
@ -689,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) {
@ -697,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 {

View file

@ -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, PrimitiveSchemaDefinition, 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,7 +86,6 @@ const getServer = () => {
};
}
);
// Register a tool that demonstrates elicitation (user input collection)
// This creates a closure that captures the server instance
server.tool(
@ -234,13 +236,15 @@ const getServer = () => {
}
);
// Register a simple prompt
server.prompt(
// Register a simple prompt with title
server.registerPrompt(
'greeting-template',
'A simple greeting prompt template',
{
name: z.string().describe('Name to include in greeting'),
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 {
@ -299,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: [
@ -314,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;
};

View file

@ -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', () => {

View file

@ -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>

View file

@ -265,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 }),

View file

@ -19,6 +19,7 @@ import {
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
import { UriTemplate } from "../shared/uriTemplate.js";
import { getDisplayName } from "../shared/metadataUtils.js";
describe("McpServer", () => {
/***
@ -3458,6 +3459,555 @@ describe("prompt()", () => {
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
expect(result.messages[0].content.text).toContain("Received request ID:");
});
/***
* Test: Resource Template Metadata Priority
*/
test("should prioritize individual resource metadata over template metadata", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", {
list: async () => ({
resources: [
{
name: "Resource 1",
uri: "test://resource/1",
description: "Individual resource description",
mimeType: "text/plain",
},
{
name: "Resource 2",
uri: "test://resource/2",
// This resource has no description or mimeType
},
],
}),
}),
{
description: "Template description",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/list",
},
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(2);
// Resource 1 should have its own metadata
expect(result.resources[0].name).toBe("Resource 1");
expect(result.resources[0].description).toBe("Individual resource description");
expect(result.resources[0].mimeType).toBe("text/plain");
// Resource 2 should inherit template metadata
expect(result.resources[1].name).toBe("Resource 2");
expect(result.resources[1].description).toBe("Template description");
expect(result.resources[1].mimeType).toBe("application/json");
});
/***
* Test: Resource Template Metadata Overrides All Fields
*/
test("should allow resource to override all template metadata fields", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", {
list: async () => ({
resources: [
{
name: "Overridden Name",
uri: "test://resource/1",
description: "Overridden description",
mimeType: "text/markdown",
// Add any other metadata fields if they exist
},
],
}),
}),
{
name: "Template Name",
description: "Template description",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/list",
},
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(1);
// All fields should be from the individual resource, not the template
expect(result.resources[0].name).toBe("Overridden Name");
expect(result.resources[0].description).toBe("Overridden description");
expect(result.resources[0].mimeType).toBe("text/markdown");
});
});
describe("Tool title precedence", () => {
test("should follow correct title precedence: title → annotations.title → name", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
// Tool 1: Only name
mcpServer.tool(
"tool_name_only",
async () => ({
content: [{ type: "text", text: "Response" }],
})
);
// Tool 2: Name and annotations.title
mcpServer.tool(
"tool_with_annotations_title",
"Tool with annotations title",
{
title: "Annotations Title"
},
async () => ({
content: [{ type: "text", text: "Response" }],
})
);
// Tool 3: Name and title (using registerTool)
mcpServer.registerTool(
"tool_with_title",
{
title: "Regular Title",
description: "Tool with regular title"
},
async () => ({
content: [{ type: "text", text: "Response" }],
})
);
// Tool 4: All three - title should win
mcpServer.registerTool(
"tool_with_all_titles",
{
title: "Regular Title Wins",
description: "Tool with all titles",
annotations: {
title: "Annotations Title Should Not Show"
}
},
async () => ({
content: [{ type: "text", text: "Response" }],
})
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
const result = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(4);
// Tool 1: Only name - should display name
const tool1 = result.tools.find(t => t.name === "tool_name_only");
expect(tool1).toBeDefined();
expect(getDisplayName(tool1!)).toBe("tool_name_only");
// Tool 2: Name and annotations.title - should display annotations.title
const tool2 = result.tools.find(t => t.name === "tool_with_annotations_title");
expect(tool2).toBeDefined();
expect(tool2!.annotations?.title).toBe("Annotations Title");
expect(getDisplayName(tool2!)).toBe("Annotations Title");
// Tool 3: Name and title - should display title
const tool3 = result.tools.find(t => t.name === "tool_with_title");
expect(tool3).toBeDefined();
expect(tool3!.title).toBe("Regular Title");
expect(getDisplayName(tool3!)).toBe("Regular Title");
// Tool 4: All three - title should take precedence
const tool4 = result.tools.find(t => t.name === "tool_with_all_titles");
expect(tool4).toBeDefined();
expect(tool4!.title).toBe("Regular Title Wins");
expect(tool4!.annotations?.title).toBe("Annotations Title Should Not Show");
expect(getDisplayName(tool4!)).toBe("Regular Title Wins");
});
test("getDisplayName unit tests for title precedence", () => {
// Test 1: Only name
expect(getDisplayName({ name: "tool_name" })).toBe("tool_name");
// Test 2: Name and title - title wins
expect(getDisplayName({
name: "tool_name",
title: "Tool Title"
})).toBe("Tool Title");
// Test 3: Name and annotations.title - annotations.title wins
expect(getDisplayName({
name: "tool_name",
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
// Test 4: All three - title wins (correct precedence)
expect(getDisplayName({
name: "tool_name",
title: "Regular Title",
annotations: { title: "Annotations Title" }
})).toBe("Regular Title");
// Test 5: Empty title should not be used
expect(getDisplayName({
name: "tool_name",
title: "",
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
// Test 6: Undefined vs null handling
expect(getDisplayName({
name: "tool_name",
title: undefined,
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
});
test("should support resource template completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerResource(
"test",
new ResourceTemplate("github://repos/{owner}/{repo}", {
list: undefined,
complete: {
repo: (value, context) => {
if (context?.arguments?.["owner"] === "org1") {
return ["project1", "project2", "project3"].filter(r => r.startsWith(value));
} else if (context?.arguments?.["owner"] === "org2") {
return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value));
}
return [];
},
},
}),
{
title: "GitHub Repository",
description: "Repository information"
},
async () => ({
contents: [
{
uri: "github://repos/test/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with microsoft owner
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "p",
},
context: {
arguments: {
owner: "org1",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["project1", "project2", "project3"]);
expect(result1.completion.total).toBe(3);
// Test with facebook owner
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "r",
},
context: {
arguments: {
owner: "org2",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]);
expect(result2.completion.total).toBe(3);
// Test with no resolved context
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "t",
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual([]);
expect(result3.completion.total).toBe(0);
});
test("should support prompt argument completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerPrompt(
"test-prompt",
{
title: "Team Greeting",
description: "Generate a greeting for team members",
argsSchema: {
department: completable(z.string(), (value) => {
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
}),
name: completable(z.string(), (value, context) => {
const department = context?.arguments?.["department"];
if (department === "engineering") {
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
} else if (department === "sales") {
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
} else if (department === "marketing") {
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
}
return ["Guest"].filter(n => n.startsWith(value));
}),
}
},
async ({ department, name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}, welcome to the ${department} team!`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with engineering department
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "A",
},
context: {
arguments: {
department: "engineering",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["Alice"]);
// Test with sales department
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "D",
},
context: {
arguments: {
department: "sales",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["David"]);
// Test with marketing department
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
context: {
arguments: {
department: "marketing",
},
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual(["Grace"]);
// Test with no resolved context
const result4 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
},
},
CompleteResultSchema,
);
expect(result4.completion.values).toEqual(["Guest"]);
});
});
describe("elicitInput()", () => {

View file

@ -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"];
}
@ -291,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,
@ -322,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);
}
@ -373,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,
});
}
}
@ -468,6 +471,7 @@ export class McpServer {
([name, prompt]): Prompt => {
return {
name,
title: prompt.title,
description: prompt.description,
arguments: prompt.argsSchema
? promptArgumentsFromSchema(prompt.argsSchema)
@ -575,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();
@ -605,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();
@ -633,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,
@ -642,6 +763,7 @@ export class McpServer {
callback: ToolCallback<ZodRawShape | undefined>
): RegisteredTool {
const registeredTool: RegisteredTool = {
title,
description,
inputSchema:
inputSchema === undefined ? undefined : z.object(inputSchema),
@ -658,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
@ -779,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)
}
/**
@ -788,6 +911,7 @@ export class McpServer {
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
@ -799,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>
)
);
}
/**
@ -856,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()
@ -884,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
@ -925,6 +1068,9 @@ export class McpServer {
*/
export type CompleteResourceTemplateCallback = (
value: string,
context?: {
arguments?: Record<string, string>;
},
) => string[] | Promise<string[]>;
/**
@ -999,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;
@ -1008,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
};
@ -1064,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
};
@ -1084,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
};
@ -1109,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
};

View file

@ -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;

View file

@ -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
});

View file

@ -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
View 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");
});
});

View 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;
}

View file

@ -75,4 +75,9 @@ export interface Transport {
* The session ID generated for this connection.
*/
sessionId?: string;
/**
* Sets the protocol version used for the connection (called when the initialize response is received).
*/
setProtocolVersion?: (version: string) => void;
}

View file

@ -1,4 +1,12 @@
import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js";
import {
LATEST_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
ResourceLinkSchema,
ContentBlockSchema,
PromptMessageSchema,
CallToolResultSchema,
CompleteRequestSchema
} from "./types.js";
describe("Types", () => {
@ -14,4 +22,293 @@ describe("Types", () => {
expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07");
});
describe("ResourceLink", () => {
test("should validate a minimal ResourceLink", () => {
const resourceLink = {
type: "resource_link",
uri: "file:///path/to/file.txt",
name: "file.txt"
};
const result = ResourceLinkSchema.safeParse(resourceLink);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("resource_link");
expect(result.data.uri).toBe("file:///path/to/file.txt");
expect(result.data.name).toBe("file.txt");
}
});
test("should validate a ResourceLink with all optional fields", () => {
const resourceLink = {
type: "resource_link",
uri: "https://example.com/resource",
name: "Example Resource",
title: "A comprehensive example resource",
description: "This resource demonstrates all fields",
mimeType: "text/plain",
_meta: { custom: "metadata" }
};
const result = ResourceLinkSchema.safeParse(resourceLink);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.title).toBe("A comprehensive example resource");
expect(result.data.description).toBe("This resource demonstrates all fields");
expect(result.data.mimeType).toBe("text/plain");
expect(result.data._meta).toEqual({ custom: "metadata" });
}
});
test("should fail validation for invalid type", () => {
const invalidResourceLink = {
type: "invalid_type",
uri: "file:///path/to/file.txt",
name: "file.txt"
};
const result = ResourceLinkSchema.safeParse(invalidResourceLink);
expect(result.success).toBe(false);
});
test("should fail validation for missing required fields", () => {
const invalidResourceLink = {
type: "resource_link",
uri: "file:///path/to/file.txt"
// missing name
};
const result = ResourceLinkSchema.safeParse(invalidResourceLink);
expect(result.success).toBe(false);
});
});
describe("ContentBlock", () => {
test("should validate text content", () => {
const textContent = {
type: "text",
text: "Hello, world!"
};
const result = ContentBlockSchema.safeParse(textContent);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("text");
}
});
test("should validate image content", () => {
const imageContent = {
type: "image",
data: "aGVsbG8=", // base64 encoded "hello"
mimeType: "image/png"
};
const result = ContentBlockSchema.safeParse(imageContent);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("image");
}
});
test("should validate audio content", () => {
const audioContent = {
type: "audio",
data: "aGVsbG8=", // base64 encoded "hello"
mimeType: "audio/mp3"
};
const result = ContentBlockSchema.safeParse(audioContent);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("audio");
}
});
test("should validate resource link content", () => {
const resourceLink = {
type: "resource_link",
uri: "file:///path/to/file.txt",
name: "file.txt",
mimeType: "text/plain"
};
const result = ContentBlockSchema.safeParse(resourceLink);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("resource_link");
}
});
test("should validate embedded resource content", () => {
const embeddedResource = {
type: "resource",
resource: {
uri: "file:///path/to/file.txt",
mimeType: "text/plain",
text: "File contents"
}
};
const result = ContentBlockSchema.safeParse(embeddedResource);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe("resource");
}
});
});
describe("PromptMessage with ContentBlock", () => {
test("should validate prompt message with resource link", () => {
const promptMessage = {
role: "assistant",
content: {
type: "resource_link",
uri: "file:///project/src/main.rs",
name: "main.rs",
description: "Primary application entry point",
mimeType: "text/x-rust"
}
};
const result = PromptMessageSchema.safeParse(promptMessage);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.content.type).toBe("resource_link");
}
});
});
describe("CallToolResult with ContentBlock", () => {
test("should validate tool result with resource links", () => {
const toolResult = {
content: [
{
type: "text",
text: "Found the following files:"
},
{
type: "resource_link",
uri: "file:///project/src/main.rs",
name: "main.rs",
description: "Primary application entry point",
mimeType: "text/x-rust"
},
{
type: "resource_link",
uri: "file:///project/src/lib.rs",
name: "lib.rs",
description: "Library exports",
mimeType: "text/x-rust"
}
]
};
const result = CallToolResultSchema.safeParse(toolResult);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.content).toHaveLength(3);
expect(result.data.content[0].type).toBe("text");
expect(result.data.content[1].type).toBe("resource_link");
expect(result.data.content[2].type).toBe("resource_link");
}
});
test("should validate empty content array with default", () => {
const toolResult = {};
const result = CallToolResultSchema.safeParse(toolResult);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.content).toEqual([]);
}
});
});
describe("CompleteRequest", () => {
test("should validate a CompleteRequest without resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/prompt", name: "greeting" },
argument: { name: "name", value: "A" }
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.method).toBe("completion/complete");
expect(result.data.params.ref.type).toBe("ref/prompt");
expect(result.data.params.context).toBeUndefined();
}
});
test("should validate a CompleteRequest with resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" },
argument: { name: "repo", value: "t" },
context: {
arguments: {
"{owner}": "microsoft"
}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({
"{owner}": "microsoft"
});
}
});
test("should validate a CompleteRequest with empty resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/prompt", name: "test" },
argument: { name: "arg", value: "" },
context: {
arguments: {}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({});
}
});
test("should validate a CompleteRequest with multiple resolved variables", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" },
argument: { name: "id", value: "123" },
context: {
arguments: {
"{tenant}": "acme-corp",
"{resource}": "users"
}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({
"{tenant}": "acme-corp",
"{resource}": "users"
});
}
});
});
});

View file

@ -1,6 +1,7 @@
import { z, ZodTypeAny } from "zod";
export const LATEST_PROTOCOL_VERSION = "2025-03-26";
export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26";
export const SUPPORTED_PROTOCOL_VERSIONS = [
LATEST_PROTOCOL_VERSION,
"2024-11-05",
@ -43,8 +44,8 @@ export const RequestSchema = z.object({
const BaseNotificationParamsSchema = z
.object({
/**
* This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.
*/
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -57,8 +58,8 @@ export const NotificationSchema = z.object({
export const ResultSchema = z
.object({
/**
* This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.
*/
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -194,16 +195,33 @@ export const CancelledNotificationSchema = NotificationSchema.extend({
}),
});
/* Base Metadata */
/**
* Base metadata interface for common properties across resources, tools, prompts, and implementations.
*/
export const BaseMetadataSchema = z
.object({
/** Intended for programmatic or logical use, but used as a display name in past specs or fallback */
name: z.string(),
/**
* Intended for UI and end-user contexts — optimized to be human-readable and easily understood,
* even by those unfamiliar with domain-specific terminology.
*
* If not provided, the name should be used for display (except for Tool,
* where `annotations.title` should be given precedence over using `name`,
* if present).
*/
title: z.optional(z.string()),
})
.passthrough();
/* Initialization */
/**
* Describes the name and version of an MCP implementation.
*/
export const ImplementationSchema = z
.object({
name: z.string(),
version: z.string(),
})
.passthrough();
export const ImplementationSchema = BaseMetadataSchema.extend({
version: z.string(),
});
/**
* Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.
@ -421,6 +439,10 @@ export const ResourceContentsSchema = z
* The MIME type of this resource, if known.
*/
mimeType: z.optional(z.string()),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -441,64 +463,56 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({
/**
* A known resource that the server is capable of reading.
*/
export const ResourceSchema = z
.object({
/**
* The URI of this resource.
*/
uri: z.string(),
export const ResourceSchema = BaseMetadataSchema.extend({
/**
* The URI of this resource.
*/
uri: z.string(),
/**
* A human-readable name for this resource.
*
* This can be used by clients to populate UI elements.
*/
name: z.string(),
/**
* A description of what this resource represents.
*
* This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
*/
description: z.optional(z.string()),
/**
* A description of what this resource represents.
*
* This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
*/
description: z.optional(z.string()),
/**
* The MIME type of this resource, if known.
*/
mimeType: z.optional(z.string()),
/**
* The MIME type of this resource, if known.
*/
mimeType: z.optional(z.string()),
})
.passthrough();
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
});
/**
* A template description for resources available on the server.
*/
export const ResourceTemplateSchema = z
.object({
/**
* A URI template (according to RFC 6570) that can be used to construct resource URIs.
*/
uriTemplate: z.string(),
export const ResourceTemplateSchema = BaseMetadataSchema.extend({
/**
* A URI template (according to RFC 6570) that can be used to construct resource URIs.
*/
uriTemplate: z.string(),
/**
* A human-readable name for the type of resource this template refers to.
*
* This can be used by clients to populate UI elements.
*/
name: z.string(),
/**
* A description of what this template is for.
*
* This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
*/
description: z.optional(z.string()),
/**
* A description of what this template is for.
*
* This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
*/
description: z.optional(z.string()),
/**
* The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.
*/
mimeType: z.optional(z.string()),
/**
* The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.
*/
mimeType: z.optional(z.string()),
})
.passthrough();
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
});
/**
* Sent from the client to request a list of resources the server has.
@ -622,22 +636,20 @@ export const PromptArgumentSchema = z
/**
* A prompt or prompt template that the server offers.
*/
export const PromptSchema = z
.object({
/**
* The name of the prompt or prompt template.
*/
name: z.string(),
/**
* An optional description of what this prompt provides
*/
description: z.optional(z.string()),
/**
* A list of arguments to use for templating the prompt.
*/
arguments: z.optional(z.array(PromptArgumentSchema)),
})
.passthrough();
export const PromptSchema = BaseMetadataSchema.extend({
/**
* An optional description of what this prompt provides
*/
description: z.optional(z.string()),
/**
* A list of arguments to use for templating the prompt.
*/
arguments: z.optional(z.array(PromptArgumentSchema)),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
});
/**
* Sent from the client to request a list of prompts and prompt templates the server has.
@ -680,6 +692,11 @@ export const TextContentSchema = z
* The text content of the message.
*/
text: z.string(),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -697,6 +714,11 @@ export const ImageContentSchema = z
* The MIME type of the image. Different providers may support different image types.
*/
mimeType: z.string(),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -714,6 +736,11 @@ export const AudioContentSchema = z
* The MIME type of the audio. Different providers may support different audio types.
*/
mimeType: z.string(),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -724,21 +751,40 @@ export const EmbeddedResourceSchema = z
.object({
type: z.literal("resource"),
resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
/**
* A resource that the server is capable of reading, included in a prompt or tool call result.
*
* Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.
*/
export const ResourceLinkSchema = ResourceSchema.extend({
type: z.literal("resource_link"),
});
/**
* A content block that can be used in prompts and tool results.
*/
export const ContentBlockSchema = z.union([
TextContentSchema,
ImageContentSchema,
AudioContentSchema,
ResourceLinkSchema,
EmbeddedResourceSchema,
]);
/**
* Describes a message returned as part of a prompt.
*/
export const PromptMessageSchema = z
.object({
role: z.enum(["user", "assistant"]),
content: z.union([
TextContentSchema,
ImageContentSchema,
AudioContentSchema,
EmbeddedResourceSchema,
]),
content: ContentBlockSchema,
})
.passthrough();
@ -820,44 +866,43 @@ export const ToolAnnotationsSchema = z
/**
* Definition for a tool the client can call.
*/
export const ToolSchema = z
.object({
/**
* The name of the tool.
*/
name: z.string(),
/**
* A human-readable description of the tool.
*/
description: z.optional(z.string()),
/**
* A JSON Schema object defining the expected parameters for the tool.
*/
inputSchema: z
.object({
type: z.literal("object"),
properties: z.optional(z.object({}).passthrough()),
required: z.optional(z.array(z.string())),
})
.passthrough(),
/**
* An optional JSON Schema object defining the structure of the tool's output returned in
* the structuredContent field of a CallToolResult.
*/
outputSchema: z.optional(
z.object({
type: z.literal("object"),
properties: z.optional(z.object({}).passthrough()),
required: z.optional(z.array(z.string())),
})
export const ToolSchema = BaseMetadataSchema.extend({
/**
* A human-readable description of the tool.
*/
description: z.optional(z.string()),
/**
* A JSON Schema object defining the expected parameters for the tool.
*/
inputSchema: z
.object({
type: z.literal("object"),
properties: z.optional(z.object({}).passthrough()),
required: z.optional(z.array(z.string())),
})
.passthrough(),
/**
* An optional JSON Schema object defining the structure of the tool's output returned in
* the structuredContent field of a CallToolResult.
*/
outputSchema: z.optional(
z.object({
type: z.literal("object"),
properties: z.optional(z.object({}).passthrough()),
required: z.optional(z.array(z.string())),
})
.passthrough()
),
/**
* Optional additional tool information.
*/
annotations: z.optional(ToolAnnotationsSchema),
})
.passthrough();
),
/**
* Optional additional tool information.
*/
annotations: z.optional(ToolAnnotationsSchema),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
});
/**
* Sent from the client to request a list of tools the server has.
@ -883,13 +928,7 @@ export const CallToolResultSchema = ResultSchema.extend({
* If the Tool does not define an outputSchema, this field MUST be present in the result.
* For backwards compatibility, this field is always present, but it may be empty.
*/
content: z.array(
z.union([
TextContentSchema,
ImageContentSchema,
AudioContentSchema,
EmbeddedResourceSchema,
])).default([]),
content: z.array(ContentBlockSchema).default([]),
/**
* An object containing structured tool output.
@ -1197,7 +1236,7 @@ export const ElicitResultSchema = ResultSchema.extend({
/**
* A reference to a resource or resource template definition.
*/
export const ResourceReferenceSchema = z
export const ResourceTemplateReferenceSchema = z
.object({
type: z.literal("ref/resource"),
/**
@ -1207,6 +1246,11 @@ export const ResourceReferenceSchema = z
})
.passthrough();
/**
* @deprecated Use ResourceTemplateReferenceSchema instead
*/
export const ResourceReferenceSchema = ResourceTemplateReferenceSchema;
/**
* Identifies a prompt.
*/
@ -1226,7 +1270,7 @@ export const PromptReferenceSchema = z
export const CompleteRequestSchema = RequestSchema.extend({
method: z.literal("completion/complete"),
params: BaseRequestParamsSchema.extend({
ref: z.union([PromptReferenceSchema, ResourceReferenceSchema]),
ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]),
/**
* The argument's information
*/
@ -1242,6 +1286,14 @@ export const CompleteRequestSchema = RequestSchema.extend({
value: z.string(),
})
.passthrough(),
context: z.optional(
z.object({
/**
* Previously-resolved variables in a URI template or prompt.
*/
arguments: z.optional(z.record(z.string(), z.string())),
})
),
}),
});
@ -1281,6 +1333,11 @@ export const RootSchema = z
* An optional name for the root.
*/
name: z.optional(z.string()),
/**
* Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents.
*/
_meta: z.optional(z.object({}).passthrough()),
})
.passthrough();
@ -1413,6 +1470,9 @@ export type EmptyResult = Infer<typeof EmptyResultSchema>;
/* Cancellation */
export type CancelledNotification = Infer<typeof CancelledNotificationSchema>;
/* Base Metadata */
export type BaseMetadata = Infer<typeof BaseMetadataSchema>;
/* Initialization */
export type Implementation = Infer<typeof ImplementationSchema>;
export type ClientCapabilities = Infer<typeof ClientCapabilitiesSchema>;
@ -1459,6 +1519,8 @@ export type TextContent = Infer<typeof TextContentSchema>;
export type ImageContent = Infer<typeof ImageContentSchema>;
export type AudioContent = Infer<typeof AudioContentSchema>;
export type EmbeddedResource = Infer<typeof EmbeddedResourceSchema>;
export type ResourceLink = Infer<typeof ResourceLinkSchema>;
export type ContentBlock = Infer<typeof ContentBlockSchema>;
export type PromptMessage = Infer<typeof PromptMessageSchema>;
export type GetPromptResult = Infer<typeof GetPromptResultSchema>;
export type PromptListChangedNotification = Infer<typeof PromptListChangedNotificationSchema>;
@ -1493,7 +1555,11 @@ export type ElicitRequest = Infer<typeof ElicitRequestSchema>;
export type ElicitResult = Infer<typeof ElicitResultSchema>;
/* Autocomplete */
export type ResourceReference = Infer<typeof ResourceReferenceSchema>;
export type ResourceTemplateReference = Infer<typeof ResourceTemplateReferenceSchema>;
/**
* @deprecated Use ResourceTemplateReference instead
*/
export type ResourceReference = ResourceTemplateReference;
export type PromptReference = Infer<typeof PromptReferenceSchema>;
export type CompleteRequest = Infer<typeof CompleteRequestSchema>;
export type CompleteResult = Infer<typeof CompleteResultSchema>;