Tools over the wire: MCP from scratch, then Gateway and Identity
Part 1's tools were Python functions in the loop's own process. Real platforms cannot stay there: the tools worth having live in other services, other teams, other companies. The concepts chapter named the industry's answer (MCP, the standard port for tools) and the decoder ring claimed AgentCore Gateway is an MCP-server factory. This chapter cashes both claims the book's way: by building a working MCP server and client from scratch, watching the Part 1 investigation run over it, and only then decoding what the managed factory adds. The protocol turns out to be small enough to own completely, which is the best possible property for something half the ecosystem now stands on.
The wire, demystified
MCP's local transport is almost anticlimactic: newline-delimited
JSON-RPC 2.0 between two processes over stdin and stdout. JSON-RPC
is a 2010-vintage convention for remote calls as JSON objects: a
request carries id, method, params; a response echoes the id
with a result or an error. MCP is a vocabulary on top: an
initialize handshake where the two sides introduce themselves, then
tools/list (what can you do?) and tools/call (do it). Servers can
also expose resources and prompts, and remote servers speak the same
vocabulary over HTTP; the lab keeps to tools over stdio, which is the
shape most local MCP servers actually use.
Two design choices explain the protocol's spread. It is
discovery-first: clients ask servers what tools exist, with schemas
attached, so wiring is runtime negotiation instead of compile-time
knowledge. And it is model-vocabulary-native: what tools/list
returns is precisely the name-description-schema triple from
tool design, ready to hand a model. The protocol
is the Part 1 Tool record, standardized and put on a wire.
Lab 3.0: build both ends
One file, both roles: run it plain and it is the client, which spawns
itself with serve as the server subprocess. The server wraps the
Lab 1.1 toolbox; the client performs the handshake, discovers the
tools, then re-runs the checkout investigation with the
same loop and script as Chapter 7, every tool call
now a tools/call over the pipe.
python3 mcp_mini.py
--- handshake: the first exchange on the wire ---
-> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"...
<- {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "serverInfo": {"name":...
--- tools/list: 3 tools discovered, none hardcoded in the client ---
query_metrics Read p99 latency by hour for one service. Use when the...
search_logs Full-text search over application log lines. Use when ...
list_deploys List recent deployments with times and change notes. U...
--- the Lab 1.1 investigation, every tool call remote ---
[turn 1] tools/call query_metrics({"service": "checkout", "window": "24h"}) -> 153 chars
[turn 2] tools/call list_deploys({"service": "checkout"}) -> 244 chars
[turn 2] tools/call search_logs({"query": "payments-db"}) -> 241 chars
[turn 3] final: Likely cause: deploy v841 at 01:55 shrank the payments-db connection poo...
same loop, same script, same verdict; the tools moved out of the process
Three things to take from the run. The handshake is real: that is
the actual MCP initialize exchange, protocol version and all, in under
a hundred visible characters each way. Discovery changed the
architecture: the client hardcodes no tool names; it learned all
three, schemas included, from tools/list, which is the property that
lets one agent use a thousand community servers it has never seen.
And nothing about the agent changed: same loop, same script, same
v841 verdict. The tool boundary moved from a function call to a
process boundary, and the loop, built against an interface since
Chapter 7, did not notice. Next stop for that boundary: another
machine, another team, another company, and the protocol is already
dressed for it.
The simplifications are listed in the file's docstring (no notifications, no capability negotiation depth, tools only), and none of them change the shape. What the 150 lines deliberately lack is more interesting, because it is the next section's job description: nothing here authenticates the caller, authorizes per tool, injects credentials for downstream calls, meters usage, or survives the server process dying. A protocol is not a platform.
Gateway: the MCP-server factory
Now the managed version reads as exactly what it is. AgentCore Gateway takes things your organization already has and manufactures MCP servers in front of them:
- Lambda functions become tools (write the handler, Gateway speaks the protocol),
- OpenAPI specifications become toolsets (every operation in the spec, schema-translated: your existing REST APIs, agent-ready without a rewrite),
- Smithy models likewise (the decoder ring noted why that is quietly enormous: AWS's own services are all defined in Smithy),
- existing MCP servers can be fronted, which turns Gateway into an aggregation point: one endpoint, many servers behind it, with search across the combined catalog so an agent facing hundreds of tools can find the right three (the tool-discovery economics the context-engineering book prices).
Around the manufactured servers, the platform layers the lab lacked: callers authenticate in (OAuth against your identity provider, or IAM SigV4), authorization is per tool, traffic is logged and metered. The open-protocol consequence deserves emphasis, because it is the anti-lock-in argument made structural: a Gateway-manufactured tool is consumable by any MCP client (Strands agents, Claude-family clients, IDEs, the 150-line client you just wrote), and if Gateway vanished tomorrow, its targets are ordinary APIs and functions you still own, servable by the pattern in this chapter's lab.
Identity: whose errand is this?
The remaining hole is the deepest one. When an agent calls a tool that reaches a real system (Jira, GitHub, a customer database), someone's authority is being exercised. The confused deputy taught the failure: an agent with its own powerful standing identity will eventually be tricked into lending it out. The correct shape is delegation: the agent acts as the user it serves, with that user's entitlements, provably.
AgentCore Identity is the managed implementation of that shape for this stack: it runs the OAuth choreography (consent, tokens, refresh) against identity providers, stores the resulting credentials in a token vault, and injects them into outbound tool calls at the boundary. The property that matters most is a negative one: tokens never transit the model. Not in the prompt, not in tool results, not in anything the model could be tricked into echoing. Credentials attach to requests on the platform side of the boundary, so injection attacks can misdirect what the agent asks for, never harvest what it carries, and a fooled agent still cannot exceed the user it acts for. That is rung four from the control ladder applied to third-party authority, and it is the pattern to demand from any agent platform, AWS or otherwise: Anthropic's equivalent (vault-held credentials substituted at egress) makes the same promise in the same place, which tells you it is the category's convergent answer, not a vendor flourish.
Don't be confused: an MCP server vs Gateway. An MCP server is a protocol endpoint: anything that answers
tools/listandtools/call, including 150 lines of standard-library Python. Gateway is a factory and front door: it manufactures MCP servers from APIs you already have and wraps them in auth, search, and metering. The community ships thousands of hand-written MCP servers; Gateway exists for the enterprise long tail, where the tools already exist as APIs and nobody wants to hand-write and operate hundreds of wrappers.
Full source
"""Lab 3.0: MCP from scratch. A working tool server and client, no SDK.
The Model Context Protocol's stdio transport really is this simple:
newline-delimited JSON-RPC 2.0 between a client and a server process.
This file is both sides:
python3 mcp_mini.py client mode: spawns the server as a
subprocess, performs the handshake,
discovers tools, and re-runs the Lab 1.1
investigation with every tool call going
over the wire
python3 mcp_mini.py serve server mode: serves the Lab 1.1 tools as
MCP tools on stdin/stdout
Deliberate simplifications vs the full spec (noted in the chapter):
no notifications, no capability negotiation detail, tools only (real
servers can also expose resources and prompts).
Standard library only. Deterministic: same run, same transcript.
"""
from __future__ import annotations
import json
import subprocess
import sys
from loop_agent import INVESTIGATION_SCRIPT, TOOLS, ScriptedModel
PROTOCOL = "2025-06-18" # an MCP protocol revision date
TASK = "Checkout p99 latency spiked overnight. Find the likely cause."
# ---------------------------------------------------------------------------
# Server: the Lab 1.1 toolbox, spoken over JSON-RPC
# ---------------------------------------------------------------------------
def serve() -> None:
toolbox = {t.name: t for t in TOOLS}
for line in sys.stdin:
req = json.loads(line)
rid, method = req.get("id"), req.get("method")
params = req.get("params", {})
if method == "initialize":
result = {"protocolVersion": PROTOCOL,
"serverInfo": {"name": "hive-tools", "version": "0.1"},
"capabilities": {"tools": {}}}
elif method == "tools/list":
result = {"tools": [{"name": t.name,
"description": t.description,
"inputSchema": t.params} for t in TOOLS]}
elif method == "tools/call":
tool = toolbox.get(params["name"])
if tool is None:
_reply(rid, error={"code": -32602,
"message": f"unknown tool {params['name']}"})
continue
text = tool.fn(**params.get("arguments", {}))
result = {"content": [{"type": "text", "text": text}],
"isError": False}
else:
_reply(rid, error={"code": -32601,
"message": f"method not found: {method}"})
continue
_reply(rid, result=result)
def _reply(rid, result=None, error=None) -> None:
msg: dict = {"jsonrpc": "2.0", "id": rid}
msg["error" if error else "result"] = error if error else result
print(json.dumps(msg), flush=True)
# ---------------------------------------------------------------------------
# Client: handshake, discovery, and remote tool calls
# ---------------------------------------------------------------------------
class MCPClient:
def __init__(self, command: list[str]):
self.proc = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
self._id = 0
self.wire: list[tuple[str, str]] = [] # raw traffic, for show
def rpc(self, method: str, params: dict | None = None) -> dict:
self._id += 1
req: dict = {"jsonrpc": "2.0", "id": self._id, "method": method}
if params is not None:
req["params"] = params
raw = json.dumps(req)
self.proc.stdin.write(raw + "\n")
self.proc.stdin.flush()
raw_resp = self.proc.stdout.readline().strip()
self.wire += [("->", raw), ("<-", raw_resp)]
resp = json.loads(raw_resp)
if "error" in resp:
raise RuntimeError(resp["error"]["message"])
return resp["result"]
def call_tool(self, name: str, arguments: dict) -> str:
result = self.rpc("tools/call",
{"name": name, "arguments": arguments})
return result["content"][0]["text"]
def main() -> None:
client = MCPClient([sys.executable, __file__, "serve"])
client.rpc("initialize",
{"protocolVersion": PROTOCOL, "capabilities": {},
"clientInfo": {"name": "hive-loop", "version": "0.1"}})
print("--- handshake: the first exchange on the wire ---")
for direction, raw in client.wire[:2]:
print(f" {direction} {raw if len(raw) <= 94 else raw[:94] + '...'}")
tools = client.rpc("tools/list")["tools"]
print(f"\n--- tools/list: {len(tools)} tools discovered, "
f"none hardcoded in the client ---")
for t in tools:
print(f" {t['name']:<14} {t['description'][:54]}...")
print("\n--- the Lab 1.1 investigation, every tool call remote ---")
model = ScriptedModel(INVESTIGATION_SCRIPT)
messages: list[dict] = [{"role": "user", "content": TASK}]
for turn in range(1, 11):
reply = model.respond(messages)
messages.append({"role": "assistant", "content": reply.text,
"tool_calls": [vars(c) for c in reply.tool_calls]})
if reply.stop_reason == "end_turn":
print(f"[turn {turn}] final: {reply.text[:72]}...")
break
results = []
for call in reply.tool_calls:
output = client.call_tool(call.name, call.args)
print(f"[turn {turn}] tools/call {call.name}"
f"({json.dumps(call.args)}) -> {len(output)} chars")
results.append({"tool_call_id": call.id, "content": output})
messages.append({"role": "tool", "results": results})
print("\nsame loop, same script, same verdict; the tools moved out "
"of the process")
client.proc.stdin.close()
client.proc.wait()
if __name__ == "__main__":
serve() if sys.argv[1:] == ["serve"] else main()
👉 Next: managed hands, the two most dangerous things an agent does (running code and browsing the web) and the session-shaped sandboxes the platforms sell for them.