« Phase 02 · Warmup · Track Overview
Deep Dive — Mechanism & Internals
Table of Contents
- 1. The dispatch pipeline
- 2. The validator's recursion
- 3. The change classifier
- 4. The registry's publish guard
- 5. Discovery: filter, then take newest
- 6. A traced tools/call
- 7. The repair loop's fixed point
- 8. Invariants and complexity
1. The dispatch pipeline
MCPServer.handle is a five-gate pipeline, and the order is the design:
1. validate_envelope -> error answered with message.get("id") (may be None)
2. method startswith "notifications/" -> handle, return None
3. no "id" -> return None (nothing to answer to)
4. not initialized and method != "initialize" -> -32600
5. _dispatch(method, params) -> result, or a JsonRpcError converted to an error
Gate 1 uses message.get("id") rather than message["id"] because an envelope error may be
that the id is malformed. Answering with id: null is the spec's own guidance for
"we could not determine the id."
Gate 2 before gate 3 matters: a notification has no id, so gate 3 would swallow it before it was
handled. Reversing them silently drops notifications/initialized, and the symptom is that the
session never becomes live — a bug that looks like a transport problem.
Gate 4 is where the handshake becomes enforceable. Without it, a server answers tools/list
before it knows the client's protocol version or capabilities, which is how version-skew bugs
become intermittent instead of loud.
2. The validator's recursion
_validate(value, schema, path, errors) accumulates into a list rather than returning, so a
single traversal collects everything. The early return is the interesting part:
if not any(_TYPE_CHECKS[t](value) for t in types):
errors.append(...)
return # every further check would be noise
Without it, {"amount": "abc"} against {"type": "integer", "minimum": 1} produces two errors:
a type error and — because "abc" < 1 raises or is skipped — either a crash or a nonsense
message. With it, exactly one actionable error.
The _TYPE_CHECKS table encodes two JSON/Python mismatches explicitly:
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
bool subclasses int in Python, so the naive check accepts True as an amount. And number
accepts an int because JSON has one numeric type — but integer does not accept 5.0,
which is the correct asymmetry.
additionalProperties: False iterates sorted(value) rather than value so the error order is
stable regardless of dict insertion order — one of several places determinism is bought cheaply.
The final errors.sort(key=lambda e: (e.path, e.message)) makes the repair prompt byte-identical
across runs. That is not tidiness: a varying prompt defeats prefix caching and makes a failure
irreproducible.
3. The change classifier
classify_schema_change checks all MAJOR conditions first, then MINOR, then falls through to
PATCH. The ordering is load-bearing because a single edit can be both: adding an optional field
and narrowing an enum is a major change, and a classifier that returned on the first MINOR match
would under-report it.
The bound checks use infinities as the "absent" sentinel:
for key in ("minimum", "minLength", "minItems"):
if key in after and after.get(key, -inf) > before.get(key, -inf):
return MAJOR
before.get(key, -inf) means "an absent lower bound is negative infinity," so adding a
minimum where there was none correctly reads as a tightening. The mirror uses +inf for upper
bounds. Getting this wrong — using 0 as the default — makes adding minimum: 0 look like a
loosening.
additionalProperties is checked one-directionally: open → False is major; False → open is a
loosening and falls through to MINOR only if something else also changed, otherwise PATCH. That is
slightly generous and defensible: opening an object cannot break an existing caller.
4. The registry's publish guard
if spec.version in versions: raise # immutability
if spec.version <= previous.version: raise # monotonicity
change = classify_schema_change(previous.input_schema, spec.input_schema)
if change is MAJOR and spec.version.major != previous.version.major + 1: raise
if change is MINOR and (major changed or minor did not increase): raise
Three properties, each with a distinct failure it prevents:
- Immutability → a version pin means something, and "this run called
v1.0.0" is a true statement six months later. - Monotonicity →
latest()is well-defined, and history is append-only. - Bump enforcement → the estate cannot be broken by an optimistic release note.
The comparison against previous = self.latest(name) uses the latest active version, not the
absolute latest. A subtlety worth noticing: after deprecating 1.0.0, latest() returns
2.0.0, so the next publish is classified against 2.0.0. That is correct — you evolve from the
live contract, not from a retired one.
The guard is deliberately not applied to the first publish of a name (there is no previous
schema to compare against), which is why previous is not None wraps the whole block.
5. Discovery: filter, then take newest
for name in sorted(self._by_name): # deterministic order
for spec in reversed(self.versions(name)): # newest first
if retired: continue
if deprecated and not include_deprecated: continue
if spec.tenants and principal.tenant not in spec.tenants: continue
if classification_rank(spec.data_classification) > allowed_rank: continue
if not set(spec.required_scopes) <= held: continue
out.append(spec); break # one version per tool
The break implements "newest visible version," which is subtly different from "newest
version, if visible." Consider a tool whose 2.0.0 requires a scope the principal lacks and whose
1.0.0 does not. The loop skips 2.0.0 and offers 1.0.0 — the principal sees the newest
contract they are entitled to, rather than nothing.
Whether that is right is a genuine design question. It is right when scopes tighten over time (a
tool becomes more sensitive); it is wrong if you need every caller on one contract. The lab picks
availability; a bank might pick uniformity for money-moving tools. The important thing is that the
break's position is a decision, not an accident.
Visibility is checked with set(required) <= held — a subset test, so a tool needing two scopes is
invisible to a principal holding one. And classification_rank raises on an unknown
classification rather than defaulting, because a typo in a classification must not silently make
a tool visible.
6. A traced tools/call
Input: tools/call {"name": "payments.lookup", "arguments": {"reference": "PMT771", "include_legs": "yes", "urgent": true}}, principal holding payments.read, cleared to
confidential.
| Step | Check | Outcome |
|---|---|---|
| 1 | envelope | valid |
| 2 | initialized? | yes |
| 3 | name is a string | yes |
| 4 | arguments is an object | yes |
| 5 | build discover(principal) → {crm.notes.append, payments.lookup} | payments.lookup visible |
| 6 | validate_schema(arguments, spec.input_schema) | 3 errors |
| 7 | raise JsonRpcError(-32602, data={"errors": [...]}) | handler never called, call_log empty |
The three errors, sorted:
$.include_legs: expected type boolean, got string
$.reference: does not match pattern '^PMT-\d{3,}$'
$.urgent: additional property is not allowed
Now the kernel's repair pass: include_legs is not coercible ("yes" is not "true"), urgent
is dropped by the additionalProperties: False rule, reference is untouched (repairing a
pattern would be guessing). Two errors remain and go to the model in one turn.
After the model returns {"reference": "PMT-771", "include_legs": true}: validation passes,
call_log gets ("payments.lookup", {...}), the handler runs, and the response is
{"content": [{"type": "text", "text": "PMT-771: HELD, 250000 AED, ..."}], "isError": false}
Contrast the failure path after dispatch: if the handler returns
ToolCallResult("core banking timeout", is_error=True), the JSON-RPC response is a result,
not an error — same content shape, isError: true. The model sees the timeout as an
observation. That single difference is §8 of the WARMUP made concrete.
7. The repair loop's fixed point
repair_arguments must be idempotent: repair(repair(x)) == repair(x). Each transformation is
individually idempotent —
- coercion: once
"42"is42, theisinstance(value, str)guard no longer matches; - dropping: once the extra key is gone, there is nothing to drop;
- default-filling: once filled, the key is present.
— and they do not interact (coercion only touches keys in properties; dropping only touches keys
not in properties). So the composition is idempotent, which the test asserts directly.
The function returns (repaired, remaining_errors) rather than raising, because the caller needs
both: the repaired arguments to retry with, and the remaining errors to put in the model's prompt.
A version that only returned errors would force the caller to re-derive the repair.
8. Invariants and complexity
Invariants (each asserted by a test):
- A notification never produces a response.
- A schema violation leaves
call_loguntouched. - An unentitled tool and a nonexistent tool produce the same error code.
to_mcp()emits exactly{name, title, description, inputSchema, _meta}.- Validation errors are sorted by
(path, message). - A published
(name, version)is never mutated or replaced. latest()never returns a deprecated or retired version;resolve()never returns a retired one.discover()returns at most one version per tool, in sorted name order.repair_argumentsis idempotent.RETRYABLEcovers everySideEffectmember.
Complexity:
| Operation | Cost |
|---|---|
validate_schema | \( O(S) \) in the value's size, plus an \( O(E \log E) \) sort |
classify_schema_change | \( O(P) \) over properties |
publish | \( O(V + P) \) — versions of that tool, plus the classification |
versions | \( O(V \log V) \) — sorts each call; fine at V ≈ 10, memoizable |
discover | \( O(N \cdot V) \) worst case, \( O(N) \) typical (the break fires on the first visible version) |
handle | \( O(1) \) dispatch plus the method's own cost |
list_tools (cached) | \( O(1) \) after the first call, until invalidated |
The one to watch at scale is discover, because it runs on every tools/list and the estate
grows. In production it is a cached, precomputed view per (tenant, scope-set, clearance) tuple,
invalidated on registry change — the same listChanged event that invalidates the client's cache
invalidates the server's.