feat(server): expose icons on registerTool/registerPrompt (McpServer) (#2353)
This commit is contained in:
parent
6312f2a3ec
commit
c59dc3aa1a
6 changed files with 271 additions and 2 deletions
5
.changeset/expose-icons-on-tools-and-prompts.md
Normal file
5
.changeset/expose-icons-on-tools-and-prompts.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
188
packages/server/test/server/mcp.icons.test.ts
Normal file
188
packages/server/test/server/mcp.icons.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue