Skip to content

Commit 696bdd1

Browse files
authored
Merge pull request #42 from storybookjs/mcp-telemetry
Add optional handlers to `@storybook/mcp` and telemetry to the new tools in `@storybook/addon-mcp`
2 parents 4e83251 + 7884042 commit 696bdd1

File tree

12 files changed

+254
-21
lines changed

12 files changed

+254
-21
lines changed

.changeset/chatty-bees-cut.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@storybook/mcp': patch
3+
---
4+
5+
Add optional event handlers to the tool calls, so you can optionally run some functionality on all tool calls

.changeset/dark-chairs-cover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@storybook/addon-mcp': patch
3+
---
4+
5+
Log telemetry when the additional @storybook/mcp tools are called

.github/copilot-instructions.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ The `@storybook/mcp` package (in `packages/mcp`) is framework-agnostic:
2929

3030
- Uses `tmcp` with HTTP transport and Valibot schema validation
3131
- Factory pattern: `createStorybookMcpHandler()` returns a request handler
32-
- Context-based: handlers accept `StorybookContext` to override source URLs
32+
- Context-based: handlers accept `StorybookContext` to override source URLs and provide optional callbacks
3333
- **Exports tools and types** for reuse by `addon-mcp` and other consumers
34+
- **Optional handlers**: `StorybookContext` supports optional handlers that are called at various points, allowing consumers to track usage or collect telemetry:
35+
- `onSessionInitialize`: Called when an MCP session is initialized
36+
- `onListAllComponents`: Called when the list-all-components tool is invoked
37+
- `onGetComponentDocumentation`: Called when the get-component-documentation tool is invoked
3438

3539
## Development Environment
3640

@@ -186,6 +190,11 @@ export { addMyTool, MY_TOOL_NAME } from './tools/my-tool.ts';
186190
- Checks for `experimental_componentManifestGenerator` preset
187191
- Only registers `addListAllComponentsTool` and `addGetComponentDocumentationTool` when enabled
188192
- Context includes `source` URL pointing to `/manifests/components.json` endpoint
193+
- **Optional handlers for tracking**:
194+
- `onSessionInitialize`: Called when an MCP session is initialized, receives context
195+
- `onListAllComponents`: Called when list tool is invoked, receives context and manifest
196+
- `onGetComponentDocumentation`: Called when get tool is invoked, receives context, input, found components, and not found IDs
197+
- Addon-mcp uses these handlers to collect telemetry on tool usage
189198

190199
**Storybook internals used:**
191200

.github/instructions/addon-mcp.instructions.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,36 @@ The addon collects anonymous usage data:
350350
- Tool usage events (which tools are called and how often)
351351
- Client information (MCP client name)
352352

353+
**For addon-specific tools**: Telemetry is collected directly in the tool implementation using `collectTelemetry()`.
354+
355+
**For reused tools from `@storybook/mcp`**: The addon uses optional handlers (`onListAllComponents`, `onGetComponentDocumentation`) provided by the `StorybookContext` to track usage. These handlers are passed in the context when calling `transport.respond()` in `mcp-handler.ts`:
356+
357+
```typescript
358+
const addonContext: AddonContext = {
359+
// ... other context properties
360+
onListAllComponents: async ({ manifest }) => {
361+
if (!disableTelemetry && server) {
362+
await collectTelemetry({
363+
event: 'tool:listAllComponents',
364+
server,
365+
componentCount: Object.keys(manifest.components).length,
366+
});
367+
}
368+
},
369+
onGetComponentDocumentation: async ({ input, foundComponents, notFoundIds }) => {
370+
if (!disableTelemetry && server) {
371+
await collectTelemetry({
372+
event: 'tool:getComponentDocumentation',
373+
server,
374+
inputComponentCount: input.componentIds.length,
375+
foundCount: foundComponents.length,
376+
notFoundCount: notFoundIds.length,
377+
});
378+
}
379+
},
380+
};
381+
```
382+
353383
Telemetry respects Storybook's `disableTelemetry` config:
354384

355385
```typescript

.github/instructions/mcp.instructions.md

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ This is a Model Context Protocol (MCP) server for Storybook that serves knowledg
1313
### Key Components
1414

1515
- **MCP Server**: Built using the `tmcp` library with HTTP transport
16-
- **Tools System**: Extensible tool registration system (includes `list_all_components` and `get_component_documentation`)
16+
- **Tools System**: Extensible tool registration system with optional handlers for tracking tool usage
1717
- **Component Manifest**: Parses and formats component documentation including React prop information from react-docgen
1818
- **Schema Validation**: Uses Valibot for JSON schema validation via `@tmcp/adapter-valibot`
1919
- **HTTP Transport**: Provides HTTP-based MCP communication via `@tmcp/transport-http`
20+
- **Context System**: `StorybookContext` allows passing optional handlers (`onSessionInitialize`, `onListAllComponents`, `onGetComponentDocumentation`) that are called at various points when provided
2021

2122
### File Structure
2223

@@ -68,7 +69,8 @@ Component manifests can include a `reactDocgen` property containing prop informa
6869
```
6970

7071
**Type serialization examples:**
71-
- Unions: `"primary" | "secondary"`
72+
73+
- Unions: `"primary" | "secondary"`
7274
- Functions: `(event: MouseEvent) => void`
7375
- Objects: `{ name: string; age?: number }`
7476
- Arrays: `string[]`
@@ -192,20 +194,37 @@ To add a new MCP tool:
192194
1. Create a new file in `src/tools/` (e.g., `src/tools/my-tool.ts`)
193195
2. Export a constant for the tool name
194196
3. Export an async function that adds the tool to the server:
197+
195198
```typescript
196-
export async function addMyTool(server: McpServer) {
199+
export async function addMyTool(server: McpServer<any, StorybookContext>) {
197200
server.tool(
198201
{
199202
name: 'my_tool_name',
200203
description: 'Tool description',
201204
},
202-
() => ({
203-
content: [{ type: 'text', text: 'result' }],
204-
}),
205+
async () => {
206+
// Tool implementation
207+
const result = 'result';
208+
209+
// Call optional handler if provided
210+
await server.ctx.custom?.onMyTool?.({
211+
context: server.ctx.custom,
212+
// ... any relevant data
213+
});
214+
215+
return {
216+
content: [{ type: 'text', text: result }],
217+
};
218+
},
205219
);
206220
}
207221
```
208-
4. Import and call the function in `src/index.ts` after `addListTool(server)`
222+
223+
4. Import and call the function in `src/index.ts` in the `createStorybookMcpHandler` function
224+
5. If adding an optional handler:
225+
- Add the handler type to `StorybookContext` in `src/types.ts`
226+
- Document what parameters the handler receives
227+
- Export the handler type from `src/index.ts` if it should be usable by consumers
209228

210229
## MCP Protocol
211230

packages/addon-mcp/src/mcp-handler.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { logger } from 'storybook/internal/node-logger';
1919
let transport: HttpTransport<AddonContext> | undefined;
2020
let origin: string | undefined;
2121
// Promise that ensures single initialization, even with concurrent requests
22-
let initialize: Promise<void> | undefined;
22+
let initialize: Promise<McpServer<any, AddonContext>> | undefined;
2323

2424
const initializeMCPServer = async (options: Options) => {
2525
const server = new McpServer(
@@ -67,6 +67,7 @@ const initializeMCPServer = async (options: Options) => {
6767

6868
origin = `http://localhost:${options.port}`;
6969
logger.debug('MCP server origin:', origin);
70+
return server;
7071
};
7172

7273
/**
@@ -79,16 +80,13 @@ export const mcpServerHandler = async (
7980
next: Connect.NextFunction,
8081
options: Options,
8182
) => {
82-
const { disableTelemetry = false } = await options.presets.apply<CoreConfig>(
83-
'core',
84-
{},
85-
);
83+
const disableTelemetry = options.disableTelemetry ?? false;
8684

8785
// Initialize MCP server and transport on first request, with concurrency safety
8886
if (!initialize) {
8987
initialize = initializeMCPServer(options);
9088
}
91-
await initialize;
89+
const server = await initialize;
9290

9391
// Convert Node.js request to Web API Request
9492
const webRequest = await incomingMessageToWebRequest(req);
@@ -100,6 +98,29 @@ export const mcpServerHandler = async (
10098
disableTelemetry,
10199
// Source URL for component manifest tools - points to the manifest endpoint
102100
source: `${origin}/manifests/components.json`,
101+
// Telemetry handlers for component manifest tools
102+
...(!disableTelemetry && {
103+
onListAllComponents: async ({ manifest }) => {
104+
await collectTelemetry({
105+
event: 'tool:listAllComponents',
106+
server,
107+
componentCount: Object.keys(manifest.components).length,
108+
});
109+
},
110+
onGetComponentDocumentation: async ({
111+
input,
112+
foundComponents,
113+
notFoundIds,
114+
}) => {
115+
await collectTelemetry({
116+
event: 'tool:getComponentDocumentation',
117+
server,
118+
inputComponentCount: input.componentIds.length,
119+
foundCount: foundComponents.length,
120+
notFoundCount: notFoundIds.length,
121+
});
122+
},
123+
}),
103124
};
104125

105126
const response = await transport!.respond(webRequest, addonContext);

packages/mcp/src/index.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,54 @@ export {
1919
// Export types for reuse
2020
export type { StorybookContext } from './types.ts';
2121

22+
// copied from tmcp internals as it's not exposed
23+
type InitializeRequestParams = {
24+
protocolVersion: string;
25+
capabilities: {
26+
experimental?: {} | undefined;
27+
sampling?: {} | undefined;
28+
elicitation?: {} | undefined;
29+
roots?:
30+
| {
31+
listChanged?: boolean | undefined;
32+
}
33+
| undefined;
34+
};
35+
clientInfo: {
36+
icons?:
37+
| {
38+
src: string;
39+
mimeType?: string | undefined;
40+
sizes?: string[] | undefined;
41+
}[]
42+
| undefined;
43+
version: string;
44+
websiteUrl?: string | undefined;
45+
name: string;
46+
title?: string | undefined;
47+
};
48+
};
49+
50+
/**
51+
* Options for creating a Storybook MCP handler.
52+
* Extends StorybookContext with server-level configuration.
53+
*/
54+
export interface StorybookMcpHandlerOptions extends StorybookContext {
55+
/**
56+
* Optional handler called when an MCP session is initialized.
57+
* This is only valid at the handler creation level, not per-request.
58+
* Receives the initialize request parameters from the MCP protocol.
59+
*/
60+
onSessionInitialize?: (
61+
initializeRequestParams: InitializeRequestParams,
62+
) => void | Promise<void>;
63+
}
64+
export type { ComponentManifest, ComponentManifestMap } from './types.ts';
65+
2266
type Handler = (req: Request, context?: StorybookContext) => Promise<Response>;
2367

2468
export const createStorybookMcpHandler = async (
25-
options: StorybookContext = {},
69+
options: StorybookMcpHandlerOptions = {},
2670
): Promise<Handler> => {
2771
const adapter = new ValibotJsonSchemaAdapter();
2872
const server = new McpServer(
@@ -41,16 +85,24 @@ export const createStorybookMcpHandler = async (
4185
},
4286
).withContext<StorybookContext>();
4387

88+
if (options.onSessionInitialize) {
89+
server.on('initialize', options.onSessionInitialize);
90+
}
91+
4492
await addListAllComponentsTool(server);
4593
await addGetComponentDocumentationTool(server);
4694

4795
const transport = new HttpTransport(server, { path: null });
4896

4997
return (async (req, context) => {
50-
const source = context?.source ?? options.source;
51-
const manifestProvider =
52-
context?.manifestProvider ?? options.manifestProvider;
53-
54-
return await transport.respond(req, { source, manifestProvider });
98+
return await transport.respond(req, {
99+
source: context?.source ?? options.source,
100+
manifestProvider: context?.manifestProvider ?? options.manifestProvider,
101+
onListAllComponents:
102+
context?.onListAllComponents ?? options.onListAllComponents,
103+
onGetComponentDocumentation:
104+
context?.onGetComponentDocumentation ??
105+
options.onGetComponentDocumentation,
106+
});
55107
}) as Handler;
56108
};

packages/mcp/src/tools/get-component-documentation.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,40 @@ describe('getComponentDocumentationTool', () => {
300300
`);
301301
});
302302

303+
it('should call onGetComponentDocumentation handler when provided', async () => {
304+
const handler = vi.fn();
305+
306+
const request = {
307+
jsonrpc: '2.0' as const,
308+
id: 2,
309+
method: 'tools/call',
310+
params: {
311+
name: GET_TOOL_NAME,
312+
arguments: {
313+
componentIds: ['button', 'card', 'non-existent'],
314+
},
315+
},
316+
};
317+
318+
// Pass the handler in the context for this specific request
319+
await server.receive(request, {
320+
custom: { onGetComponentDocumentation: handler },
321+
});
322+
323+
expect(handler).toHaveBeenCalledTimes(1);
324+
expect(handler).toHaveBeenCalledWith({
325+
context: expect.objectContaining({
326+
onGetComponentDocumentation: handler,
327+
}),
328+
input: { componentIds: ['button', 'card', 'non-existent'] },
329+
foundComponents: [
330+
expect.objectContaining({ id: 'button', name: 'Button' }),
331+
expect.objectContaining({ id: 'card', name: 'Card' }),
332+
],
333+
notFoundIds: ['non-existent'],
334+
});
335+
});
336+
303337
it('should include props section when reactDocgen is present', async () => {
304338
const manifestWithReactDocgen = {
305339
v: 1,

packages/mcp/src/tools/get-component-documentation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as v from 'valibot';
22
import type { McpServer } from 'tmcp';
3-
import type { StorybookContext } from '../types.ts';
3+
import type { StorybookContext, ComponentManifest } from '../types.ts';
44
import { getManifest, errorToMCPContent } from '../utils/get-manifest.ts';
55
import { formatComponentManifest } from '../utils/format-manifest.ts';
66

@@ -36,6 +36,7 @@ export async function addGetComponentDocumentationTool(
3636

3737
const content = [];
3838
const notFoundIds: string[] = [];
39+
const foundComponents: ComponentManifest[] = [];
3940

4041
for (const componentId of input.componentIds) {
4142
const component = manifest.components[componentId];
@@ -45,6 +46,7 @@ export async function addGetComponentDocumentationTool(
4546
continue;
4647
}
4748

49+
foundComponents.push(component);
4850
content.push({
4951
type: 'text' as const,
5052
text: formatComponentManifest(component),
@@ -62,6 +64,13 @@ export async function addGetComponentDocumentationTool(
6264
});
6365
}
6466

67+
await server.ctx.custom?.onGetComponentDocumentation?.({
68+
context: server.ctx.custom,
69+
input: { componentIds: input.componentIds },
70+
foundComponents,
71+
notFoundIds,
72+
});
73+
6574
return {
6675
content,
6776
...(allNotFound && { isError: true }),

0 commit comments

Comments
 (0)