@storybook/addon-mcp 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,7 +50,6 @@ export default {
50
50
  dev: true, // Tools for story URL retrieval and UI building instructions (default: true)
51
51
  docs: true, // Tools for component manifest and documentation (default: true, requires experimental feature flag below 👇)
52
52
  },
53
- experimentalFormat: 'markdown', // Output format: 'markdown' (default) or 'xml'
54
53
  },
55
54
  },
56
55
  ],
package/dist/preset.js CHANGED
@@ -3,18 +3,19 @@ import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
3
3
  import { HttpTransport } from "@tmcp/transport-http";
4
4
  import path from "node:path";
5
5
  import url from "node:url";
6
+ import { normalizeStoryPath } from "storybook/internal/common";
6
7
  import { storyNameFromExport } from "storybook/internal/csf";
7
8
  import { logger } from "storybook/internal/node-logger";
8
9
  import * as v from "valibot";
9
10
  import { telemetry } from "storybook/internal/telemetry";
10
11
  import { stringify } from "picoquery";
11
12
  import fs from "node:fs/promises";
12
- import { addGetDocumentationTool, addListAllDocumentationTool } from "@storybook/mcp";
13
+ import { ComponentManifestMap, DocsManifestMap, addGetDocumentationTool, addListAllDocumentationTool } from "@storybook/mcp";
13
14
  import { buffer } from "node:stream/consumers";
14
15
 
15
16
  //#region package.json
16
17
  var name = "@storybook/addon-mcp";
17
- var version = "0.2.3";
18
+ var version = "0.3.0";
18
19
  var description = "Help agents automatically write and test stories for your UI components";
19
20
 
20
21
  //#endregion
@@ -195,16 +196,13 @@ const frameworkToRendererMap = {
195
196
 
196
197
  //#endregion
197
198
  //#region src/types.ts
198
- const AddonOptions = v.object({
199
- toolsets: v.optional(v.object({
200
- dev: v.exactOptional(v.boolean(), true),
201
- docs: v.exactOptional(v.boolean(), true)
202
- }), {
203
- dev: true,
204
- docs: true
205
- }),
206
- experimentalFormat: v.optional(v.picklist(["xml", "markdown"]), "markdown")
207
- });
199
+ const AddonOptions = v.object({ toolsets: v.optional(v.object({
200
+ dev: v.exactOptional(v.boolean(), true),
201
+ docs: v.exactOptional(v.boolean(), true)
202
+ }), {
203
+ dev: true,
204
+ docs: true
205
+ }) });
208
206
  /**
209
207
  * Schema for a single story input when requesting story URLs.
210
208
  */
@@ -254,6 +252,9 @@ const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
254
252
  async function addPreviewStoriesTool(server) {
255
253
  const previewStoryAppScript = await fs.readFile(url.fileURLToPath(import.meta.resolve("@storybook/addon-mcp/internal/preview-stories-app-script")), "utf-8");
256
254
  const appHtml = preview_stories_app_template_default.replace("// APP_SCRIPT_PLACEHOLDER", previewStoryAppScript);
255
+ const normalizeImportPath = (importPath) => {
256
+ return slash(normalizeStoryPath(path.posix.normalize(slash(importPath))));
257
+ };
257
258
  server.resource({
258
259
  name: PREVIEW_STORIES_RESOURCE_URI,
259
260
  description: "App resource for the Preview Stories tool",
@@ -297,7 +298,7 @@ async function addPreviewStoriesTool(server) {
297
298
  const { exportName, explicitStoryName, absoluteStoryPath } = inputParams;
298
299
  const normalizedCwd = slash(process.cwd());
299
300
  const normalizedAbsolutePath = slash(absoluteStoryPath);
300
- const relativePath = `./${path.posix.relative(normalizedCwd, normalizedAbsolutePath)}`;
301
+ const relativePath = normalizeImportPath(path.posix.relative(normalizedCwd, normalizedAbsolutePath));
301
302
  logger.debug("Searching for:");
302
303
  logger.debug({
303
304
  exportName,
@@ -305,7 +306,7 @@ async function addPreviewStoriesTool(server) {
305
306
  absoluteStoryPath,
306
307
  relativePath
307
308
  });
308
- const foundStory = entriesList.find((entry) => entry.importPath === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
309
+ const foundStory = entriesList.find((entry) => normalizeImportPath(entry.importPath) === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
309
310
  if (foundStory) {
310
311
  logger.debug(`Found story ID: ${foundStory.id}`);
311
312
  let previewUrl = `${origin$1}/?path=/story/${foundStory.id}`;
@@ -427,7 +428,7 @@ let transport;
427
428
  let origin;
428
429
  let initialize;
429
430
  let disableTelemetry;
430
- const initializeMCPServer = async (options) => {
431
+ const initializeMCPServer = async (options, multiSource) => {
431
432
  disableTelemetry = (await options.presets.apply("core", {}))?.disableTelemetry ?? false;
432
433
  const server = new McpServer({
433
434
  name,
@@ -452,33 +453,35 @@ const initializeMCPServer = async (options) => {
452
453
  logger.info("Experimental components manifest feature detected - registering component tools");
453
454
  const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
454
455
  await addListAllDocumentationTool(server, contextAwareEnabled);
455
- await addGetDocumentationTool(server, contextAwareEnabled);
456
+ await addGetDocumentationTool(server, contextAwareEnabled, { multiSource });
456
457
  }
457
458
  transport = new HttpTransport(server, { path: null });
458
459
  origin = `http://localhost:${options.port}`;
459
460
  logger.debug(`MCP server origin: ${origin}`);
460
461
  return server;
461
462
  };
462
- const mcpServerHandler = async ({ req, res, options, addonOptions }) => {
463
- if (!initialize) initialize = initializeMCPServer(options);
463
+ const mcpServerHandler = async ({ req, res, options, addonOptions, sources, manifestProvider, compositionAuth }) => {
464
+ if (!initialize) initialize = initializeMCPServer(options, sources?.some((s) => s.url));
464
465
  const server = await initialize;
465
466
  const webRequest = await incomingMessageToWebRequest(req);
466
467
  const addonContext = {
467
468
  options,
468
469
  toolsets: getToolsets(webRequest, addonOptions),
469
- format: addonOptions.experimentalFormat,
470
470
  origin,
471
471
  disableTelemetry,
472
472
  request: webRequest,
473
+ sources,
474
+ manifestProvider,
473
475
  ...!disableTelemetry && {
474
- onListAllDocumentation: async ({ manifests, resultText }) => {
476
+ onListAllDocumentation: async ({ manifests, resultText, sources: sourceManifests }) => {
475
477
  await collectTelemetry({
476
478
  event: "tool:listAllDocumentation",
477
479
  server,
478
480
  toolset: "docs",
479
481
  componentCount: Object.keys(manifests.componentManifest.components).length,
480
482
  docsCount: Object.keys(manifests.docsManifest?.docs || {}).length,
481
- resultTokenCount: estimateTokens(resultText)
483
+ resultTokenCount: estimateTokens(resultText),
484
+ sourceCount: sourceManifests?.length
482
485
  });
483
486
  },
484
487
  onGetDocumentation: async ({ input, foundDocumentation, resultText }) => {
@@ -494,7 +497,19 @@ const mcpServerHandler = async ({ req, res, options, addonOptions }) => {
494
497
  }
495
498
  };
496
499
  const response = await transport.respond(webRequest, addonContext);
497
- if (response) await webResponseToServerResponse(response, res);
500
+ if (response) {
501
+ const body = await response.arrayBuffer();
502
+ await webResponseToServerResponse(compositionAuth.hadAuthError(webRequest) ? new Response("401 - Unauthorized", {
503
+ status: 401,
504
+ headers: {
505
+ "Content-Type": "text/plain",
506
+ "WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin)
507
+ }
508
+ }) : new Response(body, {
509
+ status: response.status,
510
+ headers: response.headers
511
+ }), res);
512
+ }
498
513
  };
499
514
  /**
500
515
  * Converts a Node.js IncomingMessage to a Web Request.
@@ -551,32 +566,314 @@ function getToolsets(request, addonOptions) {
551
566
  //#region src/template.html
552
567
  var template_default = "<!doctype html>\n<html>\n <head>\n {{REDIRECT_META}}\n <style>\n @font-face {\n font-family: 'Nunito Sans';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');\n }\n\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n html,\n body {\n height: 100%;\n font-family:\n 'Nunito Sans',\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n Oxygen,\n Ubuntu,\n Cantarell,\n sans-serif;\n }\n\n body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n padding: 2rem;\n background-color: #ffffff;\n color: rgb(46, 52, 56);\n line-height: 1.6;\n }\n\n p {\n margin-bottom: 1rem;\n }\n\n code {\n font-family: 'Monaco', 'Courier New', monospace;\n background: #f5f5f5;\n padding: 0.2em 0.4em;\n border-radius: 3px;\n }\n\n a {\n color: #1ea7fd;\n }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n }\n\n .toolsets {\n margin: 1.5rem 0;\n text-align: left;\n max-width: 500px;\n }\n\n .toolsets h3 {\n font-size: 1rem;\n margin-bottom: 0.75rem;\n text-align: center;\n }\n\n .toolset {\n margin-bottom: 1rem;\n padding: 0.75rem 1rem;\n border-radius: 6px;\n background: #f8f9fa;\n border: 1px solid #e9ecef;\n }\n\n .toolset-header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-weight: 600;\n margin-bottom: 0.5rem;\n }\n\n .toolset-status {\n display: inline-block;\n padding: 0.15em 0.5em;\n border-radius: 3px;\n font-size: 0.75rem;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n .toolset-status.enabled {\n background: #d4edda;\n color: #155724;\n }\n\n .toolset-status.disabled {\n background: #f8d7da;\n color: #721c24;\n }\n\n .toolset-tools {\n font-size: 0.875rem;\n color: #6c757d;\n padding-left: 1.5rem;\n margin: 0;\n }\n\n .toolset-tools li {\n margin-bottom: 0.25rem;\n }\n\n .toolset-tools code {\n font-size: 0.8rem;\n }\n\n .toolset-notice {\n font-size: 0.8rem;\n color: #856404;\n background: #fff3cd;\n padding: 0.5rem;\n border-radius: 4px;\n margin-top: 0.5rem;\n }\n\n .toolset-notice a {\n color: #533f03;\n }\n\n @media (prefers-color-scheme: dark) {\n body {\n background-color: rgb(34, 36, 37);\n color: rgb(201, 205, 207);\n }\n\n code {\n background: rgba(255, 255, 255, 0.1);\n }\n\n .toolset {\n background: rgba(255, 255, 255, 0.05);\n border-color: rgba(255, 255, 255, 0.1);\n }\n\n .toolset-tools {\n color: #adb5bd;\n }\n\n .toolset-status.enabled {\n background: rgba(40, 167, 69, 0.2);\n color: #75d67e;\n }\n\n .toolset-status.disabled {\n background: rgba(220, 53, 69, 0.2);\n color: #f5a6ad;\n }\n\n .toolset-notice {\n background: rgba(255, 193, 7, 0.15);\n color: #ffc107;\n }\n\n .toolset-notice a {\n color: #ffe066;\n }\n }\n </style>\n </head>\n <body>\n <div class=\"container\">\n <p>\n Storybook MCP server successfully running via\n <code>@storybook/addon-mcp</code>.\n </p>\n <p>\n See how to connect to it from your coding agent in\n <a\n target=\"_blank\"\n href=\"https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#configuring-your-agent\"\n >the addon's README</a\n >.\n </p>\n\n <div class=\"toolsets\">\n <h3>Available Toolsets</h3>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>dev</span>\n <span class=\"toolset-status {{DEV_STATUS}}\">{{DEV_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>preview-stories</code></li>\n <li><code>get-storybook-story-instructions</code></li>\n </ul>\n </div>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>docs</span>\n <span class=\"toolset-status {{DOCS_STATUS}}\">{{DOCS_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>list-all-documentation</code></li>\n <li><code>get-documentation</code></li>\n </ul>\n {{DOCS_NOTICE}}\n </div>\n </div>\n\n <p id=\"redirect-message\">\n Automatically redirecting to\n <a href=\"/manifests/components.html\">component manifest</a>\n in <span id=\"countdown\">10</span> seconds...\n </p>\n </div>\n <script>\n let countdown = 10;\n const countdownElement = document.getElementById('countdown');\n if (countdownElement) {\n setInterval(() => {\n countdown -= 1;\n countdownElement.textContent = countdown.toString();\n }, 1000);\n }\n <\/script>\n </body>\n</html>\n";
553
568
 
569
+ //#endregion
570
+ //#region src/auth/composition-auth.ts
571
+ /**
572
+ * Composition authentication for fetching manifests from private Storybooks.
573
+ *
574
+ * This class handles OAuth discovery and token-based manifest fetching for
575
+ * composed Storybooks (refs). It acts as a proxy for OAuth metadata, allowing
576
+ * MCP clients like VS Code to handle the OAuth flow with Chromatic.
577
+ */
578
+ const OAuthResourceMetadata = v.object({
579
+ resource: v.optional(v.string()),
580
+ authorization_servers: v.pipe(v.array(v.string()), v.minLength(1)),
581
+ scopes_supported: v.optional(v.array(v.string()))
582
+ });
583
+ const OAuthServerMetadata = v.object({
584
+ issuer: v.string(),
585
+ authorization_endpoint: v.string(),
586
+ token_endpoint: v.string(),
587
+ scopes_supported: v.optional(v.array(v.string()))
588
+ });
589
+ const MANIFEST_CACHE_TTL = 3600 * 1e3;
590
+ const REVALIDATION_TTL = 60 * 1e3;
591
+ var AuthenticationError = class extends Error {
592
+ constructor(url$1) {
593
+ super(`Authentication failed for ${url$1}. Your token may be invalid or expired.`);
594
+ this.name = "AuthenticationError";
595
+ }
596
+ };
597
+ var CompositionAuth = class {
598
+ #authRequirement = null;
599
+ #authRequiredUrls = [];
600
+ #refsWithManifests = [];
601
+ #manifestCache = /* @__PURE__ */ new Map();
602
+ #lastToken = null;
603
+ #authErrors = /* @__PURE__ */ new WeakMap();
604
+ /** Initialize by checking which refs require authentication and have manifests. */
605
+ async initialize(refs) {
606
+ for (const ref of refs) try {
607
+ const result = await this.#checkRef(ref.url);
608
+ if (result === "no-manifest") continue;
609
+ this.#refsWithManifests.push(ref);
610
+ if (result === "public") continue;
611
+ this.#authRequiredUrls.push(ref.url);
612
+ if (!this.#authRequirement) this.#authRequirement = result;
613
+ else {
614
+ const existingServer = this.#authRequirement.resourceMetadata.authorization_servers[0];
615
+ const newServer = result.resourceMetadata.authorization_servers[0];
616
+ if (existingServer !== newServer) console.warn(`[addon-mcp] Composed ref "${ref.title}" uses a different OAuth server (${newServer}) than the first authenticated ref (${existingServer}). Only the first OAuth server will be used for authentication.`);
617
+ }
618
+ } catch (error) {
619
+ console.warn(`[addon-mcp] Failed to check auth for composed ref "${ref.title}" (${ref.url}): ${error instanceof Error ? error.message : String(error)}. Skipping this ref.`);
620
+ }
621
+ }
622
+ get requiresAuth() {
623
+ return this.#authRequiredUrls.length > 0;
624
+ }
625
+ get authUrls() {
626
+ return this.#authRequiredUrls;
627
+ }
628
+ /** Check if a request encountered an auth error during manifest fetching. */
629
+ hadAuthError(request) {
630
+ return this.#authErrors.has(request);
631
+ }
632
+ /** Check if a URL requires authentication based on discovered auth requirements. */
633
+ #isAuthRequiredUrl(url$1) {
634
+ return this.#authRequiredUrls.some((authUrl) => url$1.startsWith(authUrl));
635
+ }
636
+ /** Build .well-known/oauth-protected-resource response. */
637
+ buildWellKnown(origin$1) {
638
+ if (!this.#authRequirement) return null;
639
+ return {
640
+ resource: `${origin$1}/mcp`,
641
+ authorization_servers: this.#authRequirement.resourceMetadata.authorization_servers,
642
+ scopes_supported: this.#authRequirement.resourceMetadata.scopes_supported
643
+ };
644
+ }
645
+ /** Build WWW-Authenticate header for 401 responses */
646
+ buildWwwAuthenticate(origin$1) {
647
+ return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin$1}/.well-known/oauth-protected-resource"`;
648
+ }
649
+ /** Build sources configuration: local first, then refs that have manifests. */
650
+ buildSources() {
651
+ return [{
652
+ id: "local",
653
+ title: "Local"
654
+ }, ...this.#refsWithManifests.map((ref) => ({
655
+ id: ref.id,
656
+ title: ref.title,
657
+ url: ref.url
658
+ }))];
659
+ }
660
+ /** Create a manifest provider for multi-source mode. */
661
+ createManifestProvider(localOrigin) {
662
+ return async (request, path$1, source) => {
663
+ const token = extractBearerToken(request?.headers.get("Authorization"));
664
+ const baseUrl = source?.url ?? localOrigin;
665
+ const manifestUrl = `${baseUrl}${path$1.replace("./", "/")}`;
666
+ const isRemote = !!source?.url;
667
+ const tokenForRequest = isRemote && this.#isAuthRequiredUrl(baseUrl) ? token : null;
668
+ if (token && token !== this.#lastToken) {
669
+ this.#manifestCache.clear();
670
+ this.#lastToken = token;
671
+ }
672
+ if (isRemote) {
673
+ const cached = this.#manifestCache.get(manifestUrl);
674
+ if (cached) if (Date.now() - cached.timestamp > MANIFEST_CACHE_TTL) this.#manifestCache.delete(manifestUrl);
675
+ else {
676
+ if (!cached.lastRevalidatedAt || Date.now() - cached.lastRevalidatedAt > REVALIDATION_TTL) {
677
+ cached.lastRevalidatedAt = Date.now();
678
+ this.#fetchManifest(manifestUrl, tokenForRequest).then((text) => this.#manifestCache.set(manifestUrl, {
679
+ text,
680
+ timestamp: Date.now(),
681
+ lastRevalidatedAt: Date.now()
682
+ })).catch(() => {});
683
+ }
684
+ return cached.text;
685
+ }
686
+ }
687
+ try {
688
+ const text = await this.#fetchManifest(manifestUrl, tokenForRequest);
689
+ if (isRemote) this.#manifestCache.set(manifestUrl, {
690
+ text,
691
+ timestamp: Date.now()
692
+ });
693
+ return text;
694
+ } catch (error) {
695
+ if (error instanceof AuthenticationError && request) this.#authErrors.set(request, error);
696
+ throw error;
697
+ }
698
+ };
699
+ }
700
+ /**
701
+ * Fetch a manifest with optional auth token.
702
+ * If the response is 200 but not a valid manifest, checks /mcp for auth issues.
703
+ */
704
+ async #fetchManifest(url$1, token) {
705
+ const headers = { Accept: "application/json" };
706
+ if (token) headers["Authorization"] = `Bearer ${token}`;
707
+ const response = await fetch(url$1, { headers });
708
+ if (response.status === 401) throw new AuthenticationError(url$1);
709
+ if (!response.ok) throw new Error(`Failed to fetch ${url$1}: ${response.status}`);
710
+ const text = await response.text();
711
+ const schema = url$1.includes("docs.json") ? DocsManifestMap : ComponentManifestMap;
712
+ if (v.safeParse(v.pipe(v.string(), v.parseJson(), schema), text).success) return text;
713
+ if (await this.#isMcpUnauthorized(new URL(url$1).origin)) throw new AuthenticationError(url$1);
714
+ throw new Error(`Invalid manifest response from ${url$1}: expected valid JSON manifest but got unexpected content.`);
715
+ }
716
+ /**
717
+ * Check a ref to determine if it has a manifest and whether it requires auth.
718
+ * Returns 'public' if the ref has a valid manifest without auth,
719
+ * 'no-manifest' if no manifest is available, or an AuthRequirement if auth is needed.
720
+ */
721
+ async #checkRef(refUrl) {
722
+ const response = await fetch(`${refUrl}/manifests/components.json`, { headers: { Accept: "application/json" } });
723
+ const authReq = await this.#parseAuthFromResponse(response);
724
+ if (authReq) return authReq;
725
+ if (response.ok) {
726
+ const text = await response.text();
727
+ if (v.safeParse(v.pipe(v.string(), v.parseJson(), ComponentManifestMap), text).success) return "public";
728
+ }
729
+ const mcpAuth = await this.#checkMcpAuth(refUrl);
730
+ if (mcpAuth) return mcpAuth;
731
+ return "no-manifest";
732
+ }
733
+ /** Check /mcp endpoint for 401 auth requirement. */
734
+ async #checkMcpAuth(refUrl) {
735
+ const response = await fetch(`${refUrl}/mcp`, {
736
+ method: "POST",
737
+ headers: { "Content-Type": "application/json" },
738
+ body: JSON.stringify({
739
+ jsonrpc: "2.0",
740
+ id: 1,
741
+ method: "tools/list"
742
+ })
743
+ });
744
+ return this.#parseAuthFromResponse(response);
745
+ }
746
+ /** Quick check: does the remote /mcp return 401? */
747
+ async #isMcpUnauthorized(origin$1) {
748
+ try {
749
+ return (await fetch(`${origin$1}/mcp`, {
750
+ method: "POST",
751
+ headers: { "Content-Type": "application/json" },
752
+ body: JSON.stringify({
753
+ jsonrpc: "2.0",
754
+ id: 1,
755
+ method: "tools/list"
756
+ })
757
+ })).status === 401;
758
+ } catch {
759
+ return false;
760
+ }
761
+ }
762
+ /** Extract auth requirement from a 401 response's WWW-Authenticate header. */
763
+ async #parseAuthFromResponse(response) {
764
+ if (response.status !== 401) return null;
765
+ const wwwAuth = response.headers.get("WWW-Authenticate");
766
+ if (!wwwAuth) return null;
767
+ const match = wwwAuth.match(/resource_metadata="([^"]+)"/);
768
+ if (!match?.[1]) return null;
769
+ const resourceMetadataUrl = match[1];
770
+ const resourceResponse = await fetch(resourceMetadataUrl);
771
+ if (!resourceResponse.ok) {
772
+ console.warn(`[addon-mcp] Failed to fetch OAuth resource metadata from ${resourceMetadataUrl}: ${resourceResponse.status}`);
773
+ return null;
774
+ }
775
+ const resourceResult = v.safeParse(OAuthResourceMetadata, await resourceResponse.json());
776
+ if (!resourceResult.success) {
777
+ console.warn(`[addon-mcp] Invalid OAuth resource metadata from ${resourceMetadataUrl}: ${resourceResult.issues.map((i) => i.message).join(", ")}`);
778
+ return null;
779
+ }
780
+ const serverMetadataUrl = `${resourceResult.output.authorization_servers[0]}/.well-known/oauth-authorization-server`;
781
+ const serverResponse = await fetch(serverMetadataUrl);
782
+ if (!serverResponse.ok) {
783
+ console.warn(`[addon-mcp] Failed to fetch OAuth server metadata from ${serverMetadataUrl}: ${serverResponse.status}`);
784
+ return null;
785
+ }
786
+ const serverResult = v.safeParse(OAuthServerMetadata, await serverResponse.json());
787
+ if (!serverResult.success) {
788
+ console.warn(`[addon-mcp] Invalid OAuth server metadata from ${serverMetadataUrl}: ${serverResult.issues.map((i) => i.message).join(", ")}`);
789
+ return null;
790
+ }
791
+ return {
792
+ resourceMetadataUrl,
793
+ resourceMetadata: resourceResult.output,
794
+ serverMetadata: serverResult.output
795
+ };
796
+ }
797
+ };
798
+ /**
799
+ * Extract Bearer token from Authorization header.
800
+ * Handles both Node.js (string | string[] | undefined) and Web API (string | null) headers.
801
+ */
802
+ function extractBearerToken(authHeader) {
803
+ const bearer = (Array.isArray(authHeader) ? authHeader : [authHeader]).find((value) => typeof value === "string" && value.startsWith("Bearer "));
804
+ return bearer ? bearer.slice(7) : null;
805
+ }
806
+
554
807
  //#endregion
555
808
  //#region src/preset.ts
556
809
  const previewAnnotations = async (existingAnnotations = []) => {
557
810
  return [...existingAnnotations, path.join(import.meta.dirname, "preview.js")];
558
811
  };
559
812
  const experimental_devServer = async (app, options) => {
560
- const addonOptions = v.parse(AddonOptions, {
561
- toolsets: "toolsets" in options ? options.toolsets : {},
562
- experimentalFormat: "experimentalFormat" in options ? options.experimentalFormat : "markdown"
813
+ const addonOptions = v.parse(AddonOptions, { toolsets: "toolsets" in options ? options.toolsets : {} });
814
+ const origin$1 = `http://localhost:${options.port}`;
815
+ const refs = await getRefsFromConfig(options);
816
+ const compositionAuth = new CompositionAuth();
817
+ let sources;
818
+ let manifestProvider;
819
+ if (refs.length > 0) {
820
+ logger.info(`Initializing composition with ${refs.length} remote Storybook(s)`);
821
+ await compositionAuth.initialize(refs);
822
+ if (compositionAuth.requiresAuth) logger.info(`Auth required for: ${compositionAuth.authUrls.join(", ")}`);
823
+ sources = compositionAuth.buildSources();
824
+ logger.info(`Sources: ${sources.map((s) => s.id).join(", ")}`);
825
+ manifestProvider = compositionAuth.createManifestProvider(origin$1);
826
+ }
827
+ app.get("/.well-known/oauth-protected-resource", (_req, res) => {
828
+ const wellKnown = compositionAuth.buildWellKnown(origin$1);
829
+ if (!wellKnown) {
830
+ res.writeHead(404);
831
+ res.end("Not found");
832
+ return;
833
+ }
834
+ res.writeHead(200, { "Content-Type": "application/json" });
835
+ res.end(JSON.stringify(wellKnown));
563
836
  });
564
- app.post("/mcp", (req, res) => mcpServerHandler({
565
- req,
566
- res,
567
- options,
568
- addonOptions
569
- }));
570
- const manifestStatus = await getManifestStatus(options);
571
- const isDevEnabled = addonOptions.toolsets?.dev ?? true;
572
- const isDocsEnabled = manifestStatus.available && (addonOptions.toolsets?.docs ?? true);
573
- app.get("/mcp", (req, res) => {
574
- if (!req.headers["accept"]?.includes("text/html")) return mcpServerHandler({
837
+ const requireAuth = (req, res) => {
838
+ const token = extractBearerToken(req.headers["authorization"]);
839
+ if (compositionAuth.requiresAuth && !token) {
840
+ res.writeHead(401, {
841
+ "Content-Type": "text/plain",
842
+ "WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin$1)
843
+ });
844
+ res.end("401 - Unauthorized");
845
+ return true;
846
+ }
847
+ return false;
848
+ };
849
+ app.post("/mcp", (req, res) => {
850
+ if (requireAuth(req, res)) return;
851
+ return mcpServerHandler({
575
852
  req,
576
853
  res,
577
854
  options,
578
- addonOptions
855
+ addonOptions,
856
+ sources,
857
+ manifestProvider,
858
+ compositionAuth
579
859
  });
860
+ });
861
+ const manifestStatus = await getManifestStatus(options);
862
+ const isDevEnabled = addonOptions.toolsets?.dev ?? true;
863
+ const isDocsEnabled = manifestStatus.available && (addonOptions.toolsets?.docs ?? true);
864
+ app.get("/mcp", (req, res) => {
865
+ if (!req.headers["accept"]?.includes("text/html")) {
866
+ if (requireAuth(req, res)) return;
867
+ return mcpServerHandler({
868
+ req,
869
+ res,
870
+ options,
871
+ addonOptions,
872
+ sources,
873
+ manifestProvider,
874
+ compositionAuth
875
+ });
876
+ }
580
877
  res.writeHead(200, { "Content-Type": "text/html" });
581
878
  let docsNotice = "";
582
879
  if (!manifestStatus.hasManifests) docsNotice = `<div class="toolset-notice">
@@ -591,6 +888,23 @@ const experimental_devServer = async (app, options) => {
591
888
  });
592
889
  return app;
593
890
  };
891
+ /**
892
+ * Get composed Storybook refs from Storybook config.
893
+ * See: https://storybook.js.org/docs/sharing/storybook-composition
894
+ */
895
+ async function getRefsFromConfig(options) {
896
+ try {
897
+ const refs = await options.presets.apply("refs", {});
898
+ if (!refs || typeof refs !== "object") return [];
899
+ return Object.entries(refs).map(([key, value]) => ({
900
+ id: key,
901
+ title: value.title || key,
902
+ url: value.url
903
+ })).filter((ref) => ref.url);
904
+ } catch {
905
+ return [];
906
+ }
907
+ }
594
908
 
595
909
  //#endregion
596
910
  export { experimental_devServer, previewAnnotations };
@@ -4,7 +4,7 @@ const MCP_APP_SIZE_CHANGED_EVENT = "storybook-mcp:size-changed";
4
4
 
5
5
  //#endregion
6
6
  //#region package.json
7
- var version = "0.2.3";
7
+ var version = "0.3.0";
8
8
 
9
9
  //#endregion
10
10
  //#region src/tools/preview-stories/preview-stories-app-script.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storybook/addon-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Help agents automatically write and test stories for your UI components",
5
5
  "keywords": [
6
6
  "ai",
@@ -34,10 +34,10 @@
34
34
  "picoquery": "^2.5.0",
35
35
  "tmcp": "^1.16.0",
36
36
  "valibot": "1.2.0",
37
- "@storybook/mcp": "0.2.2"
37
+ "@storybook/mcp": "0.3.0"
38
38
  },
39
39
  "devDependencies": {
40
- "storybook": "10.3.0-alpha.4"
40
+ "storybook": "10.3.0-alpha.7"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "storybook": "^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0"