@storybook/addon-mcp 0.2.3 → 0.3.1
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 +0 -1
- package/dist/preset.js +351 -37
- package/dist/preview-stories-app-script.js +1 -1
- package/package.json +3 -3
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.
|
|
18
|
+
var version = "0.3.1";
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
565
|
-
req
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storybook/addon-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
37
|
+
"@storybook/mcp": "0.4.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"storybook": "10.3.0-alpha.
|
|
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"
|