Chapter 3 of 6 · 13 min read
The resources/read response
On this page
A read is the simplest exchange in this topic and the one with the most room for a silent failure, because its request has no arguments, no schema and no model in the loop. The host sends the URI it stored when the user attached the item; the server answers with contents. There is nothing to negotiate and nothing to get wrong on the way in.
What there is to get wrong is on the way out, and it is a matter of shape rather than semantics. The response shape is small enough to hold in your head — a list of contents, where each entry names the URI it came from, declares a MIME type, and puts the payload in one of two fields — and the whole of this chapter's failure surface is what a client does when it assumes the wrong one of those two fields is populated.
What comes back for a Markdown document
The request:
{
"jsonrpc": "2.0", "id": 7,
"method": "resources/read",
"params": { "uri": "docs://onboarding/engineering" }
}The server answers with a contents array:
{
"jsonrpc": "2.0", "id": 7,
"result": {
"contents": [
{
"uri": "docs://onboarding/engineering",
"name": "engineering-onboarding",
"title": "Engineering onboarding",
"mimeType": "text/markdown",
"text": "# Engineering onboarding\n\nWelcome. Your first week...\n"
}
]
}
}Three things in that reply do work later. The uri is echoed rather than assumed, so a
client that batched several reads can match replies to requests, and a server that resolved a
template into a concrete document can say which one it resolved to. The mimeType tells the
host how to render the payload and whether it can be sent to this provider at all. The text
field holds the document as a string, and the absence of a blob field beside it is what
marks this entry as textual.
The array is an array on purpose. One URI may resolve to more than one piece of content:
file:///repo/src/ can come back as every file under it, a spreadsheet URI can come back as
one entry per sheet, and a bundle URI can come back as a manifest plus its parts. Most reads
return a single entry, and client code that indexes contents[0] and ignores the rest is
making an assumption that holds for most servers and silently truncates the ones where it
does not. Iterating costs one loop.
The same call for a PNG
file:///repo/architecture.png is 1,834,208 bytes on disk. JSON has no way to carry those
bytes, so the server base64-encodes them — rewriting arbitrary binary as a string drawn from
64 characters every JSON parser can handle — and puts the result in blob.
{
"jsonrpc": "2.0", "id": 8,
"result": {
"contents": [
{
"uri": "file:///repo/architecture.png",
"mimeType": "image/png",
"blob": "iVBORw0KGgoAAAANSUhEUgAABLAAAAKgCAYAAAA..."
}
]
}
}The ratio between what is on disk and what goes on the wire is not a constant somebody chose; it falls out of the encoding in one step. A JSON string holds characters, and an arbitrary sequence of bytes is not a valid sequence of characters, so the bytes have to be re-expressed in a repertoire that is. Base64's repertoire has 64 members, and 64 is two multiplied by itself six times, which means one character can distinguish that many possibilities and therefore carries exactly six bits — no more, because there are not enough characters, and no fewer, because that would waste some of them. Bytes come in eight bits, so the encoder needs the smallest run of characters whose bits divide evenly into bytes: four characters carry twenty-four bits, and twenty-four bits is three bytes.
Encoding inflates the payload by four bytes for every three, so 1,834,208 bytes leave the server as 2,445,612 bytes of base64 plus JSON overhead — 2.4 MB in a log that records response sizes. That inflation is paid on the wire, in the client's memory while it parses the response, and again in any log that records response bodies. A server that returns a handful of screenshots per session will never notice; a server that exposes a directory of 40 design mockups at 3 MB each is shipping 160 MB of base64 through a JSON parser to satisfy one directory read, which is the point at which the URI you exposed should have been the directory listing rather than the directory contents.
Both of those figures can be reproduced from the disk size and the four-for-three rule, and
reproducing them is how you check that a rule of thumb has been applied and not merely quoted.
Take the byte count, divide it into groups of three and round up, because a final group with
one or two bytes left in it still costs a full four characters, padded out with = signs.
Multiply the group count by four. Applied to 1,834,208 bytes that yields the 2,445,612 figure
exactly, not approximately — and the remainder is the interesting part of the check, because
that byte count is two more than a whole number of groups, so the last group is a short one
and the encoded string ends in a single padding character. Exposing a whole directory behind
one URI runs the same arithmetic twice: forty mockups at 3 MB each is a hundred and twenty
megabytes on disk, and a hundred and twenty grown by a third is the 160 MB quoted. If your own
measurement of an
encoded payload comes out meaningfully above four-thirds of the source, the excess is JSON
overhead — the quoting, the escaping, the field names around it — rather than the encoding,
and that is a different line item to attack.
The two fields are mutually exclusive, and that is the whole discriminator a client needs:
type ResourceContent = {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
function toAttachment(c: ResourceContent): Attachment {
if (typeof c.text === "string") {
return { kind: "text", uri: c.uri, mimeType: c.mimeType ?? "text/plain", body: c.text };
}
if (typeof c.blob === "string") {
return { kind: "binary", uri: c.uri, mimeType: c.mimeType ?? "application/octet-stream",
bytes: Buffer.from(c.blob, "base64") };
}
throw new Error(`resource ${c.uri} returned neither text nor blob`);
}The throw at the end is the line most host adapters are missing. A content entry with neither field is a server bug, and a client that turns it into an empty string hides that bug behind a model that appears to have read the document and ignored it — which is a very expensive thing to debug, because every trace on the way in looks healthy.
The empty-string default is worth understanding as something other than carelessness, because
the path to it is short and it runs through a tool that is supposed to help. The wire type
marks both text and blob as optional, which is correct — either may be absent on any given
entry. A type checker reads "optional" and refuses to let a possibly-absent string be used
where a string is required, which is also correct. It then asks the author to handle the
absent case, and the cheapest handling that satisfies it is a default value. So the compiler
poses a question about types and accepts an answer about types, and the question it never asks
is the semantic one: what does it mean for a content entry to carry neither payload field.
The answer is that it means nothing, because no such entry is legal — and a type system that
records optionality has no way to record that two optional fields are jointly required. The
throw is the author supplying that missing constraint by hand.
mimeType is a declaration and not a guarantee. Nothing in the protocol verifies that the
bytes in blob are a PNG, and a host that trusts the declaration and hands image/png to a
vision-capable provider will get a provider-side error when the bytes turn out to be a JPEG
that someone renamed. Sniffing the first few bytes before forwarding is cheap. The same
applies in the other direction: a server that returns JSON in text under a declared
text/markdown is telling every host to render it as prose.
When the document is not there
The rule chapter 1 set out — the host controls invocation, the model does not — has a
consequence here that changes who has to understand the failure. A tool call that fails
returns a result with isError: true, and that result goes into the transcript for the model
to interpret, which means the model decides whether to retry, to try a different identifier,
or to apologise to the user. A resource read that fails is a JSON-RPC error returned to the
host, and the model never sees it unless the host chooses to put it there.
{
"jsonrpc": "2.0", "id": 9,
"error": {
"code": -32002,
"message": "Resource not found",
"data": { "uri": "docs://team/platform/policy/on-call-escalation" }
}
}-32002 is the code the specification reserves for a resource that does not exist. Alongside
it sit the ordinary JSON-RPC codes: -32602 for a malformed URI or a scheme the server does
not serve, and -32603 for the storage layer being down. Those three cases want three
different host behaviours, and collapsing them is a common shortcut. A document that does not
exist should drop the attachment and tell the user, since retrying will not help. A malformed
URI is a client bug worth surfacing loudly in development. A backend failure is the one worth
retrying with backoff, because the document probably exists and the server could not reach
it.
A saved session makes the not-found case ordinary rather than exceptional. A host that
persisted docs://team/platform/policy/on-call-escalation in a workspace on Monday will
re-read that exact string when the workspace reopens on Thursday, and the policy may have
been renamed in between. The read is the only place that discovers it. Deciding what happens
next is a host design question with real answers — re-list and offer the closest match, keep
the last-read copy and mark it stale, or drop it silently — and the one thing that does not
work is passing the error through to the model as though it were a failed tool call, because
the model has no way to fix a URI it did not choose.
Reads should also carry a timeout that is shorter than the session's patience. A docs server
answering a Markdown read in 12 ms at the median and 40 ms at p99, the figure 99% of reads
come in under, makes a 5-second ceiling generous; a host that waits indefinitely on a server
whose storage layer has stalled will hold up the turn behind an attachment nobody asked to be
blocking. The MCP base protocol allows either side to cancel an in-flight request with a
notifications/cancelled, and a host that attaches four resources in parallel and cancels the
stragglers at 800 ms will assemble a context window on a predictable schedule.
Those two ceilings are far apart on purpose, and the gap between them is the teaching. The 5-second ceiling sits more than a hundred times above the 40 ms p99, which is deliberately absurd headroom: it is not tuned to the server's performance at all, it exists to distinguish "slow" from "never going to answer", and a value close to the observed distribution would start failing healthy reads for no gain. The 800 ms cancellation sits about twenty times above the same p99 and is tuned to something else entirely — the turn's schedule, which is a property of the user's patience rather than of the server. The statistic to size it against is the tail rather than the median, because four reads issued in parallel finish when the slowest of them finishes, so a policy sized on the 12 ms median is sized on a number the host almost never waits for.
Reading it from a client
Against a session that is already initialised, the read is one call in both SDKs, and the SDK hands back the same two-field shape rather than flattening it for you.
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.read_resource("file:///repo/architecture.png")
for content in result.contents:
if isinstance(content, TextResourceContents):
attach_text(content.uri, content.mimeType, content.text)
elif isinstance(content, BlobResourceContents):
attach_bytes(content.uri, content.mimeType, base64.b64decode(content.blob))Nothing in that code path touches a model, and nothing about it is asynchronous with respect to the conversation. The host runs it while it is assembling the request, gets bytes, and either has them before the provider call or knows that it does not.
A read is neither a query nor a cache
A read is not a query. There is no filter, no projection, no range and no Accept header —
the request carries a URI and nothing else, so a client that wants lines 400 to 500 of a
12 MB log either gets all 12 MB or the server exposes log://service/api/tail/{lines} as a
separate template and the client reads that instead. Parameters live in the URI or they do
not exist, which is a real constraint on what belongs behind a resource at all.
A read is not a stream. The response arrives as one JSON-RPC message, so a 40 MB document is 40 MB of buffering on both sides before anything is usable. Servers that need to expose large or growing data usually expose a pointer to it — a signed HTTPS URL in a small JSON document — and let the host fetch the bytes out of band.
A read carries no cache semantics. There is no ETag, no conditional read and no revalidation
in the protocol; if a client wants to avoid re-fetching an unchanged document it has to build
that on annotations.lastModified or on the subscription mechanism, and either way it is the
client's bookkeeping rather than something the read gives it. Freshness is where the read
shows its limit generally: what comes back is a snapshot as of the moment the server
answered, and it stays that snapshot in the context window for as long as the transcript
lives. A host that re-reads every attached URI immediately before each turn keeps the context
within one turn of the truth at the cost of one round trip per attachment per turn, which is
4 attachments × 12 ms against a fast docs server and considerably more against one that
fronts a slow backend. Chapter 5 takes up what to do about that.
A read is also not the only way a document can reach a context window, and the neighbouring
mechanism is easy to mistake for this one. A tool result may contain a resource_link, which
is a URI and a MIME type with no bytes attached, leaving the host to decide whether to follow
it with a resources/read. A tool result may also contain an embedded resource, which is the
same {uri, mimeType, text|blob} shape you have been reading throughout this chapter,
inlined into the tool's output. The second form lets a tool hand the host a self-describing
payload rather than a wall of untyped text, and it changes nothing about who chose to invoke
it, since the model still did. Seeing the resource contents shape inside a tool result does
not make that call a resource read.
A read is also not an authorization decision the client can make. The host sends a URI it believes the session may see, and the server answers, and the only thing standing between those two facts is whatever check the server performs.
The shape is therefore the whole contract, and it is worth being strict about it on both sides. A server states the URI it actually served, states a MIME type it believes, and populates exactly one payload field per entry. A client iterates the array, branches on which field is present, and treats neither-present as an error rather than as an empty document. Everything else here — the base64 inflation, the three error codes, the timeout, the cancellation — follows from those two sentences being honoured or not.
Every URI in this chapter has been a fixed string the host stored from a listing. The moment the client builds one by expanding a template with values that came from somewhere else, both halves of the read get harder: the string has to be built correctly, and the server has to stop believing the client about who it is asking on behalf of.
Resources — read the current version at https://daviddominguez.dev/learn/en/resources/the-resources-read-response