diff --git a/.changeset/expose-icons-on-tools-and-prompts.md b/.changeset/expose-icons-on-tools-and-prompts.md new file mode 100644 index 00000000..3a8bb38c --- /dev/null +++ b/.changeset/expose-icons-on-tools-and-prompts.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Support `icons` on the high-level `McpServer.registerTool()` and `registerPrompt()` config objects (and on their `update()` methods). Tool and prompt icons now surface in `tools/list` and `prompts/list`. Resource, resource-template, and server-info (`Implementation`) icons already passed through. This closes the gap with the MCP spec, which allows `icons` on tools, resources, resource templates, and prompts. diff --git a/docs/server.md b/docs/server.md index d03a6735..468bf0cb 100644 --- a/docs/server.md +++ b/docs/server.md @@ -168,6 +168,34 @@ server.registerTool( ); ``` +### Icons + +Tools, prompts, resources, and resource templates can advertise `icons` that a client may render in its UI — the same field is also accepted on your server's `Implementation` info. Each icon needs a `src` (a URL or `data:` URI) and may add a `mimeType`, the `sizes` it suits (`"WxH"` strings, or `"any"` for scalable formats like SVG), and a `theme` (`light` or `dark`). Icons are passed straight through to the relevant list response, such as `tools/list`: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_icons" +server.registerTool( + 'generate-chart', + { + title: 'Generate Chart', + description: 'Render a chart from a series of numbers', + inputSchema: z.object({ data: z.array(z.number()) }), + // Icons a client may render in its UI. `src` is required; `mimeType`, + // `sizes`, and `theme` ('light' | 'dark') are optional hints. + icons: [ + { src: 'https://example.com/icons/chart.svg', mimeType: 'image/svg+xml', sizes: ['any'] }, + { src: 'https://example.com/icons/chart-48.png', mimeType: 'image/png', sizes: ['48x48'], theme: 'light' } + ] + }, + async ({ data }): Promise => { + // ... render chart ... + return { content: [{ type: 'text', text: `Charted ${data.length} points` }] }; + } +); +``` + +> [!NOTE] +> Clients that render icons must support `image/png` and `image/jpeg`, and should also support `image/svg+xml` and `image/webp`. Pass the same `icons` field to `registerPrompt`, `registerResource`, and the `McpServer` constructor's server-info object to advertise icons for prompts, resources, and the server itself. + ### Error handling Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 1d4f2fd4..ec9eed21 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -148,6 +148,30 @@ function registerTool_annotations(server: McpServer) { //#endregion registerTool_annotations } +/** Example: Advertising icons a client can render in its UI for a tool. */ +function registerTool_icons(server: McpServer) { + //#region registerTool_icons + server.registerTool( + 'generate-chart', + { + title: 'Generate Chart', + description: 'Render a chart from a series of numbers', + inputSchema: z.object({ data: z.array(z.number()) }), + // Icons a client may render in its UI. `src` is required; `mimeType`, + // `sizes`, and `theme` ('light' | 'dark') are optional hints. + icons: [ + { src: 'https://example.com/icons/chart.svg', mimeType: 'image/svg+xml', sizes: ['any'] }, + { src: 'https://example.com/icons/chart-48.png', mimeType: 'image/png', sizes: ['48x48'], theme: 'light' } + ] + }, + async ({ data }): Promise => { + // ... render chart ... + return { content: [{ type: 'text', text: `Charted ${data.length} points` }] }; + } + ); + //#endregion registerTool_icons +} + /** Example: Registering a static resource at a fixed URI. */ function registerResource_static(server: McpServer) { //#region registerResource_static @@ -564,6 +588,7 @@ void registerTool_basic; void registerTool_resourceLink; void registerTool_errorHandling; void registerTool_annotations; +void registerTool_icons; void registerTool_logging; void registerTool_progress; void registerTool_traceContext; diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts index 1f0998cc..d034a48d 100644 --- a/examples/server/src/simpleStreamableHttp.ts +++ b/examples/server/src/simpleStreamableHttp.ts @@ -43,6 +43,8 @@ const getServer = () => { { title: 'Greeting Tool', // Display name for UI description: 'A simple greeting tool', + // Optional icons a client can render in its UI for this tool. + icons: [{ src: './greet.svg', sizes: ['48x48'], mimeType: 'image/svg+xml' }], inputSchema: z.object({ name: z.string().describe('Name to greet') }) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 5e911539..95c2476f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -5,6 +5,7 @@ import type { CompleteRequestResourceTemplate, CompleteResult, GetPromptResult, + Icon, Implementation, ListPromptsResult, ListResourcesResult, @@ -143,6 +144,7 @@ export class McpServer { ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, + icons: tool.icons, execution: tool.execution, _meta: tool._meta }; @@ -457,6 +459,7 @@ export class McpServer { title: prompt.title, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + icons: prompt.icons, _meta: prompt._meta }; }) @@ -627,6 +630,7 @@ export class McpServer { description: string | undefined, argsSchema: StandardSchemaWithJSON | undefined, callback: PromptCallback, + icons: Icon[] | undefined, _meta: Record | undefined ): RegisteredPrompt { // Track current schema and callback for handler regeneration @@ -637,6 +641,7 @@ export class McpServer { title, description, argsSchema, + icons, _meta, handler: createPromptHandler(name, argsSchema, callback), enabled: true, @@ -650,6 +655,7 @@ export class McpServer { } if (updates.title !== undefined) registeredPrompt.title = updates.title; if (updates.description !== undefined) registeredPrompt.description = updates.description; + if (updates.icons !== undefined) registeredPrompt.icons = updates.icons; if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; // Track if we need to regenerate the handler @@ -697,6 +703,7 @@ export class McpServer { inputSchema: StandardSchemaWithJSON | undefined, outputSchema: StandardSchemaWithJSON | undefined, annotations: ToolAnnotations | undefined, + icons: Icon[] | undefined, execution: ToolExecution | undefined, _meta: Record | undefined, handler: AnyToolHandler @@ -713,6 +720,7 @@ export class McpServer { inputSchema, outputSchema, annotations, + icons, execution, _meta, handler: handler, @@ -749,6 +757,7 @@ export class McpServer { if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; + if (updates.icons !== undefined) registeredTool.icons = updates.icons; if (updates._meta !== undefined) registeredTool._meta = updates._meta; if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; this.sendToolListChanged(); @@ -796,6 +805,7 @@ export class McpServer { inputSchema?: InputArgs; outputSchema?: OutputArgs; annotations?: ToolAnnotations; + icons?: Icon[]; _meta?: Record; }, cb: ToolCallback @@ -809,6 +819,7 @@ export class McpServer { inputSchema?: InputArgs; outputSchema?: OutputArgs; annotations?: ToolAnnotations; + icons?: Icon[]; _meta?: Record; }, cb: LegacyToolCallback @@ -821,6 +832,7 @@ export class McpServer { inputSchema?: StandardSchemaWithJSON | ZodRawShape; outputSchema?: StandardSchemaWithJSON | ZodRawShape; annotations?: ToolAnnotations; + icons?: Icon[]; _meta?: Record; }, cb: ToolCallback | LegacyToolCallback @@ -829,7 +841,7 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema, outputSchema, annotations, _meta } = config; + const { title, description, inputSchema, outputSchema, annotations, icons, _meta } = config; return this._createRegisteredTool( name, @@ -838,6 +850,7 @@ export class McpServer { normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, + icons, undefined, _meta, cb as ToolCallback @@ -876,6 +889,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: Args; + icons?: Icon[]; _meta?: Record; }, cb: PromptCallback @@ -887,6 +901,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: Args; + icons?: Icon[]; _meta?: Record; }, cb: LegacyPromptCallback @@ -897,6 +912,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: StandardSchemaWithJSON | ZodRawShape; + icons?: Icon[]; _meta?: Record; }, cb: PromptCallback | LegacyPromptCallback @@ -905,7 +921,7 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, argsSchema, _meta } = config; + const { title, description, argsSchema, icons, _meta } = config; const registeredPrompt = this._createRegisteredPrompt( name, @@ -913,6 +929,7 @@ export class McpServer { description, normalizeRawShapeSchema(argsSchema), cb as PromptCallback, + icons, _meta ); @@ -1091,6 +1108,7 @@ export type RegisteredTool = { inputSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; + icons?: Icon[]; execution?: ToolExecution; _meta?: Record; handler: AnyToolHandler; @@ -1106,6 +1124,7 @@ export type RegisteredTool = { paramsSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; + icons?: Icon[]; _meta?: Record; callback?: ToolCallback; enabled?: boolean; @@ -1215,6 +1234,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: StandardSchemaWithJSON; + icons?: Icon[]; _meta?: Record; /** @hidden */ handler: PromptHandler; @@ -1226,6 +1246,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: Args; + icons?: Icon[]; _meta?: Record; callback?: PromptCallback; enabled?: boolean; diff --git a/packages/server/test/server/mcp.icons.test.ts b/packages/server/test/server/mcp.icons.test.ts new file mode 100644 index 00000000..42aba4fd --- /dev/null +++ b/packages/server/test/server/mcp.icons.test.ts @@ -0,0 +1,188 @@ +import type { Icon, JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import { McpServer, ResourceTemplate } from '../../src/index.js'; + +const ICONS: Icon[] = [ + { src: 'https://example.com/icon.png', mimeType: 'image/png', sizes: ['48x48', '96x96'] }, + { src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml', sizes: ['any'], theme: 'dark' } +]; + +/** + * Initializes a client<->server pair over in-memory transport, sends a single + * request, and resolves with its `result` payload. + */ +async function initializeAndRequest(server: McpServer, request: { method: string; params?: unknown }): Promise> { + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, ...request } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + const response = responses.find(r => 'id' in r && r.id === 2) as { result?: Record }; + return response.result ?? {}; +} + +/** Returns the `result` of the `initialize` response (which carries `serverInfo`). */ +async function getInitializeResult(server: McpServer): Promise> { + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 1)).toBe(true)); + const response = responses.find(r => 'id' in r && r.id === 1) as { result?: Record }; + return response.result ?? {}; +} + +describe('icons on high-level McpServer registration', () => { + it('surfaces tool icons in tools/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerTool('iconic', { description: 'a tool with icons', icons: ICONS }, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })); + + const result = await initializeAndRequest(server, { method: 'tools/list' }); + const tools = result.tools as Array<{ name: string; icons?: Icon[] }>; + + expect(tools[0]?.name).toBe('iconic'); + expect(tools[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + it('surfaces prompt icons in prompts/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerPrompt('iconic', { description: 'a prompt with icons', icons: ICONS }, async () => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'hi' } }] + })); + + const result = await initializeAndRequest(server, { method: 'prompts/list' }); + const prompts = result.prompts as Array<{ name: string; icons?: Icon[] }>; + + expect(prompts[0]?.name).toBe('iconic'); + expect(prompts[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + it('reflects tool icons set via update() in tools/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + const tool = server.registerTool('iconic', { description: 'd' }, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })); + tool.update({ icons: ICONS }); + + const result = await initializeAndRequest(server, { method: 'tools/list' }); + const tools = result.tools as Array<{ icons?: Icon[] }>; + + expect(tools[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + it('reflects prompt icons set via update() in prompts/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + const prompt = server.registerPrompt('iconic', { description: 'd' }, async () => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'hi' } }] + })); + prompt.update({ icons: ICONS }); + + const result = await initializeAndRequest(server, { method: 'prompts/list' }); + const prompts = result.prompts as Array<{ icons?: Icon[] }>; + + expect(prompts[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + // Resources and resource templates already carry icons via their `metadata` + // (ResourceMetadata = Omit, which includes `icons`). + // These guard that pass-through against regressions. + it('surfaces resource icons in resources/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerResource('res', 'file:///r', { description: 'a resource with icons', icons: ICONS }, async uri => ({ + contents: [{ uri: uri.href, text: 'x' }] + })); + + const result = await initializeAndRequest(server, { method: 'resources/list' }); + const resources = result.resources as Array<{ uri: string; icons?: Icon[] }>; + + expect(resources[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + it('surfaces resource template icons in resources/templates/list', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerResource( + 'tmpl', + new ResourceTemplate('file:///{id}', { list: undefined }), + { description: 'a template with icons', icons: ICONS }, + async uri => ({ contents: [{ uri: uri.href, text: 'x' }] }) + ); + + const result = await initializeAndRequest(server, { method: 'resources/templates/list' }); + const templates = result.resourceTemplates as Array<{ name: string; icons?: Icon[] }>; + + expect(templates[0]?.icons).toEqual(ICONS); + + await server.close(); + }); + + // Implementation (server info) already passes through verbatim into the + // initialize result. Guard icons/websiteUrl/description against regressions. + it('surfaces server icons, websiteUrl, and description in the initialize result', async () => { + const server = new McpServer({ + name: 's', + version: '1.0.0', + title: 'Server', + description: 'a server with metadata', + websiteUrl: 'https://example.com', + icons: ICONS + }); + + const result = await getInitializeResult(server); + const serverInfo = result.serverInfo as { + title?: string; + description?: string; + websiteUrl?: string; + icons?: Icon[]; + }; + + expect(serverInfo.title).toBe('Server'); + expect(serverInfo.description).toBe('a server with metadata'); + expect(serverInfo.websiteUrl).toBe('https://example.com'); + expect(serverInfo.icons).toEqual(ICONS); + + await server.close(); + }); +});