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
5 changes: 5 additions & 0 deletions .changeset/spotty-toolsets-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@storybook/addon-mcp": patch
---

Surface the change-detection and review tools on the `/mcp` landing page. The "Available Toolsets" list now includes `get-stories-by-component`, `get-changed-stories`, and `display-review` under **dev** (each with an enabled/disabled badge reflecting its real runtime gate), and `get-documentation-for-story` under **docs**.
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('buildServerInstructions', () => {
testEnabled: false,
docsEnabled: false,
changeDetectionEnabled: false,
dependencyGraphAvailable: true,
dependencyGraphSupported: true,
});

// The status-store-driven get-changed-stories isn't registered, but the reverse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type BuildServerInstructionsOptions = {
* When true and `changeDetectionEnabled` is false, the workflow falls back to manual lookup
* via `get-stories-by-component` instead of the status-store-driven `get-changed-stories`.
*/
dependencyGraphAvailable?: boolean;
dependencyGraphSupported?: boolean;
reviewEnabled?: boolean;
};

Expand All @@ -22,11 +22,11 @@ export function buildServerInstructions(options: BuildServerInstructionsOptions)

if (options.devEnabled) {
const changeDetection = options.changeDetectionEnabled ?? false;
const graphAvailable = options.dependencyGraphAvailable ?? false;
const graphSupported = options.dependencyGraphSupported ?? false;
const reviewEnabled = options.reviewEnabled ?? false;
const previewStoriesStep = changeDetection
? 'After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs.'
: graphAvailable
: graphSupported
? 'After changing any component or story, call **get-stories-by-component** with the absolute paths of the files you touched to find the stories that render them, then call **preview-stories** to retrieve preview URLs.'
: 'After changing any component or story, call **preview-stories** to retrieve preview URLs.';
sections.push(
Expand Down
83 changes: 83 additions & 0 deletions packages/addon-mcp/src/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import { PassThrough } from 'node:stream';
import { CompositionAuth } from './auth/index.ts';

// Lets us flip `@storybook/addon-review` presence so the review tool's gate can be exercised.
// Keep the real exports (e.g. `normalizeStoryPath`) so unrelated tools still register.
const { mockGetAddonNames } = vi.hoisted(() => ({
mockGetAddonNames: vi.fn(() => [] as string[]),
}));
vi.mock('storybook/internal/common', async (importActual) => ({
...(await importActual<typeof import('storybook/internal/common')>()),
loadMainConfig: vi.fn().mockResolvedValue({}),
getAddonNames: () => mockGetAddonNames(),
}));

Comment on lines +12 to +22
// Test helpers to reduce boilerplate
function createMockIncomingMessage(options: {
method?: string;
Expand Down Expand Up @@ -233,6 +244,45 @@ describe('mcpServerHandler', () => {
};
}

// Initializes the server then asks for `tools/list`, returning the registered tool names.
async function getRegisteredToolNames(mockOptions: any, port: number): Promise<string[]> {
const host = `localhost:${port}`;
const addonOptions = { toolsets: { dev: true, docs: true } };

const initReq = createMockIncomingMessage({
method: 'POST',
headers: { 'content-type': 'application/json', host },
body: createMCPInitializeRequest(),
});
const { response: initResponse } = createMockServerResponse();
await mcpServerHandler({
req: initReq,
res: initResponse,
options: mockOptions,
addonOptions,
compositionAuth: new CompositionAuth(),
});

const listReq = createMockIncomingMessage({
method: 'POST',
headers: { 'content-type': 'application/json', host },
body: { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
});
const { response: listResponse, getResponseData } = createMockServerResponse();
await mcpServerHandler({
req: listReq,
res: listResponse,
options: mockOptions,
addonOptions,
compositionAuth: new CompositionAuth(),
});

const { body } = getResponseData();
const dataLine = body.split('\n').find((line) => line.startsWith('data: '));
const parsed = JSON.parse(dataLine!.replace(/^data: /, '').trim());
return parsed.result.tools.map((t: any) => t.name);
}

it('should initialize MCP server and handle requests', async () => {
const mockOptions = createMockOptions();
const mockReq = createMockIncomingMessage({
Expand Down Expand Up @@ -464,6 +514,39 @@ describe('mcpServerHandler', () => {
expect(toolNames).toContain('get-documentation');
expect(toolNames).toContain('get-documentation-for-story');
});

it('registers get-changed-stories when the changeDetection feature flag is on', async () => {
const mockOptions = createMockOptions({
port: 6009,
presets: {
apply: vi.fn(async (key: string, defaultValue?: any) => {
if (key === 'core') return { disableTelemetry: false };
if (key === 'features') return { changeDetection: true };
return defaultValue;
}),
},
});

const toolNames = await getRegisteredToolNames(mockOptions, 6009);
expect(toolNames).toContain('get-changed-stories');
});

it('registers display-review when changeDetection and @storybook/addon-review are present', async () => {
mockGetAddonNames.mockReturnValue(['@storybook/addon-review']);
const mockOptions = createMockOptions({
port: 6010,
presets: {
apply: vi.fn(async (key: string, defaultValue?: any) => {
if (key === 'core') return { disableTelemetry: false };
if (key === 'features') return { changeDetection: true };
return defaultValue;
}),
},
});

const toolNames = await getRegisteredToolNames(mockOptions, 6010);
expect(toolNames).toContain('display-review');
});
});

describe('getToolsets', () => {
Expand Down
41 changes: 16 additions & 25 deletions packages/addon-mcp/src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,12 @@ import { buffer } from 'node:stream/consumers';
import { collectTelemetry } from './telemetry.ts';
import type { AddonContext, AddonOptionsOutput } from './types.ts';
import { logger } from 'storybook/internal/node-logger';
import { getManifestStatus } from './tools/is-manifest-available.ts';
import { getReviewStatus } from './utils/is-review-available.ts';
import { addRunStoryTestsTool, getAddonVitestConstants } from './tools/run-story-tests.ts';
import { getToolAvailability } from './utils/get-tool-availability.ts';
import { addRunStoryTestsTool } from './tools/run-story-tests.ts';
import { estimateTokens } from './utils/estimate-tokens.ts';
import { isAddonA11yEnabled } from './utils/is-addon-a11y-enabled.ts';
import type { CompositionAuth } from './auth/index.ts';
import { buildServerInstructions } from './instructions/build-server-instructions.ts';
import { DEFAULT_MCP_ENDPOINT } from './constants.ts';
import { isDependencyGraphSupported } from './utils/change-detection.ts';

let transport: HttpTransport<AddonContext> | undefined;
let origin: string | undefined;
Expand All @@ -39,20 +36,14 @@ let a11yEnabled: boolean | undefined;
const initializeMCPServer = async (options: Options, multiSource?: boolean) => {
const core = await options.presets.apply('core', {});
const features = await options.presets.apply('features', {});
// The dependency graph and the change-detection status pipeline are independent in Storybook:
// the graph runs whenever the dev-server has a supporting builder; `features.changeDetection`
// only gates the status pipeline that powers `get-changed-stories`.
const dependencyGraphSupported = await isDependencyGraphSupported();
const changeDetectionEnabled = (features?.changeDetection ?? false) && dependencyGraphSupported;
disableTelemetry = core?.disableTelemetry ?? false;

// Determine tool availability before creating server so instructions can be tailored.
// Reuse the already-resolved `features` so getReviewStatus doesn't re-call
// `presets.apply('features', …)` and risk a different snapshot.
const addonVitestConstants = await getAddonVitestConstants();
const manifestStatus = await getManifestStatus(options);
const reviewStatus = await getReviewStatus(options, { features });
a11yEnabled = await isAddonA11yEnabled(options);
// Shares one source of truth with the browser landing page (see get-tool-availability.ts)
// so the registered tools and the page's enabled/disabled badges can't drift. Reuse the
// already-resolved `features` so it doesn't re-apply the preset and risk a different snapshot.
const availability = await getToolAvailability(options, { features });
a11yEnabled = availability.a11yEnabled;

let server: McpServer<any, AddonContext>;

Expand All @@ -61,11 +52,11 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => {
get instructions() {
return buildServerInstructions({
devEnabled: server?.ctx.custom?.toolsets?.dev ?? true,
testEnabled: (server?.ctx.custom?.toolsets?.test ?? true) && !!addonVitestConstants,
docsEnabled: (server?.ctx.custom?.toolsets?.docs ?? true) && manifestStatus.available,
changeDetectionEnabled,
dependencyGraphAvailable: dependencyGraphSupported,
reviewEnabled: reviewStatus.available,
testEnabled: (server?.ctx.custom?.toolsets?.test ?? true) && availability.testSupported,
docsEnabled: (server?.ctx.custom?.toolsets?.docs ?? true) && availability.docsEnabled,
changeDetectionEnabled: availability.changeDetectionEnabled,
dependencyGraphSupported: availability.dependencyGraphSupported,
reviewEnabled: availability.reviewEnabled,
});
},
capabilities: {
Expand Down Expand Up @@ -93,24 +84,24 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => {
await addPreviewStoriesTool(server);
await addGetUIBuildingInstructionsTool(server);

if (changeDetectionEnabled) {
if (availability.changeDetectionEnabled) {
await addGetChangedStoriesTool(server);
}

// get-stories-by-component only needs the dependency graph, not the status pipeline.
if (dependencyGraphSupported) {
if (availability.dependencyGraphSupported) {
await addGetStoriesByComponentTool(server);
}

if (reviewStatus.available) {
if (availability.reviewEnabled) {
await addDisplayReviewTool(server);
}

// Register test addon tools
await addRunStoryTestsTool(server, { a11yEnabled });

// Only register the additional tools if the component manifest feature is enabled
if (manifestStatus.available) {
if (availability.docsEnabled) {
logger.info('Experimental components manifest feature detected - registering component tools');
const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
await addListAllDocumentationTool(server, contextAwareEnabled);
Expand Down
94 changes: 94 additions & 0 deletions packages/addon-mcp/src/preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { experimental_devServer } from './preset.ts';
import { STORYBOOK_MCP_PROXY_HEADER } from './auth/index.ts';
import * as mcpHandlerModule from './mcp-handler.ts';
import * as runStoryTests from './tools/run-story-tests.ts';
import * as changeDetection from './utils/change-detection.ts';

Comment on lines 5 to 9
describe('experimental_devServer', () => {
let mockApp: any;
Expand Down Expand Up @@ -354,6 +355,99 @@ describe('experimental_devServer', () => {
expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining('<html'));
});

it('should list the change-detection and review tools in the landing page', async () => {
let getHandler: any;
mockApp.get = vi.fn((_path, handler) => {
getHandler = handler;
});

await (experimental_devServer as any)(mockApp, mockOptions);

const mockRes = { writeHead: vi.fn(), end: vi.fn() } as any;
await getHandler({ headers: { accept: 'text/html' } } as any, mockRes);

const html = mockRes.end.mock.calls[0][0] as string;
for (const tool of [
'get-stories-by-component',
'get-changed-stories',
'display-review',
'get-documentation-for-story',
]) {
expect(html).toContain(`<code>${tool}</code>`);
}
// Every placeholder must be substituted — no `{{...}}` may leak to the page.
expect(html).not.toMatch(/\{\{[A-Z_]+\}\}/);
});

it('marks dev tools disabled on the landing page when the dev toolset is turned off', async () => {
// Dependency graph IS supported, so `get-stories-by-component` would otherwise badge
// as enabled — proving the badge now also honors the `dev` toolset being disabled.
vi.spyOn(changeDetection, 'isDependencyGraphSupported').mockResolvedValue(true);

let getHandler: any;
mockApp.get = vi.fn((_path, handler) => {
getHandler = handler;
});

const devOffOptions = {
...mockOptions,
toolsets: { dev: false },
} as unknown as Options;

await (experimental_devServer as any)(mockApp, devOffOptions);

const mockRes = { writeHead: vi.fn(), end: vi.fn() } as any;
await getHandler({ headers: { accept: 'text/html' } } as any, mockRes);

const html = mockRes.end.mock.calls[0][0] as string;

const badgeFor = (tool: string) =>
html.match(
new RegExp(`<code>${tool}</code>\\s*<span class="toolset-status (enabled|disabled)"`),
)?.[1];

for (const tool of ['get-stories-by-component', 'get-changed-stories', 'display-review']) {
expect(badgeFor(tool)).toBe('disabled');
}
expect(html).toContain('The <code>dev</code> toolset is disabled via addon options.');
expect(html).not.toMatch(/\{\{[A-Z_]+\}\}/);
});

it('prompts to enable the manifest feature when manifests exist but the flag is off', async () => {
// Manifests are present on disk, but `componentsManifest` is not enabled — the docs
// toolset is unavailable for a different reason than "no manifests", so the page shows
// the "enable the feature" notice rather than the React-only one.
const manifestsNoFlagOptions = {
...mockOptions,
presets: {
apply: vi.fn(async (key: string) => {
if (key === 'features') {
return { componentsManifest: false };
}
if (key === 'experimental_manifests') {
return { components: { v: 1, components: {} } };
}
return undefined;
}),
},
} as unknown as Options;

let getHandler: any;
mockApp.get = vi.fn((_path, handler) => {
getHandler = handler;
});

await (experimental_devServer as any)(mockApp, manifestsNoFlagOptions);

const mockRes = { writeHead: vi.fn(), end: vi.fn() } as any;
await getHandler({ headers: { accept: 'text/html' } } as any, mockRes);

const html = mockRes.end.mock.calls[0][0] as string;
expect(html).toContain('This toolset requires enabling the component manifest feature.');
expect(html).not.toContain('only supported in React-based setups');
expect(html).not.toMatch(/\{\{[A-Z_]+\}\}/);
});

it('should show Storybook version requirement for addon-vitest and a manual manifest link', async () => {
vi.spyOn(runStoryTests, 'getAddonVitestConstants').mockResolvedValue(undefined);
const manifestEnabledOptions = {
Expand Down
Loading
Loading