Module 4: Resources — context and data

`mimeType`, `text`, and `blob`

Description

Reservo's two policies are plain text, so far you've only seen contents's text field. But MCP allows for a resource to be binary — an image, a PDF, an audio file — and needs a way to carry those bytes inside a JSON message, which only knows how to represent text. This lesson shows the complete mechanism: mimeType declares what type of content it is, and depending on that type, contents carries text (for readable content) or blob (for binary content, base64-encoded).

Connection to the module

This lesson closes the complete resources/read cycle lessons 03 and 05 opened: you already saw the catalog mechanics and reading by URI; this lesson goes deeper into the content's exact shape, the last detail missing before moving on to lesson 07's design criterion (when a resource, when a tool).


mimeType: what type of content you're receiving

mimeType is a standard string (defined by IANA, the same type system HTTP's Content-Type header uses) that tells whoever receives a resource how to interpret it, without having to guess from the name's extension. Some examples:

text/plain          -> plain, unformatted text
text/markdown       -> text with Markdown syntax (Reservo's 2 policies)
application/json    -> a JSON document
image/png           -> a PNG image (binary)
application/pdf     -> a PDF document (binary)
audio/mpeg          -> an MP3 audio file (binary)

The rule that decides text vs blob isn't "whichever mimeType you prefer" — it's whether the content is readable text as a string or binary data. text/markdown, text/plain, application/json (almost always) go in text. image/*, audio/*, application/pdf, and generally any binary format go in blob.


Why binary can't go directly in the JSON

JSON is, by design, a text format. A JSON string can only contain valid text characters (with the escapes you already saw in Module 2, like \n) — it can't contain arbitrary binary bytes, which could include sequences that break the JSON's own syntax or that aren't even valid text in any encoding. The standard solution —the same one countless JSON-based protocols use, nothing MCP-specific— is to encode the binary bytes as a text string, using Base64: a scheme that represents any byte sequence using only 64 printable characters (letters, digits, +, /, and = for padding). The result is already legitimate text, safe to put inside a JSON string.

import base64

raw_bytes = b"\x89PNG\r\n\x1a\n..."       # real binary bytes
encoded = base64.b64encode(raw_bytes).decode("ascii")   # -> string safe for JSON

base64.b64encode receives bytes and returns bytes (that's why the .decode("ascii"): Base64 only produces ASCII characters, so decoding is safe with no risk of an encoding error). The result is a string longer than the original bytes —Base64 has a size cost of roughly 33%—, but in exchange it's transportable inside any JSON text field, with no exceptions.


Worked example: a text resource and a binary one, executed

This example uses a separate demo server —not this module's canonical reservo_mcp_server.py, which only has the two text policies— with a second resource invented for the case: reservo://rooms/focus-floorplan.png, the Focus room's floor plan, as a real PNG image (a 1×1 pixel transparent PNG, 67 fixed bytes — small enough to quote in full, but a genuinely valid PNG, not made-up data).

# blob_demo_server.py (relevant fragment)
import base64

# 1x1 transparent pixel PNG -- fixed, deterministic bytes (a known constant, NEVER random).
FLOORPLAN_PNG_BYTES = bytes.fromhex(
    "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
    "0000000a49444154789c6360000002000100ff9f0d9e0000000049454e44ae426082"
)

def handle_resources_read(msg_id, params):
    uri = params.get("uri")
    resource = RESOURCES.get(uri)
    if resource is None:
        return {"jsonrpc": "2.0", "id": msg_id,
                "error": {"code": -32002, "message": "Resource not found", "data": {"uri": uri}}}

    if resource["kind"] == "text":
        content = {"uri": resource["uri"], "mimeType": resource["mimeType"], "text": resource["text"]}
    else:
        encoded = base64.b64encode(resource["bytes"]).decode("ascii")
        content = {"uri": resource["uri"], "mimeType": resource["mimeType"], "blob": encoded}

    return {"jsonrpc": "2.0", "id": msg_id, "result": {"contents": [content]}}

Notice the branch: kind == "text" assembles contents with the text field; kind == "blob" encodes the bytes with base64.b64encode and assembles contents with the blob field instead. Never both fields at once — it's the same mutual-exclusion rule you already saw with result/error in Module 2, applied here to text/blob.

The client reads both resource types in the same run:

# blob_demo_client.py (relevant fragment)
send({"jsonrpc": "2.0", "id": next(ids), "method": "resources/read",
      "params": {"uri": "reservo://policies/cancellation-policy"}})
text_response = recv()
text_content = text_response["result"]["contents"][0]
print(f"[client] text content -> mimeType={text_content['mimeType']!r}, 'text' field present={'text' in text_content}, 'blob' field present={'blob' in text_content}")

send({"jsonrpc": "2.0", "id": next(ids), "method": "resources/read",
      "params": {"uri": "reservo://rooms/focus-floorplan.png"}})
blob_response = recv()
blob_content = blob_response["result"]["contents"][0]
print(f"[client] blob content -> mimeType={blob_content['mimeType']!r}, 'text' field present={'text' in blob_content}, 'blob' field present={'blob' in blob_content}")
print(f"[client] blob (base64, {len(blob_content['blob'])} characters): {blob_content['blob']}")

raw_bytes = base64.b64decode(blob_content["blob"])
print(f"[client] decoded bytes: {len(raw_bytes)} bytes, first 8 in hex: {raw_bytes[:8].hex()}")

What to expect:

[client -> server] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[server -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, "serverInfo": {"name": "reservo-mcp-server", "version": "1.0.0"}}}
[client -> server] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client -> server] {"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "reservo://policies/cancellation-policy"}}
[server -> client] {"jsonrpc": "2.0", "id": 2, "result": {"contents": [{"uri": "reservo://policies/cancellation-policy", "mimeType": "text/markdown", "text": "# Cancellation Policy\n\n(full text in lesson 05)\n"}]}}
[client] text content -> mimeType='text/markdown', 'text' field present=True, 'blob' field present=False
[client -> server] {"jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": {"uri": "reservo://rooms/focus-floorplan.png"}}
[server -> client] {"jsonrpc": "2.0", "id": 3, "result": {"contents": [{"uri": "reservo://rooms/focus-floorplan.png", "mimeType": "image/png", "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGNgAAACAAEA/58NngAAAABJRU5ErkJggg=="}]}}
[client] blob content -> mimeType='image/png', 'text' field present=False, 'blob' field present=True
[client] blob (base64, 92 characters): iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGNgAAACAAEA/58NngAAAABJRU5ErkJggg==
[client] decoded bytes: 67 bytes, first 8 in hex: 89504e470d0a1a0a

The last line closes the loop: the client takes the blob string (92 characters of Base64), decodes it with base64.b64decode, and recovers exactly the PNG's original 67 binary bytes — confirmed by the same hexadecimal prefix (89504e470d0a1a0a, the standard signature of any valid PNG file) FLOORPLAN_PNG_BYTES had on the server's side. The complete roundtrip —bytes → Base64 → JSON → Base64 → bytes— loses not a single byte.


text and blob in a table

FIELD   TYPE OF CONTENT               EXAMPLE mimeType             HOW IT'S TRANSPORTED
------------------------------------------------------------------------------------
text    Text readable as a string     text/plain, text/markdown,   Normal JSON string,
                                       application/json               unencoded
blob    Binary data                    image/png, application/pdf, Base64 string
                                       audio/mpeg                    (`base64.b64encode`)

A contents element always carries exactly one of the two, never both, never neither. mimeType is the clue for which to expect, but the source of truth is which field is actually present in the JSON — the way this example's client did, checking with in instead of assuming.


Common mistakes

  1. Forgetting the .decode("ascii") after base64.b64encode. b64encode returns bytes, not str — trying to put that value directly into a dictionary that later goes through json.dumps produces a TypeError: Object of type bytes is not JSON serializable. The .decode("ascii") is mandatory before the value can travel in a JSON-RPC message.

  2. Sending raw binary bytes inside text, unencoded. This would break JSON serialization immediately (json.dumps would fail trying to serialize bytes, which isn't a valid JSON type) — or worse, if forced with .decode("utf-8", errors="replace"), it would silently corrupt the binary data, which would no longer be reconstructible. blob with Base64 exists exactly to avoid this problem.

  3. Putting text and blob together "just in case." The specification is explicit: they're mutually exclusive, the same rule you already saw with result/error. A well-written client shouldn't have to handle the case of both being present — it's not a valid state.

  4. Assuming mimeType starting with text/ always implies the text field. It's the most common convention, but not an absolute rule of the specification — what determines whether something goes in text or blob is whether the content is representable as readable text, not the literal mimeType prefix. In practice, though, you'll find almost every server follows this convention (readable text with mimeType text/* or application/json goes in text; everything else, in blob).


Exercises

Exercise 1: Classify five mimeTypes (Easy)

For each one, say whether the corresponding content would go in text or blob:

A) text/csv
B) image/jpeg
C) application/json
D) application/octet-stream
E) text/html
See solution
  • A) text — a CSV is readable plain text, with commas and newlines.
  • B) blob — a JPEG image is binary.
  • C) text — JSON is text (though structured), representable directly as a string.
  • D) blobapplication/octet-stream is, literally, the generic mimeType for "binary data with no more specific type" — always goes in blob.
  • E) text — HTML is markup text, readable as a string.

Exercise 2: Encode and decode your own bytes (Medium)

Write a script that takes the string "Reservo" (encoded to bytes with .encode("utf-8")), encodes it in Base64, prints the result, and then decodes it back, confirming with an assert that the final result matches the original string exactly.

See solution
import base64

original_text = "Reservo"
original_bytes = original_text.encode("utf-8")

encoded = base64.b64encode(original_bytes).decode("ascii")
print("base64:", encoded)

decoded_bytes = base64.b64decode(encoded)
decoded_text = decoded_bytes.decode("utf-8")
print("decoded:", decoded_text)

assert decoded_text == original_text
print("OK: the roundtrip lost no characters")

Expected output:

base64: UmVzZXJ2bw==
decoded: Reservo
OK: the roundtrip lost no characters

Explanation: even though this example uses text (not real binary), it proves Base64 is a general encoding mechanism for any byte sequence, text or not. The reason Reservo doesn't encode its policies this way is simply that it isn't needed — they're already readable text, and json.dumps serializes them directly with no need for the extra Base64 step, which only adds size with no benefit for content that's already text.

Exercise 3: Why Base64 and not a more efficient encoding? (Hard)

A teammate proposes: "Base64 adds 33% size overhead — why not carry the binary directly as raw bytes over the stdio pipe, since we're using subprocess anyway?". Explain why that proposal would break Module 2 lesson 03's framing (one message per line), and why Base64 —despite the overhead— remains the correct choice for this transport.

See solution

Stdio's framing (Module 2, lesson 03) depends on a strict rule: every message occupies exactly one line, delimited by \n, and the complete message has to be valid JSON, parseable with json.loads. Raw binary bytes could contain, at any position, the byte sequence representing a newline (0x0A) — and if that happened inside a field being shoved "as-is" into the middle of a JSON message, the reader's readline() would cut the message right there, in the middle of binary data, exactly the same framing problem Module 2's lesson 03 showed with real, unescaped newlines inside text. Worse still: arbitrary binary bytes generally aren't even a valid UTF-8 sequence — and this guide's stdio transport uses text=True in subprocess.Popen, which assumes everything traveling over the pipes can be decoded as text.

Base64 solves both problems at once: by encoding any byte sequence using only a fixed set of 64 printable characters (no \n, no control bytes, nothing outside ASCII), it guarantees the result is (a) valid UTF-8 text, compatible with text=True, and (b) free of any character that could be confused with the framing's line delimiter. The 33% overhead cost is the price of keeping the transport's simplicity: one message, one line, always text — the same simplicity that made stdio's framing so easy to implement by hand in Module 2. A "more efficient" binary protocol could exist (and, in fact, Streamable HTTP, MCP's other transport out of scope for this guide, handles some cases differently), but it would completely change the framing model this module and the previous one carefully built.


Summary and next step

  • mimeType declares a resource's content type (text/markdown, image/png, etc.), following IANA's MIME type standard.
  • Readable text content goes in contents's text field; binary content goes in blob, encoded in Base64 with base64.b64encode(...).decode("ascii") — never both fields at once.
  • Base64 exists because JSON only carries text; encoding binary bytes as safe ASCII text is the standard way to solve that, at a size cost of ~33%.
  • Run end to end: a text resource (text/markdown) and a binary one (image/png, a real 67-byte PNG), with the Base64 roundtrip confirmed byte for byte.

Next lesson: 07 — When a resource and when a tool. With resources' complete mechanism now mastered (list, URIs, read, text/blob), this lesson closes the module with the design criterion: when to model something as a resource, and when as a search tool — with the exact boundary toward production-rag-and-document-ingestion-guide.


Additional resources

  1. Model Context Protocol — Specification 2025-06-18: Resources — The exact shape of contents, with text and blob as mutually exclusive fields.
  2. IANA Media Types — The official MIME type registry, the source of mimeType's exact value.
  3. Python — base64b64encode/b64decode, used throughout this lesson's worked example.
  4. Python — bytes.fromhex / bytes.hex — How the demo PNG's fixed bytes were built and verified.