Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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**. The page and the MCP server now derive tool availability from a single shared helper (`getToolAvailability`) so the rendered badges can't drift from the tools that are actually registered.
40 changes: 19 additions & 21 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,21 @@ 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 {
dependencyGraphSupported,
changeDetectionEnabled,
reviewAvailable,
docsAvailable,
testSupported,
a11yEnabled: a11yAvailable,
} = await getToolAvailability(options, { features });
a11yEnabled = a11yAvailable;

let server: McpServer<any, AddonContext>;

Expand All @@ -61,11 +59,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,
testEnabled: (server?.ctx.custom?.toolsets?.test ?? true) && testSupported,
docsEnabled: (server?.ctx.custom?.toolsets?.docs ?? true) && docsAvailable,
changeDetectionEnabled,
dependencyGraphAvailable: dependencyGraphSupported,
reviewEnabled: reviewStatus.available,
reviewEnabled: reviewAvailable,
Comment thread
yannbf marked this conversation as resolved.
Outdated
});
},
capabilities: {
Expand Down Expand Up @@ -102,15 +100,15 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => {
await addGetStoriesByComponentTool(server);
}

if (reviewStatus.available) {
if (reviewAvailable) {
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 (docsAvailable) {
logger.info('Experimental components manifest feature detected - registering component tools');
const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
await addListAllDocumentationTool(server, contextAwareEnabled);
Expand Down
59 changes: 59 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,64 @@ 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('should show Storybook version requirement for addon-vitest and a manual manifest link', async () => {
vi.spyOn(runStoryTests, 'getAddonVitestConstants').mockResolvedValue(undefined);
const manifestEnabledOptions = {
Expand Down
59 changes: 47 additions & 12 deletions packages/addon-mcp/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { mcpServerHandler } from './mcp-handler.ts';
import type { PresetPropertyFn, StorybookConfigRaw } from 'storybook/internal/types';
import { AddonOptions, type AddonOptionsInput } from './types.ts';
import * as v from 'valibot';
import { getManifestStatus } from './tools/is-manifest-available.ts';
import { getAddonVitestConstants } from './tools/run-story-tests.ts';
import { isAddonA11yEnabled } from './utils/is-addon-a11y-enabled.ts';
import { getToolAvailability } from './utils/get-tool-availability.ts';
import htmlTemplate from './template.html';
import path from 'node:path';
import {
Expand Down Expand Up @@ -110,13 +108,22 @@ export const experimental_devServer: PresetPropertyFn<
});
});

const manifestStatus = await getManifestStatus(options);
const addonVitestConstants = await getAddonVitestConstants();
const a11yEnabled = await isAddonA11yEnabled(options);
// Same gates the MCP server uses to register these tools, so the page can't
// claim a tool is available when it isn't (and vice versa).
const {
dependencyGraphSupported,
changeDetectionEnabled,
reviewAvailable,
docsAvailable,
docsHasManifests,
docsFeatureEnabled,
testSupported,
a11yEnabled,
} = await getToolAvailability(options);

const isDevEnabled = addonOptions.toolsets?.dev ?? true;
const isDocsEnabled = manifestStatus.available && (addonOptions.toolsets?.docs ?? true);
const isTestEnabled = !!addonVitestConstants && (addonOptions.toolsets?.test ?? true);
const isDocsEnabled = docsAvailable && (addonOptions.toolsets?.docs ?? true);
const isTestEnabled = testSupported && (addonOptions.toolsets?.test ?? true);

app!.get(endpoint, (req, res) => {
if (!req.headers['accept']?.includes('text/html')) {
Expand All @@ -138,19 +145,19 @@ export const experimental_devServer: PresetPropertyFn<
res.writeHead(200, { 'Content-Type': 'text/html' });

let docsNotice = '';
if (!manifestStatus.hasManifests) {
if (!docsHasManifests) {
docsNotice = `<div class="toolset-notice">
This toolset is only supported in React-based setups.
</div>`;
} else if (!manifestStatus.hasFeatureFlag) {
} else if (!docsFeatureEnabled) {
docsNotice = `<div class="toolset-notice">
This toolset requires enabling the component manifest feature.
<a target="_blank" href="https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#docs-tools-experimental">Learn how to enable it</a>
</div>`;
}

const testNoticeLines = [
!addonVitestConstants &&
!testSupported &&
`This toolset requires Storybook 10.3.0+ with <code>@storybook/addon-vitest</code>. <a target="_blank" href="https://storybook.js.org/docs/writing-tests/test-addon">Learn how to set it up</a>`,
!a11yEnabled &&
`Add <code>@storybook/addon-a11y</code> for accessibility testing. <a target="_blank" href="https://storybook.js.org/docs/writing-tests/accessibility-testing">Learn more</a>`,
Expand All @@ -163,15 +170,43 @@ export const experimental_devServer: PresetPropertyFn<
? ' <span class="toolset-status enabled">+ accessibility</span>'
: '';

// `get-stories-by-component`, `get-changed-stories`, and `display-review` are gated
// independently of the `dev` toolset (they need the dependency graph, the change-detection
// feature flag, and `@storybook/addon-review` respectively), so each shows its own badge.
// When the whole `dev` toolset is turned off via addon options every dev tool is
// disabled regardless of its own gate, so explain that instead of the per-tool reasons.
const devNoticeLines = !isDevEnabled
? [`The <code>dev</code> toolset is disabled via addon options.`]
: [
!dependencyGraphSupported &&
`<code>get-stories-by-component</code> requires a dev server with a builder that supports the dependency graph (e.g. Vite).`,
!changeDetectionEnabled &&
`<code>get-changed-stories</code> requires enabling the <code>changeDetection</code> feature flag.`,
!reviewAvailable &&
`<code>display-review</code> requires the <code>changeDetection</code> feature flag and <code>@storybook/addon-review</code>.`,
].filter(Boolean);
const devNotice = devNoticeLines.length
? `<div class="toolset-notice">${devNoticeLines.join('<br>')}</div>`
: '';

const statusWord = (enabled: boolean) => (enabled ? 'enabled' : 'disabled');

const html = htmlTemplate
.replaceAll('{{DEV_STATUS}}', isDevEnabled ? 'enabled' : 'disabled')
.replaceAll(
'{{STORIES_BY_COMPONENT_STATUS}}',
statusWord(isDevEnabled && dependencyGraphSupported),
)
.replaceAll('{{CHANGE_DETECTION_STATUS}}', statusWord(isDevEnabled && changeDetectionEnabled))
.replaceAll('{{REVIEW_STATUS}}', statusWord(isDevEnabled && reviewAvailable))
.replace('{{DEV_NOTICE}}', devNotice)
Comment thread
yannbf marked this conversation as resolved.
.replaceAll('{{DOCS_STATUS}}', isDocsEnabled ? 'enabled' : 'disabled')
.replace('{{DOCS_NOTICE}}', docsNotice)
.replaceAll('{{TEST_STATUS}}', isTestEnabled ? 'enabled' : 'disabled')
.replace('{{TEST_NOTICE}}', testNotice)
.replace(
'{{MANIFEST_DEBUGGER_LINK}}',
manifestStatus.available
docsAvailable
? '<p>View the <a href="/manifests/components.html">component manifest debugger</a>.</p>'
: '',
)
Expand Down
18 changes: 18 additions & 0 deletions packages/addon-mcp/src/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,24 @@ <h3>Available Toolsets</h3>
<ul class="toolset-tools">
<li><code>preview-stories</code></li>
<li><code>get-storybook-story-instructions</code></li>
<li>
<code>get-changed-stories</code>
<span class="toolset-status {{CHANGE_DETECTION_STATUS}}"
>{{CHANGE_DETECTION_STATUS}}</span
>
</li>
<li>
<code>get-stories-by-component</code>
<span class="toolset-status {{STORIES_BY_COMPONENT_STATUS}}"
>{{STORIES_BY_COMPONENT_STATUS}}</span
>
</li>
<li>
<code>display-review</code>
<span class="toolset-status {{REVIEW_STATUS}}">{{REVIEW_STATUS}}</span>
</li>
</ul>
{{DEV_NOTICE}}
</div>

<div class="toolset">
Expand All @@ -216,6 +233,7 @@ <h3>Available Toolsets</h3>
<ul class="toolset-tools">
<li><code>list-all-documentation</code></li>
<li><code>get-documentation</code></li>
<li><code>get-documentation-for-story</code></li>
</ul>
{{DOCS_NOTICE}}
</div>
Expand Down
77 changes: 77 additions & 0 deletions packages/addon-mcp/src/utils/get-tool-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Options } from 'storybook/internal/types';
import { isDependencyGraphSupported } from './change-detection.ts';
import { getReviewStatus } from './is-review-available.ts';
import { getManifestStatus } from '../tools/is-manifest-available.ts';
import { getAddonVitestConstants } from '../tools/run-story-tests.ts';
import { isAddonA11yEnabled } from './is-addon-a11y-enabled.ts';

export interface ToolAvailability {
/** Dev-server builder supports the dependency-graph API. Gates `get-stories-by-component`. */
dependencyGraphSupported: boolean;
Comment on lines +9 to +10
/** The `changeDetection` feature flag is enabled. Gates `get-changed-stories`. */
changeDetectionEnabled: boolean;
/** `changeDetection` flag + `@storybook/addon-review` are both present. Gates `display-review`. */
reviewAvailable: boolean;
/** Component-manifest feature is on AND manifests were found. Gates the `docs` toolset. */
docsAvailable: boolean;
/** Any component manifests were found (drives the docs "why disabled" copy). */
docsHasManifests: boolean;
/** The component-manifest feature flag is enabled (drives the docs "why disabled" copy). */
docsFeatureEnabled: boolean;
/** `@storybook/addon-vitest` is installed. Gates the `test` toolset (`run-story-tests`). */
testSupported: boolean;
/** `@storybook/addon-a11y` is enabled. Gates the accessibility sub-feature of `run-story-tests`. */
a11yEnabled: boolean;
}

export interface GetToolAvailabilityOptions {
/**
* Pre-resolved `features` preset. Pass it to avoid re-applying the preset and
* risking a different snapshot than the caller already resolved.
*/
features?: { changeDetection?: boolean } | undefined;
}

/**
* Single source of truth for the runtime gates that decide whether each tool is
* registered (and how the landing page badges it).
*
* Every dynamic gate lives here — the dependency graph, the change-detection
* pipeline, review, the component manifest (docs), addon-vitest (test) and the
* accessibility sub-feature — so the MCP server (which registers the tools) and
* the browser landing page (which shows enabled/disabled badges) can never drift
* apart. Add new gates here rather than computing them ad-hoc at a call site.
*/
export async function getToolAvailability(
Comment thread
yannbf marked this conversation as resolved.
options: Options,
{ features }: GetToolAvailabilityOptions = {},
): Promise<ToolAvailability> {
const resolvedFeatures =
features ??
((await options.presets.apply('features', {})) as { changeDetection?: boolean } | undefined);

const [
dependencyGraphSupported,
reviewStatus,
manifestStatus,
addonVitestConstants,
a11yEnabled,
] = await Promise.all([
isDependencyGraphSupported(),
getReviewStatus(options, { features: resolvedFeatures }),
getManifestStatus(options),
getAddonVitestConstants(),
isAddonA11yEnabled(options),
]);

return {
dependencyGraphSupported,
changeDetectionEnabled: resolvedFeatures?.changeDetection ?? false,
reviewAvailable: reviewStatus.available,
docsAvailable: manifestStatus.available,
docsHasManifests: manifestStatus.hasManifests,
docsFeatureEnabled: manifestStatus.hasFeatureFlag,
testSupported: !!addonVitestConstants,
a11yEnabled,
};
}
Loading