diff --git a/.changeset/experimental-server-cards.md b/.changeset/experimental-server-cards.md new file mode 100644 index 0000000000..ae32fb6fd6 --- /dev/null +++ b/.changeset/experimental-server-cards.md @@ -0,0 +1,23 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/express': patch +--- + +Add experimental Server Card extension support (SEP-2127) behind new +`experimental/server-card` subpaths. `@modelcontextprotocol/core` gains the +Zod schemas, inferred types, and wire constants for Server Cards and AI +Catalogs. `@modelcontextprotocol/server` gains `buildServerCard`, +`buildAICatalog`, `serverCardCatalogEntry`, `getServerCardUrl`, and the +web-standard `serverCardResponse`/`aiCatalogResponse` responders (CORS, +Cache-Control, strong ETag with If-None-Match 304s, sync fall-through +matching). `@modelcontextprotocol/client` gains hardened `fetchServerCard`, +`fetchAICatalog`, and `discoverServerCards` helpers (HTTPS-only defaults, +private-address and single-label host guards, size and redirect caps, +credential-free requests, caller-owned ETag caching, listing-chain +provenance), plus `requiredRemoteInputs`, `resolveRemote`, +`reconcileServerCard`, and `ServerCardError`. `@modelcontextprotocol/express` +gains a thin `mcpServerCardRouter` adapter over the neutral responders. +Nothing lands on any package root; the subpaths are the experimental marker +and may change or be removed in any release. diff --git a/CLAUDE.md b/CLAUDE.md index b027791d8e..5d650fb071 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,11 @@ The repo also ships “middleware” packages under `packages/middleware/` (e.g. ### Experimental Features -Located in `packages/*/src/experimental/`. Currently empty. +Located in `packages/*/src/experimental/` and exported only at `./experimental/*` subpaths +(never from a package root), which marks them as changeable or removable in any release. +Currently: Server Card extension support (SEP-2127) — `experimental/server-card` subpaths on +`core` (schemas/types/constants), `server` (builders + responders), `client` (hardened +fetch/discover/resolve/reconcile helpers), and `express` (router adapter). ### Zod Schemas diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 02ab4d2ff6..50ee89cf2c 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -69,7 +69,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Schema libraries', link: '/advanced/schema-libraries' }, { text: 'Custom transports', link: '/advanced/custom-transports' }, { text: 'Wire schemas', link: '/advanced/wire-schemas' }, - { text: 'Gateway', link: '/advanced/gateway' } + { text: 'Gateway', link: '/advanced/gateway' }, + { text: 'Server cards', link: '/advanced/server-cards' } ] }, { text: 'Testing', link: '/testing' }, diff --git a/docs/advanced/server-cards.md b/docs/advanced/server-cards.md new file mode 100644 index 0000000000..cbd0d0c7f9 --- /dev/null +++ b/docs/advanced/server-cards.md @@ -0,0 +1,138 @@ +--- +shape: how-to +--- + +# Server Cards + +::: warning +Server Cards are an experimental MCP extension ([SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127)). The `experimental/server-card` subpaths may change or be removed in any release. +::: + +A **Server Card** is a static JSON document describing a remote MCP server well enough to discover and connect to it before any protocol exchange. Cards are advisory: never use their contents for security or access-control decisions. + +## Serve a card and a catalog + +Build both documents at startup — an invalid card is a boot error, never a broken production document — and compose the responders in front of your MCP handler. + +```ts source="../../examples/guides/advanced/server-cards.examples.ts#serve_card" +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { + aiCatalogResponse, + buildAICatalog, + buildServerCard, + getServerCardUrl, + serverCardCatalogEntry, + serverCardResponse +} from '@modelcontextprotocol/server/experimental/server-card'; + +const serverInfo = { name: 'com.example/weather', version: '1.0.0' }; +const mcpUrl = new URL('https://weather.example.com/mcp'); + +const card = buildServerCard({ + name: 'com.example/weather', + description: 'Hourly and 7-day forecasts for any coordinates', + serverInfo, // prefills version, title, websiteUrl, icons + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] +}); +const catalog = buildAICatalog({ + entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] +}); + +const handler = createMcpHandler(() => new McpServer(serverInfo)); + +async function fetchHandler(request: Request): Promise { + return await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }) ?? handler.fetch(request)); +} +``` + +`serverCardResponse` answers `GET /server-card` (the reserved location) and `aiCatalogResponse` answers `GET /.well-known/ai-catalog.json`, both with the spec's CORS headers, `Cache-Control: public, max-age=3600`, and a strong ETag that turns unchanged refetches into 304s. Unmatched paths return `undefined` synchronously and fall through to your own routing. + +::: tip +Publish the catalog on the domain users associate with your service, which is not always the API host serving MCP traffic. `aiCatalogResponse` takes a `path` option when the well-known location is taken. +::: + +## Serve from Express + +An exact `app.all('/mcp', ...)` mount never sees `GET /mcp/server-card`. Mount the card router at the application root in front of it. + +```ts source="../../examples/guides/advanced/server-cards.examples.ts#express_router" +// import { mcpServerCardRouter } from '@modelcontextprotocol/express/experimental/server-card'; +const app = express(); +app.use(mcpServerCardRouter({ card, mcpUrl, catalog: { catalog } })); +app.all('/mcp', mcpHandler); // the exact mount never sees GET /mcp/server-card; the router does +``` + +## Discover servers from a domain + +`discoverServerCards` probes `https://{domain}/.well-known/ai-catalog.json`, follows the card entries, and returns validated cards with their listing chain. A domain without a catalog yields `[]`, a cacheable miss. + +```ts source="../../examples/guides/advanced/server-cards.examples.ts#discover_domain" +const hits = await discoverServerCards('weather.example.com', discoveryOptions); +for (const hit of hits) { + console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); +} +``` + +Each hit names both sides of the listing chain for your consent UI: + +``` +urn:air:example.com:mcp:weather: listed by weather.example.com, hosted by weather.example.com +``` + +`listingDomain` is where the claim came from and `hostingDomain` is where traffic would go. Entries are self-asserted, so de-duplicate on `hit.card.remotes[].url`, never on names or catalog identifiers. Run the probe in the background, never auto-connect, and scope approval per server. + +::: info +The fetchers reject private, loopback, and link-local addresses, cap response sizes, and bound redirects by default; the local-dev hosts `localhost`, `127.0.0.1`, and `[::1]` are always exempt. These are hostname-level checks: inject a DNS-pinning `fetch` through the `fetch` option to defend against DNS rebinding, and pass `allowHttp`/`allowPrivateHosts` to reach other private hosts. +::: + +## Resolve inputs and connect + +`requiredRemoteInputs` lists everything a card remote wants from the user (secrets, choices, defaults included). `resolveRemote` substitutes the `{var}` templates and hands you a URL and headers for the transport. After connecting, `reconcileServerCard` diffs the card's claims against the live `serverInfo`. + +```ts source="../../examples/guides/advanced/server-cards.examples.ts#resolve_connect" +const remote = hits[0]!.card.remotes![0]!; +console.log(requiredRemoteInputs(remote)); // prompt the user for these; [] here +const { url, headers } = resolveRemote(remote); + +const client = new Client({ name: 'discovery-host', version: '1.0.0' }); +await client.connect(new StreamableHTTPClientTransport(url, { requestInit: { headers }, fetch: inProcessFetch })); + +const mismatches = reconcileServerCard(hits[0]!.card, client.getServerVersion()!, { remote }); +console.log(mismatches); // runtime wins on any disagreement; [] means the card told the truth +``` + +The card above reconciles cleanly: + +``` +[] +``` + +A missing required input throws a `ServerCardError` with code `missing-input` carrying every unmet input, so one prompt round trip collects them all. + +## Revalidate with the ETag + +Fetch results return `etag` and `cacheControl` verbatim; you own the cache. Send the stored validator back and an unchanged document costs a 304. + +```ts source="../../examples/guides/advanced/server-cards.examples.ts#etag_refetch" +const cardUrl = getServerCardUrl(mcpUrl); +const first = await fetchServerCard(cardUrl, discoveryOptions); +if (!first.notModified) { + console.log(`cache ${first.url} etag=${first.etag !== undefined} cacheControl=${first.cacheControl}`); +} +const again = await fetchServerCard(cardUrl, { ...discoveryOptions, etag: first.etag }); +console.log(again.notModified); // true: the document did not change +``` + +``` +true +``` + +Cache per domain, including misses, and honor `Cache-Control` instead of polling. + +## Recap + +- `buildServerCard` and `buildAICatalog` validate at startup; `serverCardResponse` and `aiCatalogResponse` serve the documents with CORS, caching, and ETag handling from any web-standard host. +- `mcpServerCardRouter` serves both routes from Express, in front of an exact `/mcp` mount. +- `discoverServerCards` turns a domain into validated cards plus listing-chain provenance; a missing catalog is `[]`, not an error. +- `resolveRemote` and `requiredRemoteInputs` turn a card remote into a connectable URL and headers; `reconcileServerCard` diffs card claims against runtime, and runtime wins. +- Cards are advisory. De-duplicate on remote URLs, keep approval per server, and cache with the returned ETags. diff --git a/examples/README.md b/examples/README.md index fc57f2de7d..221825b131 100644 --- a/examples/README.md +++ b/examples/README.md @@ -54,6 +54,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | | [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | | [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | +| [`server-card-discovery/`](./server-card-discovery/README.md) | Experimental Server Cards (SEP-2127): card + AI Catalog responders, domain discovery, resolve, connect, reconcile | http | modern | ## HTTP hosting variants diff --git a/examples/guides/advanced/server-cards.examples.ts b/examples/guides/advanced/server-cards.examples.ts new file mode 100644 index 0000000000..deeb599b00 --- /dev/null +++ b/examples/guides/advanced/server-cards.examples.ts @@ -0,0 +1,114 @@ +/** + * Companion example for `docs/advanced/server-cards.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness drives the + * composed fetch handler in process (a FetchLike that dispatches straight to + * it), discovers the card, connects, and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/server-cards.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { check } from '@mcp-examples/shared'; +import { mcpServerCardRouter } from '@modelcontextprotocol/express/experimental/server-card'; +import express from 'express'; + +//#region serve_card +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { + aiCatalogResponse, + buildAICatalog, + buildServerCard, + getServerCardUrl, + serverCardCatalogEntry, + serverCardResponse +} from '@modelcontextprotocol/server/experimental/server-card'; + +const serverInfo = { name: 'com.example/weather', version: '1.0.0' }; +const mcpUrl = new URL('https://weather.example.com/mcp'); + +const card = buildServerCard({ + name: 'com.example/weather', + description: 'Hourly and 7-day forecasts for any coordinates', + serverInfo, // prefills version, title, websiteUrl, icons + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] +}); +const catalog = buildAICatalog({ + entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] +}); + +const handler = createMcpHandler(() => new McpServer(serverInfo)); + +async function fetchHandler(request: Request): Promise { + return await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }) ?? handler.fetch(request)); +} +//#endregion serve_card + +// --------------------------------------------------------------------------- +// Harness plumbing (not shown on the page): a FetchLike that dispatches to the +// handler above in process, so discovery and the MCP connection run without +// binding a port. +// --------------------------------------------------------------------------- + +const { Client, StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); +const { discoverServerCards, fetchServerCard, requiredRemoteInputs, resolveRemote, reconcileServerCard } = await import( + '@modelcontextprotocol/client/experimental/server-card' +); +const inProcessFetch = async (url: string | URL, init?: RequestInit): Promise => fetchHandler(new Request(url, init)); +const discoveryOptions = { fetch: inProcessFetch }; + +// "Discover servers from a domain" — the hit the page quotes. +//#region discover_domain +const hits = await discoverServerCards('weather.example.com', discoveryOptions); +for (const hit of hits) { + console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); +} +//#endregion discover_domain +check(hits.length === 1, 'one card discovered'); +check(hits[0]!.listingDomain === 'weather.example.com' && hits[0]!.hostingDomain === 'weather.example.com', 'provenance'); + +// "Resolve inputs and connect". +//#region resolve_connect +const remote = hits[0]!.card.remotes![0]!; +console.log(requiredRemoteInputs(remote)); // prompt the user for these; [] here +const { url, headers } = resolveRemote(remote); + +const client = new Client({ name: 'discovery-host', version: '1.0.0' }); +await client.connect(new StreamableHTTPClientTransport(url, { requestInit: { headers }, fetch: inProcessFetch })); + +const mismatches = reconcileServerCard(hits[0]!.card, client.getServerVersion()!, { remote }); +console.log(mismatches); // runtime wins on any disagreement; [] means the card told the truth +//#endregion resolve_connect +check(url.href === 'https://weather.example.com/mcp', 'resolved url'); +check(mismatches.length === 0, 'card reconciles cleanly'); + +// "Revalidate with the ETag" — caller-owned caching. +//#region etag_refetch +const cardUrl = getServerCardUrl(mcpUrl); +const first = await fetchServerCard(cardUrl, discoveryOptions); +if (!first.notModified) { + console.log(`cache ${first.url} etag=${first.etag !== undefined} cacheControl=${first.cacheControl}`); +} +const again = await fetchServerCard(cardUrl, { ...discoveryOptions, etag: first.etag }); +console.log(again.notModified); // true: the document did not change +//#endregion etag_refetch +check(again.notModified, '304 on revalidation'); + +await client.close(); +await handler.close(); + +// The express adapter (shown on the page, never started here): mount the card +// router in front of an exact /mcp mount. +export function expressWiring(mcpHandler: (req: unknown, res: unknown) => void) { + //#region express_router + // import { mcpServerCardRouter } from '@modelcontextprotocol/express/experimental/server-card'; + const app = express(); + app.use(mcpServerCardRouter({ card, mcpUrl, catalog: { catalog } })); + app.all('/mcp', mcpHandler); // the exact mount never sees GET /mcp/server-card; the router does + //#endregion express_router + return app; +} diff --git a/examples/server-card-discovery/README.md b/examples/server-card-discovery/README.md new file mode 100644 index 0000000000..5348a6a88a --- /dev/null +++ b/examples/server-card-discovery/README.md @@ -0,0 +1,17 @@ +# server-card-discovery + +Experimental Server Card extension (SEP-2127) end to end. The server exposes an +MCP endpoint plus its Server Card at `/mcp/server-card` and an AI Catalog at +`/.well-known/ai-catalog.json`. The client is told only the domain: it probes +the well-known catalog with `discoverServerCards`, validates the card, resolves +the remote with `resolveRemote`, connects, calls a tool, and reconciles the +card's claims against the live `serverInfo` with `reconcileServerCard`. + +HTTP only — cards describe remote servers. + +```bash +pnpm --filter @mcp-examples/server-card-discovery server -- --http --port 3000 +pnpm --filter @mcp-examples/server-card-discovery client -- --http http://127.0.0.1:3000/mcp +``` + +See `docs/advanced/server-cards.md` for the guide. diff --git a/examples/server-card-discovery/client.ts b/examples/server-card-discovery/client.ts new file mode 100644 index 0000000000..82040268b5 --- /dev/null +++ b/examples/server-card-discovery/client.ts @@ -0,0 +1,44 @@ +/** + * Server Card discovery example (experimental extension, SEP-2127): the + * client is told only the domain. It probes the well-known AI Catalog, + * validates the discovered card, resolves the remote, connects, calls a tool, + * and reconciles the card's claims against the live serverInfo. + * + * HTTP only — run the sibling `server.ts --http --port ` first, then + * `client.ts --http http://127.0.0.1:/mcp`. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { discoverServerCards, reconcileServerCard, resolveRemote } from '@modelcontextprotocol/client/experimental/server-card'; + +const { transport, url } = parseExampleArgs(); +if (transport !== 'http') { + throw new Error('this story is HTTP-only; pass --http http://127.0.0.1:/mcp'); +} + +// Only the ORIGIN goes in: discovery finds the /mcp endpoint from the domain's +// AI Catalog. The hardened defaults reject plain HTTP and private addresses, +// but the local-dev hosts (localhost, 127.0.0.1, [::1]) are always exempt. +const origin = new URL(url).origin; +const hits = await discoverServerCards(origin); +check.equal(hits.length, 1, `expected one discovered card from ${origin}`); +const hit = hits[0]!; +console.error(`[client] discovered ${hit.entry.identifier} (listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'})`); +check.equal(hit.card.name, 'com.example/weather'); + +const remote = hit.card.remotes![0]!; +const resolved = resolveRemote(remote); +check.equal(resolved.url.href, new URL(url).href, 'card remote must resolve to the real endpoint'); + +const client = new Client({ name: 'server-card-discovery-client', version: '1.0.0' }); +await client.connect(new StreamableHTTPClientTransport(resolved.url, { requestInit: { headers: resolved.headers } })); + +const result = await client.callTool({ name: 'forecast', arguments: { city: 'Berlin' } }); +check.deepEqual(result.content, [{ type: 'text', text: 'Sunny in Berlin' }]); + +// Advisory reconciliation: runtime wins on any disagreement. +const mismatches = reconcileServerCard(hit.card, client.getServerVersion()!, { remote }); +check.deepEqual(mismatches, [], 'card claims must match the live serverInfo'); + +console.error('[client] discovery, connection, and reconciliation all verified'); +await client.close(); diff --git a/examples/server-card-discovery/package.json b/examples/server-card-discovery/package.json new file mode 100644 index 0000000000..78c607cb5e --- /dev/null +++ b/examples/server-card-discovery/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/server-card-discovery", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "Server Cards describe remote HTTP servers, so the story is HTTP-only: the client discovers the endpoint from the domain's AI Catalog instead of being told the /mcp URL." + } +} diff --git a/examples/server-card-discovery/server.ts b/examples/server-card-discovery/server.ts new file mode 100644 index 0000000000..07cefa2377 --- /dev/null +++ b/examples/server-card-discovery/server.ts @@ -0,0 +1,61 @@ +/** + * Server Card discovery example (experimental extension, SEP-2127): an MCP + * endpoint that also serves its Server Card at `/mcp/server-card` and an AI + * Catalog at `/.well-known/ai-catalog.json`, so clients can discover it from + * the domain alone. + * + * HTTP only — cards describe remote servers. Start with `--http --port `. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { + aiCatalogResponse, + buildAICatalog, + buildServerCard, + getServerCardUrl, + serverCardCatalogEntry, + serverCardResponse +} from '@modelcontextprotocol/server/experimental/server-card'; +import { z } from 'zod/v4'; + +const serverInfo = { name: 'com.example/weather', version: '1.0.0' }; + +function buildServer(): McpServer { + const mcp = new McpServer(serverInfo); + mcp.registerTool('forecast', { inputSchema: z.object({ city: z.string() }) }, ({ city }) => ({ + content: [{ type: 'text', text: `Sunny in ${city}` }] + })); + return mcp; +} + +const { transport, port } = parseExampleArgs(); +if (transport !== 'http') { + throw new Error('this story is HTTP-only; start with --http --port '); +} + +const mcpUrl = new URL(`http://127.0.0.1:${port}/mcp`); +// Built once at startup: an invalid card is a boot error, never a broken +// production document. +const card = buildServerCard({ + name: 'com.example/weather', + description: 'Hourly and 7-day forecasts for any coordinates', + serverInfo, + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] +}); +const catalog = buildAICatalog({ + entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] +}); + +const handler = createMcpHandler(buildServer); +serve( + { + fetch: async (request: Request): Promise => + (await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }))) ?? handler.fetch(request), + port, + hostname: '127.0.0.1' + }, + () => { + console.error(`[server] listening on ${mcpUrl.href} (card at ${getServerCardUrl(mcpUrl)})`); + } +); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 6a35348636..bec38ad4b3 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -14,8 +14,20 @@ "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/client/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/client/src/experimental/serverCard/index.ts" + ], + "@modelcontextprotocol/server/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/server/src/experimental/serverCard.ts" + ], "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/core/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/express/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/express/src/experimental/serverCardRouter.ts" + ], "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], diff --git a/packages/client/package.json b/packages/client/package.json index 44f12382ec..783b348f43 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./experimental/server-card": { + "import": { + "types": "./dist/experimental/serverCard/index.d.mts", + "default": "./dist/experimental/serverCard/index.mjs" + }, + "require": { + "types": "./dist/experimental/serverCard/index.d.cts", + "default": "./dist/experimental/serverCard/index.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "experimental/server-card": [ + "dist/experimental/serverCard/index.d.mts" ] } }, diff --git a/packages/client/src/experimental/serverCard.examples.ts b/packages/client/src/experimental/serverCard.examples.ts new file mode 100644 index 0000000000..8b1ebac4b2 --- /dev/null +++ b/packages/client/src/experimental/serverCard.examples.ts @@ -0,0 +1,26 @@ +/** + * Type-checked examples for the `serverCard/` client helpers. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { discoverServerCards, requiredRemoteInputs, resolveRemote } from './serverCard/index'; + +/** + * Example: probing a domain and resolving the first discovered remote. + */ +async function discoverServerCards_probeDomain(promptUser: (inputs: unknown) => Promise>) { + //#region discoverServerCards_probeDomain + const hits = await discoverServerCards('example.com'); // [] when the domain has no catalog + for (const hit of hits) { + console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); + } + const remote = hits[0]!.card.remotes![0]!; + const inputs = await promptUser(requiredRemoteInputs(remote)); + const { url, headers } = resolveRemote(remote, inputs); + //#endregion discoverServerCards_probeDomain + return { url, headers }; +} diff --git a/packages/client/src/experimental/serverCard/discover.ts b/packages/client/src/experimental/serverCard/discover.ts new file mode 100644 index 0000000000..1de181ed91 --- /dev/null +++ b/packages/client/src/experimental/serverCard/discover.ts @@ -0,0 +1,139 @@ +import type { AICatalogEntry, ServerCard } from '@modelcontextprotocol/core/experimental/server-card'; +import { SERVER_CARD_MEDIA_TYPE } from '@modelcontextprotocol/core/experimental/server-card'; + +import { ServerCardError } from './errors'; +import { DEFAULT_MAX_CATALOG_ENTRIES, fetchAICatalog, fetchServerCard, getAICatalogUrl, parseCardDocument } from './fetch'; +import type { DiscoveryFetchOptions } from './guard'; + +/** + * Options for {@link discoverServerCards}. + */ +export interface DiscoverServerCardsOptions extends DiscoveryFetchOptions { + /** + * Cap on the number of Server Card entries processed from the catalog, + * applied after filtering to card-type entries — other entry types never + * consume the budget. Defaults to 100. + */ + maxEntries?: number; + + /** + * Called for each entry that fails (fetch error, invalid card, bad URL). + * A failing entry is skipped; the walk never aborts on one entry. + */ + onEntryError?: (error: ServerCardError, entry: AICatalogEntry) => void; +} + +/** + * One discovered Server Card with its listing-chain provenance for consent + * UI: `listingDomain` is where the claim came from, `hostingDomain` is where + * traffic would go. The two may differ, and the entry's identifier, + * publisher, and trust manifest are self-asserted and unverified. + * + * Cards are advisory. Never use them for security or access-control + * decisions, never auto-connect, and de-duplicate on `card.remotes[].url`, + * never on names or catalog identifiers (both spoofable). + */ +export interface DiscoveredServerCard { + /** The validated card. Advisory, unverified. */ + card: ServerCard; + /** The catalog entry that listed it. Self-asserted data. */ + entry: AICatalogEntry; + /** Final URL the catalog was fetched from. */ + catalogUrl: string; + /** Host of `catalogUrl`: where the listing claim came from. */ + listingDomain: string; + /** Final URL the card was fetched from. Undefined for inline entries. */ + cardUrl?: string; + /** Host of `cardUrl`: where traffic would go. Undefined for inline entries. */ + hostingDomain?: string; +} + +/** + * Domain-level Server Card discovery: one background probe of + * `https://{domain}/.well-known/ai-catalog.json`. + * + * Fetches the catalog, keeps entries whose `type` is + * `application/mcp-server-card+json`, follows `url` entries with + * {@link fetchServerCard}, and parses inline `data` entries with zero extra + * fetches. A catalog 404 or 410 returns `[]`: absence is a cacheable miss, + * not an error. Any other catalog failure throws. Per-entry failures invoke + * `onEntryError` and skip the entry. + * + * Returns data only: no dedup, no persistence, no auto-connect. When to + * probe, and what to do with hits, is host policy. The returned promise is + * fire-and-forget friendly; never block a user request on it. + * + * @example + * ```ts source="../serverCard.examples.ts#discoverServerCards_probeDomain" + * const hits = await discoverServerCards('example.com'); // [] when the domain has no catalog + * for (const hit of hits) { + * console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); + * } + * const remote = hits[0]!.card.remotes![0]!; + * const inputs = await promptUser(requiredRemoteInputs(remote)); + * const { url, headers } = resolveRemote(remote, inputs); + * ``` + */ +export async function discoverServerCards( + domainOrUrl: string | URL, + options: DiscoverServerCardsOptions = {} +): Promise { + const catalogUrl = getAICatalogUrl(domainOrUrl); + let fetched; + try { + // `maxEntries` here caps *card* entries, so it must apply after the + // type filter below. Fetch the catalog uncapped (the body is already + // bounded by `maxResponseBytes`) instead of forwarding the option, + // which would truncate the raw entry list before filtering and could + // silently drop every card in a mixed catalog. + fetched = await fetchAICatalog(catalogUrl, { ...options, maxEntries: Number.POSITIVE_INFINITY }); + } catch (error) { + if (error instanceof ServerCardError && error.code === 'http-error' && (error.status === 404 || error.status === 410)) { + return []; + } + throw error; + } + if (fetched.notModified) { + // Unreachable without a caller-supplied etag; discovery sends none. + return []; + } + const maxEntries = options.maxEntries ?? DEFAULT_MAX_CATALOG_ENTRIES; + const cardEntries = fetched.catalog.entries.filter(entry => entry.type === SERVER_CARD_MEDIA_TYPE).slice(0, maxEntries); + const listingDomain = new URL(fetched.url).host; + + const discovered: DiscoveredServerCard[] = []; + for (const entry of cardEntries) { + try { + if (entry.url === undefined) { + // Inline entry: same lenient ingestion, zero extra fetches. + const card = parseCardDocument(entry.data, fetched.url); + discovered.push({ card, entry, catalogUrl: fetched.url, listingDomain }); + } else { + const result = await fetchServerCard(entry.url, options); + if (result.notModified) { + continue; + } + discovered.push({ + card: result.card, + entry, + catalogUrl: fetched.url, + listingDomain, + cardUrl: result.url, + hostingDomain: new URL(result.url).host + }); + } + } catch (error) { + // Non-ServerCardError failures here come from the fetch layer + // (DNS, TLS, connection reset): the fetchers and the card parser + // throw ServerCardError for everything they classify themselves. + const entryError = + error instanceof ServerCardError + ? error + : new ServerCardError('network-error', `Catalog entry ${entry.identifier} could not be fetched`, { + cause: error + }); + options.onEntryError?.(entryError, entry); + } + } + return discovered; +} diff --git a/packages/client/src/experimental/serverCard/errors.ts b/packages/client/src/experimental/serverCard/errors.ts new file mode 100644 index 0000000000..3743b28a36 --- /dev/null +++ b/packages/client/src/experimental/serverCard/errors.ts @@ -0,0 +1,78 @@ +import type { ServerCardInput } from '@modelcontextprotocol/core/experimental/server-card'; + +/** + * Error codes for the Server Card discovery helpers. + */ +export type ServerCardErrorCode = + /** A discovery URL string could not be parsed as a URL (`cause` is the TypeError). */ + | 'invalid-url' + /** URL guard rejection (scheme or address class), on any redirect hop. */ + | 'blocked-host' + /** + * The transport failed before an HTTP response arrived (DNS failure, + * TLS error, connection reset). `cause` is the underlying thrown error. + */ + | 'network-error' + /** Non-2xx, non-304 HTTP status. */ + | 'http-error' + /** Content-Type essence is not an acceptable media type. */ + | 'invalid-media-type' + /** Response body exceeded `maxResponseBytes`. */ + | 'response-too-large' + /** Redirect chain exceeded `maxRedirects`. */ + | 'too-many-redirects' + /** + * A Server Card document failed validation. `cause` is the ZodError, or + * the SyntaxError when the response body is not JSON at all. + */ + | 'invalid-server-card' + /** + * An AI Catalog document failed validation. `cause` is the ZodError, or + * the SyntaxError when the response body is not JSON at all. + */ + | 'invalid-ai-catalog' + /** `resolveRemote`: a required input was not supplied. */ + | 'missing-input' + /** `resolveRemote`: a choices violation, or the resolved URL is not http(s). */ + | 'invalid-input'; + +/** + * Error thrown by the Server Card discovery and resolution helpers. The + * `code` identifies the failure class; `url`, `status`, `mediaType`, and + * `missing` carry the structured detail relevant to that class. + */ +export class ServerCardError extends Error { + /** The failure class. */ + readonly code: ServerCardErrorCode; + /** The offending URL (final hop), when the failure has one. */ + readonly url?: string; + /** The HTTP status, for `'http-error'`. */ + readonly status?: number; + /** The offending media type essence, for `'invalid-media-type'`. */ + readonly mediaType?: string; + /** + * Every unmet required input, for `'missing-input'`. Aggregated so a + * host can prompt for all of them in one round trip. + */ + readonly missing?: ReadonlyArray<{ key: string; input: ServerCardInput }>; + + constructor( + code: ServerCardErrorCode, + message: string, + options?: { + url?: string; + status?: number; + mediaType?: string; + missing?: ReadonlyArray<{ key: string; input: ServerCardInput }>; + cause?: unknown; + } + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ServerCardError'; + this.code = code; + this.url = options?.url; + this.status = options?.status; + this.mediaType = options?.mediaType; + this.missing = options?.missing; + } +} diff --git a/packages/client/src/experimental/serverCard/fetch.ts b/packages/client/src/experimental/serverCard/fetch.ts new file mode 100644 index 0000000000..f86b511f83 --- /dev/null +++ b/packages/client/src/experimental/serverCard/fetch.ts @@ -0,0 +1,199 @@ +import type { AICatalog, ServerCard } from '@modelcontextprotocol/core/experimental/server-card'; +import { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_WELL_KNOWN_PATH, + AICatalogSchema, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_SCHEMA_URL, + ServerCardSchema +} from '@modelcontextprotocol/core/experimental/server-card'; +import { mediaTypeEssence } from '@modelcontextprotocol/core-internal'; + +import { ServerCardError } from './errors'; +import type { DiscoveryFetchOptions } from './guard'; +import { DEFAULT_MAX_RESPONSE_BYTES, guardedFetch, readBodyWithCap } from './guard'; + +/** Default cap on the number of catalog entries processed. */ +export const DEFAULT_MAX_CATALOG_ENTRIES = 100; + +/** + * Options for {@link fetchServerCard}. + */ +export interface FetchServerCardOptions extends DiscoveryFetchOptions { + /** + * A previously stored `ETag`, sent as `If-None-Match` so an unchanged + * document costs a 304. + */ + etag?: string; +} + +/** + * Result of {@link fetchServerCard}: either the parsed card with the response + * cache validators, or a `notModified` marker when the server answered 304. + * The caller owns the cache; `etag` and `cacheControl` are returned verbatim + * for it. + */ +export type ServerCardFetchResult = + | { notModified: false; card: ServerCard; url: string; etag?: string; cacheControl?: string } + | { notModified: true; etag?: string; cacheControl?: string }; + +/** + * Fetches and validates a Server Card. + * + * Sends `Accept: application/mcp-server-card+json` with no cookies or + * credentials. A 200 body must have that media type essence or bare + * `application/json` (static hosts often cannot set custom types); anything + * else fails with `'invalid-media-type'`. A missing `$schema` is defaulted to + * the v1 schema URL before validation (lenient ingestion); a wrong `$schema` + * still fails. A 304 returns `{ notModified: true }`. + * + * Card contents are advisory and unverified. Never use them for security or + * access-control decisions. The URL guards are hostname-level; inject a + * DNS-pinning `fetch` to defend against DNS rebinding. + * + * @throws ServerCardError on guard rejection, HTTP error, oversized body, + * redirect overflow, unacceptable media type, or an invalid document. + */ +export async function fetchServerCard(url: string | URL, options: FetchServerCardOptions = {}): Promise { + const { response, url: finalUrl } = await guardedFetch(toUrl(url), SERVER_CARD_MEDIA_TYPE, options, options.etag); + const preamble = handleCommonStatuses(response, finalUrl); + if (preamble !== undefined) { + return preamble; + } + const json = await readJsonDocument(response, finalUrl, SERVER_CARD_MEDIA_TYPE, options, 'invalid-server-card'); + return { + notModified: false, + card: parseCardDocument(json, finalUrl), + url: finalUrl, + ...cacheFields(response) + }; +} + +/** + * Options for {@link fetchAICatalog}. + */ +export interface FetchAICatalogOptions extends DiscoveryFetchOptions { + /** A previously stored `ETag`, sent as `If-None-Match`. */ + etag?: string; + /** + * Cap on the number of entries kept from the catalog. Entries beyond the + * cap are truncated, not an error. Defaults to 100. + */ + maxEntries?: number; +} + +/** + * Result of {@link fetchAICatalog}. Same caller-owned cache contract as + * {@link ServerCardFetchResult}. + */ +export type AICatalogFetchResult = + | { notModified: false; catalog: AICatalog; url: string; etag?: string; cacheControl?: string } + | { notModified: true; etag?: string; cacheControl?: string }; + +/** + * Fetches and validates an AI Catalog document. Same hardened pipeline as + * {@link fetchServerCard} with the `application/ai-catalog+json` media type + * (bare `application/json` tolerated). Nested catalogs are not followed. + * + * @throws ServerCardError with code `'invalid-ai-catalog'` on an invalid + * document, plus the same transport error codes as {@link fetchServerCard}. + */ +export async function fetchAICatalog(url: string | URL, options: FetchAICatalogOptions = {}): Promise { + const { response, url: finalUrl } = await guardedFetch(toUrl(url), AI_CATALOG_MEDIA_TYPE, options, options.etag); + const preamble = handleCommonStatuses(response, finalUrl); + if (preamble !== undefined) { + return preamble; + } + const json = await readJsonDocument(response, finalUrl, AI_CATALOG_MEDIA_TYPE, options, 'invalid-ai-catalog'); + const parsed = AICatalogSchema.safeParse(json); + if (!parsed.success) { + throw new ServerCardError('invalid-ai-catalog', `AI Catalog document from ${finalUrl} failed validation`, { + url: finalUrl, + cause: parsed.error + }); + } + const maxEntries = options.maxEntries ?? DEFAULT_MAX_CATALOG_ENTRIES; + const catalog = + parsed.data.entries.length > maxEntries ? { ...parsed.data, entries: parsed.data.entries.slice(0, maxEntries) } : parsed.data; + return { notModified: false, catalog, url: finalUrl, ...cacheFields(response) }; +} + +/** + * Computes the well-known AI Catalog URL for a domain or URL: + * `'example.com'` and `'https://example.com/anything'` both become + * `https://example.com/.well-known/ai-catalog.json`. Bare domains get + * `https://`. + */ +export function getAICatalogUrl(domainOrUrl: string | URL): URL { + return new URL(AI_CATALOG_WELL_KNOWN_PATH, toUrl(domainOrUrl)); +} + +/** Lenient card ingestion: default a missing $schema, then validate. */ +export function parseCardDocument(json: unknown, url: string): ServerCard { + const withSchema = + json !== null && typeof json === 'object' && !Array.isArray(json) && !('$schema' in json) + ? { $schema: SERVER_CARD_SCHEMA_URL, ...json } + : json; + const parsed = ServerCardSchema.safeParse(withSchema); + if (!parsed.success) { + throw new ServerCardError('invalid-server-card', `Server Card document from ${url} failed validation`, { + url, + cause: parsed.error + }); + } + return parsed.data; +} + +function toUrl(value: string | URL): URL { + if (value instanceof URL) { + return new URL(value.href); + } + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`; + try { + return new URL(withScheme); + } catch (error) { + throw new ServerCardError('invalid-url', `Invalid discovery URL: ${value}`, { url: value, cause: error }); + } +} + +function handleCommonStatuses(response: Response, url: string): { notModified: true; etag?: string; cacheControl?: string } | undefined { + if (response.status === 304) { + return { notModified: true, ...cacheFields(response) }; + } + if (response.status < 200 || response.status >= 300) { + throw new ServerCardError('http-error', `Request to ${url} failed with status ${response.status}`, { + url, + status: response.status + }); + } + return undefined; +} + +async function readJsonDocument( + response: Response, + url: string, + canonicalMediaType: string, + options: DiscoveryFetchOptions, + invalidCode: 'invalid-server-card' | 'invalid-ai-catalog' +): Promise { + const body = await readBodyWithCap(response, url, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES); + const essence = mediaTypeEssence(response.headers.get('content-type')); + if (essence !== canonicalMediaType && essence !== 'application/json') { + throw new ServerCardError('invalid-media-type', `Expected ${canonicalMediaType} from ${url}, got ${essence ?? 'no media type'}`, { + url, + mediaType: essence + }); + } + try { + return JSON.parse(body) as unknown; + } catch (error) { + throw new ServerCardError(invalidCode, `Response from ${url} is not valid JSON`, { url, cause: error }); + } +} + +function cacheFields(response: Response): { etag?: string; cacheControl?: string } { + return { + ...(response.headers.get('etag') === null ? {} : { etag: response.headers.get('etag')! }), + ...(response.headers.get('cache-control') === null ? {} : { cacheControl: response.headers.get('cache-control')! }) + }; +} diff --git a/packages/client/src/experimental/serverCard/guard.ts b/packages/client/src/experimental/serverCard/guard.ts new file mode 100644 index 0000000000..e9ed0e6967 --- /dev/null +++ b/packages/client/src/experimental/serverCard/guard.ts @@ -0,0 +1,293 @@ +import type { FetchLike } from '@modelcontextprotocol/core-internal'; + +import { ServerCardError } from './errors'; + +/** + * Options shared by every Server Card discovery fetcher. + * + * The defaults are the hardened choices from the extension's best-practices + * guidance: HTTPS only, private and link-local address classes rejected, + * response size capped, redirects bounded and re-validated on every hop, and + * requests sent without cookies, credentials, or ambient auth. + * + * Honest limit: the URL guards are hostname-level string and address checks. + * They cannot see where DNS actually resolves, so they do not defend against + * DNS rebinding. For that depth, inject a DNS-pinning `fetch`. + */ +export interface DiscoveryFetchOptions { + /** + * Fetch implementation used for every request. Compose `withOAuth`, + * middleware, or a DNS-pinning fetch here; the URL guards still apply + * around whatever fetch is supplied. + */ + fetch?: FetchLike; + + /** Abort signal threaded through every request. */ + signal?: AbortSignal; + + /** + * Maximum response body size in bytes. Counted while streaming; the read + * aborts over the cap. Defaults to 1 MiB (1_048_576). + */ + maxResponseBytes?: number; + + /** + * Maximum redirect hops. Every hop is re-validated by the URL guards. + * Defaults to 3. + */ + maxRedirects?: number; + + /** + * Allow plain `http:` URLs beyond the always-exempt local-dev hosts + * (`localhost`, `127.0.0.1`, `[::1]`). Defaults to `false`: the spec + * requires HTTPS in production. + */ + allowHttp?: boolean; + + /** + * Allow IP-literal hosts in loopback, link-local (including + * `169.254.169.254`), private (RFC 1918), and unique-local ranges, plus + * single-label hostnames other than `localhost`. Not needed for the + * always-exempt local-dev hosts (`localhost`, `127.0.0.1`, `[::1]`). + * Defaults to `false`. + */ + allowPrivateHosts?: boolean; +} + +export const DEFAULT_MAX_RESPONSE_BYTES = 1_048_576; +export const DEFAULT_MAX_REDIRECTS = 3; + +const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +function parseIpv4(host: string): [number, number, number, number] | undefined { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (!match) { + return undefined; + } + const octets = match.slice(1).map(Number) as [number, number, number, number]; + return octets.every(octet => octet <= 255) ? octets : undefined; +} + +function isPrivateIpv4(octets: [number, number, number, number]): boolean { + const [a, b] = octets; + return ( + a === 0 || // 0.0.0.0/8 + a === 10 || // 10.0.0.0/8 + a === 127 || // 127.0.0.0/8 loopback + (a === 169 && b === 254) || // 169.254.0.0/16 link-local, incl. 169.254.169.254 + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 + (a === 192 && b === 168) // 192.168.0.0/16 + ); +} + +/** Expands an IPv6 literal (brackets already stripped) to its 8 16-bit words. */ +function ipv6Words(ipv6: string): number[] | undefined { + let groupsText = ipv6; + // Rewrite a trailing dotted-quad tail (`…:a.b.c.d`) as two hex words. + if (groupsText.includes('.')) { + const tailStart = groupsText.lastIndexOf(':'); + const tail = parseIpv4(groupsText.slice(tailStart + 1)); + if (tail === undefined) { + return undefined; + } + const [a, b, c, d] = tail; + groupsText = `${groupsText.slice(0, tailStart + 1)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; + } + const halves = groupsText.split('::'); + if (halves.length > 2) { + return undefined; + } + const wordsOf = (half: string): number[] | undefined => { + if (half === '') { + return []; + } + const words: number[] = []; + for (const group of half.split(':')) { + if (!/^[0-9a-f]{1,4}$/.test(group)) { + return undefined; + } + words.push(Number.parseInt(group, 16)); + } + return words; + }; + const head = wordsOf(halves[0]!); + const tail = halves.length === 2 ? wordsOf(halves[1]!) : []; + if (head === undefined || tail === undefined) { + return undefined; + } + if (halves.length === 1) { + return head.length === 8 ? head : undefined; + } + if (head.length + tail.length > 7) { + return undefined; + } + return [...head, ...Array.from({ length: 8 - head.length - tail.length }, () => 0), ...tail]; +} + +/** The IPv4 address embedded in two adjacent 16-bit words. */ +function embeddedIpv4Of(words: number[], index: number): [number, number, number, number] { + const high = words[index]!; + const low = words[index + 1]!; + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; +} + +const NAT64_PREFIX = [0x64, 0xff_9b, 0, 0, 0, 0]; // 64:ff9b::/96 + +function isPrivateIpv6(host: string): boolean { + const words = ipv6Words(host.slice(1, -1).toLowerCase()); + if (words === undefined) { + return true; // unparseable IPv6 literal: fail closed + } + if ((words[0]! & 0xff_c0) === 0xfe_80) { + return true; // fe80::/10 link-local + } + if ((words[0]! & 0xfe_00) === 0xfc_00) { + return true; // fc00::/7 unique-local + } + if (words[0] === 0x20_02) { + return isPrivateIpv4(embeddedIpv4Of(words, 1)); // 2002::/16 6to4 + } + if (NAT64_PREFIX.every((word, index) => words[index] === word)) { + return isPrivateIpv4(embeddedIpv4Of(words, 6)); // NAT64 well-known prefix + } + if (words.slice(0, 5).every(word => word === 0) && (words[5] === 0 || words[5] === 0xff_ff)) { + // ::ffff:a.b.c.d IPv4-mapped and ::a.b.c.d IPv4-compatible forms. + // Also covers `::` and `::1`: 0.0.0.0/8 is a private IPv4 range. + return isPrivateIpv4(embeddedIpv4Of(words, 6)); + } + return false; +} + +/** + * Applies the discovery URL guards to one URL (one redirect hop). Throws + * `ServerCardError` with code `'blocked-host'` on rejection. + */ +export function assertAllowedUrl(url: URL, options: DiscoveryFetchOptions): void { + const host = url.hostname; + if (url.protocol !== 'https:') { + if (url.protocol !== 'http:') { + throw new ServerCardError('blocked-host', `Discovery URLs must be http(s), got ${url.protocol}`, { url: url.href }); + } + if (!options.allowHttp && !LOCAL_DEV_HOSTS.has(host)) { + throw new ServerCardError('blocked-host', 'Discovery over plain HTTP is limited to localhost; use HTTPS', { + url: url.href + }); + } + } + if (LOCAL_DEV_HOSTS.has(host)) { + // Loopback by IP literal is no more dangerous than loopback by name: + // the local-dev hosts are exempt from the address-class checks too. + return; + } + if (options.allowPrivateHosts) { + return; + } + if (host.startsWith('[')) { + if (isPrivateIpv6(host)) { + throw new ServerCardError('blocked-host', `IPv6 host ${host} is in a private or local address range`, { url: url.href }); + } + return; + } + const ipv4 = parseIpv4(host); + if (ipv4 !== undefined) { + if (isPrivateIpv4(ipv4)) { + throw new ServerCardError('blocked-host', `IPv4 host ${host} is in a private or local address range`, { url: url.href }); + } + return; + } + if (!host.includes('.')) { + throw new ServerCardError('blocked-host', `Single-label hostname ${host} is not allowed for discovery`, { url: url.href }); + } +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * The guarded request pipeline shared by the fetchers: validates the URL, + * sends a credential-free GET with the given `Accept` (and optional + * `If-None-Match`), and follows redirects manually so every hop passes the + * URL guards again. Returns the final response and the final URL. + */ +export async function guardedFetch( + url: URL, + accept: string, + options: DiscoveryFetchOptions, + etag?: string +): Promise<{ response: Response; url: string }> { + const fetchFn = options.fetch ?? fetch; + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + let current = url; + for (let hop = 0; ; hop++) { + assertAllowedUrl(current, options); + const headers: Record = { Accept: accept }; + if (etag !== undefined) { + headers['If-None-Match'] = etag; + } + // No cookies, no credentials, no ambient auth: discovery requests + // carry nothing beyond Accept and the optional validator. + const response = await fetchFn(current.href, { + method: 'GET', + redirect: 'manual', + credentials: 'omit', + signal: options.signal, + headers + }); + if (!REDIRECT_STATUSES.has(response.status)) { + return { response, url: current.href }; + } + const location = response.headers.get('location'); + if (location === null) { + throw new ServerCardError('http-error', `Redirect status ${response.status} without a Location header`, { + url: current.href, + status: response.status + }); + } + if (hop >= maxRedirects) { + throw new ServerCardError('too-many-redirects', `More than ${maxRedirects} redirects while fetching ${url.href}`, { + url: current.href + }); + } + current = new URL(location, current); + } +} + +/** + * Reads a response body as text with a streamed byte cap. Throws + * `ServerCardError` with code `'response-too-large'` over the cap. + * + * A response without a readable `body` stream — only reachable through a + * caller-supplied `FetchLike`; native fetch responses expose one for every + * non-empty body — falls back to `text()`, so on that branch the cap is + * enforced only after the full body has been buffered. + */ +export async function readBodyWithCap(response: Response, url: string, maxBytes: number): Promise { + if (response.body === null) { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + throw new ServerCardError('response-too-large', `Response from ${url} exceeds ${maxBytes} bytes`, { url }); + } + return text; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new ServerCardError('response-too-large', `Response from ${url} exceeds ${maxBytes} bytes`, { url }); + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(merged); +} diff --git a/packages/client/src/experimental/serverCard/index.ts b/packages/client/src/experimental/serverCard/index.ts new file mode 100644 index 0000000000..49382dc916 --- /dev/null +++ b/packages/client/src/experimental/serverCard/index.ts @@ -0,0 +1,39 @@ +// @modelcontextprotocol/client/experimental/server-card +// +// Client-side helpers for the experimental MCP Server Card extension +// (SEP-2127): hardened fetchers, domain-level AI Catalog discovery, remote +// input resolution, and post-connect reconciliation. Stateless by design; +// caching, consent, dedup, and persistence are host policy. +// +// Experimental: tracks the `experimental-ext-server-card` spec repository and +// may change or be removed in any release. + +export type { DiscoveredServerCard, DiscoverServerCardsOptions } from './discover'; +export { discoverServerCards } from './discover'; +export type { ServerCardErrorCode } from './errors'; +export { ServerCardError } from './errors'; +export type { AICatalogFetchResult, FetchAICatalogOptions, FetchServerCardOptions, ServerCardFetchResult } from './fetch'; +export { fetchAICatalog, fetchServerCard, getAICatalogUrl } from './fetch'; +export type { DiscoveryFetchOptions } from './guard'; +export type { ServerCardMismatch } from './reconcile'; +export { reconcileServerCard } from './reconcile'; +export type { RemoteInputRequirement, ResolvedRemote } from './resolve'; +export { requiredRemoteInputs, resolveRemote } from './resolve'; + +// Re-exported so client authors import everything from one module. Types and +// constants only; the Zod schemas stay on the core subpath. +export type { + AICatalog, + AICatalogEntry, + ServerCard, + ServerCardInput, + ServerCardKeyValueInput, + ServerCardRemote +} from '@modelcontextprotocol/core/experimental/server-card'; +export { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_WELL_KNOWN_PATH, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_PATH_SUFFIX, + SERVER_CARD_SCHEMA_URL +} from '@modelcontextprotocol/core/experimental/server-card'; diff --git a/packages/client/src/experimental/serverCard/reconcile.ts b/packages/client/src/experimental/serverCard/reconcile.ts new file mode 100644 index 0000000000..a769a2df35 --- /dev/null +++ b/packages/client/src/experimental/serverCard/reconcile.ts @@ -0,0 +1,69 @@ +import type { ServerCard, ServerCardRemote } from '@modelcontextprotocol/core/experimental/server-card'; +import type { Implementation } from '@modelcontextprotocol/core-internal'; + +/** + * One disagreement between a Server Card claim and the live connection. + * Runtime always wins; the card was advisory. + */ +export interface ServerCardMismatch { + /** The disagreeing field. */ + field: 'name' | 'version' | 'title' | 'websiteUrl' | 'protocolVersion'; + /** What the card claimed. */ + cardValue: string | undefined; + /** What the live connection reported. */ + runtimeValue: string | undefined; +} + +/** + * Post-connect advisory check: compares a card's claims against the live + * connection's `serverInfo`, per the spec's requirement that clients verify + * card claims and prefer runtime values on disagreement. + * + * Returns a diff and never a merged card, so there is nothing to accidentally + * treat as authoritative. Never throws; returns `[]` on agreement. Optional + * card fields are compared only when the card states them. A + * `'protocolVersion'` mismatch is reported when + * `options.negotiatedProtocolVersion` is given and the remote declares + * `supportedProtocolVersions` that do not include it. + * + * `name` is compared verbatim: a card's `name` is the namespaced form + * (`'com.example/weather'`), so a server whose `serverInfo.name` is the bare + * unqualified name reports an advisory `'name'` mismatch. Per the extension + * spec the two are expected to match exactly; treat the mismatch as the + * card and the server disagreeing, not as a false positive. + * + * Deliberately not wired into `Client.connect`: cards must not gate + * anything. + */ +export function reconcileServerCard( + card: ServerCard, + serverInfo: Implementation, + options?: { remote?: ServerCardRemote; negotiatedProtocolVersion?: string } +): ServerCardMismatch[] { + const mismatches: ServerCardMismatch[] = []; + if (card.name !== serverInfo.name) { + mismatches.push({ field: 'name', cardValue: card.name, runtimeValue: serverInfo.name }); + } + if (card.version !== serverInfo.version) { + mismatches.push({ field: 'version', cardValue: card.version, runtimeValue: serverInfo.version }); + } + if (card.title !== undefined && card.title !== serverInfo.title) { + mismatches.push({ field: 'title', cardValue: card.title, runtimeValue: serverInfo.title }); + } + if (card.websiteUrl !== undefined && card.websiteUrl !== serverInfo.websiteUrl) { + mismatches.push({ field: 'websiteUrl', cardValue: card.websiteUrl, runtimeValue: serverInfo.websiteUrl }); + } + const supported = options?.remote?.supportedProtocolVersions; + if ( + options?.negotiatedProtocolVersion !== undefined && + supported !== undefined && + !supported.includes(options.negotiatedProtocolVersion) + ) { + mismatches.push({ + field: 'protocolVersion', + cardValue: supported.join(', '), + runtimeValue: options.negotiatedProtocolVersion + }); + } + return mismatches; +} diff --git a/packages/client/src/experimental/serverCard/resolve.ts b/packages/client/src/experimental/serverCard/resolve.ts new file mode 100644 index 0000000000..789113db76 --- /dev/null +++ b/packages/client/src/experimental/serverCard/resolve.ts @@ -0,0 +1,186 @@ +import type { ServerCardInput, ServerCardRemote } from '@modelcontextprotocol/core/experimental/server-card'; + +import { ServerCardError } from './errors'; + +// The spec's template grammar: `{var}` where var is [a-zA-Z_][a-zA-Z0-9_]*. +// This is deliberately not RFC 6570; the SDK's UriTemplate is a different +// grammar and is not reused here. +const TEMPLATE_VAR = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g; + +/** + * One input a consent or configuration UI may prompt for, walked from a card + * remote. Pure data: `isSecret`, `choices`, `default`, and `placeholder` are + * surfaced on `input`; presentation is host policy. + */ +export interface RemoteInputRequirement { + /** The name to use as the key in {@link resolveRemote}'s `inputs` map. */ + key: string; + /** + * Provenance of the input within the remote, e.g. `'variables.tenant'` + * or `'headers.Authorization.variables.token'`. + */ + path: string; + /** The input declaration from the card. */ + input: ServerCardInput; + /** Whether the input is declared `isRequired`. */ + required: boolean; +} + +/** + * Walks everything a UI may prompt for on a card remote: the remote's + * `variables`, header inputs lacking a fixed `value`, and the nested + * `variables` of headers that have one. Keys share one flat namespace + * (variable names plus header names), matching what {@link resolveRemote} + * reads from `inputs`. + * + * Despite the name, the walk returns *every* promptable input, optional ones + * included — check each requirement's `required` flag before treating it as + * mandatory. + */ +export function requiredRemoteInputs(remote: ServerCardRemote): RemoteInputRequirement[] { + const requirements: RemoteInputRequirement[] = []; + for (const [name, input] of Object.entries(remote.variables ?? {})) { + requirements.push({ key: name, path: `variables.${name}`, input, required: input.isRequired === true }); + } + for (const header of remote.headers ?? []) { + if (header.value === undefined) { + // Without a `value` template there is nothing for nested + // variables to fill: resolveRemote reads only `inputs[name]` and + // the header's own `default`, so they are not prompted for. + requirements.push({ key: header.name, path: `headers.${header.name}`, input: header, required: header.isRequired === true }); + continue; + } + for (const [name, input] of Object.entries(header.variables ?? {})) { + requirements.push({ + key: name, + path: `headers.${header.name}.variables.${name}`, + input, + required: input.isRequired === true + }); + } + } + return requirements; +} + +/** + * A card remote resolved to a connectable endpoint. Feed it to + * `new StreamableHTTPClientTransport(url, { requestInit: { headers } })` or + * `SSEClientTransport`; pass a supported protocol version to the transport's + * `protocolVersion` option if you negotiate one up front. + */ +export interface ResolvedRemote { + /** Transport type declared by the card. */ + type: 'streamable-http' | 'sse'; + /** Fully substituted endpoint URL. */ + url: URL; + /** Fully substituted header values, ready for `requestInit.headers`. */ + headers: Record; + /** Protocol versions the card claims the endpoint supports. */ + supportedProtocolVersions?: string[]; +} + +/** + * Resolves a card remote's `{var}` templates against supplied inputs. + * + * Each `{var}` in the URL resolves from `inputs[key]`, then the variable's + * `value`, then its `default`. A header with a fixed `value` resolves its + * nested variables the same way; a header without one takes + * `inputs[headerName]`, then the header's `default`. A header that resolves + * to no value and is not required is omitted. + * + * Every unmet required input is aggregated into one `'missing-input'` error + * (one prompt round trip, not N). A supplied value outside an input's + * `choices`, an unresolved `{var}` left in the final URL, or a resolved URL + * that is not http(s) throws `'invalid-input'`. + * + * No transport is constructed: choosing a remote and supplying inputs is + * host policy, and card data stays advisory. + */ +export function resolveRemote(remote: ServerCardRemote, inputs: Record = {}): ResolvedRemote { + const missing = new Map(); + + const checkChoices = (key: string, declaration: ServerCardInput | undefined): void => { + const supplied = inputs[key]; + if (supplied !== undefined && declaration?.choices !== undefined && !declaration.choices.includes(supplied)) { + throw new ServerCardError('invalid-input', `Input ${key} must be one of: ${declaration.choices.join(', ')}`); + } + }; + + const substitute = ( + template: string, + variables: Record | undefined + ): { value: string; unresolved: string[] } => { + const unresolved: string[] = []; + const value = template.replaceAll(TEMPLATE_VAR, (whole, key: string) => { + const declaration = variables?.[key]; + checkChoices(key, declaration); + const resolved = inputs[key] ?? declaration?.value ?? declaration?.default; + if (resolved === undefined) { + unresolved.push(key); + if (declaration?.isRequired === true) { + missing.set(key, declaration); + } + return whole; + } + return resolved; + }); + return { value, unresolved }; + }; + + const substitutedUrl = substitute(remote.url, remote.variables); + + const headers: Record = {}; + for (const header of remote.headers ?? []) { + if (header.value !== undefined) { + const { value, unresolved } = substitute(header.value, header.variables); + if (unresolved.length === 0) { + headers[header.name] = value; + } else if (header.isRequired === true) { + for (const key of unresolved) { + if (!missing.has(key)) { + missing.set(key, header.variables?.[key] ?? {}); + } + } + } + // Otherwise the header resolves to no value and is omitted; + // any required nested variable was already recorded. + continue; + } + checkChoices(header.name, header); + const value = inputs[header.name] ?? header.default; + if (value !== undefined) { + headers[header.name] = value; + } else if (header.isRequired === true) { + missing.set(header.name, header); + } + } + + if (missing.size > 0) { + const entries = [...missing.entries()].map(([key, input]) => ({ key, input })); + throw new ServerCardError( + 'missing-input', + `Missing required input${entries.length === 1 ? '' : 's'}: ${entries.map(entry => entry.key).join(', ')}`, + { missing: entries } + ); + } + + if (substitutedUrl.unresolved.length > 0) { + throw new ServerCardError('invalid-input', `Unresolved template variables in remote URL: ${substitutedUrl.unresolved.join(', ')}`); + } + let url: URL; + try { + url = new URL(substitutedUrl.value); + } catch (error) { + throw new ServerCardError('invalid-input', `Resolved remote URL is not a valid URL: ${substitutedUrl.value}`, { cause: error }); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new ServerCardError('invalid-input', `Resolved remote URL must be http(s), got ${url.protocol}`, { url: url.href }); + } + + return { + type: remote.type, + url, + headers, + ...(remote.supportedProtocolVersions === undefined ? {} : { supportedProtocolVersions: remote.supportedProtocolVersions }) + }; +} diff --git a/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts index 567543deed..287c472740 100644 --- a/packages/client/test/client/barrelClean.test.ts +++ b/packages/client/test/client/barrelClean.test.ts @@ -23,7 +23,9 @@ function chunkImportsOf(entryPath: string): string[] { if (visited.has(file)) continue; visited.add(file); const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + // Follow ./ and ../ relative chunk imports: subpath entries in nested + // dist directories import shared chunks from parent directories. + for (const m of src.matchAll(/from\s+["'](\.\.?\/.+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } @@ -96,3 +98,32 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { } }); }); + +describe('@modelcontextprotocol/client experimental server-card subpath', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test('dist/experimental/serverCard/index.mjs exists, exports the helpers, and stays runtime-neutral', () => { + const entry = join(distDir, 'experimental/serverCard/index.mjs'); + const content = readFileSync(entry, 'utf8'); + expect(content).toMatch(/\bdiscoverServerCards\b/); + expect(content).toMatch(/\bfetchServerCard\b/); + expect(content).toMatch(/\bresolveRemote\b/); + expect(content).not.toMatch(NODE_ONLY); + for (const chunk of chunkImportsOf(entry)) { + expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) + ); + } + }); + + test('root declarations do not advertise the experimental server-card exports', () => { + for (const declaration of ['index.d.mts', 'index.d.cts']) { + const rootExportBlock = rootExportBlockOf(readFileSync(join(distDir, declaration), 'utf8')); + for (const symbol of ['discoverServerCards', 'fetchServerCard', 'resolveRemote', 'reconcileServerCard', 'ServerCardError']) { + expect(rootExportBlock).not.toMatch(new RegExp(`\\b(?:type\\s+)?${symbol}\\b`)); + } + } + }); +}); diff --git a/packages/client/test/experimental/discover.test.ts b/packages/client/test/experimental/discover.test.ts new file mode 100644 index 0000000000..60f1a3469e --- /dev/null +++ b/packages/client/test/experimental/discover.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import type { AICatalogEntry, ServerCardError } from '../../src/experimental/serverCard/index'; +import { discoverServerCards, SERVER_CARD_MEDIA_TYPE, SERVER_CARD_SCHEMA_URL } from '../../src/experimental/serverCard/index'; +import { jsonResponse, mockFetch } from './mockFetch'; + +const catalogUrl = 'https://example.com/.well-known/ai-catalog.json'; +const cardUrl = 'https://mcp-host.example.net/mcp/server-card'; + +const card = { + $schema: SERVER_CARD_SCHEMA_URL, + name: 'com.example/weather', + version: '1.0.0', + description: 'Forecasts', + remotes: [{ type: 'streamable-http', url: 'https://mcp-host.example.net/mcp' }] +}; + +const urlEntry = { identifier: 'urn:air:example.com:mcp:weather', type: SERVER_CARD_MEDIA_TYPE, url: cardUrl }; + +function catalogOf(entries: unknown[]): unknown { + return { specVersion: '1.0', entries }; +} + +describe('discoverServerCards', () => { + it('returns [] for a missing catalog (404) and a gone catalog (410)', async () => { + for (const status of [404, 410]) { + const fetch = mockFetch({ [catalogUrl]: () => new Response('gone', { status }) }); + await expect(discoverServerCards('example.com', { fetch })).resolves.toEqual([]); + } + }); + + it('throws for other catalog failures', async () => { + const fetch = mockFetch({ [catalogUrl]: () => new Response('err', { status: 500 }) }); + await expect(discoverServerCards('example.com', { fetch })).rejects.toMatchObject({ code: 'http-error', status: 500 }); + }); + + it('follows url entries and assembles listing-chain provenance', async () => { + const fetch = mockFetch({ + [catalogUrl]: () => jsonResponse(catalogOf([urlEntry])), + [cardUrl]: () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) + }); + const hits = await discoverServerCards('example.com', { fetch }); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ + card, + entry: urlEntry, + catalogUrl, + listingDomain: 'example.com', + cardUrl, + hostingDomain: 'mcp-host.example.net' + }); + }); + + it('parses inline data entries with zero extra fetches and no hosting domain', async () => { + const inlineEntry = { identifier: urlEntry.identifier, type: SERVER_CARD_MEDIA_TYPE, data: card }; + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf([inlineEntry])) }); + const hits = await discoverServerCards('example.com', { fetch }); + expect(hits).toHaveLength(1); + expect(hits[0]!.card).toEqual(card); + expect(hits[0]!.cardUrl).toBeUndefined(); + expect(hits[0]!.hostingDomain).toBeUndefined(); + expect(fetch.calls).toHaveLength(1); + }); + + it('applies lenient $schema ingestion to inline entries', async () => { + const { $schema: _schema, ...inlineCard } = card; + const inlineEntry = { identifier: urlEntry.identifier, type: SERVER_CARD_MEDIA_TYPE, data: inlineCard }; + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf([inlineEntry])) }); + const hits = await discoverServerCards('example.com', { fetch }); + expect(hits[0]!.card.$schema).toBe(SERVER_CARD_SCHEMA_URL); + }); + + it('skips entries of other types silently', async () => { + const agentEntry = { + identifier: 'urn:air:example.com:agent:helper', + type: 'application/agent-card+json', + url: 'https://x.example/a' + }; + const fetch = mockFetch({ + [catalogUrl]: () => jsonResponse(catalogOf([agentEntry, urlEntry])), + [cardUrl]: () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) + }); + const hits = await discoverServerCards('example.com', { fetch }); + expect(hits).toHaveLength(1); + expect(fetch.calls.map(call => call.url)).toEqual([catalogUrl, cardUrl]); + }); + + it('reports per-entry failures via onEntryError and continues the walk', async () => { + const badEntry = { + identifier: 'urn:air:example.com:mcp:broken', + type: SERVER_CARD_MEDIA_TYPE, + url: 'https://broken.example.net/card' + }; + const invalidInline = { identifier: 'urn:air:example.com:mcp:bad-inline', type: SERVER_CARD_MEDIA_TYPE, data: { nope: true } }; + const fetch = mockFetch({ + [catalogUrl]: () => jsonResponse(catalogOf([badEntry, invalidInline, urlEntry])), + 'https://broken.example.net/card': () => new Response('down', { status: 500 }), + [cardUrl]: () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) + }); + const failures: Array<{ code: string; identifier: string }> = []; + const hits = await discoverServerCards('example.com', { + fetch, + onEntryError: (error: ServerCardError, entry: AICatalogEntry) => + failures.push({ code: error.code, identifier: entry.identifier }) + }); + expect(hits).toHaveLength(1); + expect(failures).toEqual([ + { code: 'http-error', identifier: 'urn:air:example.com:mcp:broken' }, + { code: 'invalid-server-card', identifier: 'urn:air:example.com:mcp:bad-inline' } + ]); + }); + + it('caps processed card entries at maxEntries', async () => { + const entries = Array.from({ length: 4 }, (_, index) => ({ + identifier: `urn:air:example.com:mcp:s${index}`, + type: SERVER_CARD_MEDIA_TYPE, + data: card + })); + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf(entries)) }); + const hits = await discoverServerCards('example.com', { fetch, maxEntries: 2 }); + expect(hits).toHaveLength(2); + }); + + it('applies maxEntries after the type filter, so other entry types never consume the budget', async () => { + const agentEntries = Array.from({ length: 3 }, (_, index) => ({ + identifier: `urn:air:example.com:agent:a${index}`, + type: 'application/agent-card+json', + url: `https://x.example/a${index}` + })); + const cardEntries = Array.from({ length: 2 }, (_, index) => ({ + identifier: `urn:air:example.com:mcp:s${index}`, + type: SERVER_CARD_MEDIA_TYPE, + data: card + })); + // The 3 leading non-card entries would exhaust maxEntries: 3 if the + // cap were applied to the raw entry list before filtering. + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf([...agentEntries, ...cardEntries])) }); + const hits = await discoverServerCards('example.com', { fetch, maxEntries: 3 }); + expect(hits).toHaveLength(2); + }); + + it('processes card entries beyond the default raw-entry count in a large mixed catalog', async () => { + const agentEntries = Array.from({ length: 110 }, (_, index) => ({ + identifier: `urn:air:example.com:agent:a${index}`, + type: 'application/agent-card+json', + url: `https://x.example/a${index}` + })); + const cardEntry = { identifier: urlEntry.identifier, type: SERVER_CARD_MEDIA_TYPE, data: card }; + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf([...agentEntries, cardEntry])) }); + const hits = await discoverServerCards('example.com', { fetch }); + expect(hits).toHaveLength(1); + }); + + it('reports a transport failure on one entry as network-error with the cause preserved', async () => { + const cause = new TypeError('fetch failed'); + const fetch = mockFetch({ + [catalogUrl]: () => jsonResponse(catalogOf([urlEntry])), + [cardUrl]: () => { + throw cause; + } + }); + const failures: ServerCardError[] = []; + const hits = await discoverServerCards('example.com', { fetch, onEntryError: error => failures.push(error) }); + expect(hits).toEqual([]); + expect(failures).toHaveLength(1); + expect(failures[0]!.code).toBe('network-error'); + expect(failures[0]!.cause).toBe(cause); + }); + + it('accepts a full URL and probes its origin', async () => { + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalogOf([])) }); + await expect(discoverServerCards('https://example.com/deep/page', { fetch })).resolves.toEqual([]); + expect(fetch.calls[0]!.url).toBe(catalogUrl); + }); +}); diff --git a/packages/client/test/experimental/fetch.test.ts b/packages/client/test/experimental/fetch.test.ts new file mode 100644 index 0000000000..3a53339072 --- /dev/null +++ b/packages/client/test/experimental/fetch.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; + +import { + fetchAICatalog, + fetchServerCard, + getAICatalogUrl, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_SCHEMA_URL, + ServerCardError +} from '../../src/experimental/serverCard/index'; +import { jsonResponse, mockFetch } from './mockFetch'; + +const cardUrl = 'https://weather.example.com/mcp/server-card'; +const catalogUrl = 'https://example.com/.well-known/ai-catalog.json'; + +const card = { + $schema: SERVER_CARD_SCHEMA_URL, + name: 'com.example/weather', + version: '1.0.0', + description: 'Forecasts' +}; + +const catalog = { + specVersion: '1.0', + entries: [{ identifier: 'urn:air:example.com:mcp:weather', type: SERVER_CARD_MEDIA_TYPE, url: cardUrl }] +}; + +async function expectCode(promise: Promise, code: string): Promise { + const error = await promise.then( + () => undefined, + (thrown: unknown) => thrown + ); + expect(error).toBeInstanceOf(ServerCardError); + expect((error as ServerCardError).code).toBe(code); + return error as ServerCardError; +} + +describe('fetchServerCard', () => { + it('sends Accept with the canonical media type and no credentials', async () => { + const fetch = mockFetch({ [cardUrl]: () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) }); + const result = await fetchServerCard(cardUrl, { fetch }); + expect(result).toMatchObject({ notModified: false, card, url: cardUrl }); + expect(fetch.calls).toHaveLength(1); + expect(new Headers(fetch.calls[0]!.init!.headers).get('Accept')).toBe(SERVER_CARD_MEDIA_TYPE); + expect(fetch.calls[0]!.init!.credentials).toBe('omit'); + expect(fetch.calls[0]!.init!.redirect).toBe('manual'); + }); + + it('accepts bare application/json and media type parameters', async () => { + for (const contentType of ['application/json', 'application/json; charset=utf-8', `${SERVER_CARD_MEDIA_TYPE}; charset=utf-8`]) { + const fetch = mockFetch({ [cardUrl]: () => jsonResponse(card, { contentType }) }); + const result = await fetchServerCard(cardUrl, { fetch }); + expect(result.notModified).toBe(false); + } + }); + + it('rejects other media types with the offending essence', async () => { + const fetch = mockFetch({ [cardUrl]: () => jsonResponse(card, { contentType: 'text/html' }) }); + const error = await expectCode(fetchServerCard(cardUrl, { fetch }), 'invalid-media-type'); + expect(error.mediaType).toBe('text/html'); + }); + + it('defaults a missing $schema but rejects a wrong one', async () => { + const { $schema: _schema, ...cardWithoutSchema } = card; + const lenient = mockFetch({ [cardUrl]: () => jsonResponse(cardWithoutSchema, { contentType: SERVER_CARD_MEDIA_TYPE }) }); + const result = await fetchServerCard(cardUrl, { fetch: lenient }); + expect(result.notModified).toBe(false); + expect(!result.notModified && result.card.$schema).toBe(SERVER_CARD_SCHEMA_URL); + + const wrong = mockFetch({ + [cardUrl]: () => jsonResponse({ ...card, $schema: 'https://example.com/other.json' }, { contentType: SERVER_CARD_MEDIA_TYPE }) + }); + await expectCode(fetchServerCard(cardUrl, { fetch: wrong }), 'invalid-server-card'); + }); + + it('rejects an invalid card with the ZodError as cause', async () => { + const fetch = mockFetch({ [cardUrl]: () => jsonResponse({ ...card, name: 'no-slash' }, { contentType: SERVER_CARD_MEDIA_TYPE }) }); + const error = await expectCode(fetchServerCard(cardUrl, { fetch }), 'invalid-server-card'); + expect(error.cause).toBeDefined(); + }); + + it('throws invalid-url, before any request, for a string that does not parse as a URL', async () => { + const fetch = mockFetch({}); + const error = await expectCode(fetchServerCard('https://exa mple.com/card', { fetch }), 'invalid-url'); + expect(error.cause).toBeDefined(); + expect(fetch.calls).toHaveLength(0); + }); + + it('throws http-error with status for non-2xx responses', async () => { + const fetch = mockFetch({ [cardUrl]: () => new Response('down', { status: 503 }) }); + const error = await expectCode(fetchServerCard(cardUrl, { fetch }), 'http-error'); + expect(error.status).toBe(503); + expect(error.url).toBe(cardUrl); + }); + + it('enforces the response size cap on streamed bodies', async () => { + const fetch = mockFetch({ + [cardUrl]: () => new Response('x'.repeat(2048), { headers: { 'Content-Type': SERVER_CARD_MEDIA_TYPE } }) + }); + await expectCode(fetchServerCard(cardUrl, { fetch, maxResponseBytes: 1024 }), 'response-too-large'); + const underCap = mockFetch({ [cardUrl]: () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) }); + await expect(fetchServerCard(cardUrl, { fetch: underCap, maxResponseBytes: 1024 })).resolves.toMatchObject({ notModified: false }); + }); + + it('follows redirects up to the cap and re-guards every hop', async () => { + const hop = (location: string) => new Response(null, { status: 302, headers: { Location: location } }); + const fetch = mockFetch({ + 'https://a.example.com/card': () => hop('https://b.example.com/card'), + 'https://b.example.com/card': () => jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE }) + }); + const result = await fetchServerCard('https://a.example.com/card', { fetch }); + expect(!result.notModified && result.url).toBe('https://b.example.com/card'); + + const looping = mockFetch({ + 'https://a.example.com/card': () => hop('https://a.example.com/card') + }); + await expectCode(fetchServerCard('https://a.example.com/card', { fetch: looping, maxRedirects: 2 }), 'too-many-redirects'); + + const intoMetadata = mockFetch({ + 'https://a.example.com/card': () => hop('http://169.254.169.254/latest/meta-data') + }); + await expectCode(fetchServerCard('https://a.example.com/card', { fetch: intoMetadata }), 'blocked-host'); + }); + + it('returns notModified with cache validators on 304', async () => { + const fetch = mockFetch({ + [cardUrl]: request => { + expect(new Headers(request.init!.headers).get('If-None-Match')).toBe('"abc"'); + return new Response(null, { status: 304, headers: { ETag: '"abc"', 'Cache-Control': 'public, max-age=3600' } }); + } + }); + const result = await fetchServerCard(cardUrl, { fetch, etag: '"abc"' }); + expect(result).toEqual({ notModified: true, etag: '"abc"', cacheControl: 'public, max-age=3600' }); + }); + + it('passes response etag and cacheControl through so the caller owns the cache', async () => { + const fetch = mockFetch({ + [cardUrl]: () => + jsonResponse(card, { contentType: SERVER_CARD_MEDIA_TYPE, headers: { ETag: '"v1"', 'Cache-Control': 'max-age=60' } }) + }); + const result = await fetchServerCard(cardUrl, { fetch }); + expect(result).toMatchObject({ etag: '"v1"', cacheControl: 'max-age=60' }); + }); +}); + +describe('fetchAICatalog', () => { + it('fetches and validates a catalog with its media type', async () => { + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse(catalog, { contentType: 'application/ai-catalog+json' }) }); + const result = await fetchAICatalog(catalogUrl, { fetch }); + expect(result).toMatchObject({ notModified: false, catalog, url: catalogUrl }); + expect(new Headers(fetch.calls[0]!.init!.headers).get('Accept')).toBe('application/ai-catalog+json'); + }); + + it('rejects an invalid catalog document', async () => { + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse({ entries: [] }) }); + await expectCode(fetchAICatalog(catalogUrl, { fetch }), 'invalid-ai-catalog'); + }); + + it('truncates entries beyond maxEntries instead of throwing', async () => { + const entries = Array.from({ length: 5 }, (_, index) => ({ + identifier: `urn:air:example.com:mcp:s${index}`, + type: SERVER_CARD_MEDIA_TYPE, + url: `https://example.com/${index}` + })); + const fetch = mockFetch({ [catalogUrl]: () => jsonResponse({ specVersion: '1.0', entries }) }); + const result = await fetchAICatalog(catalogUrl, { fetch, maxEntries: 2 }); + expect(!result.notModified && result.catalog.entries).toHaveLength(2); + }); +}); + +describe('getAICatalogUrl', () => { + it('prefixes bare domains with https and appends the well-known path', () => { + expect(getAICatalogUrl('example.com').href).toBe('https://example.com/.well-known/ai-catalog.json'); + expect(getAICatalogUrl('localhost:3000').href).toBe('https://localhost:3000/.well-known/ai-catalog.json'); + }); + + it('keeps the origin of full URLs and drops their path', () => { + expect(getAICatalogUrl('https://example.com/some/page').href).toBe('https://example.com/.well-known/ai-catalog.json'); + expect(getAICatalogUrl(new URL('http://localhost:8080/mcp')).href).toBe('http://localhost:8080/.well-known/ai-catalog.json'); + }); +}); diff --git a/packages/client/test/experimental/guard.test.ts b/packages/client/test/experimental/guard.test.ts new file mode 100644 index 0000000000..f20eb25c0f --- /dev/null +++ b/packages/client/test/experimental/guard.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { ServerCardError } from '../../src/experimental/serverCard/errors'; +import { assertAllowedUrl } from '../../src/experimental/serverCard/guard'; + +function codeOf(fn: () => void): string | undefined { + try { + fn(); + return undefined; + } catch (error) { + expect(error).toBeInstanceOf(ServerCardError); + return (error as ServerCardError).code; + } +} + +describe('assertAllowedUrl', () => { + it('allows https to public hosts', () => { + expect(codeOf(() => assertAllowedUrl(new URL('https://example.com/x'), {}))).toBeUndefined(); + }); + + it('rejects non-http(s) schemes', () => { + expect(codeOf(() => assertAllowedUrl(new URL('ftp://example.com/x'), {}))).toBe('blocked-host'); + expect(codeOf(() => assertAllowedUrl(new URL('file:///etc/passwd'), {}))).toBe('blocked-host'); + }); + + it('rejects plain http to remote hosts unless allowHttp, exempting localhost forms', () => { + expect(codeOf(() => assertAllowedUrl(new URL('http://example.com/x'), {}))).toBe('blocked-host'); + expect(codeOf(() => assertAllowedUrl(new URL('http://example.com/x'), { allowHttp: true }))).toBeUndefined(); + }); + + it.each(['http://localhost:3000/x', 'http://127.0.0.1:3000/x', 'http://[::1]:3000/x', 'https://127.0.0.1/x', 'https://[::1]/x'])( + 'always exempts the local-dev host %s, with no overrides', + url => { + expect(codeOf(() => assertAllowedUrl(new URL(url), {}))).toBeUndefined(); + } + ); + + it.each([ + 'https://0.0.0.0/x', + 'https://10.1.2.3/x', + 'https://127.0.0.2/x', + 'https://169.254.169.254/latest/meta-data', + 'https://172.16.0.1/x', + 'https://172.31.255.255/x', + 'https://192.168.1.1/x', + 'https://[::]/x', + 'https://[fe80::1]/x', + 'https://[fd00::1]/x', + 'https://[::ffff:127.0.0.1]/x', + 'https://[::ffff:10.0.0.1]/x', + // Embedded-IPv4 spellings of private targets: IPv4-compatible + // (`[::127.0.0.1]` URL-normalizes to `[::7f00:1]`), NAT64 well-known + // prefix, and 6to4. + 'https://[::7f00:1]/x', + 'https://[::a9fe:a9fe]/x', + 'https://[64:ff9b::a9fe:a9fe]/x', + 'https://[64:ff9b::169.254.169.254]/x', + 'https://[2002:7f00:1::]/x', + 'https://[2002:a9fe:a9fe::]/x' + ])('rejects private or local address %s by default', url => { + expect(codeOf(() => assertAllowedUrl(new URL(url), {}))).toBe('blocked-host'); + }); + + it.each([ + 'https://172.15.0.1/x', + 'https://172.32.0.1/x', + 'https://8.8.8.8/x', + 'https://[2606:4700::1]/x', + // Embedded-IPv4 forms of PUBLIC addresses stay allowed. + 'https://[64:ff9b::808:808]/x', + 'https://[2002:808:808::]/x' + ])('allows public address %s', url => { + expect(codeOf(() => assertAllowedUrl(new URL(url), {}))).toBeUndefined(); + }); + + it('rejects single-label hostnames other than localhost', () => { + expect(codeOf(() => assertAllowedUrl(new URL('https://intranet/x'), {}))).toBe('blocked-host'); + expect(codeOf(() => assertAllowedUrl(new URL('https://localhost/x'), {}))).toBeUndefined(); + }); + + it('allows everything private with allowPrivateHosts', () => { + for (const url of ['https://169.254.169.254/x', 'https://192.168.1.1/x', 'https://intranet/x', 'https://[::1]/x']) { + expect(codeOf(() => assertAllowedUrl(new URL(url), { allowPrivateHosts: true }))).toBeUndefined(); + } + }); +}); diff --git a/packages/client/test/experimental/mockFetch.ts b/packages/client/test/experimental/mockFetch.ts new file mode 100644 index 0000000000..f130ec1e6a --- /dev/null +++ b/packages/client/test/experimental/mockFetch.ts @@ -0,0 +1,32 @@ +import type { FetchLike } from '@modelcontextprotocol/core-internal'; + +export interface RecordedRequest { + url: string; + init?: RequestInit; +} + +/** + * A FetchLike backed by a URL-to-response map, recording every call so tests + * can assert on headers, credentials, and hop order. + */ +export function mockFetch(routes: Record Response>): FetchLike & { calls: RecordedRequest[] } { + const calls: RecordedRequest[] = []; + const fetchLike = (async (url: string | URL, init?: RequestInit): Promise => { + const call = { url: url.toString(), init }; + calls.push(call); + const route = routes[call.url]; + if (route === undefined) { + return new Response('not found', { status: 404 }); + } + return route(call); + }) as FetchLike & { calls: RecordedRequest[] }; + fetchLike.calls = calls; + return fetchLike; +} + +export function jsonResponse(body: unknown, init?: { status?: number; contentType?: string; headers?: Record }): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { 'Content-Type': init?.contentType ?? 'application/json', ...init?.headers } + }); +} diff --git a/packages/client/test/experimental/reconcile.test.ts b/packages/client/test/experimental/reconcile.test.ts new file mode 100644 index 0000000000..29d5350e6c --- /dev/null +++ b/packages/client/test/experimental/reconcile.test.ts @@ -0,0 +1,66 @@ +import type { Implementation } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import type { ServerCard, ServerCardRemote } from '../../src/experimental/serverCard/index'; +import { reconcileServerCard, SERVER_CARD_SCHEMA_URL } from '../../src/experimental/serverCard/index'; + +const card: ServerCard = { + $schema: SERVER_CARD_SCHEMA_URL, + name: 'com.example/weather', + version: '1.0.0', + description: 'Forecasts', + title: 'Weather', + websiteUrl: 'https://example.com' +}; + +const agreeingServerInfo: Implementation = { + name: 'com.example/weather', + version: '1.0.0', + title: 'Weather', + websiteUrl: 'https://example.com' +}; + +const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://example.com/mcp', + supportedProtocolVersions: ['2025-11-25'] +}; + +describe('reconcileServerCard', () => { + it('returns [] on full agreement', () => { + expect(reconcileServerCard(card, agreeingServerInfo)).toEqual([]); + }); + + it('reports each disagreeing field with card and runtime values', () => { + const mismatches = reconcileServerCard(card, { name: 'other/name', version: '2.0.0', title: 'Other' }); + expect(mismatches).toEqual([ + { field: 'name', cardValue: 'com.example/weather', runtimeValue: 'other/name' }, + { field: 'version', cardValue: '1.0.0', runtimeValue: '2.0.0' }, + { field: 'title', cardValue: 'Weather', runtimeValue: 'Other' }, + { field: 'websiteUrl', cardValue: 'https://example.com', runtimeValue: undefined } + ]); + }); + + it('compares optional fields only when the card states them', () => { + const minimal: ServerCard = { $schema: SERVER_CARD_SCHEMA_URL, name: 'com.example/weather', version: '1.0.0', description: 'x' }; + expect(reconcileServerCard(minimal, agreeingServerInfo)).toEqual([]); + }); + + it('reports a protocolVersion mismatch only with a negotiated version and declared support', () => { + expect(reconcileServerCard(card, agreeingServerInfo, { remote, negotiatedProtocolVersion: '2025-11-25' })).toEqual([]); + expect(reconcileServerCard(card, agreeingServerInfo, { remote, negotiatedProtocolVersion: '2024-11-05' })).toEqual([ + { field: 'protocolVersion', cardValue: '2025-11-25', runtimeValue: '2024-11-05' } + ]); + expect(reconcileServerCard(card, agreeingServerInfo, { negotiatedProtocolVersion: '2024-11-05' })).toEqual([]); + expect( + reconcileServerCard(card, agreeingServerInfo, { + remote: { type: 'streamable-http', url: 'https://example.com/mcp' }, + negotiatedProtocolVersion: '2024-11-05' + }) + ).toEqual([]); + }); + + it('never throws', () => { + expect(() => reconcileServerCard(card, { name: '', version: '' })).not.toThrow(); + }); +}); diff --git a/packages/client/test/experimental/resolve.test.ts b/packages/client/test/experimental/resolve.test.ts new file mode 100644 index 0000000000..1ab582589c --- /dev/null +++ b/packages/client/test/experimental/resolve.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; + +import type { ServerCardRemote } from '../../src/experimental/serverCard/index'; +import { requiredRemoteInputs, resolveRemote, ServerCardError } from '../../src/experimental/serverCard/index'; + +// The spec repo's templated-remote example shape: templated url, Authorization +// header with a nested secret token variable, tenant variable with a default. +const templatedRemote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://{tenant}.example.com/mcp', + headers: [ + { + name: 'Authorization', + isRequired: true, + isSecret: true, + value: 'Bearer {token}', + variables: { token: { description: 'API token', isRequired: true, isSecret: true } } + } + ], + variables: { tenant: { description: 'Tenant subdomain', isRequired: true, default: 'default' } }, + supportedProtocolVersions: ['2025-06-18', '2025-11-25'] +}; + +function thrown(fn: () => void): ServerCardError { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(ServerCardError); + return error as ServerCardError; + } + throw new Error('expected resolveRemote to throw'); +} + +describe('requiredRemoteInputs', () => { + it('walks remote variables, valueless header inputs, and nested header variables', () => { + const remote: ServerCardRemote = { + ...templatedRemote, + headers: [...templatedRemote.headers!, { name: 'X-Region', isRequired: false, choices: ['eu', 'us'] }] + }; + const requirements = requiredRemoteInputs(remote); + expect(requirements).toEqual([ + { key: 'tenant', path: 'variables.tenant', input: remote.variables!['tenant'], required: true }, + { key: 'token', path: 'headers.Authorization.variables.token', input: remote.headers![0]!.variables!['token'], required: true }, + { key: 'X-Region', path: 'headers.X-Region', input: remote.headers![1], required: false } + ]); + }); + + it('does not list headers with a fixed value as their own input', () => { + const keys = requiredRemoteInputs(templatedRemote).map(requirement => requirement.key); + expect(keys).not.toContain('Authorization'); + }); + + it('skips nested variables of a valueless header, which resolveRemote never reads', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://example.com/mcp', + headers: [{ name: 'X-Key', variables: { token: { isRequired: true, isSecret: true } } }] + }; + expect(requiredRemoteInputs(remote)).toEqual([{ key: 'X-Key', path: 'headers.X-Key', input: remote.headers![0], required: false }]); + // The walk mirrors consumption: only inputs['X-Key'] is read. + expect(resolveRemote(remote, { 'X-Key': 'k1' }).headers).toEqual({ 'X-Key': 'k1' }); + }); + + it('returns [] for a remote with no inputs', () => { + expect(requiredRemoteInputs({ type: 'sse', url: 'https://example.com/sse' })).toEqual([]); + }); +}); + +describe('resolveRemote', () => { + it('substitutes url and header templates from inputs', () => { + const resolved = resolveRemote(templatedRemote, { tenant: 'acme', token: 'secret123' }); + expect(resolved.type).toBe('streamable-http'); + expect(resolved.url.href).toBe('https://acme.example.com/mcp'); + expect(resolved.headers).toEqual({ Authorization: 'Bearer secret123' }); + expect(resolved.supportedProtocolVersions).toEqual(['2025-06-18', '2025-11-25']); + }); + + it('falls back to variable defaults, with inputs taking precedence over value and default', () => { + const resolved = resolveRemote(templatedRemote, { token: 'secret123' }); + expect(resolved.url.href).toBe('https://default.example.com/mcp'); + + const withValue: ServerCardRemote = { + type: 'streamable-http', + url: 'https://{env}.example.com/mcp', + variables: { env: { value: 'prod', default: 'staging' } } + }; + expect(resolveRemote(withValue).url.href).toBe('https://prod.example.com/mcp'); + expect(resolveRemote(withValue, { env: 'dev' }).url.href).toBe('https://dev.example.com/mcp'); + }); + + it('aggregates every unmet required input into one missing-input error', () => { + const error = thrown(() => resolveRemote({ ...templatedRemote, variables: { tenant: { isRequired: true } } })); + expect(error.code).toBe('missing-input'); + expect(error.missing!.map(entry => entry.key).sort()).toEqual(['tenant', 'token']); + }); + + it('resolves a valueless header from inputs by header name and from its default', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://example.com/mcp', + headers: [ + { name: 'X-Api-Key', isRequired: true }, + { name: 'X-Region', default: 'eu' } + ] + }; + const resolved = resolveRemote(remote, { 'X-Api-Key': 'k1' }); + expect(resolved.headers).toEqual({ 'X-Api-Key': 'k1', 'X-Region': 'eu' }); + }); + + it('omits an optional header that resolves to no value', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://example.com/mcp', + headers: [{ name: 'X-Optional' }, { name: 'X-Templated', value: 'v-{missing}' }] + }; + expect(resolveRemote(remote).headers).toEqual({}); + }); + + it('reports a missing required valueless header as missing-input', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://example.com/mcp', + headers: [{ name: 'X-Api-Key', isRequired: true }] + }; + const error = thrown(() => resolveRemote(remote)); + expect(error.code).toBe('missing-input'); + expect(error.missing![0]!.key).toBe('X-Api-Key'); + }); + + it('throws invalid-input for a supplied value outside choices', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: 'https://{region}.example.com/mcp', + variables: { region: { choices: ['eu', 'us'] } } + }; + expect(resolveRemote(remote, { region: 'eu' }).url.href).toBe('https://eu.example.com/mcp'); + expect(thrown(() => resolveRemote(remote, { region: 'mars' })).code).toBe('invalid-input'); + }); + + it('throws invalid-input for an unresolved variable left in the url', () => { + const remote: ServerCardRemote = { type: 'streamable-http', url: 'https://{tenant}.example.com/mcp' }; + expect(thrown(() => resolveRemote(remote)).code).toBe('invalid-input'); + }); + + it('throws invalid-input when the resolved url is not http(s)', () => { + const remote: ServerCardRemote = { + type: 'streamable-http', + url: '{base}/mcp', + variables: { base: { value: 'ftp://example.com' } } + }; + expect(thrown(() => resolveRemote(remote)).code).toBe('invalid-input'); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8fc1de9347..5a57702b55 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -7,6 +7,9 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/core/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 45e0d7a28e..67a2332660 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/ajv.ts', - 'src/validators/cfWorker.ts' + 'src/validators/cfWorker.ts', + 'src/experimental/serverCard/index.ts' ], format: ['esm', 'cjs'], fixedExtension: true, @@ -29,7 +30,8 @@ export default defineConfig({ '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], - '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] + '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'], + '@modelcontextprotocol/core/experimental/server-card': ['../core/src/experimental/serverCard/index.ts'] } } }, @@ -37,5 +39,10 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/client/_shims', '@modelcontextprotocol/core/internal'] + // The experimental server-card schemas follow the same rule. + external: [ + '@modelcontextprotocol/client/_shims', + '@modelcontextprotocol/core/internal', + '@modelcontextprotocol/core/experimental/server-card' + ] }); diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 979ff15070..c6e109eb9c 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -37,17 +37,20 @@ function readManifest(relativeDir: string): PackageManifest { const PUBLIC_PACKAGES: Record }> = { client: { name: '@modelcontextprotocol/client', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + // './experimental/server-card' is the experimental Server Card extension + // (SEP-2127); the subpath is the experimental marker and may change or be + // removed in any release. + exportKeys: ['.', './stdio', './experimental/server-card', './validators/ajv', './validators/cf-worker', './_shims'] }, server: { name: '@modelcontextprotocol/server', - exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + exportKeys: ['.', './stdio', './experimental/server-card', './validators/ajv', './validators/cf-worker', './_shims'] }, 'server-legacy': { name: '@modelcontextprotocol/server-legacy', exportKeys: ['.', './sse', './auth'] }, - 'middleware/express': { name: '@modelcontextprotocol/express', exportKeys: ['.'] }, + 'middleware/express': { name: '@modelcontextprotocol/express', exportKeys: ['.', './experimental/server-card'] }, 'middleware/fastify': { name: '@modelcontextprotocol/fastify', exportKeys: ['.'] }, 'middleware/hono': { name: '@modelcontextprotocol/hono', exportKeys: ['.'] }, 'middleware/node': { name: '@modelcontextprotocol/node', exportKeys: ['.'] }, @@ -56,7 +59,9 @@ const PUBLIC_PACKAGES: Record (entry.url !== undefined) !== (entry.data !== undefined), { + message: 'an AI Catalog entry must carry exactly one of `url` or `data`' + }); + +/** + * Information about the host publishing an AI Catalog. + */ +export const AICatalogHostSchema = z.looseObject({ + /** + * Display name of the catalog host. + */ + displayName: z.string(), + /** + * Identifier of the catalog host. + */ + identifier: z.string().optional(), + /** + * Documentation URL for the catalog host. + */ + documentationUrl: z.string().optional(), + /** + * Logo URL for the catalog host. + */ + logoUrl: z.string().optional(), + /** + * Self-asserted trust manifest for the host. Kept loose. + */ + trustManifest: z.record(z.string(), z.unknown()).optional() +}); + +/** + * An AI Catalog document, typically published at + * `/.well-known/ai-catalog.json`, advertising AI artifacts including MCP + * Server Cards. + */ +export const AICatalogSchema = z.looseObject({ + /** + * Catalog spec version in `Major.Minor` form, e.g. `"1.0"`. + */ + specVersion: z.string(), + /** + * Catalog entries. May be empty. + */ + entries: z.array(AICatalogEntrySchema), + /** + * Information about the publishing host. + */ + host: AICatalogHostSchema.optional(), + /** + * Extension metadata, keys reverse-DNS prefixed. + */ + metadata: z.record(z.string(), z.unknown()).optional() +}); + +/** A validated AI Catalog document. */ +export type AICatalog = z.infer; +/** One validated AI Catalog entry. Self-asserted, unverified data. */ +export type AICatalogEntry = z.infer; +/** Validated host information from an AI Catalog. */ +export type AICatalogHost = z.infer; diff --git a/packages/core/src/experimental/serverCard/constants.ts b/packages/core/src/experimental/serverCard/constants.ts new file mode 100644 index 0000000000..d61d2494a9 --- /dev/null +++ b/packages/core/src/experimental/serverCard/constants.ts @@ -0,0 +1,37 @@ +/** + * Wire constants for the experimental MCP Server Card extension (SEP-2127). + * + * Experimental: this module tracks the `experimental-ext-server-card` spec + * repository and may change or be removed in any release. + */ + +/** + * The canonical `$schema` URL every v1 Server Card document must carry. + * Schema URLs are versioned by the `vN` segment rather than by date; a + * breaking revision of the Server Card shape publishes a new `vN` family. + */ +export const SERVER_CARD_SCHEMA_URL = 'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'; + +/** + * Media type for Server Card documents. Servers should serve cards with this + * `Content-Type` and clients should send it in `Accept` when fetching a card. + */ +export const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json'; + +/** + * Reserved path suffix for the recommended card location: MCP reserves + * `GET /server-card`. The suffix is appended to the + * streamable HTTP endpoint path, never to the domain root. + */ +export const SERVER_CARD_PATH_SUFFIX = '/server-card'; + +/** + * Media type for AI Catalog documents (external Agent-Card spec). + */ +export const AI_CATALOG_MEDIA_TYPE = 'application/ai-catalog+json'; + +/** + * Well-known path where hosts may publish an AI Catalog for domain-level + * discovery. + */ +export const AI_CATALOG_WELL_KNOWN_PATH = '/.well-known/ai-catalog.json'; diff --git a/packages/core/src/experimental/serverCard/index.ts b/packages/core/src/experimental/serverCard/index.ts new file mode 100644 index 0000000000..3ff3ada244 --- /dev/null +++ b/packages/core/src/experimental/serverCard/index.ts @@ -0,0 +1,29 @@ +// @modelcontextprotocol/core/experimental/server-card +// +// Zod schemas, inferred types, and wire constants for the experimental MCP +// Server Card extension (SEP-2127) and the AI Catalog discovery document it +// uses. This subpath is the schema source of truth for the sibling SDK +// packages; the server and client experimental subpaths re-export the types +// and constants only, keeping their public surfaces Zod-free. +// +// Experimental: tracks the `experimental-ext-server-card` spec repository and +// may change or be removed in any release. Nothing here is part of the stable +// core root surface. + +export type { AICatalog, AICatalogEntry, AICatalogHost } from './catalog'; +export { AICatalogEntrySchema, AICatalogHostSchema, AICatalogSchema } from './catalog'; +export { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_WELL_KNOWN_PATH, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_PATH_SUFFIX, + SERVER_CARD_SCHEMA_URL +} from './constants'; +export type { ServerCard, ServerCardInput, ServerCardKeyValueInput, ServerCardRemote, ServerCardRepository } from './schema'; +export { + ServerCardInputSchema, + ServerCardKeyValueInputSchema, + ServerCardRemoteSchema, + ServerCardRepositorySchema, + ServerCardSchema +} from './schema'; diff --git a/packages/core/src/experimental/serverCard/schema.ts b/packages/core/src/experimental/serverCard/schema.ts new file mode 100644 index 0000000000..16623490b2 --- /dev/null +++ b/packages/core/src/experimental/serverCard/schema.ts @@ -0,0 +1,242 @@ +import * as z from 'zod/v4'; + +import { IconSchema } from '../../schemas'; +import { SERVER_CARD_SCHEMA_URL } from './constants'; + +// Hand-written port of the experimental Server Card extension's authoritative +// schema.ts (SEP-2127, repo experimental-ext-server-card). Field JSDoc is +// carried over from the extension source. Objects are open (looseObject): +// unknown fields survive parsing, and vendor data belongs in namespaced +// `_meta`. Core-spec `Icon` is reused from ../../schemas, never re-modeled. + +/** + * Version ranges are rejected at the validator level (spec prose, not + * expressible in the generated JSON Schema): `^1.2.3`, `~1.2.3`, `>=1.2.3`, + * `<=1`, `>1`, `<1`, `1.x`, `1.*`. Exact versions and plain non-semver + * strings pass. + */ +function isVersionRange(version: string): boolean { + if (/^(?:[\^~]|[<>]=?)/.test(version)) { + return true; + } + return /^\d+(?:\.(?:\d+|[x*]))*$/i.test(version) && /[x*]/i.test(version); +} + +/** + * A user-supplied or pre-set input value, used for remote URL variables and + * header values. + */ +export const ServerCardInputSchema = z.looseObject({ + /** + * Human-readable explanation of the input. Clients can use this to + * provide context to the user. + */ + description: z.string().optional(), + /** + * Whether the input must be supplied for the connection to succeed. + */ + isRequired: z.boolean().optional(), + /** + * Whether the input is a secret value (e.g., password, token). If true, + * clients should handle the value securely. + */ + isSecret: z.boolean().optional(), + /** + * Specifies the input format. `"filepath"` should be interpreted as a + * file on the user's filesystem. When the input is converted to a string, + * booleans should be represented by `"true"`/`"false"`, and numbers by + * decimal values. + */ + format: z.enum(['string', 'number', 'boolean', 'filepath']).optional(), + /** + * Default value for the input. SHOULD be a valid value for the input. + */ + default: z.string().optional(), + /** + * Placeholder displayed during configuration to provide examples or + * guidance about the expected form of the input. + */ + placeholder: z.string().optional(), + /** + * Pre-set value for the input. If set, the value should not be + * configurable by end users. Identifiers wrapped in `{curly_braces}` will + * be replaced with the corresponding entries from the input's `variables` + * map (if any). + */ + value: z.string().optional(), + /** + * Allowed values for the input. If provided, the user must select one. + */ + choices: z.array(z.string()).optional() +}); + +/** + * A named input — used for HTTP headers — whose `value` may reference + * variables for substitution. + */ +export const ServerCardKeyValueInputSchema = ServerCardInputSchema.extend({ + /** + * Name of the header. + */ + name: z.string(), + /** + * Variables referenced by `{curly_braces}` identifiers in `value`. The + * map key is the variable name; the value defines the variable's + * properties. + */ + variables: z.record(z.string(), ServerCardInputSchema).optional() +}); + +/** + * Metadata for connecting to a remote (HTTP-based) MCP server endpoint. + */ +export const ServerCardRemoteSchema = z.looseObject({ + /** + * The transport type for this remote endpoint. + */ + type: z.enum(['streamable-http', 'sse']), + /** + * URL template for the remote endpoint. Must start with `http://`, + * `https://`, or a `{template_variable}`. Variables in `{curly_braces}` + * are substituted from the `variables` map before the client connects. + */ + url: z.string().regex(/^(https?:\/\/[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$/), + /** + * HTTP headers required or accepted when connecting to this remote + * endpoint. Each header is described as a key-value input so that clients + * can prompt users for required values, mark secrets, surface defaults, + * and constrain to a list of choices. + */ + headers: z.array(ServerCardKeyValueInputSchema).optional(), + /** + * Configuration variables that can be referenced as `{curly_braces}` + * placeholders in `url` (and inside header values via each header's own + * `variables`). The map key is the variable name; the value defines the + * variable's properties. + */ + variables: z.record(z.string(), ServerCardInputSchema).optional(), + /** + * MCP protocol versions actively supported by this remote endpoint. + * Allows clients to select a compatible protocol version before + * connecting. + */ + supportedProtocolVersions: z.array(z.string()).optional() +}); + +/** + * Repository metadata for the MCP server source code. Enables users and + * security experts to inspect the code, improving transparency. + */ +export const ServerCardRepositorySchema = z.looseObject({ + /** + * Repository URL for browsing source code. Should support both web + * browsing and `git clone` operations. + */ + url: z.url(), + /** + * Repository hosting service identifier (e.g., `"github"`). Used by + * registries to determine validation and API access methods. + */ + source: z.string(), + /** + * Optional relative path from repository root to the server location + * within a monorepo or nested package structure. Must be a clean relative + * path. + */ + subfolder: z.string().optional(), + /** + * Repository identifier from the hosting service (e.g., GitHub repo ID). + * Owned and determined by the source forge. Should remain stable across + * repository renames and may be used to detect repository resurrection + * attacks. + */ + id: z.string().optional() +}); + +/** + * A static metadata document describing a remote MCP server, suitable for + * pre-connection discovery. A Server Card may be hosted at any unreserved + * URI; MCP reserves `GET /server-card` as the + * recommended location. + * + * Card contents are advisory, never authoritative: clients MUST NOT use them + * for security or access-control decisions, and SHOULD prefer runtime values + * on disagreement. + * + * Validation is strict about the spec's constraints: a missing or wrong + * `$schema` is rejected here. Lenient ingestion (defaulting a missing + * `$schema`) lives in the client fetch helpers, not in this schema. + */ +export const ServerCardSchema = z.looseObject({ + /** + * The Server Card JSON Schema URI that this document conforms to. + * Required. Must be exactly the v1 Server Card schema URL. + */ + $schema: z.literal(SERVER_CARD_SCHEMA_URL), + /** + * Server name in reverse-DNS format. Must contain exactly one forward + * slash separating namespace from server name. + */ + name: z + .string() + .min(3) + .max(200) + .regex(/^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/), + /** + * Version string for this server. SHOULD follow semantic versioning + * (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of `Implementation.version`. + * Non-semantic versions are allowed but may not sort predictably. + * Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', + * '1.x', '1.*'). + */ + version: z + .string() + .max(255) + .refine(version => !isVersionRange(version), { + message: 'version must be an exact version, not a range (e.g. ^1.2.3, ~1.2.3, >=1.2.3, 1.x, 1.*)' + }), + /** + * Clear human-readable explanation of server functionality. Should focus + * on capabilities, not implementation details. + */ + description: z.string().min(1).max(100), + /** + * Optional human-readable title or display name for the MCP server. + */ + title: z.string().min(1).max(100).optional(), + /** + * Optional URL to the server's homepage, documentation, or project + * website. + */ + websiteUrl: z.url().optional(), + /** + * Optional repository metadata for the MCP server source code. + */ + repository: ServerCardRepositorySchema.optional(), + /** + * Optional set of sized icons that the client can display in a user + * interface. Clients that render icons MUST support `image/png` and + * `image/jpeg`, and SHOULD support `image/svg+xml` and `image/webp`. + */ + icons: z.array(IconSchema).optional(), + /** + * Metadata helpful for making HTTP-based connections to this MCP server. + */ + remotes: z.array(ServerCardRemoteSchema).optional(), + /** + * Extension metadata using reverse-DNS namespacing for vendor-specific + * data. Follows the protocol's standard `_meta` definition. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** A validated Server Card document. Advisory data; see `ServerCardSchema`. */ +export type ServerCard = z.infer; +/** A user-supplied or pre-set input value declared by a card remote. */ +export type ServerCardInput = z.infer; +/** A named header input declared by a card remote. */ +export type ServerCardKeyValueInput = z.infer; +/** Connection metadata for one remote endpoint declared by a card. */ +export type ServerCardRemote = z.infer; +/** Source repository metadata declared by a card. */ +export type ServerCardRepository = z.infer; diff --git a/packages/core/test/experimental/aiCatalog.test.ts b/packages/core/test/experimental/aiCatalog.test.ts new file mode 100644 index 0000000000..978cd06deb --- /dev/null +++ b/packages/core/test/experimental/aiCatalog.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { AICatalogEntrySchema, AICatalogSchema, SERVER_CARD_MEDIA_TYPE } from '../../src/experimental/serverCard'; + +const urlEntry = { + identifier: 'urn:air:example.com:mcp:weather', + type: SERVER_CARD_MEDIA_TYPE, + url: 'https://example.com/mcp/server-card' +}; + +describe('AICatalogEntrySchema', () => { + it('accepts a url entry and a data entry', () => { + expect(AICatalogEntrySchema.safeParse(urlEntry).success).toBe(true); + expect(AICatalogEntrySchema.safeParse({ ...urlEntry, url: undefined, data: { any: 'card' } }).success).toBe(true); + }); + + it('rejects an entry with both url and data', () => { + expect(AICatalogEntrySchema.safeParse({ ...urlEntry, data: {} }).success).toBe(false); + }); + + it('rejects an entry with neither url nor data', () => { + expect(AICatalogEntrySchema.safeParse({ identifier: urlEntry.identifier, type: urlEntry.type }).success).toBe(false); + }); + + it('requires identifier and type', () => { + expect(AICatalogEntrySchema.safeParse({ type: urlEntry.type, url: urlEntry.url }).success).toBe(false); + expect(AICatalogEntrySchema.safeParse({ identifier: urlEntry.identifier, url: urlEntry.url }).success).toBe(false); + }); +}); + +describe('AICatalogSchema', () => { + it('accepts a catalog with empty entries', () => { + expect(AICatalogSchema.safeParse({ specVersion: '1.0', entries: [] }).success).toBe(true); + }); + + it('requires specVersion and entries', () => { + expect(AICatalogSchema.safeParse({ entries: [] }).success).toBe(false); + expect(AICatalogSchema.safeParse({ specVersion: '1.0' }).success).toBe(false); + }); + + it('keeps loose extra fields on catalog, host, and entries', () => { + const catalog = AICatalogSchema.parse({ + specVersion: '1.0', + entries: [{ ...urlEntry, trustManifest: { identity: 'https://example.com' }, extra: true }], + host: { displayName: 'Example', futureField: 'kept' }, + metadata: { 'com.example/region': 'eu' } + }); + expect(catalog.entries[0]!['extra']).toBe(true); + expect(catalog.entries[0]!.trustManifest).toEqual({ identity: 'https://example.com' }); + expect(catalog.host!['futureField']).toBe('kept'); + }); +}); diff --git a/packages/core/test/experimental/fixtures/server-card/README.md b/packages/core/test/experimental/fixtures/server-card/README.md new file mode 100644 index 0000000000..fb8e0eabae --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/README.md @@ -0,0 +1,10 @@ +# Server Card conformance fixtures + +Vendored from the `modelcontextprotocol/experimental-ext-server-card` +repository, `examples/ServerCard/`, at commit +`1491bd24714c885d5bc6b3bf0857094beff939fd`, then reformatted to this repo's +Prettier style (4-space indentation). The JSON content is otherwise unchanged. + +Do not edit these by hand. Refresh them by copying from the spec repository +when the schema changes, running `pnpm lint:fix:all` to reapply the repo +formatting, and updating the commit hash above. diff --git a/packages/core/test/experimental/fixtures/server-card/invalid/bad-name-pattern.json b/packages/core/test/experimental/fixtures/server-card/invalid/bad-name-pattern.json new file mode 100644 index 0000000000..69ea441ae3 --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/invalid/bad-name-pattern.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", + "name": "no-slash-in-name", + "version": "1.0.0", + "description": "Name must contain exactly one slash separating namespace and server name." +} diff --git a/packages/core/test/experimental/fixtures/server-card/invalid/date-versioned-schema.json b/packages/core/test/experimental/fixtures/server-card/invalid/date-versioned-schema.json new file mode 100644 index 0000000000..441df187a6 --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/invalid/date-versioned-schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-11-25/server-card.schema.json", + "name": "example-org/wrong-schema-url", + "version": "1.0.0", + "description": "Uses date-versioned schema URL; only /v1/ URLs are valid." +} diff --git a/packages/core/test/experimental/fixtures/server-card/invalid/missing-name.json b/packages/core/test/experimental/fixtures/server-card/invalid/missing-name.json new file mode 100644 index 0000000000..ce0bc0531f --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/invalid/missing-name.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", + "version": "1.0.0", + "description": "Missing required `name` field." +} diff --git a/packages/core/test/experimental/fixtures/server-card/invalid/missing-schema.json b/packages/core/test/experimental/fixtures/server-card/invalid/missing-schema.json new file mode 100644 index 0000000000..c940c4e51f --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/invalid/missing-schema.json @@ -0,0 +1,5 @@ +{ + "name": "example-org/no-schema", + "version": "1.0.0", + "description": "Missing the required `$schema` field." +} diff --git a/packages/core/test/experimental/fixtures/server-card/invalid/wrong-schema-name.json b/packages/core/test/experimental/fixtures/server-card/invalid/wrong-schema-name.json new file mode 100644 index 0000000000..2b71f58c0d --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/invalid/wrong-schema-name.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server.schema.json", + "name": "example-org/wrong-schema-name", + "version": "1.0.0", + "description": "References the removed registry server.json schema; only server-card.schema.json is valid in v1." +} diff --git a/packages/core/test/experimental/fixtures/server-card/valid/minimal.json b/packages/core/test/experimental/fixtures/server-card/valid/minimal.json new file mode 100644 index 0000000000..f8e881ca7c --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/valid/minimal.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", + "name": "example-org/minimal", + "version": "1.0.0", + "description": "Smallest valid Server Card." +} diff --git a/packages/core/test/experimental/fixtures/server-card/valid/templated-remote.json b/packages/core/test/experimental/fixtures/server-card/valid/templated-remote.json new file mode 100644 index 0000000000..60bb20b106 --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/valid/templated-remote.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", + "name": "example-org/with-remote", + "version": "2.1.0", + "description": "Server Card with a templated remote endpoint and headers.", + "title": "Example Remote Server", + "websiteUrl": "https://example.com", + "remotes": [ + { + "type": "streamable-http", + "url": "https://{tenant}.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for the remote endpoint.", + "isRequired": true, + "isSecret": true, + "value": "Bearer {token}", + "variables": { + "token": { + "description": "API token issued by Example Inc.", + "isRequired": true, + "isSecret": true + } + } + } + ], + "variables": { + "tenant": { + "description": "Tenant subdomain.", + "isRequired": true, + "default": "default" + } + }, + "supportedProtocolVersions": ["2025-06-18", "2025-11-25"] + } + ] +} diff --git a/packages/core/test/experimental/serverCard.test.ts b/packages/core/test/experimental/serverCard.test.ts new file mode 100644 index 0000000000..c05ba22ed4 --- /dev/null +++ b/packages/core/test/experimental/serverCard.test.ts @@ -0,0 +1,106 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { SERVER_CARD_SCHEMA_URL, ServerCardRemoteSchema, ServerCardSchema } from '../../src/experimental/serverCard'; + +const fixturesDir = fileURLToPath(new URL('./fixtures/server-card', import.meta.url)); + +function readFixtures(kind: 'valid' | 'invalid'): Array<{ name: string; document: unknown }> { + const dir = join(fixturesDir, kind); + return readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => ({ name: file, document: JSON.parse(readFileSync(join(dir, file), 'utf8')) as unknown })); +} + +const baseCard = { + $schema: SERVER_CARD_SCHEMA_URL, + name: 'com.example/weather', + version: '1.0.0', + description: 'Hourly and 7-day forecasts for any coordinates' +}; + +describe('ServerCardSchema conformance fixtures', () => { + it.each(readFixtures('valid'))('accepts and round-trips valid fixture $name', ({ document }) => { + expect(ServerCardSchema.parse(document)).toEqual(document); + }); + + it('rejects a name without a slash for the name pattern', () => { + const fixture = readFixtures('invalid').find(f => f.name === 'bad-name-pattern.json')!; + const result = ServerCardSchema.safeParse(fixture.document); + expect(result.success).toBe(false); + expect(result.error!.issues.map(issue => issue.path.join('.'))).toContain('name'); + }); + + it('rejects a date-versioned $schema URL', () => { + const fixture = readFixtures('invalid').find(f => f.name === 'date-versioned-schema.json')!; + const result = ServerCardSchema.safeParse(fixture.document); + expect(result.success).toBe(false); + expect(result.error!.issues.map(issue => issue.path.join('.'))).toContain('$schema'); + }); + + it('rejects a missing name', () => { + const fixture = readFixtures('invalid').find(f => f.name === 'missing-name.json')!; + const result = ServerCardSchema.safeParse(fixture.document); + expect(result.success).toBe(false); + expect(result.error!.issues.map(issue => issue.path.join('.'))).toContain('name'); + }); + + it('rejects a missing $schema (strict schema; leniency lives in the client fetchers)', () => { + const fixture = readFixtures('invalid').find(f => f.name === 'missing-schema.json')!; + const result = ServerCardSchema.safeParse(fixture.document); + expect(result.success).toBe(false); + expect(result.error!.issues.map(issue => issue.path.join('.'))).toContain('$schema'); + }); + + it('rejects the removed registry server.schema.json URL', () => { + const fixture = readFixtures('invalid').find(f => f.name === 'wrong-schema-name.json')!; + const result = ServerCardSchema.safeParse(fixture.document); + expect(result.success).toBe(false); + expect(result.error!.issues.map(issue => issue.path.join('.'))).toContain('$schema'); + }); +}); + +describe('ServerCardSchema constraints', () => { + it.each(['^1.2.3', '~1.2.3', '>=1.2.3', '<=1', '>1', '<1', '1.x', '1.*'])('rejects version range %s', version => { + const result = ServerCardSchema.safeParse({ ...baseCard, version }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('range'); + }); + + it.each(['1.0.2', '2.1.0-alpha', 'build-2024', '2024-01-15'])('accepts exact or non-semver version %s', version => { + expect(ServerCardSchema.safeParse({ ...baseCard, version }).success).toBe(true); + }); + + it('rejects a description longer than 100 characters and an empty description', () => { + expect(ServerCardSchema.safeParse({ ...baseCard, description: 'x'.repeat(101) }).success).toBe(false); + expect(ServerCardSchema.safeParse({ ...baseCard, description: '' }).success).toBe(false); + }); + + it('rejects a name with more than one slash or shorter than 3 characters', () => { + expect(ServerCardSchema.safeParse({ ...baseCard, name: 'a/b/c' }).success).toBe(false); + expect(ServerCardSchema.safeParse({ ...baseCard, name: 'a/' }).success).toBe(false); + }); + + it('keeps unknown fields (open objects) and _meta content', () => { + const card = ServerCardSchema.parse({ ...baseCard, 'x-vendor': 1, _meta: { 'com.example/hint': 'v' } }); + expect(card['x-vendor']).toBe(1); + expect(card._meta).toEqual({ 'com.example/hint': 'v' }); + }); +}); + +describe('ServerCardRemoteSchema url template pattern', () => { + it.each(['https://example.com/mcp', 'http://example.com/mcp', '{base}/mcp', '{base_url}'])('accepts %s', url => { + expect(ServerCardRemoteSchema.safeParse({ type: 'streamable-http', url }).success).toBe(true); + }); + + it.each(['ftp://example.com/mcp', 'example.com/mcp', '{1bad}/mcp', 'https://exa mple.com'])('rejects %s', url => { + expect(ServerCardRemoteSchema.safeParse({ type: 'streamable-http', url }).success).toBe(false); + }); + + it('rejects unknown transport types', () => { + expect(ServerCardRemoteSchema.safeParse({ type: 'websocket', url: 'https://example.com/mcp' }).success).toBe(false); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index e947feab67..3dc1f169c5 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'; // makes a node-only dependency leaking in fail the build here instead of silently shipping. export default defineConfig({ failOnWarn: 'ci-only', - entry: ['src/index.ts', 'src/internal.ts'], + entry: ['src/index.ts', 'src/internal.ts', 'src/experimental/serverCard/index.ts'], format: ['esm', 'cjs'], fixedExtension: true, outDir: 'dist', diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 793de91197..610064e41f 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -31,10 +31,27 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./experimental/server-card": { + "import": { + "types": "./dist/experimental/serverCardRouter.d.mts", + "default": "./dist/experimental/serverCardRouter.mjs" + }, + "require": { + "types": "./dist/experimental/serverCardRouter.d.cts", + "default": "./dist/experimental/serverCardRouter.cjs" + } } }, "main": "./dist/index.cjs", "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "experimental/server-card": [ + "dist/experimental/serverCardRouter.d.mts" + ] + } + }, "files": [ "dist" ], diff --git a/packages/middleware/express/src/experimental/serverCardRouter.ts b/packages/middleware/express/src/experimental/serverCardRouter.ts new file mode 100644 index 0000000000..db89d89e44 --- /dev/null +++ b/packages/middleware/express/src/experimental/serverCardRouter.ts @@ -0,0 +1,85 @@ +import type { AICatalogResponseOptions, ServerCardResponseOptions } from '@modelcontextprotocol/server/experimental/server-card'; +import { aiCatalogResponse, serverCardResponse } from '@modelcontextprotocol/server/experimental/server-card'; +import type { Request as ExpressRequest, Response as ExpressResponse, Router } from 'express'; +import express from 'express'; + +/** + * Options for {@link mcpServerCardRouter}: the runtime-neutral + * `ServerCardResponseOptions` from + * `@modelcontextprotocol/server/experimental/server-card`, plus an optional + * `catalog` to also serve the AI Catalog from this app. + */ +export interface McpServerCardRouterOptions extends ServerCardResponseOptions { + /** + * Also serve an AI Catalog (at `/.well-known/ai-catalog.json` unless the + * options name another path). Best practice is to publish the catalog on + * the domain users associate with the service. + */ + catalog?: AICatalogResponseOptions; +} + +/** + * Builds an Express router that serves a Server Card at the reserved + * `/server-card` route, and optionally an AI Catalog at its + * well-known path. Thin adapter over the runtime-neutral responders from + * `@modelcontextprotocol/server/experimental/server-card`; behavior + * (CORS, Cache-Control, ETag/304, 405, preflight) is identical. + * + * Mount at the application root: + * + * ```ts + * app.use(mcpServerCardRouter({ card, mcpUrl, catalog: { catalog } })); + * app.all('/mcp', mcpHandler); + * ``` + * + * An exact `/mcp` mount never sees `GET /mcp/server-card`; this router is + * what serves it. + * + * Experimental: tracks the `experimental-ext-server-card` spec repository and + * may change or be removed in any release. + */ +export function mcpServerCardRouter(options: McpServerCardRouterOptions): Router { + const { catalog, ...cardOptions } = options; + const router = express.Router(); + router.use((req, res, next) => { + const request = toFetchRequest(req); + const matched = + serverCardResponse(request, cardOptions) ?? (catalog === undefined ? undefined : aiCatalogResponse(request, catalog)); + if (matched === undefined) { + next(); + return; + } + matched.then(response => writeFetchResponse(response, res)).catch((error: unknown) => next(error)); + }); + return router; +} + +function toFetchRequest(req: ExpressRequest): Request { + const url = new URL(req.originalUrl, `${req.protocol}://${req.headers.host ?? 'localhost'}`); + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + headers.set(name, value); + } else if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } + } + // The responders only ever read GET/HEAD/OPTIONS (and answer 405 for the + // rest), so the converted request never needs a body. + return new Request(url, { method: req.method, headers }); +} + +async function writeFetchResponse(response: Response, res: ExpressResponse): Promise { + res.status(response.status); + for (const [name, value] of response.headers.entries()) { + res.setHeader(name, value); + } + const body = Buffer.from(await response.arrayBuffer()); + if (body.byteLength > 0) { + res.send(body); + } else { + res.end(); + } +} diff --git a/packages/middleware/express/test/serverCardRouter.test.ts b/packages/middleware/express/test/serverCardRouter.test.ts new file mode 100644 index 0000000000..2600bbd52b --- /dev/null +++ b/packages/middleware/express/test/serverCardRouter.test.ts @@ -0,0 +1,74 @@ +import { + buildAICatalog, + buildServerCard, + getServerCardUrl, + SERVER_CARD_MEDIA_TYPE, + serverCardCatalogEntry +} from '@modelcontextprotocol/server/experimental/server-card'; +import express from 'express'; +import supertest from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { mcpServerCardRouter } from '../src/experimental/serverCardRouter'; + +const mcpUrl = new URL('https://weather.example.com/mcp'); +const card = buildServerCard({ + name: 'com.example/weather', + description: 'Forecasts', + version: '1.0.0', + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] +}); +const catalog = buildAICatalog({ entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] }); + +function appWith(options: Parameters[0]): express.Express { + const app = express(); + app.use(mcpServerCardRouter(options)); + app.get('/other', (_req, res) => { + res.status(200).send('fallthrough'); + }); + return app; +} + +describe('mcpServerCardRouter', () => { + it('serves the card at the reserved path with media type and CORS', async () => { + const response = await supertest(appWith({ card, mcpUrl })).get('/mcp/server-card'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain(SERVER_CARD_MEDIA_TYPE); + expect(response.headers['access-control-allow-origin']).toBe('*'); + expect(response.headers['etag']).toMatch(/^"[0-9a-f]{64}"$/); + expect(response.body).toEqual(card); + }); + + it('serves the catalog at the well-known path when configured', async () => { + const app = appWith({ card, mcpUrl, catalog: { catalog } }); + const response = await supertest(app).get('/.well-known/ai-catalog.json'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/ai-catalog+json'); + expect(response.body).toEqual(catalog); + const without = await supertest(appWith({ card, mcpUrl })).get('/.well-known/ai-catalog.json'); + expect(without.status).toBe(404); + }); + + it('answers OPTIONS preflight and 405 for non-GET methods', async () => { + const app = appWith({ card, mcpUrl }); + const preflight = await supertest(app).options('/mcp/server-card').set('Access-Control-Request-Headers', 'if-none-match'); + expect(preflight.status).toBe(204); + expect(preflight.headers['access-control-allow-methods']).toBe('GET, HEAD, OPTIONS'); + const post = await supertest(app).post('/mcp/server-card'); + expect(post.status).toBe(405); + expect(post.headers['allow']).toBe('GET, HEAD, OPTIONS'); + }); + + it('answers If-None-Match revalidation with 304', async () => { + const app = appWith({ card, mcpUrl }); + const first = await supertest(app).get('/mcp/server-card'); + const revalidated = await supertest(app).get('/mcp/server-card').set('If-None-Match', first.headers['etag']!); + expect(revalidated.status).toBe(304); + }); + + it('falls through for unmatched paths', async () => { + const response = await supertest(appWith({ card, mcpUrl })).get('/other'); + expect(response.status).toBe(200); + expect(response.text).toBe('fallthrough'); + }); +}); diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json index 5cf75f3ba6..28e12a7f77 100644 --- a/packages/middleware/express/tsconfig.json +++ b/packages/middleware/express/tsconfig.json @@ -7,12 +7,18 @@ "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/server/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/server/src/experimental/serverCard.ts" + ], "@modelcontextprotocol/core-internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" ], "@modelcontextprotocol/core/internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" ], + "@modelcontextprotocol/core/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ] diff --git a/packages/middleware/express/tsdown.config.ts b/packages/middleware/express/tsdown.config.ts index 5fb5f7b39b..16ed428439 100644 --- a/packages/middleware/express/tsdown.config.ts +++ b/packages/middleware/express/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ failOnWarn: 'ci-only', - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/experimental/serverCardRouter.ts'], format: ['esm', 'cjs'], fixedExtension: true, outDir: 'dist', diff --git a/packages/server/package.json b/packages/server/package.json index f8a17bb990..9fcdb742cf 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,6 +40,16 @@ "default": "./dist/stdio.cjs" } }, + "./experimental/server-card": { + "import": { + "types": "./dist/experimental/serverCard.d.mts", + "default": "./dist/experimental/serverCard.mjs" + }, + "require": { + "types": "./dist/experimental/serverCard.d.cts", + "default": "./dist/experimental/serverCard.cjs" + } + }, "./validators/ajv": { "import": { "types": "./dist/validators/ajv.d.mts", @@ -115,6 +125,9 @@ ], "stdio": [ "dist/stdio.d.mts" + ], + "experimental/server-card": [ + "dist/experimental/serverCard.d.mts" ] } }, diff --git a/packages/server/src/experimental/serverCard.examples.ts b/packages/server/src/experimental/serverCard.examples.ts new file mode 100644 index 0000000000..21174d7aa7 --- /dev/null +++ b/packages/server/src/experimental/serverCard.examples.ts @@ -0,0 +1,29 @@ +/** + * Type-checked examples for `serverCard.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { AICatalog, ServerCard } from './serverCard'; +import { aiCatalogResponse, serverCardResponse } from './serverCard'; + +/** + * Example: composing the card and catalog responders in front of an MCP + * handler in a web-standard fetch host. + */ +function serverCardResponse_fetchHandler( + card: ServerCard, + catalog: AICatalog, + mcpUrl: URL, + serveMcp: (request: Request) => Promise +) { + //#region serverCardResponse_fetchHandler + async function fetchHandler(request: Request): Promise { + return await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }) ?? serveMcp(request)); + } + //#endregion serverCardResponse_fetchHandler + return fetchHandler; +} diff --git a/packages/server/src/experimental/serverCard.ts b/packages/server/src/experimental/serverCard.ts new file mode 100644 index 0000000000..b54c631b59 --- /dev/null +++ b/packages/server/src/experimental/serverCard.ts @@ -0,0 +1,383 @@ +import type { + AICatalog, + AICatalogEntry, + AICatalogHost, + ServerCard, + ServerCardRemote, + ServerCardRepository +} from '@modelcontextprotocol/core/experimental/server-card'; +import { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_WELL_KNOWN_PATH, + AICatalogEntrySchema, + AICatalogSchema, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_PATH_SUFFIX, + SERVER_CARD_SCHEMA_URL, + ServerCardSchema +} from '@modelcontextprotocol/core/experimental/server-card'; +import type { Icon, Implementation } from '@modelcontextprotocol/core-internal'; + +// Experimental Server Card serving helpers (SEP-2127). Mirrors the +// build-then-respond shape of ../server/middleware/oauthMetadata.ts: pure +// builders that validate at startup, plus web-standard responders that match +// synchronously and fall through with `undefined`. + +/** + * Options for {@link buildServerCard}. + */ +export interface BuildServerCardOptions { + /** + * Server name in reverse-DNS format with exactly one slash, e.g. + * `'com.example/weather'`. Cannot be derived from anything else, so it is + * always explicit. + */ + name: string; + + /** + * Human-readable, capabilities-focused description. Required, 1 to 100 + * characters. + */ + description: string; + + /** + * Prefills `version`, `title`, `websiteUrl`, and `icons` from your + * server's `serverInfo` Implementation. Explicit options win over these + * prefills. + */ + serverInfo?: Implementation; + + /** + * Exact version string. Required unless `serverInfo` is given. Version + * ranges are rejected. + */ + version?: string; + + /** Display name, 1 to 100 characters. */ + title?: string; + + /** Homepage or documentation URL. */ + websiteUrl?: string; + + /** Icons the client can display. */ + icons?: Icon[]; + + /** Source repository metadata. */ + repository?: ServerCardRepository; + + /** + * Remote endpoints. These MUST reflect the real endpoints your server + * exposes; the spec requires the card to stay consistent with runtime + * behavior. + */ + remotes?: ServerCardRemote[]; + + /** Extension metadata, keys reverse-DNS prefixed. */ + _meta?: Record; +} + +/** + * Builds and validates a Server Card. `$schema` is always + * {@link SERVER_CARD_SCHEMA_URL} and is not an input, so a card built here can + * never be mis-pinned. Call this at startup: an invalid card is a boot error, + * never a broken production document. Throws `ZodError` on any constraint + * violation. + * + * Cards MUST NOT contain credentials, tokens, internal network topology, or + * private endpoints. Everything in a card is public. + */ +export function buildServerCard(options: BuildServerCardOptions): ServerCard { + const card: Record = { + $schema: SERVER_CARD_SCHEMA_URL, + name: options.name, + version: options.version ?? options.serverInfo?.version, + description: options.description, + title: options.title ?? options.serverInfo?.title, + websiteUrl: options.websiteUrl ?? options.serverInfo?.websiteUrl, + icons: options.icons ?? options.serverInfo?.icons, + repository: options.repository, + remotes: options.remotes, + _meta: options._meta + }; + for (const key of Object.keys(card)) { + if (card[key] === undefined) { + delete card[key]; + } + } + return ServerCardSchema.parse(card); +} + +/** + * Computes the reserved card location for a streamable HTTP endpoint: + * `'https://host/mcp'` becomes `'https://host/mcp/server-card'`. The suffix + * is appended to the MCP path, never to the origin. The location is derived + * from the origin and path only: any query string or fragment on `mcpUrl` is + * dropped. + */ +export function getServerCardUrl(mcpUrl: URL | string): string { + const url = new URL(mcpUrl); + const path = stripTrailingSlash(url.pathname); + url.pathname = `${path === '/' ? '' : path}${SERVER_CARD_PATH_SUFFIX}`; + url.search = ''; + url.hash = ''; + return url.href; +} + +/** + * Options for {@link serverCardResponse}. + */ +export interface ServerCardResponseOptions { + /** + * The card to serve, as given. Validate it once at startup with + * {@link buildServerCard}; the responder never re-validates per request. + */ + card: ServerCard; + + /** + * Public streamable HTTP endpoint of this server. The matched route is + * the path of `getServerCardUrl(mcpUrl)`. + */ + mcpUrl: URL | string; + + /** + * `Cache-Control` header value. Defaults to `'public, max-age=3600'` + * (spec SHOULD). Pass `false` to omit the header. + */ + cacheControl?: string | false; + + /** + * Serve a strong SHA-256 `ETag` and answer `If-None-Match` with `304`. + * Defaults to `true`. + */ + etag?: boolean; +} + +/** + * Serves a Server Card from a web-standard `fetch(request)` handler at the + * reserved `/server-card` location. + * + * Matching is synchronous: unmatched paths return `undefined` immediately so + * handlers compose under a single await. The bare MCP endpoint is never + * matched; its GET stays reserved for the SSE stream. + * + * Responses carry the spec-required permissive CORS headers, the + * `application/mcp-server-card+json` media type, a `Cache-Control` header, + * and a strong ETag with `If-None-Match` conditional handling. + * + * Under an exact mount like `app.all('/mcp', ...)`, `GET /mcp/server-card` + * never reaches the MCP handler. Compose this responder in front of it, or + * use `mcpServerCardRouter` from `@modelcontextprotocol/express`. + * + * @example + * ```ts source="./serverCard.examples.ts#serverCardResponse_fetchHandler" + * async function fetchHandler(request: Request): Promise { + * return await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }) ?? serveMcp(request)); + * } + * ``` + */ +export function serverCardResponse(request: Request, options: ServerCardResponseOptions): Promise | undefined { + const targetPath = stripTrailingSlash(new URL(getServerCardUrl(options.mcpUrl)).pathname); + const requestPath = stripTrailingSlash(new URL(request.url).pathname); + if (requestPath !== targetPath) { + return undefined; + } + return documentResponse(request, options.card, SERVER_CARD_MEDIA_TYPE, options); +} + +/** + * Options for {@link aiCatalogResponse}. + */ +export interface AICatalogResponseOptions { + /** + * The catalog to serve, as given. Validate it once at startup with + * {@link buildAICatalog}. + */ + catalog: AICatalog; + + /** + * Route to serve the catalog at. Defaults to + * `'/.well-known/ai-catalog.json'`. Best practice is to publish the + * catalog on the domain users associate with the service, which may not + * be the API host serving MCP traffic. + */ + path?: string; + + /** + * `Cache-Control` header value. Defaults to `'public, max-age=3600'`. + * Pass `false` to omit the header. + */ + cacheControl?: string | false; + + /** + * Serve a strong SHA-256 `ETag` and answer `If-None-Match` with `304`. + * Defaults to `true`. + */ + etag?: boolean; +} + +/** + * Serves an AI Catalog from a web-standard `fetch(request)` handler at + * `/.well-known/ai-catalog.json` (or `options.path`). Same matching, + * CORS, caching, and ETag behavior as {@link serverCardResponse}, with the + * `application/ai-catalog+json` media type. + */ +export function aiCatalogResponse(request: Request, options: AICatalogResponseOptions): Promise | undefined { + const targetPath = stripTrailingSlash(options.path ?? AI_CATALOG_WELL_KNOWN_PATH); + const requestPath = stripTrailingSlash(new URL(request.url).pathname); + if (requestPath !== targetPath) { + return undefined; + } + return documentResponse(request, options.catalog, AI_CATALOG_MEDIA_TYPE, options); +} + +/** + * Builds a validated AI Catalog entry for a card. + * + * The identifier is the spec's 4-segment URN + * `urn:air:{publisher}:mcp:{server}` where the publisher is the card name's + * reverse-DNS namespace re-reversed to a domain: `'com.example/weather'` + * becomes `'urn:air:example.com:mcp:weather'`. The entry type is always + * `application/mcp-server-card+json`. + * + * Pass `{ url }` for a hosted entry pointing at the card's URL, or + * `{ inline: true }` to embed the card itself as the entry's `data`. The + * entry deliberately does not duplicate the card's human-readable fields; + * per the spec, clients read `title` and `description` from the card. + * + * Throws `ZodError` when `card.name` is not the namespaced + * `{namespace}/{server-name}` form — only reachable with a hand-cast card + * that skipped {@link buildServerCard}. + */ +export function serverCardCatalogEntry(card: ServerCard, location: { url: URL | string } | { inline: true }): AICatalogEntry { + // A hand-cast card can bypass ServerCardSchema's name regex; re-validate + // here so a nameless namespace fails at boot instead of minting a + // garbage URN. + const name = ServerCardSchema.shape.name.parse(card.name); + const slash = name.indexOf('/'); + const namespace = name.slice(0, slash); + const serverName = name.slice(slash + 1); + const publisher = namespace.split('.').toReversed().join('.'); + return AICatalogEntrySchema.parse({ + identifier: `urn:air:${publisher}:mcp:${serverName}`, + type: SERVER_CARD_MEDIA_TYPE, + ...('url' in location ? { url: new URL(location.url).href } : { data: card }) + }); +} + +/** + * Builds and validates an AI Catalog document with `specVersion: '1.0'`. + * Throws `ZodError` on an invalid entry (for example one carrying both `url` + * and `data`). Call at startup, like {@link buildServerCard}. + */ +export function buildAICatalog(init: { entries: AICatalogEntry[]; host?: AICatalogHost }): AICatalog { + return AICatalogSchema.parse({ + specVersion: '1.0', + entries: init.entries, + ...(init.host === undefined ? {} : { host: init.host }) + }); +} + +const ALLOWED_METHODS = 'GET, HEAD, OPTIONS'; +const DEFAULT_CACHE_CONTROL = 'public, max-age=3600'; + +interface DocumentResponseOptions { + cacheControl?: string | false; + etag?: boolean; +} + +async function documentResponse( + request: Request, + document: object, + mediaType: string, + options: DocumentResponseOptions +): Promise { + // Discovery documents must be fetchable from web-based MCP clients on any + // origin, so every response carries permissive CORS headers (spec MUST). + if (request.method === 'OPTIONS') { + const requestedHeaders = request.headers.get('access-control-request-headers'); + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': ALLOWED_METHODS, + // The reflected allow-list makes the response vary by request + // (a client revalidating with If-None-Match preflights that + // header, which is not CORS-safelisted): without Vary a shared + // cache would replay one preflight's allow-list against + // another's headers. + ...(requestedHeaders === null + ? {} + : { 'Access-Control-Allow-Headers': requestedHeaders, Vary: 'Access-Control-Request-Headers' }) + } + }); + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return Response.json( + { error: 'method_not_allowed', error_description: `The method ${request.method} is not allowed for this endpoint` }, + { status: 405, headers: { Allow: ALLOWED_METHODS, 'Access-Control-Allow-Origin': '*' } } + ); + } + + const body = JSON.stringify(document); + const headers: Record = { 'Access-Control-Allow-Origin': '*' }; + const cacheControl = options.cacheControl ?? DEFAULT_CACHE_CONTROL; + if (cacheControl !== false) { + headers['Cache-Control'] = cacheControl; + } + + if (options.etag !== false) { + const etag = await strongEtagOf(body); + headers['ETag'] = etag; + if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { + return new Response(null, { status: 304, headers }); + } + } + + headers['Content-Type'] = mediaType; + // RFC 9110: HEAD is GET without the body, same headers. + return new Response(request.method === 'HEAD' ? null : body, { status: 200, headers }); +} + +/** Strong ETag: quoted hex SHA-256 of the exact body bytes. */ +async function strongEtagOf(body: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + const hex = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + return `"${hex}"`; +} + +/** + * RFC 9110 If-None-Match evaluation for a strong validator: `*` matches any + * representation; list members compare by exact validator; a weak `W/` entry + * never strong-matches. + */ +function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { + if (header === null) { + return false; + } + return header.split(',').some(candidate => { + const trimmed = candidate.trim(); + return trimmed === '*' || trimmed === etag; + }); +} + +function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +// Re-exported so server authors import everything from one module. Types and +// constants only; the Zod schemas stay on the core subpath. +export type { + AICatalog, + AICatalogEntry, + AICatalogHost, + ServerCard, + ServerCardRemote, + ServerCardRepository +} from '@modelcontextprotocol/core/experimental/server-card'; +export { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_WELL_KNOWN_PATH, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_PATH_SUFFIX, + SERVER_CARD_SCHEMA_URL +} from '@modelcontextprotocol/core/experimental/server-card'; diff --git a/packages/server/test/experimental/serverCard.test.ts b/packages/server/test/experimental/serverCard.test.ts new file mode 100644 index 0000000000..a856a41571 --- /dev/null +++ b/packages/server/test/experimental/serverCard.test.ts @@ -0,0 +1,277 @@ +import type { Implementation } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { AICatalog, ServerCard } from '../../src/experimental/serverCard'; +import { + AI_CATALOG_MEDIA_TYPE, + aiCatalogResponse, + buildAICatalog, + buildServerCard, + getServerCardUrl, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_SCHEMA_URL, + serverCardCatalogEntry, + serverCardResponse +} from '../../src/experimental/serverCard'; + +const mcpUrl = new URL('https://weather.example.com/mcp'); + +const card: ServerCard = buildServerCard({ + name: 'com.example/weather', + description: 'Hourly and 7-day forecasts for any coordinates', + version: '1.2.3', + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] +}); + +const catalog: AICatalog = buildAICatalog({ + entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] +}); + +describe('buildServerCard', () => { + it('pins $schema and validates the card', () => { + expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL); + expect(card.name).toBe('com.example/weather'); + }); + + it('prefills version, title, websiteUrl, and icons from serverInfo', () => { + const serverInfo: Implementation = { + name: 'weather', + version: '9.9.9', + title: 'Weather', + websiteUrl: 'https://example.com', + icons: [{ src: 'https://example.com/icon.png' }] + }; + const built = buildServerCard({ name: 'com.example/weather', description: 'Forecasts', serverInfo }); + expect(built.version).toBe('9.9.9'); + expect(built.title).toBe('Weather'); + expect(built.websiteUrl).toBe('https://example.com'); + expect(built.icons).toEqual([{ src: 'https://example.com/icon.png' }]); + }); + + it('prefers explicit options over serverInfo prefills', () => { + const serverInfo: Implementation = { name: 'weather', version: '9.9.9', title: 'Prefilled' }; + const built = buildServerCard({ + name: 'com.example/weather', + description: 'Forecasts', + serverInfo, + version: '1.0.0', + title: 'Explicit' + }); + expect(built.version).toBe('1.0.0'); + expect(built.title).toBe('Explicit'); + }); + + it('throws at build time on constraint violations', () => { + expect(() => buildServerCard({ name: 'no-slash', description: 'Forecasts', version: '1.0.0' })).toThrow(); + expect(() => buildServerCard({ name: 'com.example/weather', description: 'x'.repeat(101), version: '1.0.0' })).toThrow(); + expect(() => buildServerCard({ name: 'com.example/weather', description: 'Forecasts', version: '^1.2.3' })).toThrow(); + // No version anywhere: the composed parse rejects the missing field. + expect(() => buildServerCard({ name: 'com.example/weather', description: 'Forecasts' })).toThrow(); + }); +}); + +describe('getServerCardUrl', () => { + it('appends /server-card to the MCP path', () => { + expect(getServerCardUrl('https://weather.example.com/mcp')).toBe('https://weather.example.com/mcp/server-card'); + }); + + it('tolerates a trailing slash on the MCP URL', () => { + expect(getServerCardUrl('https://weather.example.com/mcp/')).toBe('https://weather.example.com/mcp/server-card'); + }); + + it('serves from the root path for a root MCP URL', () => { + expect(getServerCardUrl('https://weather.example.com/')).toBe('https://weather.example.com/server-card'); + }); +}); + +describe('serverCardResponse', () => { + it('returns undefined synchronously for unmatched paths and the bare MCP endpoint', () => { + expect(serverCardResponse(new Request('https://weather.example.com/mcp'), { card, mcpUrl })).toBeUndefined(); + expect(serverCardResponse(new Request('https://weather.example.com/other'), { card, mcpUrl })).toBeUndefined(); + }); + + it('serves the card with media type, CORS, and default Cache-Control', async () => { + const response = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { card, mcpUrl }); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe(SERVER_CARD_MEDIA_TYPE); + expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response!.headers.get('Cache-Control')).toBe('public, max-age=3600'); + expect(await response!.json()).toEqual(card); + }); + + it('tolerates a trailing slash on the request and on the MCP URL', async () => { + const withSlash = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card/'), { card, mcpUrl }); + expect(withSlash!.status).toBe(200); + const slashMcp = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { + card, + mcpUrl: 'https://weather.example.com/mcp/' + }); + expect(slashMcp!.status).toBe(200); + }); + + it('answers OPTIONS with 204, reflected headers, and Vary', async () => { + const response = await serverCardResponse( + new Request('https://weather.example.com/mcp/server-card', { + method: 'OPTIONS', + headers: { 'Access-Control-Request-Headers': 'if-none-match' } + }), + { card, mcpUrl } + ); + expect(response!.status).toBe(204); + expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response!.headers.get('Access-Control-Allow-Methods')).toBe('GET, HEAD, OPTIONS'); + expect(response!.headers.get('Access-Control-Allow-Headers')).toBe('if-none-match'); + expect(response!.headers.get('Vary')).toBe('Access-Control-Request-Headers'); + }); + + it('answers non-GET methods with 405 and an Allow header', async () => { + const response = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card', { method: 'POST' }), { + card, + mcpUrl + }); + expect(response!.status).toBe(405); + expect(response!.headers.get('Allow')).toBe('GET, HEAD, OPTIONS'); + expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await response!.json()).toMatchObject({ error: 'method_not_allowed' }); + }); + + it('answers HEAD with the same headers and no body', async () => { + const response = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card', { method: 'HEAD' }), { + card, + mcpUrl + }); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe(SERVER_CARD_MEDIA_TYPE); + expect(response!.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/); + expect(await response!.text()).toBe(''); + }); + + it('honors a custom Cache-Control and omits the header for false', async () => { + const custom = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { + card, + mcpUrl, + cacheControl: 'no-store' + }); + expect(custom!.headers.get('Cache-Control')).toBe('no-store'); + const omitted = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { + card, + mcpUrl, + cacheControl: false + }); + expect(omitted!.headers.get('Cache-Control')).toBeNull(); + }); + + it('serves a stable strong ETag across calls', async () => { + const first = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { card, mcpUrl }); + const second = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { card, mcpUrl }); + expect(first!.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/); + expect(first!.headers.get('ETag')).toBe(second!.headers.get('ETag')); + }); + + it('answers a matching If-None-Match with 304, keeping ETag, CORS, and Cache-Control', async () => { + const first = await serverCardResponse(new Request('https://weather.example.com/mcp/server-card'), { card, mcpUrl }); + const etag = first!.headers.get('ETag')!; + for (const headerValue of [etag, `"other", ${etag}`, '*']) { + const response = await serverCardResponse( + new Request('https://weather.example.com/mcp/server-card', { headers: { 'If-None-Match': headerValue } }), + { card, mcpUrl } + ); + expect(response!.status).toBe(304); + expect(response!.headers.get('ETag')).toBe(etag); + expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response!.headers.get('Cache-Control')).toBe('public, max-age=3600'); + expect(await response!.text()).toBe(''); + } + }); + + it('serves 200 for a non-matching If-None-Match and ignores conditionals with etag: false', async () => { + const miss = await serverCardResponse( + new Request('https://weather.example.com/mcp/server-card', { headers: { 'If-None-Match': '"nope"' } }), + { card, mcpUrl } + ); + expect(miss!.status).toBe(200); + const disabled = await serverCardResponse( + new Request('https://weather.example.com/mcp/server-card', { headers: { 'If-None-Match': '*' } }), + { card, mcpUrl, etag: false } + ); + expect(disabled!.status).toBe(200); + expect(disabled!.headers.get('ETag')).toBeNull(); + }); +}); + +describe('aiCatalogResponse', () => { + it('serves the catalog at the well-known path with its media type', async () => { + const response = await aiCatalogResponse(new Request('https://weather.example.com/.well-known/ai-catalog.json'), { catalog }); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe(AI_CATALOG_MEDIA_TYPE); + expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await response!.json()).toEqual(catalog); + }); + + it('honors a custom path and falls through elsewhere', async () => { + const custom = await aiCatalogResponse(new Request('https://weather.example.com/catalog.json'), { + catalog, + path: '/catalog.json' + }); + expect(custom!.status).toBe(200); + expect( + aiCatalogResponse(new Request('https://weather.example.com/.well-known/ai-catalog.json'), { catalog, path: '/catalog.json' }) + ).toBeUndefined(); + expect(aiCatalogResponse(new Request('https://weather.example.com/elsewhere'), { catalog })).toBeUndefined(); + }); + + it('supports conditional requests like the card responder', async () => { + const first = await aiCatalogResponse(new Request('https://weather.example.com/.well-known/ai-catalog.json'), { catalog }); + const etag = first!.headers.get('ETag')!; + const revalidated = await aiCatalogResponse( + new Request('https://weather.example.com/.well-known/ai-catalog.json', { headers: { 'If-None-Match': etag } }), + { catalog } + ); + expect(revalidated!.status).toBe(304); + }); +}); + +describe('serverCardCatalogEntry', () => { + it('derives the 4-segment URN from the reverse-DNS card name', () => { + const entry = serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) }); + expect(entry.identifier).toBe('urn:air:example.com:mcp:weather'); + expect(entry.type).toBe(SERVER_CARD_MEDIA_TYPE); + expect(entry.url).toBe('https://weather.example.com/mcp/server-card'); + expect(entry.data).toBeUndefined(); + }); + + it('embeds the card as data for inline entries', () => { + const entry = serverCardCatalogEntry(card, { inline: true }); + expect(entry.data).toEqual(card); + expect(entry.url).toBeUndefined(); + }); + + it('does not duplicate the card title or description onto the entry', () => { + const entry = serverCardCatalogEntry(card, { inline: true }); + expect(entry.displayName).toBeUndefined(); + expect(entry.description).toBeUndefined(); + }); + + it('rejects a hand-cast card whose name is not namespaced instead of minting a garbage URN', () => { + const handCast = { ...card, name: 'weather' } as ServerCard; + expect(() => serverCardCatalogEntry(handCast, { inline: true })).toThrow(z.ZodError); + }); +}); + +describe('buildAICatalog', () => { + it('sets specVersion 1.0 and validates entries', () => { + expect(catalog.specVersion).toBe('1.0'); + expect(catalog.entries).toHaveLength(1); + expect(() => + buildAICatalog({ + entries: [{ identifier: 'urn:air:example.com:mcp:weather', type: SERVER_CARD_MEDIA_TYPE, url: 'https://x', data: {} }] + }) + ).toThrow(); + }); + + it('carries host information through', () => { + const withHost = buildAICatalog({ entries: [], host: { displayName: 'Example' } }); + expect(withHost.host).toEqual({ displayName: 'Example' }); + }); +}); diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index e248f24265..52ba9934cc 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -23,7 +23,9 @@ function chunkImportsOf(entryPath: string): string[] { if (visited.has(file)) continue; visited.add(file); const src = readFileSync(file, 'utf8'); - for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + // Follow ./ and ../ relative chunk imports: subpath entries in nested + // dist directories import shared chunks from parent directories. + for (const m of src.matchAll(/from\s+["'](\.\.?\/.+?\.mjs)["']/g)) { queue.push(join(dirname(file), m[1]!)); } } @@ -100,3 +102,32 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { } }); }); + +describe('@modelcontextprotocol/server experimental server-card subpath', () => { + beforeAll(async () => { + await ensureBuilt(pkgDir); + }, 180_000); + + test('dist/experimental/serverCard.mjs exists, exports the responders, and stays runtime-neutral', () => { + const entry = join(distDir, 'experimental/serverCard.mjs'); + const content = readFileSync(entry, 'utf8'); + expect(content).toMatch(/\bserverCardResponse\b/); + expect(content).toMatch(/\baiCatalogResponse\b/); + expect(content).toMatch(/\bbuildServerCard\b/); + expect(content).not.toMatch(NODE_ONLY); + for (const chunk of chunkImportsOf(entry)) { + expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) + ); + } + }); + + test('root declarations do not advertise the experimental server-card exports', () => { + for (const declaration of ['index.d.mts', 'index.d.cts']) { + const rootExportBlock = rootExportBlockOf(readFileSync(join(distDir, declaration), 'utf8')); + for (const symbol of ['serverCardResponse', 'aiCatalogResponse', 'buildServerCard', 'buildAICatalog']) { + expect(rootExportBlock).not.toMatch(new RegExp(`\\b(?:type\\s+)?${symbol}\\b`)); + } + } + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 184ab7a899..3dfd1f2457 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -7,6 +7,9 @@ "*": ["./*"], "@modelcontextprotocol/core-internal": ["./node_modules/@modelcontextprotocol/core-internal/src/index.ts"], "@modelcontextprotocol/core/internal": ["./node_modules/@modelcontextprotocol/core/src/internal.ts"], + "@modelcontextprotocol/core/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/core/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 88004cfc06..ce136a94be 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/ajv.ts', - 'src/validators/cfWorker.ts' + 'src/validators/cfWorker.ts', + 'src/experimental/serverCard.ts' ], format: ['esm', 'cjs'], fixedExtension: true, @@ -29,7 +30,8 @@ export default defineConfig({ '@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'], '@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'], '@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'], - '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'] + '@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts'], + '@modelcontextprotocol/core/experimental/server-card': ['../core/src/experimental/serverCard/index.ts'] } } }, @@ -37,5 +39,10 @@ export default defineConfig({ // The schema modules live in @modelcontextprotocol/core (a real runtime dependency); the // bundled core-internal shims import them via the './internal' subpath, which must stay an // external import (explicit entry — the tsconfig paths alias would otherwise inline it). - external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/core/internal'] + // The experimental server-card schemas follow the same rule. + external: [ + '@modelcontextprotocol/server/_shims', + '@modelcontextprotocol/core/internal', + '@modelcontextprotocol/core/experimental/server-card' + ] }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f5941b410..e682a7fa0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -996,6 +996,28 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/server-card-discovery: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/server-quickstart: dependencies: '@modelcontextprotocol/server': diff --git a/test/integration/test/server/serverCardDiscovery.test.ts b/test/integration/test/server/serverCardDiscovery.test.ts new file mode 100644 index 0000000000..719fef9ecf --- /dev/null +++ b/test/integration/test/server/serverCardDiscovery.test.ts @@ -0,0 +1,99 @@ +/** + * Server Card discovery end to end: the card and catalog responders composed + * in front of createMcpHandler on a real HTTP server, walked by + * discoverServerCards, resolved with resolveRemote, connected with a real + * client, and reconciled against the live serverInfo. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { discoverServerCards, reconcileServerCard, resolveRemote } from '@modelcontextprotocol/client/experimental/server-card'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { + aiCatalogResponse, + buildAICatalog, + buildServerCard, + getServerCardUrl, + serverCardCatalogEntry, + serverCardResponse +} from '@modelcontextprotocol/server/experimental/server-card'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe('Server Card discovery against a live MCP endpoint', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startEndpoint(): Promise<{ baseUrl: URL }> { + const serverInfo = { name: 'com.example/weather', version: '1.2.3' }; + const factory = () => new McpServer(serverInfo, { capabilities: { tools: {} } }); + const handler = createMcpHandler(factory); + + // The card is built lazily on the first request, once the port is known. + let documents: { card: ReturnType; catalog: ReturnType; mcpUrl: URL } | undefined; + const fetchHandler = async (request: Request): Promise => { + if (documents === undefined) { + const mcpUrl = new URL('/mcp', new URL(request.url).origin); + const card = buildServerCard({ + name: 'com.example/weather', + description: 'Hourly and 7-day forecasts for any coordinates', + serverInfo, + remotes: [{ type: 'streamable-http', url: mcpUrl.href }] + }); + const catalog = buildAICatalog({ entries: [serverCardCatalogEntry(card, { url: getServerCardUrl(mcpUrl) })] }); + documents = { card, catalog, mcpUrl }; + } + return await (serverCardResponse(request, { card: documents.card, mcpUrl: documents.mcpUrl }) ?? + aiCatalogResponse(request, { catalog: documents.catalog }) ?? + handler.fetch(request)); + }; + + const httpServer: HttpServer = createServer(toNodeHandler({ fetch: fetchHandler })); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + return { baseUrl }; + } + + it('discovers, resolves, connects, and reconciles', async () => { + const { baseUrl } = await startEndpoint(); + // Loopback endpoint: the local-dev hosts are always exempt from the + // hardened defaults, so no overrides are needed. + const hits = await discoverServerCards(baseUrl); + expect(hits).toHaveLength(1); + const hit = hits[0]!; + expect(hit.listingDomain).toBe(baseUrl.host); + expect(hit.hostingDomain).toBe(baseUrl.host); + expect(hit.card.name).toBe('com.example/weather'); + + const remote = hit.card.remotes![0]!; + const resolved = resolveRemote(remote); + expect(resolved.url.href).toBe(new URL('/mcp', baseUrl).href); + + const client = new Client({ name: 'discovery-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(resolved.url, { requestInit: { headers: resolved.headers } })); + cleanups.push(() => client.close()); + + const serverInfo = client.getServerVersion(); + expect(serverInfo).toBeDefined(); + expect(reconcileServerCard(hit.card, serverInfo!, { remote })).toEqual([]); + }); + + it('returns [] for a host without a catalog', async () => { + const httpServer: HttpServer = createServer((_req, res) => { + res.statusCode = 404; + res.end(); + }); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(() => { + httpServer.close(); + }); + await expect(discoverServerCards(baseUrl)).resolves.toEqual([]); + }); +}); diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index 2b016fd288..77cfdf31ba 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -18,13 +18,22 @@ "@modelcontextprotocol/core-internal/validators/cfWorker": [ "./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts" ], + "@modelcontextprotocol/core/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/core-internal/node_modules/@modelcontextprotocol/core/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/client/src/experimental/serverCard/index.ts" + ], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/client/validators/ajv": ["./node_modules/@modelcontextprotocol/client/src/validators/ajv.ts"], "@modelcontextprotocol/client/validators/cf-worker": ["./node_modules/@modelcontextprotocol/client/src/validators/cfWorker.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/experimental/server-card": [ + "./node_modules/@modelcontextprotocol/server/src/experimental/serverCard.ts" + ], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"],