|
| 1 | +# Proposal evaluation: a `/server-card.json` path-suffix discovery convention |
| 2 | + |
| 3 | +**Status:** Draft for discussion. Not a settled change. |
| 4 | +**Relates to:** [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127), [`docs/discovery.md`](../discovery.md), issue #12 / PR #22 (which introduced the current `/server-card` reservation). |
| 5 | + |
| 6 | +## TL;DR |
| 7 | + |
| 8 | +The suggestion is to recommend fetching a Server Card with a plain `GET` at a fixed |
| 9 | +path suffix — `<mcp_url>/server-card.json` — to spare simple scrapers from HTTP |
| 10 | +content-type negotiation. |
| 11 | + |
| 12 | +The motivating premise is **largely already true in this spec, and stronger than the |
| 13 | +suggestion assumes**: discovery here does **not** hinge on content negotiation against |
| 14 | +the `/mcp` endpoint. The spec already reserves a path-suffix convention, |
| 15 | +`GET <streamable-http-url>/server-card`, and the "Alternatives considered" section of |
| 16 | +[`discovery.md`](../discovery.md) already _explicitly rejects_ serving the card off the |
| 17 | +bare `/mcp` endpoint **because** that would "force content negotiation to disambiguate." |
| 18 | +So the headline win the suggestion is reaching for — "a plain GET to a predictable URL, |
| 19 | +no content negotiation" — is the design the spec already landed on. |
| 20 | + |
| 21 | +That reframes the decision to two narrow, genuine questions: |
| 22 | + |
| 23 | +1. Should the reserved suffix carry a **`.json` extension** (`/server-card.json`) instead |
| 24 | + of the current extension-less `/server-card`? |
| 25 | +2. Should the spec **soften the `Accept: application/mcp-server-card+json` expectation** |
| 26 | + so that a naive scraper issuing a bare `GET` to the reserved path is a first-class, |
| 27 | + guaranteed-to-work client? |
| 28 | + |
| 29 | +This document recommends: **(1) keep `/server-card` — do not adopt `.json` as the |
| 30 | +reserved suffix**, for the reasons below; and **(2) yes — clarify that the reserved |
| 31 | +endpoint MUST answer a plain `GET` regardless of `Accept`.** (2) delivers essentially all |
| 32 | +of the scraper-friendliness the suggestion wants, without a churn-y change to an in-flight |
| 33 | +convention. The contrary case for (1) is laid out honestly so a maintainer can overrule. |
| 34 | + |
| 35 | +## What the spec actually says today |
| 36 | + |
| 37 | +From [`docs/discovery.md`](../discovery.md): |
| 38 | + |
| 39 | +- **The discovery entrypoint is the MCP Catalog**, a JSON document at the well-known URI |
| 40 | + `/.well-known/mcp/catalog.json`. Each catalog entry carries an explicit `url` for that |
| 41 | + server's card. **Clients following the catalog never guess a card URL — they follow the |
| 42 | + `url` the catalog hands them**, and a card "MAY be hosted at any unreserved URI (on any |
| 43 | + domain)." |
| 44 | +- **For servers that want a predictable default location**, the spec reserves exactly one: |
| 45 | + |
| 46 | + > MCP Servers MAY host their Server Card at `GET <streamable-http-url>/server-card`, |
| 47 | + > which we reserve for this purpose, though any unreserved URI (on any domain) is valid. |
| 48 | +
|
| 49 | + A server at `https://host/mcp` therefore yields `https://host/mcp/server-card` — the |
| 50 | + doc's stated rationale is that you "get path-namespacing for free." |
| 51 | + |
| 52 | +- **Content negotiation is layered on top as a SHOULD, not a load-bearing requirement.** |
| 53 | + Clients SHOULD send `Accept: application/mcp-server-card+json`; the card has the media |
| 54 | + type `application/mcp-server-card+json`. But because the path is _already dedicated to |
| 55 | + the card_, the `Accept` header is a politeness/robustness hint, not the disambiguator. |
| 56 | +- **The bare-endpoint, content-negotiated approach was considered and rejected.** The |
| 57 | + "Alternatives considered" list rejects `GET <streamable-http-url>` (no suffix) precisely |
| 58 | + because in Streamable HTTP a `GET` on the MCP endpoint already opens the SSE stream, so |
| 59 | + serving the card there "overloads that endpoint and forces content negotiation to |
| 60 | + disambiguate 'give me the card' from 'open the stream.'" `.well-known` for the |
| 61 | + single-server card and a domain-root `/mcp/` metadata namespace were also considered and |
| 62 | + rejected. |
| 63 | + |
| 64 | +**Correction to the framing:** the suggestion is pitched as "switch _away from_ content |
| 65 | +negotiation _to_ a path suffix." The spec is already on the path-suffix side of that line. |
| 66 | +The live decision is not _whether_ to use a path suffix, but _which spelling_ of the |
| 67 | +suffix, and _how hard_ to lean on `Accept`. |
| 68 | + |
| 69 | +## The crux the "no drawback" claim skips: appending a suffix to an arbitrary URL is not free |
| 70 | + |
| 71 | +The suggestion frames `/server-card.json` as "just a convention tacked onto the end of an |
| 72 | +`/mcp` URL." Tacking a segment onto an _arbitrary_ URL by string concatenation is **not** |
| 73 | +unambiguous. These edge cases apply, and note they apply **equally to the current |
| 74 | +`/server-card`** — they are a property of "suffix on a server URL," not of the `.json` |
| 75 | +spelling: |
| 76 | + |
| 77 | +- **Query strings.** A streamable-HTTP URL may carry query parameters |
| 78 | + (`https://host/mcp?tenant=acme`). Naive concatenation produces |
| 79 | + `https://host/mcp?tenant=acme/server-card.json`, which puts the "suffix" _inside the |
| 80 | + query string_ — wrong. Correct resolution requires manipulating the URL **path** |
| 81 | + component (RFC 3986), stripping/handling the query and fragment. |
| 82 | +- **Trailing slash.** `https://host/mcp/` vs `https://host/mcp` yields `…/mcp//server-card` |
| 83 | + vs `…/mcp/server-card`. The convention needs a defined normalization rule. |
| 84 | +- **Templated URLs.** Server Cards already ship templated remote URLs in this repo — |
| 85 | + `examples/ServerCard/valid/templated-remote.json` has |
| 86 | + `"url": "https://{tenant}.example.com/mcp"`. The suffix must be appended to the |
| 87 | + _resolved_ URL, after variable expansion, which a scraper that has only the template |
| 88 | + cannot do on its own. |
| 89 | +- **Non-root / nested paths.** `https://host/server/mcp` → |
| 90 | + `https://host/server/mcp/server-card.json`. Fine, but only if "append to the path" is |
| 91 | + the defined operation, not "append to the string." |
| 92 | + |
| 93 | +**Implication:** the simplest scrapers — the population the suggestion is trying to help — |
| 94 | +are exactly the ones least likely to do correct RFC 3986 path resolution. For those |
| 95 | +clients the **catalog `url` is the robust answer**: it is an already-resolved, absolute URL |
| 96 | +that needs no suffix arithmetic. The path-suffix convention (either spelling) is best |
| 97 | +understood as a _convenience for servers/operators_ and for clients that already hold a |
| 98 | +concrete `/mcp` URL — not as the primary scraper interface. If we adopt or keep _any_ |
| 99 | +suffix convention, the spec should state the resolution rule explicitly (operate on the |
| 100 | +path component; strip query and fragment; collapse a single trailing slash). This is a |
| 101 | +real gap in the current text regardless of the `.json` question. |
| 102 | + |
| 103 | +## `/server-card.json` vs `/server-card`: the honest ledger |
| 104 | + |
| 105 | +### Arguments for `.json` |
| 106 | + |
| 107 | +- **Static file hosts.** A dumb static server (S3/GCS bucket, `nginx autoindex`, GitHub |
| 108 | + Pages) infers `Content-Type: application/json` from a `.json` extension and serves the |
| 109 | + bytes with zero configuration. An extension-less `/server-card` on such a host often |
| 110 | + defaults to `application/octet-stream` or `text/plain`. |
| 111 | +- **Human/tooling legibility.** `.json` signals "this is a JSON file" to a developer |
| 112 | + eyeballing a URL, and to generic tooling (browsers, `curl`-to-file, link scanners) that |
| 113 | + keys off extensions. |
| 114 | +- **CDN / cache key cleanliness.** A fixed `.json` URL with no reliance on `Vary: Accept` |
| 115 | + is the friendliest possible CDN object — one URL, one representation, one cache key. |
| 116 | + |
| 117 | +### Arguments against `.json` (why this doc leans "keep `/server-card`") |
| 118 | + |
| 119 | +- **The benefit is weakest exactly where the convention applies.** The reserved suffix |
| 120 | + hangs off a server's **streamable-HTTP endpoint** — a _live MCP server_, not a dumb |
| 121 | + static host. That server is, by definition, capable of setting |
| 122 | + `Content-Type: application/mcp-server-card+json` on the response. The headline `.json` |
| 123 | + win ("static host guesses the content type for you") mostly evaporates when the host is a |
| 124 | + dynamic application endpoint. A server that genuinely _wants_ static-host hosting can |
| 125 | + already put the card at a `.json` URL anywhere and advertise it via the catalog `url` — |
| 126 | + the spec already permits "any unreserved URI." So `.json` buys little _at the reserved |
| 127 | + location_ specifically. |
| 128 | +- **`.json` advertises `application/json`, but the card's media type is |
| 129 | + `application/mcp-server-card+json`.** A `.json` extension nudges static hosts toward the |
| 130 | + _generic_ `application/json`, which is technically a less specific content type than the |
| 131 | + registered card media type the spec asks servers to serve. So the extension can pull the |
| 132 | + served `Content-Type` in the _wrong_ direction on the very hosts where it has effect. |
| 133 | +- **Couples the discovery path to a serialization.** `/server-card` names the _resource_; |
| 134 | + `/server-card.json` bakes the _encoding_ into the path. If a future revision ever serves |
| 135 | + an alternate representation (e.g. a signed/JWT-wrapped card, or a CBOR variant for |
| 136 | + constrained clients), an extension-less resource path negotiates that cleanly while a |
| 137 | + `.json` path has pre-committed. This is a small risk, but it cuts against an _in-flight, |
| 138 | + experimental_ convention. |
| 139 | +- **Churn on a convention that was just set.** `/server-card` landed deliberately in PR #22 |
| 140 | + (resolving #12) with written rationale. Re-spelling it now, while SEP-2127 is under |
| 141 | + review, spends maintainer/reviewer attention and any nascent implementer goodwill for a |
| 142 | + marginal, host-dependent gain. Principle 3 of this repo's `AGENTS.md` ("respect the |
| 143 | + experimental, spec-tracked nature") and the "stay conservative" guidance both point |
| 144 | + toward _not_ re-litigating a fresh decision without a strong forcing function. |
| 145 | +- **It does not fix the real ambiguity.** As shown above, query strings / trailing slashes |
| 146 | + / templated URLs are problems for `/server-card.json` just as much as for `/server-card`. |
| 147 | + `.json` changes the spelling, not the hard part. |
| 148 | + |
| 149 | +### Net |
| 150 | + |
| 151 | +The `.json` extension is a **legitimate option with a coherent rationale**, but its main |
| 152 | +benefit (static-host content-type inference) is structurally weakest at the reserved |
| 153 | +location it would govern (a dynamic MCP endpoint), it risks mis-advertising the card's |
| 154 | +specific media type as generic `application/json`, and it couples the path to a |
| 155 | +serialization on an experimental convention that was only just decided. On balance this |
| 156 | +doc recommends **keeping `/server-card`** and instead making the cheaper, higher-leverage |
| 157 | +change below. |
| 158 | + |
| 159 | +## The change that actually delivers the suggestion's goal |
| 160 | + |
| 161 | +The suggestion's real target is "**a simple scraper can do a plain `GET` and get the |
| 162 | +card, without dealing with content negotiation.**" That is achievable today with a one-line |
| 163 | +clarification, no path change and no breaking effect on consumers: |
| 164 | + |
| 165 | +> A server that hosts its Server Card at the reserved `<streamable-http-url>/server-card` |
| 166 | +> location **MUST** serve the card in response to a plain `GET`, i.e. it MUST NOT _require_ |
| 167 | +> an `Accept: application/mcp-server-card+json` request header to return the card at that |
| 168 | +> reserved path. Clients SHOULD still send the `Accept` header; servers SHOULD still |
| 169 | +> respond with `Content-Type: application/mcp-server-card+json`. (For cards hosted at an |
| 170 | +> arbitrary `url` advertised by a catalog, content negotiation at that URL remains at the |
| 171 | +> host's discretion.) |
| 172 | +
|
| 173 | +This: |
| 174 | + |
| 175 | +- makes the bare-`GET` scraper a **first-class, guaranteed** client at the reserved path — |
| 176 | + the exact ergonomics the suggestion wants; |
| 177 | +- keeps `Accept`/`Content-Type` as the correct, recommended behavior for well-behaved |
| 178 | + clients and caches; |
| 179 | +- is **non-breaking** for existing consumers (it only constrains servers _toward_ being |
| 180 | + more permissive); and |
| 181 | +- does not pre-empt any unresolved SEP-2127 question about media types or alternate |
| 182 | + representations. |
| 183 | + |
| 184 | +If, after discussion, maintainers still want the static-host ergonomics of an extension, |
| 185 | +the **least-disruptive** form is to keep `/server-card` as the reserved suffix and add a |
| 186 | +note that servers/operators **MAY** additionally expose the card at a `.json` URL and point |
| 187 | +the catalog `url` at it — rather than re-spelling the single reserved location. |
| 188 | + |
| 189 | +## Recommendation |
| 190 | + |
| 191 | +1. **Do not adopt `/server-card.json` as the reserved suffix.** Keep `/server-card`. The |
| 192 | + premise that this escapes content negotiation is already satisfied by the current path |
| 193 | + convention; the `.json` benefit is weakest at the dynamic endpoint it would govern, and |
| 194 | + re-spelling a just-decided experimental convention is not worth the churn. _(Presented as |
| 195 | + an available option above, with the case argued against.)_ |
| 196 | +2. **Adopt the bare-`GET` clarification** (servers MUST serve the card at the reserved path |
| 197 | + without requiring `Accept`). This is the cheap, non-breaking change that delivers the |
| 198 | + scraper-friendliness the suggestion is after. |
| 199 | +3. **Specify the suffix-resolution rule** in `discovery.md` (operate on the URL _path_ |
| 200 | + component per RFC 3986; strip query and fragment; collapse a single trailing slash; |
| 201 | + append after template expansion) so "append `/server-card`" is unambiguous. This gap |
| 202 | + exists today independent of the `.json` question. |
| 203 | +4. **Keep steering scrapers to the catalog `url`** as the robust path: it is an |
| 204 | + already-resolved absolute URL and sidesteps all suffix arithmetic. |
| 205 | + |
| 206 | +## Open questions for maintainers |
| 207 | + |
| 208 | +- Is there a concrete deployment scenario where MCP servers genuinely sit behind a |
| 209 | + pure static host that cannot set `Content-Type` for the reserved path? If that population |
| 210 | + is real and large, the case for `.json` strengthens and item (1) should be reconsidered. |
| 211 | +- Should the bare-`GET`-MUST guarantee (item 2) be scoped strictly to the _reserved_ |
| 212 | + `/server-card` path (as proposed here), or generalized? Generalizing would intrude on the |
| 213 | + "any unreserved URI" freedom and is probably out of scope. |
| 214 | +- Does SEP-2127 anticipate any non-JSON or wrapped (signed) card representation? If yes, |
| 215 | + that is a further argument for an extension-less resource path (item 1) and worth |
| 216 | + recording in the SEP. |
| 217 | +- Is `application/json` (what a `.json` extension tends to advertise on static hosts) |
| 218 | + acceptable as a fallback `Content-Type`, or must the registered |
| 219 | + `application/mcp-server-card+json` always be served? The answer affects how damaging the |
| 220 | + `.json` media-type-genericization point is. |
0 commit comments