« Phase 02 · Warmup · Track Overview

Deep Dive — Mechanism & Internals


Table of Contents


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.
  • Monotonicitylatest() 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.

StepCheckOutcome
1envelopevalid
2initialized?yes
3name is a stringyes
4arguments is an objectyes
5build discover(principal){crm.notes.append, payments.lookup}payments.lookup visible
6validate_schema(arguments, spec.input_schema)3 errors
7raise 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" is 42, the isinstance(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):

  1. A notification never produces a response.
  2. A schema violation leaves call_log untouched.
  3. An unentitled tool and a nonexistent tool produce the same error code.
  4. to_mcp() emits exactly {name, title, description, inputSchema, _meta}.
  5. Validation errors are sorted by (path, message).
  6. A published (name, version) is never mutated or replaced.
  7. latest() never returns a deprecated or retired version; resolve() never returns a retired one.
  8. discover() returns at most one version per tool, in sorted name order.
  9. repair_arguments is idempotent.
  10. RETRYABLE covers every SideEffect member.

Complexity:

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