Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/hip-sloths-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@storybook/mcp': minor
---

Replace the `source` property in the context with `request`.

Now you don't pass in a source string that might be fetched or handled by your custom `manifestProvider`, but instead you pass in the whole web request. (This is automatically handled if you use the createStorybookMcpHandler() function).

The default action is now to fetch the manifest from `../manifests/components.json` assuming the server is running at `./mcp`. Your custom `manifestProvider()`-function then also does not get a source string as an argument, but gets the whole web request, that you can use to get information about where to fetch the manifest from. It also gets a second argument, `path`, which it should use to determine which specific manifest to get from a built Storybook. (Currently always `./manifests/components.json`, but in the future it might be other paths too).
Comment thread
JReinhold marked this conversation as resolved.
11 changes: 9 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ The `@storybook/mcp` package (in `packages/mcp`) is framework-agnostic:

- Uses `tmcp` with HTTP transport and Valibot schema validation
- Factory pattern: `createStorybookMcpHandler()` returns a request handler
- Context-based: handlers accept `StorybookContext` to override source URLs and provide optional callbacks
- Context-based: handlers accept `StorybookContext` which includes the HTTP `Request` object and optional callbacks
- **Exports tools and types** for reuse by `addon-mcp` and other consumers
- **Request-based manifest loading**: The `request` property in context is passed to tools, which use it to determine the manifest URL (defaults to same origin, replacing `/mcp` with the manifest path)
- **Optional manifestProvider**: Custom function to override default manifest fetching behavior
- Signature: `(request: Request, path: string) => Promise<string>`
- Receives the `Request` object and a `path` parameter (currently always `'./manifests/components.json'`)
- The provider determines the base URL (e.g., mapping to S3 buckets) while the MCP server handles the path
- Returns the manifest JSON as a string
- **Optional handlers**: `StorybookContext` supports optional handlers that are called at various points, allowing consumers to track usage or collect telemetry:
- `onSessionInitialize`: Called when an MCP session is initialized
- `onListAllComponents`: Called when the list-all-components tool is invoked
Expand Down Expand Up @@ -221,7 +227,8 @@ export { addMyTool, MY_TOOL_NAME } from './tools/my-tool.ts';
- Checks `features.experimentalComponentsManifest` flag
- Checks for `experimental_componentManifestGenerator` preset
- Only registers `addListAllComponentsTool` and `addGetComponentDocumentationTool` when enabled
- Context includes `source` URL pointing to `/manifests/components.json` endpoint
- Context includes `request` (HTTP Request object) which tools use to determine manifest location
- Default manifest URL is constructed from request origin, replacing `/mcp` with `/manifests/components.json`
- **Optional handlers for tracking**:
- `onSessionInitialize`: Called when an MCP session is initialized, receives context
- `onListAllComponents`: Called when list tool is invoked, receives context and manifest
Expand Down
44 changes: 44 additions & 0 deletions .github/instructions/mcp.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,50 @@ src/
1. **Factory Pattern**: `createStorybookMcpHandler()` creates configured handler instances
2. **Tool Registration**: Tools are added to the server using `server.tool()` method
3. **Async Handler**: Returns a Promise-based request handler compatible with standard HTTP servers
4. **Request-based Context**: The `Request` object is passed through context to tools, which use it to construct the manifest URL

### Manifest Provider API

The handler accepts a `StorybookContext` with the following key properties:

- **`request`**: The HTTP `Request` object being processed (automatically passed by the handler)
- **`manifestProvider`**: Optional custom function `(request: Request, path: string) => Promise<string>` to override default manifest fetching
- **Parameters**:
- `request`: The HTTP `Request` object to determine base URL, headers, routing, etc.
- `path`: The manifest path (currently always `'./manifests/components.json'`)
- **Responsibility**: The provider determines the "first part" of the URL (base URL/origin) by examining the request. The MCP server provides the path.
- Default behavior: Constructs URL from request origin, replacing `/mcp` with the provided path
- Return value should be the manifest JSON as a string

**Example with custom manifestProvider:**

```typescript
import { createStorybookMcpHandler } from '@storybook/mcp';
import { readFile } from 'node:fs/promises';

const handler = await createStorybookMcpHandler({
manifestProvider: async (request, path) => {
// Custom logic: read from local filesystem
// The provider decides on the base path, MCP provides the manifest path
const basePath = '/path/to/manifests';
// Remove leading './' from path if present
const normalizedPath = path.replace(/^\.\//g, '');
const fullPath = `${basePath}/${normalizedPath}`;
return await readFile(fullPath, 'utf-8');
},
// Or map requests to different S3 buckets:
manifestProvider: async (request, path) => {
const url = new URL(request.url);
const bucket = url.hostname.includes('staging')
? 'staging-bucket'
: 'prod-bucket';
const normalizedPath = path.replace(/^\.\//g, '');
Comment thread
JReinhold marked this conversation as resolved.
Outdated
const manifestUrl = `https://${bucket}.s3.amazonaws.com/${normalizedPath}`;
const response = await fetch(manifestUrl);
return await response.text();
},
});
Comment on lines +66 to +96

Copilot AI Nov 18, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid JavaScript syntax: The example shows two manifestProvider properties in the same object literal (lines 67-75 and 77-86). Only the last one would be used. These should be shown as separate examples.

Suggestion: Split into two separate code blocks with appropriate headings like "Example 1: Local filesystem" and "Example 2: S3 buckets".

Copilot uses AI. Check for mistakes.
```

### Component Manifest and ReactDocgen Support

Expand Down
3 changes: 1 addition & 2 deletions packages/addon-mcp/src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ export const mcpServerHandler = async ({
toolsets: getToolsets(webRequest, addonOptions),
origin: origin!,
disableTelemetry,
// Source URL for component manifest tools - points to the manifest endpoint
source: `${origin}/manifests/components.json`,
request: webRequest,
// Telemetry handlers for component manifest tools
...(!disableTelemetry && {
onListAllComponents: async ({ manifest }) => {
Expand Down
12 changes: 8 additions & 4 deletions packages/mcp/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@ const args = parseArgs({

transport.listen({
source: args.values.manifestPath,
manifestProvider: async (source) => {
if (source.startsWith('http://') || source.startsWith('https://')) {
const res = await fetch(source);
manifestProvider: async () => {
Comment thread
JReinhold marked this conversation as resolved.
const { manifestPath } = args.values;
if (
manifestPath.startsWith('http://') ||
manifestPath.startsWith('https://')
) {
const res = await fetch(manifestPath);
return await res.text();
}
return await fs.readFile(source, 'utf-8');
return await fs.readFile(manifestPath, 'utf-8');
},
});
13 changes: 8 additions & 5 deletions packages/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { parseArgs } from 'node:util';

async function serveMcp(port: number, manifestPath: string) {
const storybookMcpHandler = await createStorybookMcpHandler({
source: manifestPath,
manifestProvider: async (source) => {
if (source.startsWith('http://') || source.startsWith('https://')) {
const res = await fetch(source);
// Use the local fixture file via manifestProvider
manifestProvider: async () => {
Comment thread
JReinhold marked this conversation as resolved.
if (
manifestPath.startsWith('http://') ||
manifestPath.startsWith('https://')
) {
const res = await fetch(manifestPath);
return await res.text();
Comment thread
JReinhold marked this conversation as resolved.
}
return await fs.readFile(source, 'utf-8');
return await fs.readFile(manifestPath, 'utf-8');
},
});

Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export {
GET_TOOL_NAME,
} from './tools/get-component-documentation.ts';

// Export manifest constants
export { MANIFEST_PATH } from './utils/get-manifest.ts';

// Export types for reuse
export type { StorybookContext } from './types.ts';

Expand Down Expand Up @@ -94,7 +97,7 @@ export const createStorybookMcpHandler = async (

return (async (req, context) => {
return await transport.respond(req, {
source: context?.source ?? options.source,
request: req,
manifestProvider: context?.manifestProvider ?? options.manifestProvider,
onListAllComponents:
context?.onListAllComponents ?? options.onListAllComponents,
Expand Down
29 changes: 23 additions & 6 deletions packages/mcp/src/tools/get-component-documentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ describe('getComponentDocumentationTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -102,7 +105,10 @@ describe('getComponentDocumentationTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -137,7 +143,10 @@ describe('getComponentDocumentationTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -167,14 +176,19 @@ describe('getComponentDocumentationTool', () => {
},
};

// Pass the handler in the context for this specific request
const mockHttpRequest = new Request('https://example.com/mcp');
// Pass the handler and request in the context for this specific request
await server.receive(request, {
custom: { onGetComponentDocumentation: handler },
custom: {
request: mockHttpRequest,
onGetComponentDocumentation: handler,
},
});

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
context: expect.objectContaining({
request: mockHttpRequest,
onGetComponentDocumentation: handler,
}),
input: { componentId: 'button' },
Expand Down Expand Up @@ -232,7 +246,10 @@ describe('getComponentDocumentationTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-component-documentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function addGetComponentDocumentationTool(
async (input: GetComponentDocumentationInput) => {
try {
const manifest = await getManifest(
server.ctx.custom?.source,
server.ctx.custom?.request,
server.ctx.custom?.manifestProvider,
);

Expand Down
23 changes: 18 additions & 5 deletions packages/mcp/src/tools/list-all-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ describe('listAllComponentsTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -115,7 +118,10 @@ describe('listAllComponentsTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -143,7 +149,10 @@ describe('listAllComponentsTool', () => {
},
};

const response = await server.receive(request);
const mockHttpRequest = new Request('https://example.com/mcp');
const response = await server.receive(request, {
custom: { request: mockHttpRequest },
});

expect(response.result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -171,12 +180,16 @@ describe('listAllComponentsTool', () => {
},
};

// Pass the handler in the context for this specific request
await server.receive(request, { custom: { onListAllComponents: handler } });
const mockHttpRequest = new Request('https://example.com/mcp');
// Pass the handler and request in the context for this specific request
await server.receive(request, {
custom: { request: mockHttpRequest, onListAllComponents: handler },
});

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
context: expect.objectContaining({
request: mockHttpRequest,
onListAllComponents: handler,
}),
manifest: smallManifestFixture,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/list-all-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function addListAllComponentsTool(
async () => {
try {
const manifest = await getManifest(
server.ctx.custom?.source,
server.ctx.custom?.request,
server.ctx.custom?.manifestProvider,
);

Expand Down
15 changes: 9 additions & 6 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ import * as v from 'valibot';

/**
* Custom context passed to MCP server and tools.
* Contains the source URL for getting component manifests.
* Contains the request object and optional manifest provider.
*/
export interface StorybookContext extends Record<string, unknown> {
/**
* The URL of the remote manifest to get component data from.
* The incoming HTTP request being processed.
*/
source?: string;
request?: Request;
/**
* Optional function to provide custom manifest retrieval logic.
* If provided, this function will be called instead of using fetch.
* The function receives the source URL and should return the manifest as a string.
* If provided, this function will be called instead of the default fetch-based provider.
* The function receives the request object and a path to the manifest file,
* and should return the manifest as a string.
* The default provider constructs the manifest URL from the request origin,
* replacing /mcp with /manifests/components.json
*/
manifestProvider?: (source: string) => Promise<string>;
manifestProvider?: (request: Request, path: string) => Promise<string>;
/**
* Optional handler called when list-all-components tool is invoked.
* Receives the context and the component manifest.
Expand Down
Loading
Loading