Skip to content
academia.sh

Lesson 04 / 11

Data Layer

Counting the cost of request, response and notification messages: an eight-subscriber batch is 24 function calls and zero messages without the protocol, and 48 messages, 640 tokens and 32 round trips with it, 384 of the 640 tokens being envelope alone. The same progress information costs 1147 tokens and 80 round trips as request-response pairs and 976 tokens and 32 round trips as notifications. Its price is that five notifications dropped on the line leave no trace on the sender.

Published Updated

In this lesson

  • Separates request, response and notification messages on the axes of expecting a response and round trip cost.
  • Compares the messages, envelope tokens and round trips the same batch pays without and with the protocol.
  • Calculates the number of messages and round trips the notification saves by being responseless.
  • Shows why a lost notification leaves no trace on the sender.
Contents

The previous three lessons measured how the connection is established: tying the tool connection to a specification, the distribution of responsibility among host application, client and server, the round trip difference between local and remote transport. In all three, what was counted was the connection itself. What passed over it was never opened.

This lesson fills that gap. The Model Context Protocol’s data layer sits on top of the connection and recognizes only three message kinds: request, response and notification. What will be measured is what each of these three costs — how many messages, how many envelope tokens, how many round trips — and what one of them, the notification, gains and makes invisible by being responseless.

  • PS31. The environment, the three servers, the tools and the envelope count are fictional; no real server, client library or runtime is called. 60 fictional subscribers, three fictional servers, five declared tools. Seed 20260218, second run 20260219.
  • PS32. There are three message kinds and every message carries an envelope. The envelope’s fixed cost is 8 tokens; the body is the field names and values passed through the fictional tokenizer.
  • PS33. A request awaits a response and spends one round trip: one on local transport, two on remote transport. A notification awaits no response and spends no round trip on the counter.
  • PS34. A response is not sent on its own, it is the answer to a request; it stands as a separate kind on the counter because its envelope is paid separately.
  • PS35. This is the baseline without the protocol: the tool is defined inside the lesson, the call is a direct function call, there are no messages, no envelope is counted.
  • PS36. The batch measured is the period charge of eight fictional subscribers; each subscriber goes through sayac_oku (read meter), tuketim (consumption) and tarife (tariff) in order.
  • PS37. The fictional transport line drops a share of the messages. The loss share is taken as 0.20 and kept high so that the count is visible; the dropping decision is determined by the seed.

The Three Message Kinds

The difference between a request and a notification is not a difference of form, it is a difference of expectation. The sending side of a request expects a response in return; until that response arrives the work is half done, and the waiting side pays the round trip. The sending side of a notification expects nothing: the message is left on the line and the work goes on. The response is not an independent kind between the two — it is not sent on its own, but since its envelope is paid separately it stands apart on the counter.

The contract, versioning and error response of an interface were measured in the Web API Design course; that procedure is not repeated here. This lesson counts not the content of the contract but its carrying cost: into how many messages the same job is split and how many tokens each split adds. The code below sets up the fictional environment, the three servers and the envelope counter.

SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def tokenize(m):
    b = []
    for s in str(m).lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


# ---- FICTIONAL environment and tools (same setup as the AI Agents course, moved into a server here)
REGION = ["dogu", "bati", "kuzey", "guney", "merkez"]
TYPE = ["mesken", "isyeri"]
SUBSCRIBERS = {}
for i in range(60):
    r = rng(SEED + 31 * i)
    previous = 10000 + int(r() * 80000)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"bolge": REGION[int(r() * 5)], "tip": TYPE[int(r() * 2)],
                                    "onceki": previous, "simdiki": previous + int(r() * 70)}
TIERS, ABOVE, COMMERCIAL_FACTOR = ((10, 4), (25, 7), (40, 11)), 16, 2


def _charge(m3, type_):
    remaining, lower, total = m3, 0, 0
    for upper, unit in TIERS:
        band = min(remaining, upper - lower)
        if band <= 0:
            break
        total += band * unit
        remaining -= band
        lower = upper
    return (total + remaining * ABOVE) * (COMMERCIAL_FACTOR if type_ == "isyeri" else 1)


def t_meter(abone=None, **_):
    a = SUBSCRIBERS.get(abone)
    return {"hata": "abone yok"} if a is None else {
        "onceki": a["onceki"], "simdiki": a["simdiki"], "tip": a["tip"]}


def t_consumption(onceki=None, simdiki=None, **_):
    if onceki is None or simdiki is None:
        return {"hata": "eksik alan"}
    f = simdiki - onceki
    return {"m3": f + 100000 if f < 0 else f}


def t_tariff(m3=None, tip="mesken", **_):
    return {"hata": "eksik alan"} if m3 is None else {"tutar": _charge(m3, tip)}


def t_region(bolge=None, **_):
    if bolge not in REGION:
        return {"hata": "bolge yok"}
    n = sum(1 for a in SUBSCRIBERS.values() if a["bolge"] == bolge)
    return {"bolge": bolge, "abone": n}


def t_record(abone=None, sutun=None, deger=None, **_):
    if not (abone and sutun and deger is not None):
        return {"hata": "eksik alan"}
    return {"yazildi": True}


# ---- FICTIONAL servers: each one declares its capabilities
SERVERS = {
    "olcum": {"version": 2, "transport": "local",
              "tools": {"sayac_oku": {"function": t_meter, "fields": ("abone",), "side_effect": False},
                        "tuketim": {"function": t_consumption, "fields": ("onceki", "simdiki"),
                                    "side_effect": False}},
              "resources": ("abone_listesi",), "prompts": ()},
    "tarife": {"version": 1, "transport": "remote",
               "tools": {"tarife": {"function": t_tariff, "fields": ("m3", "tip"),
                                    "side_effect": False}},
               "resources": ("basamak_tablosu",), "prompts": ("bedel_aciklamasi",)},
    "kayit": {"version": 3, "transport": "remote",
              "tools": {"bolge_ozet": {"function": t_region, "fields": ("bolge",),
                                       "side_effect": False},
                        "kayit_yaz": {"function": t_record, "fields": ("abone", "sutun", "deger"),
                                      "side_effect": True}},
              "resources": (), "prompts": ()},
}
CLIENT_VERSION = {"olcum": 2, "tarife": 1, "kayit": 2}      # the kayit server is ahead
ALLOW = ("sayac_oku", "tuketim", "tarife", "bolge_ozet")     # kayit_yaz is not on the allowlist

COUNT = {"request": 0, "response": 0, "notification": 0, "tokens": 0, "round_trips": 0}


def message(kind, body, transport="local"):
    """FICTIONAL envelope. No real protocol is implemented; what is counted is the cost of the envelope."""
    COUNT[kind] += 1
    b = len(tokenize(body)) + 8                          # envelope constant
    COUNT["tokens"] += b
    if kind == "request":
        COUNT["round_trips"] += 2 if transport == "remote" else 1
    return b


def handshake(client_version=None, allow=None):
    """Capability negotiation: a version mismatch and the allowlist narrow the tool set."""
    client_version = CLIENT_VERSION if client_version is None else client_version
    allow = ALLOW if allow is None else allow
    agreed, dropped = {}, []
    for name, s in SERVERS.items():
        message("request", f"yetenek sor {name}", s["transport"])
        message("response", f"yetenek bildir {name} surum {s['version']} "
                            + " ".join(s["tools"]) + " " + " ".join(s["resources"]))
        if client_version.get(name, 0) < s["version"]:
            dropped.append((name, "version", s["version"], client_version.get(name, 0)))
            continue
        for t, spec in s["tools"].items():
            if t not in allow:
                dropped.append((name, "allowlist", t, None))
                continue
            agreed[t] = dict(spec, server=name, transport=s["transport"])
    return agreed, dropped


def call(agreed, name, state):
    if name not in agreed:
        return {"hata": "arac bildirilmedi"}
    t = agreed[name]
    kw = {a: state[a] for a in t["fields"] if a in state}
    message("request", f"{name} " + " ".join(f"{k} {v}" for k, v in kw.items()), t["transport"])
    s = t["function"](**kw)
    message("response", " ".join(f"{k} {v}" for k, v in s.items()))
    return s


agreed, dropped = handshake()
print("agreed tools :", " ".join(sorted(agreed)))
print("handshake    :", COUNT["request"], "requests,", COUNT["response"], "responses,",
      COUNT["tokens"], "tokens,", COUNT["round_trips"], "round trips")
agreed tools : sayac_oku tarife tuketim
handshake    : 3 requests, 3 responses, 99 tokens, 5 round trips

The Cost of the Envelope

The envelope counter knows how to count by kind but does not separate the body from the envelope. For measurement a thin wrapper is placed around the message: the wrapper writes the kind and the body tokens into its own breakdown, then hands the job to the real counter. The eight-subscriber batch is run with this breakdown.

BATCH = ("A003", "A011", "A017", "A024", "A038", "A045", "A052", "A059")

# ---- measurement wrapper: body tokens per kind, on top of the same envelope counter
BREAKDOWN = {k: [0, 0] for k in ("request", "response", "notification")}
_envelope = message


def message(kind, body, transport="local"):
    BREAKDOWN[kind][0] += 1
    BREAKDOWN[kind][1] += len(tokenize(body))
    return _envelope(kind, body, transport)


def reset():
    for k in COUNT:
        COUNT[k] = 0
    for k in BREAKDOWN:
        BREAKDOWN[k] = [0, 0]


def chain(agreed, subscriber, progress=None):
    """Three-tool chain; after each step an optional progress message."""
    state = {"abone": subscriber}
    for name in ("sayac_oku", "tuketim", "tarife"):
        s = call(agreed, name, state)
        state.update({k: v for k, v in s.items() if k != "hata"})
        if progress is not None:
            progress(subscriber, name)
    return state.get("tutar")


agreed, _ = handshake()
reset()
print("eight-subscriber batch, amounts:", [chain(agreed, a) for a in BATCH])
print("kind         count  body envelope  total")
for k in BREAKDOWN:
    n, g = BREAKDOWN[k]
    print(f"{k:<12} {n:>5} {g:>5} {n * 8:>8} {g + n * 8:>6}")
print(f"with protocol    : {sum(BREAKDOWN[k][0] for k in BREAKDOWN)} messages,"
      f" {COUNT['tokens']} tokens, {COUNT['round_trips']} round trips")
print("without protocol : 24 function calls, 0 messages, 0 envelope tokens, 0 round trips")
eight-subscriber batch, amounts: [211, 16, 844, 710, 812, 876, 694, 470]
kind         count  body envelope  total
request         24   152      192    344
response        24   104      192    296
notification     0     0        0      0
with protocol    : 48 messages, 640 tokens, 32 round trips
without protocol : 24 function calls, 0 messages, 0 envelope tokens, 0 round trips

The last two lines are the lesson’s first number. The same eight amounts come out at the baseline without the protocol with 24 function calls: no messages, no envelope, no round trips. With the protocol the same job is split into 48 messages and costs 640 tokens and 32 round trips. 384 of the tokens are envelope alone — 192 in the 24 requests, 192 in the 24 responses — and the remaining 256 tokens are body. The envelope takes one and a half times as much room as the information carried.

The shape of the split shows in the numbers too. The request body is 152 tokens, the response body 104; the request is more expensive because it carries the tool name and all fields as name and value pairs, while the response carries only the result. The round trips are 32, not 24: the first two steps of the eight chains go to the local olcum (the measurement server), the last step to a remote server, and a remote request doubles the round trip. The notification row is zero for now; the third kind was never used in this batch.

What the Notification Gains

When a batch runs long, the calling side wants to know where it is. That information can be carried in two designs. In the first, a request is sent at the end of every step and the other side returns a confirming response. In the second, the same text is sent as a notification and no response is awaited. Both designs run on the same fictional line, with the same seed and dropping decisions.

LOSS_SHARE = 0.20                    # FICTIONAL share of messages that do not reach the line


def line(seed):
    """FICTIONAL transport line: a share of the messages never reaches the other side."""
    r = rng(seed)

    def arrives():
        return r() >= LOSS_SHARE
    return arrives


def measure_progress(form, seed=SEED):
    """The same progress information in two designs: as a request-response pair or as a notification."""
    arrives, arrived = line(seed), [0]

    def report(subscriber, name):
        text = f"ilerleme {subscriber} {name} tamam"
        if form == "request-response":
            message("request", text, "remote")
            if arrives():
                arrived[0] += 1
                message("response", "onay")
        elif form == "notification":
            message("notification", text, "remote")
            if arrives():
                arrived[0] += 1

    agreed, _ = handshake()
    reset()
    for a in BATCH:
        chain(agreed, a, report)
    sent = 0 if form == "none" else 24
    seen = sent - arrived[0] if form == "request-response" else 0
    return (BREAKDOWN["request"][0], BREAKDOWN["response"][0], BREAKDOWN["notification"][0],
            COUNT["tokens"], COUNT["round_trips"], sent, arrived[0], seen)


print("design           run requests responses notifications tokens round trips"
      " sent arrived visible")
for form in ("none", "request-response", "notification"):
    for t in (SEED, SEED + 1):
        s = measure_progress(form, t)
        print(f"{form:<16} {'AB'[t - SEED]:>3} {s[0]:>8} {s[1]:>9} {s[2]:>13} {s[3]:>6}"
              f" {s[4]:>11} {s[5]:>4} {s[6]:>7} {s[7]:>7}")
design           run requests responses notifications tokens round trips sent arrived visible
none               A       24        24             0    640          32    0       0       0
none               B       24        24             0    640          32    0       0       0
request-response   A       48        43             0   1147          80   24      19       5
request-response   B       48        44             0   1156          80   24      20       4
notification       A       24        24            24    976          32   24      19       0
notification       B       24        24            24    976          32   24      20       0

The first two rows are the baseline without progress, the same in both runs. In the first run the request-response design raises the batch’s message count from 48 to 91, its tokens from 640 to 1147 and its round trips from 32 to 80. The notification design carries the same information with 72 messages, 976 tokens and 32 round trips. The notification’s gain reads in three columns at once: 19 messages, 171 tokens and 48 round trips.

The three numbers have separate sources. The message gain is the responses to the requests that arrived; in the first run 19 requests arrived, 19 confirmations came back and in the notification design those 19 confirmations were never written. The token gain is the envelope and body of those 19 responses, nine tokens per message. The round trip gain is the largest and has nothing to do with arrival: since all 24 progress messages pass over remote transport, each of them spent two round trips in the request design and none in the notification design. When there is no waiting side, there is nothing awaited on the line either.

What the Notification Makes Invisible

The last three columns of the same table carry the price. The fictional line passed 19 of the 24 progress messages in the first run and 20 in the second. In the request-response design the loss the sender sees is exactly this difference: 5 in the first run, 4 in the second. In the notification design the same line, with the same seed and the same dropping decisions, lost the same number of messages, but the loss the sender sees is zero in both runs.

The difference comes from the response itself. In a lost request the awaited response does not come and the end of the wait is an event on the sending side — it can be counted, written to a log, retried. In a lost notification nothing is awaited; the sender’s counter says 24, the receiving side’s counter says 19, and the five messages between them produce an error nowhere. In a setup that does not put the two counters side by side, this difference is invisible.

The rule that follows decides the choice of message kind. No information the other side has to act on and the sender has to know the result of is carried by a notification: a tool call, a capability query, a write operation. Information whose loss does not break the job suits a notification: progress, a log line, an announcement of a status change. The difference between the two runs confirms this split too — the number of lost messages moves between 5 and 4 from run to run, but the loss seen in the notification design stays zero in both. What changes is the loss, what does not change is the blindness.

Summary

  • The data layer recognizes three message kinds: a request awaits a response and spends a round trip, a response is the answer to a request, a notification awaits no response and spends no round trip on the counter.
  • The eight-subscriber batch is 24 function calls, zero messages and zero envelope tokens at the baseline without the protocol, and 48 messages, 640 tokens and 32 round trips with it.
  • 384 of the 640 tokens are envelope, and it takes up one and a half times the room of the 256 tokens of body carried.
  • The same progress information costs 91 messages, 1147 tokens and 80 round trips as request-response pairs in the first run, and 72 messages, 976 tokens and 32 round trips as notifications. The notification’s gain is 19 messages, 171 tokens and 48 round trips.
  • The fictional line dropped 5 and 4 messages in the two runs; in the request-response design the sender saw all of this loss, in the notification design it saw zero in both runs.
  • No information the other side has to act on and whose result has to be known is carried by a notification; a notification suits only information whose loss does not break the job.

Next Step

This lesson counted the cost of messages, but all the messages counted worked on a set of tools that had already been agreed. The single line in the first block’s output quietly gives this away: three tools came out of the handshake, yet the number of tools the three fictional servers declare is larger. What the server declared, how much of it the client took, and in which message with which error the gap between them arose was never asked. The next lesson measures server capabilities — tools, resources and prompts — by the gap between declared and agreed, and counts how that gap arises without producing any error message.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close