Skip to content

Skills M4 (7/9): dev-server docs tools run on the shared toolset, with composition - #35734

Closed
valentinpalkovic wants to merge 1 commit into
valentin/m4-06-docs-access-seamfrom
valentin/m4-07-docs-tools-on-core
Closed

valentinpalkovic wants to merge 1 commit into
valentin/m4-06-docs-access-seamfrom
valentin/m4-07-docs-tools-on-core

Conversation

@valentinpalkovic

Copy link
Copy Markdown
Contributor

Closes #

Part 7 of 9 of the Skills M4 migration. Stacked on #35732, replaces #35677.

What I did

The three documentation tools the dev server exposes now run on the shared docs toolset in core, and the MCP addon drops its dependency on the hosted @storybook/mcp package together with the parallel copy of the manifest handling it had grown. Composition lands in the same change: a Storybook that composes other Storybooks can serve documentation from all of them, and it does so by composing the accessors introduced in the previous PR rather than by teaching each tool about composition.

This is the largest PR in the stack and the seam is genuinely not splittable. Dropping that dependency requires all three tools, their shared types, their instructions and their formatting helpers to move at once.

Two things to expect while reading it. Core gains a published entry point that has to stay dependency-light, which required turning the shared type-declaration build step from one pass into several, so this touches build infrastructure used by every package in the repo and deserves a full compile before merging. And a large share of what looks like new code here is code moving in from the hosted package, whose other half is deleted in the next PR, so rename detection cannot connect the two sides.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

  1. Build the monorepo packages with yarn nx run-many -t compile -c production, not just the two obviously affected ones, since the type-declaration build step is shared. The production flag matters: type declarations are only emitted there, so a plain compile will not exercise this change at all and the guard test covering it silently skips.
  2. In test-storybooks/mcp, run yarn install and then yarn storybook. The MCP endpoint comes up at http://localhost:6006/mcp.
  3. Point an MCP client at that endpoint and call list-all-documentation, get-documentation and get-documentation-for-story. The Markdown they return must be identical to what the base branch returns, character for character.
  4. Stop that Storybook and run yarn storybook:composition instead, which serves a Storybook composing others on port 6007. Call the same three tools and confirm documentation from every composed source shows up, grouped by source, and that a source that is unreachable is reported as such instead of silently disappearing.
  5. Confirm the dev server still starts and serves docs in a Storybook with the experimental docgen server feature enabled.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Declare whether manual QA will be needed for this PR during the next release, through qa:needed or qa:skip

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

The three documentation tools the dev server exposes over MCP now run on the
docs toolset that lives in core, instead of on the separate hosted MCP package.
That package is no longer a dependency of the addon, and the addon's parallel
copy of the manifest reading and formatting code is gone with it.

Composition support moves into the toolset too: the composed Storybooks, the
provider that fetches their manifests and the local Storybook's own reader are
now inputs to the toolset rather than plumbing spread across the addon. A
composition still builds its toolset per request, since what it reads belongs
to the request.

Core gains a dependency-light entry point for the docs toolset. Its type
declarations are produced as one flat self-contained file so the hosted package
can bundle them, guarded by a test that fails on the producing side if the entry
ever picks up an import it should not have.

The tools that clients see are unchanged: same names, titles, descriptions,
schemas and rendered output.
@valentinpalkovic valentinpalkovic added the maintenance User-facing maintenance tasks label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫

PR is not labeled with one of: ["ci:normal","ci:merged","ci:daily","ci:docs"]

🚫

PR is not labeled with one of: ["qa:needed","qa:skip","qa:success"]

🚫 PR title must be in the format of "Area: Summary", With both Area and Summary starting with a capital letter Good examples: - "Docs: Describe Canvas Doc Block" - "Svelte: Support Svelte v4" Bad examples: - "add new api docs" - "fix: Svelte 4 support" - "Vue: improve docs"
🚫 This PR needs an approving review from a Storybook Core or Developer Experience team member before it can be merged.
Warnings
⚠️

This PR targets valentin/m4-06-docs-access-seam. The default branch for contributions is next. Please make sure you are targeting the correct branch.

Generated by 🚫 dangerJS against 5c3f66b

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR moves MCP documentation handling into a shared core docs toolset. It adds provider-backed and multi-source access, updates addon integration, exposes a portable public entry point, and removes legacy manifest access and dependency overrides.

Changes

Shared documentation toolset

Layer / File(s) Summary
Core docs access and multi-source toolset
code/core/src/shared/open-service/toolsets/docs/*
Adds manifest providers, $ref resolution, multi-source access, source errors, bounded concurrency, Markdown formatting, telemetry, instructions, and public exports.
MCP addon integration
code/addons/mcp/src/auth/*, code/addons/mcp/src/tools/*, code/addons/mcp/src/preset.ts, code/addons/mcp/src/types.ts, code/addons/mcp/src/mcp-handler.ts
The addon uses shared docs APIs, creates request-scoped composition toolsets, passes local docs access separately, and removes the in-process manifest implementation.
Portable docs export and declaration bundling
code/core/build-config.ts, code/core/package.json, scripts/build/utils/*
Adds the storybook/internal/toolsets-docs export and isolates portable declaration bundling with an external-package allowlist.
Sandbox dependency rewrite cleanup
agent-eval/lib/templates.ts, agent-eval/lib/templates.test.ts, code/addons/mcp/package.json
Removes dependency override handling and the workspace dependency on @storybook/mcp.

Possibly related issues

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
agent-eval/lib/templates.test.ts (1)

91-92: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Restore an assertion for @storybook/mcp.

Lines 91-92 remove the only assertion for this dependency. The test can now pass if the generated manifest keeps a workspace: spec or uses another incorrect value.

If the fixture still uses @storybook/mcp: "workspace:*", assert that the rewritten value equals packageJson.version. Otherwise, add a fixture that verifies the dependency is absent.

Proposed assertion for the existing workspace fixture
+    expect(
+      (rewritten.dependencies as Record<string, unknown>)['`@storybook/mcp`']
+    ).toBe(packageJson.version);

As per coding guidelines, test public contracts and externally observable side effects rather than private implementation details.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent-eval/lib/templates.test.ts` around lines 91 - 92, Restore coverage for
`@storybook/mcp` in the relevant rewritten manifest test: when the fixture
declares it as workspace:*, assert its rewritten value equals
packageJson.version, or add a fixture and assertion confirming the dependency is
absent if that is the intended contract. Keep the existing workspace-spec
assertion and other dependency checks unchanged.

Source: Coding guidelines

🧹 Nitpick comments (8)
code/addons/mcp/src/tools/tool-registry-composition.test.ts (1)

13-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock ../telemetry.ts with spy: true and access it through vi.mocked().

The current mock replaces the module with a bare factory and uses the hoisted vi.fn() directly. The repository rules require the spy: true option and typed access through vi.mocked().

♻️ Proposed refactor
-const collectTelemetry = vi.hoisted(() => vi.fn());
-vi.mock('../telemetry.ts', () => ({ collectTelemetry }));
+vi.mock('../telemetry.ts', { spy: true });

Then read the spy inside the suite:

import { collectTelemetry } from '../telemetry.ts';

const collectTelemetryMock = vi.mocked(collectTelemetry);

beforeEach(() => {
  registerCoreToolsetsForTest();
  collectTelemetryMock.mockClear();
  collectTelemetryMock.mockResolvedValue(undefined);
});

Replace the collectTelemetry assertions with collectTelemetryMock.

As per coding guidelines: "Use vi.mock() with the spy: true option for all package and file mocks in Vitest tests" and "Use vi.mocked() to type and access the mocked functions in Vitest tests".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/addons/mcp/src/tools/tool-registry-composition.test.ts` around lines 13
- 14, Update the telemetry mock in tool-registry-composition.test.ts to use
vi.mock('../telemetry.ts', { spy: true }) and import collectTelemetry from that
module. Create a typed collectTelemetryMock via vi.mocked(collectTelemetry), use
it for mock setup and clearing in beforeEach, and replace all direct
collectTelemetry assertions with collectTelemetryMock.

Source: Coding guidelines

code/core/src/shared/open-service/toolsets/docs/access-provider.ts (1)

433-459: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

One broken story $ref discards every resolved row for the source.

mapWithConcurrency uses Promise.all, so the first rejection rejects the whole batch. The catch then drops all results, including components whose story $ref resolved successfully. The comment states the intent is to lose story ids for the unreachable rows only.

Isolate the failure per component instead.

♻️ Proposed refactor
-        try {
-          const resolved = await mapWithConcurrency(
-            Object.entries(components),
-            STORY_REF_CONCURRENCY,
-            async ([id, component]) =>
-              [
-                id,
-                await resolveComponentStories(component, request(), manifestProvider, source),
-              ] as const
-          );
-          for (const [id, component] of resolved) {
-            components[id] = component;
-          }
-        } catch (error) {
-          // One composed source must not lose its whole listing because a single story `$ref` was
-          // unreachable: leave its rows unresolved instead. A single Storybook has no other source
-          // to fall back on, so there the failure is the answer.
-          if (!source) {
-            throw error;
-          }
-        }
+        // One composed source must not lose its whole listing because a single story `$ref` was
+        // unreachable: leave the affected row unresolved instead. A single Storybook has no other
+        // source to fall back on, so there the failure is the answer.
+        const resolved = await mapWithConcurrency(
+          Object.entries(components),
+          STORY_REF_CONCURRENCY,
+          async ([id, component]) => {
+            try {
+              return [
+                id,
+                await resolveComponentStories(component, request(), manifestProvider, source),
+              ] as const;
+            } catch (error) {
+              if (!source) {
+                throw error;
+              }
+              return [id, component] as const;
+            }
+          }
+        );
+        for (const [id, component] of resolved) {
+          components[id] = component;
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.ts` around
lines 433 - 459, Update the withStoryIds resolution flow around
mapWithConcurrency and resolveComponentStories to handle failures per component
rather than rejecting the entire batch. Preserve successfully resolved
components and leave only failed rows unresolved for composed sources; retain
the existing behavior of rethrowing for a single Storybook source when source is
absent.
code/core/src/shared/open-service/toolsets/docs/sources.test.ts (1)

5-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a path without a trailing slash.

getSourceMcpEndpoint appends the missing trailing slash before resolving mcp. The current cases cover the root URL and a path that already ends in a slash. A path without a trailing slash exercises that normalization.

♻️ Proposed addition
     ['https://example.com', 'https://example.com/mcp'],
+    ['https://example.com/storybook', 'https://example.com/storybook/mcp'],
     ['https://example.com/storybook/', 'https://example.com/storybook/mcp'],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/sources.test.ts` around lines
5 - 11, Add a test case to the existing getSourceMcpEndpoint parameterized suite
using a URL with a non-root path that lacks a trailing slash, and assert the
expected path-normalized /mcp endpoint. Keep the existing root, trailing-slash,
and query/fragment cases unchanged.
code/core/src/shared/open-service/toolsets/docs/sources.ts (1)

29-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Encode the "exactly one outcome" rule in the type.

The comment states that exactly one of manifests, error or notice carries the outcome. The current shape allows all three at once, or none. A discriminated union removes the invalid states, and consumers such as formatMultiSourceManifestsToLists then get exhaustive checking.

♻️ Proposed refactor
-export type SourceListing = {
-  source: Source;
-  manifests?: AllManifests;
-  error?: string;
-  notice?: RequiresOwnMcpNotice;
-};
+export type SourceListing = { source: Source } & (
+  | { manifests: AllManifests; error?: never; notice?: never }
+  | { manifests?: never; error: string; notice?: never }
+  | { manifests?: never; error?: never; notice: RequiresOwnMcpNotice }
+);

As per coding guidelines: "Use static TypeScript types and existing lint rules to encode invariants whenever practical".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/sources.ts` around lines 29 -
38, Update the SourceListing type to a discriminated union requiring source plus
exactly one outcome: manifests, error, or notice. Preserve the existing field
types and source property while making the other outcome fields unavailable in
each variant, so consumers such as formatMultiSourceManifestsToLists receive
exhaustive type checking.

Source: Coding guidelines

code/core/src/shared/open-service/toolsets/docs/definition.ts (1)

250-261: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the shared id field of showSchema.

Both branches repeat the same id definition. One shared constant keeps the description in one place.

♻️ Proposed refactor
+  const idField = {
+    id: v.pipe(v.string(), v.description('The component or docs entry ID (e.g., "button")')),
+  };
+
   // A composition needs the caller to say which Storybook they mean; a single one must not ask.
   const showSchema = multiSource
-    ? v.object({
-        id: v.pipe(v.string(), v.description('The component or docs entry ID (e.g., "button")')),
-        ...storybookIdField,
-      })
-    : v.object({
-        id: v.pipe(v.string(), v.description('The component or docs entry ID (e.g., "button")')),
-      });
+    ? v.object({ ...idField, ...storybookIdField })
+    : v.object({ ...idField });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/definition.ts` around lines
250 - 261, Extract the repeated id field definition from showSchema into a
shared constant, then reuse it in both the multiSource and non-multiSource
object schemas while preserving the existing validation and description.
code/core/src/shared/open-service/toolsets/docs/portable-dist.test.ts (1)

14-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the exemption from the memfs rule.

This test reads the real filesystem through node:fs. The repository rule requires memfs for filesystem tests. The rationale in the file header is sound, because the guarded property is the artifact on disk. Confirm the exemption with the maintainers, or record it where the rule is defined, so a later cleanup pass does not convert this suite to memfs and remove its value.

As per coding guidelines: "Filesystem tests touching node:fs or node:fs/promises must use memfs; reset vol in beforeEach, seed virtual files, and use spy redirects rather than full async factory mocks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/portable-dist.test.ts` around
lines 14 - 22, Record and confirm the intentional real-filesystem exemption for
the portable distribution test using node:fs, preferably in the repository rule
documentation or an appropriate maintainer-approved note. Preserve the existing
artifact-on-disk test behavior and do not convert it to memfs.

Source: Coding guidelines

code/core/src/shared/open-service/toolsets/docs/definition.test.ts (1)

159-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the multi-source paths.

This suite exercises only the single-source toolset from line 45. The new selectSource behavior is untested: a missing storybookId, an unknown storybookId, and the sourceCount telemetry field on a composed listing. These are the branches most likely to regress.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/definition.test.ts` around
lines 159 - 196, Extend the usage-reporting tests around the existing run helper
to exercise the composed multi-source toolset and its selectSource behavior:
verify missing storybookId uses the default source, unknown storybookId follows
the expected fallback or error path, and a composed list telemetry payload
includes the correct sourceCount. Reuse the existing telemetry assertions and
multi-source fixtures/symbols rather than changing single-source coverage.
code/core/src/shared/open-service/toolsets/docs/manifest-formatter/markdown.test.ts (1)

1490-1513: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the remaining multi-source branches.

The new suite pins only the requires-own-mcp notice. The error: branch and the component/docs listing branch of formatMultiSourceManifestsToLists stay unpinned, including the withStoryIds sub-bullets and the ## heading level that differs from the single-source formatter. This file states it binds the Markdown every consumer receives, so add one listing case and one error case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@code/core/src/shared/open-service/toolsets/docs/manifest-formatter/markdown.test.ts`
around lines 1490 - 1513, The test suite around
formatMultiSourceManifestsToLists currently covers only requires-own-mcp
notices. Add one test covering the component/docs listing branch, including
withStoryIds sub-bullets and its ## heading level, and one test covering the
error notice branch with the expected error: output; assert complete inline
snapshots for both cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@code/core/src/shared/open-service/toolsets/docs/access-provider-isolation.test.ts`:
- Around line 79-99: Update the manifestProvider fallback payload in the
access.list concurrency test so it contains the value requested by each story
ref, rather than always returning only c0. Ensure every cN pointer resolves
successfully, allowing the fan-out to complete while preserving the existing
concurrency assertions.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.test.ts`:
- Around line 185-190: Update the tests around getManifests so rejection
assertions execute directly rather than only inside catch blocks. Ensure both
affected cases verify that the returned rejection error message does not contain
componentsManifest, while still failing if getManifests resolves.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.ts`:
- Around line 70-97: Update defaultManifestProvider to create a bounded-timeout
AbortSignal and pass it in the fetch call for each manifestUrl request. Ensure
the signal aborts stalled responses and preserves the existing ManifestGetError
handling for non-OK responses.
- Around line 122-135: Update the getUrl helper in fetchManifests to prefer
source.url when a remote source is provided, falling back to the request-derived
manifest URL for local manifests and to the existing unknown-source value when
neither is available. Ensure ManifestGetError instances use this corrected URL
for failures from composed remote sources.

In `@code/core/src/shared/open-service/toolsets/docs/definition.ts`:
- Around line 245-247: Update createDocsToolset to assert that options.sources
is non-empty whenever sources is provided, rejecting { sources: [] } before
handlers can dereference docsAccess; preserve the existing multiSource behavior
for valid non-empty sources and docsAccess configurations.

In `@code/core/src/shared/open-service/toolsets/docs/instructions.ts`:
- Around line 21-24: Update the “Multi-Source Requests” guidance in the
instructions template to mention that both ${MCP_TOOL_NAMES['docs.show']} and
${MCP_TOOL_NAMES['docs.showStory']} require storybookId when selecting a source,
while preserving the existing docs.list behavior.

---

Outside diff comments:
In `@agent-eval/lib/templates.test.ts`:
- Around line 91-92: Restore coverage for `@storybook/mcp` in the relevant
rewritten manifest test: when the fixture declares it as workspace:*, assert its
rewritten value equals packageJson.version, or add a fixture and assertion
confirming the dependency is absent if that is the intended contract. Keep the
existing workspace-spec assertion and other dependency checks unchanged.

---

Nitpick comments:
In `@code/addons/mcp/src/tools/tool-registry-composition.test.ts`:
- Around line 13-14: Update the telemetry mock in
tool-registry-composition.test.ts to use vi.mock('../telemetry.ts', { spy: true
}) and import collectTelemetry from that module. Create a typed
collectTelemetryMock via vi.mocked(collectTelemetry), use it for mock setup and
clearing in beforeEach, and replace all direct collectTelemetry assertions with
collectTelemetryMock.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.ts`:
- Around line 433-459: Update the withStoryIds resolution flow around
mapWithConcurrency and resolveComponentStories to handle failures per component
rather than rejecting the entire batch. Preserve successfully resolved
components and leave only failed rows unresolved for composed sources; retain
the existing behavior of rethrowing for a single Storybook source when source is
absent.

In `@code/core/src/shared/open-service/toolsets/docs/definition.test.ts`:
- Around line 159-196: Extend the usage-reporting tests around the existing run
helper to exercise the composed multi-source toolset and its selectSource
behavior: verify missing storybookId uses the default source, unknown
storybookId follows the expected fallback or error path, and a composed list
telemetry payload includes the correct sourceCount. Reuse the existing telemetry
assertions and multi-source fixtures/symbols rather than changing single-source
coverage.

In `@code/core/src/shared/open-service/toolsets/docs/definition.ts`:
- Around line 250-261: Extract the repeated id field definition from showSchema
into a shared constant, then reuse it in both the multiSource and
non-multiSource object schemas while preserving the existing validation and
description.

In
`@code/core/src/shared/open-service/toolsets/docs/manifest-formatter/markdown.test.ts`:
- Around line 1490-1513: The test suite around formatMultiSourceManifestsToLists
currently covers only requires-own-mcp notices. Add one test covering the
component/docs listing branch, including withStoryIds sub-bullets and its ##
heading level, and one test covering the error notice branch with the expected
error: output; assert complete inline snapshots for both cases.

In `@code/core/src/shared/open-service/toolsets/docs/portable-dist.test.ts`:
- Around line 14-22: Record and confirm the intentional real-filesystem
exemption for the portable distribution test using node:fs, preferably in the
repository rule documentation or an appropriate maintainer-approved note.
Preserve the existing artifact-on-disk test behavior and do not convert it to
memfs.

In `@code/core/src/shared/open-service/toolsets/docs/sources.test.ts`:
- Around line 5-11: Add a test case to the existing getSourceMcpEndpoint
parameterized suite using a URL with a non-root path that lacks a trailing
slash, and assert the expected path-normalized /mcp endpoint. Keep the existing
root, trailing-slash, and query/fragment cases unchanged.

In `@code/core/src/shared/open-service/toolsets/docs/sources.ts`:
- Around line 29-38: Update the SourceListing type to a discriminated union
requiring source plus exactly one outcome: manifests, error, or notice. Preserve
the existing field types and source property while making the other outcome
fields unavailable in each variant, so consumers such as
formatMultiSourceManifestsToLists receive exhaustive type checking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c638c99c-715a-4691-a85d-e9d9d697e758

📥 Commits

Reviewing files that changed from the base of the PR and between 28b6037 and 5c3f66b.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (44)
  • agent-eval/lib/templates.test.ts
  • agent-eval/lib/templates.ts
  • code/addons/mcp/package.json
  • code/addons/mcp/src/auth/composition-auth.test.ts
  • code/addons/mcp/src/auth/composition-auth.ts
  • code/addons/mcp/src/auth/resolve-composition-sources.ts
  • code/addons/mcp/src/instructions/build-server-instructions.ts
  • code/addons/mcp/src/manifests/__fixtures__/core-manifest.fixture.json
  • code/addons/mcp/src/manifests/core-format-contract.test.ts
  • code/addons/mcp/src/manifests/in-process-provider.test.ts
  • code/addons/mcp/src/manifests/in-process-provider.ts
  • code/addons/mcp/src/manifests/service-access.test.ts
  • code/addons/mcp/src/manifests/service-access.ts
  • code/addons/mcp/src/manifests/vendored.ts
  • code/addons/mcp/src/mcp-handler.test.ts
  • code/addons/mcp/src/mcp-handler.ts
  • code/addons/mcp/src/preset.ts
  • code/addons/mcp/src/storybook-ai-metadata.test.ts
  • code/addons/mcp/src/tools/tool-registry-composition.test.ts
  • code/addons/mcp/src/tools/tool-registry.ts
  • code/addons/mcp/src/tools/toolset-tools.ts
  • code/addons/mcp/src/types.ts
  • code/core/build-config.ts
  • code/core/package.json
  • code/core/src/shared/open-service/services/docgen/types.ts
  • code/core/src/shared/open-service/toolsets/docs/access-provider-isolation.test.ts
  • code/core/src/shared/open-service/toolsets/docs/access-provider.test.ts
  • code/core/src/shared/open-service/toolsets/docs/access-provider.ts
  • code/core/src/shared/open-service/toolsets/docs/definition.test.ts
  • code/core/src/shared/open-service/toolsets/docs/definition.ts
  • code/core/src/shared/open-service/toolsets/docs/instructions.ts
  • code/core/src/shared/open-service/toolsets/docs/manifest-formatter/markdown.test.ts
  • code/core/src/shared/open-service/toolsets/docs/manifest-formatter/markdown.ts
  • code/core/src/shared/open-service/toolsets/docs/map-with-concurrency.ts
  • code/core/src/shared/open-service/toolsets/docs/multi-source.test.ts
  • code/core/src/shared/open-service/toolsets/docs/multi-source.ts
  • code/core/src/shared/open-service/toolsets/docs/portable-dist.test.ts
  • code/core/src/shared/open-service/toolsets/docs/public.ts
  • code/core/src/shared/open-service/toolsets/docs/sources.test.ts
  • code/core/src/shared/open-service/toolsets/docs/sources.ts
  • code/core/src/shared/open-service/toolsets/estimate-tokens.test.ts
  • code/core/src/shared/open-service/toolsets/estimate-tokens.ts
  • scripts/build/utils/entry-utils.ts
  • scripts/build/utils/generate-types-rolldown.ts
💤 Files with no reviewable changes (9)
  • code/addons/mcp/src/manifests/fixtures/core-manifest.fixture.json
  • code/addons/mcp/package.json
  • code/addons/mcp/src/manifests/in-process-provider.ts
  • code/addons/mcp/src/manifests/service-access.ts
  • code/addons/mcp/src/auth/composition-auth.test.ts
  • code/addons/mcp/src/manifests/core-format-contract.test.ts
  • code/addons/mcp/src/manifests/service-access.test.ts
  • code/addons/mcp/src/manifests/in-process-provider.test.ts
  • code/addons/mcp/src/manifests/vendored.ts

Comment on lines +79 to +99
const access = createProviderDocsAccess({
source: SOURCE,
manifestProvider: async (_request: unknown, path: string) => {
if (path.endsWith('components.json')) {
return JSON.stringify({ v: 1, components });
}
if (path.endsWith('docs.json')) {
return JSON.stringify({ v: 1, docs: {} });
}
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
return JSON.stringify({ c0: [] });
},
});

await access.list({ withStoryIds: true });

expect(peak).toBeLessThanOrEqual(16);
expect(peak).toBeGreaterThan(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The concurrency test measures a mostly failing fan-out.

The provider returns { "c0": [] } for every story $ref. The pointer for component c1 and later is c1, c2, and so on, so fetchRefValue throws for 59 of the 60 refs. list swallows those errors because source is set, and mapWithConcurrency rejects on the first failure. The assertion on peak therefore describes a short, aborted fan-out rather than sustained bounded concurrency.

Serve a payload that matches the requested pointer so every ref resolves.

💚 Proposed fix
         inFlight += 1;
         peak = Math.max(peak, inFlight);
         await new Promise((resolve) => setTimeout(resolve, 1));
         inFlight -= 1;
-        return JSON.stringify({ c0: [] });
+        return JSON.stringify(
+          Object.fromEntries(Array.from({ length: total }, (_, index) => [`c${index}`, []]))
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const access = createProviderDocsAccess({
source: SOURCE,
manifestProvider: async (_request: unknown, path: string) => {
if (path.endsWith('components.json')) {
return JSON.stringify({ v: 1, components });
}
if (path.endsWith('docs.json')) {
return JSON.stringify({ v: 1, docs: {} });
}
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
return JSON.stringify({ c0: [] });
},
});
await access.list({ withStoryIds: true });
expect(peak).toBeLessThanOrEqual(16);
expect(peak).toBeGreaterThan(1);
const access = createProviderDocsAccess({
source: SOURCE,
manifestProvider: async (_request: unknown, path: string) => {
if (path.endsWith('components.json')) {
return JSON.stringify({ v: 1, components });
}
if (path.endsWith('docs.json')) {
return JSON.stringify({ v: 1, docs: {} });
}
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
return JSON.stringify(
Object.fromEntries(Array.from({ length: total }, (_, index) => [`c${index}`, []]))
);
},
});
await access.list({ withStoryIds: true });
expect(peak).toBeLessThanOrEqual(16);
expect(peak).toBeGreaterThan(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@code/core/src/shared/open-service/toolsets/docs/access-provider-isolation.test.ts`
around lines 79 - 99, Update the manifestProvider fallback payload in the
access.list concurrency test so it contains the value requested by each story
ref, rather than always returning only c0. Ensure every cN pointer resolves
successfully, allowing the fan-out to complete while preserving the existing
concurrency assertions.

Comment on lines +185 to +190
try {
await getManifests(request);
} catch (error) {
expect((error as Error).message).not.toContain('componentsManifest');
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Two assertions never run if the call resolves.

Both blocks assert inside catch. If getManifests resolves, the catch body is skipped and the test passes without checking anything. Assert on the rejection directly.

💚 Proposed fix
-      try {
-        await getManifests(request);
-      } catch (error) {
-        expect((error as Error).message).not.toContain('componentsManifest');
-      }
+      await expect(getManifests(request)).rejects.not.toThrow('componentsManifest');
       const request = createMockRequest('https://example.com/mcp');
-      try {
-        await getManifests(request);
-      } catch (error) {
-        expect(error).toBeInstanceOf(ManifestGetError);
-        expect((error as ManifestGetError).url).toBe(
-          'https://example.com/manifests/components.json'
-        );
-      }
+      await expect(getManifests(request)).rejects.toMatchObject({
+        name: 'ManifestGetError',
+        url: 'https://example.com/manifests/components.json',
+      });

Also applies to: 296-304

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.test.ts`
around lines 185 - 190, Update the tests around getManifests so rejection
assertions execute directly rather than only inside catch blocks. Ensure both
affected cases verify that the returned rejection error message does not contain
componentsManifest, while still failing if getManifests resolves.

Comment on lines +70 to +97
async function defaultManifestProvider(
request: Request | undefined,
path: string
): Promise<string> {
if (!request) {
throw new ManifestGetError(
"Request is required when using the default manifest provider. You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request."
);
}
const manifestUrl = getManifestUrlFromRequest(request, path);
const response = await fetch(manifestUrl);

if (!response.ok) {
throw new ManifestGetError(
`Failed to fetch manifest: ${response.status} ${response.statusText}`,
manifestUrl
);
}

const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new ManifestGetError(
`Invalid content type: expected application/json, got ${contentType}`,
manifestUrl
);
}
return response.text();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the default manifest fetch.

defaultManifestProvider calls fetch with no abort signal. A composed Storybook that accepts the connection but never responds holds the MCP request open indefinitely. The listing path fans out to many $ref fetches, so one slow host can occupy the request for a long time.

Pass an AbortSignal so each manifest fetch fails within a bounded time.

🛡️ Proposed fix
+/** Upper bound on one manifest fetch, so an unresponsive host cannot hold the request open. */
+const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
+
 async function defaultManifestProvider(
   request: Request | undefined,
   path: string
 ): Promise<string> {
   if (!request) {
     throw new ManifestGetError(
       "Request is required when using the default manifest provider. You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request."
     );
   }
   const manifestUrl = getManifestUrlFromRequest(request, path);
-  const response = await fetch(manifestUrl);
+  const response = await fetch(manifestUrl, {
+    signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function defaultManifestProvider(
request: Request | undefined,
path: string
): Promise<string> {
if (!request) {
throw new ManifestGetError(
"Request is required when using the default manifest provider. You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request."
);
}
const manifestUrl = getManifestUrlFromRequest(request, path);
const response = await fetch(manifestUrl);
if (!response.ok) {
throw new ManifestGetError(
`Failed to fetch manifest: ${response.status} ${response.statusText}`,
manifestUrl
);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new ManifestGetError(
`Invalid content type: expected application/json, got ${contentType}`,
manifestUrl
);
}
return response.text();
}
/** Upper bound on one manifest fetch, so an unresponsive host cannot hold the request open. */
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
async function defaultManifestProvider(
request: Request | undefined,
path: string
): Promise<string> {
if (!request) {
throw new ManifestGetError(
"Request is required when using the default manifest provider. You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request."
);
}
const manifestUrl = getManifestUrlFromRequest(request, path);
const response = await fetch(manifestUrl, {
signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new ManifestGetError(
`Failed to fetch manifest: ${response.status} ${response.statusText}`,
manifestUrl
);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new ManifestGetError(
`Invalid content type: expected application/json, got ${contentType}`,
manifestUrl
);
}
return response.text();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.ts` around
lines 70 - 97, Update defaultManifestProvider to create a bounded-timeout
AbortSignal and pass it in the fetch call for each manifestUrl request. Ensure
the signal aborts stalled responses and preserves the existing ManifestGetError
handling for non-OK responses.

Comment on lines +122 to +135
export async function fetchManifests(
request?: Request,
manifestProvider?: ManifestProvider,
source?: Source
): Promise<AllManifests> {
const provider = manifestProvider ?? defaultManifestProvider;

const [componentResult, docsResult] = await Promise.allSettled([
provider(request, COMPONENT_MANIFEST_PATH, source),
provider(request, DOCS_MANIFEST_PATH, source),
]);

const getUrl = (path: string) =>
request ? getManifestUrlFromRequest(request, path) : 'Unknown manifest source';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Error URLs name the local origin for remote sources.

getUrl always derives the manifest URL from request.url. When source.url is set, the provider reads the manifest from the remote origin, as createManifestProvider in code/addons/mcp/src/auth/composition-auth.ts shows. The resulting ManifestGetError.url then reports the local Storybook origin for a remote failure, which misdirects debugging of composed sources.

🐛 Proposed fix
   const getUrl = (path: string) =>
-    request ? getManifestUrlFromRequest(request, path) : 'Unknown manifest source';
+    source?.url
+      ? `${source.url.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
+      : request
+        ? getManifestUrlFromRequest(request, path)
+        : 'Unknown manifest source';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function fetchManifests(
request?: Request,
manifestProvider?: ManifestProvider,
source?: Source
): Promise<AllManifests> {
const provider = manifestProvider ?? defaultManifestProvider;
const [componentResult, docsResult] = await Promise.allSettled([
provider(request, COMPONENT_MANIFEST_PATH, source),
provider(request, DOCS_MANIFEST_PATH, source),
]);
const getUrl = (path: string) =>
request ? getManifestUrlFromRequest(request, path) : 'Unknown manifest source';
export async function fetchManifests(
request?: Request,
manifestProvider?: ManifestProvider,
source?: Source
): Promise<AllManifests> {
const provider = manifestProvider ?? defaultManifestProvider;
const [componentResult, docsResult] = await Promise.allSettled([
provider(request, COMPONENT_MANIFEST_PATH, source),
provider(request, DOCS_MANIFEST_PATH, source),
]);
const getUrl = (path: string) =>
source?.url
? `${source.url.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
: request
? getManifestUrlFromRequest(request, path)
: 'Unknown manifest source';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/access-provider.ts` around
lines 122 - 135, Update the getUrl helper in fetchManifests to prefer source.url
when a remote source is provided, falling back to the request-derived manifest
URL for local manifests and to the existing unknown-source value when neither is
available. Ensure ManifestGetError instances use this corrected URL for failures
from composed remote sources.

Comment on lines +245 to +247
export function createDocsToolset(options: CreateDocsToolsetOptions) {
const { docsAccess, sources } = options;
const multiSource = !!sources?.length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against an empty sources array.

multiSource is false when sources is [], and the union permits { sources: [] } with no docsAccess. The list handler then calls docsAccess!.list(...) on undefined and throws a TypeError. The MCP registry reaches this state, because custom?.sources ?? [{ id: 'local', title: 'Local' }] keeps an empty array instead of applying the default (see code/addons/mcp/src/tools/tool-registry.ts lines 133-138). Add a cheap runtime assertion at this boundary.

As per coding guidelines: "Use static TypeScript types and existing lint rules to encode invariants whenever practical; otherwise add a cheap runtime assertion near the boundary."

🛡️ Proposed guard
 export function createDocsToolset(options: CreateDocsToolsetOptions) {
   const { docsAccess, sources } = options;
   const multiSource = !!sources?.length;
+
+  if (!multiSource && !docsAccess) {
+    throw new Error('createDocsToolset requires either a docsAccess or at least one source.');
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createDocsToolset(options: CreateDocsToolsetOptions) {
const { docsAccess, sources } = options;
const multiSource = !!sources?.length;
export function createDocsToolset(options: CreateDocsToolsetOptions) {
const { docsAccess, sources } = options;
const multiSource = !!sources?.length;
if (!multiSource && !docsAccess) {
throw new Error('createDocsToolset requires either a docsAccess or at least one source.');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/definition.ts` around lines
245 - 247, Update createDocsToolset to assert that options.sources is non-empty
whenever sources is provided, rejecting { sources: [] } before handlers can
dereference docsAccess; preserve the existing multiSource behavior for valid
non-empty sources and docsAccess configurations.

Source: Coding guidelines

Comment on lines +21 to +24
## Multi-Source Requests

- With multiple sources configured, **${MCP_TOOL_NAMES['docs.list']}** returns entries from every source; pass \`storybookId\` to **${MCP_TOOL_NAMES['docs.show']}** to scope one.
`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Mention storybookId for docs.showStory too.

In multi-source mode, showStorySchema also requires storybookId (see definition.ts lines 259-261). The instructions name only docs.show, so an agent can call docs.showStory without the field and get a source-selection error.

📝 Proposed wording
-- With multiple sources configured, **${MCP_TOOL_NAMES['docs.list']}** returns entries from every source; pass \`storybookId\` to **${MCP_TOOL_NAMES['docs.show']}** to scope one.
+- With multiple sources configured, **${MCP_TOOL_NAMES['docs.list']}** returns entries from every source; pass \`storybookId\` to **${MCP_TOOL_NAMES['docs.show']}** and **${MCP_TOOL_NAMES['docs.showStory']}** to scope one.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Multi-Source Requests
- With multiple sources configured, **${MCP_TOOL_NAMES['docs.list']}** returns entries from every source; pass \`storybookId\` to **${MCP_TOOL_NAMES['docs.show']}** to scope one.
`;
## Multi-Source Requests
- With multiple sources configured, **${MCP_TOOL_NAMES['docs.list']}** returns entries from every source; pass \`storybookId\` to **${MCP_TOOL_NAMES['docs.show']}** and **${MCP_TOOL_NAMES['docs.showStory']}** to scope one.
`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/shared/open-service/toolsets/docs/instructions.ts` around lines
21 - 24, Update the “Multi-Source Requests” guidance in the instructions
template to mention that both ${MCP_TOOL_NAMES['docs.show']} and
${MCP_TOOL_NAMES['docs.showStory']} require storybookId when selecting a source,
while preserving the existing docs.list behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintenance User-facing maintenance tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant