Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/loud-maps-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@storybook/addon-mcp": patch
"@storybook/mcp": patch
---

Show private composed Storybooks as own-MCP guidance when accessed through the local MCP proxy.
49 changes: 49 additions & 0 deletions packages/addon-mcp/src/auth/composition-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RequiresOwnMcpError } from '@storybook/mcp';
import { CompositionAuth, extractBearerToken } from './composition-auth.ts';

describe('CompositionAuth', () => {
Expand Down Expand Up @@ -326,6 +327,54 @@ describe('CompositionAuth', () => {
);
});

it('throws requires-own-mcp for trusted proxy requests without a token', async () => {
const auth = new CompositionAuth();

vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 401,
headers: new Headers({
'WWW-Authenticate':
'Bearer resource_metadata="http://remote.example.com/.well-known/oauth-protected-resource"',
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
resource: 'http://remote.example.com/mcp',
authorization_servers: ['http://auth.example.com'],
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: 'http://auth.example.com',
authorization_endpoint: 'http://auth.example.com/authorize',
token_endpoint: 'http://auth.example.com/token',
}),
}),
);

const source = { id: 'remote', title: 'Remote', url: 'http://remote.example.com' };
await auth.initialize([source]);

const provider = auth.createManifestProvider('http://localhost:6006', {
requiresOwnMcpForUnauthenticatedRequests: true,
});
const request = new Request('http://localhost:6006/mcp');

await expect(provider(request, './manifests/components.json', source)).rejects.toBeInstanceOf(
RequiresOwnMcpError,
);
expect(auth.hadAuthError(request)).toBe(false);
});

it('throws auth error when response is invalid manifest and /mcp returns 401', async () => {
const auth = new CompositionAuth();

Expand Down
42 changes: 38 additions & 4 deletions packages/addon-mcp/src/auth/composition-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
* MCP clients like VS Code to handle the OAuth flow with Chromatic.
*/

import { ComponentManifestMap, DocsManifestMap, type Source } from '@storybook/mcp';
import {
ComponentManifestMap,
DocsManifestMap,
RequiresOwnMcpError,
type Source,
} from '@storybook/mcp';
import * as v from 'valibot';

export interface ComposedRef {
Expand Down Expand Up @@ -41,6 +46,10 @@ export type ManifestProvider = (
path: string,
source?: Source,
) => Promise<string>;
type RemoteSource = Source & { url: string };

export const STORYBOOK_MCP_PROXY_HEADER = 'X-Storybook-MCP-Proxy';
export const STORYBOOK_MCP_PROXY_HEADER_VALUE = 'true';

const MANIFEST_CACHE_TTL = 60 * 60 * 1000; // 60 minutes
const REVALIDATION_TTL = 60 * 1000; // 60 seconds
Expand Down Expand Up @@ -144,14 +153,26 @@ export class CompositionAuth {
}

/** Create a manifest provider for multi-source mode. */
createManifestProvider(localOrigin: string): ManifestProvider {
createManifestProvider(
localOrigin: string,
options: { requiresOwnMcpForUnauthenticatedRequests?: boolean } = {},
): ManifestProvider {
return async (request, path, source) => {
const token = extractBearerToken(request?.headers.get('Authorization'));
const baseUrl = source?.url ?? localOrigin;
const remoteSource: RemoteSource | undefined = source?.url
? { ...source, url: source.url }
: undefined;
const baseUrl = remoteSource?.url ?? localOrigin;
const manifestUrl = `${baseUrl}${path.replace('./', '/')}`;
const isRemote = !!source?.url;
const isRemote = !!remoteSource;
const needsAuth = isRemote && this.#isAuthRequiredUrl(baseUrl);
const tokenForRequest = needsAuth ? token : null;
const shouldUseOwnMcp =
isRemote && !token && options.requiresOwnMcpForUnauthenticatedRequests;

if (needsAuth && shouldUseOwnMcp && remoteSource) {
throw new RequiresOwnMcpError(remoteSource);
}

// New token = user re-authenticated, invalidate all cached manifests
if (token && token !== this.#lastToken) {
Expand Down Expand Up @@ -197,6 +218,9 @@ export class CompositionAuth {
return text;
} catch (error) {
if (error instanceof AuthenticationError && request) {
if (shouldUseOwnMcp && remoteSource) {
throw new RequiresOwnMcpError(remoteSource);
}
this.#authErrors.set(request, error);
}
throw error;
Expand Down Expand Up @@ -355,3 +379,13 @@ export function extractBearerToken(
const bearer = values.find((value) => typeof value === 'string' && value.startsWith('Bearer '));
return bearer ? bearer.slice(7) : null;
}

export function isStorybookMcpProxyRequest(
headerValue: string | string[] | null | undefined,
): boolean {
const values = Array.isArray(headerValue) ? headerValue : [headerValue];
return values.some(
(value) =>
typeof value === 'string' && value.trim().toLowerCase() === STORYBOOK_MCP_PROXY_HEADER_VALUE,
);
}
2 changes: 2 additions & 0 deletions packages/addon-mcp/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
export {
CompositionAuth,
AuthenticationError,
STORYBOOK_MCP_PROXY_HEADER,
extractBearerToken,
isStorybookMcpProxyRequest,
type ComposedRef,
type ManifestProvider,
} from './composition-auth.ts';
111 changes: 111 additions & 0 deletions packages/addon-mcp/src/preset.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Options } from 'storybook/internal/types';
import { RequiresOwnMcpError } from '@storybook/mcp';
import { experimental_devServer } from './preset.ts';
import { STORYBOOK_MCP_PROXY_HEADER } from './auth/index.ts';
import * as mcpHandlerModule from './mcp-handler.ts';
import * as runStoryTests from './tools/run-story-tests.ts';

Expand Down Expand Up @@ -35,6 +37,58 @@ describe('experimental_devServer', () => {
vi.unstubAllGlobals();
});

const stubPrivateRefDiscovery = () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 401,
headers: new Headers({
'WWW-Authenticate':
'Bearer resource_metadata="https://private.example.com/.well-known/oauth-protected-resource"',
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
resource: 'https://private.example.com/mcp',
authorization_servers: ['https://auth.example.com'],
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
}),
}),
);
};

const createOptionsWithPrivateRef = () =>
({
...mockOptions,
port: 6006,
presets: {
apply: vi.fn((key: string) => {
if (key === 'refs') {
return Promise.resolve({
private: { title: 'Private', url: 'https://private.example.com' },
});
}
if (key === 'features') {
return Promise.resolve({ componentsManifest: false });
}
return Promise.resolve(undefined);
}),
},
}) as unknown as Options;

it('should register /mcp POST endpoint', async () => {
await (experimental_devServer as any)(mockApp, mockOptions);

Expand Down Expand Up @@ -196,6 +250,63 @@ describe('experimental_devServer', () => {
);
});

it('should keep direct unauthenticated requests on the OAuth challenge path', async () => {
const mcpServerHandler = vi
.spyOn(mcpHandlerModule, 'mcpServerHandler')
.mockResolvedValue(undefined);
stubPrivateRefDiscovery();

await (experimental_devServer as any)(mockApp, createOptionsWithPrivateRef());

const mockRes = { writeHead: vi.fn(), end: vi.fn() } as any;
await mcpHandler(
{
headers: {},
},
mockRes,
);

expect(mockRes.writeHead).toHaveBeenCalledWith(
401,
expect.objectContaining({
'WWW-Authenticate': expect.stringContaining('/.well-known/oauth-protected-resource'),
}),
);
expect(mockRes.end).toHaveBeenCalledWith('401 - Unauthorized');
expect(mcpServerHandler).not.toHaveBeenCalled();
});

it('should let proxy requests reach MCP with a requires-own-mcp manifest provider', async () => {
const mcpServerHandler = vi
.spyOn(mcpHandlerModule, 'mcpServerHandler')
.mockResolvedValue(undefined);
stubPrivateRefDiscovery();

await (experimental_devServer as any)(mockApp, createOptionsWithPrivateRef());

const mockRes = { writeHead: vi.fn(), end: vi.fn() } as any;
await mcpHandler(
{
headers: { [STORYBOOK_MCP_PROXY_HEADER.toLowerCase()]: 'true' },
},
mockRes,
);

expect(mockRes.writeHead).not.toHaveBeenCalledWith(401, expect.anything());
expect(mcpServerHandler).toHaveBeenCalledTimes(1);

const { manifestProvider, sources } = mcpServerHandler.mock.calls[0]![0];
expect(manifestProvider).toBeDefined();
expect(sources).toBeDefined();
await expect(
manifestProvider!(
new Request('http://localhost:6006/mcp'),
'./manifests/components.json',
sources![1],
),
).rejects.toBeInstanceOf(RequiresOwnMcpError);
});

it('should serve HTML for browser GET requests', async () => {
let getHandler: any;
mockApp.get = vi.fn((path, handler) => {
Expand Down
22 changes: 17 additions & 5 deletions packages/addon-mcp/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import htmlTemplate from './template.html';
import path from 'node:path';
import {
CompositionAuth,
STORYBOOK_MCP_PROXY_HEADER,
extractBearerToken,
isStorybookMcpProxyRequest as hasStorybookMcpProxyHeader,
type ComposedRef,
type ManifestProvider,
} from './auth/index.ts';
Expand All @@ -18,6 +20,7 @@ import type { Source } from '@storybook/mcp';
import type { IncomingMessage, ServerResponse } from 'node:http';

const DEFAULT_MCP_ENDPOINT = '/mcp';
const STORYBOOK_MCP_PROXY_HEADER_KEY = STORYBOOK_MCP_PROXY_HEADER.toLowerCase();

export const previewAnnotations: PresetPropertyFn<'previewAnnotations'> = async (
existingAnnotations = [],
Expand Down Expand Up @@ -46,7 +49,7 @@ export const experimental_devServer: PresetPropertyFn<

// Build sources and manifest provider
let sources: Source[] | undefined;
let manifestProvider: ManifestProvider | undefined;
let createManifestProvider: ((req: IncomingMessage) => ManifestProvider) | undefined;

if (refs.length > 0) {
logger.info(`Initializing composition with ${refs.length} remote Storybook(s)`);
Expand All @@ -60,7 +63,10 @@ export const experimental_devServer: PresetPropertyFn<
logger.info(`Sources: ${sources.map((s) => s.id).join(', ')}`);

// Create manifest provider that handles multi-source
manifestProvider = compositionAuth.createManifestProvider(origin);
createManifestProvider = (req) =>
compositionAuth.createManifestProvider(origin, {
requiresOwnMcpForUnauthenticatedRequests: isStorybookMcpProxyHttpRequest(req),
});
}

// Serve .well-known/oauth-protected-resource for MCP auth
Expand All @@ -78,7 +84,7 @@ export const experimental_devServer: PresetPropertyFn<

const requireAuth = (req: IncomingMessage, res: ServerResponse): boolean => {
const token = extractBearerToken(req.headers['authorization']);
if (compositionAuth.requiresAuth && !token) {
if (compositionAuth.requiresAuth && !token && !isStorybookMcpProxyHttpRequest(req)) {
res.writeHead(401, {
'Content-Type': 'text/plain',
'WWW-Authenticate': compositionAuth.buildWwwAuthenticate(origin),
Expand All @@ -98,7 +104,7 @@ export const experimental_devServer: PresetPropertyFn<
options,
addonOptions,
sources,
manifestProvider,
manifestProvider: createManifestProvider?.(req),
compositionAuth,
});
});
Expand All @@ -121,7 +127,7 @@ export const experimental_devServer: PresetPropertyFn<
options,
addonOptions,
sources,
manifestProvider,
manifestProvider: createManifestProvider?.(req),
compositionAuth,
});
}
Expand Down Expand Up @@ -180,6 +186,12 @@ export const features: PresetPropertyFn<'features'> = async (existingFeatures) =
};
};

function isStorybookMcpProxyHttpRequest(req: IncomingMessage): boolean {
const headerValue =
req.headers[STORYBOOK_MCP_PROXY_HEADER_KEY] ?? req.headers[STORYBOOK_MCP_PROXY_HEADER];
return hasStorybookMcpProxyHeader(headerValue);
}

/**
* Get composed Storybook refs from Storybook config.
* See: https://storybook.js.org/docs/sharing/storybook-composition
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-proxy/src/utils/proxy-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('proxyToolCall', () => {
const init = call[1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers.Accept).toBe('application/json, text/event-stream');
expect(headers['X-Storybook-MCP-Proxy']).toBe('true');
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({
jsonrpc: '2.0',
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp-proxy/src/utils/proxy-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type {
StorybookInstanceRecordV1,
} from '../types/index.ts';

const STORYBOOK_MCP_PROXY_HEADER = 'X-Storybook-MCP-Proxy';
const STORYBOOK_MCP_PROXY_HEADER_VALUE = 'true';

/**
* Forward an MCP `tools/call` JSON-RPC request to a local Storybook MCP server.
*
Expand All @@ -28,6 +31,7 @@ export async function proxyToolCall(
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
[STORYBOOK_MCP_PROXY_HEADER]: STORYBOOK_MCP_PROXY_HEADER_VALUE,
},
body: JSON.stringify({
jsonrpc: '2.0',
Expand Down
Loading
Loading