Chapter 2 of 6 · 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.
{
"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/list | resources/templates/list | |
|---|---|---|
| Result key | resources | resourceTemplates |
| Identifier field | uri, a concrete URI | uriTemplate, an RFC 6570 template: a URI with {braces} marking variables |
| Also carries | name, title, description, mimeType, size, annotations | name, title, description, mimeType |
| Paginated | yes, via cursor / nextCursor | yes, via cursor / nextCursor |
| What a host does | renders a browsable, attachable item | renders a form, an autocomplete, or nothing |
| Change notification | notifications/resources/list_changed | same 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:
{
"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:
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:
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.
{
"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.
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:
{"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 host | What is actually wrong | Where to look first |
|---|---|---|
| Empty menu, no error anywhere | resources capability absent at initialize | the initialize result, not the listing handler |
| Most documents appear, one obvious family does not | a template published on resources/list | whether resources/templates/list returns [] |
| A prefix of the catalogue appears | the host ignoring nextCursor | whether the server sent a cursor at all |
| An entry appears and fails on read | a braced string sitting in a uri field | the 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