Skip to content
Chapter 6 of 6 · ResourcesA fetch tool beside resources

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 pathAdded tool path
Who invokeshost or user, before the turnmodel, mid-turn
Authorization runs inthe read handlerthe tool handler
Freshness owned byhost re-read or subscriptionwhenever the model calls it
Identifier isa URI the host storeda name the model wrote
Audit line readshost read URI X for turn Nmodel requested name X; host allowed
Standing token costnone until attachedtool schema in every request
Present in a session at allonly where the host surfaces italways

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.

Flowchart: host: resources/read → C; model: tools/call fetch_document → C; resolve URI + authorize session + load → contents: uri, mimeType, text or blob; contents: uri, mimeType, text or blob → resource result; contents: uri, mimeType, text or blob → tool result carrying an embedded resource.
python
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.

python
def tools_for(client: ClientInfo) -> list[Tool]:
    base = [search_documents, open_incident, ...]
    if client.name in RESOURCE_BLIND_HOSTS:
        return base + [fetch_document]
    return base

What you measure to defend it

The argument cannot be settled by preference, and every quantity it turns on is one the server can record.

MetricHow the server sees itWhat it decides
Read reachsessions with ≥1 resources/read ÷ sessions, per clientInfowhether the resource path works in that host at all
List-without-readsessions that listed and never reada host fetching a menu it never renders
Duplicate deliverysame URI served twice in one session, once per paththe cost of the second door, in tokens and contradictions
Fetch reliabilityturns where the document was relevant and the tool firedchapter 1's coin flip, measured on your corpus
Added latencyp50, the median, of the extra provider round trip per fetched turnwhat the tool path costs the user in seconds
Freshness at deliveryage of the document when it entered the contextwhether either path is serving stale content
Authorization denials-32002 counts, split by pathwhether 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

Back to top