Skip to content

Commit 5d18405

Browse files
authored
remove source API and use the request instead (#54)
* remove source API and use the request instead * cleanup * add changesets * add path argument to manifestProvider * cleanup * update changeset * fix serve.ts * cleanup
1 parent f40da8f commit 5d18405

File tree

14 files changed

+254
-109
lines changed

14 files changed

+254
-109
lines changed

.changeset/hip-sloths-jog.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@storybook/mcp': minor
3+
---
4+
5+
Replace the `source` property in the context with `request`.
6+
7+
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).
8+
9+
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).

.github/copilot-instructions.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ The `@storybook/mcp` package (in `packages/mcp`) is framework-agnostic:
6161

6262
- Uses `tmcp` with HTTP transport and Valibot schema validation
6363
- Factory pattern: `createStorybookMcpHandler()` returns a request handler
64-
- Context-based: handlers accept `StorybookContext` to override source URLs and provide optional callbacks
64+
- Context-based: handlers accept `StorybookContext` which includes the HTTP `Request` object and optional callbacks
6565
- **Exports tools and types** for reuse by `addon-mcp` and other consumers
66+
- **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)
67+
- **Optional manifestProvider**: Custom function to override default manifest fetching behavior
68+
- Signature: `(request: Request, path: string) => Promise<string>`
69+
- Receives the `Request` object and a `path` parameter (currently always `'./manifests/components.json'`)
70+
- The provider determines the base URL (e.g., mapping to S3 buckets) while the MCP server handles the path
71+
- Returns the manifest JSON as a string
6672
- **Optional handlers**: `StorybookContext` supports optional handlers that are called at various points, allowing consumers to track usage or collect telemetry:
6773
- `onSessionInitialize`: Called when an MCP session is initialized
6874
- `onListAllComponents`: Called when the list-all-components tool is invoked
@@ -221,7 +227,8 @@ export { addMyTool, MY_TOOL_NAME } from './tools/my-tool.ts';
221227
- Checks `features.experimentalComponentsManifest` flag
222228
- Checks for `experimental_componentManifestGenerator` preset
223229
- Only registers `addListAllComponentsTool` and `addGetComponentDocumentationTool` when enabled
224-
- Context includes `source` URL pointing to `/manifests/components.json` endpoint
230+
- Context includes `request` (HTTP Request object) which tools use to determine manifest location
231+
- Default manifest URL is constructed from request origin, replacing `/mcp` with `/manifests/components.json`
225232
- **Optional handlers for tracking**:
226233
- `onSessionInitialize`: Called when an MCP session is initialized, receives context
227234
- `onListAllComponents`: Called when list tool is invoked, receives context and manifest

.github/instructions/mcp.instructions.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,59 @@ src/
4242
1. **Factory Pattern**: `createStorybookMcpHandler()` creates configured handler instances
4343
2. **Tool Registration**: Tools are added to the server using `server.tool()` method
4444
3. **Async Handler**: Returns a Promise-based request handler compatible with standard HTTP servers
45+
4. **Request-based Context**: The `Request` object is passed through context to tools, which use it to construct the manifest URL
46+
47+
### Manifest Provider API
48+
49+
The handler accepts a `StorybookContext` with the following key properties:
50+
51+
- **`request`**: The HTTP `Request` object being processed (automatically passed by the handler)
52+
- **`manifestProvider`**: Optional custom function `(request: Request, path: string) => Promise<string>` to override default manifest fetching
53+
- **Parameters**:
54+
- `request`: The HTTP `Request` object to determine base URL, headers, routing, etc.
55+
- `path`: The manifest path (currently always `'./manifests/components.json'`)
56+
- **Responsibility**: The provider determines the "first part" of the URL (base URL/origin) by examining the request. The MCP server provides the path.
57+
- Default behavior: Constructs URL from request origin, replacing `/mcp` with the provided path
58+
- Return value should be the manifest JSON as a string
59+
60+
**Example with custom manifestProvider (local filesystem):**
61+
62+
```typescript
63+
import { createStorybookMcpHandler } from '@storybook/mcp';
64+
import { readFile } from 'node:fs/promises';
65+
66+
const handler = await createStorybookMcpHandler({
67+
manifestProvider: async (request, path) => {
68+
// Custom logic: read from local filesystem
69+
// The provider decides on the base path, MCP provides the manifest path
70+
const basePath = '/path/to/manifests';
71+
// Remove leading './' from path if present
72+
const normalizedPath = path.replace(/^\.\//, '');
73+
const fullPath = `${basePath}/${normalizedPath}`;
74+
return await readFile(fullPath, 'utf-8');
75+
},
76+
});
77+
```
78+
79+
**Example with custom manifestProvider (S3 bucket mapping):**
80+
81+
```typescript
82+
import { createStorybookMcpHandler } from '@storybook/mcp';
83+
84+
const handler = await createStorybookMcpHandler({
85+
manifestProvider: async (request, path) => {
86+
// Map requests to different S3 buckets based on hostname
87+
const url = new URL(request.url);
88+
const bucket = url.hostname.includes('staging')
89+
? 'staging-bucket'
90+
: 'prod-bucket';
91+
const normalizedPath = path.replace(/^\.\, '');
92+
const manifestUrl = `https://${bucket}.s3.amazonaws.com/${normalizedPath}`;
93+
const response = await fetch(manifestUrl);
94+
return await response.text();
95+
},
96+
});
97+
```
4598

4699
### Component Manifest and ReactDocgen Support
47100

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ export const mcpServerHandler = async ({
9696
toolsets: getToolsets(webRequest, addonOptions),
9797
origin: origin!,
9898
disableTelemetry,
99-
// Source URL for component manifest tools - points to the manifest endpoint
100-
source: `${origin}/manifests/components.json`,
99+
request: webRequest,
101100
// Telemetry handlers for component manifest tools
102101
...(!disableTelemetry && {
103102
onListAllComponents: async ({ manifest }) => {

packages/mcp/bin.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@ const args = parseArgs({
5252

5353
transport.listen({
5454
source: args.values.manifestPath,
55-
manifestProvider: async (source) => {
56-
if (source.startsWith('http://') || source.startsWith('https://')) {
57-
const res = await fetch(source);
55+
manifestProvider: async () => {
56+
const { manifestPath } = args.values;
57+
if (
58+
manifestPath.startsWith('http://') ||
59+
manifestPath.startsWith('https://')
60+
) {
61+
const res = await fetch(manifestPath);
5862
return await res.text();
5963
}
60-
return await fs.readFile(source, 'utf-8');
64+
return await fs.readFile(manifestPath, 'utf-8');
6165
},
6266
});

packages/mcp/serve.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { parseArgs } from 'node:util';
55

66
async function serveMcp(port: number, manifestPath: string) {
77
const storybookMcpHandler = await createStorybookMcpHandler({
8-
source: manifestPath,
9-
manifestProvider: async (source) => {
10-
if (source.startsWith('http://') || source.startsWith('https://')) {
11-
const res = await fetch(source);
8+
// Use the local fixture file via manifestProvider
9+
manifestProvider: async () => {
10+
if (
11+
manifestPath.startsWith('http://') ||
12+
manifestPath.startsWith('https://')
13+
) {
14+
const res = await fetch(manifestPath);
1215
return await res.text();
1316
}
14-
return await fs.readFile(source, 'utf-8');
17+
return await fs.readFile(manifestPath, 'utf-8');
1518
},
1619
});
1720

packages/mcp/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export {
1616
GET_TOOL_NAME,
1717
} from './tools/get-component-documentation.ts';
1818

19+
// Export manifest constants
20+
export { MANIFEST_PATH } from './utils/get-manifest.ts';
21+
1922
// Export types for reuse
2023
export type { StorybookContext } from './types.ts';
2124

@@ -94,7 +97,7 @@ export const createStorybookMcpHandler = async (
9497

9598
return (async (req, context) => {
9699
return await transport.respond(req, {
97-
source: context?.source ?? options.source,
100+
request: req,
98101
manifestProvider: context?.manifestProvider ?? options.manifestProvider,
99102
onListAllComponents:
100103
context?.onListAllComponents ?? options.onListAllComponents,

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

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ describe('getComponentDocumentationTool', () => {
6363
},
6464
};
6565

66-
const response = await server.receive(request);
66+
const mockHttpRequest = new Request('https://example.com/mcp');
67+
const response = await server.receive(request, {
68+
custom: { request: mockHttpRequest },
69+
});
6770

6871
expect(response.result).toMatchInlineSnapshot(`
6972
{
@@ -102,7 +105,10 @@ describe('getComponentDocumentationTool', () => {
102105
},
103106
};
104107

105-
const response = await server.receive(request);
108+
const mockHttpRequest = new Request('https://example.com/mcp');
109+
const response = await server.receive(request, {
110+
custom: { request: mockHttpRequest },
111+
});
106112

107113
expect(response.result).toMatchInlineSnapshot(`
108114
{
@@ -137,7 +143,10 @@ describe('getComponentDocumentationTool', () => {
137143
},
138144
};
139145

140-
const response = await server.receive(request);
146+
const mockHttpRequest = new Request('https://example.com/mcp');
147+
const response = await server.receive(request, {
148+
custom: { request: mockHttpRequest },
149+
});
141150

142151
expect(response.result).toMatchInlineSnapshot(`
143152
{
@@ -167,14 +176,19 @@ describe('getComponentDocumentationTool', () => {
167176
},
168177
};
169178

170-
// Pass the handler in the context for this specific request
179+
const mockHttpRequest = new Request('https://example.com/mcp');
180+
// Pass the handler and request in the context for this specific request
171181
await server.receive(request, {
172-
custom: { onGetComponentDocumentation: handler },
182+
custom: {
183+
request: mockHttpRequest,
184+
onGetComponentDocumentation: handler,
185+
},
173186
});
174187

175188
expect(handler).toHaveBeenCalledTimes(1);
176189
expect(handler).toHaveBeenCalledWith({
177190
context: expect.objectContaining({
191+
request: mockHttpRequest,
178192
onGetComponentDocumentation: handler,
179193
}),
180194
input: { componentId: 'button' },
@@ -232,7 +246,10 @@ describe('getComponentDocumentationTool', () => {
232246
},
233247
};
234248

235-
const response = await server.receive(request);
249+
const mockHttpRequest = new Request('https://example.com/mcp');
250+
const response = await server.receive(request, {
251+
custom: { request: mockHttpRequest },
252+
});
236253

237254
expect(response.result).toMatchInlineSnapshot(`
238255
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export async function addGetComponentDocumentationTool(
2929
async (input: GetComponentDocumentationInput) => {
3030
try {
3131
const manifest = await getManifest(
32-
server.ctx.custom?.source,
32+
server.ctx.custom?.request,
3333
server.ctx.custom?.manifestProvider,
3434
);
3535

packages/mcp/src/tools/list-all-components.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ describe('listAllComponentsTool', () => {
6161
},
6262
};
6363

64-
const response = await server.receive(request);
64+
const mockHttpRequest = new Request('https://example.com/mcp');
65+
const response = await server.receive(request, {
66+
custom: { request: mockHttpRequest },
67+
});
6568

6669
expect(response.result).toMatchInlineSnapshot(`
6770
{
@@ -115,7 +118,10 @@ describe('listAllComponentsTool', () => {
115118
},
116119
};
117120

118-
const response = await server.receive(request);
121+
const mockHttpRequest = new Request('https://example.com/mcp');
122+
const response = await server.receive(request, {
123+
custom: { request: mockHttpRequest },
124+
});
119125

120126
expect(response.result).toMatchInlineSnapshot(`
121127
{
@@ -143,7 +149,10 @@ describe('listAllComponentsTool', () => {
143149
},
144150
};
145151

146-
const response = await server.receive(request);
152+
const mockHttpRequest = new Request('https://example.com/mcp');
153+
const response = await server.receive(request, {
154+
custom: { request: mockHttpRequest },
155+
});
147156

148157
expect(response.result).toMatchInlineSnapshot(`
149158
{
@@ -171,12 +180,16 @@ describe('listAllComponentsTool', () => {
171180
},
172181
};
173182

174-
// Pass the handler in the context for this specific request
175-
await server.receive(request, { custom: { onListAllComponents: handler } });
183+
const mockHttpRequest = new Request('https://example.com/mcp');
184+
// Pass the handler and request in the context for this specific request
185+
await server.receive(request, {
186+
custom: { request: mockHttpRequest, onListAllComponents: handler },
187+
});
176188

177189
expect(handler).toHaveBeenCalledTimes(1);
178190
expect(handler).toHaveBeenCalledWith({
179191
context: expect.objectContaining({
192+
request: mockHttpRequest,
180193
onListAllComponents: handler,
181194
}),
182195
manifest: smallManifestFixture,

0 commit comments

Comments
 (0)