From 71593330d9f34957523c2352b58dc4ac0231623d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:38:33 +0000 Subject: [PATCH 01/10] feat(core): add experimental Server Card and AI Catalog schemas (SEP-2127) New public subpath @modelcontextprotocol/core/experimental/server-card: Zod v4 schemas hand-ported from the extension repo's authoritative schema.ts, inferred types, and the five wire constants. Objects are open (looseObject), IconSchema is reused from the core spec schemas, and the schema stays strict (missing/wrong $schema rejected) so the vendored conformance fixtures round-trip verbatim. Version ranges are rejected at the validator level per spec prose. --- packages/core/package.json | 13 + .../src/experimental/serverCard/catalog.ts | 139 ++++++++++ .../src/experimental/serverCard/constants.ts | 37 +++ .../core/src/experimental/serverCard/index.ts | 29 +++ .../src/experimental/serverCard/schema.ts | 242 ++++++++++++++++++ .../core/test/experimental/aiCatalog.test.ts | 52 ++++ .../fixtures/server-card/README.md | 8 + .../server-card/invalid/bad-name-pattern.json | 6 + .../invalid/date-versioned-schema.json | 6 + .../server-card/invalid/missing-name.json | 5 + .../server-card/invalid/missing-schema.json | 5 + .../invalid/wrong-schema-name.json | 6 + .../fixtures/server-card/valid/minimal.json | 6 + .../server-card/valid/templated-remote.json | 38 +++ .../core/test/experimental/serverCard.test.ts | 106 ++++++++ packages/core/tsdown.config.ts | 2 +- 16 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/experimental/serverCard/catalog.ts create mode 100644 packages/core/src/experimental/serverCard/constants.ts create mode 100644 packages/core/src/experimental/serverCard/index.ts create mode 100644 packages/core/src/experimental/serverCard/schema.ts create mode 100644 packages/core/test/experimental/aiCatalog.test.ts create mode 100644 packages/core/test/experimental/fixtures/server-card/README.md create mode 100644 packages/core/test/experimental/fixtures/server-card/invalid/bad-name-pattern.json create mode 100644 packages/core/test/experimental/fixtures/server-card/invalid/date-versioned-schema.json create mode 100644 packages/core/test/experimental/fixtures/server-card/invalid/missing-name.json create mode 100644 packages/core/test/experimental/fixtures/server-card/invalid/missing-schema.json create mode 100644 packages/core/test/experimental/fixtures/server-card/invalid/wrong-schema-name.json create mode 100644 packages/core/test/experimental/fixtures/server-card/valid/minimal.json create mode 100644 packages/core/test/experimental/fixtures/server-card/valid/templated-remote.json create mode 100644 packages/core/test/experimental/serverCard.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 2cd99bc56e..77c774a704 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,12 +40,25 @@ "types": "./dist/internal.d.cts", "default": "./dist/internal.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" + } } }, "typesVersions": { "*": { "internal": [ "dist/internal.d.mts" + ], + "experimental/server-card": [ + "dist/experimental/serverCard/index.d.mts" ] } }, diff --git a/packages/core/src/experimental/serverCard/catalog.ts b/packages/core/src/experimental/serverCard/catalog.ts new file mode 100644 index 0000000000..2c597d0d00 --- /dev/null +++ b/packages/core/src/experimental/serverCard/catalog.ts @@ -0,0 +1,139 @@ +import * as z from 'zod/v4'; + +// AI Catalog schemas (external Agent-Card spec, https://ai-catalog.io/spec/). +// Modeled only as deep as Server Card discovery needs: entry identity, the +// url/data carrier, and display metadata. Host, publisher, and trust-manifest +// shapes stay loose records so catalog-spec churn cannot break parsing. + +/** + * One entry in an AI Catalog. For MCP Server Card discovery the relevant + * entries have `type: 'application/mcp-server-card+json'` and carry the card + * either by `url` (fetch it there) or inline as `data` — exactly one of the + * two. + * + * `identifier`, `publisher`, and `trustManifest` are self-asserted by the + * catalog publisher and unverified. + */ +export const AICatalogEntrySchema = z + .looseObject({ + /** + * Unique identifier for the entry. The recommended form is the + * domain-anchored URN `urn:air:{publisher}:{namespace}:{name}`, e.g. + * `urn:air:example.com:mcp:weather`. + */ + identifier: z.string(), + /** + * Media type of the referenced artifact, e.g. + * `application/mcp-server-card+json`. + */ + type: z.string(), + /** + * URL where the artifact can be fetched. Exactly one of `url` or + * `data` must be present. + */ + url: z.string().optional(), + /** + * Inline artifact content. Exactly one of `url` or `data` must be + * present. + */ + data: z.unknown().optional(), + /** + * Display name for the entry. Takes precedence over the artifact's + * internal name for display purposes. + */ + displayName: z.string().optional(), + /** + * Human-readable description of the entry. + */ + description: z.string().optional(), + /** + * Version of the referenced artifact. + */ + version: z.string().optional(), + /** + * ISO 8601 timestamp of the entry's last update. + */ + updatedAt: z.string().optional(), + /** + * Free-form tags for the entry. + */ + tags: z.array(z.string()).optional(), + /** + * Self-asserted publisher identity for the entry. + */ + publisher: z + .looseObject({ + identifier: z.string().optional(), + displayName: z.string().optional() + }) + .optional(), + /** + * Self-asserted trust manifest (publisher identity, attestations, + * provenance). Kept loose; verification is host policy. + */ + trustManifest: z.record(z.string(), z.unknown()).optional(), + /** + * Extension metadata, keys reverse-DNS prefixed. + */ + metadata: z.record(z.string(), z.unknown()).optional() + }) + .refine(entry => (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..93c7611474 --- /dev/null +++ b/packages/core/test/experimental/fixtures/server-card/README.md @@ -0,0 +1,8 @@ +# Server Card conformance fixtures + +Vendored verbatim from the `modelcontextprotocol/experimental-ext-server-card` +repository, `examples/ServerCard/`, at commit +`1491bd24714c885d5bc6b3bf0857094beff939fd`. + +Do not edit these by hand. Refresh them by copying from the spec repository +when the schema changes, and update 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', From 2cfbb6ba476f364a05e3e24196bb531ed2dd7a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:38:50 +0000 Subject: [PATCH 02/10] feat(server): serve Server Cards and AI Catalogs from web-standard hosts New public subpath @modelcontextprotocol/server/experimental/server-card: buildServerCard/buildAICatalog validate at startup, getServerCardUrl computes the reserved /server-card location, serverCardCatalogEntry derives the 4-segment urn:air identifier, and serverCardResponse/aiCatalogResponse answer GET/HEAD/OPTIONS with permissive CORS, Cache-Control, and a strong SHA-256 ETag with If-None-Match 304 handling. Matching is synchronous and unmatched paths fall through as undefined, mirroring oauthMetadataResponse. --- packages/server/package.json | 13 + .../src/experimental/serverCard.examples.ts | 29 ++ .../server/src/experimental/serverCard.ts | 373 ++++++++++++++++++ .../test/experimental/serverCard.test.ts | 271 +++++++++++++ .../server/test/server/barrelClean.test.ts | 29 ++ packages/server/tsconfig.json | 3 + packages/server/tsdown.config.ts | 13 +- 7 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/experimental/serverCard.examples.ts create mode 100644 packages/server/src/experimental/serverCard.ts create mode 100644 packages/server/test/experimental/serverCard.test.ts 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..737dc14f25 --- /dev/null +++ b/packages/server/src/experimental/serverCard.ts @@ -0,0 +1,373 @@ +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. + */ +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. + */ +export function serverCardCatalogEntry(card: ServerCard, location: { url: URL | string } | { inline: true }): AICatalogEntry { + const slash = card.name.indexOf('/'); + const namespace = card.name.slice(0, slash); + const serverName = card.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..7ed2668b87 --- /dev/null +++ b/packages/server/test/experimental/serverCard.test.ts @@ -0,0 +1,271 @@ +import type { Implementation } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +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(); + }); +}); + +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..d1527b40c0 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -100,3 +100,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' + ] }); From dbc330fab7dd95af1ba171c279d8ba4d3d63c35b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:38:50 +0000 Subject: [PATCH 03/10] feat(client): hardened Server Card discovery, resolution, and reconciliation New public subpath @modelcontextprotocol/client/experimental/server-card: fetchServerCard/fetchAICatalog send credential-free GETs with the canonical Accept type, enforce HTTPS-only and private-address guards on every redirect hop, cap response size and redirect count, verify the media type essence, apply lenient missing-$schema ingestion, and return etag/cacheControl so the caller owns the cache. discoverServerCards walks the well-known AI Catalog (404/410 is an empty cacheable miss) with listing-chain provenance and per-entry error reporting. requiredRemoteInputs/resolveRemote handle the {var} template grammar with one aggregated missing-input error, and reconcileServerCard diffs card claims against the live serverInfo without ever throwing. --- packages/client/package.json | 13 + .../src/experimental/serverCard.examples.ts | 26 ++ .../src/experimental/serverCard/discover.ts | 130 ++++++++++ .../src/experimental/serverCard/errors.ts | 65 +++++ .../src/experimental/serverCard/fetch.ts | 199 +++++++++++++++ .../src/experimental/serverCard/guard.ts | 233 ++++++++++++++++++ .../src/experimental/serverCard/index.ts | 39 +++ .../src/experimental/serverCard/reconcile.ts | 63 +++++ .../src/experimental/serverCard/resolve.ts | 177 +++++++++++++ .../client/test/client/barrelClean.test.ts | 29 +++ .../client/test/experimental/discover.test.ts | 129 ++++++++++ .../client/test/experimental/fetch.test.ts | 174 +++++++++++++ .../client/test/experimental/guard.test.ts | 68 +++++ .../client/test/experimental/mockFetch.ts | 32 +++ .../test/experimental/reconcile.test.ts | 66 +++++ .../client/test/experimental/resolve.test.ts | 142 +++++++++++ packages/client/tsconfig.json | 3 + packages/client/tsdown.config.ts | 13 +- 18 files changed, 1598 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/experimental/serverCard.examples.ts create mode 100644 packages/client/src/experimental/serverCard/discover.ts create mode 100644 packages/client/src/experimental/serverCard/errors.ts create mode 100644 packages/client/src/experimental/serverCard/fetch.ts create mode 100644 packages/client/src/experimental/serverCard/guard.ts create mode 100644 packages/client/src/experimental/serverCard/index.ts create mode 100644 packages/client/src/experimental/serverCard/reconcile.ts create mode 100644 packages/client/src/experimental/serverCard/resolve.ts create mode 100644 packages/client/test/experimental/discover.test.ts create mode 100644 packages/client/test/experimental/fetch.test.ts create mode 100644 packages/client/test/experimental/guard.test.ts create mode 100644 packages/client/test/experimental/mockFetch.ts create mode 100644 packages/client/test/experimental/reconcile.test.ts create mode 100644 packages/client/test/experimental/resolve.test.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..d8f7e0d3d8 --- /dev/null +++ b/packages/client/src/experimental/serverCard/discover.ts @@ -0,0 +1,130 @@ +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. + * 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 { + fetched = await fetchAICatalog(catalogUrl, options); + } 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) { + const entryError = + error instanceof ServerCardError + ? error + : new ServerCardError('invalid-server-card', `Catalog entry ${entry.identifier} could not be processed`, { + 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..128ac853a5 --- /dev/null +++ b/packages/client/src/experimental/serverCard/errors.ts @@ -0,0 +1,65 @@ +import type { ServerCardInput } from '@modelcontextprotocol/core/experimental/server-card'; + +/** + * Error codes for the Server Card discovery helpers. + */ +export type ServerCardErrorCode = + /** URL guard rejection (scheme or address class), on any redirect hop. */ + | 'blocked-host' + /** 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). */ + | 'invalid-server-card' + /** An AI Catalog document failed validation (`cause` is the ZodError). */ + | '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..539231a029 --- /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('blocked-host', `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..1e21f8ffdb --- /dev/null +++ b/packages/client/src/experimental/serverCard/guard.ts @@ -0,0 +1,233 @@ +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`. 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 + ); +} + +/** Decode an IPv4-mapped IPv6 host (`::ffff:a.b.c.d` or `::ffff:xxyy:zzww`). */ +function mappedIpv4Of(ipv6: string): [number, number, number, number] | undefined { + const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(ipv6); + if (dotted) { + return parseIpv4(dotted[1]!); + } + const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ipv6); + if (hex) { + const high = Number.parseInt(hex[1]!, 16); + const low = Number.parseInt(hex[2]!, 16); + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; + } + return undefined; +} + +function isPrivateIpv6(host: string): boolean { + const ipv6 = host.slice(1, -1).toLowerCase(); + if (ipv6 === '::1' || ipv6 === '::') { + return true; + } + if (/^fe[89ab]/.test(ipv6)) { + return true; // fe80::/10 link-local + } + if (/^f[cd]/.test(ipv6)) { + return true; // fc00::/7 unique-local + } + const mapped = mappedIpv4Of(ipv6); + return mapped !== undefined && isPrivateIpv4(mapped); +} + +/** + * 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 (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 !== 'localhost' && !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. + */ +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..91681a9077 --- /dev/null +++ b/packages/client/src/experimental/serverCard/reconcile.ts @@ -0,0 +1,63 @@ +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. + * + * 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..102ba43bf8 --- /dev/null +++ b/packages/client/src/experimental/serverCard/resolve.ts @@ -0,0 +1,177 @@ +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 each header's + * nested `variables`. Keys share one flat namespace (variable names plus + * header names), matching what {@link resolveRemote} reads from `inputs`. + */ +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) { + requirements.push({ key: header.name, path: `headers.${header.name}`, input: header, required: header.isRequired === true }); + } + 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..d5202ff169 100644 --- a/packages/client/test/client/barrelClean.test.ts +++ b/packages/client/test/client/barrelClean.test.ts @@ -96,3 +96,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..94878ee454 --- /dev/null +++ b/packages/client/test/experimental/discover.test.ts @@ -0,0 +1,129 @@ +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('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..426355feee --- /dev/null +++ b/packages/client/test/experimental/fetch.test.ts @@ -0,0 +1,174 @@ +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 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..c2d164c5ca --- /dev/null +++ b/packages/client/test/experimental/guard.test.ts @@ -0,0 +1,68 @@ +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(); + expect(codeOf(() => assertAllowedUrl(new URL('http://localhost:3000/x'), {}))).toBeUndefined(); + expect(codeOf(() => assertAllowedUrl(new URL('http://127.0.0.1:3000/x'), { allowPrivateHosts: true }))).toBeUndefined(); + expect(codeOf(() => assertAllowedUrl(new URL('http://[::1]:3000/x'), { allowPrivateHosts: true }))).toBeUndefined(); + }); + + it.each([ + 'https://0.0.0.0/x', + 'https://10.1.2.3/x', + 'https://127.0.0.1/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://[::1]/x', + 'https://[fe80::1]/x', + 'https://[fd00::1]/x', + 'https://[::ffff:127.0.0.1]/x', + 'https://[::ffff:10.0.0.1]/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'])( + '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..f969db60ad --- /dev/null +++ b/packages/client/test/experimental/resolve.test.ts @@ -0,0 +1,142 @@ +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('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' + ] }); From 063859f3af6faabaf10f9cefb5616c0ee2b9b4de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:39:08 +0000 Subject: [PATCH 04/10] feat(express): mcpServerCardRouter adapter for the Server Card responders New subpath @modelcontextprotocol/express/experimental/server-card: a thin Router that converts the Express request to a fetch Request, delegates to serverCardResponse/aiCatalogResponse, and writes the Response back. No MCP functionality; unmatched paths call next(). --- packages/middleware/express/package.json | 17 ++++ .../src/experimental/serverCardRouter.ts | 85 +++++++++++++++++++ .../express/test/serverCardRouter.test.ts | 74 ++++++++++++++++ packages/middleware/express/tsconfig.json | 6 ++ packages/middleware/express/tsdown.config.ts | 2 +- 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/middleware/express/src/experimental/serverCardRouter.ts create mode 100644 packages/middleware/express/test/serverCardRouter.test.ts 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', From cde094154a4b3fe50fdfa6a732e26af26bc2c5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:39:08 +0000 Subject: [PATCH 05/10] test: pin the new experimental export-map keys and add a discovery integration test The topology pins gain the ./experimental/server-card entries on core, client, server, and express. The integration workspace composes the card and catalog responders in front of createMcpHandler on a real HTTP server, then discovers, resolves, connects, and reconciles with the public client helpers. --- .../test/packageTopologyPins.test.ts | 13 ++- .../test/server/serverCardDiscovery.test.ts | 98 +++++++++++++++++++ test/integration/tsconfig.json | 9 ++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 test/integration/test/server/serverCardDiscovery.test.ts 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 { + 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 overrides are required and exercised. + const hits = await discoverServerCards(baseUrl, { allowHttp: true, allowPrivateHosts: true }); + 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, { allowHttp: true, allowPrivateHosts: true })).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"], From 97134473cc77909eba04b4c2a2796a57506537f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:39:08 +0000 Subject: [PATCH 06/10] docs: Server Cards guide page with a runnable snippet companion docs/advanced/server-cards.md covers serving (fetch-API and Express, including the exact-mount caveat), domain discovery with listing-chain consent guidance, input resolution and reconciliation, and caller-owned ETag caching. All fences sync from examples/guides/advanced/server-cards.examples.ts, which runs the whole flow in process and produces the quoted output. --- docs/.vitepress/nav.ts | 3 +- docs/advanced/server-cards.md | 138 ++++++++++++++++++ .../guides/advanced/server-cards.examples.ts | 114 +++++++++++++++ examples/tsconfig.json | 12 ++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 docs/advanced/server-cards.md create mode 100644 examples/guides/advanced/server-cards.examples.ts 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..2d34c6c838 --- /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}`); +} +``` + +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. These are hostname-level checks: inject a DNS-pinning `fetch` through the `fetch` option to defend against DNS rebinding, and pass `allowHttp`/`allowPrivateHosts` for local development. +::: + +## 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/guides/advanced/server-cards.examples.ts b/examples/guides/advanced/server-cards.examples.ts new file mode 100644 index 0000000000..9d2ed1604b --- /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}`); +} +//#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/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"], From 90b5773c7837029cbaf3b2eb39450fdf5a71b61a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:39:08 +0000 Subject: [PATCH 07/10] examples: server-card-discovery story HTTP-only self-verifying pair: the server exposes an MCP endpoint plus its card and AI Catalog; the client is told only the origin, probes the well-known catalog, validates and resolves the card, connects, calls a tool, and reconciles the card against the live serverInfo. --- examples/README.md | 1 + examples/server-card-discovery/README.md | 17 ++++++ examples/server-card-discovery/client.ts | 44 +++++++++++++++ examples/server-card-discovery/package.json | 26 +++++++++ examples/server-card-discovery/server.ts | 61 +++++++++++++++++++++ pnpm-lock.yaml | 22 ++++++++ 6 files changed, 171 insertions(+) create mode 100644 examples/server-card-discovery/README.md create mode 100644 examples/server-card-discovery/client.ts create mode 100644 examples/server-card-discovery/package.json create mode 100644 examples/server-card-discovery/server.ts 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/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..f047451f89 --- /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. Loopback needs the explicit local-dev overrides; the hardened +// defaults reject plain HTTP and private addresses. +const origin = new URL(url).origin; +const hits = await discoverServerCards(origin, { allowHttp: true, allowPrivateHosts: true }); +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})`); +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/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': From fa62c778e8194db2cdbe2abf68ce2ccffdde3e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:39:09 +0000 Subject: [PATCH 08/10] changeset: experimental Server Card extension support Minor for the fixed group (core, client, server), patch for express. Additive experimental subpaths only; no migration-guide entry needed. --- .changeset/experimental-server-cards.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/experimental-server-cards.md 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. From eb8e4785c139e29b213ba890e6f8380a508168c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 07:43:37 +0000 Subject: [PATCH 09/10] fix(client): correct Server Card discovery guard and error semantics - apply discoverServerCards' maxEntries after the card-type filter, so non-card entries in a mixed catalog can no longer silently starve discovery of every Server Card - always exempt the local-dev hosts (localhost, 127.0.0.1, [::1]) from the private-address guard, matching the allowHttp JSDoc and the extension's local-dev policy; drop the now-unneeded overrides from the integration test and example story - block NAT64 (64:ff9b::/96), 6to4 (2002::/16), and IPv4-compatible IPv6 embeddings of private/metadata IPv4 targets in the SSRF guard, via a full 8-word IPv6 expansion that fails closed - report unparseable discovery URLs as 'invalid-url' and per-entry transport failures as 'network-error' instead of misfiling them under 'blocked-host'/'invalid-server-card'; document the SyntaxError cause for non-JSON bodies on the two validation codes - stop listing nested variables of valueless headers in requiredRemoteInputs: resolveRemote never reads them, so a consent UI would prompt for inputs that are then ignored - print 'inline' instead of undefined for inline entries in the docs guide snippet and the discovery example story --- docs/advanced/server-cards.md | 4 +- .../guides/advanced/server-cards.examples.ts | 2 +- examples/server-card-discovery/client.ts | 8 +- .../src/experimental/serverCard/discover.ts | 17 ++- .../src/experimental/serverCard/errors.ts | 17 ++- .../src/experimental/serverCard/fetch.ts | 2 +- .../src/experimental/serverCard/guard.ts | 100 ++++++++++++++---- .../src/experimental/serverCard/reconcile.ts | 6 ++ .../src/experimental/serverCard/resolve.ts | 15 ++- .../client/test/experimental/discover.test.ts | 46 ++++++++ .../client/test/experimental/fetch.test.ts | 7 ++ .../client/test/experimental/guard.test.ts | 42 +++++--- .../client/test/experimental/resolve.test.ts | 11 ++ .../test/server/serverCardDiscovery.test.ts | 7 +- 14 files changed, 232 insertions(+), 52 deletions(-) diff --git a/docs/advanced/server-cards.md b/docs/advanced/server-cards.md index 2d34c6c838..cbd0d0c7f9 100644 --- a/docs/advanced/server-cards.md +++ b/docs/advanced/server-cards.md @@ -69,7 +69,7 @@ app.all('/mcp', mcpHandler); // the exact mount never sees GET /mcp/server-card; ```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}`); + console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); } ``` @@ -82,7 +82,7 @@ urn:air:example.com:mcp:weather: listed by weather.example.com, hosted by weathe `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. These are hostname-level checks: inject a DNS-pinning `fetch` through the `fetch` option to defend against DNS rebinding, and pass `allowHttp`/`allowPrivateHosts` for local development. +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 diff --git a/examples/guides/advanced/server-cards.examples.ts b/examples/guides/advanced/server-cards.examples.ts index 9d2ed1604b..deeb599b00 100644 --- a/examples/guides/advanced/server-cards.examples.ts +++ b/examples/guides/advanced/server-cards.examples.ts @@ -65,7 +65,7 @@ const discoveryOptions = { fetch: inProcessFetch }; //#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}`); + console.log(`${hit.entry.identifier}: listed by ${hit.listingDomain}, hosted by ${hit.hostingDomain ?? 'inline'}`); } //#endregion discover_domain check(hits.length === 1, 'one card discovered'); diff --git a/examples/server-card-discovery/client.ts b/examples/server-card-discovery/client.ts index f047451f89..82040268b5 100644 --- a/examples/server-card-discovery/client.ts +++ b/examples/server-card-discovery/client.ts @@ -17,13 +17,13 @@ if (transport !== 'http') { } // Only the ORIGIN goes in: discovery finds the /mcp endpoint from the domain's -// AI Catalog. Loopback needs the explicit local-dev overrides; the hardened -// defaults reject plain HTTP and private addresses. +// 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, { allowHttp: true, allowPrivateHosts: true }); +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})`); +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]!; diff --git a/packages/client/src/experimental/serverCard/discover.ts b/packages/client/src/experimental/serverCard/discover.ts index d8f7e0d3d8..1de181ed91 100644 --- a/packages/client/src/experimental/serverCard/discover.ts +++ b/packages/client/src/experimental/serverCard/discover.ts @@ -10,8 +10,9 @@ import type { DiscoveryFetchOptions } from './guard'; */ export interface DiscoverServerCardsOptions extends DiscoveryFetchOptions { /** - * Cap on the number of Server Card entries processed from the catalog. - * Defaults to 100. + * 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; @@ -80,7 +81,12 @@ export async function discoverServerCards( const catalogUrl = getAICatalogUrl(domainOrUrl); let fetched; try { - fetched = await fetchAICatalog(catalogUrl, options); + // `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 []; @@ -117,10 +123,13 @@ export async function discoverServerCards( }); } } 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('invalid-server-card', `Catalog entry ${entry.identifier} could not be processed`, { + : new ServerCardError('network-error', `Catalog entry ${entry.identifier} could not be fetched`, { cause: error }); options.onEntryError?.(entryError, entry); diff --git a/packages/client/src/experimental/serverCard/errors.ts b/packages/client/src/experimental/serverCard/errors.ts index 128ac853a5..3743b28a36 100644 --- a/packages/client/src/experimental/serverCard/errors.ts +++ b/packages/client/src/experimental/serverCard/errors.ts @@ -4,8 +4,15 @@ import type { ServerCardInput } from '@modelcontextprotocol/core/experimental/se * 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. */ @@ -14,9 +21,15 @@ export type ServerCardErrorCode = | 'response-too-large' /** Redirect chain exceeded `maxRedirects`. */ | 'too-many-redirects' - /** A Server Card document failed validation (`cause` is the ZodError). */ + /** + * 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). */ + /** + * 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' diff --git a/packages/client/src/experimental/serverCard/fetch.ts b/packages/client/src/experimental/serverCard/fetch.ts index 539231a029..f86b511f83 100644 --- a/packages/client/src/experimental/serverCard/fetch.ts +++ b/packages/client/src/experimental/serverCard/fetch.ts @@ -152,7 +152,7 @@ function toUrl(value: string | URL): URL { try { return new URL(withScheme); } catch (error) { - throw new ServerCardError('blocked-host', `Invalid discovery URL: ${value}`, { url: value, cause: error }); + throw new ServerCardError('invalid-url', `Invalid discovery URL: ${value}`, { url: value, cause: error }); } } diff --git a/packages/client/src/experimental/serverCard/guard.ts b/packages/client/src/experimental/serverCard/guard.ts index 1e21f8ffdb..e9ed0e6967 100644 --- a/packages/client/src/experimental/serverCard/guard.ts +++ b/packages/client/src/experimental/serverCard/guard.ts @@ -47,7 +47,9 @@ export interface DiscoveryFetchOptions { /** * 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`. Defaults to `false`. + * 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; } @@ -78,34 +80,82 @@ function isPrivateIpv4(octets: [number, number, number, number]): boolean { ); } -/** Decode an IPv4-mapped IPv6 host (`::ffff:a.b.c.d` or `::ffff:xxyy:zzww`). */ -function mappedIpv4Of(ipv6: string): [number, number, number, number] | undefined { - const dotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(ipv6); - if (dotted) { - return parseIpv4(dotted[1]!); +/** 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 hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ipv6); - if (hex) { - const high = Number.parseInt(hex[1]!, 16); - const low = Number.parseInt(hex[2]!, 16); - return [high >> 8, high & 0xff, low >> 8, low & 0xff]; + 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 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 ipv6 = host.slice(1, -1).toLowerCase(); - if (ipv6 === '::1' || ipv6 === '::') { - return true; + const words = ipv6Words(host.slice(1, -1).toLowerCase()); + if (words === undefined) { + return true; // unparseable IPv6 literal: fail closed } - if (/^fe[89ab]/.test(ipv6)) { + if ((words[0]! & 0xff_c0) === 0xfe_80) { return true; // fe80::/10 link-local } - if (/^f[cd]/.test(ipv6)) { + if ((words[0]! & 0xfe_00) === 0xfc_00) { return true; // fc00::/7 unique-local } - const mapped = mappedIpv4Of(ipv6); - return mapped !== undefined && isPrivateIpv4(mapped); + 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; } /** @@ -124,6 +174,11 @@ export function assertAllowedUrl(url: URL, options: DiscoveryFetchOptions): void }); } } + 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; } @@ -140,7 +195,7 @@ export function assertAllowedUrl(url: URL, options: DiscoveryFetchOptions): void } return; } - if (host !== 'localhost' && !host.includes('.')) { + if (!host.includes('.')) { throw new ServerCardError('blocked-host', `Single-label hostname ${host} is not allowed for discovery`, { url: url.href }); } } @@ -199,6 +254,11 @@ export async function guardedFetch( /** * 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) { diff --git a/packages/client/src/experimental/serverCard/reconcile.ts b/packages/client/src/experimental/serverCard/reconcile.ts index 91681a9077..a769a2df35 100644 --- a/packages/client/src/experimental/serverCard/reconcile.ts +++ b/packages/client/src/experimental/serverCard/reconcile.ts @@ -26,6 +26,12 @@ export interface ServerCardMismatch { * `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. */ diff --git a/packages/client/src/experimental/serverCard/resolve.ts b/packages/client/src/experimental/serverCard/resolve.ts index 102ba43bf8..789113db76 100644 --- a/packages/client/src/experimental/serverCard/resolve.ts +++ b/packages/client/src/experimental/serverCard/resolve.ts @@ -28,9 +28,14 @@ export interface RemoteInputRequirement { /** * Walks everything a UI may prompt for on a card remote: the remote's - * `variables`, header inputs lacking a fixed `value`, and each header's - * nested `variables`. Keys share one flat namespace (variable names plus - * header names), matching what {@link resolveRemote} reads from `inputs`. + * `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[] = []; @@ -39,7 +44,11 @@ export function requiredRemoteInputs(remote: ServerCardRemote): RemoteInputRequi } 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({ diff --git a/packages/client/test/experimental/discover.test.ts b/packages/client/test/experimental/discover.test.ts index 94878ee454..60f1a3469e 100644 --- a/packages/client/test/experimental/discover.test.ts +++ b/packages/client/test/experimental/discover.test.ts @@ -121,6 +121,52 @@ describe('discoverServerCards', () => { 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([]); diff --git a/packages/client/test/experimental/fetch.test.ts b/packages/client/test/experimental/fetch.test.ts index 426355feee..3a53339072 100644 --- a/packages/client/test/experimental/fetch.test.ts +++ b/packages/client/test/experimental/fetch.test.ts @@ -79,6 +79,13 @@ describe('fetchServerCard', () => { 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'); diff --git a/packages/client/test/experimental/guard.test.ts b/packages/client/test/experimental/guard.test.ts index c2d164c5ca..f20eb25c0f 100644 --- a/packages/client/test/experimental/guard.test.ts +++ b/packages/client/test/experimental/guard.test.ts @@ -26,34 +26,52 @@ describe('assertAllowedUrl', () => { 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(); - expect(codeOf(() => assertAllowedUrl(new URL('http://localhost:3000/x'), {}))).toBeUndefined(); - expect(codeOf(() => assertAllowedUrl(new URL('http://127.0.0.1:3000/x'), { allowPrivateHosts: true }))).toBeUndefined(); - expect(codeOf(() => assertAllowedUrl(new URL('http://[::1]:3000/x'), { allowPrivateHosts: 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.1/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://[::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' + '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'])( - 'allows public address %s', - url => { - expect(codeOf(() => assertAllowedUrl(new URL(url), {}))).toBeUndefined(); - } - ); + 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'); diff --git a/packages/client/test/experimental/resolve.test.ts b/packages/client/test/experimental/resolve.test.ts index f969db60ad..1ab582589c 100644 --- a/packages/client/test/experimental/resolve.test.ts +++ b/packages/client/test/experimental/resolve.test.ts @@ -50,6 +50,17 @@ describe('requiredRemoteInputs', () => { 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([]); }); diff --git a/test/integration/test/server/serverCardDiscovery.test.ts b/test/integration/test/server/serverCardDiscovery.test.ts index a0436847cc..719fef9ecf 100644 --- a/test/integration/test/server/serverCardDiscovery.test.ts +++ b/test/integration/test/server/serverCardDiscovery.test.ts @@ -63,8 +63,9 @@ describe('Server Card discovery against a live MCP endpoint', () => { it('discovers, resolves, connects, and reconciles', async () => { const { baseUrl } = await startEndpoint(); - // Loopback endpoint: the local-dev overrides are required and exercised. - const hits = await discoverServerCards(baseUrl, { allowHttp: true, allowPrivateHosts: true }); + // 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); @@ -93,6 +94,6 @@ describe('Server Card discovery against a live MCP endpoint', () => { cleanups.push(() => { httpServer.close(); }); - await expect(discoverServerCards(baseUrl, { allowHttp: true, allowPrivateHosts: true })).resolves.toEqual([]); + await expect(discoverServerCards(baseUrl)).resolves.toEqual([]); }); }); From f07aff930ef98ab875b894c92abcac69a86765c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 07:43:49 +0000 Subject: [PATCH 10/10] fix(server), test, docs: Server Card review polish - serverCardCatalogEntry re-validates card.name, so a hand-cast card without a namespace fails at boot with a ZodError instead of minting a garbage URN - barrelClean runtime-neutrality traversal now follows ../-relative chunk imports, so nested subpath entries' shared chunks are actually scanned instead of only the thin entry file - getServerCardUrl documents that query and fragment are dropped; readBodyWithCap documents the buffered fallback for stream-less responses; reconcileServerCard documents the namespaced-name comparison; requiredRemoteInputs warns it returns optional inputs too - CLAUDE.md experimental section describes the new server-card subpaths instead of claiming the directory is empty; the vendored fixtures README acknowledges the Prettier reformatting so the refresh workflow does not produce spurious diffs --- CLAUDE.md | 6 +++++- .../client/test/client/barrelClean.test.ts | 4 +++- .../fixtures/server-card/README.md | 8 +++++--- packages/server/src/experimental/serverCard.ts | 18 ++++++++++++++---- .../test/experimental/serverCard.test.ts | 6 ++++++ .../server/test/server/barrelClean.test.ts | 4 +++- 6 files changed, 36 insertions(+), 10 deletions(-) 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/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts index d5202ff169..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]!)); } } diff --git a/packages/core/test/experimental/fixtures/server-card/README.md b/packages/core/test/experimental/fixtures/server-card/README.md index 93c7611474..fb8e0eabae 100644 --- a/packages/core/test/experimental/fixtures/server-card/README.md +++ b/packages/core/test/experimental/fixtures/server-card/README.md @@ -1,8 +1,10 @@ # Server Card conformance fixtures -Vendored verbatim from the `modelcontextprotocol/experimental-ext-server-card` +Vendored from the `modelcontextprotocol/experimental-ext-server-card` repository, `examples/ServerCard/`, at commit -`1491bd24714c885d5bc6b3bf0857094beff939fd`. +`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, and update the commit hash above. +when the schema changes, running `pnpm lint:fix:all` to reapply the repo +formatting, and updating the commit hash above. diff --git a/packages/server/src/experimental/serverCard.ts b/packages/server/src/experimental/serverCard.ts index 737dc14f25..b54c631b59 100644 --- a/packages/server/src/experimental/serverCard.ts +++ b/packages/server/src/experimental/serverCard.ts @@ -110,7 +110,9 @@ export function buildServerCard(options: BuildServerCardOptions): ServerCard { /** * 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. + * 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); @@ -241,11 +243,19 @@ export function aiCatalogResponse(request: Request, options: AICatalogResponseOp * `{ 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 { - const slash = card.name.indexOf('/'); - const namespace = card.name.slice(0, slash); - const serverName = card.name.slice(slash + 1); + // 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}`, diff --git a/packages/server/test/experimental/serverCard.test.ts b/packages/server/test/experimental/serverCard.test.ts index 7ed2668b87..a856a41571 100644 --- a/packages/server/test/experimental/serverCard.test.ts +++ b/packages/server/test/experimental/serverCard.test.ts @@ -1,5 +1,6 @@ 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 { @@ -251,6 +252,11 @@ describe('serverCardCatalogEntry', () => { 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', () => { diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index d1527b40c0..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]!)); } }