diff --git a/.changeset/hip-sloths-jog.md b/.changeset/hip-sloths-jog.md new file mode 100644 index 00000000..532de13e --- /dev/null +++ b/.changeset/hip-sloths-jog.md @@ -0,0 +1,9 @@ +--- +'@storybook/mcp': minor +--- + +Replace the `source` property in the context with `request`. + +Now you don't pass in a source string that might be fetched or handled by your custom `manifestProvider`, but instead you pass in the whole web request. (This is automatically handled if you use the createStorybookMcpHandler() function). + +The default action is now to fetch the manifest from `../manifests/components.json` assuming the server is running at `./mcp`. Your custom `manifestProvider()`-function then also does not get a source string as an argument, but gets the whole web request, that you can use to get information about where to fetch the manifest from. It also gets a second argument, `path`, which it should use to determine which specific manifest to get from a built Storybook. (Currently always `./manifests/components.json`, but in the future it might be other paths too). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ed33bbc4..cadbb2f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -61,8 +61,14 @@ The `@storybook/mcp` package (in `packages/mcp`) is framework-agnostic: - Uses `tmcp` with HTTP transport and Valibot schema validation - Factory pattern: `createStorybookMcpHandler()` returns a request handler -- Context-based: handlers accept `StorybookContext` to override source URLs and provide optional callbacks +- Context-based: handlers accept `StorybookContext` which includes the HTTP `Request` object and optional callbacks - **Exports tools and types** for reuse by `addon-mcp` and other consumers +- **Request-based manifest loading**: The `request` property in context is passed to tools, which use it to determine the manifest URL (defaults to same origin, replacing `/mcp` with the manifest path) +- **Optional manifestProvider**: Custom function to override default manifest fetching behavior + - Signature: `(request: Request, path: string) => Promise` + - Receives the `Request` object and a `path` parameter (currently always `'./manifests/components.json'`) + - The provider determines the base URL (e.g., mapping to S3 buckets) while the MCP server handles the path + - Returns the manifest JSON as a string - **Optional handlers**: `StorybookContext` supports optional handlers that are called at various points, allowing consumers to track usage or collect telemetry: - `onSessionInitialize`: Called when an MCP session is initialized - `onListAllComponents`: Called when the list-all-components tool is invoked @@ -221,7 +227,8 @@ export { addMyTool, MY_TOOL_NAME } from './tools/my-tool.ts'; - Checks `features.experimentalComponentsManifest` flag - Checks for `experimental_componentManifestGenerator` preset - Only registers `addListAllComponentsTool` and `addGetComponentDocumentationTool` when enabled -- Context includes `source` URL pointing to `/manifests/components.json` endpoint +- Context includes `request` (HTTP Request object) which tools use to determine manifest location +- Default manifest URL is constructed from request origin, replacing `/mcp` with `/manifests/components.json` - **Optional handlers for tracking**: - `onSessionInitialize`: Called when an MCP session is initialized, receives context - `onListAllComponents`: Called when list tool is invoked, receives context and manifest diff --git a/.github/instructions/mcp.instructions.md b/.github/instructions/mcp.instructions.md index 81308a9f..4ca8918b 100644 --- a/.github/instructions/mcp.instructions.md +++ b/.github/instructions/mcp.instructions.md @@ -42,6 +42,59 @@ src/ 1. **Factory Pattern**: `createStorybookMcpHandler()` creates configured handler instances 2. **Tool Registration**: Tools are added to the server using `server.tool()` method 3. **Async Handler**: Returns a Promise-based request handler compatible with standard HTTP servers +4. **Request-based Context**: The `Request` object is passed through context to tools, which use it to construct the manifest URL + +### Manifest Provider API + +The handler accepts a `StorybookContext` with the following key properties: + +- **`request`**: The HTTP `Request` object being processed (automatically passed by the handler) +- **`manifestProvider`**: Optional custom function `(request: Request, path: string) => Promise` to override default manifest fetching + - **Parameters**: + - `request`: The HTTP `Request` object to determine base URL, headers, routing, etc. + - `path`: The manifest path (currently always `'./manifests/components.json'`) + - **Responsibility**: The provider determines the "first part" of the URL (base URL/origin) by examining the request. The MCP server provides the path. + - Default behavior: Constructs URL from request origin, replacing `/mcp` with the provided path + - Return value should be the manifest JSON as a string + +**Example with custom manifestProvider (local filesystem):** + +```typescript +import { createStorybookMcpHandler } from '@storybook/mcp'; +import { readFile } from 'node:fs/promises'; + +const handler = await createStorybookMcpHandler({ + manifestProvider: async (request, path) => { + // Custom logic: read from local filesystem + // The provider decides on the base path, MCP provides the manifest path + const basePath = '/path/to/manifests'; + // Remove leading './' from path if present + const normalizedPath = path.replace(/^\.\//, ''); + const fullPath = `${basePath}/${normalizedPath}`; + return await readFile(fullPath, 'utf-8'); + }, +}); +``` + +**Example with custom manifestProvider (S3 bucket mapping):** + +```typescript +import { createStorybookMcpHandler } from '@storybook/mcp'; + +const handler = await createStorybookMcpHandler({ + manifestProvider: async (request, path) => { + // Map requests to different S3 buckets based on hostname + const url = new URL(request.url); + const bucket = url.hostname.includes('staging') + ? 'staging-bucket' + : 'prod-bucket'; + const normalizedPath = path.replace(/^\.\, ''); + const manifestUrl = `https://${bucket}.s3.amazonaws.com/${normalizedPath}`; + const response = await fetch(manifestUrl); + return await response.text(); + }, +}); +``` ### Component Manifest and ReactDocgen Support diff --git a/packages/addon-mcp/src/mcp-handler.ts b/packages/addon-mcp/src/mcp-handler.ts index 12c12719..931cbbd9 100644 --- a/packages/addon-mcp/src/mcp-handler.ts +++ b/packages/addon-mcp/src/mcp-handler.ts @@ -96,8 +96,7 @@ export const mcpServerHandler = async ({ toolsets: getToolsets(webRequest, addonOptions), origin: origin!, disableTelemetry, - // Source URL for component manifest tools - points to the manifest endpoint - source: `${origin}/manifests/components.json`, + request: webRequest, // Telemetry handlers for component manifest tools ...(!disableTelemetry && { onListAllComponents: async ({ manifest }) => { diff --git a/packages/mcp/bin.ts b/packages/mcp/bin.ts index 80e0b6fa..6de06119 100644 --- a/packages/mcp/bin.ts +++ b/packages/mcp/bin.ts @@ -52,11 +52,15 @@ const args = parseArgs({ transport.listen({ source: args.values.manifestPath, - manifestProvider: async (source) => { - if (source.startsWith('http://') || source.startsWith('https://')) { - const res = await fetch(source); + manifestProvider: async () => { + const { manifestPath } = args.values; + if ( + manifestPath.startsWith('http://') || + manifestPath.startsWith('https://') + ) { + const res = await fetch(manifestPath); return await res.text(); } - return await fs.readFile(source, 'utf-8'); + return await fs.readFile(manifestPath, 'utf-8'); }, }); diff --git a/packages/mcp/serve.ts b/packages/mcp/serve.ts index 946b217d..9b965562 100644 --- a/packages/mcp/serve.ts +++ b/packages/mcp/serve.ts @@ -5,13 +5,16 @@ import { parseArgs } from 'node:util'; async function serveMcp(port: number, manifestPath: string) { const storybookMcpHandler = await createStorybookMcpHandler({ - source: manifestPath, - manifestProvider: async (source) => { - if (source.startsWith('http://') || source.startsWith('https://')) { - const res = await fetch(source); + // Use the local fixture file via manifestProvider + manifestProvider: async () => { + if ( + manifestPath.startsWith('http://') || + manifestPath.startsWith('https://') + ) { + const res = await fetch(manifestPath); return await res.text(); } - return await fs.readFile(source, 'utf-8'); + return await fs.readFile(manifestPath, 'utf-8'); }, }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 9d7282c3..252c3723 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -16,6 +16,9 @@ export { GET_TOOL_NAME, } from './tools/get-component-documentation.ts'; +// Export manifest constants +export { MANIFEST_PATH } from './utils/get-manifest.ts'; + // Export types for reuse export type { StorybookContext } from './types.ts'; @@ -94,7 +97,7 @@ export const createStorybookMcpHandler = async ( return (async (req, context) => { return await transport.respond(req, { - source: context?.source ?? options.source, + request: req, manifestProvider: context?.manifestProvider ?? options.manifestProvider, onListAllComponents: context?.onListAllComponents ?? options.onListAllComponents, diff --git a/packages/mcp/src/tools/get-component-documentation.test.ts b/packages/mcp/src/tools/get-component-documentation.test.ts index 891db65f..5ebcca0f 100644 --- a/packages/mcp/src/tools/get-component-documentation.test.ts +++ b/packages/mcp/src/tools/get-component-documentation.test.ts @@ -63,7 +63,10 @@ describe('getComponentDocumentationTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -102,7 +105,10 @@ describe('getComponentDocumentationTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -137,7 +143,10 @@ describe('getComponentDocumentationTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -167,14 +176,19 @@ describe('getComponentDocumentationTool', () => { }, }; - // Pass the handler in the context for this specific request + const mockHttpRequest = new Request('https://example.com/mcp'); + // Pass the handler and request in the context for this specific request await server.receive(request, { - custom: { onGetComponentDocumentation: handler }, + custom: { + request: mockHttpRequest, + onGetComponentDocumentation: handler, + }, }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith({ context: expect.objectContaining({ + request: mockHttpRequest, onGetComponentDocumentation: handler, }), input: { componentId: 'button' }, @@ -232,7 +246,10 @@ describe('getComponentDocumentationTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { diff --git a/packages/mcp/src/tools/get-component-documentation.ts b/packages/mcp/src/tools/get-component-documentation.ts index b454f84d..47949deb 100644 --- a/packages/mcp/src/tools/get-component-documentation.ts +++ b/packages/mcp/src/tools/get-component-documentation.ts @@ -29,7 +29,7 @@ export async function addGetComponentDocumentationTool( async (input: GetComponentDocumentationInput) => { try { const manifest = await getManifest( - server.ctx.custom?.source, + server.ctx.custom?.request, server.ctx.custom?.manifestProvider, ); diff --git a/packages/mcp/src/tools/list-all-components.test.ts b/packages/mcp/src/tools/list-all-components.test.ts index 12010976..5487e6d6 100644 --- a/packages/mcp/src/tools/list-all-components.test.ts +++ b/packages/mcp/src/tools/list-all-components.test.ts @@ -61,7 +61,10 @@ describe('listAllComponentsTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -115,7 +118,10 @@ describe('listAllComponentsTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -143,7 +149,10 @@ describe('listAllComponentsTool', () => { }, }; - const response = await server.receive(request); + const mockHttpRequest = new Request('https://example.com/mcp'); + const response = await server.receive(request, { + custom: { request: mockHttpRequest }, + }); expect(response.result).toMatchInlineSnapshot(` { @@ -171,12 +180,16 @@ describe('listAllComponentsTool', () => { }, }; - // Pass the handler in the context for this specific request - await server.receive(request, { custom: { onListAllComponents: handler } }); + const mockHttpRequest = new Request('https://example.com/mcp'); + // Pass the handler and request in the context for this specific request + await server.receive(request, { + custom: { request: mockHttpRequest, onListAllComponents: handler }, + }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith({ context: expect.objectContaining({ + request: mockHttpRequest, onListAllComponents: handler, }), manifest: smallManifestFixture, diff --git a/packages/mcp/src/tools/list-all-components.ts b/packages/mcp/src/tools/list-all-components.ts index 48c1dd8e..d456581e 100644 --- a/packages/mcp/src/tools/list-all-components.ts +++ b/packages/mcp/src/tools/list-all-components.ts @@ -20,7 +20,7 @@ export async function addListAllComponentsTool( async () => { try { const manifest = await getManifest( - server.ctx.custom?.source, + server.ctx.custom?.request, server.ctx.custom?.manifestProvider, ); diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index cdac4c12..42b29e9e 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -3,19 +3,22 @@ import * as v from 'valibot'; /** * Custom context passed to MCP server and tools. - * Contains the source URL for getting component manifests. + * Contains the request object and optional manifest provider. */ export interface StorybookContext extends Record { /** - * The URL of the remote manifest to get component data from. + * The incoming HTTP request being processed. */ - source?: string; + request?: Request; /** * Optional function to provide custom manifest retrieval logic. - * If provided, this function will be called instead of using fetch. - * The function receives the source URL and should return the manifest as a string. + * If provided, this function will be called instead of the default fetch-based provider. + * The function receives the request object and a path to the manifest file, + * and should return the manifest as a string. + * The default provider constructs the manifest URL from the request origin, + * replacing /mcp with /manifests/components.json */ - manifestProvider?: (source: string) => Promise; + manifestProvider?: (request: Request, path: string) => Promise; /** * Optional handler called when list-all-components tool is invoked. * Receives the context and the component manifest. diff --git a/packages/mcp/src/utils/get-manifest.test.ts b/packages/mcp/src/utils/get-manifest.test.ts index 7c487f27..677d74e7 100644 --- a/packages/mcp/src/utils/get-manifest.test.ts +++ b/packages/mcp/src/utils/get-manifest.test.ts @@ -4,6 +4,15 @@ import type { ComponentManifestMap } from '../types'; global.fetch = vi.fn(); +/** + * Helper function to create a mock Request object + */ +function createMockRequest(url: string): Request { + return new Request(url, { + method: 'POST', + }); +} + describe('getManifest', () => { beforeEach(() => { // Reset the fetch mock between tests since we're checking call counts @@ -11,14 +20,14 @@ describe('getManifest', () => { }); describe('error cases', () => { - it('should throw ManifestGetError when url is not provided', async () => { + it('should throw ManifestGetError when request is not provided', async () => { await expect(getManifest()).rejects.toThrow(ManifestGetError); await expect(getManifest()).rejects.toThrow( - 'The source URL is required, but was not part of the request context nor was a default source for the server set', + 'The request is required but was not provided in the context', ); }); - it('should throw ManifestGetError when url is undefined', async () => { + it('should throw ManifestGetError when request is undefined', async () => { await expect(getManifest(undefined)).rejects.toThrow(ManifestGetError); }); @@ -29,12 +38,11 @@ describe('getManifest', () => { statusText: 'Not Found', }); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow('Failed to fetch manifest: 404 Not Found'); + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow(ManifestGetError); + await expect(getManifest(request)).rejects.toThrow( + 'Failed to fetch manifest: 404 Not Found', + ); }); it('should throw ManifestGetError when fetch fails with 500', async () => { @@ -44,9 +52,10 @@ describe('getManifest', () => { statusText: 'Internal Server Error', }); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow('Failed to fetch manifest: 500 Internal Server Error'); + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow( + 'Failed to fetch manifest: 500 Internal Server Error', + ); }); it('should throw ManifestGetError when content type is not JSON', async () => { @@ -57,12 +66,9 @@ describe('getManifest', () => { }, }); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow( + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow(ManifestGetError); + await expect(getManifest(request)).rejects.toThrow( 'Invalid content type: expected application/json, got text/html', ); }); @@ -76,12 +82,11 @@ describe('getManifest', () => { text: vi.fn().mockResolvedValue('not valid json{'), }); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow('Failed to get manifest:'); + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow(ManifestGetError); + await expect(getManifest(request)).rejects.toThrow( + 'Failed to get manifest:', + ); }); it('should throw ManifestGetError when manifest schema is invalid', async () => { @@ -98,8 +103,9 @@ describe('getManifest', () => { ), }); + const request = createMockRequest('https://example.com/mcp'); await expect( - getManifest('https://example.com/manifest.json'), + getManifest(request), ).rejects.toThrowErrorMatchingInlineSnapshot( `[ManifestGetError: Failed to get manifest: Invalid key: Expected "v" but received undefined]`, ); @@ -119,12 +125,11 @@ describe('getManifest', () => { ), }); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow('No components found in the manifest'); + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow(ManifestGetError); + await expect(getManifest(request)).rejects.toThrow( + 'No components found in the manifest', + ); }); it('should wrap network errors in ManifestGetError', async () => { @@ -132,12 +137,11 @@ describe('getManifest', () => { .fn() .mockRejectedValue(new Error('Network connection failed')); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('https://example.com/manifest.json'), - ).rejects.toThrow('Network connection failed'); + const request = createMockRequest('https://example.com/mcp'); + await expect(getManifest(request)).rejects.toThrow(ManifestGetError); + await expect(getManifest(request)).rejects.toThrow( + 'Network connection failed', + ); }); it('should preserve ManifestGetError when already thrown', async () => { @@ -147,12 +151,13 @@ describe('getManifest', () => { statusText: 'Not Found', }); + const request = createMockRequest('https://example.com/mcp'); try { - await getManifest('https://example.com/manifest.json'); + await getManifest(request); } catch (error) { expect(error).toBeInstanceOf(ManifestGetError); expect((error as ManifestGetError).url).toBe( - 'https://example.com/manifest.json', + 'https://example.com/manifests/components.json', ); } }); @@ -180,11 +185,12 @@ describe('getManifest', () => { text: vi.fn().mockResolvedValue(JSON.stringify(validManifest)), }); - const result = await getManifest('https://example.com/manifest.json'); + const request = createMockRequest('https://example.com/mcp'); + const result = await getManifest(request); expect(result).toEqual(validManifest); expect(global.fetch).toHaveBeenCalledExactlyOnceWith( - 'https://example.com/manifest.json', + 'https://example.com/manifests/components.json', ); }); }); @@ -203,18 +209,17 @@ describe('getManifest', () => { }, }; + const request = createMockRequest('https://example.com/mcp'); const manifestProvider = vi .fn() .mockResolvedValue(JSON.stringify(validManifest)); - const result = await getManifest( - './fixtures/manifest.json', - manifestProvider, - ); + const result = await getManifest(request, manifestProvider); expect(result).toEqual(validManifest); expect(manifestProvider).toHaveBeenCalledExactlyOnceWith( - './fixtures/manifest.json', + request, + './manifests/components.json', ); // fetch should not be called when manifestProvider is used expect(global.fetch).not.toHaveBeenCalled(); @@ -241,33 +246,36 @@ describe('getManifest', () => { text: vi.fn().mockResolvedValue(JSON.stringify(validManifest)), }); - const result = await getManifest('https://example.com/manifest.json'); + const request = createMockRequest('https://example.com/mcp'); + const result = await getManifest(request); expect(result).toEqual(validManifest); expect(global.fetch).toHaveBeenCalledExactlyOnceWith( - 'https://example.com/manifest.json', + 'https://example.com/manifests/components.json', ); }); it('should handle errors from manifestProvider', async () => { + const request = createMockRequest('https://example.com/mcp'); const manifestProvider = vi .fn() .mockRejectedValue(new Error('File not found')); - await expect( - getManifest('./fixtures/manifest.json', manifestProvider), - ).rejects.toThrow(ManifestGetError); - await expect( - getManifest('./fixtures/manifest.json', manifestProvider), - ).rejects.toThrow('Failed to get manifest: File not found'); + await expect(getManifest(request, manifestProvider)).rejects.toThrow( + ManifestGetError, + ); + await expect(getManifest(request, manifestProvider)).rejects.toThrow( + 'Failed to get manifest: File not found', + ); }); it('should handle invalid JSON from manifestProvider', async () => { + const request = createMockRequest('https://example.com/mcp'); const manifestProvider = vi.fn().mockResolvedValue('not valid json{'); - await expect( - getManifest('./fixtures/manifest.json', manifestProvider), - ).rejects.toThrow(ManifestGetError); + await expect(getManifest(request, manifestProvider)).rejects.toThrow( + ManifestGetError, + ); }); }); }); diff --git a/packages/mcp/src/utils/get-manifest.ts b/packages/mcp/src/utils/get-manifest.ts index 45d108cc..e71a6506 100644 --- a/packages/mcp/src/utils/get-manifest.ts +++ b/packages/mcp/src/utils/get-manifest.ts @@ -1,6 +1,11 @@ import { ComponentManifestMap } from '../types.ts'; import * as v from 'valibot'; +/** + * The path to the component manifest file relative to the Storybook build + */ +export const MANIFEST_PATH = './manifests/components.json'; + /** * Error thrown when getting or parsing a manifest fails */ @@ -57,33 +62,34 @@ export const errorToMCPContent = (error: unknown): MCPErrorResult => { }; /** - * Gets a component manifest from a remote URL or using a custom provider + * Gets a component manifest from a request or using a custom provider * - * @param url - The URL to get the manifest from + * @param request - The HTTP request to get the manifest for * @param manifestProvider - Optional custom function to get the manifest * @returns A promise that resolves to the parsed ComponentManifestMap * @throws {ManifestGetError} If getting the manifest fails or the response is invalid */ export async function getManifest( - url?: string, - manifestProvider?: (source: string) => Promise, + request?: Request, + manifestProvider?: (request: Request, path: string) => Promise, ): Promise { + if (!request) { + throw new ManifestGetError( + 'The request is required but was not provided in the context', + ); + } try { - if (!url) { - throw new ManifestGetError( - 'The source URL is required, but was not part of the request context nor was a default source for the server set', - ); - } - - // Use custom manifestProvider if provided, otherwise fallback to fetch - const manifestString = await ( - manifestProvider ?? defaultFetchManifestProvider - )(url); + // Use custom manifestProvider if provided, otherwise fallback to default + const manifestString = await (manifestProvider ?? defaultManifestProvider)( + request, + MANIFEST_PATH, + ); const manifestData: unknown = JSON.parse(manifestString); const manifest = v.parse(ComponentManifestMap, manifestData); if (Object.keys(manifest.components).length === 0) { + const url = getManifestUrlFromRequest(request, MANIFEST_PATH); throw new ManifestGetError(`No components found in the manifest`, url); } @@ -96,19 +102,39 @@ export async function getManifest( // Wrap network errors and other unexpected errors throw new ManifestGetError( `Failed to get manifest: ${error instanceof Error ? error.message : String(error)}`, - url, + getManifestUrlFromRequest(request, MANIFEST_PATH), error instanceof Error ? error : undefined, ); } } -async function defaultFetchManifestProvider(source: string): Promise { - const response = await fetch(source); +/** + * Constructs the manifest URL from a request by replacing /mcp with the provided path + */ +function getManifestUrlFromRequest(request: Request, path: string): string { + const url = new URL(request.url); + // Replace /mcp endpoint with the provided path (e.g., './manifests/components.json') + // Remove leading './' from path if present + const normalizedPath = path.replace(/^\.\//, ''); + url.pathname = url.pathname.replace(/\/mcp\/?$/, `/${normalizedPath}`); + return url.toString(); +} + +/** + * Default manifest provider that fetches from the same origin as the request, + * replacing /mcp with the provided path + */ +async function defaultManifestProvider( + request: Request, + path: string, +): Promise { + const manifestUrl = getManifestUrlFromRequest(request, path); + const response = await fetch(manifestUrl); if (!response.ok) { throw new ManifestGetError( `Failed to fetch manifest: ${response.status} ${response.statusText}`, - source, + manifestUrl, ); } @@ -116,7 +142,7 @@ async function defaultFetchManifestProvider(source: string): Promise { if (!contentType?.includes('application/json')) { throw new ManifestGetError( `Invalid content type: expected application/json, got ${contentType}`, - source, + manifestUrl, ); } return response.text();