Skip to content

14 min read

Advertising resources and resource templates

On this page

Before a host can read anything, it has to know the thing exists. Discovery in MCP is a pair of listing calls a client makes after the handshake, and what comes back is the whole of what a user can browse: an array of entries, each naming a document and describing it well enough for a menu. A resource that is not on one of those two listings is not unreachable in principle — a client that already knows the URI can still read it — but it is unreachable in practice, because nobody can attach what nobody can see.

The two listings exist because the two things being advertised are different shapes. A concrete resource is one document with one identifier: docs://onboarding/engineering, 18 KB of Markdown, byte count known. A parameterised family is a pattern: docs://team/{team}/policy/{name}, standing in for every policy document any team owns, with no fixed member count and no bytes to report until the variables are filled in. Advertising the second on the listing built for the first is the most common way a working server ships with invisible content, and the wire shows it plainly.

json
{
  "jsonrpc": "2.0", "id": 3,
  "result": {
    "resources": [
      { "uri": "docs://onboarding/engineering", "name": "Engineering onboarding",
        "mimeType": "text/markdown" },
      { "uri": "docs://onboarding/design", "name": "Design onboarding",
        "mimeType": "text/markdown" },
      { "uri": "docs://onboarding/support", "name": "Support onboarding",
        "mimeType": "text/markdown" },
      { "uri": "docs://onboarding/sales", "name": "Sales onboarding",
        "mimeType": "text/markdown" },
      { "uri": "docs://team/{team}/policy/{name}", "name": "Team policy",
        "mimeType": "text/markdown" }
    ]
  }
}

The fifth entry is a template string sitting in a field that is specified to hold a URI. Hosts that validate the uri field drop the entry; hosts that do not validate render an item called "Team policy" that fails on read, since there is no document whose identifier is the literal string with the braces in it. Either way the family behind it is unreachable, and the difference between the two host behaviours is why this class of bug reaches production — one host reports an absence nobody can describe, the other reports an error somebody can screenshot.

The mistake is tempting because the template string looks like a URI and behaves like one everywhere except in the two places that matter. It passes a regex for a scheme followed by a path. It sorts sensibly beside the concrete entries. It renders as a plausible menu item. A server's own test suite will not catch it either, because a test that asserts "the listing contains an entry whose name is Team policy" passes on both the broken and the correct version.

The reason a validating host has no choice about dropping it is worth walking, because it explains why this cannot be repaired on the host side. A URI is defined by a grammar, and that grammar admits a fixed repertoire of characters; the brace is not one of them, and no amount of leniency turns docs://team/{team}/policy/{name} into a legal identifier. A host that checks the field against the grammar therefore finds a value the field cannot hold, and has exactly two options, neither of them good: drop the entry, or keep a value it already knows is not a URI. The deeper problem is not the characters. A uri field is typed to hold one member of a set, and a template is a description of the whole set, so putting the description where a member belongs is a category error that happens to also be a syntax error — and the syntax error is the only half a validator can see.

Two listings, two shapes

MCP advertises fixed documents and parameterised families through separate methods, and an entry in each has a different identifier field.

resources/listresources/templates/list
Result keyresourcesresourceTemplates
Identifier fielduri, a concrete URIuriTemplate, an RFC 6570 template: a URI with {braces} marking variables
Also carriesname, title, description, mimeType, size, annotationsname, title, description, mimeType
Paginatedyes, via cursor / nextCursoryes, via cursor / nextCursor
What a host doesrenders a browsable, attachable itemrenders a form, an autocomplete, or nothing
Change notificationnotifications/resources/list_changedsame notification covers both

The absence of size on a template entry is not an oversight. A concrete resource is one document whose byte count the server can state, and a host can use that to decide whether attaching it will blow the context budget. A template names a family whose members have not been enumerated and may not be enumerable, so there is no number to report.

Correctly split, the same two documents look like this:

json
{
  "resources": [
    { "uri": "docs://onboarding/engineering", "name": "engineering-onboarding",
      "title": "Engineering onboarding", "mimeType": "text/markdown",
      "size": 18422 }
  ],
  "resourceTemplates": [
    { "uriTemplate": "docs://team/{team}/policy/{name}",
      "name": "team-policy",
      "title": "Team policy document",
      "description": "A named policy document owned by one team. Team slugs come from the org directory; policy names are lowercase with hyphens, e.g. on-call-escalation.",
      "mimeType": "text/markdown" }
  ]
}

Both blocks are shown together for comparison; on the wire they arrive as the results of two separate requests.

What the host actually renders

The fields are not decoration. A host builds its attachment interface out of them, and each one drives a specific behaviour.

uri is the identity. It is what the host stores when the user attaches something, what it sends back on resources/read, and what it uses to deduplicate when two servers expose overlapping content. Keep it stable across deploys, because a host that persisted docs://onboarding/engineering in a saved session will re-read that exact string next week.

name and title split identity from display. name is the programmatic handle, and title is the human-facing string a host puts in a menu; when title is absent the host falls back to name, which is why a name of docs-onboarding-eng-v2 shows up in someone's attachment picker looking like a database key. A listing that has a name of "Engineering onboarding" doing double duty works, and gives the host nothing machine-readable to key on.

description is the only field that reaches the model in most hosts. When a host includes a listing summary in the context — some do, to let the model suggest attachments to the user — the description is what it reasons over. It is also what a user reads when deciding between on-call-escalation and on-call-rotation. The template description above spends a sentence on the shape of the variables, which is the difference between a user guessing Platform Engineering and typing platform-eng.

mimeType, the media-type label such as text/markdown or image/png that tells a consumer what kind of bytes it is about to receive, decides rendering and, in several hosts, eligibility. A host that will not attach application/octet-stream to a text-only provider filters those entries out of the menu before the user sees them. Declaring text/markdown on a document that turns out to be JSON is a mismatch the host discovers at read time, when it has already committed screen space to the attachment.

Declaring it in the SDKs

Both major SDKs make the split for you when you use their high-level API, and both let you get it wrong through the low-level one. In Python, a URI containing braces registers as a template and a URI without them registers as a concrete resource:

python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("docs")

@mcp.resource("docs://onboarding/engineering", mime_type="text/markdown")
def engineering_onboarding() -> str:
    return read_doc("onboarding/engineering")

@mcp.resource(
    "docs://team/{team}/policy/{name}",
    name="team-policy",
    title="Team policy document",
    description="A named policy document owned by one team.",
    mime_type="text/markdown",
)
def team_policy(team: str, name: str) -> str:
    return read_policy(team, name)

The first decorator puts an entry on resources/list; the second puts an entry on resources/templates/list and routes reads whose URI matches the pattern into team_policy with the variables bound. The TypeScript SDK asks for the distinction explicitly, through a ResourceTemplate wrapper:

ts
server.registerResource(
  "team-policy",
  new ResourceTemplate("docs://team/{team}/policy/{name}", { list: undefined }),
  {
    title: "Team policy document",
    description: "A named policy document owned by one team.",
    mimeType: "text/markdown",
  },
  async (uri, { team, name }) => ({
    contents: [{ uri: uri.href, mimeType: "text/markdown",
                 text: await readPolicy(team, name) }],
  }),
);

The { list: undefined } says this template does not enumerate its members. A server that can enumerate them — because the policy set is a query away — may supply a list callback instead, and then the same family also appears as concrete entries on resources/list.

Enumerating has a ceiling. resources/list is paginated with an opaque cursor, so a host walking 340 entries at a page size of 100 makes four round trips before it can draw a complete menu, and a host walking 50,000 file entries makes 500. At a measured 45 ms per page, 340 entries cost 180 ms of menu latency and 50,000 cost 22 seconds, which is a spinner nobody waits through. Somewhere between those two the enumeration stops being a listing and starts being a search problem that resources do not solve, and the template — one entry, one round trip, completion on demand — is what you fall back to.

Both latency figures come from the same two multiplications, and doing them by hand is what turns the ceiling from an assertion into something you can apply to your own catalogue. Divide the entry count by the page size and round up, because a partial final page still costs a round trip: three full pages of a hundred plus a fourth carrying the remaining forty is four requests, and fifty thousand divided by a hundred is five hundred with nothing left over. Multiply each count by the measured cost of a page and you have the wall-clock figure — four requests at 45 ms is the 180 ms quoted, and five hundred at the same 45 ms is the twenty-two and a half seconds quoted as 22 seconds. The reason the multiplication is the right operation rather than an over-estimate is that the pages are strictly sequential: the cursor for the next page arrives inside the response to the current one, so a host cannot request page three before page two has come back, and there is nothing to overlap. That sequencing is also what makes the fourth page's short size irrelevant — the host learns it was the last page only by finding no nextCursor in it, which it can only do after paying for it.

The listing is not static, either. A server that declares listChanged in its resources capability may send notifications/resources/list_changed when documents are added or removed, and the host re-lists. The docs server declares it and fires it on the nightly sync, which is how a policy written on Monday afternoon appears in the menu on Tuesday morning without the user reconnecting. A server that adds documents and does not declare listChanged leaves every host showing the listing it fetched at connect time, for as long as that session lives, and the earlier topic on architecture already covered why a client must degrade on the declaration rather than assume the notification will arrive.

Helping a user fill in a template

A template with two free-text variables is a form the user has to complete from memory, and the protocol has an answer for that: completion/complete, which asks the server to suggest values for one argument given what has been typed so far.

json
{
  "method": "completion/complete",
  "params": {
    "ref": { "type": "ref/resource",
             "uri": "docs://team/{team}/policy/{name}" },
    "argument": { "name": "team", "value": "pl" },
    "context": { "arguments": {} }
  }
}

The server replies with up to 100 candidate values and a total count, and the host turns that into an autocomplete. Completion is a separate declared capability, so a host that does not negotiate it shows a bare text field, and a server that does not implement it leaves every host showing a bare text field. For a family keyed on org slugs, that one capability separates a discoverable resource from one only its author can address.

It is worth being exact about what a template entry does and does not make discoverable, because the word covers two different things here. Publishing the template makes the shape of the family discoverable: a host now knows that policy documents exist, that they are keyed on a team and a name, and what MIME type they arrive as. It does not make any member discoverable, because the entry contains no member — there is nothing in it a user could pick. So the user's path to an actual document runs through knowledge the listing never supplied, and there are only three places that knowledge can come from: the description, if it describes the variables well enough to guess from; the user's own memory of the org's slugs; or a completion call. The first two are why a template with two free-text variables and a one-clause description behaves, in practice, like a family that was never advertised at all.

Three other ways a document fails to reach the menu

A template on the wrong listing is one fault, and the support ticket it generates — "the documents don't show up" — is generated identically by three other faults sitting at different points of the same short sequence.

Flowchart: initialize → resources capability declared?; resources capability declared? → no; no → host never lists: empty menu, no error; resources capability declared? → yes; yes → resources/list; resources/list → nextCursor?; nextCursor? → yes; yes → resources/list; nextCursor? → no; no → resources/templates/list; resources/templates/list → host draws the menu; host draws the menu → user picks a template entry; user picks a template entry → completion/complete per variable; completion/complete per variable → expanded URI, then resources/read.

The capability check happens before any listing and is the quietest of the failures. A server whose resources are registered after its capability object is assembled — a plugin loaded late, a registry populated in a startup hook that runs after the transport is constructed — announces itself like this:

json
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":true}},
  "serverInfo":{"name":"docs","version":"1.4.0"}}}

There is no resources key, so a compliant host does not call resources/list at all, and no request arrives that a server log could record as having failed. The user sees an empty section where the documents should be; the operator sees a healthy session in which the tools work perfectly.

Pagination produces the third variant. resources/list returns an opaque nextCursor whenever there is more to fetch, and a host that reads one page and stops renders exactly 100 of the 340 policies with nothing on screen to say it truncated. The asymmetry here is unpleasant: the defect is in the host, and the report arrives on your side phrased as "everything after the letter P is missing", which is what an alphabetically ordered catalogue cut at a page boundary looks like from the outside.

Symptom in the hostWhat is actually wrongWhere to look first
Empty menu, no error anywhereresources capability absent at initializethe initialize result, not the listing handler
Most documents appear, one obvious family does nota template published on resources/listwhether resources/templates/list returns []
A prefix of the catalogue appearsthe host ignoring nextCursorwhether the server sent a cursor at all
An entry appears and fails on reada braced string sitting in a uri fieldthe identifier field of that one entry

Read the table against the flowchart and the four rows line up one-to-one with the four points in the sequence where something can be interrupted, with no row left over and no branch unaccounted for. The capability check happens before any listing, so its failure removes the whole menu. The cursor loop happens inside resources/list, so its failure removes a suffix. The templates call happens after that loop, so its failure removes one family and leaves the concrete entries intact. The read happens after the user has already chosen, so its failure is the only one that produces an error message with a URI in it. That correspondence is what makes the symptom diagnostic rather than suggestive: how much of the menu survives tells you how far down the sequence the client got before it stopped, and that is a different question from which handler has the bug in it.

A listing is a claim about what exists

It is not an access-control boundary. Everything on it is a claim about what exists and what it is called, and omitting a URI from resources/list does not stop a client from reading it — a client that guesses docs://team/security/policy/incident-response sends the same resources/read it would have sent had the entry been listed, and the server answers unless it checks. Listings shape discovery; chapter 4 takes up what has to happen on the read itself.

Nor is a listing a manifest the model consumes by default. Tool definitions go into the provider request on every turn because the model has to know what it may call. Resource listings are consumed by the host, and whether any of it reaches the model is entirely the host's decision. A description written as instructions to the model — "use this when the user asks about escalation" — is aimed at a reader who, in most hosts, never sees it.

The listing also says nothing about whether the document changed since the last time anyone looked. annotations.lastModified can carry a timestamp, which is a hint for ordering and display and not a change feed.

Getting discovery right is therefore a matter of putting each entry on the listing whose identifier field it can legally fill, and then accepting that the listing has done its whole job at that point: a stable uri on resources/list, a uriTemplate on resources/templates/list with a description that tells a person what the variables look like. How many members of a family to publish as concrete entries is a judgement about menu usability with a hard ceiling set by pagination, and the two listings exist precisely so you can answer it per family rather than once for the whole server.

The docs server now advertises both shapes, and the host draws a menu. The next question is mechanical: when the user picks an entry and the client issues resources/read, what actually comes back — for a Markdown file, and for the architecture diagram sitting beside it as a PNG.

Resources — read the current version at https://daviddominguez.dev/learn/en/resources/advertising-resources-and-resource-templates

Back to top