diff --git a/code/addons/mcp/package.json b/code/addons/mcp/package.json index 2c6461097b63..2b9fb73a9584 100644 --- a/code/addons/mcp/package.json +++ b/code/addons/mcp/package.json @@ -39,7 +39,6 @@ "@storybook/mcp": "workspace:*", "@tmcp/adapter-valibot": "^0.1.5", "@tmcp/transport-http": "^0.8.5", - "picoquery": "^2.5.0", "tmcp": "^1.19.4", "valibot": "^1.4.0" }, diff --git a/code/addons/mcp/src/tools/preview-stories.test.ts b/code/addons/mcp/src/tools/preview-stories.test.ts deleted file mode 100644 index f863a75bc918..000000000000 --- a/code/addons/mcp/src/tools/preview-stories.test.ts +++ /dev/null @@ -1,1076 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { McpServer } from 'tmcp'; -import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; -import { addPreviewStoriesTool, getPreviewStoriesToolDescription } from './preview-stories.ts'; -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 getStoryIndexModule from '../utils/get-story-index.ts'; -import { PREVIEW_STORIES_TOOL_NAME } from './tool-names.ts'; - -vi.mock('storybook/internal/csf', () => ({ - storyNameFromExport: (exportName: string) => exportName, -})); - -describe('previewStoriesTool', () => { - let server: McpServer; - let getStoryIndexSpy: any; - const testContext: AddonContext = { - origin: 'http://localhost:6006', - options: {} as any, - disableTelemetry: true, - }; - - beforeEach(async () => { - const adapter = new ValibotJsonSchemaAdapter(); - server = new McpServer( - { - name: 'test-server', - version: '1.0.0', - description: 'Test server for preview-stories tool', - }, - { - adapter, - capabilities: { - tools: { listChanged: true }, - }, - } - ).withContext(); - - // initialize test session - await server.receive( - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0.0' }, - }, - }, - { - sessionId: 'test-session', - } - ); - - await addPreviewStoriesTool(server); - - // Mock getStoryIndex to return the fixture - getStoryIndexSpy = vi.spyOn(getStoryIndexModule, 'getStoryIndex'); - getStoryIndexSpy.mockResolvedValue(smallStoryIndexFixture); - }); - - it('should return story URL for a valid story', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - }, - }); - expect(getStoryIndexSpy).toHaveBeenCalledWith(testContext.options); - }); - - it('should return story URL when input uses storyId', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [{ storyId: 'button--primary' }], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - }, - }); - }); - - it('should return story URLs for multiple stories', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { - exportName: 'Secondary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { - exportName: 'Default', - absoluteStoryPath: `${process.cwd()}/src/Input.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--secondary', - }, - { - type: 'text', - text: 'http://localhost:6006/?path=/story/input--default', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - { - title: 'Button', - name: 'Secondary', - previewUrl: 'http://localhost:6006/?path=/story/button--secondary', - }, - { - title: 'Input', - name: 'Default', - previewUrl: 'http://localhost:6006/?path=/story/input--default', - }, - ], - }, - }); - }); - - it('should preserve output order for mixed found and not-found inputs', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { storyId: 'button--does-not-exist' }, - { storyId: 'button--primary' }, - { - exportName: 'Missing', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { storyId: 'input--default' }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.content).toEqual([ - { - type: 'text', - text: 'No story found for story ID "button--does-not-exist"', - }, - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - { - type: 'text', - text: `No story found for export name "Missing" with absolute file path "${process.cwd()}/src/Button.stories.tsx" (did you forget to pass the explicit story name?)`, - }, - { - type: 'text', - text: 'http://localhost:6006/?path=/story/input--default', - }, - ]); - }); - - it('should return error message for story not found', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'NonExistent', - absoluteStoryPath: `${process.cwd()}/src/NonExistent.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.content[0].type).toBe('text'); - expect(response.result?.content[0].text).toContain('No story found'); - expect(response.result?.content[0].text).toContain('NonExistent'); - expect(response.result?.content[0].text).toContain( - 'did you forget to pass the explicit story name?' - ); - }); - - it('should handle explicit story names', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - explicitStoryName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - }, - }); - }); - - it('should not include "forgot explicit story name" hint when explicitStoryName is provided', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'NonExistent', - explicitStoryName: 'NonExistent', - absoluteStoryPath: `${process.cwd()}/src/NonExistent.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.content[0].text).toContain('No story found'); - expect(response.result?.content[0].text).not.toContain( - 'did you forget to pass the explicit story name?' - ); - }); - - it('should collect telemetry when enabled', async () => { - const { telemetry } = await import('storybook/internal/telemetry'); - - const telemetryContext = { - origin: 'http://localhost:6006', - options: {} as any, - disableTelemetry: false, - }; - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ], - }, - }, - }; - - await server.receive(request, { - sessionId: 'test-session', - custom: telemetryContext, - }); - - expect(telemetry).toHaveBeenCalledWith( - 'addon-mcp', - expect.objectContaining({ - event: 'tool:previewStories', - mcpSessionId: 'test-session', - toolset: 'dev', - inputStoryCount: 1, - outputStoryCount: 1, - }) - ); - }); - - it('should handle missing origin in context', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: { - options: {} as any, - disableTelemetry: true, - } as any, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Origin is required in addon context', - }, - ], - isError: true, - }); - }); - - it('should handle fetch errors gracefully', async () => { - getStoryIndexSpy.mockRejectedValue(new Error('Network timeout')); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Network timeout', - }, - ], - isError: true, - }); - }); - - it('should include props as args query param in URL', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - props: { - label: 'Custom Label', - disabled: true, - }, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.structuredContent?.stories[0]).toEqual({ - title: 'Button', - name: 'Primary', - previewUrl: - 'http://localhost:6006/?path=/story/button--primary&args=label:Custom+Label;disabled:!true', - }); - }); - - it('should include globals query param in URL', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - globals: { - theme: 'dark', - locale: 'fr', - }, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.structuredContent?.stories[0]).toEqual({ - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary&globals=theme:dark;locale:fr', - }); - }); - - it('should include props as args query param in URL when using storyId', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - storyId: 'button--primary', - props: { - label: 'Custom Label', - disabled: true, - }, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.structuredContent?.stories[0]).toEqual({ - title: 'Button', - name: 'Primary', - previewUrl: - 'http://localhost:6006/?path=/story/button--primary&args=label:Custom+Label;disabled:!true', - }); - }); - - it('should include globals query param in URL when using storyId', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - storyId: 'button--primary', - globals: { - theme: 'dark', - locale: 'fr', - }, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.structuredContent?.stories[0]).toEqual({ - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary&globals=theme:dark;locale:fr', - }); - }); - - it('should include both props and globals query params in URL', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - props: { - label: 'Dark Mode Button', - }, - globals: { - theme: 'dark', - }, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.structuredContent?.stories[0]).toEqual({ - title: 'Button', - name: 'Primary', - previewUrl: - 'http://localhost:6006/?path=/story/button--primary&args=label:Dark+Mode+Button&globals=theme:dark', - }); - }); - - describe('Windows paths', () => { - const originalCwd = process.cwd; - const windowsCwd = 'C:\\Users\\test\\project'; - - beforeEach(() => { - // Mock process.cwd to return a Windows path - Object.defineProperty(process, 'cwd', { - value: () => windowsCwd, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - // Restore original process.cwd - Object.defineProperty(process, 'cwd', { - value: originalCwd, - writable: true, - configurable: true, - }); - }); - - it('should return story URL for a valid story with Windows absolute path', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: 'C:\\Users\\test\\project\\src\\Button.stories.tsx', - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - }, - }); - expect(getStoryIndexSpy).toHaveBeenCalledWith(testContext.options); - }); - - it('should return error message for story not found with Windows path', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'NonExistent', - absoluteStoryPath: 'C:\\Users\\test\\project\\src\\NonExistent.stories.tsx', - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.content[0].type).toBe('text'); - expect(response.result?.content[0].text).toContain('No story found'); - expect(response.result?.content[0].text).toContain('NonExistent'); - expect(response.result?.content[0].text).toContain( - 'did you forget to pass the explicit story name?' - ); - }); - - it('should handle Windows path with mixed forward and backslashes', async () => { - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: 'C:/Users/test/project/src/Button.stories.tsx', - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', - }, - ], - }, - }); - }); - }); - - describe('review-enabled sessions', () => { - // Registers the tool on a fresh server with the review feature enabled, - // so the display-review recovery nudge paths can be exercised. - async function createReviewEnabledServer() { - const adapter = new ValibotJsonSchemaAdapter(); - const reviewServer = new McpServer( - { - name: 'test-server-review', - version: '1.0.0', - description: 'Test server for preview-stories with review enabled', - }, - { adapter, capabilities: { tools: { listChanged: true } } } - ).withContext(); - - await reviewServer.receive( - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0.0' }, - }, - }, - { sessionId: 'test-session' } - ); - - await addPreviewStoriesTool(reviewServer, undefined, { reviewEnabled: true }); - return reviewServer; - } - - async function callToolOn( - targetServer: McpServer, - stories: Array> - ) { - return targetServer.receive( - { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { name: PREVIEW_STORIES_TOOL_NAME, arguments: { stories } }, - }, - { sessionId: 'test-session', custom: testContext } - ); - } - - it('appends the display-review recovery nudge when review is enabled and a URL resolved', async () => { - const response = await callToolOn(await createReviewEnabledServer(), [ - { storyId: 'button--primary' }, - ]); - - const texts = response.result?.content.map((block: { text: string }) => block.text); - expect(texts?.[0]).toBe('http://localhost:6006/?path=/story/button--primary'); - expect(texts?.at(-1)).toContain('publish the review with **display-review**'); - expect(texts?.at(-1)).toContain('available in this session'); - }); - - it('omits the nudge when review is enabled but no story resolved to a URL', async () => { - const response = await callToolOn(await createReviewEnabledServer(), [ - { storyId: 'does-not--exist' }, - ]); - - const texts = response.result?.content.map((block: { text: string }) => block.text); - expect(texts?.join('\n')).not.toContain('display-review'); - }); - - it('omits the nudge and all display-review mentions when review is disabled', async () => { - // The default `server` from beforeEach registers the tool without - // reviewEnabled — display-review is not registered in such sessions, - // so neither the result nor the description may point at it. - const response = await server.receive( - { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { stories: [{ storyId: 'button--primary' }] }, - }, - }, - { sessionId: 'test-session', custom: testContext } - ); - - const texts = response.result?.content.map((block: { text: string }) => block.text); - expect(texts?.join('\n')).not.toContain('display-review'); - expect(getPreviewStoriesToolDescription()).not.toContain('display-review'); - }); - - it('frames the tool as mid-loop only in the review-enabled description', () => { - const description = getPreviewStoriesToolDescription({ reviewEnabled: true }); - expect(description).toContain('while iterating on a specific story'); - expect(description).toContain('publish a curated review with display-review'); - expect(description).toContain('changedFiles: []'); - // No "include the URLs in your final response" default and no hedged - // availability clause — both sanctioned preview links as an ending. - expect(description).not.toContain('final user-facing response'); - expect(description).not.toMatch(/and the display-review tool is available/); - }); - }); - - describe('Monorepo path resolution', () => { - 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/... - getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/../../packages/design-system/components/Button/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/design-system-components-button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'Design System / Components / Button', - name: 'Primary', - previewUrl: - 'http://localhost:6006/?path=/story/design-system-components-button--primary', - }, - ], - }, - }); - }); - - 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 - getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/stories/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/app-stories-button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'App / Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/app-stories-button--primary', - }, - ], - }, - }); - }); - - 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" - getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Default', - absoluteStoryPath: `${process.cwd()}/stories/Utils/Helpers.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/utils-helpers--default', - }, - ], - structuredContent: { - stories: [ - { - title: 'Utils / Helpers', - name: 'Default', - previewUrl: 'http://localhost:6006/?path=/story/utils-helpers--default', - }, - ], - }, - }); - }); - - 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 - getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: PREVIEW_STORIES_TOOL_NAME, - arguments: { - stories: [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/stories/../stories/Button.stories.tsx`, - }, - ], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result).toEqual({ - content: [ - { - type: 'text', - text: 'http://localhost:6006/?path=/story/app-stories-button--primary', - }, - ], - structuredContent: { - stories: [ - { - title: 'App / Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/app-stories-button--primary', - }, - ], - }, - }); - }); - }); -}); diff --git a/code/addons/mcp/src/tools/preview-stories.ts b/code/addons/mcp/src/tools/preview-stories.ts index ab9f0ed9398e..21d1d8b6bebc 100644 --- a/code/addons/mcp/src/tools/preview-stories.ts +++ b/code/addons/mcp/src/tools/preview-stories.ts @@ -1,94 +1,21 @@ -import type { McpServer } from 'tmcp'; import url from 'node:url'; -import * as v from 'valibot'; -import { collectTelemetry } from '../telemetry.ts'; -import { buildArgsParam } from '../utils/build-args-param.ts'; -import { getStoryIndex } from '../utils/get-story-index.ts'; -import { findStoryIds } from '../utils/find-story-ids.ts'; -import { errorToMCPContent } from '../utils/errors.ts'; +import fs from 'node:fs/promises'; + +import type { McpServer } from 'tmcp'; + import type { AddonContext } from '../types.ts'; -import { StoryInput, StoryInputArray } from '../types.ts'; import appTemplate from './preview-stories/preview-stories-app-template.html'; -import fs from 'node:fs/promises'; import { PREVIEW_STORIES_TOOL_NAME } from './tool-names.ts'; export const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`; -// With review enabled this is strictly a mid-loop tool: no "include the URLs -// in your final response" default (that sanctioned preview links as the -// ending of visual work) and no hedging about display-review's availability -// (a hedged "when available" clause let an agent that wrongly believed the -// review tool was missing treat raw links as a sanctioned fallback). When -// review is disabled the tool is not registered, so the description must not -// mention it at all. -export function getPreviewStoriesToolDescription({ - reviewEnabled = false, -}: { reviewEnabled?: boolean } = {}): string { - if (!reviewEnabled) { - return `Use this tool to get one or more Storybook preview URLs. -Call it after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — no exceptions. A shared file has no stories of its own: preview the stories of the components that consume it. -Include each returned preview URL in your final user-facing response so users can open them directly.`; - } - - return `Use this tool to get Storybook preview URLs while iterating on a specific story, or when the user asks for a direct link to one. -Do not end visual work or browse requests with these links — publish a curated review with display-review instead (passing changedFiles: [] when no code changed) and link that.`; -} - -export const PreviewStoriesInput = v.object({ - stories: v.pipe( - StoryInputArray, - v.description( - `Stories to preview. -Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery. -Use { storyId } when IDs were discovered from documentation tools. -Use { absoluteStoryPath + exportName } only when you're already working in a specific .stories.* file and already have that context.` - ) - ), -}); - -export const PreviewStoriesOutput = v.object({ - stories: v.array( - v.union([ - v.object({ - title: v.string(), - name: v.string(), - previewUrl: v.pipe( - v.string(), - v.description( - 'Direct URL to open the story preview. Include this URL in the final user-facing response so users can open it directly — unless a curated review page is being published via display-review, in which case link the review page instead of listing individual URLs.' - ) - ), - }), - v.object({ - input: StoryInput, - error: v.string(), - }), - ]) - ), -}); - -export type PreviewStoriesInput = v.InferOutput; -export type PreviewStoriesOutput = v.InferOutput; - -export function getPreviewStoriesToolMetadata({ - reviewEnabled = false, -}: { reviewEnabled?: boolean } = {}) { - return { - name: PREVIEW_STORIES_TOOL_NAME, - title: 'Get story preview URLs', - description: getPreviewStoriesToolDescription({ reviewEnabled }), - schema: PreviewStoriesInput, - outputSchema: PreviewStoriesOutput, - _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } }, - }; -} - -export async function addPreviewStoriesTool( - server: McpServer, - enabled: Parameters['tool']>[0]['enabled'] = () => - server.ctx.custom?.toolsets?.dev ?? true, - { reviewEnabled = false }: { reviewEnabled?: boolean } = {} -) { +/** + * Serves the MCP app that renders story previews inline in the client. + * + * The app reads the tool result's `structuredContent`, so it is bound to the preview tool's output + * contract rather than to its implementation. + */ +export async function addPreviewStoriesResource(server: McpServer) { const previewStoryAppScript = await fs.readFile( url.fileURLToPath( import.meta.resolve('@storybook/addon-mcp/internal/preview-stories-app-script') @@ -130,109 +57,4 @@ export async function addPreviewStoriesTool( }; } ); - - server.tool( - { - ...getPreviewStoriesToolMetadata({ reviewEnabled }), - enabled, - }, - async (input) => { - try { - const { origin, options, disableTelemetry } = server.ctx.custom ?? {}; - - if (!origin) { - throw new Error('Origin is required in addon context'); - } - if (!options) { - throw new Error('Storybook options are required in addon context'); - } - - const index = await getStoryIndex(options); - const resolvedStories = findStoryIds(index, input.stories); - - const structuredResult: PreviewStoriesOutput['stories'] = []; - const textResult: string[] = []; - - for (const story of resolvedStories) { - if ('errorMessage' in story) { - structuredResult.push({ - input: story.input, - error: story.errorMessage, - }); - textResult.push(story.errorMessage); - continue; - } - - const indexEntry = index.entries[story.id]; - if (!indexEntry) { - structuredResult.push({ - input: story.input, - error: `No story found for story ID "${story.id}"`, - }); - textResult.push(`No story found for story ID "${story.id}"`); - continue; - } - - let previewUrl = `${origin}/?path=/story/${story.id}`; - - // Add props as args query param if provided - const argsParam = buildArgsParam(story.input.props ?? {}); - if (argsParam) { - previewUrl += `&args=${argsParam}`; - } - - // Add globals query param if provided - const globalsParam = buildArgsParam(story.input.globals ?? {}); - if (globalsParam) { - previewUrl += `&globals=${globalsParam}`; - } - - structuredResult.push({ - title: indexEntry.title, - name: indexEntry.name, - previewUrl, - }); - textResult.push(previewUrl); - } - - if (!disableTelemetry) { - await collectTelemetry({ - event: 'tool:previewStories', - server, - toolset: 'dev', - inputStoryCount: input.stories.length, - outputStoryCount: structuredResult.length, - }); - } - - const content: Array<{ type: 'text'; text: string }> = textResult.map((text) => ({ - type: 'text', - text, - })); - - // Recovery nudge for the review exit ramp: agents that skip - // display-review (observed: an agent wrongly claimed the tool was - // not exposed) end visual work on exactly this call, so the result - // itself must contradict that belief while there is still a step - // left to recover in. Only when at least one URL resolved — an - // all-error result has nothing to curate into a review. - const resolvedAnyStory = structuredResult.some((story) => 'previewUrl' in story); - if (reviewEnabled && resolvedAnyStory) { - content.push({ - type: 'text', - text: `These preview links are for iterating or sharing a specific story — they are not how visual work or a browse request ends. The display-review tool is available in this session: if you are finishing visually observable work or showing a set of stories, publish the review with **display-review** and link that instead.`, - }); - } - - return { - content, - structuredContent: { - stories: structuredResult, - }, - }; - } catch (error) { - return errorToMCPContent(error); - } - } - ); } diff --git a/code/addons/mcp/src/tools/preview-stories/preview-stories-app-script.ts b/code/addons/mcp/src/tools/preview-stories/preview-stories-app-script.ts index 8464bcd41df2..b24ca5c06527 100644 --- a/code/addons/mcp/src/tools/preview-stories/preview-stories-app-script.ts +++ b/code/addons/mcp/src/tools/preview-stories/preview-stories-app-script.ts @@ -6,7 +6,7 @@ const LATEST_PROTOCOL_VERSION = '2025-11-21'; import { MCP_APP_SIZE_CHANGED_EVENT, MCP_APP_PARAM } from '../../constants.ts'; -import type { PreviewStoriesOutput } from '../preview-stories.ts'; +import type { PreviewStoriesOutput } from 'storybook/internal/core-server'; import pkg from '../../../package.json' with { type: 'json' }; interface McpUiHostStyles { diff --git a/code/addons/mcp/src/tools/tool-registry.test.ts b/code/addons/mcp/src/tools/tool-registry.test.ts index 35ef15624032..a6756f250ac8 100644 --- a/code/addons/mcp/src/tools/tool-registry.test.ts +++ b/code/addons/mcp/src/tools/tool-registry.test.ts @@ -4,7 +4,12 @@ * onto the MCP `isError` flag is the generic unwrap's contract, pinned in `toolset-tools.test.ts`. */ -import { clearToolsetRegistry, defineToolset, registerToolset } from 'storybook/open-service'; +import { + clearToolsetRegistry, + defineToolset, + registerToolset, + type ToolsetMethodDescription, +} from 'storybook/open-service'; import * as v from 'valibot'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -19,21 +24,28 @@ vi.mock('storybook/internal/node-logger', () => ({ logger: { error: loggerError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); +/** Every MCP tool currently served from a core toolset. */ +const TOOLSET_BACKED = ['preview-stories', 'get-changed-stories', 'get-stories-by-component']; + /** Registers a stub `stories` toolset whose metadata resolution throws the given error. */ function registerStoriesToolsetThrowing(error: Error) { + const stubMethod = (description: ToolsetMethodDescription) => ({ + schema: v.object({}), + description, + handler: async () => ({ ok: true, data: {}, markdown: '' }), + }); + registerToolset( defineToolset({ id: 'stories', description: 'stub', telemetryGroup: 'dev', methods: { - changed: { - schema: v.object({}), - description: () => { - throw error; - }, - handler: async () => ({ ok: true, data: {}, markdown: '' }), - }, + preview: stubMethod(() => { + throw error; + }), + changed: stubMethod('changed'), + findByComponent: stubMethod('find by component'), }, }) as any ); @@ -53,10 +65,15 @@ function makeServer() { } describe('a broken tool row', () => { - // The availability gate claims change detection while the `stories` toolset never registered — - // the wiring bug this containment exists for. + // Every gate claims support while the `stories` toolset never registered — the wiring bug this + // containment exists for. const context = { - availability: { changeDetectionEnabled: true, docsEnabled: true, a11yEnabled: false }, + availability: { + changeDetectionEnabled: true, + moduleGraphSupported: true, + docsEnabled: true, + a11yEnabled: false, + }, } as never; beforeEach(() => { @@ -69,20 +86,23 @@ describe('a broken tool row', () => { await expect(registerAddonMcpTools(server, context)).resolves.toBeUndefined(); - expect([...tools.keys()]).not.toContain('get-changed-stories'); + expect([...tools.keys()]).toEqual(expect.not.arrayContaining(TOOLSET_BACKED)); expect([...tools.keys()]).toEqual( - expect.arrayContaining(['preview-stories', 'list-all-documentation', 'get-documentation']) + expect.arrayContaining(['list-all-documentation', 'get-documentation']) ); - expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('get-changed-stories')); + for (const name of TOOLSET_BACKED) { + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining(name)); + } }); it('is dropped from the storybook ai metadata instead of failing the build', () => { - const metadata = getAddonToolMetadata(context); - const names = metadata.map((tool) => tool.name); + const names = getAddonToolMetadata(context).map((tool) => tool.name); - expect(names).not.toContain('get-changed-stories'); - expect(names).toEqual(expect.arrayContaining(['preview-stories', 'list-all-documentation'])); - expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('get-changed-stories')); + expect(names).toEqual(expect.not.arrayContaining(TOOLSET_BACKED)); + expect(names).toEqual( + expect.arrayContaining(['get-storybook-story-instructions', 'list-all-documentation']) + ); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('preview-stories')); }); it('contains only the missing-toolset case: any other adapter failure still fails fast', () => { @@ -92,26 +112,37 @@ describe('a broken tool row', () => { }); }); -describe('get-changed-stories over the registry', () => { +describe('the migrated rows over the registry', () => { + const context = { + availability: { + changeDetectionEnabled: true, + moduleGraphSupported: true, + docsEnabled: false, + a11yEnabled: false, + }, + } as never; + beforeEach(() => { clearToolsetRegistry(); }); - // One integration probe through the real `stories` toolset: the row resolves off the registry + // One integration probe through the real `stories` toolset: each row resolves off the registry // and publishes the frozen name and title an MCP client sees. - it('serves the migrated row once its toolset is registered', async () => { + it('serves every migrated row once its toolset is registered', async () => { registerCoreToolsetsForTest(); const { server, tools } = makeServer(); - await registerAddonMcpTools(server, { - availability: { changeDetectionEnabled: true, docsEnabled: false, a11yEnabled: false }, - } as never); + await registerAddonMcpTools(server, context); - expect(tools.has('get-changed-stories')).toBe(true); + expect([...tools.keys()]).toEqual(expect.arrayContaining(TOOLSET_BACKED)); expect( - getAddonToolMetadata({ - availability: { changeDetectionEnabled: true, docsEnabled: false, a11yEnabled: false }, - } as never).find((tool) => tool.name === 'get-changed-stories') - ).toMatchObject({ title: 'Get changed stories metadata' }); + getAddonToolMetadata(context) + .filter((tool) => TOOLSET_BACKED.includes(tool.name)) + .map((tool) => tool.title) + ).toEqual([ + 'Get story preview URLs', + 'Get changed stories metadata', + 'Get stories for component files', + ]); }); }); diff --git a/code/addons/mcp/src/tools/tool-registry.ts b/code/addons/mcp/src/tools/tool-registry.ts index 2178f0aa50d1..e5cef03a4c60 100644 --- a/code/addons/mcp/src/tools/tool-registry.ts +++ b/code/addons/mcp/src/tools/tool-registry.ts @@ -15,12 +15,12 @@ import { import type { AddonContext } from '../types.ts'; import type { ToolAvailability } from '../utils/get-tool-availability.ts'; import { getDisplayReviewToolMetadata, addDisplayReviewTool } from './display-review.ts'; +import { PREVIEW_STORIES_RESOURCE_URI, addPreviewStoriesResource } from './preview-stories.ts'; import { buildStorybookStoryInstructions, getStorybookStoryInstructionsToolMetadata, addGetUIBuildingInstructionsTool, } from './get-storybook-story-instructions.ts'; -import { getPreviewStoriesToolMetadata, addPreviewStoriesTool } from './preview-stories.ts'; import { getRunStoryTestsToolMetadata, addRunStoryTestsTool } from './run-story-tests.ts'; // The error class must come from the same entry as `getToolset` (which throws it, via // `toolset-tools.ts`); a copy from another core entry is a different constructor and @@ -29,7 +29,6 @@ import { MCP_TOOL_NAMES, OpenServiceMissingToolsetError } from 'storybook/open-s import { DISPLAY_REVIEW_TOOL_NAME, GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME, - PREVIEW_STORIES_TOOL_NAME, RUN_STORY_TESTS_TOOL_NAME, } from './tool-names.ts'; import { @@ -127,14 +126,13 @@ function fromToolset( } const addonToolDefinitions: AddonToolDefinition[] = [ - { - name: PREVIEW_STORIES_TOOL_NAME, + fromToolset({ toolset: 'dev', - getMetadata: ({ availability }) => - getPreviewStoriesToolMetadata({ reviewEnabled: availability.reviewEnabled }), - register: (server, { availability }, enabled) => - addPreviewStoriesTool(server, enabled, { reviewEnabled: availability.reviewEnabled }), - }, + options: { + method: 'stories.preview', + extras: { _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } } }, + }, + }), { name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME, toolset: 'dev', @@ -286,6 +284,10 @@ export async function registerAddonMcpTools( server: McpServer, context: AddonToolRegistryContext ) { + // The preview app resource is transport-level: it is served to the client independently of the + // tool call that references it. + await addPreviewStoriesResource(server); + for (const definition of addonToolDefinitions) { if ( isToolsetEnabled(definition.toolset, context.toolsets) && diff --git a/code/addons/mcp/src/tools/toolset-tools.ts b/code/addons/mcp/src/tools/toolset-tools.ts index 19e4e01cc6fb..a1907b910fef 100644 --- a/code/addons/mcp/src/tools/toolset-tools.ts +++ b/code/addons/mcp/src/tools/toolset-tools.ts @@ -35,6 +35,8 @@ type ToolEnabled = Parameters[0]['enabled']; export type ToolsetToolOptions = { /** Which toolset method backs this MCP tool. */ method: ToolsetMethodRef; + /** Extra MCP-only tool metadata, e.g. the preview app resource. */ + extras?: Record; }; function resolveToolset(options: ToolsetToolOptions): AnyToolsetDefinition { @@ -152,6 +154,7 @@ export function getToolsetToolMetadata(options: ToolsetToolOptions) { description: resolveToolsetDescription(method.description, descriptionCtx), ...(hasInput ? { schema: method.schema } : {}), ...(method.outputSchema ? { outputSchema: method.outputSchema } : {}), + ...options.extras, }; } diff --git a/code/addons/mcp/src/utils/build-args-param.test.ts b/code/addons/mcp/src/utils/build-args-param.test.ts deleted file mode 100644 index 1ddfb3e5ccee..000000000000 --- a/code/addons/mcp/src/utils/build-args-param.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildArgsParam } from './build-args-param.ts'; - -describe('buildArgsParam', () => { - it('returns empty string for empty object', () => { - expect(buildArgsParam({})).toEqual(''); - }); - - it('returns empty string for null/undefined', () => { - expect(buildArgsParam(null as any)).toEqual(''); - expect(buildArgsParam(undefined as any)).toEqual(''); - }); - - it('builds a simple key-value pair', () => { - const param = buildArgsParam({ key: 'val' }); - expect(param).toEqual('key:val'); - }); - - it('builds multiple values', () => { - const param = buildArgsParam({ one: '1', two: '2', three: '3' }); - expect(param).toEqual('one:1;two:2;three:3'); - }); - - it('builds booleans', () => { - const param = buildArgsParam({ yes: true, no: false }); - expect(param).toEqual('yes:!true;no:!false'); - }); - - it('builds numbers', () => { - const param = buildArgsParam({ count: 42, decimal: 3.14 }); - expect(param).toEqual('count:42;decimal:3.14'); - }); - - it('builds arrays', () => { - const param = buildArgsParam({ arr: ['1', '2', '3'] }); - expect(param).toEqual('arr[0]:1;arr[1]:2;arr[2]:3'); - }); - - it('builds sparse arrays', () => { - const param = buildArgsParam({ arr: ['1', , '3'] }); - expect(param).toEqual('arr[0]:1;arr[2]:3'); - }); - - it('builds simple objects', () => { - const param = buildArgsParam({ obj: { one: '1', two: '2' } }); - expect(param).toEqual('obj.one:1;obj.two:2'); - }); - - it('builds nested objects', () => { - const param = buildArgsParam({ - obj: { foo: { one: '1', two: '2' }, bar: { one: '1' } }, - }); - expect(param).toEqual('obj.foo.one:1;obj.foo.two:2;obj.bar.one:1'); - }); - - it('builds arrays in objects', () => { - const param = buildArgsParam({ obj: { foo: ['1', , '3'] } }); - expect(param).toEqual('obj.foo[0]:1;obj.foo[2]:3'); - }); - - it('builds single object in array', () => { - const param = buildArgsParam({ arr: [{ one: '1', two: '2' }] }); - expect(param).toEqual('arr[0].one:1;arr[0].two:2'); - }); - - it('builds multiple objects in array', () => { - const param = buildArgsParam({ arr: [{ one: '1' }, { two: '2' }] }); - expect(param).toEqual('arr[0].one:1;arr[1].two:2'); - }); - - it('builds nested object in array', () => { - const param = buildArgsParam({ arr: [{ foo: { bar: 'val' } }] }); - expect(param).toEqual('arr[0].foo.bar:val'); - }); - - it('encodes space as +', () => { - const param = buildArgsParam({ key: 'foo bar baz' }); - expect(param).toEqual('key:foo+bar+baz'); - }); - - it('encodes null values as !null', () => { - const param = buildArgsParam({ key: null }); - expect(param).toEqual('key:!null'); - }); - - it('encodes undefined values as !undefined', () => { - const param = buildArgsParam({ key: undefined }); - expect(param).toEqual('key:!undefined'); - }); - - it('encodes nested null values as !null', () => { - const param = buildArgsParam({ - foo: { bar: [{ key: null }], baz: null }, - }); - expect(param).toEqual('foo.bar[0].key:!null;foo.baz:!null'); - }); - - it('encodes hex color values as !hex(value)', () => { - const param = buildArgsParam({ key: '#ff4785' }); - expect(param).toEqual('key:!hex(ff4785)'); - }); - - it('encodes short hex color values', () => { - const param = buildArgsParam({ key: '#f47' }); - expect(param).toEqual('key:!hex(f47)'); - }); - - it('encodes 8-digit hex color values with alpha', () => { - const param = buildArgsParam({ key: '#ff478580' }); - expect(param).toEqual('key:!hex(ff478580)'); - }); - - it('encodes rgba color values by prefixing and compacting', () => { - const param = buildArgsParam({ - rgb: 'rgb(255, 71, 133)', - rgba: 'rgba(255, 71, 133, 0.5)', - }); - expect(param).toEqual('rgb:!rgb(255,71,133);rgba:!rgba(255,71,133,0.5)'); - }); - - it('encodes hsla color values by prefixing and compacting', () => { - const param = buildArgsParam({ - hsl: 'hsl(45, 99%, 70%)', - hsla: 'hsla(45, 99%, 70%, 0.5)', - }); - expect(param).toEqual('hsl:!hsl(45,99,70);hsla:!hsla(45,99,70,0.5)'); - }); - - it('encodes Date objects as !date(ISO string)', () => { - const param = buildArgsParam({ - key: new Date('2001-02-03T04:05:06.789Z'), - }); - expect(param).toEqual('key:!date(2001-02-03T04:05:06.789Z)'); - }); - - it('handles mixed types', () => { - const param = buildArgsParam({ - str: 'hello', - num: 42, - bool: true, - nil: null, - arr: [1, 2], - obj: { nested: 'value' }, - }); - expect(param).toEqual( - 'str:hello;num:42;bool:!true;nil:!null;arr[0]:1;arr[1]:2;obj.nested:value' - ); - }); -}); diff --git a/code/addons/mcp/src/utils/build-args-param.ts b/code/addons/mcp/src/utils/build-args-param.ts deleted file mode 100644 index 9037efe65672..000000000000 --- a/code/addons/mcp/src/utils/build-args-param.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* -This file is adapted from Storybook's own implementation: -https://github.com/storybookjs/storybook/blob/466824f7d57bfed34e0096671602333bf85c86bc/code/core/src/router/utils.test.ts -Just simplified a bit for our use case. -*/ -import { stringify } from 'picoquery'; - -type Args = Record; - -const HEX_REGEXP = /^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/i; -const COLOR_REGEXP = - /^(rgba?|hsla?)\(([0-9]{1,3}),\s?([0-9]{1,3})%?,\s?([0-9]{1,3})%?,?\s?([0-9](\.[0-9]{1,2})?)?\)$/i; - -/** - * Encodes special values for Storybook's args URL format. - * Handles undefined, null, booleans, dates, hex colors, and rgba/hsla colors. - */ -function encodeSpecialValues(value: unknown): unknown { - if (value === undefined) { - return '!undefined'; - } - - if (value === null) { - return '!null'; - } - - if (typeof value === 'string') { - if (HEX_REGEXP.test(value)) { - return `!hex(${value.slice(1)})`; - } - - if (COLOR_REGEXP.test(value)) { - return `!${value.replace(/[\s%]/g, '')}`; - } - return value; - } - - if (typeof value === 'boolean') { - return `!${value}`; - } - - if (value instanceof Date) { - return `!date(${value.toISOString()})`; - } - - if (Array.isArray(value)) { - return value.map(encodeSpecialValues); - } - - // is object - if (typeof value === 'object' && value !== null) { - return Object.entries(value as Record).reduce( - (acc, [key, val]) => Object.assign(acc, { [key]: encodeSpecialValues(val) }), - {} - ); - } - - return value; -} - -/** - * Replaces some url-encoded characters with their decoded equivalents. - * The URI RFC specifies these should be encoded, but all browsers will - * tolerate them being decoded, so we opt to go with it for cleaner looking URIs. - */ -function decodeKnownQueryChar(chr: string): string { - switch (chr) { - case '%20': - return '+'; - case '%5B': - return '['; - case '%5D': - return ']'; - case '%2C': - return ','; - case '%3A': - return ':'; - } - return chr; -} - -const KNOWN_QUERY_CHAR_REGEXP = /%[0-9A-F]{2}/g; - -/** - * Builds a Storybook args query parameter string from an object of props. - * - * The format uses semicolons as delimiters and colons for key:value pairs, - * with special encoding for booleans, null, undefined, dates, and colors. - * - * Example output: "disabled:!true;label:Hello+World;count:42" - */ -export function buildArgsParam(args: Args): string { - if (!args || Object.keys(args).length === 0) { - return ''; - } - - return stringify(encodeSpecialValues(args), { - delimiter: ';', - nesting: true, - nestingSyntax: 'js', - }) - .replace(KNOWN_QUERY_CHAR_REGEXP, decodeKnownQueryChar) - .split(';') - .map((part: string) => part.replace('=', ':')) - .join(';'); -} diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index f31c58a0dda9..5429546fd5a8 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -66,6 +66,7 @@ export { */ export { registerToolset } from '../shared/open-service/toolset-registry.ts'; export { createStoriesToolset } from '../shared/open-service/toolsets/stories/definition.ts'; +export type { PreviewStoriesOutput } from '../shared/open-service/toolsets/stories/definition.ts'; export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store/index.ts'; export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock.ts'; diff --git a/code/core/src/shared/open-service/toolsets/stories/definition.test.ts b/code/core/src/shared/open-service/toolsets/stories/definition.test.ts index 8ec85e8def62..0cbcf2c84f94 100644 --- a/code/core/src/shared/open-service/toolsets/stories/definition.test.ts +++ b/code/core/src/shared/open-service/toolsets/stories/definition.test.ts @@ -6,7 +6,10 @@ import { vol } from 'memfs'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as v from 'valibot'; -import { OpenServiceModuleGraphUnavailableError } from '../../../../server-errors.ts'; +import { + OpenServiceMissingOriginError, + OpenServiceModuleGraphUnavailableError, +} from '../../../../server-errors.ts'; import { CHANGE_DETECTION_STATUS_TYPE_ID } from '../../../status-store/index.ts'; import { resolveToolsetDescription, type ToolsetCtx } from '../../toolset-definition.ts'; import { createStoriesToolset, type StoriesToolset } from './definition.ts'; @@ -39,6 +42,7 @@ const changedComponentFile = 'packages/ui/src/Button.tsx'; const changedThemeFile = 'packages/ui/src/theme.ts'; const buttonStoryHit = { storyFile: './src/Button.stories.tsx', depth: 1 }; +const previewUrl = 'http://localhost:6006/?path=/story/button--primary'; const getIndex = vi.fn(); const getChangedFiles = vi.fn(); @@ -144,29 +148,73 @@ describe('stories.preview', () => { it('resolves story ids against the live index', async () => { const outcome = await runPreview([{ storyId: 'button--primary' }]); + expect(outcome.ok).toBe(true); + expect(outcome.data).toEqual({ + stories: [{ title: 'Button', name: 'Primary', previewUrl }], + }); + expect(getIndex).toHaveBeenCalledOnce(); + }); + + it('reports per-input lookup failures instead of failing the call', async () => { + const outcome = await runPreview([{ storyId: 'gone--story' }]); + expect(outcome.ok).toBe(true); expect(outcome.data).toEqual({ stories: [ { - title: 'Button', - name: 'Primary', - previewUrl: 'http://localhost:6006/?path=/story/button--primary', + input: { storyId: 'gone--story' }, + error: 'No story found for story ID "gone--story"', }, ], }); - expect(getIndex).toHaveBeenCalledOnce(); }); - it('renders compact Markdown preview URLs', async () => { - const outcome = await runPreview([{ storyId: 'button--primary' }]); + it('rejects when the adapter has no Storybook origin to build URLs from', async () => { + await expect( + runPreview([{ storyId: 'button--primary' }], { ...cliCtx, origin: undefined }) + ).rejects.toBeInstanceOf(OpenServiceMissingOriginError); + }); - expect(outcome.markdown).toBe( - [ - '# Story previews', - '- Button - Primary', - ' http://localhost:6006/?path=/story/button--primary', - ].join('\n') - ); + it('reports the story counts it resolved', async () => { + await runPreview([{ storyId: 'button--primary' }, { storyId: 'gone--story' }]); + + expect(telemetry).toHaveBeenCalledWith('tool:previewStories', { + inputStoryCount: 2, + outputStoryCount: 2, + }); + }); + + describe('rendering', () => { + it('lists titled entries for the CLI', async () => { + const outcome = await runPreview([{ storyId: 'button--primary' }]); + + expect(outcome.markdown).toBe( + ['# Story previews', '- Button - Primary', ` ${previewUrl}`].join('\n') + ); + }); + + it('returns one text block per URL for MCP', async () => { + const outcome = await runPreview([{ storyId: 'button--primary' }], mcpCtx); + + expect(outcome.markdown).toEqual([previewUrl]); + }); + + it('appends a review nudge for MCP once a URL resolved and reviews exist', async () => { + const withReviews = createToolset({ reviewEnabled: true }); + const outcome = await runPreview([{ storyId: 'button--primary' }], mcpCtx, withReviews); + + expect(outcome.markdown).toEqual([ + previewUrl, + 'These preview links are for iterating or sharing a specific story — they are not how visual work or a browse request ends. The display-review tool is available in this session: if you are finishing visually observable work or showing a set of stories, publish the review with **display-review** and link that instead.', + ]); + }); + + it('leaves an all-error result unnudged, since there is nothing to curate', async () => { + const withReviews = createToolset({ reviewEnabled: true }); + const outcome = await runPreview([{ storyId: 'gone--story' }], mcpCtx, withReviews); + + expect(outcome.markdown).toEqual(['No story found for story ID "gone--story"']); + }); }); }); @@ -413,6 +461,25 @@ describe('descriptions', () => { ); }); + it('makes preview the end of visual work when no review page exists', () => { + expect(resolveToolsetDescription(toolset.methods.preview.description, mcpCtx)) + .toBe(`Use this tool to get one or more Storybook preview URLs. +Call it after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — no exceptions. A shared file has no stories of its own: preview the stories of the components that consume it. +Include each returned preview URL in your final user-facing response so users can open them directly.`); + }); + + it('demotes preview to a mid-loop tool when reviews are enabled', () => { + const withReviews = createToolset({ reviewEnabled: true }); + + expect(resolveToolsetDescription(withReviews.methods.preview.description, mcpCtx)) + .toBe(`Use this tool to get Storybook preview URLs while iterating on a specific story, or when the user asks for a direct link to one. +Do not end visual work or browse requests with these links — publish a curated review with display-review instead (passing changedFiles: [] when no code changed) and link that.`); + + expect(resolveToolsetDescription(withReviews.methods.preview.description, cliCtx)) + .toBe(`Use this tool to get Storybook preview URLs while iterating on a specific story, or when the user asks for a direct link to one. +Do not end visual work or browse requests with these links — publish a curated review with npx storybook tools review create instead (passing changedFiles: [] when no code changed) and link that.`); + }); + it('offers the review page as a hand-off target only when reviews are enabled', () => { const withReviews = createToolset({ reviewEnabled: true }); diff --git a/code/core/src/shared/open-service/toolsets/stories/definition.ts b/code/core/src/shared/open-service/toolsets/stories/definition.ts index 8717abd6342f..12c473329195 100644 --- a/code/core/src/shared/open-service/toolsets/stories/definition.ts +++ b/code/core/src/shared/open-service/toolsets/stories/definition.ts @@ -8,7 +8,7 @@ import { } from '../../../../server-errors.ts'; import type { ModuleGraphService } from '../../services/module-graph/definition.ts'; import { defineToolset, type ToolsetCtx, type ToolsetOutcome } from '../../toolset-definition.ts'; -import { getRef } from '../../toolset-names.ts'; +import { getRef, MCP_TOOL_NAMES } from '../../toolset-names.ts'; import type { StatusesByStoryIdAndTypeId } from '../../../status-store/index.ts'; import { getChangedStories } from './changed.ts'; import { DEFAULT_MAX_DISTANCE, findStoriesByComponent } from './find-by-component.ts'; @@ -21,7 +21,14 @@ import { detectUnreachableFiles } from './unreachable-files.ts'; const previewSuccessSchema = v.object({ title: v.string(), name: v.string(), - previewUrl: v.pipe(v.string(), v.description('Direct URL for the story preview.')), + previewUrl: v.pipe( + v.string(), + v.description( + // An `outputSchema` is built once per toolset, with no `ctx` to resolve a sibling tool's + // spelling against, so this names the frozen MCP tool rather than a hardcoded string. + `Direct URL to open the story preview. Include this URL in the final user-facing response so users can open it directly — unless a curated review page is being published via ${MCP_TOOL_NAMES['review.create']}, in which case link the review page instead of listing individual URLs.` + ) + ), }); const previewFailureSchema = v.object({ @@ -138,6 +145,21 @@ export type CreateStoriesToolsetOptions = { reviewEnabled?: boolean; }; +function describePreview(ctx: ToolsetCtx, reviewEnabled: boolean): string { + if (!reviewEnabled) { + return `Use this tool to get one or more Storybook preview URLs. +Call it after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — no exceptions. A shared file has no stories of its own: preview the stories of the components that consume it. +Include each returned preview URL in your final user-facing response so users can open them directly.`; + } + + // With reviews available this is strictly a mid-loop tool: no "include the URLs in your final + // response" default (that sanctioned preview links as the ending of visual work) and no hedging + // about the review tool's availability (a hedged "when available" let an agent that wrongly + // believed the tool was missing treat raw links as a sanctioned fallback). + return `Use this tool to get Storybook preview URLs while iterating on a specific story, or when the user asks for a direct link to one. +Do not end visual work or browse requests with these links — publish a curated review with ${getRef(ctx)('review.create')} instead (passing changedFiles: [] when no code changed) and link that.`; +} + function describeChanged(ctx: ToolsetCtx): string { return `Get Storybook stories marked as new, modified, or related. Returns story metadata only (no URLs). @@ -183,24 +205,35 @@ export function createStoriesToolset({ schema: v.object({ stories: v.pipe( storyInputArraySchema, - v.description('Stories to preview. Prefer { storyId } when available.') + v.description( + `Stories to preview. +Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery. +Use { storyId } when IDs were discovered from documentation tools. +Use { absoluteStoryPath + exportName } only when you're already working in a specific .stories.* file and already have that context.` + ) ), }), - description: 'Resolves story selectors to preview URLs.', + outputSchema: previewOutputSchema, + description: (ctx) => describePreview(ctx, reviewEnabled), handler: async (input, ctx): Promise> => { - const origin = ctx.origin; - if (!origin) { + if (!ctx.origin) { throw new OpenServiceMissingOriginError({ toolsetId: 'stories', methodName: 'preview', }); } const data = previewStories({ - origin, + origin: ctx.origin, index: await storyIndex.getIndex(), stories: input.stories, }); - return { ok: true, data, markdown: formatPreviewStories(data) }; + + await ctx.telemetry?.('tool:previewStories', { + inputStoryCount: input.stories.length, + outputStoryCount: data.stories.length, + }); + + return { ok: true, data, markdown: formatPreviewStories(data, ctx, { reviewEnabled }) }; }, }, changed: { diff --git a/code/core/src/shared/open-service/toolsets/stories/format.ts b/code/core/src/shared/open-service/toolsets/stories/format.ts index ba67912ff162..c877bb4eb450 100644 --- a/code/core/src/shared/open-service/toolsets/stories/format.ts +++ b/code/core/src/shared/open-service/toolsets/stories/format.ts @@ -10,19 +10,55 @@ function pluralize(count: number, singular: string, plural = `${singular}s`): st return count === 1 ? singular : plural; } -/** Formats preview URLs as compact Markdown for humans and agents. */ -export function formatPreviewStories({ stories }: PreviewStoriesOutput): string { - const lines = ['# Story previews']; - - for (const story of stories) { - if ('error' in story) { - lines.push(`- Error: ${story.error}`); - } else { - lines.push(`- ${story.title} - ${story.name}`, ` ${story.previewUrl}`); - } +/** + * Recovery nudge for the review exit ramp: agents that skip the review tool end visual work on + * exactly this call, so the result itself has to contradict that while a step remains to recover in. + */ +function previewReviewNudge(ctx: ToolsetCtx): string { + const reviewTool = getRef(ctx)('review.create'); + return `These preview links are for iterating or sharing a specific story — they are not how visual work or a browse request ends. The ${reviewTool} tool is available in this session: if you are finishing visually observable work or showing a set of stories, publish the review with **${reviewTool}** and link that instead.`; +} + +/** + * Splits a preview result into the text blocks a consumer shows. + * + * MCP renders one block per URL, so this returns the blocks; {@link formatPreviewStories} joins + * them for consumers that want a single string. + */ +export function formatPreviewStoryBlocks( + { stories }: PreviewStoriesOutput, + ctx: ToolsetCtx, + { reviewEnabled = false }: { reviewEnabled?: boolean } = {} +): string[] { + if (ctx.consumer !== 'mcp') { + return [ + '# Story previews', + ...stories.map((story) => + 'error' in story + ? `- Error: ${story.error}` + : `- ${story.title} - ${story.name}\n ${story.previewUrl}` + ), + ]; } - return lines.join('\n'); + const blocks = stories.map((story) => ('error' in story ? story.error : story.previewUrl)); + + // An all-error result has nothing to curate, so the nudge only applies once a URL resolved. + if (reviewEnabled && stories.some((story) => 'previewUrl' in story)) { + blocks.push(previewReviewNudge(ctx)); + } + + return blocks; +} + +export function formatPreviewStories( + data: PreviewStoriesOutput, + ctx: ToolsetCtx, + options: { reviewEnabled?: boolean } = {} +): string | string[] { + const blocks = formatPreviewStoryBlocks(data, ctx, options); + // MCP renders one text block per URL; the CLI list reads better as one joined document. + return ctx.consumer === 'mcp' ? blocks : blocks.join('\n'); } const BANNER_INLINE_LIMIT = 3; diff --git a/code/core/src/shared/open-service/toolsets/stories/story-input.ts b/code/core/src/shared/open-service/toolsets/stories/story-input.ts index af319b5b7466..5b88c83d7e67 100644 --- a/code/core/src/shared/open-service/toolsets/stories/story-input.ts +++ b/code/core/src/shared/open-service/toolsets/stories/story-input.ts @@ -1,5 +1,30 @@ import * as v from 'valibot'; +import { MCP_TOOL_NAMES } from '../../toolset-names.ts'; + +/** + * Per-shape props/globals selectors, shared by both story selector variants. + * + * The prose predates the toolsets and is eval-tuned, so it is kept verbatim. It names sibling + * capabilities by their MCP tool names rather than through `getRef`, because valibot descriptions + * are static strings with no per-consumer rendering. One of them, `get-storybook-story-instructions`, + * is an `@storybook/addon-mcp` tool with no toolset method and so no entry in {@link MCP_TOOL_NAMES}; + * a CLI consumer would need this sentence rewritten rather than re-rendered. + */ +const storyInputProps = { + props: v.pipe( + v.optional(v.record(v.string(), v.any())), + v.description(`Optional custom props to pass to the story for rendering. Use this when you don't want to render the default story, +but you want to customize some args or other props. +You can look up the component's documentation using the get-storybook-story-instructions tool to see what props are available.`) + ), + globals: v.pipe( + v.optional(v.record(v.string(), v.any())), + v.description(`Optional Storybook globals to set for the story preview. Globals are used for things like theme, locale, viewport, and other cross-cutting concerns. +Common globals include 'theme' (e.g., 'dark', 'light'), 'locale' (e.g., 'en', 'fr'), and 'backgrounds' (e.g., { value: '#000' }).`) + ), +}; + /** * Shared story selector used by the stories and test APIs. * @@ -9,38 +34,37 @@ export const storyInputSchema = v.union([ v.object({ exportName: v.pipe( v.string(), - v.description('Export name of the story from the story file (for example "Primary").') + v.description( + `The export name of the story from the story file. +Use this path-based shape only when you're already editing a .stories.* file and know the export names in that file. +If you do not already have story file context, prefer the storyId shape instead of searching files.` + ) ), explicitStoryName: v.pipe( v.optional(v.string()), - v.description('Story `name` when it differs from the export name.') + v.description( + `If the story has an explicit name set via the "name" property, that is different from the export name, provide it here. +Otherwise don't set this.` + ) ), absoluteStoryPath: v.pipe( v.string(), - v.description('Absolute path to the story file, used with exportName.') - ), - props: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description('Optional story args/props overrides.') - ), - globals: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description('Optional Storybook globals (theme, locale, viewport, …).') + v.description( + 'Absolute path to the story file. Use together with exportName only when story file context is already available.' + ) ), + ...storyInputProps, }), v.object({ storyId: v.pipe( v.string(), - v.description('Full Storybook story id (for example "button--primary").') - ), - props: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description('Optional story args/props overrides.') - ), - globals: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description('Optional Storybook globals (theme, locale, viewport, …).') + v.description( + `The full Storybook story ID (for example "button--primary"). +Prefer this shape whenever you are not already working in a specific story file. +Use IDs discovered from ${MCP_TOOL_NAMES['docs.list']} (withStoryIds=true) or ${MCP_TOOL_NAMES['docs.show']}.` + ) ), + ...storyInputProps, }), ]); diff --git a/yarn.lock b/yarn.lock index 88d38675b0f4..8f6b08d3ba0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11826,7 +11826,6 @@ __metadata: "@storybook/mcp": "workspace:*" "@tmcp/adapter-valibot": "npm:^0.1.5" "@tmcp/transport-http": "npm:^0.8.5" - picoquery: "npm:^2.5.0" storybook: "workspace:*" tmcp: "npm:^1.19.4" typescript: "npm:^6.0.3"