Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ import type { AddonContext } from '../types.ts';

export const MY_TOOL_NAME = 'my_tool';

const MyToolInput = v.object({
param: v.string()
const MyToolInput = v.object({
param: v.string(),
});

type MyToolInput = v.InferOutput<typeof MyToolInput>;
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
pnpm-lock.yaml
coverage
17 changes: 7 additions & 10 deletions apps/internal-storybook/.claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
{
"permissions": {
"allow": [
"mcp__storybook-addon-mcp__get-ui-building-instructions",
"mcp__storybook-addon-mcp__get-story-urls"
]
},
"enabledMcpjsonServers": [
"storybook-addon-mcp",
"storybook-mcp"
]
"permissions": {
"allow": [
"mcp__storybook-addon-mcp__get-ui-building-instructions",
"mcp__storybook-addon-mcp__get-story-urls"
]
},
"enabledMcpjsonServers": ["storybook-addon-mcp", "storybook-mcp"]
}
1 change: 1 addition & 0 deletions packages/mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
coverage
Comment thread
JReinhold marked this conversation as resolved.
Outdated
17 changes: 6 additions & 11 deletions packages/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { serve } from 'srvx';
import fs from 'node:fs/promises';

const storybookMcpHandler = await createStorybookMcpHandler({
// fetch the manifest from this local server, itself
source: 'http://localhost:13316/fixtures/full-manifest.fixture.json',
// Use the local path directly via manifestProvider
source: './fixtures/full-manifest.fixture.json',
manifestProvider: async (source) => {
// Read the manifest from the local file system
return await fs.readFile(source, 'utf-8');
},
});

serve({
Expand All @@ -15,15 +19,6 @@ serve({
return await storybookMcpHandler(req);
}

// Serve local manifests for the tools to use
if (pathname.startsWith('/fixtures')) {
try {
const fixture = await fs.readFile(`.${pathname}`, 'utf-8');
return new Response(fixture, {
headers: { 'Content-Type': 'application/json' },
});
} catch {}
}
return new Response('Not found', { status: 404 });
},
port: 13316,
Expand Down
10 changes: 9 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ type StorybookMcpHandlerOptions = {
* Can be overridden per-request via context parameter.
*/
source?: string;
/**
* 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.
*/
manifestProvider?: (source: string) => Promise<string>;
};
Comment thread
JReinhold marked this conversation as resolved.
Outdated

export const createStorybookMcpHandler = async (
Expand Down Expand Up @@ -43,7 +49,9 @@ export const createStorybookMcpHandler = async (

return (async (req, context) => {
const source = context?.source ?? options.source;
const manifestProvider =
context?.manifestProvider ?? options.manifestProvider;

return await transport.respond(req, { source });
return await transport.respond(req, { source, manifestProvider });
}) as Handler;
};
5 changes: 4 additions & 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,10 @@ export async function addGetComponentDocumentationTool(
},
async (input: GetComponentDocumentationInput) => {
try {
const manifest = await fetchManifest(server.ctx.custom?.source);
const manifest = await fetchManifest(
server.ctx.custom?.source,
server.ctx.custom?.manifestProvider,
);

const content = [];
const notFoundIds: string[] = [];
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/src/tools/list-all-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export async function addListAllComponentsTool(
},
async () => {
try {
const manifest = await fetchManifest(server.ctx.custom?.source);
const manifest = await fetchManifest(
server.ctx.custom?.source,
server.ctx.custom?.manifestProvider,
);

const componentList = formatComponentManifestMapToList(manifest);
return {
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface StorybookContext extends Record<string, unknown> {
* The URL of the remote manifest to fetch component data from.
*/
source?: string;
/**
* 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.
*/
manifestProvider?: (source: string) => Promise<string>;
}

const JSDocTag = v.object({
Expand Down
119 changes: 119 additions & 0 deletions packages/mcp/src/utils/fetch-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { ComponentManifestMap } from '../types';
global.fetch = vi.fn();

describe('fetchManifest', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('error cases', () => {
it('should throw ManifestFetchError when url is not provided', async () => {
await expect(fetchManifest()).rejects.toThrow(ManifestFetchError);
Expand Down Expand Up @@ -180,4 +184,119 @@ describe('fetchManifest', () => {
);
});
});

describe('manifestProvider', () => {
it('should use manifestProvider when provided', async () => {
const validManifest: ComponentManifestMap = {
v: 1,
components: {
button: {
id: 'button',
name: 'Button',
description: 'A button component',
},
},
};

const manifestProvider = vi
.fn()
.mockResolvedValue(JSON.stringify(validManifest));

const result = await fetchManifest(
'./fixtures/manifest.json',
manifestProvider,
);

expect(result).toEqual(validManifest);
expect(manifestProvider).toHaveBeenCalledExactlyOnceWith(
'./fixtures/manifest.json',
);
// fetch should not be called when manifestProvider is used
expect(global.fetch).not.toHaveBeenCalled();
});

it('should fallback to fetch when manifestProvider is not provided', async () => {
const validManifest: ComponentManifestMap = {
v: 1,
components: {
button: {
id: 'button',
name: 'Button',
description: 'A button component',
},
},
};

global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: {
get: vi.fn().mockReturnValue('application/json'),
},
json: vi.fn().mockResolvedValue(validManifest),
});

const result = await fetchManifest('https://example.com/manifest.json');

expect(result).toEqual(validManifest);
expect(global.fetch).toHaveBeenCalledExactlyOnceWith(
'https://example.com/manifest.json',
);
});

it('should handle errors from manifestProvider', async () => {
const manifestProvider = vi
.fn()
.mockRejectedValue(new Error('File not found'));

await expect(
fetchManifest('./fixtures/manifest.json', manifestProvider),
).rejects.toThrow(ManifestFetchError);
await expect(
fetchManifest('./fixtures/manifest.json', manifestProvider),
).rejects.toThrow('Failed to fetch manifest: File not found');
});

it('should handle invalid JSON from manifestProvider', async () => {
const manifestProvider = vi.fn().mockResolvedValue('not valid json{');

await expect(
fetchManifest('./fixtures/manifest.json', manifestProvider),
).rejects.toThrow(ManifestFetchError);
});

it('should validate manifest from manifestProvider', async () => {
// Missing required 'v' field
const invalidManifest = {
components: {
button: {
id: 'button',
name: 'Button',
},
},
};

const manifestProvider = vi
.fn()
.mockResolvedValue(JSON.stringify(invalidManifest));

await expect(
fetchManifest('./fixtures/manifest.json', manifestProvider),
).rejects.toThrow(ManifestFetchError);
});

it('should throw when manifest from manifestProvider has no components', async () => {
const emptyManifest = {
v: 1,
components: {},
};

const manifestProvider = vi
.fn()
.mockResolvedValue(JSON.stringify(emptyManifest));

await expect(
fetchManifest('./fixtures/manifest.json', manifestProvider),
).rejects.toThrow('No components found in the manifest');
});
});
});
41 changes: 26 additions & 15 deletions packages/mcp/src/utils/fetch-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ export const errorToMCPContent = (error: unknown): MCPErrorResult => {
};

/**
* Fetches a component manifest from a remote URL
* Fetches a component manifest from a remote URL or using a custom provider
*
* @param url - The URL to fetch the manifest from
* @param manifestProvider - Optional custom function to fetch the manifest
* @returns A promise that resolves to the parsed ComponentManifestMap
* @throws {ManifestFetchError} If the fetch fails or the response is invalid
*/
export async function fetchManifest(
url?: string,
manifestProvider?: (source: string) => Promise<string>,
): Promise<ComponentManifestMap> {
try {
if (!url) {
Expand All @@ -73,24 +75,33 @@ export async function fetchManifest(
);
}

const response = await fetch(url);
let data: unknown;

if (!response.ok) {
throw new ManifestFetchError(
`Failed to fetch manifest: ${response.status} ${response.statusText}`,
url,
);
}
// Use custom manifestProvider if provided, otherwise fallback to fetch
if (manifestProvider) {
const manifestString = await manifestProvider(url);
data = JSON.parse(manifestString);
} else {
const response = await fetch(url);

const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new ManifestFetchError(
`Invalid content type: expected application/json, got ${contentType}`,
url,
);
if (!response.ok) {
throw new ManifestFetchError(
`Failed to fetch manifest: ${response.status} ${response.statusText}`,
url,
);
}

const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new ManifestFetchError(
`Invalid content type: expected application/json, got ${contentType}`,
url,
);
}

data = await response.json();
}

const data: unknown = await response.json();
const manifest = v.parse(ComponentManifestMap, data);

if (Object.keys(manifest.components).length === 0) {
Expand Down
Loading