Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/claude-plugin/skills/storybook-init/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ npx storybook add @storybook/addon-mcp

- Do not hand-write a full Storybook config when the official initializer can do it.
- Preserve existing app source and package manager choices.
- Do not start Storybook as an ad hoc Bash command or background task in Claude; use the Claude launcher entry.
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
name: storybook-setup-claude-launch
description: Create or repair Claude launch configuration so Claude can start the project's Storybook preview.
description: >-
Create or repair the .claude/launch.json so Claude can start the project's
Storybook dev server. Use when the user asks to set up or repair launch
config, detect dev servers, create or update .claude/launch.json, or start
Storybook through the Claude launcher — or when repair instructions mention
a missing launch entry.
---

# Storybook Setup — Claude Launch
Expand All @@ -13,6 +18,12 @@ Use this skill when Storybook is configured but Claude needs a `.claude/launch.j
2. Inspect any existing `.claude/launch.json`.
3. Add or repair a Storybook launch entry that runs the existing Storybook script from the Storybook invocation directory.
4. Keep other launch entries intact.
5. Verify the launch command manually or by running the equivalent shell command.
5. Verify the launch entry by using the Claude launcher or by inspecting the saved launch config.

Use the project's existing Storybook script instead of inventing a new command whenever possible.

Do not start Storybook as an ad hoc Bash command or background task in Claude. The Claude flow should always start Storybook through `.claude/launch.json` and the Claude launcher.

## If this skill is invoked when creating stories

If this skill is invoked as part of the story creation flow, start the preview server immediately after repairing the launch config, so that the user can continue with story creation without needing to manually start Storybook.
4 changes: 4 additions & 0 deletions packages/claude-plugin/skills/storybook-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ npx storybook ai setup
Use the repo's package manager when appropriate: `pnpm exec storybook ai setup`, `yarn exec storybook ai setup`.

**Follow the printed Markdown precisely.** Do not substitute your own plan.

## Guardrails

- If Storybook needs to be started or previewed in Claude, use `/storybook-setup-claude-launch` and the Claude launcher entry. Do not start Storybook as an ad hoc Bash command or background task.
3 changes: 2 additions & 1 deletion packages/claude-plugin/skills/storybook-upgrade/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ npx storybook add @storybook/addon-mcp
npx storybook doctor
```

8. Start Storybook, then use `/storybook-setup` if the user still needs configuration or stories.
8. Use `/storybook-setup-claude-launch` to configure or repair `.claude/launch.json`, then start Storybook through that launch entry. Use `/storybook-setup` if the user still needs configuration or stories.

## Guardrails

- Do not skip across multiple major versions unless the official Storybook upgrade path supports it.
- Preserve user changes in Storybook config files and story files.
- If the upgrade command creates a `debug-storybook.log`, read it before guessing at fixes.
- Do not start Storybook as an ad hoc Bash command or background task in Claude; use the Claude launcher entry.
61 changes: 47 additions & 14 deletions packages/mcp-proxy/src/tools/intercepts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { getInterceptMarkdown, intercept, META_INTERCEPT_REASON } from './intercepts.ts';
import {
getInterceptMarkdown,
intercept,
isClaudeClient,
META_INTERCEPT_REASON,
} from './intercepts.ts';

describe('intercepts', () => {
it.each([
Expand All @@ -13,6 +18,20 @@ describe('intercepts', () => {
expect(getInterceptMarkdown(reason)).toContain(needle);
});

it('no-instance omits Claude launch repair guidance for generic clients', () => {
const md = getInterceptMarkdown('no-instance');
expect(md).toContain('Storybook is not running');
expect(md).not.toContain('storybook-setup-claude-launch');
});

it('no-instance includes Claude launch repair guidance for Claude clients', () => {
const md = getInterceptMarkdown('no-instance', undefined, {
clientInfo: { name: 'claude-code', version: '2.1.145' },
});
expect(md).toContain('storybook-setup-claude-launch');
expect(md).toContain('.claude/launch.json');
});

it('storybook-too-old reports the detected version, the required version, and points to the upgrade skill', () => {
const md = getInterceptMarkdown('storybook-too-old', { version: '9.0.5' });
expect(md).toMatchInlineSnapshot(`
Expand All @@ -27,22 +46,29 @@ describe('intercepts', () => {
});

it('no-instance lists running candidates when any are provided', () => {
const md = getInterceptMarkdown('no-instance', {
records: [
{
schemaVersion: 1,
instanceId: 'a',
pid: 1,
cwd: '/a',
url: 'http://localhost:6006',
port: 6006,
mcp: { status: 'ready', endpoint: 'http://localhost:6006/mcp' },
},
],
});
const records = [
{
schemaVersion: 1 as const,
instanceId: 'a',
pid: 1,
cwd: '/a',
url: 'http://localhost:6006',
port: 6006,
mcp: { status: 'ready' as const, endpoint: 'http://localhost:6006/mcp' },
},
];
const md = getInterceptMarkdown('no-instance', { records });
expect(md).toContain('Running Storybooks');
expect(md).toContain('/a');
expect(md).toContain('http://localhost:6006');
expect(md).not.toContain('storybook-setup-claude-launch');

const claudeMd = getInterceptMarkdown(
'no-instance',
{ records },
{ clientInfo: { name: 'Claude Code', version: '2.1.145' } },
);
expect(claudeMd).toContain('storybook-setup-claude-launch');
});

it('multiple-matches lists conflicting pids', () => {
Expand Down Expand Up @@ -80,4 +106,11 @@ describe('intercepts', () => {
expect(META_INTERCEPT_REASON).toBe('storybook.dev/interceptReason');
expect(result.content[0]?.type).toBe('text');
});

it('detects Claude from MCP client metadata', () => {
expect(isClaudeClient({ name: 'claude-code' })).toBe(true);
expect(isClaudeClient({ title: 'Claude Code' })).toBe(true);
expect(isClaudeClient({ name: 'test-client' })).toBe(false);
expect(isClaudeClient(undefined)).toBe(false);
});
});
53 changes: 46 additions & 7 deletions packages/mcp-proxy/src/tools/intercepts.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import type { InterceptReason, StorybookInstanceRecordV1 } from '../types/index.ts';
import { STORYBOOK_MIN_VERSION } from '../utils/version-check.ts';

type ClientInfoLike = {
name?: string;
version?: string;
title?: string;
websiteUrl?: string;
};

export type InterceptContext = {
clientInfo?: ClientInfoLike;
};

/**
* Namespaced `_meta` key. MCP reserves unprefixed and `mcp.*` /
* `modelcontextprotocol.*` / `anthropic.*` keys; everything else should live
* under a reverse-DNS-like prefix to keep the wire format forward-compatible.
*/
export const META_INTERCEPT_REASON = 'storybook.dev/interceptReason';

const NO_INSTANCE_EMPTY = `Storybook is not running. Start \`storybook dev\` in the project root and retry the tool call.`;
const CLAUDE_LAUNCH_REPAIR = `The story file you wrote cannot be verified (no play tests, no preview, no a11y)
until Storybook is started.
You CANNOT ignore this error! DO NOT ATTEMPT A FIX ON YOUR OWN, IT WILL NOT WORK! You MUST do these healing steps before continuing with writing a story, OTHERWISE IT WON'T WORK ANYWAY.
Recover now, without asking the user:
1. Invoke skill: storybook:storybook-setup-claude-launch (creates/repairs .claude/launch.json AND starts Storybook)
2. Retry this MCP tool call.
Do NOT fall back to writing stories from existing patterns —
unverified stories are not a deliverable. If step 1 reports an
error, surface it to the user and stop.`;

const NO_INSTANCE_EMPTY = `Storybook is not running at this cwd. Start Storybook from the exact Storybook cwd and retry the tool call.`;

const buildNoInstanceWithCandidates = (records: StorybookInstanceRecordV1[]) =>
`No Storybook is running at this cwd. Either start \`storybook dev\` from the project's cwd, or retry with one of the running cwds below.
`No Storybook is running at this cwd. Either start Storybook from the project's cwd, or retry with one of the running cwds below.

Running Storybooks:
${records.map((r) => `- \`${r.cwd}\` (${r.url})`).join('\n')}`;
Expand Down Expand Up @@ -51,16 +72,30 @@ export type InterceptExtras = {
version?: string;
};

export function isClaudeClient(clientInfo?: ClientInfoLike): boolean {
const clientText = [clientInfo?.name, clientInfo?.title, clientInfo?.websiteUrl]
.filter(Boolean)
.join(' ')
.toLowerCase();

return /(^|[^a-z])claude([^a-z]|$)/.test(clientText);
}

const appendClientSpecificRepair = (message: string, context?: InterceptContext) =>
isClaudeClient(context?.clientInfo) ? `${message}\n\n${CLAUDE_LAUNCH_REPAIR}` : message;

export function getInterceptMarkdown(
reason: InterceptReason,
extras: InterceptExtras = {},
context?: InterceptContext,
): string {
const { records, version } = extras;
switch (reason) {
case 'no-instance':
return records && records.length > 0
? buildNoInstanceWithCandidates(records)
: NO_INSTANCE_EMPTY;
return appendClientSpecificRepair(
records && records.length > 0 ? buildNoInstanceWithCandidates(records) : NO_INSTANCE_EMPTY,
context,
);
case 'addon-missing':
return ADDON_MISSING;
case 'mcp-starting':
Expand All @@ -76,9 +111,13 @@ export function getInterceptMarkdown(
}
}

export function intercept(reason: InterceptReason, extras: InterceptExtras = {}) {
export function intercept(
reason: InterceptReason,
extras: InterceptExtras = {},
context?: InterceptContext,
) {
return {
content: [{ type: 'text' as const, text: getInterceptMarkdown(reason, extras) }],
content: [{ type: 'text' as const, text: getInterceptMarkdown(reason, extras, context) }],
isError: true,
_meta: { [META_INTERCEPT_REASON]: reason },
};
Expand Down
47 changes: 39 additions & 8 deletions packages/mcp-proxy/src/tools/proxy-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock('../utils/version-check.ts', async (importOriginal) => {
});

const REGISTRY_DIR = '/tmp/test-registry';
const serverClientInfo = new WeakMap<McpServer<any>, { name: string; version: string }>();

const record: StorybookInstanceRecordV1 = {
schemaVersion: 1,
Expand All @@ -44,7 +45,7 @@ beforeEach(() => {
vi.mocked(checkStorybookVersion).mockReturnValue({ status: 'ok' });
});

async function buildServer() {
async function buildServer(clientInfo = { name: 't', version: '0' }) {
const server = new McpServer(
{ name: 'test', version: '0.0.0', description: 'test' },
{
Expand All @@ -53,14 +54,15 @@ async function buildServer() {
},
);
registerProxiedTools(server, REGISTRY_DIR);
serverClientInfo.set(server, clientInfo);
await server.receive({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 't', version: '0' },
clientInfo,
},
} as never);
return server;
Expand All @@ -76,12 +78,18 @@ async function listTools(server: McpServer<any>) {
}

async function callTool(server: McpServer<any>, args: Record<string, unknown>) {
return (await server.receive({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'list-all-documentation', arguments: args },
} as never)) as { result: ProxyToolCallResult };
const clientInfo = serverClientInfo.get(server);
return (await server.receive(
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'list-all-documentation', arguments: args },
} as never,
{
sessionInfo: { clientInfo },
} as never,
)) as { result: ProxyToolCallResult };
}

function firstText(result: ProxyToolCallResult): string {
Expand Down Expand Up @@ -124,6 +132,29 @@ describe('registerProxyTool / list-all-documentation', () => {
expect(response.result.isError).toBe(true);
expect(response.result._meta).toEqual({ [META_INTERCEPT_REASON]: 'no-instance' });
expect(firstText(response.result)).toContain('Storybook is not running');
expect(firstText(response.result)).not.toContain('storybook-setup-claude-launch');
});

it('adds Claude launch repair guidance when the MCP client is Claude', async () => {
vi.mocked(readRegistry).mockResolvedValue([]);
const server = await buildServer({ name: 'claude-code', version: '2.1.145' });
const response = await callTool(server, { cwd: '/projects/foo' });
expect(response.result.isError).toBe(true);
expect(response.result._meta).toEqual({ [META_INTERCEPT_REASON]: 'no-instance' });
expect(firstText(response.result)).toContain('storybook-setup-claude-launch');
expect(firstText(response.result)).toMatchInlineSnapshot(`
"Storybook is not running at this cwd. Start Storybook from the exact Storybook cwd and retry the tool call.

The story file you wrote cannot be verified (no play tests, no preview, no a11y)
until Storybook is started.
You CANNOT ignore this error! DO NOT ATTEMPT A FIX ON YOUR OWN, IT WILL NOT WORK! You MUST do these healing steps before continuing with writing a story, OTHERWISE IT WON'T WORK ANYWAY.
Recover now, without asking the user:
1. Invoke skill: storybook:storybook-setup-claude-launch (creates/repairs .claude/launch.json AND starts Storybook)
2. Retry this MCP tool call.
Do NOT fall back to writing stories from existing patterns —
unverified stories are not a deliverable. If step 1 reports an
error, surface it to the user and stop."
`);
});

it('returns the no-instance intercept with candidate cwds when no record matches', async () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/mcp-proxy/src/tools/proxy-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,16 @@ export function registerProxyTool<Schema extends v.ObjectEntries>(
// cast at the boundary rather than carry the union through every internal type.
async (input: Record<string, unknown> & { cwd: string }): Promise<ProxyToolCallResult> => {
const { cwd, ...upstreamArgs } = input;
const context = { clientInfo: server.ctx.sessionInfo?.clientInfo };

if (!path.isAbsolute(cwd)) {
return intercept('invalid-cwd');
return intercept('invalid-cwd', {}, context);
}

// first check the Storybook version before hitting the registry or doing instance resolution, to fail fast if the version is too old
const versionStatus = checkStorybookVersion(cwd);
if (versionStatus.status === 'too-old') {
return intercept('storybook-too-old', { version: versionStatus.version });
return intercept('storybook-too-old', { version: versionStatus.version }, context);
}

// read the registry and resolve the target instance based on the input cwd;
Expand All @@ -69,7 +70,7 @@ export function registerProxyTool<Schema extends v.ObjectEntries>(
const resolution = resolveInstance(records, cwd);

if (resolution.kind === 'intercept') {
return intercept(resolution.reason, { records: resolution.records });
return intercept(resolution.reason, { records: resolution.records }, context);
}

try {
Expand Down
Loading