Skip to content

Commit c89349e

Browse files
authored
Merge pull request #35212 from storybookjs/kasper/ai-serverless-help
CLI: Load Storybook AI help from preset metadata
2 parents f52ee7f + 77bf5a6 commit c89349e

10 files changed

Lines changed: 1205 additions & 545 deletions

File tree

code/core/src/cli/ai/mcp/client.test.ts

Lines changed: 4 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@ import { versions } from 'storybook/internal/common';
22

33
import { describe, expect, it, vi } from 'vitest';
44

5-
import {
6-
MCP_CLIENT_INFO,
7-
McpJsonRpcError,
8-
callMcpTool,
9-
listMcpTools,
10-
listMcpToolsWithServerMetadata,
11-
} from './client.ts';
5+
import { MCP_CLIENT_INFO, McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts';
126
import type { StorybookInstanceRecord } from './types.ts';
137

148
const record: StorybookInstanceRecord = {
@@ -228,38 +222,20 @@ describe('listMcpTools', () => {
228222
});
229223

230224
describe('initialize handshake (clientInfo for telemetry segmentation)', () => {
231-
const initializeResponse = (sessionId?: string, instructions?: unknown) =>
225+
const initializeResponse = (sessionId?: string) =>
232226
jsonResponse(
233227
{
234228
jsonrpc: '2.0',
235229
id: 'init',
236230
result: {
237231
protocolVersion: '2025-06-18',
238232
serverInfo: {},
239-
...(instructions !== undefined ? { instructions } : {}),
240233
},
241234
},
242235
200,
243236
sessionId ? { 'mcp-session-id': sessionId } : {}
244237
);
245238

246-
const initializeSseResponse = (sessionId: string | undefined, instructions: unknown) => {
247-
const envelope = {
248-
jsonrpc: '2.0',
249-
id: 'init',
250-
result: {
251-
protocolVersion: '2025-06-18',
252-
serverInfo: {},
253-
instructions,
254-
},
255-
};
256-
return sseResponse(
257-
`event: message\ndata: ${JSON.stringify(envelope)}\n\n`,
258-
200,
259-
sessionId ? { 'mcp-session-id': sessionId } : {}
260-
);
261-
};
262-
263239
const toolResult = () =>
264240
jsonResponse({
265241
jsonrpc: '2.0',
@@ -406,51 +382,6 @@ describe('initialize handshake (clientInfo for telemetry segmentation)', () => {
406382
expect(headers['Mcp-Session-Id']).toBe('session-7');
407383
});
408384

409-
it('returns server instructions from the initialize response alongside tools/list results', async () => {
410-
const tools = [{ name: 'get-documentation', description: 'Get docs' }];
411-
const fetchImpl = vi
412-
.fn()
413-
.mockResolvedValueOnce(initializeResponse('session-1', ' Follow the story workflow. '))
414-
.mockResolvedValueOnce(
415-
jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools } })
416-
) as unknown as typeof fetch;
417-
418-
await expect(listMcpToolsWithServerMetadata(record, fetchImpl)).resolves.toEqual({
419-
tools,
420-
serverMetadata: { instructions: 'Follow the story workflow.' },
421-
});
422-
});
423-
424-
it('returns server instructions from an SSE initialize response', async () => {
425-
const tools = [{ name: 'get-documentation', description: 'Get docs' }];
426-
const fetchImpl = vi
427-
.fn()
428-
.mockResolvedValueOnce(initializeSseResponse('session-1', ' Follow the story workflow. '))
429-
.mockResolvedValueOnce(toolListResult(tools)) as unknown as typeof fetch;
430-
431-
await expect(listMcpToolsWithServerMetadata(record, fetchImpl)).resolves.toEqual({
432-
tools,
433-
serverMetadata: { instructions: 'Follow the story workflow.' },
434-
});
435-
});
436-
437-
it.each([undefined, '', ' ', 123])(
438-
'omits server instructions when initialize returns %j',
439-
async (instructions) => {
440-
const fetchImpl = vi
441-
.fn()
442-
.mockResolvedValueOnce(initializeResponse('session-1', instructions))
443-
.mockResolvedValueOnce(
444-
jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools: [] } })
445-
) as unknown as typeof fetch;
446-
447-
await expect(listMcpToolsWithServerMetadata(record, fetchImpl)).resolves.toEqual({
448-
tools: [],
449-
serverMetadata: {},
450-
});
451-
}
452-
);
453-
454385
it.each([
455386
[
456387
'malformed JSON',
@@ -472,17 +403,14 @@ describe('initialize handshake (clientInfo for telemetry segmentation)', () => {
472403
.mockResolvedValueOnce(initResponse())
473404
.mockResolvedValueOnce(toolListResult(tools)) as unknown as typeof fetch;
474405

475-
await expect(listMcpToolsWithServerMetadata(record, fetchImpl)).resolves.toEqual({
476-
tools,
477-
serverMetadata: {},
478-
});
406+
await expect(listMcpTools(record, fetchImpl)).resolves.toEqual(tools);
479407
});
480408

481409
it('keeps listMcpTools returning only the tool descriptors', async () => {
482410
const tools = [{ name: 'get-documentation', description: 'Get docs' }];
483411
const fetchImpl = vi
484412
.fn()
485-
.mockResolvedValueOnce(initializeResponse('session-1', 'Follow the story workflow.'))
413+
.mockResolvedValueOnce(initializeResponse('session-1'))
486414
.mockResolvedValueOnce(
487415
jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools } })
488416
) as unknown as typeof fetch;

code/core/src/cli/ai/mcp/client.ts

Lines changed: 19 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,10 @@ const JsonRpcEnvelopeSchema = v.looseObject({
5454
error: v.optional(v.looseObject({ code: v.number(), message: v.string() })),
5555
});
5656

57-
const InitializeResultSchema = v.looseObject({
58-
instructions: v.optional(v.string()),
59-
});
60-
6157
const ToolListResultSchema = v.looseObject({
6258
tools: v.optional(v.array(McpToolDescriptorSchema)),
6359
});
6460

65-
export type McpServerMetadata = {
66-
instructions?: string;
67-
};
68-
69-
export type McpToolList = {
70-
tools: McpToolDescriptor[];
71-
serverMetadata: McpServerMetadata;
72-
};
73-
7461
/** Forward an MCP `tools/call` JSON-RPC request to a local Storybook MCP server. */
7562
export async function callMcpTool(
7663
record: StorybookInstanceRecord,
@@ -92,23 +79,14 @@ export async function listMcpTools(
9279
record: StorybookInstanceRecord,
9380
fetchImpl: typeof fetch = fetch
9481
): Promise<McpToolDescriptor[]> {
95-
const { tools } = await listMcpToolsWithServerMetadata(record, fetchImpl);
96-
return tools;
97-
}
98-
99-
/** List tools and include server metadata from the preceding MCP `initialize` response. */
100-
export async function listMcpToolsWithServerMetadata(
101-
record: StorybookInstanceRecord,
102-
fetchImpl: typeof fetch = fetch
103-
): Promise<McpToolList> {
104-
const { result, serverMetadata } = await sendJsonRpcRequest(
82+
const { result } = await sendJsonRpcRequest(
10583
record,
10684
'tools/list',
10785
{},
10886
ToolListResultSchema,
10987
fetchImpl
11088
);
111-
return { tools: result.tools ?? [], serverMetadata };
89+
return result.tools ?? [];
11290
}
11391

11492
const REQUEST_HEADERS = {
@@ -117,29 +95,21 @@ const REQUEST_HEADERS = {
11795
[STORYBOOK_MCP_PROXY_HEADER]: STORYBOOK_MCP_PROXY_HEADER_VALUE,
11896
};
11997

120-
type InitializeMcpSessionResult = {
121-
sessionId: string | null;
122-
serverMetadata: McpServerMetadata;
123-
};
124-
12598
/**
12699
* Send a minimal MCP `initialize` request carrying {@link MCP_CLIENT_INFO} and return the session
127-
* id plus any best-effort server metadata from the initialize response.
100+
* id when the server assigns one.
128101
*
129102
* The session id is MCP Streamable HTTP spec behavior, not a tmcp implementation detail: the
130103
* server assigns it during initialization, returns it in the `Mcp-Session-Id` response header,
131104
* and associates the session's clientInfo with later requests echoing that header. The same
132-
* initialize result can also carry server instructions, so metadata is parsed from the response
133-
* body even when no session id is assigned.
105+
* initialize response body is still consumed so the follow-up request cannot race ahead of the
106+
* server storing clientInfo for telemetry segmentation.
134107
*
135108
* The handshake is best-effort — when it fails (or a future server ignores sessions), the actual
136-
* request proceeds without a session and keeps working; only the telemetry segmentation and
137-
* initialize metadata are lost, and error reporting stays anchored on the real call. It shares the
138-
* full {@link REQUEST_TIMEOUT_MS} budget rather than a tighter one: `storybook ai --help` renders the
139-
* server instructions carried here, and the only thing that slows the handshake is the dev server
140-
* still starting up — which the command must wait through anyway. A tighter budget would just drop
141-
* the instructions on that first slow request while the command list (sent right after) comes back
142-
* fine.
109+
* request proceeds without a session and keeps working; only telemetry segmentation is lost, and
110+
* error reporting stays anchored on the real call. It shares the full {@link REQUEST_TIMEOUT_MS}
111+
* budget rather than a tighter one because the only thing that slows the handshake is the dev
112+
* server still starting up, which the command must wait through anyway.
143113
*
144114
* Sessions are deliberately one-shot: each JSON-RPC request gets its own handshake and the session
145115
* is never reused or closed. A CLI invocation makes one request on the happy path (two on error
@@ -153,7 +123,7 @@ type InitializeMcpSessionResult = {
153123
async function initializeMcpSession(
154124
target: string,
155125
fetchImpl: typeof fetch
156-
): Promise<InitializeMcpSessionResult> {
126+
): Promise<string | null> {
157127
try {
158128
const response = await fetchImpl(target, {
159129
method: 'POST',
@@ -173,22 +143,18 @@ async function initializeMcpSession(
173143
const sessionId = response.headers.get('mcp-session-id');
174144
if (!response.ok) {
175145
await response.body?.cancel();
176-
return { sessionId: null, serverMetadata: {} };
146+
return null;
177147
}
178148

179-
let serverMetadata: McpServerMetadata = {};
180149
try {
181-
serverMetadata = parseInitializeServerMetadata(
182-
await readJsonRpcResponse(response, target),
183-
target
184-
);
150+
await response.text();
185151
} catch {
186-
// The initialize request is best-effort metadata; a malformed response must not block the
152+
// The initialize request is best-effort telemetry setup; a malformed body must not block the
187153
// actual tools/list or tools/call request from preserving its existing behavior.
188154
}
189-
return { sessionId, serverMetadata };
155+
return sessionId;
190156
} catch {
191-
return { sessionId: null, serverMetadata: {} };
157+
return null;
192158
}
193159
}
194160

@@ -212,15 +178,15 @@ async function sendJsonRpcRequest<TResult>(
212178
params: unknown,
213179
resultSchema: v.GenericSchema<unknown, TResult>,
214180
fetchImpl: typeof fetch
215-
): Promise<{ result: TResult; serverMetadata: McpServerMetadata }> {
181+
): Promise<{ result: TResult }> {
216182
const endpoint = record.mcp.endpoint;
217183
if (!endpoint) {
218184
throw new Error(`The Storybook instance at ${record.cwd} has no server endpoint registered`);
219185
}
220186

221187
const target = new URL(endpoint, record.url).href;
222188

223-
const { sessionId, serverMetadata } = await initializeMcpSession(target, fetchImpl);
189+
const sessionId = await initializeMcpSession(target, fetchImpl);
224190

225191
const response = await fetchImpl(target, {
226192
method: 'POST',
@@ -254,7 +220,7 @@ async function sendJsonRpcRequest<TResult>(
254220
if (!result.success) {
255221
throw unexpectedShapeError(target);
256222
}
257-
return { result: result.output, serverMetadata };
223+
return { result: result.output };
258224
}
259225

260226
function unexpectedShapeError(target: string): Error {
@@ -263,8 +229,7 @@ function unexpectedShapeError(target: string): Error {
263229

264230
/**
265231
* Unwrap a parsed JSON-RPC payload into its `result`, or report why it isn't usable. The command
266-
* path throws on the reported error; the best-effort initialize-metadata parse falls back to empty
267-
* — sharing this keeps the envelope handling in one place.
232+
* path throws on the reported error.
268233
*/
269234
function unwrapJsonRpcResult(
270235
payload: unknown,
@@ -286,21 +251,6 @@ function unwrapJsonRpcResult(
286251
return { ok: true, result: envelope.output.result };
287252
}
288253

289-
function parseInitializeServerMetadata(payload: unknown, target: string): McpServerMetadata {
290-
const unwrapped = unwrapJsonRpcResult(payload, target);
291-
if (!unwrapped.ok) {
292-
return {};
293-
}
294-
295-
const result = v.safeParse(InitializeResultSchema, unwrapped.result);
296-
if (!result.success) {
297-
return {};
298-
}
299-
300-
const instructions = result.output.instructions?.trim();
301-
return instructions ? { instructions } : {};
302-
}
303-
304254
async function readJsonRpcResponse(response: Response, endpoint: string): Promise<unknown> {
305255
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
306256
const body = await response.text();

0 commit comments

Comments
 (0)