Skip to content
Chapter 3 of 6 · ResourcesThe resources/read response

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:

json
{
  "jsonrpc": "2.0", "id": 7,
  "method": "resources/read",
  "params": { "uri": "docs://onboarding/engineering" }
}

The server answers with a contents array:

json
{
  "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.

json
{
  "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:

ts
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.

json
{
  "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.

Flowchart: host issues resources/read → response; response → attach to context (result.contents); response → drop attachment, tell user (-32002); response → client bug: log, do not retry (-32602); response → retry with backoff, then degrade (-32603); attach to context → compose provider request; drop attachment, tell user → compose provider request; retry with backoff, then degrade → compose provider request.

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.

python
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

Back to top