feat(server): expose icons on registerTool/registerPrompt (McpServer) (#2353)

This commit is contained in:
Konstantin Konstantinov 2026-06-23 23:40:41 +03:00 • committed by GitHub
commit c59dc3aa1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 271 additions and 2 deletions

View file

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

View file

@ -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<CallToolResult> => {
// ... 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:

View file

@ -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<CallToolResult> => {
// ... 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;

View file

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

View file

@ -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<StandardSchemaWithJSON | undefined>,
icons: Icon[] | undefined,
_meta: Record<string, unknown> | 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<string, unknown> | undefined,
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
@ -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<string, unknown>;
},
cb: ToolCallback<InputArgs>
@ -809,6 +819,7 @@ export class McpServer {
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
@ -821,6 +832,7 @@ export class McpServer {
inputSchema?: StandardSchemaWithJSON | ZodRawShape;
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
annotations?: ToolAnnotations;
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
@ -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<StandardSchemaWithJSON | undefined>
@ -876,6 +889,7 @@ export class McpServer {
title?: string;
description?: string;
argsSchema?: Args;
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: PromptCallback<Args>
@ -887,6 +901,7 @@ export class McpServer {
title?: string;
description?: string;
argsSchema?: Args;
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: LegacyPromptCallback<Args>
@ -897,6 +912,7 @@ export class McpServer {
title?: string;
description?: string;
argsSchema?: StandardSchemaWithJSON | ZodRawShape;
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
@ -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<StandardSchemaWithJSON | undefined>,
icons,
_meta
);
@ -1091,6 +1108,7 @@ export type RegisteredTool = {
inputSchema?: StandardSchemaWithJSON;
outputSchema?: StandardSchemaWithJSON;
annotations?: ToolAnnotations;
icons?: Icon[];
execution?: ToolExecution;
_meta?: Record<string, unknown>;
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>;
@ -1106,6 +1124,7 @@ export type RegisteredTool = {
paramsSchema?: StandardSchemaWithJSON;
outputSchema?: StandardSchemaWithJSON;
annotations?: ToolAnnotations;
icons?: Icon[];
_meta?: Record<string, unknown>;
callback?: ToolCallback<StandardSchemaWithJSON>;
enabled?: boolean;
@ -1215,6 +1234,7 @@ export type RegisteredPrompt = {
title?: string;
description?: string;
argsSchema?: StandardSchemaWithJSON;
icons?: Icon[];
_meta?: Record<string, unknown>;
/** @hidden */
handler: PromptHandler;
@ -1226,6 +1246,7 @@ export type RegisteredPrompt = {
title?: string;
description?: string;
argsSchema?: Args;
icons?: Icon[];
_meta?: Record<string, unknown>;
callback?: PromptCallback<Args>;
enabled?: boolean;

View file

@ -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<Record<string, unknown>> {
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<string, unknown> };
return response.result ?? {};
}
/** Returns the `result` of the `initialize` response (which carries `serverInfo`). */
async function getInitializeResult(server: McpServer): Promise<Record<string, unknown>> {
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<string, unknown> };
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<Resource, 'uri' | 'name'>, 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();
});
});