Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/experimental-server-cards.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
138 changes: 138 additions & 0 deletions docs/advanced/server-cards.md
Original file line number Diff line number Diff line change
@@ -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<Response> {
return await (serverCardResponse(request, { card, mcpUrl }) ?? aiCatalogResponse(request, { catalog }) ?? handler.fetch(request));
}
```

`serverCardResponse` answers `GET <mcp-path>/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.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
114 changes: 114 additions & 0 deletions examples/guides/advanced/server-cards.examples.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Response> => 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;
}
17 changes: 17 additions & 0 deletions examples/server-card-discovery/README.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions examples/server-card-discovery/client.ts
Original file line number Diff line number Diff line change
@@ -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 <N>` first, then
* `client.ts --http http://127.0.0.1:<N>/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:<port>/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();
26 changes: 26 additions & 0 deletions examples/server-card-discovery/package.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
Loading
Loading