diff --git a/README.md b/README.md index 235cff3..5dfe1dd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ A Server Card describes **remote connectivity only**. Metadata for locally-insta Vendors who genuinely need to attach install hints to a Server Card can use namespaced [`_meta`](https://modelcontextprotocol.io/specification/latest/basic#meta) extension metadata, which remains the card's extension point. +## Best practices + +For recommendations on serving Server Cards (for server implementors) and on discovering +and installing servers by way of AI Catalogs (for client implementors), see +[best-practices.md](docs/best-practices.md). + ## Layout ``` @@ -73,6 +79,8 @@ The `schema.ts` in this repo is deliberately structured to be copy-pasted into t ## References +- [Best practices for server and client implementors](docs/best-practices.md) +- [Discovery (AI Catalog and Server Card location)](docs/discovery.md) - [SEP-2127: MCP Server Cards (PR)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) - [SEP-2133: Extensions framework (PR)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) - Abandoned core spec PR (superseded by this repo): [modelcontextprotocol#2652](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2652) diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..2d1d003 --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,373 @@ +# MCP Server Card Best Practices + +Practical guidance for the two sides of the Server Card ecosystem: people **hosting** +remote MCP servers, and people **building MCP clients** that discover and connect to them. + +This document is advisory. The normative mechanics for Server Cards live in the +[README](../README.md) and [discovery.md](./discovery.md), and the catalog format is defined +by the [AI Catalog specification](https://ai-catalog.io/spec/) — this page only +collects recommendations on top of them. + +## Best Practices for Server Implementors + +If you host a remote MCP server, we highly recommend you serve a Server Card — and publish an +AI Catalog entry that points at it. The two do different jobs: the card is how a client +_connects_ to you, and the catalog is how a client _finds_ you in the first place. The guidance +below covers what to put in the card, what does not belong in one, and where to publish the +catalog entry. + +### Serve a card: it is your connection entry point + +A Server Card advertises how to connect — transport endpoints, supported protocol versions, and +a hint at the incoming requirements a client should expect (such as authentication) — before +the client connects, and without prior configuration. This is valuable on its own, with no +catalog involved: a client that already knows your MCP URL can point at the card directly. + +Keep in mind the card is advisory and read before connecting, so clients reconcile it against +the live connection and +[MUST NOT treat it as authoritative for access control](./discovery.md#consistency-with-runtime-behavior) — +the connection itself remains the source of truth. + +### Fill out your card completely + +Populate every applicable field — not just the required minimum. `description` is required, but +the optional identity fields (`title`, `icons`, `repository`, `websiteUrl`) and fully-specified +transport metadata are what make your server easy to discover, present, and connect to. + +### Server Cards describe remote connectivity only + +If your server is **not remote**, there is nothing to serve a card for — Server Cards exist to +advertise remote transport endpoints only. Locally-installable server metadata lives in the +[MCP Registry](https://github.com/modelcontextprotocol/registry)'s `server.json` schema instead +(see [Relationship to the MCP Registry](../README.md#relationship-to-the-mcp-registry)). + +Internal-only but still remote is a different case: serve a card anyway. Even if your server is +not meant for the public, a card is still worth publishing — some clients may discover and +connect to you this way within your organization. + +### Link your card from an AI Catalog entry + +A card lets a client connect once it has your URL; an +[AI Catalog](https://ai-catalog.io/spec/) is what lets clients find that URL in the first place — +so publish both. An AI Catalog is a cross-protocol discovery document that can index your MCP +server alongside other AI artifacts; see [discovery.md](./discovery.md). + +**Serve it at `/.well-known/ai-catalog.json`.** That is the one location a client can try +against a bare domain, with nothing fetched from you first — which is exactly what in-session +discovery depends on. (The specification allows a catalog to live elsewhere, and defines other +ways to point at one; support for those is tracked in +[#43](https://github.com/modelcontextprotocol/experimental-ext-server-card/issues/43).) + +Publish it at the domain people associate with your service: + +- For a **public server**, that is your **primary domain** — the domain humans or agents would + naturally associate with your service. +- For an **internal enterprise** server, that is wherever an internal team would first encounter + you — for example the domain hosting your REST API or the other resources a team becomes aware + of _before_ they learn you also expose MCP. + +Review the guidance below for Client Implementors to determine the appropriate domain for your +service. For example, for GitHub, it would be common for the user of a coding agent to paste a +URL like `https://github.com/modelcontextprotocol/experimental-ext-server-card/pull/36` into a +session. So `github.com/.well-known/ai-catalog.json` is an excellent place to put your AI +catalog - not `api.githubcopilot.com/.well-known/ai-catalog.json`, where GitHub's MCP server +[happens to live](https://github.com/github/github-mcp-server). + +## Best Practices for Client Implementors + +Every MCP server your client can discover organically helps the user to stay in the flow while +giving them the capabilities they need. The hard part is that connecting a server is often a +chore where the user has to leave their flow to configure it out of band, before it can help +them — so most users never do it. + +As per above guidance for server implementors, we now have a way for them to advertise +their service in an easy-to-find, standardized location. Using this, your client can offer the +connection **at the moment the user is already referencing that service** — mid-session, in +context, instead of in a settings pane the user never visits. + +If you wire that in where we recommend below, it costs you remarkably little: + +- **No new UX to design.** You already ask users to approve consequential actions like tool + calls. This is one more approval, in a flow they already know. +- **No new trust to establish.** You only ever offer servers published by a domain the user + themselves just put in front of you. You are not recommending anything — the domain the user + named is. +- **No discovery problem to solve.** No ranking, no index, no crawl, no editorial judgment. + The user supplied the domain; you are just asking it what it offers. + +The outcome compounds in every direction: the user gets a capability exactly when they need +it, your client gets stickier, the service gets reached by agents that would otherwise have +scraped it or given up, and the pie grows as complementary AI services get strung together. + +### Where to trigger discovery + +The probe itself is cheap: one asynchronous `GET /.well-known/ai-catalog.json`, run in the +background so it never blocks what the user asked for. Build that one first — it is the probe +you can run against a bare domain, without having fetched anything from it. Additional ways to +locate a catalog are tracked in +[#43](https://github.com/modelcontextprotocol/experimental-ext-server-card/issues/43); expect +this list to grow, and keep the resolution step separate from the triggers below so it can. + +The design decision, then, is not _how_ to probe — it is **which moments** in a session should +trigger one. + +Where a concrete example helps, this section points at [Goose](https://goose-docs.ai/), an +open-source MCP agent hosted by the Agentic AI Foundation, simply because its hooks and install +path are easy to read. Nothing here is Goose-specific — every mechanism below has an equivalent +in any client that runs tools, reads project files, or mediates network access. + +#### Start here: probe the domains a user hands you + +**At minimum, we recommend a default-on experience that probes any domain a user enters as a +URL.** This is the strongest signal in the session and the safest place to begin: the user +typed or pasted the domain themselves, so there is no ambiguity about intent, no inference, +and no domain touched that the user did not already name. + +```mermaid +flowchart TD + A[User enters a URL in the session] --> B[Probe domain's /.well-known/ai-catalog.json] + B --> C{AI Catalog found?} + C -->|No| D[Cache the miss, stay silent] + C -->|Yes| E[Select application/mcp-server-card+json entries] + E --> G[Resolve the Server Card for each entry] + G --> F{Endpoint already installed, or previously declined?} + F -->|Yes| D + F -->|No| H["Offer install — naming the listing chain"] + H --> I{User approves?} + I -->|This session| J[Server connected mid-turn] + I -->|Always| K[Server connected and persisted to config] + I -->|Not now| L[Remember the decline] +``` + +One detail worth getting right when you walk the entries: an entry carries its artifact either +by reference or inline, so not every entry requires a fetch. Catalogs can also be organized in +ways this extension does not yet resolve — see +[#44](https://github.com/modelcontextprotocol/experimental-ext-server-card/issues/44) — so treat +"read the entries" as a step that will gain cases, not a fixed one. + +A closely related and equally bounded set: the domains a **project** already points at — links +in an `AGENTS.md`, a project config, or whatever hints file your client injects into the system +prompt (in Goose, `.goosehints`, which supports literal `https://` URLs). Probe that set once at +session start rather than on every turn. These are domains the project is built around, the user +put them there deliberately, and there are only ever a handful. + +#### Expand carefully: broader triggers, off by default for now + +Beyond user-supplied URLs, the same probe can hang off progressively broader signals. These +find more servers and touch more domains, and the trade is the same each time: **broader +coverage, at the cost of more requests, more noise, and more domains learning the user +interacted with them.** + +```mermaid +flowchart LR + A["User-entered URLs
(recommended, default-on)"] --> P[Shared probe + cache] + B["Project files
(AGENTS.md, hints files, recipes)"] --> P + C["Tool-call results
(web fetch, scrape)"] -.opt-in.-> P + D["Network egress boundary
(every domain reached)"] -.opt-in.-> P + P --> E[domain → catalog map, misses cached] + E --> F[Install offer] +``` + +- **Tool-call results.** The user or the agent chose to retrieve a page, which is close to + direct intent. If your client can intercept tool calls, select the URL-bearing ones — a web + fetch or scrape — and read the target host out of the tool's input before or alongside the + call. This needs no change to the tool itself, and you stay in control of _which_ tools + trigger a probe. (Goose implements this shape with + [lifecycle hooks](https://goose-docs.ai/docs/guides/context-engineering/hooks), following the + cross-tool [Open Plugins](https://open-plugins.com/agent-builders/components/hooks) spec: a + plugin's `hooks/hooks.json` maps `PreToolUse` / `PostToolUse` to scripts, with a `matcher` + regex selecting the tool and the tool input handed to the hook as JSON. Note the hook layer + runs local commands, so it is itself a surface worth trusting deliberately.) +- **The network egress boundary.** The broadest option: if your client already mediates network + access, that chokepoint sees every domain the agent actually reaches, not just the ones a tool + or file surfaced — so discovery can ride the same seam as the filtering you already do there. + It composes with an allow/deny boundary you may already run, but it is also where the noise is + worst: most domains publish no catalog, so the caching in + [Keep probing cheap](#keep-probing-cheap-and-let-enterprises-scope-it) matters most here. (A + sandbox that denies the agent direct network access and tunnels its traffic through a local + proxy already has this chokepoint; the proxy sees every destination before the connection is + made.) + +**We do not recommend turning these on by default at this time.** Ship them opt-in, behind a +setting, while the ecosystem and the interaction pattern are still young — the default-on +case above is the one where the user's intent is unambiguous, and it is worth learning how +that experience lands before widening the aperture. As implementations gather evidence, this +guidance may change. + +### Turn a hit into a one-click install + +You almost certainly do not need new machinery for this. Your client already has a way to add +an MCP server — a config file, an install command, a deep link from an extensions directory — +and a Server Card carries exactly what that path needs: the endpoint, the transport, and the +identity to display. Discovery just supplies those values from a catalog instead of from a user +who typed them. + +So when a probe finds an entry, surface it (interrupting the turn or presenting it passively is +your call) and route the accept into the install path you already have. The one thing worth +insisting on is that the server is added and connected **mid-turn** — the value here is the +user not having to leave what they were doing. + +For a concrete instance: in Goose an extension _is_ an MCP server, so a card maps onto its +existing [install path](https://goose-docs.ai/docs/getting-started/using-extensions) directly — +either the `goose://extension?...` deep link its extensions directory generates, or the +equivalent block written into `config.yaml`. + +### Security and trust considerations + +#### Show the listing chain, not just the endpoint + +The domain that publishes a catalog is often **not** the domain that hosts the server it points +at — a catalog on `example.com` may list a server running on `mcp-host-saas.com`. A prompt that +names only the endpoint asks the user to trust a domain they may not recognize as belonging to +the service, which is indistinguishable, from the user's side, from a phishing prompt. + +Surface the **chain** instead: the domain the user actually interacted with, the fact that it +listed the entry, and the domain that will own the connection. Name both, and make it clear +which one the credentials and traffic will go to. + +A minimal prompt carrying that chain: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Connect an MCP server? │ +│ │ +│ example.com lists a server it does not host: │ +│ │ +│ Example example.com │ +│ └── hosted at mcp-host-saas.com │ +│ │ +│ Requests and any credentials you approve will go to │ +│ mcp-host-saas.com. │ +│ │ +│ [ Not now ] [ This session ] [ Always ] │ +└──────────────────────────────────────────────────────────┘ +``` + +Two things are doing the work here. The listing domain is the one the user recognizes and just +interacted with, so it leads. The hosting domain is named as a **consequence** — where traffic +and credentials actually go — rather than as a bare URL the user is asked to pattern-match +against a brand they know. + +Be careful what you claim for that chain. A listing is an assertion by whoever controls the +domain, not a verified statement about the server: the AI Catalog specification attaches no +endorsement semantics to publication, and a compromised catalog host can inject entries. Where +an entry carries a +[trust manifest](https://ai-catalog.io/spec/#trust-manifest), +that — publisher identity and attestations — is the mechanism built for this decision, and the +listing chain is what you fall back on when it is absent. + +#### Do not let a probe reach where a user could not + +A probe is an outbound request to a host the client picked, which makes it a request-forgery +primitive if you leave it unbounded. Restrict it to publicly resolvable hosts: reject IP +literals, loopback, link-local, and private ranges, and re-check after DNS resolution rather +than only on the hostname, so a name that resolves inward is caught. `169.254.169.254` and its +equivalents are the case that matters most — a cloud instance probing its own metadata endpoint +is a credential disclosure, not a discovery miss. + +The rest is ordinary hygiene, and worth stating because the probe is automatic and unattended: +send no cookies, credentials, or ambient authorization; cap the response size and the number of +entries you will follow; and bound redirects, resolving each hop under the same rules — a +redirect is the easy way around a check applied only to the URL you started with. In a managed +environment, an internal host that _does_ serve a catalog is best reached through the enterprise +controls below rather than by relaxing these rules. + +#### Always ask before installing — and be stricter here than you are with tools + +Reuse the consent surface your users already know; do not invent a second vocabulary for +discovery. But hold discovery to a **higher bar than tool calls**. Approving a tool call +authorizes one action by code the user already chose to run. Installing a discovered server +adds a new, unvetted counterparty to the session — one that will expose its own tools, receive +its own inputs, and persist if the user lets it. That asymmetry means the autonomy settings a +client offers for tools should not simply extend to installs: + +- **A discovered server should never be installed automatically.** No "completely autonomous" + mode should silently connect one. The approval dialog is load-bearing: the listing chain is + the user's main signal, and skipping the prompt discards it. +- **There is no blanket "always allow."** An install approval is scoped to _that server_, never + to discovery in general and never to a domain's future entries. + +Offer the user two accept paths, which is the distinction the prompt above encodes: + +- **This session** — connect now, discard at session end. The right default for a server the + user is trying out; a mistake costs one session. +- **Always** — persist the server to the user's configuration, as if they had installed it + themselves. This is the equivalent of _always allow_, scoped to that one server. + +Together with **Not now**, that is the whole vocabulary. A user who wants a server permanently +can say so in one click, and a user who is merely curious is not talked into a permanent +change to their setup. + +#### De-duplicate, and let the user turn it off + +Discovery is only useful if it stays quiet. A client that re-offers a server the user already +runs, or that surfaces every entry on a busy catalog at once, trains the user to dismiss the +prompt without reading it — which costs you the one moment where the listing chain above +actually gets evaluated. + +- **Track what is already installed, keyed on the server's endpoint.** Match a discovered entry + against the user's configured servers on the transport URLs in the card's `remotes[]` — the + thing you would actually connect to. Resist keying on the card's `name` or the entry's + `identifier`: both are strings the catalog asserts about itself, so a hostile entry that + claims a name you already run would silence the prompt by design. Endpoint matching has no + such failure mode, since an entry that names the same endpoint _is_ the same server. Note this + means the de-dup check happens after you fetch the card, not before. +- **Consider culling a long catalog.** A domain may list many servers. Presenting the full list + is usually fine — most catalogs are short. But if a catalog is long, there is room to get + creative: an inference step over the entries' descriptions, weighed against what the user is + actually doing in the session, can surface the two or three that fit and tuck the rest behind + a "show all." This is an open design space, and we would like to see what clients try. +- **Remember a "no."** Give the user a durable _don't ask me again_ — per server, and per + domain — and honor it across sessions. A declined install is a preference, not a + per-turn answer. + +#### Keep probing cheap, and let enterprises scope it + +Run probes asynchronously and never block the operation the user asked for. Cache the result +per domain — _including misses_, since most domains publish no catalog — and honor the +catalog's `Cache-Control` response headers so you do not re-probe on every touch. When a cached +entry does expire, revalidate rather than refetch: store the `ETag` a catalog or card endpoint +returns and send it back as `If-None-Match`, so an unchanged document costs you a `304` and no +body. + +Because each probe reveals to the domain that the user interacted with it, give enterprises +control: an organization might disable in-session discovery entirely, or restrict it to an +allowlist of domains. + +Outright disabling is the blunt option, and it throws away the signal. In a managed +environment, the interesting move is to **keep discovering and change what a hit does**. An +employee who hits a useful server is exactly the demand signal a platform team wants, and today +that signal is lost — the user shrugs, works around it, and nobody learns the server was +wanted. Instead of offering a direct install, route the find into the organization's existing +approval path: the client swaps the install button for a request, pre-filled from the catalog +entry and Server Card it already fetched, so the user supplies intent and the client supplies +the technical detail. + +``` +┌──────────────────────────────────────────────────────────┐ +│ Request this server from IT │ +│ │ +│ Direct installs are disabled by your organization. │ +│ │ +│ Server Example │ +│ Listed by example.com │ +│ Hosted at mcp-host-saas.com │ +│ Transport streamable HTTP │ +│ │ +│ Why do you need it? │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Reviewing tickets in the session — would let the │ │ +│ │ agent read them directly instead of pasting them. │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ [ Cancel ] [ Send request ] │ +└──────────────────────────────────────────────────────────┘ +``` + +Where that request lands is the organization's business — a ticket, a Slack approval, a pull +request against a gateway's server list. What matters for a client implementor is that the +Server Card gives you most of what the approver needs (identity, endpoint, transport, protocol +versions) without the user having to hunt for it, and that an approved request ends with the +server added to the **managed gateway** the whole org already connects through, rather than +installed ad hoc on one laptop. The user gets a path instead of a dead end, and the platform +team gets a queue of real, evidenced demand.