Lesson 06 / 11
Writing a Server
Separating a server's handlers from its capability declaration and measuring the completeness of the declaration as callability: when the same three fictional servers and the same twelve tasks are run at four declaration levels, the cost of the handshake is 3 requests, 3 responses, 99 tokens and 5 round trips at all four levels, and the only thing that changes is what those three responses leave in the client's hands. At the level that declares only names, the negotiated tools number four but, because the client cannot fill in fields, 12 of 12 calls fail and the completed tasks are zero. Once fields are declared, calls rise to 30 and completed tasks to 9. Declaring the side effect moves no number on this allowlist. Declaring the version drops the negotiated tools from 4 to 3, lowers the completed tasks to 6 and pulls the envelope tokens from 816 to 744: the most complete declaration gives the smallest tool set. When the allowlist is widened to accept kayit_yaz, three write calls whose side effect is undeclared pass silently; when it is declared, the same three calls are stopped at the approval gate.
Published Updated
In this lesson
- Separates a server's handlers from its capability declaration and produces the declaration at four levels.
- Counts the negotiated tools, failed calls and completed tasks at each declaration level.
- Shows with numbers that a complete declaration narrows the tool set.
- Measures that an undeclared side effect cannot be stopped on the client side.
Contents
In the previous five lessons the Model Context Protocol was explained from the outside: the responsibility of the three roles was separated, the round trips of the transport were counted, the message types of the data layer were measured, and it was seen that of the five tools a server declared only three were negotiated. In all those measurements the server was something given. None of it was written.
This lesson writes the server. What gets written has two parts, and the distinction between them carries the rest of the course’s measurements. The handler is the function that serves an incoming request and returns the result; it lives inside the server and the client never sees it. The capability declaration is the definition of that handler given to the outside, and it is the only thing the client sees. What a tool definition contributes was measured in the AI Agents course: with a definition given by name alone the valid call rate was 0.2803, with a schema 0.8919. That measurement questioned the content of the definition, and the definition was at hand. Here the definition is on the other side, and the question is its completeness: even though the handler stands in place, a field that is not declared does not exist in the client’s hands at all.
- IM1. The environment, the three servers and the protocol simulator in the lesson 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.
- IM2. At all four levels the handlers are the same; only the declaration changes: name only; name and fields; name, fields and side effect; full declaration (version added).
- IM3. The client uses only what is declared. If a field is not declared, the call goes out without fields.
- IM4. The envelope counts every request and response; its constant cost is 8 tokens, and on remote transport the round trip is doubled.
- IM5. The allowlist looks at the name and carries four tools;
kayit_yaz(write record) is not on the list. The client knows version 2 of thekayit(records) server, not version 3. - IM6. The task set is 12 fictional tasks: three of each of four types, each solved with a known tool chain. If a call returns an error, the chain is cut at that point.
- IM7. A completed task is when the result left by the chain equals the correct answer.
- IM8. A side-effecting tool asks for approval if its side effect is declared; if it is not declared, the client cannot build the approval gate.
- IM9. The baseline is the setup without the protocol: the tool defined inside the lesson, no handshake, the envelope not counted.
- IM10. The set is small: three servers, five declared tools. So counts are printed, not rates; the smallest step this set can measure is 1/5, that is, 0.2000.
Handler and Declaration
The code below sets up the fictional environment, the three fictional servers and the envelope count, then adds two functions that separate the declaration from the handler: declare produces the definition the server gives to the outside at one level, and negotiate carries out capability negotiation with that definition.
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 holds handlers and 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 # ---- Declaration levels: what the definition the server gives to the outside carries LEVELS = ("name", "name + fields", "name + fields + side effect", "full") def declare(s, level): """The server's declaration; the level decides which face of the handler is exposed.""" b = {"tools": {}} for name, t in s["tools"].items(): decl = {} if level >= 1: decl["fields"] = t["fields"] if level >= 2: decl["side_effect"] = t["side_effect"] b["tools"][name] = decl if level >= 3: b["version"] = s["version"] return b def negotiate(level, allow=ALLOW): """Capability negotiation uses only what is declared: what is not declared cannot be negotiated.""" agreed, dropped = {}, [] for name, s in SERVERS.items(): b = declare(s, level) message("request", f"yetenek sor {name}", s["transport"]) message("response", f"yetenek bildir {name} surum {s['version']} " + " ".join(s["tools"]) + " " + " ".join(s["resources"])) if "version" in b and CLIENT_VERSION.get(name, 0) < b["version"]: dropped += [(t, "version") for t in b["tools"]] continue for t, decl in b["tools"].items(): if t not in allow: dropped.append((t, "allowlist")) continue agreed[t] = dict(decl, function=s["tools"][t]["function"], transport=s["transport"]) return agreed, dropped def call(agreed, name, state, approval=False): """The client fills in only the declared fields; a declared side effect asks for approval.""" if name not in agreed: return {"hata": "arac bildirilmedi"} t = agreed[name] if t.get("side_effect") and not approval: return {"hata": "onay yok"} kw = {a: state[a] for a in t.get("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 print("level declaration of the olcum server ( sayac_oku )") for level, name in enumerate(LEVELS): b = declare(SERVERS["olcum"], level) print(f"{level} {name:<27} version {b.get('version', '-')} , {b['tools']['sayac_oku']}") print() print("level negotiated dropped request response tokens round trips") for level, name in enumerate(LEVELS): for k in COUNT: COUNT[k] = 0 a, ds = negotiate(level) print(f"{level} {name:<27} {len(a):>10} {len(ds):>7} {COUNT['request']:>7} {COUNT['response']:>8}" f" {COUNT['tokens']:>6} {COUNT['round_trips']:>11}") print("negotiated at full level :", sorted(negotiate(3)[0]))
level declaration of the olcum server ( sayac_oku )
0 name version - , {}
1 name + fields version - , {'fields': ('abone',)}
2 name + fields + side effect version - , {'fields': ('abone',), 'side_effect': False}
3 full version 2 , {'fields': ('abone',), 'side_effect': False}
level negotiated dropped request response tokens round trips
0 name 4 1 3 3 99 5
1 name + fields 4 1 3 3 99 5
2 name + fields + side effect 4 1 3 3 99 5
3 full 3 2 3 3 99 5
negotiated at full level : ['sayac_oku', 'tarife', 'tuketim']
In the four rows of the second table the cost of the handshake does not change: at every level 3 requests, 3 responses, 99 tokens, 5 round trips. A complete declaration came free at discovery time; the only thing that changed was what those three responses left in the client’s hands. At the first three levels the negotiated tools are four and the dropped tool is one: kayit_yaz, because its name is not on the allowlist. At the fourth level, the moment the version is declared, the client can make a comparison: the version it knows is 2, the one the server declares is 3. The whole kayit server is dropped and bolge_ozet (region summary) goes with it, even though it is on the allowlist. The negotiated tools fall from four to three. This is the counterpart in this lesson of the course’s second claim: the most complete declaration gave the smallest tool set. A server that declares less negotiates more tools, because it has said nothing to compare against.
The Count at Four Levels
The same twelve tasks are run at four levels. The task set has three of each of four types: charge, consumption, region and record. The only variable is the declaration level.
CHAIN = {"charge": ("sayac_oku", "tuketim", "tarife"), "consumption": ("sayac_oku", "tuketim"), "region": ("bolge_ozet",), "record": ("sayac_oku", "tuketim", "tarife", "kayit_yaz")} def tasks(seed=SEED): """12 FICTIONAL tasks: three of each of four types, subscriber and region picked by the seed.""" g = [] for i in range(12): r = rng(seed + 9973 * i) g.append({"type": list(CHAIN)[i % 4], "subscriber": f"A{1 + int(r() * 60):03d}", "region": REGION[int(r() * 5)]}) return g def expected(g): a = SUBSCRIBERS[g["subscriber"]] f = a["simdiki"] - a["onceki"] m3 = f + 100000 if f < 0 else f if g["type"] == "consumption": return m3 if g["type"] == "region": return sum(1 for x in SUBSCRIBERS.values() if x["bolge"] == g["region"]) return True if g["type"] == "record" else _charge(m3, a["tip"]) def run(level, allow=ALLOW, approval=False, seed=SEED): """The same 12 tasks, one variable: the declaration level.""" for k in COUNT: COUNT[k] = 0 agreed = negotiate(level, allow)[0] s = {"calls": 0, "failed": 0, "completed": 0, "blocked": 0, "written": 0} for g in tasks(seed): state = {"abone": g["subscriber"], "bolge": g["region"], "sutun": "bedel"} result = None for name in CHAIN[g["type"]]: state["deger"] = result y = call(agreed, name, state, approval) s["calls"] += 1 if "hata" in y: s["failed"] += 1 s["blocked"] += y["hata"] == "onay yok" break state.update(y) s["written"] += y.get("yazildi") is True result = y.get("yazildi", y.get("tutar", y.get("m3", y.get("abone", result)))) s["completed"] += result == expected(g) return dict(agreed=len(agreed), **s, **COUNT) print("level negotiated calls failed completed request response tokens") for level, name in enumerate(LEVELS): a = run(level) print(f"{level} {name:<27} {a['agreed']:>10} {a['calls']:>5} {a['failed']:>6}" f" {a['completed']:>9} {a['request']:>7} {a['response']:>8} {a['tokens']:>6}") second = [run(level, seed=SEED + 1) for level in range(4)] print("second run ( task seed 20260219 ) completed :", [x["completed"] for x in second], ", tokens :", [x["tokens"] for x in second]) print() WIDE = ALLOW + ("kayit_yaz",) print("wide allowlist ( kayit_yaz added ) negotiated blocked written completed") for level, approval in ((1, False), (2, False), (2, True)): a = run(level, allow=WIDE, approval=approval) print(f"level {level} , approval {str(approval).lower():<5} {a['agreed']:>10}" f" {a['blocked']:>7} {a['written']:>7} {a['completed']:>9}")
level negotiated calls failed completed request response tokens 0 name 4 12 12 0 15 15 351 1 name + fields 4 30 3 9 30 30 816 2 name + fields + side effect 4 30 3 9 30 30 816 3 full 3 30 6 6 27 27 744 second run ( task seed 20260219 ) completed : [0, 9, 9, 6] , tokens : [351, 818, 818, 744] wide allowlist ( kayit_yaz added ) negotiated blocked written completed level 1 , approval false 5 0 3 12 level 2 , approval false 5 3 0 9 level 2 , approval true 5 0 3 12
In the first row the negotiated tools are four, so the client knows which tools exist; the handlers stand in place too. Even so, the completed tasks are zero. Because the client does not know which field to fill in, every call goes out without fields, sayac_oku returns abone yok (no such subscriber), and the chain is cut at the first step. Out of a total chain length of thirty steps, only 12 calls can be made, all of them fail, and the run spends 351 tokens. The cheapest run is the run that finishes no work.
In the second row the fields are declared and the chains work: calls rise to 30, completed tasks to nine. The three remaining failed calls are the last step of the record tasks; because kayit_yaz is not on the allowlist it returns arac bildirilmedi (tool not declared), and these three calls spend no messages at all: requests are 30, that is, 3 handshakes plus 27 calls sent. A call made to a tool that was not negotiated ends inside the client and never reaches the wire.
The third row is identical to the second. Declaring the side effect moved not a single number on this allowlist, because the only side-effecting tool was not on the list in the first place. A declaration that has not been tested has no measurable value. The fourth row is cheaper and worse: the envelope tokens fall from 816 to 744, the completed tasks from nine to six. The three region tasks receive arac bildirilmedi in a single step because bolge_ozet was dropped.
In the second run the task set is drawn with a different seed, so different subscribers and different regions come up; the completed tasks stay the same at all four levels. The envelope tokens move from 816 to 818 at only two levels, because a region name that enters a body is one token longer. What is counted is a property of the contract, not of the data.
The Undeclared Side Effect
The last three rows explain why the third level looked pointless. When kayit_yaz is added to the allowlist, the negotiated tools rise to five. While the side effect is undeclared (the first level), three write calls pass and all twelve tasks are completed; the client has no field at all that says these three calls differ from the others.
When the side effect is declared (the second level), the same three calls stop at the approval gate: blocked 3, written 0, completed 9. When approval is given, all three are written again. Whether approval will be given is the client’s permission model and was measured in the AI Agents course; what is measured here is its precondition: an undeclared side effect cannot be stopped, because there is no question to ask. The three blocked calls are counted among the twelve tasks; this set is too small to print a rate.
Summary
- The two faces of a server are separated: the handler stays inside and is never seen, and the capability declaration is the only thing in the client’s hands.
- The cost of the handshake is the same at all four declaration levels: 3 requests, 3 responses, 99 tokens, 5 round trips; completeness is free at discovery time.
- At the level that declares only names, the negotiated tools are four but the completed tasks are zero: because all 12 calls go out without fields, 12 of 12 fail, and at 351 tokens the run is the cheapest.
- Once fields are declared, calls rise to 30 and completed tasks to 9; three calls made to a tool that was not negotiated spend no messages.
- Once the version is declared, the negotiated tools fall from 4 to 3 and the completed tasks from 9 to 6: the most complete declaration gives the smallest tool set.
- While the side effect is undeclared, three write calls pass silently; once it is declared, they are stopped.
Next Step
In this lesson the server was written and the incompleteness of its declaration was counted as callability. The other side was assumed ready: that the handshake had already been made, that the negotiated set stood somewhere, and who asked when were never questioned. The next lesson writes the client. It measures what server discovery costs, the difference between renewing the same discovery at every task and paying for it once per session, and that the dropping of the kayit server produces no error at all: the discovery messages do not tell the dropped server from the one that was not dropped, and the only thing that makes the difference visible is counting.
To keep your progress and take notes, Log in
My notes
Log in to take notes.