Chapter 6 of 6 · 14 min read
A fetch tool beside resources
On this page
A resource is only as reachable as the host makes it, and some hosts make it not reachable at
all. A host with no attachment picker for server resources, no @-mention of URIs and no
policy layer that reads on the user's behalf will fetch your listing, render nothing, and
leave every document you publish invisible to the model — the deployment risk chapter 1
raised as the honest half of "the model can't call them".
The reflexive repair is one tool that can return any of the documents, get_document(name),
which takes an afternoon and makes them reachable in every host including the ones that
behave correctly. The argument against it is not purity. It is that the tool does not replace
the resource path — both exist afterwards — and a server with two ways in to the same
documents carries the cost of both for as long as it lives.
What the second door costs
| Resource path | Added tool path | |
|---|---|---|
| Who invokes | host or user, before the turn | model, mid-turn |
| Authorization runs in | the read handler | the tool handler |
| Freshness owned by | host re-read or subscription | whenever the model calls it |
| Identifier is | a URI the host stored | a name the model wrote |
| Audit line reads | host read URI X for turn N | model requested name X; host allowed |
| Standing token cost | none until attached | tool schema in every request |
| Present in a session at all | only where the host surfaces it | always |
Two authorization sites is the entry that will hurt first. Chapter 4's check — compare the
session's principal against the tenant or team in the identifier, on every read — now has to
exist twice, in two handlers, with two ways of parsing an identifier, and it has to stay
correct in both through every future change. The tool's identifier is worse to check, because
name is a flat string the model composed rather than a URI with a structure the server
defined, so the mapping from name to document is a lookup table that someone will extend
without remembering that the table is a security boundary.
Two freshness stories is the entry that will hurt longest. Everything chapter 5 established
about subscriptions, re-reads and supersede messages applies to the resource path and none of
it applies to the tool path, where the document arrives whenever the model asks and sits in
the transcript afterwards with no mechanism to update it. A host that does both — attaches a
runbook at session start and lets the model call get_document("incident-runbook") at turn 40
— ends up with two copies of the same document in one context, thousands of tokens apart, and
they are only identical until someone edits the runbook between the two reads.
The token arithmetic is less serious and easier to measure. On a knowledge server exposing 60
documents and 6 tools, a get_document schema with an enum of 60 document names costs about
610 tokens, against 890 tokens for the six existing tool definitions, so the tool listing in
every request grows by roughly 68%. A 200-turn session pays that on every request; prompt
caching — the provider charging a reduced rate for a prefix of the request it has already
processed — absorbs most of the cost when the listing is stable, and it stops absorbing
anything the moment a document is added and the enum changes. Duplicating a 4 KB runbook
inside one context costs about 1,000 tokens twice.
Two of those figures deserve to be taken apart, because both of them generalise and neither is about this server. The percentage first: 610 divided by 890 is a bit over sixty-eight hundredths, so the 68% is growth in the tool listing and not growth in the request. A request also carries a system prompt, a transcript and whatever the host attached, and against that total the same 610 tokens is a much smaller fraction — which matters, because the figure gets quoted in both senses and only one of them is what the table measured. The per-name cost is the one that scales: 610 tokens spread across 60 document names is roughly ten tokens a name, and the enum is the only part of the schema that grows with the corpus, so a server with ten times the documents pays ten times that component. The 4 KB runbook costing about 1,000 tokens is the same rule of thumb in the other unit — roughly four bytes to a token for ordinary prose — and it is the arithmetic that makes duplicate delivery a real number rather than an aesthetic complaint.
Prompt caching absorbs the standing cost only for as long as the prefix is stable, and the word doing the work there is prefix. A provider charges the reduced rate for a run of tokens at the front of the request that matches what it processed last time, and it can only do that up to the first position where the two requests differ. The tool listing belongs to the stable part of that prefix, which is exactly why it was cacheable in the first place. Adding a document changes the enum, the enum changes a token inside the prefix, and everything from that position onward counts as new — including the parts of the request the host was relying on the cache for. One document added to a corpus therefore does not cost a little more cache; it costs the whole cache, on the next request, for every session connected. That is why the enumerating design is worse than its token count suggests, and why a tool whose schema does not mention the corpus is a different kind of object rather than a slightly cheaper one.
The behavioural cost is the one that surprises teams. In hosts that do surface resources, the model will call the tool anyway, because a tool in the listing is an invitation and the model has no reliable way to know that the document already sitting in its context is the same document the tool would return. On that server's staging replay the duplicate-fetch rate was 34% of turns where the runbook was already attached.
It is worth being precise about why the model has no reliable way to know, because the answer
is not that it is careless. An attachment reaches the model as content — a block of runbook
text somewhere in the context. A tool reaches it as a name, a description and a schema. Those
are two different kinds of object, and nothing in either one refers to the other; the only
thing that could establish the identity between them is a shared identifier, and the resource
path has one while the tool path takes a flat name the model composed. So the question "is
the document I would get back the document I already have" is not a question the model is
answering badly. It is a question the design has left with no evidence to answer it from, and
the model's only available inference is from the prose of the runbook to the prose of the tool
description, which is exactly the kind of match a tool description is written to win.
One resolver, two front doors
The design that keeps most of what the tool buys and little of what it costs is to make the tool a thin adapter over the read handler rather than a second implementation of it. The authorization check, the identifier parsing, the storage access and the MIME typing stay in one place, and the tool contributes exactly one thing: a way for the model to trigger that path.
async def read_document(uri: str, principal: Principal) -> ResourceContents:
team, name = parse_document_uri(uri) # one parser
if team not in principal.allowed_teams: # one authorization site
raise ResourceNotFound(uri)
return storage.load(team, name) # one freshness story
@mcp.resource("docs://team/{team}/policy/{name}", mime_type="text/markdown")
async def policy_resource(team: str, name: str, ctx: Context) -> str:
contents = await read_document(f"docs://team/{team}/policy/{name}", ctx.principal)
return contents.text
@mcp.tool()
async def fetch_document(uri: str, ctx: Context) -> EmbeddedResource:
"""Fetch a policy document by its docs:// URI. URIs come from
search_documents. Do not call this for a document already attached
to this conversation."""
contents = await read_document(uri, ctx.principal)
return EmbeddedResource(resource=contents)Three details in that sketch are doing the work. The tool takes a URI rather than a flat name,
so there is one identifier vocabulary in the system and the audit log lines up across both
paths. It returns an embedded resource — the {uri, mimeType, text} shape from chapter 3 —
so the host receives something it can deduplicate against what it already attached, rather
than an anonymous string. And it does not enumerate the corpus in its schema; the model gets
URIs from a search_documents(query) tool that returns resource links, which keeps the tool
listing at a fixed size as the corpus grows and turns the unbounded case into the search it
actually is.
The line in the docstring telling the model not to fetch an already-attached document is a mitigation with a known failure rate rather than a fix. On the staging replay it cut the duplicate-fetch rate from 34% to 12%, which is worth having and is not the same as solving it. The host-side answer is to drop a tool result whose URI matches something already in the context, which some hosts do and most do not.
There is a sharper move available when you know which host you are talking to. The
initialize handshake carries clientInfo, so a server can register the fetch tool only for
clients it has observed to be resource-blind, and leave the listing clean for hosts that
attach properly.
def tools_for(client: ClientInfo) -> list[Tool]:
base = [search_documents, open_incident, ...]
if client.name in RESOURCE_BLIND_HOSTS:
return base + [fetch_document]
return baseWhat you measure to defend it
The argument cannot be settled by preference, and every quantity it turns on is one the server can record.
| Metric | How the server sees it | What it decides |
|---|---|---|
| Read reach | sessions with ≥1 resources/read ÷ sessions, per clientInfo | whether the resource path works in that host at all |
| List-without-read | sessions that listed and never read | a host fetching a menu it never renders |
| Duplicate delivery | same URI served twice in one session, once per path | the cost of the second door, in tokens and contradictions |
| Fetch reliability | turns where the document was relevant and the tool fired | chapter 1's coin flip, measured on your corpus |
| Added latency | p50, the median, of the extra provider round trip per fetched turn | what the tool path costs the user in seconds |
| Freshness at delivery | age of the document when it entered the context | whether either path is serving stale content |
| Authorization denials | -32002 counts, split by path | whether both checks are actually running |
Two of those are decision rules rather than dashboard entries. Read reach below some threshold in a host — one team set it at 20% — is the trigger to ship the fetch tool for that host and nowhere else. Duplicate delivery above a threshold is the trigger to take it away again, because at that point the second door is costing more than it is opening. Writing both thresholds down before the change ships is what makes the decision reviewable in six months, when the host has shipped an attachment picker and nobody remembers why the tool exists.
Those two percentages are more informative together than either is alone, because they share a denominator. Both are computed over the same 4,100 sessions from that one customer, so subtracting gives the share that listed and never read — a bit over half of all their sessions — and that quantity is precisely the "list-without-read" row of the table above. The subtraction is only exact if every reading session had also listed, which is the case here because a client with no picker has no other source for a URI, and it is the one assumption worth stating out loud before the figure is quoted anywhere. Granted it, there is no residual: the gap between the two percentages is fully accounted for by sessions that fetched a menu and rendered nothing. That is what rules out the competing explanations. A connection problem would have shown up in the listing figure; a permission problem would have shown up as denials; a host that renders resources but whose users ignore them would have produced reads. Listing without reading is the signature of one specific defect, which is a client that walks the protocol correctly and has nowhere to put the result.
The same two decisions the section above described as thresholds can now be checked against the numbers rather than asserted. Read reach of 9% sits below the 20% line that team wrote down, which is what licensed shipping the fetch tool for that host; read reach of 71% after the picker landed sits above it, which is what licensed taking the gate off. Neither decision required a fresh argument, and that is the pragmatic reason to write the threshold before the change rather than after: the second decision was made eleven months later, by people who had not been in the first conversation.
Where a fetch tool is the right answer anyway
None of that makes the tool wrong. There are shapes of problem where a fetch tool is the correct design and the resource path was never going to work.
The first is a corpus that cannot be enumerated. Sixty documents fit in a menu; 60,000 do not, and chapter 2's pagination arithmetic put the ceiling somewhere in the low thousands before the menu stops being usable. Beyond that the user's real operation is a search rather than a browse, and a search whose query is composed mid-reasoning is chapter 1's canonical tool.
The second is relevance that only exists mid-reasoning. If the model is halfway through diagnosing an incident and needs the runbook for a subsystem it has just identified, no host could have attached that document in advance, because the host did not know which subsystem it would be. That is a genuine mid-turn decision and it belongs to the model.
The third is a host population that will not change on any timescale you can plan around. If 90% of your users are on a host with no resource support and its maintainers have not shipped an attachment interface in a year, the choice is between documents that reach the model through a tool and documents that reach nobody. Reaching nobody is not the safer option.
What distinguishes the good version from the reflexive one is that each of these is a claim
you can check. "Our hosts do not surface resources" is a measurement, and the server already
has the data. The clientInfo gate is the same discipline applied to the fix: it costs a
table of client names that someone has to maintain and that is wrong the day a host ships
resource support, so keeping it honest means re-deriving the list from telemetry on a
schedule rather than editing it by hand, and it is still a heuristic on a version string.
The decision, then, is never "resources or a tool" in the abstract. It is whether the documents are reaching the model in the hosts you actually have, which is a number; and if they are not, whether the second door can be built as a front door onto the same resolver, gated to the hosts that need it, with a threshold that removes it later. Two parsers, two authorization checks and two freshness stories are what you pay when the tool is a second implementation, and almost none of that is what makes the documents reachable.
Everything in this topic has been about one boundary: whether a piece of data enters the context because an application decided it should, or because a model asked for it. Resources give the application side a typed, addressable, subscribable way to reach into a server, and the work of using them well is mostly work you do in the host — deciding what to attach, when to re-read, what to supersede, and what to refuse. Pick one document your server already returns from a tool, give it a URI, put it on the right listing, and read it from a client with the checks from chapter 4 in the handler. The interesting part starts at the second document, when you have to decide whether it is one resource, a family under a template, or a search.
Resources — read the current version at https://daviddominguez.dev/learn/en/resources/a-fetch-tool-beside-resources