Skip to content
Merged
39 changes: 24 additions & 15 deletions code/addons/mcp/src/tools/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME } from './tool-names.ts';
import {
getToolsetToolMetadata,
registerToolsetTool,
type McpToolsetGroup,
type ToolsetToolOptions,
} from './toolset-tools.ts';

Expand Down Expand Up @@ -52,12 +53,11 @@ export type AddonToolRegistryContext = {
options?: Options;
};

type AddonToolset = keyof NonNullable<AddonContext['toolsets']>;
type ToolEnabled = Parameters<McpServer<any, AddonContext>['tool']>[0]['enabled'];

type AddonToolDefinition = {
name: string;
toolset: AddonToolset;
toolset: McpToolsetGroup;
available?: (context: AddonToolRegistryContext) => boolean;
getMetadata: (context: AddonToolRegistryContext) => ToolMetadata;
register: (
Expand All @@ -68,8 +68,10 @@ type AddonToolDefinition = {
getLocalTool?: (context: AddonToolRegistryContext & { options: Options }) => StorybookAiLocalTool;
};

const isToolsetEnabled = (toolset: AddonToolset, toolsets: AddonContext['toolsets'] | undefined) =>
toolsets?.[toolset] ?? true;
const isToolsetEnabled = (
toolset: McpToolsetGroup,
toolsets: AddonContext['toolsets'] | undefined
) => toolsets?.[toolset] ?? true;

const isToolAvailable = (definition: AddonToolDefinition, context: AddonToolRegistryContext) =>
definition.available?.(context) ?? true;
Expand All @@ -80,7 +82,7 @@ const isMetadataToolEnabled = (
) => isToolsetEnabled(definition.toolset, context.toolsets) && isToolAvailable(definition, context);

const createToolsetEnabled =
(server: McpServer<any, AddonContext>, toolset: AddonToolset): ToolEnabled =>
(server: McpServer<any, AddonContext>, toolset: McpToolsetGroup): ToolEnabled =>
() =>
server.ctx.custom?.toolsets?.[toolset] ?? true;

Expand Down Expand Up @@ -139,14 +141,19 @@ function compositionDocsToolset(server?: McpServer<any, AddonContext>): DocsTool
});
}

const DOCS_EVENT_NAMES = {
'docs.list': 'tool:listAllDocumentation',
'docs.show': 'tool:getDocumentation',
'docs.showStory': 'tool:getDocumentationForStory',
} as const;

/** The docs tools, in the two shapes the registry needs: registered toolset, or per-request one. */
function docsToolDefinition(
method: 'docs.list' | 'docs.show' | 'docs.showStory'
): AddonToolDefinition {
const forContext = (context: AddonToolRegistryContext): ToolsetToolOptions =>
context.multiSource
? { method, resolveToolset: (server) => compositionDocsToolset(server) }
: { method };
function docsToolDefinition(method: keyof typeof DOCS_EVENT_NAMES): AddonToolDefinition {
const forContext = (context: AddonToolRegistryContext): ToolsetToolOptions => ({
method,
mcpEventName: DOCS_EVENT_NAMES[method],
...(context.multiSource ? { resolveToolset: (server) => compositionDocsToolset(server) } : {}),
});

return {
name: toMcpToolName(method),
Expand All @@ -170,6 +177,7 @@ const addonToolDefinitions: AddonToolDefinition[] = [
toolset: 'dev',
options: {
method: 'stories.preview',
mcpEventName: 'tool:previewStories',
extras: { _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } } },
},
}),
Expand Down Expand Up @@ -204,12 +212,12 @@ const addonToolDefinitions: AddonToolDefinition[] = [
fromToolset({
toolset: 'dev',
available: ({ availability }) => availability.changeDetectionEnabled,
options: { method: 'stories.changed' },
options: { method: 'stories.changed', mcpEventName: 'tool:getChangedStories' },
}),
fromToolset({
toolset: 'dev',
available: ({ availability }) => availability.moduleGraphSupported,
options: { method: 'stories.findByComponent' },
options: { method: 'stories.findByComponent', mcpEventName: 'tool:getStoriesByComponent' },
}),
fromToolset({
toolset: 'dev',
Expand All @@ -224,13 +232,14 @@ const addonToolDefinitions: AddonToolDefinition[] = [
(server.ctx.custom?.reviewEnabled ?? availability.reviewEnabled),
options: {
method: 'review.create',
mcpEventName: 'tool:displayReview',
wrapSchema: withFriendlyErrors,
},
}),
fromToolset({
toolset: 'test',
available: ({ availability }) => availability.testSupported,
options: { method: 'test.run' },
options: { method: 'test.run', mcpEventName: 'tool:runStoryTests' },
}),
// Docs run on the core docs toolset in both modes. A composition builds its toolset per request,
// because the sources it reads and the provider that fetches them belong to the request.
Expand Down
39 changes: 25 additions & 14 deletions code/addons/mcp/src/tools/toolset-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,12 @@ function registerStubStoriesToolset(
handler:
overrides.handler ??
(async (input: { id: string }, ctx) => {
await ctx.telemetry?.('tool:previewStories', {
toolset: 'dev',
inputStoryCount: 1,
});
const stories = [{ previewUrl: `${ctx.origin}/?path=/story/${input.id}` }];
return {
ok: true,
data: { stories, extraNotInContract: 'internal' },
markdown: stories.map((story) => story.previewUrl).join('\n'),
telemetry: { payload: { inputStoryCount: 1 } },
};
}),
},
Expand All @@ -57,7 +54,7 @@ function makeServer(custom: Record<string, unknown> = {}) {
} as any;
}

const previewOptions = { method: 'stories.preview' } as const;
const previewOptions = { method: 'stories.preview', mcpEventName: 'tool:previewStories' } as const;

describe('toolset-backed MCP tools', () => {
beforeEach(() => {
Expand Down Expand Up @@ -146,7 +143,11 @@ describe('toolset-backed MCP tools', () => {
}) as any
);

const result = await callToolsetMethod(makeServer(), { method: 'stories.changed' }, {});
const result = await callToolsetMethod(
makeServer(),
{ method: 'stories.changed', mcpEventName: 'tool:getChangedStories' },
{}
);

expect(result.content).toEqual([{ type: 'text', text: 'no changes' }]);
expect(result.structuredContent).toBeUndefined();
Expand Down Expand Up @@ -219,18 +220,28 @@ describe('toolset-backed MCP tools', () => {
expect(vi.mocked(logger.error).mock.calls[0][0]).toContain('boom');
});

it('forwards method telemetry with the surface fields the adapter owns', async () => {
it('sends the report on the outcome under the legacy event name the addon keeps', async () => {
registerStubStoriesToolset();

await callToolsetMethod(makeServer(), previewOptions, { id: 'button--primary' });

expect(collectTelemetry).toHaveBeenCalledWith(
expect.objectContaining({
event: 'tool:previewStories',
toolset: 'dev',
inputStoryCount: 1,
})
);
expect(collectTelemetry).toHaveBeenCalledWith({
event: 'tool:previewStories',
server: expect.anything(),
toolset: 'stories',
tool: 'preview',
inputStoryCount: 1,
});
});

it('emits no telemetry for an outcome without a report', async () => {
registerStubStoriesToolset({
handler: async () => ({ ok: true, data: { stories: [] }, markdown: '' }),
});

await callToolsetMethod(makeServer(), previewOptions, { id: 'button--primary' });

expect(collectTelemetry).not.toHaveBeenCalled();
});

it('emits no telemetry when the session disabled it', async () => {
Expand Down
49 changes: 31 additions & 18 deletions code/addons/mcp/src/tools/toolset-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import { logger } from 'storybook/internal/node-logger';
import { OpenServiceToolsetOutputMismatchError } from 'storybook/internal/server-errors';
import {
getToolset,
invokeToolsetMethod,
parseToolsetMethodId,
resolveToolsetDescription,
toMcpToolName,
type AnyToolsetDefinition,
type AnyToolsetOutcome,
type ToolsetCtx,
type ToolsetMethod,
type ToolsetMethodId,
type ToolsetMethodReport,
} from 'storybook/open-service';
import type { McpServer } from 'tmcp';

Expand All @@ -31,9 +34,18 @@ import type { StorybookAiToolCallResult } from './tool-registry.ts';
type Server = McpServer<any, AddonContext>;
type ToolEnabled = Parameters<Server['tool']>[0]['enabled'];

// The grouping behind the enable gate and the `X-MCP-Toolsets` header; not a telemetry field.
export type McpToolsetGroup = keyof NonNullable<AddonContext['toolsets']>;

export type ToolsetToolOptions = {
/** Which toolset method backs this MCP tool. */
method: ToolsetMethodId;
/**
* The `event` of this tool's `addon-mcp` record. These names predate the toolsets
* (`tool:getChangedStories`) and replace the generated `tool:stories_changed` on this surface
* only, so MCP usage data stays continuous across versions. The toolsets know nothing of them.
*/
mcpEventName: string;
/** Extra MCP-only tool metadata, e.g. the preview app resource. */
extras?: Record<string, unknown>;
/** Wraps the input schema before publishing it (used for friendlier validation errors). */
Expand All @@ -52,16 +64,14 @@ function resolveToolset(options: ToolsetToolOptions, server?: Server): AnyToolse
if (options.resolveToolset) {
return options.resolveToolset(server);
}
const [toolsetId] = options.method.split('.');
return getToolset(toolsetId);
return getToolset(parseToolsetMethodId(options.method).toolsetId);
}

function resolveMethod(
toolset: AnyToolsetDefinition,
options: ToolsetToolOptions
): ToolsetMethod<any, AnyToolsetOutcome> {
const [, methodName] = options.method.split('.');
return toolset.methods[methodName];
return toolset.methods[parseToolsetMethodId(options.method).methodName];
}

/**
Expand Down Expand Up @@ -95,36 +105,39 @@ async function toStructuredContent(
}

function buildContext(server: Server): ToolsetCtx {
const custom = server.ctx.custom;
return {
transport: 'mcp',
// The toolset origin is the complete UI base URL, including any deployment subpath.
origin: resolveToolsetOrigin(custom ?? {}),
origin: resolveToolsetOrigin(server.ctx.custom ?? {}),
getService: (serviceId, serviceOptions) => getService(serviceId as any, serviceOptions) as any,
telemetry: custom?.disableTelemetry
? undefined
: async (event, payload) => {
await collectTelemetry({
event,
server,
...payload,
});
},
};
}

async function reportToolsetTelemetry(
server: Server,
event: string,
report: ToolsetMethodReport | undefined
): Promise<void> {
if (!report || server.ctx.custom?.disableTelemetry) {
return;
}
const { payload, toolset, tool } = report;
await collectTelemetry({ event, server, ...payload, toolset, tool });
}

/** Runs one toolset method and unwraps its outcome into an MCP tool result. */
export async function callToolsetMethod(
server: Server,
options: ToolsetToolOptions,
input: unknown
): Promise<StorybookAiToolCallResult> {
const toolset = resolveToolset(options, server);
const method = resolveMethod(toolset, options);
const ctx = buildContext(server);
const { methodName } = parseToolsetMethodId(options.method);
const method = toolset.methods[methodName];

try {
const outcome = await method.handler(input as never, ctx);
const outcome = await invokeToolsetMethod(toolset, methodName, input, buildContext(server));
await reportToolsetTelemetry(server, options.mcpEventName, outcome.telemetry);
const structuredContent = await toStructuredContent(method.output, outcome.data);
const blocks = Array.isArray(outcome.markdown) ? outcome.markdown : [outcome.markdown];

Expand Down
Loading