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/list and tools/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.