Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/violet-rules-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@storybook/addon-mcp": minor
---

Resolve the Storybook story index in-process instead of fetching `/index.json` over HTTP. The addon now reads the dev server's memoised `StoryIndexGenerator` (via the `storyIndexGenerator` preset) and exposes it to the tools through the server context, so the index is always live and HMR-fresh with no loopback request.

This requires Storybook `>= 10.2.0` (where the story index generator was introduced); the `storybook` peer dependency range has been bumped accordingly.
2 changes: 1 addition & 1 deletion packages/addon-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
"peerDependencies": {
"@storybook/addon-vitest": "^0.0.0-0 || ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0",
"storybook": "^0.0.0-0 || ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0"
"storybook": "^0.0.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0"
},
"peerDependenciesMeta": {
"@storybook/addon-vitest": {
Expand Down
22 changes: 21 additions & 1 deletion packages/addon-mcp/src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
addGetStoryDocumentationTool,
type Source,
} from '@storybook/mcp';
import type { Options } from 'storybook/internal/types';
import type { Options, StoryIndex } from 'storybook/internal/types';
import type { StoryIndexGenerator } from 'storybook/internal/core-server';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { buffer } from 'node:stream/consumers';
import { collectTelemetry } from './telemetry.ts';
Expand All @@ -33,13 +34,31 @@ let origin: string | undefined;
let initialize: Promise<McpServer<any, AddonContext>> | undefined;
let disableTelemetry: boolean | undefined;
let a11yEnabled: boolean | undefined;
// Resolves the live story index in-process via the dev server's memoised
// StoryIndexGenerator. Set once during initialization and shared with every tool
// through the addon context, so tools never fetch `/index.json` over HTTP.
let getStoryIndex: (() => Promise<StoryIndex>) | undefined;

const initializeMCPServer = async (options: Options, multiSource?: boolean) => {
const core = await options.presets.apply('core', {});
const features = await options.presets.apply('features', {});
const changeDetectionEnabled = features?.changeDetection ?? false;
disableTelemetry = core?.disableTelemetry ?? false;

// Resolve the dev server's StoryIndexGenerator once (the preset memoises a single
// instance) and expose a thin getIndex() through the context. Requires Storybook
// >= 10.2.0, which the addon declares as a peer dependency.
const storyIndexGenerator =
await options.presets.apply<StoryIndexGenerator | undefined>('storyIndexGenerator');
getStoryIndex = async () => {
if (!storyIndexGenerator) {
throw new Error(
'Storybook story index generator is unavailable. These MCP tools require a running Storybook dev server (Storybook >= 10.2.0).',
);
}
return storyIndexGenerator.getIndex();
};

// 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.
Expand Down Expand Up @@ -168,6 +187,7 @@ export const mcpServerHandler = async ({
endpoint,
toolsets: getToolsets(webRequest, addonOptions),
origin: origin!,
getStoryIndex: getStoryIndex!,
disableTelemetry: disableTelemetry!,
a11yEnabled,
request: webRequest,
Expand Down
14 changes: 7 additions & 7 deletions packages/addon-mcp/src/tools/get-changed-stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { McpServer } from 'tmcp';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
import { addGetChangedStoriesTool } from './get-changed-stories.ts';
import type { AddonContext } from '../types.ts';
import * as fetchStoryIndex from '../utils/fetch-story-index.ts';
import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' };
import { GET_CHANGED_STORIES_TOOL_NAME } from './tool-names.ts';
import type { StoryIndex } from 'storybook/internal/types';
Expand All @@ -18,9 +17,11 @@ vi.mock('storybook/internal/core-server', () => ({

describe('getChangedStoriesTool', () => {
let server: McpServer<any, AddonContext>;
const getStoryIndexMock = vi.fn<() => Promise<StoryIndex>>();
const testContext: AddonContext = {
origin: 'http://localhost:6006',
options: {} as AddonContext['options'],
getStoryIndex: getStoryIndexMock,
disableTelemetry: true,
};

Expand Down Expand Up @@ -58,9 +59,8 @@ describe('getChangedStoriesTool', () => {
);

await addGetChangedStoriesTool(server);
vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue(
smallStoryIndexFixture as unknown as StoryIndex,
);
getStoryIndexMock.mockReset();
getStoryIndexMock.mockResolvedValue(smallStoryIndexFixture as unknown as StoryIndex);
});

async function callTool() {
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('getChangedStoriesTool', () => {
const response = await callTool();
const text = getResultText(response);

expect(fetchStoryIndex.fetchStoryIndex).toHaveBeenCalledWith('http://localhost:6006');
expect(getStoryIndexMock).toHaveBeenCalled();
expect(text).toMatchInlineSnapshot(`
"Detected 3 changed stories (1 new, 1 modified, 1 related).

Expand Down Expand Up @@ -293,11 +293,11 @@ describe('getChangedStoriesTool', () => {
}),
});

const callCountBefore = vi.mocked(fetchStoryIndex.fetchStoryIndex).mock.calls.length;
const callCountBefore = getStoryIndexMock.mock.calls.length;
const response = await callTool();
const text = getResultText(response);

expect(text).toBe('No new, modified, or related stories detected.');
expect(vi.mocked(fetchStoryIndex.fetchStoryIndex).mock.calls.length).toBe(callCountBefore);
expect(getStoryIndexMock.mock.calls.length).toBe(callCountBefore);
});
});
9 changes: 4 additions & 5 deletions packages/addon-mcp/src/tools/get-changed-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { experimental_getStatusStore } from 'storybook/internal/core-server';
import { collectTelemetry } from '../telemetry.ts';
import type { AddonContext } from '../types.ts';
import { errorToMCPContent } from '../utils/errors.ts';
import { fetchStoryIndex } from '../utils/fetch-story-index.ts';
import { GET_CHANGED_STORIES_TOOL_NAME } from './tool-names.ts';

const CHANGE_DETECTION_TYPE = 'storybook/change-detection';
Expand Down Expand Up @@ -59,9 +58,9 @@ export async function addGetChangedStoriesTool(server: McpServer<any, AddonConte
},
async () => {
try {
const { origin, disableTelemetry } = server.ctx.custom ?? {};
if (!origin) {
throw new Error('Origin is required in addon context');
const { getStoryIndex, disableTelemetry } = server.ctx.custom ?? {};
if (!getStoryIndex) {
throw new Error('Story index resolver is required in addon context');
}

const statusStore = experimental_getStatusStore(CHANGE_DETECTION_TYPE);
Expand Down Expand Up @@ -94,7 +93,7 @@ export async function addGetChangedStoriesTool(server: McpServer<any, AddonConte
};
}

const index = await fetchStoryIndex(origin);
const index = await getStoryIndex();
const stories = changedStoriesFromStatusStore.flatMap<ChangedStory>(
({ storyId, value }) => {
const entry = index.entries[storyId];
Expand Down
25 changes: 13 additions & 12 deletions packages/addon-mcp/src/tools/preview-stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import type { AddonContext } from '../types.ts';
import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' };
import monorepoStoryIndexFixture from '../../fixtures/monorepo-story-index.fixture.json' with { type: 'json' };
import * as fetchStoryIndex from '../utils/fetch-story-index.ts';
import { PREVIEW_STORIES_TOOL_NAME } from './tool-names.ts';

vi.mock('storybook/internal/csf', () => ({
Expand All @@ -14,10 +13,11 @@

describe('previewStoriesTool', () => {
let server: McpServer<any, AddonContext>;
let fetchStoryIndexSpy: any;
const getStoryIndexSpy = vi.fn();
const testContext: AddonContext = {
origin: 'http://localhost:6006',
options: {} as any,
getStoryIndex: getStoryIndexSpy,
disableTelemetry: true,
};

Expand Down Expand Up @@ -56,9 +56,9 @@

await addPreviewStoriesTool(server);

// Mock fetchStoryIndex to return the fixture
fetchStoryIndexSpy = vi.spyOn(fetchStoryIndex, 'fetchStoryIndex');
fetchStoryIndexSpy.mockResolvedValue(smallStoryIndexFixture);
// Provide the in-process story index via the addon context.
getStoryIndexSpy.mockReset();
getStoryIndexSpy.mockResolvedValue(smallStoryIndexFixture);
});

it('should return story URL for a valid story', async () => {
Expand Down Expand Up @@ -101,7 +101,7 @@
],
},
});
expect(fetchStoryIndexSpy).toHaveBeenCalledWith('http://localhost:6006');
expect(getStoryIndexSpy).toHaveBeenCalled();
});

it('should return story URL when input uses storyId', async () => {
Expand Down Expand Up @@ -365,6 +365,7 @@
const telemetryContext = {
origin: 'http://localhost:6006',
options: {} as any,
getStoryIndex: getStoryIndexSpy,
disableTelemetry: false,
};

Expand Down Expand Up @@ -440,7 +441,7 @@
});

it('should handle fetch errors gracefully', async () => {
fetchStoryIndexSpy.mockRejectedValue(new Error('Network timeout'));
getStoryIndexSpy.mockRejectedValue(new Error('Network timeout'));

const request = {
jsonrpc: '2.0' as const,
Expand Down Expand Up @@ -649,7 +650,7 @@
});

describe('Windows paths', () => {
const originalCwd = process.cwd;

Check warning on line 653 in packages/addon-mcp/src/tools/preview-stories.test.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(unbound-method)

void`, or consider using an arrow function instead.
const windowsCwd = 'C:\\Users\\test\\project';

beforeEach(() => {
Expand Down Expand Up @@ -710,7 +711,7 @@
],
},
});
expect(fetchStoryIndexSpy).toHaveBeenCalledWith('http://localhost:6006');
expect(getStoryIndexSpy).toHaveBeenCalled();
});

it('should return error message for story not found with Windows path', async () => {
Expand Down Expand Up @@ -791,7 +792,7 @@
it('should match stories when index.json importPath has no leading ./', async () => {
// Simulate monorepo setup where Storybook runs from apps/storybook
// but stories live in packages/ — index.json uses ../../packages/...
fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);
getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);

const request = {
jsonrpc: '2.0' as const,
Expand Down Expand Up @@ -838,7 +839,7 @@
it('should match stories when both index.json importPath and computed path have leading ./ and normalization still works', async () => {
// Simulate running Storybook from root where index.json uses ./stories/...
// The computed relative path also starts with ./stories/... and normalization preserves the match
fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);
getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);

const request = {
jsonrpc: '2.0' as const,
Expand Down Expand Up @@ -884,7 +885,7 @@
it('should match stories when index.json importPath has no ./ and computed path has ./', async () => {
// index.json stores "stories/Utils/Helpers.stories.tsx" (no ./ prefix)
// computed relative path would be "./stories/Utils/Helpers.stories.tsx"
fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);
getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);

const request = {
jsonrpc: '2.0' as const,
Expand Down Expand Up @@ -929,7 +930,7 @@

it('should match stories when path has dot-segments like ./stories/../stories/', async () => {
// dot-segments should be canonicalized: ./stories/../stories/Button.stories.tsx -> ./stories/Button.stories.tsx
fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);
getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture);

const request = {
jsonrpc: '2.0' as const,
Expand Down
8 changes: 5 additions & 3 deletions packages/addon-mcp/src/tools/preview-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import url from 'node:url';
import * as v from 'valibot';
import { collectTelemetry } from '../telemetry.ts';
import { buildArgsParam } from '../utils/build-args-param.ts';
import { fetchStoryIndex } from '../utils/fetch-story-index.ts';
import { findStoryIds } from '../utils/find-story-ids.ts';
import { errorToMCPContent } from '../utils/errors.ts';
import type { AddonContext } from '../types.ts';
Expand Down Expand Up @@ -106,13 +105,16 @@ Always include each returned preview URL in your final user-facing response so u
},
async (input) => {
try {
const { origin, disableTelemetry } = server.ctx.custom ?? {};
const { origin, getStoryIndex, disableTelemetry } = server.ctx.custom ?? {};

if (!origin) {
throw new Error('Origin is required in addon context');
}
if (!getStoryIndex) {
throw new Error('Story index resolver is required in addon context');
}

const index = await fetchStoryIndex(origin);
const index = await getStoryIndex();
const resolvedStories = findStoryIds(index, input.stories);

const structuredResult: PreviewStoriesOutput['stories'] = [];
Expand Down
9 changes: 6 additions & 3 deletions packages/addon-mcp/src/tools/run-story-tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { addRunStoryTestsTool, getAddonVitestConstants } from './run-story-tests.ts';
import type { AddonContext } from '../types.ts';
import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' };
import * as fetchStoryIndex from '../utils/fetch-story-index.ts';
import type { StoryIndex } from 'storybook/internal/types';
import type { TriggerTestRunResponsePayload } from '@storybook/addon-vitest/constants';
import { RUN_STORY_TESTS_TOOL_NAME } from './tool-names.ts';

Expand Down Expand Up @@ -34,6 +34,7 @@

describe('runStoryTestsTool', () => {
let server: McpServer<any, AddonContext>;
const getStoryIndexMock = vi.fn<() => Promise<StoryIndex>>();
let mockChannel: {
on: Mock;
off: Mock;
Expand All @@ -54,6 +55,7 @@
options: {
channel: mockChannel,
} as any,
getStoryIndex: getStoryIndexMock,
disableTelemetry: true,
toolsets: {
dev: true,
Expand Down Expand Up @@ -156,8 +158,9 @@

await addRunStoryTestsTool(server, { a11yEnabled: true });

// Mock fetchStoryIndex to return the fixture
vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue(smallStoryIndexFixture as any);
// Provide the in-process story index via the addon context.
getStoryIndexMock.mockReset();
getStoryIndexMock.mockResolvedValue(smallStoryIndexFixture as any);
});

it('should include visual a11y handling guidance in tool description', async () => {
Expand Down Expand Up @@ -1011,7 +1014,7 @@
});

describe('queue behavior', () => {
const getResponseHandlers = () =>

Check warning on line 1017 in packages/addon-mcp/src/tools/run-story-tests.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Variable 'getResponseHandlers' is declared but never used. Unused variables should start with a '_'.
mockChannel.on.mock.calls
.filter((call) => call[0] === 'storybook/test/trigger-test-run-response')
.map((call) => call[1]);
Expand Down
9 changes: 6 additions & 3 deletions packages/addon-mcp/src/tools/run-story-tests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { McpServer } from 'tmcp';
import { logger } from 'storybook/internal/node-logger';
import * as v from 'valibot';
import { fetchStoryIndex } from '../utils/fetch-story-index.ts';
import { findStoryIds, type FoundStory, type NotFoundStory } from '../utils/find-story-ids.ts';
import { errorToMCPContent } from '../utils/errors.ts';
import { collectTelemetry } from '../telemetry.ts';
Expand Down Expand Up @@ -126,7 +125,7 @@ For visual/design accessibility violations (for example color contrast), ask the
done = await testRunQueue.wait();
const runA11y = input.a11y ?? true;

const { origin, options, disableTelemetry } = server.ctx.custom ?? {};
const { origin, options, getStoryIndex, disableTelemetry } = server.ctx.custom ?? {};

if (!origin) {
throw new Error('Origin is required in addon context');
Expand All @@ -136,6 +135,10 @@ For visual/design accessibility violations (for example color contrast), ask the
throw new Error('Options are required in addon context');
}

if (!getStoryIndex) {
throw new Error('Story index resolver is required in addon context');
}

// Access channel from options (available at runtime even though types don't declare it)
const channel = (options as unknown as { channel: Channel }).channel;
if (!channel) {
Expand All @@ -146,7 +149,7 @@ For visual/design accessibility violations (for example color contrast), ask the
let inputStoryCount = 0;

if (input.stories) {
const index = await fetchStoryIndex(origin);
const index = await getStoryIndex();
const resolvedStories = findStoryIds(index, input.stories);

storyIds = resolvedStories
Expand Down
9 changes: 8 additions & 1 deletion packages/addon-mcp/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as v from 'valibot';
import type { Options } from 'storybook/internal/types';
import type { Options, StoryIndex } from 'storybook/internal/types';
import { GET_TOOL_NAME, LIST_TOOL_NAME, type StorybookContext } from '@storybook/mcp';
import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME } from './tools/tool-names.ts';

Expand Down Expand Up @@ -59,6 +59,13 @@ export type AddonContext = StorybookContext & {
*/
origin: string;

/**
* Resolves the live Storybook story index in-process, backed by the dev
* server's memoised `StoryIndexGenerator`. Provided by the addon so tools
* never have to fetch `/index.json` over loopback HTTP.
*/
getStoryIndex: () => Promise<StoryIndex>;

/**
* Whether telemetry collection is disabled.
*/
Expand Down
Loading
Loading