Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
53 changes: 53 additions & 0 deletions .github/instructions/mcp.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,59 @@ 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 (local filesystem):**

```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(/^\.\//, '');
const fullPath = `${basePath}/${normalizedPath}`;
return await readFile(fullPath, 'utf-8');
},
});
```

**Example with custom manifestProvider (S3 bucket mapping):**

```typescript
import { createStorybookMcpHandler } from '@storybook/mcp';

const handler = await createStorybookMcpHandler({
manifestProvider: async (request, path) => {
// Map requests to different S3 buckets based on hostname
const url = new URL(request.url);
const bucket = url.hostname.includes('staging')
? 'staging-bucket'
: 'prod-bucket';
const normalizedPath = path.replace(/^\.\, '');
const manifestUrl = `https://${bucket}.s3.amazonaws.com/${normalizedPath}`;
const response = await fetch(manifestUrl);
return await response.text();
},
});
```

### 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