diff --git a/README.md b/README.md index 6c100735..3ab5a898 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Resources](#resources) - [Tools](#tools) - [Prompts](#prompts) + - [Completions](#completions) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -54,22 +55,30 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ - name: "Demo", + name: "demo-server", version: "1.0.0" }); // Add an addition tool -server.tool("add", - { a: z.number(), b: z.number() }, +server.registerTool("add", + { + title: "Addition Tool", + description: "Add two numbers", + inputSchema: { a: z.number(), b: z.number() } + }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }) ); // Add a dynamic greeting resource -server.resource( +server.registerResource( "greeting", new ResourceTemplate("greeting://{name}", { list: undefined }), + { + title: "Greeting Resource", // Display name for UI + description: "Dynamic greeting generator" + }, async (uri, { name }) => ({ contents: [{ uri: uri.href, @@ -100,7 +109,7 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "My App", + name: "my-app", version: "1.0.0" }); ``` @@ -111,9 +120,14 @@ Resources are how you expose data to LLMs. They're similar to GET endpoints in a ```typescript // Static resource -server.resource( +server.registerResource( "config", "config://app", + { + title: "Application Config", + description: "Application configuration data", + mimeType: "text/plain" + }, async (uri) => ({ contents: [{ uri: uri.href, @@ -123,9 +137,13 @@ server.resource( ); // Dynamic resource with parameters -server.resource( +server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, async (uri, { userId }) => ({ contents: [{ uri: uri.href, @@ -133,6 +151,33 @@ server.resource( }] }) ); + +// Resource with context-aware completion +server.registerResource( + "repository", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + // Provide intelligent completions based on previously resolved parameters + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } + return ["default-repo"].filter(r => r.startsWith(value)); + } + } + }), + { + title: "GitHub Repository", + description: "Repository information" + }, + async (uri, { owner, repo }) => ({ + contents: [{ + uri: uri.href, + text: `Repository: ${owner}/${repo}` + }] + }) +); ``` ### Tools @@ -141,11 +186,15 @@ Tools let LLMs take actions through your server. Unlike resources, tools are exp ```typescript // Simple tool with parameters -server.tool( +server.registerTool( "calculate-bmi", { - weightKg: z.number(), - heightM: z.number() + title: "BMI Calculator", + description: "Calculate Body Mass Index", + inputSchema: { + weightKg: z.number(), + heightM: z.number() + } }, async ({ weightKg, heightM }) => ({ content: [{ @@ -156,9 +205,13 @@ server.tool( ); // Async tool with external API call -server.tool( +server.registerTool( "fetch-weather", - { city: z.string() }, + { + title: "Weather Fetcher", + description: "Get weather data for a city", + inputSchema: { city: z.string() } + }, async ({ city }) => { const response = await fetch(`https://api.weather.com/${city}`); const data = await response.text(); @@ -167,16 +220,56 @@ server.tool( }; } ); + +// Tool that returns ResourceLinks +server.registerTool( + "list-files", + { + title: "List Files", + description: "List project files", + inputSchema: { pattern: z.string() } + }, + async ({ pattern }) => ({ + content: [ + { type: "text", text: `Found files matching "${pattern}":` }, + // ResourceLinks let tools return references without file content + { + type: "resource_link", + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown", + description: 'A README file' + }, + { + type: "resource_link", + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript", + description: 'An index file' + } + ] + }) +); ``` +#### ResourceLinks + +Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. + ### Prompts Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript -server.prompt( +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +server.registerPrompt( "review-code", - { code: z.string() }, + { + title: "Code Review", + description: "Review code for best practices and potential issues", + argsSchema: { code: z.string() } + }, ({ code }) => ({ messages: [{ role: "user", @@ -187,6 +280,106 @@ server.prompt( }] }) ); + +// Prompt with context-aware completion +server.registerPrompt( + "team-greeting", + { + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + department: completable(z.string(), (value) => { + // Department suggestions + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + // Name suggestions based on selected department + const department = context?.arguments?.["department"]; + if (department === "engineering") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (department === "sales") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }) + } + }, + ({ department, name }) => ({ + messages: [{ + role: "assistant", + content: { + type: "text", + text: `Hello ${name}, welcome to the ${department} team!` + } + }] + }) +); +``` + +### Completions + +MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts). + +#### Client Usage + +```typescript +// Request completions for any argument +const result = await client.complete({ + ref: { + type: "ref/prompt", // or "ref/resource" + name: "example" // or uri: "template://..." + }, + argument: { + name: "argumentName", + value: "partial" // What the user has typed so far + }, + context: { // Optional: Include previously resolved arguments + arguments: { + previousArg: "value" + } + } +}); + +``` + +### Display Names and Metadata + +All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. + +**Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. + +#### Title Precedence for Tools + +For tools specifically, there are two ways to specify a title: +- `title` field in the tool configuration +- `annotations.title` field (when using the older `tool()` method with annotations) + +The precedence order is: `title` → `annotations.title` → `name` + +```typescript +// Using registerTool (recommended) +server.registerTool("my_tool", { + title: "My Tool", // This title takes precedence + annotations: { + title: "Annotation Title" // This is ignored if title is set + } +}, handler); + +// Using tool with annotations (older API) +server.tool("my_tool", "description", { + title: "Annotation Title" // This is used as title +}, handler); +``` + +When building clients, use the provided utility to get the appropriate display name: + +```typescript +import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; + +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server @@ -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 diff --git a/package.json b/package.json index 764ce2cb..6b184f31 100644 --- a/package.json +++ b/package.json @@ -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)", diff --git a/src/client/index.ts b/src/client/index.ts index 53ef8c52..f3d440b9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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; diff --git a/src/client/sse.ts b/src/client/sse.ts index 7939e8cb..5aa99abb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -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 { - const headers: HeadersInit = { ...this._requestInit?.headers }; + const headers = { + ...this._requestInit?.headers, + } as HeadersInit & Record; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { - (headers as Record)["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; + } } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bcfbb2d..4117bb1b 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -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 { - const headers: HeadersInit = {}; + const headers: HeadersInit & Record = {}; 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; + } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 8d143a5a..ddb27419 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -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 - 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 '); + } else { + await readResource(args[1]); + } + break; + case 'help': printHelp(); break; @@ -521,7 +534,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); } } } catch (error) { @@ -548,13 +561,37 @@ async function callTool(name: string, args: Record): Promise { if (item.type === 'text') { console.log(` ${item.text}`); + } else if (item.type === 'resource_link') { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); } else { - console.log(` ${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 ' 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 { 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 { 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 { } } +async function readResource(uri: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: ReadResourceRequest = { + method: 'resources/read', + params: { uri } + }; + + console.log(`Reading resource: ${uri}`); + const result = await client.request(request, ReadResourceResultSchema); + + console.log('Resource contents:'); + for (const content of result.contents) { + console.log(` URI: ${content.uri}`); + if (content.mimeType) { + console.log(` Type: ${content.mimeType}`); + } + + if ('text' in content && typeof content.text === 'string') { + console.log(' Content:'); + console.log(' ---'); + console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n')); + console.log(' ---'); + } else if ('blob' in content && typeof content.blob === 'string') { + console.log(` [Binary data: ${content.blob.length} bytes]`); + } + } + } catch (error) { + console.log(`Error reading resource ${uri}: ${error}`); + } +} + async function cleanup(): Promise { if (client && transport) { try { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 4ef50446..52c49caf 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -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 => { 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 => { 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 => { 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 => { + return { + contents: [ + { + uri: 'file:///example/file1.txt', + text: 'This is the content of file 1', + }, + ], + }; + } + ); + + server.registerResource( + 'example-file-2', + 'file:///example/file2.txt', + { + title: 'Example File 2', + description: 'Second example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file2.txt', + text: 'This is the content of file 2', + }, + ], + }; + } + ); + + // Register a tool that returns ResourceLinks + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: { + includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'), + }, + }, + async ({ includeDescriptions = true }): Promise => { + const resourceLinks: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'https://example.com/greetings/default', + name: 'Default Greeting', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'A simple greeting resource' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file1.txt', + name: 'Example File 1', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file2.txt', + name: 'Example File 2', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) + } + ]; + + return { + content: [ + { + type: 'text', + text: 'Here are the available files as resource links:', + }, + ...resourceLinks, + { + type: 'text', + text: '\nYou can read any of these resources using their URI.', + } + ], + }; + } + ); + return server; }; diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index b7ff17e6..d12a4f99 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, 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', () => { diff --git a/src/server/completable.ts b/src/server/completable.ts index 3b5bc164..652eaf72 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -15,6 +15,9 @@ export enum McpZodTypeKind { export type CompleteCallback = ( value: T["_input"], + context?: { + arguments?: Record; + }, ) => T["_input"][] | Promise; export interface CompletableDef diff --git a/src/server/index.ts b/src/server/index.ts index 5d482d32..10ae2fad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 }), diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7cb0c489..7fb6bd55 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -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()", () => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 38c869c7..3d9673da 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -21,7 +21,8 @@ import { CompleteRequest, CompleteResult, PromptReference, - ResourceReference, + ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -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 = field._def; - const suggestions = await def.complete(request.params.argument.value); + const suggestions = await def.complete(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } private async handleResourceCompletion( request: CompleteRequest, - ref: ResourceReference, + ref: ResourceTemplateReference, ): Promise { const template = Object.values(this._registeredResourceTemplates).find( (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, @@ -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 + ): 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 ): 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; - 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( 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 - ) + ); } /** @@ -856,27 +981,13 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, + undefined, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -884,6 +995,38 @@ export class McpServer { return registeredPrompt } + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, argsSchema } = config; + + const registeredPrompt = this._createRegisteredPrompt( + name, + title, + description, + argsSchema, + cb as PromptCallback + ); + + this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -925,6 +1068,9 @@ export class McpServer { */ export type CompleteResourceTemplateCallback = ( value: string, + context?: { + arguments?: Record; + }, ) => string[] | Promise; /** @@ -999,6 +1145,7 @@ export type ToolCallback = : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { + title?: string; description?: string; inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; @@ -1008,15 +1155,16 @@ export type RegisteredTool = { enable(): void; disable(): void; update( - updates: { - name?: string | null, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void + updates: { + name?: string | null, + title?: string, + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + 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) => GetPromptResult | Promise; export type RegisteredPrompt = { + title?: string; description?: string; argsSchema?: ZodObject; callback: PromptCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..e9a4d53a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -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; diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..d66083fe 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -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 }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..34b2ab68 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -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 { // Close all SSE connections diff --git a/src/server/title.test.ts b/src/server/title.test.ts new file mode 100644 index 00000000..3f64570b --- /dev/null +++ b/src/server/title.test.ts @@ -0,0 +1,236 @@ +import { Server } from "./index.js"; +import { Client } from "../client/index.js"; +import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; +import { McpServer, ResourceTemplate } from "./mcp.js"; + +describe("Title field backwards compatibility", () => { + it("should work with tools that have title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool with title + server.registerTool( + "test-tool", + { + title: "Test Tool Display Name", + description: "A test tool", + inputSchema: { + value: z.string() + } + }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBe("Test Tool Display Name"); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with tools without title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool without title + server.tool( + "test-tool", + "A test tool", + { value: z.string() }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with prompts that have title using update", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title by updating after creation + const prompt = server.prompt( + "test-prompt", + "A test prompt", + async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) + ); + prompt.update({ title: "Test Prompt Display Name" }); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + }); + + it("should work with prompts using registerPrompt", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title using registerPrompt + server.registerPrompt( + "test-prompt", + { + title: "Test Prompt Display Name", + description: "A test prompt", + argsSchema: { input: z.string() } + }, + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it("should work with resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register resource with title using registerResource + server.registerResource( + "test-resource", + "https://example.com/test", + { + title: "Test Resource Display Name", + description: "A test resource", + mimeType: "text/plain" + }, + async () => ({ + contents: [{ + uri: "https://example.com/test", + text: "test content" + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe("test-resource"); + expect(resources.resources[0].title).toBe("Test Resource Display Name"); + expect(resources.resources[0].description).toBe("A test resource"); + expect(resources.resources[0].mimeType).toBe("text/plain"); + }); + + it("should work with dynamic resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register dynamic resource with title using registerResource + server.registerResource( + "user-profile", + new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, + async (uri, { userId }, _extra) => ({ + contents: [{ + uri: uri.href, + text: `Profile data for user ${userId}` + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile"); + expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile"); + expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information"); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile"); + + // Test reading the resource + const readResult = await client.readResource({ uri: "users://123/profile" }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents[0].text).toBe("Profile data for user 123"); + }); + + it("should support serverInfo with title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: "test-server", + version: "1.0.0", + title: "Test Server Display Name" + }, + { capabilities: {} } + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe("test-server"); + expect(serverInfo?.version).toBe("1.0.0"); + expect(serverInfo?.title).toBe("Test Server Display Name"); + }); +}); \ No newline at end of file diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts new file mode 100644 index 00000000..410827a5 --- /dev/null +++ b/src/shared/metadataUtils.ts @@ -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; +} \ No newline at end of file diff --git a/src/shared/transport.ts b/src/shared/transport.ts index fe0a60e6..b75e072e 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -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; } diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..bc109110 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -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" + }); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index e5a544b5..b77a27bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; /* Cancellation */ export type CancelledNotification = Infer; +/* Base Metadata */ +export type BaseMetadata = Infer; + /* Initialization */ export type Implementation = Infer; export type ClientCapabilities = Infer; @@ -1459,6 +1519,8 @@ export type TextContent = Infer; export type ImageContent = Infer; export type AudioContent = Infer; export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; export type PromptMessage = Infer; export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; @@ -1493,7 +1555,11 @@ export type ElicitRequest = Infer; export type ElicitResult = Infer; /* Autocomplete */ -export type ResourceReference = Infer; +export type ResourceTemplateReference = Infer; +/** + * @deprecated Use ResourceTemplateReference instead + */ +export type ResourceReference = ResourceTemplateReference; export type PromptReference = Infer; export type CompleteRequest = Infer; export type CompleteResult = Infer;