From e7284d29c8271f62208caf4a90d70d2a2cd46bee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 18:47:44 +0100 Subject: [PATCH 01/12] added _meta to more objects --- src/types.ts | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 740b6c43..822ba8d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,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(); @@ -58,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(); @@ -463,6 +463,11 @@ export const ResourceSchema = 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(); @@ -494,6 +499,11 @@ export const ResourceTemplateSchema = z * 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()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -677,6 +687,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(); @@ -694,6 +709,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(); @@ -711,6 +731,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(); @@ -847,12 +872,17 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), required: z.optional(z.array(z.string())), }) - .passthrough() + .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()), }) .passthrough(); @@ -1182,6 +1212,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(); From 4d03b243e37a6b1965c8cbe601cd37c94b9b0080 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 20:42:25 +0100 Subject: [PATCH 02/12] add title --- README.md | 138 +++++++++--- src/examples/client/simpleStreamableHttp.ts | 7 +- src/examples/server/simpleStreamableHttp.ts | 29 ++- src/server/mcp.ts | 158 +++++++++++++- src/server/title.test.ts | 194 +++++++++++++++++ src/shared/metadataUtils.ts | 21 ++ src/types.ts | 227 +++++++++----------- 7 files changed, 601 insertions(+), 173 deletions(-) create mode 100644 src/server/title.test.ts create mode 100644 src/shared/metadataUtils.ts diff --git a/README.md b/README.md index c9e27c27..d13378b5 100644 --- a/README.md +++ b/README.md @@ -54,26 +54,35 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ - name: "Demo", - version: "1.0.0" + name: "demo-server", + version: "1.0.0", + title: "Demo Server" // Optional display name }); // 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 }), - async (uri, { name }) => ({ + { + title: "Greeting Resource", // Display name for UI + description: "Dynamic greeting generator" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Hello, ${name}!` + text: `Hello, ${params.name}!` }] }) ); @@ -100,8 +109,9 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "My App", - version: "1.0.0" + name: "my-app", // Unique identifier for your server + version: "1.0.0", // Server version + title: "My Application" // Optional display name for UI }); ``` @@ -111,9 +121,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,13 +138,17 @@ server.resource( ); // Dynamic resource with parameters -server.resource( +server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), - async (uri, { userId }) => ({ + { + title: "User Profile", + description: "User profile information" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${userId}` + text: `Profile data for user ${params.userId}` }] }) ); @@ -141,11 +160,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 +179,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(); @@ -174,9 +201,13 @@ server.tool( Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript -server.prompt( +server.registerPrompt( "review-code", - { code: z.string() }, + { + title: "Code Review", + description: "Review code for best practices and potential issues", + arguments: { code: z.string() } + }, ({ code }) => ({ messages: [{ role: "user", @@ -189,6 +220,22 @@ server.prompt( ); ``` +### 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. + + +When building clients, use the provided utility to get the appropriate display name: + +```typescript +import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; + +// Falls back to 'name' if 'title' is not provided +const displayName = getDisplayName(tool); // Returns title if available, otherwise name +``` + ## Running Your Server MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: @@ -401,32 +448,45 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc import { z } from "zod"; const server = new McpServer({ - name: "Echo", - version: "1.0.0" + name: "echo-server", + version: "1.0.0", + title: "Echo Server" }); -server.resource( +server.registerResource( "echo", new ResourceTemplate("echo://{message}", { list: undefined }), - async (uri, { message }) => ({ + { + title: "Echo Resource", + description: "Echoes back messages as resources" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${message}` + text: `Resource echo: ${params.message}` }] }) ); -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", + arguments: { message: z.string() } + }, ({ message }) => ({ messages: [{ role: "user", @@ -450,8 +510,9 @@ import { promisify } from "util"; import { z } from "zod"; const server = new McpServer({ - name: "SQLite Explorer", - version: "1.0.0" + name: "sqlite-explorer", + version: "1.0.0", + title: "SQLite Explorer" }); // Helper to create DB connection @@ -463,9 +524,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 +550,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 { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 19d32bbc..63d7d60a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, } from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; // Create readline interface for user input const readline = createInterface({ @@ -317,7 +318,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(` - ${getDisplayName(tool)}: ${tool.description}`); } } } catch (error) { @@ -429,7 +430,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(` - ${getDisplayName(prompt)}: ${prompt.description}`); } } } catch (error) { @@ -480,7 +481,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(` - ${getDisplayName(resource)}: ${resource.uri}`); } } } catch (error) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c331192..b66be93d 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -18,14 +18,18 @@ const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', version: '1.0.0', + title: 'Simple Streamable HTTP Server', // Display name for UI }, { 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 { @@ -84,12 +88,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', + arguments: { + name: z.string().describe('Name to include in greeting'), + }, }, async ({ name }): Promise => { return { @@ -148,10 +155,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: [ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7fba043f..7c77add1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -122,6 +122,10 @@ export class McpServer { annotations: tool.annotations, }; + if (tool.title !== undefined) { + toolDefinition.title = tool.title; + } + if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -467,13 +471,19 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - return { + const promptDefinition: Prompt = { name, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; + + if (prompt.title !== undefined) { + promptDefinition.title = prompt.title; + } + + return promptDefinition; }, ), }), @@ -634,6 +644,83 @@ 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 & { + title?: string; + description?: string; + mimeType?: string; + }, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === "string") { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource: RegisteredResource = { + name, + metadata: config, + 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; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; + } else { + if (this._registeredResourceTemplates[name]) { + throw new Error(`Resource template ${name} is already registered`); + } + + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: uriOrTemplate, + metadata: config, + 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; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + private _createRegisteredTool( name: string, description: string | undefined, @@ -659,6 +746,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 @@ -789,6 +877,7 @@ export class McpServer { registerTool( name: string, config: { + title?: string; description?: string; inputSchema?: InputArgs; outputSchema?: OutputArgs; @@ -800,16 +889,23 @@ 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( + const registeredTool = this._createRegisteredTool( name, description, inputSchema, outputSchema, annotations, cb as ToolCallback - ) + ); + + // Set title if provided + if (title !== undefined) { + registeredTool.title = title; + } + + return registeredTool; } /** @@ -870,6 +966,7 @@ export class McpServer { 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 @@ -885,6 +982,54 @@ export class McpServer { return registeredPrompt } + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + arguments?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, arguments: argsSchema } = config; + + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback: cb as PromptCallback, + 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; + + this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -1000,6 +1145,7 @@ export type ToolCallback = : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { + title?: string; description?: string; inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; @@ -1011,6 +1157,7 @@ export type RegisteredTool = { update( updates: { name?: string | null, + title?: string, description?: string, paramsSchema?: InputArgs, outputSchema?: OutputArgs, @@ -1110,13 +1257,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/title.test.ts b/src/server/title.test.ts new file mode 100644 index 00000000..7874fb62 --- /dev/null +++ b/src/server/title.test.ts @@ -0,0 +1,194 @@ +import { Server } from "./index.js"; +import { Client } from "../client/index.js"; +import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; +import { McpServer } 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", + arguments: { 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 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..d581a6b2 --- /dev/null +++ b/src/shared/metadataUtils.ts @@ -0,0 +1,21 @@ +import { BaseMetadata } from "../types.js"; + +/** + * Utilities for working with BaseMetadata objects. + */ + +/** + * Gets the display name for an object with BaseMetadata. + * Returns the title if available, otherwise falls back to name. + * This implements the spec requirement: "if no title is provided, name should be used for display purposes" + */ +export function getDisplayName(metadata: BaseMetadata): string { + return metadata.title ?? 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/types.ts b/src/types.ts index 822ba8d7..9ece70d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,16 +195,26 @@ 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 */ + 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. @@ -438,74 +448,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()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .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()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .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. @@ -629,22 +621,16 @@ 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)), +}); /** * Sent from the client to request a list of prompts and prompt templates the server has. @@ -842,49 +828,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())), - }) - .passthrough() - ), - /** - * Optional additional tool information. - */ - annotations: z.optional(ToolAnnotationsSchema), +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), - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .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 tools the server has. @@ -1347,6 +1327,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; From 7e59081b43ca156771470a195794cdff716f3859 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 22:19:53 +0100 Subject: [PATCH 03/12] add resource link --- README.md | 32 +++ src/examples/client/simpleStreamableHttp.ts | 74 ++++++- src/examples/server/simpleStreamableHttp.ts | 95 ++++++++- src/types.test.ts | 211 +++++++++++++++++++- src/types.ts | 37 ++-- 5 files changed, 433 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d13378b5..e7fa574c 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,40 @@ server.registerTool( }; } ); + +// 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" as const, + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown" + }, + { + type: "resource_link" as const, + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript" + } + ] + }) +); ``` +#### 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: diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..8b539d97 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,6 +14,9 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ReadResourceRequest, + ReadResourceResultSchema, + ResourceLink, } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; @@ -60,6 +63,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'); } @@ -155,6 +159,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; @@ -345,13 +357,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}`); } @@ -489,6 +525,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 b66be93d..e5d35a82 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, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult, ResourceLink } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; @@ -174,6 +174,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/types.test.ts b/src/types.test.ts index 0fbc003d..ca49867a 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,4 +1,11 @@ -import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; +import { + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + ResourceLinkSchema, + ContentBlockSchema, + PromptMessageSchema, + CallToolResultSchema +} from "./types.js"; describe("Types", () => { @@ -14,4 +21,206 @@ 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([]); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 9ece70d4..0d051ede 100644 --- a/src/types.ts +++ b/src/types.ts @@ -735,18 +735,33 @@ export const EmbeddedResourceSchema = z }) .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(); @@ -890,13 +905,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. @@ -1376,6 +1385,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; From d1c94a2c696c131f65419f2e3d68efe4a18a1d27 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:43:31 +0100 Subject: [PATCH 04/12] refactor --- README.md | 26 +- src/examples/client/simpleStreamableHttp.ts | 7 +- src/server/mcp.test.ts | 138 +++++++++ src/server/mcp.ts | 303 ++++++++++---------- src/shared/metadataUtils.ts | 19 +- src/types.ts | 9 +- 6 files changed, 348 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index d13378b5..f9f080b9 100644 --- a/README.md +++ b/README.md @@ -226,14 +226,36 @@ All resources, tools, and prompts support an optional `title` field for better U **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"; -// Falls back to 'name' if 'title' is not provided -const displayName = getDisplayName(tool); // Returns title if available, otherwise name +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..efa176df 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,7 +318,12 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${getDisplayName(tool)}: ${tool.description}`); + const displayName = getDisplayName(tool); + if (displayName !== tool.name) { + console.log(` - ${tool.name} (${displayName}): ${tool.description}`); + } else { + console.log(` - ${tool.name}: ${tool.description}`); + } } } } catch (error) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 6ef33540..36a8f7b8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,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", () => { /*** @@ -3598,3 +3599,140 @@ describe("prompt()", () => { 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"); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7c77add1..3c1c00d9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -22,6 +22,7 @@ import { CompleteResult, PromptReference, ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -128,7 +129,7 @@ export class McpServer { if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( - tool.outputSchema, + tool.outputSchema, { strictUnions: true } ) as Tool["outputSchema"]; } @@ -586,27 +587,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(); @@ -616,27 +603,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(); @@ -651,11 +624,7 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata & { - title?: string; - description?: string; - mimeType?: string; - }, + config: ResourceMetadata, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { if (typeof uriOrTemplate === "string") { @@ -663,27 +632,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, - metadata: config, - 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; + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -693,27 +648,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, - metadata: config, - 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; + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -721,8 +662,108 @@ export class McpServer { } } + 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, @@ -730,6 +771,7 @@ export class McpServer { callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { + title, description, inputSchema: inputSchema === undefined ? undefined : z.object(inputSchema), @@ -868,7 +910,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) } /** @@ -891,21 +933,15 @@ export class McpServer { const { title, description, inputSchema, outputSchema, annotations } = config; - const registeredTool = this._createRegisteredTool( + return this._createRegisteredTool( name, + title, description, inputSchema, outputSchema, annotations, cb as ToolCallback ); - - // Set title if provided - if (title !== undefined) { - registeredTool.title = title; - } - - return registeredTool; } /** @@ -953,28 +989,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.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; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1000,29 +1021,13 @@ export class McpServer { const { title, description, arguments: argsSchema } = config; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, title, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb as PromptCallback, - 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; + argsSchema, + cb as PromptCallback + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1155,16 +1160,16 @@ export type RegisteredTool = { enable(): void; disable(): void; update( - updates: { - name?: string | null, + updates: { + name?: string | null, title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1212,12 +1217,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 }; @@ -1232,12 +1238,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 }; diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index d581a6b2..410827a5 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -6,11 +6,26 @@ import { BaseMetadata } from "../types.js"; /** * Gets the display name for an object with BaseMetadata. - * Returns the title if available, otherwise falls back to name. + * 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 { - return metadata.title ?? metadata.name; + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata) { + const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; + if (metadataWithAnnotations.annotations?.title) { + return metadataWithAnnotations.annotations.title; + } + } + + // Finally fall back to name + return metadata.name; } /** diff --git a/src/types.ts b/src/types.ts index 9ece70d4..4d1d14d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,7 +203,14 @@ 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 */ + /** + * 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(); From bce2dbfb83c6936be6ab05598d30b3d4d5e09d82 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:55:02 +0100 Subject: [PATCH 05/12] small fixes like argsSchema --- README.md | 13 ++-- src/examples/server/simpleStreamableHttp.ts | 9 ++- src/server/mcp.ts | 4 +- src/server/title.test.ts | 80 ++++++++++----------- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f9f080b9..5ae9e043 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,7 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ name: "demo-server", - version: "1.0.0", - title: "Demo Server" // Optional display name + version: "1.0.0" }); // Add an addition tool @@ -109,9 +108,8 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "my-app", // Unique identifier for your server - version: "1.0.0", // Server version - title: "My Application" // Optional display name for UI + name: "my-app", + version: "1.0.0" }); ``` @@ -471,8 +469,7 @@ import { z } from "zod"; const server = new McpServer({ name: "echo-server", - version: "1.0.0", - title: "Echo Server" + version: "1.0.0" }); server.registerResource( @@ -507,7 +504,7 @@ server.registerPrompt( { title: "Echo Prompt", description: "Creates a prompt to process a message", - arguments: { message: z.string() } + argsSchema: { message: z.string() } }, ({ message }) => ({ messages: [{ diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d..fca0ff5a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -17,8 +17,7 @@ const useOAuth = process.argv.includes('--oauth'); const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', - version: '1.0.0', - title: 'Simple Streamable HTTP Server', // Display name for UI + version: '1.0.0' }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting @@ -94,7 +93,7 @@ const getServer = () => { { title: 'Greeting Template', // Display name for UI description: 'A simple greeting prompt template', - arguments: { + argsSchema: { name: z.string().describe('Name to include in greeting'), }, }, @@ -158,10 +157,10 @@ const getServer = () => { server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { + { title: 'Default Greeting', // Display name for UI description: 'A simple greeting resource', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3c1c00d9..7394ecbf 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1011,7 +1011,7 @@ export class McpServer { config: { title?: string; description?: string; - arguments?: Args; + argsSchema?: Args; }, cb: PromptCallback ): RegisteredPrompt { @@ -1019,7 +1019,7 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, arguments: argsSchema } = config; + const { title, description, argsSchema } = config; const registeredPrompt = this._createRegisteredPrompt( name, diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 7874fb62..02bafe12 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -7,12 +7,12 @@ import { McpServer } 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", @@ -25,12 +25,12 @@ describe("Title field backwards compatibility", () => { }, 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"); @@ -40,12 +40,12 @@ describe("Title field backwards compatibility", () => { 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", @@ -53,12 +53,12 @@ describe("Title field backwards compatibility", () => { { 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"); @@ -68,12 +68,12 @@ describe("Title field backwards compatibility", () => { 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", @@ -81,12 +81,12 @@ describe("Title field backwards compatibility", () => { 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"); @@ -96,33 +96,33 @@ describe("Title field backwards compatibility", () => { 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", - arguments: { input: z.string() } + argsSchema: { input: z.string() } }, - async ({ input }) => ({ - messages: [{ - role: "user", - content: { type: "text", text: `test: ${input}` } - }] + 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"); @@ -133,12 +133,12 @@ describe("Title field backwards compatibility", () => { 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", @@ -148,19 +148,19 @@ describe("Title field backwards compatibility", () => { description: "A test resource", mimeType: "text/plain" }, - async () => ({ - contents: [{ + async () => ({ + contents: [{ uri: "https://example.com/test", - text: "test content" - }] + 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"); @@ -171,21 +171,21 @@ describe("Title field backwards compatibility", () => { 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"); From 8719f933571e5ba8e0f5b1dc47fb901cb84d9a48 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:00:20 +0100 Subject: [PATCH 06/12] clean up mcp.ts --- src/server/mcp.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7394ecbf..ac6cf772 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -114,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, { @@ -123,10 +124,6 @@ export class McpServer { annotations: tool.annotations, }; - if (tool.title !== undefined) { - toolDefinition.title = tool.title; - } - if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -472,19 +469,14 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - const promptDefinition: Prompt = { + return { name, + title: prompt.title, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; - - if (prompt.title !== undefined) { - promptDefinition.title = prompt.title; - } - - return promptDefinition; }, ), }), From 3f939be6c7336af0fd91e3fd4ded20a4d0d02a57 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:21:07 +0100 Subject: [PATCH 07/12] readme fixes and template tests for title --- README.md | 12 +++++------ src/server/title.test.ts | 44 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5ae9e043..c753e3b1 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ server.registerResource( title: "Greeting Resource", // Display name for UI description: "Dynamic greeting generator" }, - async (uri, params) => ({ + async (uri, { name }) => ({ contents: [{ uri: uri.href, - text: `Hello, ${params.name}!` + text: `Hello, ${name}!` }] }) ); @@ -143,10 +143,10 @@ server.registerResource( title: "User Profile", description: "User profile information" }, - async (uri, params) => ({ + async (uri, { userId }) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${params.userId}` + text: `Profile data for user ${userId}` }] }) ); @@ -479,10 +479,10 @@ server.registerResource( title: "Echo Resource", description: "Echoes back messages as resources" }, - async (uri, params) => ({ + async (uri, { message }) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${params.message}` + text: `Resource echo: ${message}` }] }) ); diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 02bafe12..480257cd 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -2,7 +2,7 @@ import { Server } from "./index.js"; import { Client } from "../client/index.js"; import { InMemoryTransport } from "../inMemory.js"; import { z } from "zod"; -import { McpServer } from "./mcp.js"; +import { McpServer, ResourceTemplate } from "./mcp.js"; describe("Title field backwards compatibility", () => { it("should work with tools that have title", async () => { @@ -169,6 +169,48 @@ describe("Title field backwards compatibility", () => { 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(); From e78c1b787d663c17d3e122d9372d96998f268be7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:27:34 +0100 Subject: [PATCH 08/12] fix --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c753e3b1..0170b5fe 100644 --- a/README.md +++ b/README.md @@ -530,8 +530,7 @@ import { z } from "zod"; const server = new McpServer({ name: "sqlite-explorer", - version: "1.0.0", - title: "SQLite Explorer" + version: "1.0.0" }); // Helper to create DB connection From 90e45950b3c0100469a340b661731f929c02298e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:36:08 +0100 Subject: [PATCH 09/12] show name and title in example --- src/examples/client/simpleStreamableHttp.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index efa176df..e7232fe0 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,12 +318,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - const displayName = getDisplayName(tool); - if (displayName !== tool.name) { - console.log(` - ${tool.name} (${displayName}): ${tool.description}`); - } else { - console.log(` - ${tool.name}: ${tool.description}`); - } + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); } } } catch (error) { @@ -386,7 +381,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: { @@ -399,7 +394,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 @@ -435,7 +430,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${getDisplayName(prompt)}: ${prompt.description}`); + console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`); } } } catch (error) { @@ -486,7 +481,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${getDisplayName(resource)}: ${resource.uri}`); + console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`); } } } catch (error) { From f1a07a940a13b79da8d2ac3b13ba7f2590de65d5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:45:35 +0100 Subject: [PATCH 10/12] ci fix --- src/server/title.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 480257cd..3f64570b 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -185,7 +185,7 @@ describe("Title field backwards compatibility", () => { title: "User Profile", description: "User profile information" }, - async (uri, { userId }, extra) => ({ + async (uri, { userId }, _extra) => ({ contents: [{ uri: uri.href, text: `Profile data for user ${userId}` From 97519d3e32472410b04d8e20a4102f778c5d82aa Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:21 +0100 Subject: [PATCH 11/12] add description to resourse link --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0cf2b85e..ff6c482f 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,18 @@ server.registerTool( { type: "text", text: `Found files matching "${pattern}":` }, // ResourceLinks let tools return references without file content { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/README.md", name: "README.md", - mimeType: "text/markdown" + mimeType: "text/markdown", + description: 'A README file' }, { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/src/index.ts", name: "index.ts", - mimeType: "text/typescript" + mimeType: "text/typescript", + description: 'An index file' } ] }) From 1e5f0e16ba4801d7c5cfa8227b2becdff2ec6571 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:59 +0100 Subject: [PATCH 12/12] whitespaces --- src/types.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/types.test.ts b/src/types.test.ts index ca49867a..d163f03d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,5 +1,5 @@ -import { - LATEST_PROTOCOL_VERSION, +import { + LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, ResourceLinkSchema, ContentBlockSchema, @@ -28,7 +28,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -48,7 +48,7 @@ describe("Types", () => { mimeType: "text/plain", _meta: { custom: "metadata" } }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -65,7 +65,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -76,7 +76,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt" // missing name }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -88,7 +88,7 @@ describe("Types", () => { type: "text", text: "Hello, world!" }; - + const result = ContentBlockSchema.safeParse(textContent); expect(result.success).toBe(true); if (result.success) { @@ -102,7 +102,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "image/png" }; - + const result = ContentBlockSchema.safeParse(imageContent); expect(result.success).toBe(true); if (result.success) { @@ -116,7 +116,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "audio/mp3" }; - + const result = ContentBlockSchema.safeParse(audioContent); expect(result.success).toBe(true); if (result.success) { @@ -131,7 +131,7 @@ describe("Types", () => { name: "file.txt", mimeType: "text/plain" }; - + const result = ContentBlockSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -148,7 +148,7 @@ describe("Types", () => { text: "File contents" } }; - + const result = ContentBlockSchema.safeParse(embeddedResource); expect(result.success).toBe(true); if (result.success) { @@ -169,7 +169,7 @@ describe("Types", () => { mimeType: "text/x-rust" } }; - + const result = PromptMessageSchema.safeParse(promptMessage); expect(result.success).toBe(true); if (result.success) { @@ -202,7 +202,7 @@ describe("Types", () => { } ] }; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { @@ -215,7 +215,7 @@ describe("Types", () => { test("should validate empty content array with default", () => { const toolResult = {}; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) {