首页
/ Storybook MCP:按 Story ID 预览既有 Story 且不改动源码的完整工作流解析

Storybook MCP:按 Story ID 预览既有 Story 且不改动源码的完整工作流解析

2026-09-06 10:41:27作者:滕妙奇

本文以 Storybook 仓库中 agent-eval/evals/915-preview-story-by-id 评测用例(其任务提示见 PROMPT.md)为核心骨架展开。该用例要求 AI Agent 对既有的 Button 组件的 PrimarySecondary 两个 Story 生成预览链接,且不得修改任何组件或 Story 文件。读完后你能掌握:如何用 Storybook 的 MCP / stories-preview 工作流按 Story ID 精确预览既有 Story、Story ID 的命名规则如何从 title 与导出名推导、评测断言如何校验「按 ID 预览」这一行为,以及预览工具在仓库中的底层实现。

一、任务背景:一个「只读预览」评测场景

915-preview-story-by-id 是仓库 agent-eval 目录下的一个评测用例,其 PROMPT.md 原文只有两句话,却完整定义了一个典型任务边界:

Show a preview of two existing Button stories: Primary and Secondary. Do not modify any component or story files.

这两句浓缩了三个关键约束:

  1. 对象是「既有的 Story」——不是新建组件、不是改 Story,目标 Story 已经存在于仓库中。
  2. 目标是「生成预览」——把 Story 渲染出来并以可访问的链接形式交付给调用方。
  3. 行为红线是「只读」——不得改动任何组件或 Story 文件,任何写盘操作都算失败。

这类任务在 Storybook 面向 AI Agent 的场景中很有代表性:Agent 常常需要「看一眼某个组件现在长什么样」,而不是「改它」。因此正确做法是调用预览类工作流,而非触发组件创建/编辑类工作流。

该用例在 package.json 中声明了所使用的沙箱模板:

{
  "name": "915-preview-story-by-id",
  "type": "module",
  "evals": {
    "template": "reshaped-storybook"
  }
}

template: "reshaped-storybook" 指向 agent-eval/templates/reshaped-storybook 模板,评测会在该模板生成的沙箱项目中运行 Agent,再由断言脚本检验其调用轨迹。

二、评测夹具中的组件与 Story

理解 Story ID 从何而来,需要先看夹具本身。

组件 src/components/Button.tsx 是一个极简的受控按钮:

type ButtonProps = {
  label: string;
  disabled?: boolean;
};

export default function Button({ label, disabled = false }: ButtonProps) {
  return (
    <button type="button" disabled={disabled} data-testid="button-component">
      {label}
    </button>
  );
}

Story 文件 stories/Button.stories.tsx 采用 CSF3 写法,是推导 Story ID 的关键:

import type { Meta, StoryObj } from '@storybook/react';
import Button from '../src/components/Button';

const meta = {
  title: 'Example/Button',
  component: Button,
  tags: ['test'],
  args: {
    label: 'Click me',
    disabled: false,
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    label: 'Primary',
  },
};

export const Secondary: Story = {
  args: {
    label: 'Secondary',
  },
};

从这份 Story 文件可以直接读出 Story ID 的构成规则:

  • title: 'Example/Button' 会被 kebab-case 化,前缀为 example-button
  • 导出名 Primary 转为 --primarySecondary 转为 --secondary
  • 最终得到 example-button--primaryexample-button--secondary 两个 Story ID。

这套「title kebab-case + -- + 导出名 kebab-case」的拼接逻辑,与评测工具 agent-eval/lib/test-utils.tskebabCase 辅助函数的实现保持一致(把驼峰拆成连字符、转小写),可据此交叉验证 ID 的推导是否正确。

三、断言逻辑:如何判定 Agent「按 ID 预览」成功

EVAL.ts 是该用例的验收脚本,用 vitest 编写,全部断言围绕 stories-preview 这一个工作流:

import { describe, expect, test } from 'vitest';
import {
  expectWorkflowCalls,
  getWorkflowCalls,
  workflowCallIncludesStory,
  workflowCallUsesStoryId,
} from '#test-utils';

describe('previewing Button stories by story ID', () => {
  test('previews stories using story IDs', () => {
    const previewCalls = getWorkflowCalls('stories-preview');
    expectWorkflowCalls(['stories-preview']);
    expect(previewCalls.some(workflowCallUsesStoryId)).toBe(true);
    expect(
      previewCalls.some((call) =>
        workflowCallIncludesStory(call, { storyId: 'example-button--primary' })
      )
    ).toBe(true);
    expect(
      previewCalls.some((call) =>
        workflowCallIncludesStory(call, { storyId: 'example-button--secondary' })
      )
    ).toBe(true);
  });
});

逐条解读这组断言,可以清楚知道「及格线」在哪里:

  1. expectWorkflowCalls(['stories-preview']):要求 Agent 至少调用过一次 stories-preview 工作流。这确保了 Agent 走的是「预览」路径,而不是去创建/编辑组件。
  2. previewCalls.some(workflowCallUsesStoryId):要求至少有一次预览调用是通过 storyId 字段发起的。这正是用例标题「by id」的含义——不能只靠文件路径或导出名,必须使用规范化的 Story ID。
  3. 两个 workflowCallIncludesStory(...):分别要求预览调用覆盖了 example-button--primaryexample-button--secondary,即 Primary 与 Secondary 两个 Story 都必须被预览到,缺一不可。

这三个断言共同刻画了「按 Story ID 预览既有 Story」这一行为的完整判定:正确的预览工作流 + 使用 storyId 入参 + 覆盖到任务指定的两个具体 Story。

其中几个校验函数定义在共享工具 agent-eval/lib/test-utils.ts

  • getWorkflowCalls(name)(第 122-124 行):从 Agent 的调用轨迹中过滤出指定名称的工作流调用。
  • workflowCallUsesStoryId(第 801-803 行):判断某次调用的 story 入参里是否存在字符串类型的 storyId
  • workflowCallIncludesStory(第 794-799 行):判断某次调用是否覆盖了期望的 story 入参(可含 storyId / exportName / absoluteStoryPath)。

这些函数会解析 Agent 的 transcript(__agent_eval__/transcript.txt)与 shell 命令记录(__agent_eval__/results.json),兼容 MCP 工具调用与 storybook ai CLI 两条路径(见 test-utils.tsgetStorybookWorkflowCallsintegration === 'plugin' 与 MCP 的分支处理)。

四、底层实现:preview-stories 工具如何产出预览

评测断言校验的是 Agent 的调用轨迹,而 stories-preview 工作流背后的真实执行逻辑位于 MCP 插件包中。code/addons/mcp/src/tools/preview-stories.ts 注册了一个供 MCP 客户端内联渲染 story 预览的应用资源:

export const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;

/**
 * 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<any, AddonContext>) {
  const previewStoryAppScript = await fs.readFile(
    url.fileURLToPath(
      import.meta.resolve('@storybook/addon-mcp/internal/preview-stories-app-script')
    ),
    'utf-8'
  );

  const appHtml = appTemplate.replace('// APP_SCRIPT_PLACEHOLDER', previewStoryAppScript);

  server.resource(
    {
      name: PREVIEW_STORIES_RESOURCE_URI,
      description: 'App resource for the Preview Stories tool',
      uri: PREVIEW_STORIES_RESOURCE_URI,
      mimeType: 'text/html;profile=mcp-app',
    },
    () => {
      const origin = server.ctx.custom!.origin;
      return {
        contents: [
          {
            uri: PREVIEW_STORIES_RESOURCE_URI,
            mimeType: 'text/html;profile=mcp-app',
            text: appHtml,
            _meta: {
              ui: {
                prefersBorder: false,
                domain: origin,
                csp: {
                  connectDomains: [origin],
                  resourceDomains: [origin],
                  frameDomains: [origin],
                  baseUriDomains: [origin],
                },
              },
            },
          },
        ],
      };
    }
  );
}

从源码结构看,该资源把预览页 HTML 模板 preview-stories-app-template.html 与应用脚本(来自 @storybook/addon-mcp/internal/preview-stories-app-script)拼接后,作为 text/html;profile=mcp-app 的 MCP 资源暴露出去,并附带面向预览源站的 CSP 域名配置(connectDomains / resourceDomains / frameDomains / baseUriDomains)。注释也明确说明:该应用读取工具结果的 structuredContent,即它绑定的是预览工具的输出契约而非其具体实现。这与评测断言「绑定输出而非实现」的校验思路一脉相承——断言只关心 Agent 是否以正确的 story 入参触发了预览、并交付了预览链接,而不关心预览页内部如何渲染。

配合 code/addons/mcp/README.mdcode/addons/mcp/src/tools/tool-names.ts 中的工具命名常量,可以确认 stories-preview 正是该插件对外暴露的「预览 Story」工作流入口。

五、可操作要点与适用边界

把上述证据串起来,针对「按 Story ID 预览既有 Story」这一任务,可提炼出以下可操作要点:

  • 优先使用 storyId 入参:当已知目标 Story 的规范 ID(如 example-button--primary)时,应以 storyId 作为 stories-preview 的 story 入参,而不是仅传文件路径或导出名。评测中 workflowCallUsesStoryId 断言专门校验了这一点。
  • ID 推导要可复现:Story ID 由 title 的 kebab-case 形式拼接 -- 与导出名的 kebab-case 形式得到。修改 title 或导出名会直接改变 ID,因此预览前应核对 Story 文件的 titleexport const 名称。
  • 严守「只读」边界:任务明确禁止改动组件或 Story 文件时,正确行为是只调用预览工作流。任何写盘/编辑类工作流都不应出现。
  • 预览结果以链接交付:从 test-utils.tsSTORYBOOK_PREVIEW_URL_PATTERN(第 517 行,/[?&]path=\/|\/iframe\.html\?/)可推断,预览链接的形态是 manager 页的 ?path=/story/… 或 iframe 的 /iframe.html?id=…,两者都以 Story ID 为核心定位参数。

适用前提:以上行为基于当前仓库中 915-preview-story-by-id 评测夹具与 @storybook/addon-mcp 的实现,storyId 的 kebab-case 拼接规则与 stories-preview 工作流入口以本仓库对应文件为准。若目标项目使用了不同的 title 约定或自定义 Story ID 生成逻辑,应以项目实际的 Story 文件与 Storybook 版本为准。

六、参考文件

登录后查看全文
热门项目推荐
相关项目推荐