« Phase 02 · Track Overview

Warmup — MCP and the Tool Estate, From Zero

Assumes Python, JSON and HTTP. Assumes nothing about JSON-RPC, MCP, JSON Schema, or semantic versioning.


Table of Contents


1. The problem MCP solves

An agent is useful only when it can do things, and doing things means calling systems. Before MCP, every combination of (agent framework, tool) was a bespoke integration: a LangChain tool class, an OpenAI function schema, a Bedrock action group, each written separately for the same underlying API.

With M frameworks and N systems that is M × N integrations. Each one is separately written, separately reviewed, separately credentialed, and separately broken.

MCP makes it M + N. A system exposes one MCP server; any MCP-capable host can use it. The analogy the protocol's authors use is USB-C: one connector, many devices, and the device does not need to know which laptop is plugged in.

For a bank the saving is not primarily effort — it is inventory. When every tool is behind one protocol and one registry, there is a single place that knows what agents can do. That is the precondition for every governance conversation in the rest of this track, and it is why the JD names the "MCP server estate" as a thing to be engineered rather than merely adopted.

2. JSON-RPC 2.0, completely

MCP's wire format is JSON-RPC 2.0. It is a small spec and worth knowing exactly, because most implementation bugs are protocol bugs.

2.1 The three message shapes

Request — has an id, expects exactly one response:

{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
 "params": {"name": "payments.lookup", "arguments": {"reference": "PMT-771"}}}

Response — carries the same id, and exactly one of result or error:

{"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "HELD"}], "isError": false}}
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32602, "message": "arguments failed schema validation"}}

Notificationno id, and must not be answered:

{"jsonrpc": "2.0", "method": "notifications/tools/list_changed"}

The id rule is where implementations go wrong. The absence of id is not "the sender forgot" — it is a semantic statement meaning do not reply. A server that replies to a notification breaks clients that are not expecting a message; a client that waits for a reply to a notification hangs.

id may be a string or a number, but not a float (the spec discourages fractional parts because of round-tripping), and not null in a request.

2.2 The reserved error codes

CodeNameWhen
−32700Parse errorinvalid JSON
−32600Invalid Requestvalid JSON, wrong shape (bad jsonrpc, missing method)
−32601Method not foundthe method does not exist
−32602Invalid paramsthe method exists; the arguments are wrong
−32603Internal errorthe server broke
−32000 … −32099reserved for implementation-defined server errors

The distinction between −32601 and −32602 does real work in this lab: an undiscoverable tool returns −32601 (indistinguishable from nonexistent), while a schema violation on a tool you can see returns −32602 with the errors attached. One protects the estate from probing; the other helps the model fix itself.

The error object may carry data with anything you like. That is where the validation errors go.

2.3 Why JSON-RPC and not REST

A fair question in an interview. Three reasons:

  1. Bidirectional. MCP servers send messages to clients (notifications, and the two server-initiated request types in §3.4). REST is client-initiated by construction.
  2. Transport-agnostic. The same messages run over stdio (a local subprocess) and over HTTP. A local server that is a spawned process is a first-class case, and it has no URLs.
  3. Method-oriented, not resource-oriented. tools/call is an action, not a resource manipulation. Forcing it into REST produces the usual POST /tools/{name}/invocations awkwardness with no benefit.

3. MCP's architecture

3.1 Host, client, server

   ┌──────────────────────── HOST (your agent kernel) ────────────────────────┐
   │   ┌──────────┐        ┌──────────┐        ┌──────────┐                   │
   │   │ client A │        │ client B │        │ client C │   one per server   │
   │   └────┬─────┘        └────┬─────┘        └────┬─────┘                    │
   └────────┼───────────────────┼───────────────────┼─────────────────────────┘
            │ stdio             │ HTTP              │ HTTP
       ┌────▼─────┐        ┌────▼─────┐        ┌────▼─────┐
       │ payments │        │   crm    │        │ policy   │       MCP servers
       │  server  │        │  server  │        │  server  │
       └──────────┘        └──────────┘        └──────────┘
  • The host is the application — here, the agent kernel from Phase 01. It decides which servers to connect to and aggregates their tools.
  • A client is a connection object, one per server. This one-to-one rule is deliberate: it keeps each server's capabilities, protocol version and state separate, so a misbehaving server cannot contaminate another's session.
  • A server owns some tools, resources and prompts, and knows nothing about the other servers.

The consequence people miss: tool-name collisions are the host's problem. Two servers may both export search. The host must namespace them, and the namespacing must be stable, because it ends up in the model's prompt and in your audit records.

3.2 The initialize handshake

client → server   initialize {protocolVersion, capabilities, clientInfo}
server → client   {protocolVersion, capabilities, serverInfo}
client → server   notifications/initialized
                  ... normal operation ...

Three things happen at once:

Version negotiation. MCP versions are dates (2025-06-18). The client proposes; the server either accepts or responds with a version it does support. The correct behaviour on mismatch is offer an alternative, do not fail — a version skew between a client and one of twelve servers should degrade that connection, not take down the host. The lab implements exactly this.

Capability declaration. Each side states what it supports: the server declares tools, resources, prompts (each possibly with listChanged or subscribe); the client declares sampling, elicitation, roots. This is what makes the protocol survive its own evolution — a feature added in a later revision is simply not declared by older peers.

The initialized notification. Until the client sends it, the server should not consider the session live. The lab enforces this: any method other than initialize before initialized returns -32600. This matters because a server that starts pushing notifications before the client is ready will drop them.

3.3 Tools, resources, prompts — three trust levels

The three primitives are usually explained by what they are. It is more useful to explain them by who is in control, because that is a security property:

PrimitiveControlled byMeaningRisk
Toolthe modelan invocable function the model chooses to callhighest — the model decides, and it can be wrong or manipulated
Resourcethe applicationread-only context the host chooses to include, addressed by URImedium — the content is untrusted even though the choice is not
Promptthe usera template the human explicitly selectslowest — a human chose it

Two design consequences:

  • Anything the model can trigger needs a contract and a policy. That is why tools have schemas, scopes and side-effect classes, and resources do not.
  • Resource content is still untrusted input. A document fetched by URI can contain an injected instruction. "Application-controlled" describes who chose to read it, not whether its contents are safe. This is the trust-boundary rule that Phase 11 is built on.

3.4 The two server-initiated directions

Two features invert the usual direction, and both are security-relevant enough that a bank should decide about them explicitly rather than by default:

  • Sampling — a server asks the client's model for a completion. Useful (a server can do model-assisted work without its own API key); dangerous (a server can now spend your tokens, and can construct a prompt containing whatever context it likes).
  • Elicitation — a server asks the user for input mid-operation. Useful (a tool can ask for a missing parameter); dangerous (a server can present arbitrary text to your user inside your trusted UI, which is a phishing surface).

Both are opt-in via client capabilities. The bank-grade default is off for third-party servers, considered individually for first-party ones, with the client mediating and logging every such request. The lab does not implement them; knowing why they need a decision is the point.

3.5 What MCP does not provide

Write this list down; it is the answer to the most likely interview question in this phase.

Not in the protocolWhere it belongs
Authorization — who may call a toolyour registry + control plane (09)
Tenancy — whose tools these arethe registry's tenant visibility, this lab
Versioning — which contract you are callingthe registry's semver, this lab
Tool identity — that this server is really the payments serverworkload identity (08)
Rate limiting and quotasthe gateway (04)
Idempotency and transactional safetythe action gateway (10)
Auditeverywhere, joined by the chain (01, 15)
Data classificationthe registry, this lab

The spec does define an authorization framework for HTTP transports (OAuth 2.1 resource-server behaviour: protected-resource metadata, token audience validation, WWW-Authenticate challenges). That is about authenticating the caller to a remote server — it is not an authorization model for which tool a given agent may see, which is what a bank actually needs, and which is what the lab builds.

4. Schema enforcement

4.1 JSON Schema, the useful subset

Every MCP tool declares an inputSchema in JSON Schema. You do not need the whole dialect; the subset that earns its keep in a tool contract:

KeywordPurpose
typeobject, array, string, integer, number, boolean, null
properties, requiredobject shape
additionalProperties: falseclose the object — the single most useful keyword for catching hallucinated arguments
enum, constclosed value sets
minimum/maximum, exclusive*numeric ranges — where "amount must be positive" lives
minLength/maxLength, patternstring shape — account and reference formats
items, minItems/maxItems, uniqueItemsarrays

One Python-specific trap the lab tests: bool is a subclass of int. A naive isinstance(value, int) accepts True for {"type": "integer"}, so amount: true sails through into a payment call. The check must be isinstance(v, int) and not isinstance(v, bool).

The converse asymmetry is correct and deliberate: 5 is a valid number (JSON has one numeric type and integers are a subset), but 5.0 is not a valid integer.

4.2 Why all errors, sorted, with paths

A validator that raises on the first error forces a serial repair loop: the model fixes one problem, resubmits, learns about the next. Three round-trips, three model calls, three chances to make something else wrong.

Returning all errors, each with a path ($.legs[1], $.amount), lets one repair turn fix everything. And sorting them makes the repair prompt deterministic — which matters more than it sounds, because a non-deterministic prompt defeats prefix caching and makes failures irreproducible.

One refinement in the lab: when a value's type is wrong, further checks on that value are suppressed. Reporting "expected integer, got string" and "below minimum 1" for the same field is noise, and noise in a repair prompt lowers the repair success rate.

4.3 The repair loop

Before asking the model to try again, fix what is unambiguous:

SituationRepair
"42" where the schema says integercoerce to 42
42 where it says stringcoerce to "42"
"true" where it says booleancoerce to True
an extra property under additionalProperties: falsedrop it
a missing required field with a platform defaultfill it
a missing required field without a defaultleave it — that is the model's job

That last row is the discipline. Inventing an account number, a reference or an amount is not a repair; it is fabrication, and in a bank it is the difference between a helpful platform and an incident. The rule: repair syntax, never semantics.

The loop must also be idempotent — repairing twice gives the same result — or a retry mechanism built on it will oscillate.

4.4 The description is prompt surface

A tool's description is not documentation. It is text that goes into the model's context on every single turn, and it is the primary input to tool selection. Consequences:

  • It costs tokens on every request. Sixty tools with 200-token descriptions is 12 000 tokens before the user has said anything — which is why authorization-filtered discovery (§7) is a cost control as well as a security one.
  • Its quality determines p. From Phase 00, task success is \( p^n \), and per-step p is dominated by whether the model picks the right tool. A description that says when not to use the tool is often worth more than one that says what it does.
  • Platform metadata must not be in it. Scopes, classifications, owners and tenant lists are for your control plane. Putting them in the description wastes context and tells a prompt-injecting adversary the shape of your control model.

A good bank tool description names the system of record, the freshness, and the boundary:

"Return the current status, amount and counterparties of a wholesale payment by its reference. Reads the payments system of record; data is real-time. Does not cover card transactions or retail transfers — use retail.transfers.lookup for those."

5. Versioning a tool estate

5.1 Semantic versioning and constraints

MAJOR.MINOR.PATCH, where major means breaking, minor means backward-compatible addition, patch means neither. Callers express what they can tolerate:

ConstraintMatchesMeaning
1.2.3exactly thatmaximum pinning; you will be stuck on a retired version one day
~1.2.31.2.x, x ≥ 3patch updates only
^1.2.31.x.y, ≥ 1.2.3anything non-breaking — the sensible default
*anythingfine for a read tool in a sandbox, never for a production agent

The reason a platform cares: a pin is what makes a deprecation window possible. Without pins, every publish is a fleet-wide change and there is no window at all.

5.2 The rule: who breaks?

The entire classification reduces to one question: does the new contract accept everything the old one accepted?

MAJOR (breaking) — the contract got stricter:

ChangeWhy it breaks
add a required propertyexisting calls omit it
remove a propertyexisting calls send it (and with additionalProperties: false, are rejected)
change a typeexisting values are now wrong
narrow an enuma previously valid value is now invalid
raise a minimum/minLength/minItems, or lower a maximum/maxLength/maxItemspreviously valid values fall outside
set additionalProperties: false where it was openpreviously tolerated extras are now rejected

MINOR (compatible) — the contract got looser:

ChangeWhy it is safe
add an optional propertyold calls still validate
remove a required propertyold calls still validate (they sent it; it is now ignored)
widen an enumeverything old is still allowed

PATCH — descriptions, titles, examples. Note that a description change is not semantically neutral for a model — it can change tool selection — which is a real argument for treating description-only changes as minor and re-running your evaluation suite. Reasonable people differ; what matters is that you decide and encode it.

The lab's registry enforces the classification at publish time. That is the point: a rule in a wiki is a suggestion, a rule in publish() is a control.

5.3 Immutability, deprecation, retirement

Immutable versions. Once payments.lookup@1.0.0 is published, its schema never changes. If it could, a pin would mean nothing and a reproducibility claim ("this run called version 1.0.0") would be false. The lab refuses a republish.

Three lifecycle states, and the difference between the last two is operational:

StateIn latest()In resolve()In discover()Meaning
activeyesyesyesuse it
deprecatednoyesonly if askedstill works; migrate
retirednononogone

A deprecated version remaining resolvable by an explicit pin is what makes a migration window real: existing pinned callers keep working while new callers get the new version by default.

A production deprecation has four parts, and only the first is technical: mark it, notify the known callers (which requires knowing who they are — see the registry extension), set a retirement date, and enforce it. Platforms that skip step two never actually retire anything.

5.4 The change notification

Clients cache tools/list — they must, or every agent turn pays a round-trip per server. notifications/tools/list_changed is how the cache is invalidated. Without it a client will happily call a tool that was retired an hour ago, and a deprecation window is theatre.

The server declares tools: {listChanged: true} at initialize so the client knows the notification will come. The lab wires this end to end and tests that the client's list_calls counter increments only when it should.

6. The estate's metadata

The ToolSpec fields that are not in MCP are the ones that make it a bank's estate.

6.1 Side-effect class

ClassRetryableApprovalExample
readyesnobalance enquiry
write_idempotentyesmaybeupsert a case note by key
write_non_idempotentonly with an idempotency keyusuallyinitiate a payment
irreversiblenoalwaysrelease past settlement finality

The lab derives RETRYABLE from the class in a single mapping. That single mapping is the control: retry policy stops being a decision made independently by thirty agent authors, and becomes a property of the tool. It is also the field Phase 10 keys its entire behaviour off.

Make it a required field with no default. A default of read is how a payment tool ends up retryable.

6.2 Scopes, classification, tenants, owner

  • required_scopes — what the caller's credential must carry. Checked at discovery and again at the action gateway, because two independent gates is the Phase 00 rule.
  • data_classificationpublic < internal < confidential < restricted. A principal has a clearance; a tool above it is invisible. This is how an information barrier becomes a filter rather than a policy document.
  • tenants — empty means all; otherwise an allow-list. This is what stops a Retail agent from seeing a Wholesale tool at all.
  • owner — the team accountable. Every tool has one, or it will be nobody's during an incident.

7. Authorization-aware discovery

The single most important design decision in this phase:

tools/list is answered relative to a principal, and the filtering happens before the list is built.

Three reasons, in increasing order of how convincing they are in a review:

  1. Cost. Descriptions are tokens on every turn. A filtered list of six tools instead of sixty is a 90% reduction in tool-schema context.
  2. Accuracy. Selection error rises with the number of choices. Fewer, relevant tools raise per-step p, which raises \( p^n \) superlinearly.
  3. Security. A model that can see a tool will eventually try to call it — especially under prompt injection, where "call payments.release" is exactly the instruction an attacker plants. If the tool was never listed, the injected instruction has nothing to name.

And the corollary that makes it airtight: an undiscoverable tool must be indistinguishable from a nonexistent one. If tools/call on an unentitled tool returns "forbidden" while a nonsense name returns "unknown tool," the estate is probeable: an adversary enumerates your tool names by diffing error messages. The lab returns -32601 with the same message shape for both.

This is the same principle as not revealing whether a username exists on a login form, applied to a tool catalogue.

8. Protocol errors versus tool errors

MCP makes a distinction that looks like a curiosity and is actually load-bearing:

Protocol errorTool error
ShapeJSON-RPC error objectJSON-RPC result with isError: true
Examplesunknown method, unknown tool, schema violation, uninitialized sessiondownstream 503, "account not found", business rule rejection
Who sees itthe client (your kernel)the model
What happensthe call never reached the toolthe tool ran and reported a failure

The reason: the model can only react to what it is shown. A downstream timeout is information the agent should reason about ("core banking is unavailable; tell the user and offer to retry later"). A schema violation is not information for the model in the same way — it is a contract breach that the kernel should handle with a repair loop before spending another full turn.

Getting it backwards produces two distinct pathologies. Return everything as a protocol error and the agent dies on a transient downstream blip. Return everything as a tool result and the model sees -32601 unknown method, which it will cheerfully "reason" about and hallucinate around.

9. Lab walkthrough

Work Lab 01 in this order.

  1. JSON-RPC helpers (§2). make_request, make_notification, validate_envelope. Tiny; get the id rules exactly right, including rejecting a float id.
  2. validate_schema and _validate (§4.1–4.2). Write _type_name first. Remember: bool is not an integer; a type failure returns rather than continuing; sort at the end.
  3. Version, satisfies (§5.1). Strict parsing; ^ and ~ are three lines each.
  4. classify_schema_change (§5.2). Check every MAJOR condition first, then MINOR, then PATCH. The six asymmetry tests are the ones to run.
  5. RETRYABLE, classification_rank, ToolSpec.to_mcp (§6). to_mcp must emit exactly five keys — the test asserts the set.
  6. ToolRegistry.publish and lifecycle (§5.3). The publish guard is the meat: refuse a republish, refuse a non-newer version, and demand the bump the change classification requires.
  7. ToolRegistry.discover (§7). Loop names in sorted order; for each, walk versions newest first and take the first visible one.
  8. MCPServer.handle and _dispatch (§2, §3.2). Get the notification path right before anything else: notifications return None, always.
  9. _initialize (§3.2). Lenient negotiation — never fail on an unknown version.
  10. _tools_call (§8). The order matters: params validation → discoverability → schema → handler. The handler must not be reached on a schema failure, and the test checks call_log.
  11. Resources, prompts, notify_tools_changed (§3.3, §5.4).
  12. MCPClient (§3.1, §5.4). The cache and its invalidation.
  13. repair_arguments (§4.3). Coerce, drop, fill-from-defaults, and nothing else.

Then python solution.py and read the eight sections against §§2–8.

10. Success criteria

Without the guide open:

  • Draw host/client/server and say why the client-per-server rule exists.
  • Recite the three JSON-RPC message shapes and the id rule for each.
  • Explain −32601 vs −32602 and why an unentitled tool uses the former.
  • List five things MCP does not provide and where each belongs.
  • Explain tools vs resources vs prompts by who controls them.
  • State why resource content is untrusted even though resources are application-controlled.
  • Give the who-breaks rule and classify six changes correctly.
  • Explain why a deprecated version must stay resolvable by pin.
  • Give three reasons discovery is authorization-aware, and the probing corollary.
  • Explain protocol error vs tool error and the pathology of each mistake.
  • Name the four side-effect classes and their retry policy.

11. Common mistakes

Answering a notification. Breaks clients; hangs servers.

Treating tools/list as a static catalogue. It is a query with a principal.

Filtering in the client. The model already saw the list. The filter must be server-side.

Different errors for "not allowed" and "not found." Your estate is now enumerable.

Platform metadata in the description. Wasted context, and a map of your controls for an attacker.

Publishing a breaking change as a minor bump. The single most common way to break an estate.

Mutating a published version. Every pin and every reproducibility claim becomes false.

No listChanged. Clients call retired tools; the deprecation window is theatre.

isinstance(v, int) for {"type": "integer"}. True is now a valid amount.

Repairing semantics. A fabricated account number is worse than a validation error.

Defaulting side_effect to read. A payment tool becomes retryable by omission.

Failing the handshake on an unknown protocol version. A skew becomes an outage.

12. Interview Q&A

Q: What does MCP give you, and what does it not?

A: "It gives one wire protocol between an agent host and a tool provider — JSON-RPC 2.0, a capability-negotiating handshake, and three primitives split by who controls them: tools are model-controlled, resources are application-controlled, prompts are user-controlled. That collapses M×N integrations to M+N and, more importantly for a bank, gives you one place that knows what agents can do. What it does not give you is an authorization model for which agent may see which tool, tenancy, versioning, tool identity, rate limiting, idempotency, or audit. The HTTP transport does define OAuth 2.1 resource-server behaviour, but that authenticates the caller to a server — it isn't a model for filtering a catalogue. So 'we use MCP' is a transport decision, not a tool strategy, and the estate around it is the actual engineering."

Q: How do you handle tool versioning across twelve agent teams?

A: "Immutable versions in a registry, semver, and callers pin a range — ^1.2.0 by default. The registry classifies every schema change and enforces the bump: adding a required property, removing a property, changing a type, narrowing an enum, or tightening a bound is major; adding an optional property, removing a required one, or widening an enum is minor. The rule is just 'does the new contract accept everything the old one accepted' — callers break when it gets stricter. Then deprecation is a state, not a delete: a deprecated version stays resolvable by an explicit pin so pinned callers keep working, while latest() returns the new one. And it's backed by notifications/tools/list_changed, because clients cache the tool list and without the notification they'll call something that was retired an hour ago. The part most people skip is knowing who calls each version — without that, deprecation is a mark in a database and you can never actually retire anything."

Q: Should every agent see every tool?

A: "No, and for three reasons that get progressively more convincing. Cost: tool descriptions are tokens on every turn, so sixty tools is maybe twelve thousand tokens before the user speaks. Accuracy: selection error rises with choice, and task success is p^n, so trimming the list raises success superlinearly. Security: a model that can see a tool will eventually call it, and under prompt injection 'call payments.release' is exactly the planted instruction — if it was never listed, there's nothing to name. So discovery is answered relative to a principal, filtered by scope, tenant and data classification before the list is built. And the corollary: calling an unentitled tool has to return the same error as calling a nonexistent one, or an adversary enumerates your estate by diffing error messages."

Q: A tool call fails. Walk me through what the agent sees.

A: "Depends which kind of failure, and the distinction is deliberate. If the arguments violate the schema, that's a protocol error — JSON-RPC −32602 with the validation errors in data. The tool never runs, and my kernel handles it with a deterministic repair pass first: coerce a numeric string, drop a disallowed extra property, fill from a platform default. What it will not do is invent a missing account number — repair syntax, never semantics. If repair leaves errors, the model gets one turn with all of them at once, sorted, so it fixes everything in one round-trip instead of three. If instead the tool ran and failed — core banking timed out, account not found — that's a successful JSON-RPC response with isError: true, and the model sees it as an observation so it can reason about it. Getting that backwards is bad both ways: everything as a protocol error and the agent dies on a transient blip; everything as a tool result and the model starts hallucinating around -32601 unknown method."

Q: What worries you about an MCP server you did not write?

A: "Four things. First, identity — nothing in the protocol tells me this server is really the payments team's; I want a workload identity and a registry that records which server may serve which tool name, or a rogue server claims payments.release. Second, the description text: it goes into my model's context on every turn, so a hostile or careless server author has a prompt injection channel by construction. Third, sampling and elicitation — a server can ask my model for completions, spending my tokens with a prompt it controls, and can present arbitrary text to my user inside my trusted UI. Both are opt-in via client capabilities and my default for third-party servers is off. Fourth, resource content: 'application-controlled' means I chose to read it, not that it's safe — everything it returns is untrusted input and gets the same treatment as a retrieved document."

13. References

  • Model Context Protocolmodelcontextprotocol.io: the specification (read the current dated revision and one prior to see how negotiation earns its keep), the architecture overview, and the authorization section for HTTP transports.
  • JSON-RPC 2.0jsonrpc.org/specification. Short; read it once, completely.
  • JSON Schemajson-schema.org, the Understanding JSON Schema guide; and the jsonschema Python library for what a full implementation involves.
  • Semantic Versioning 2.0.0semver.org.
  • Confluent Schema Registry compatibility types — backward / forward / full: the same who-breaks reasoning applied to event schemas, and the vocabulary Phase 12 uses.
  • OWASP Top 10 for LLM Applicationsgenai.owasp.org: Excessive Agency and Supply Chain are the two entries this phase's controls address.
  • Newman, Building Microservices, 2nd ed. — contract evolution and consumer-driven contracts, which is the same problem with different words.