Day 3 — A2A + MCP together: exposing a repo as a Model Context Protocol server
Day 2 left the agents deciding architecture without real repo context. Day 3 introduces MCP (Model Context Protocol) as a complement to A2A: a Codebase MCP server that exposes the code, and a Project Mapper agent that consumes it via the Anthropic SDK's native MCP integration.
A2A is for coordinating agents. MCP is for giving an agent tools. They don't compete — they complement each other. An A2A agent can internally be an MCP client, and that turns the architecture into something more powerful than either alone.
Day 1 set up the A2A network. Day 2 added the Tech Lead that designs
implementations. But Day 2 left a clear hole: the agents were
working without real repo context. The Investigator was
investigating "magic-link auth" in the abstract; the Tech Lead was
inventing plausible paths (src/features/oauth/) without
knowing if they existed in the project.
Day 3 closes that hole introducing two components:
- A Codebase MCP server that exposes the repo as Model Context Protocol — agents can list directories, read files, and search content dynamically.
- A Project Mapper agent (A2A) that consumes it internally to produce a structured ProjectMap that feeds the other agents.
This is the first time on the blog where A2A and MCP appear together in production. It's a poorly documented combination but, in my Day 3 experience, the natural architecture for systems with agents that touch code.
A2A vs MCP: clarifying the difference
Many people arrive at these protocols thinking they're alternatives. Let's clear that up first:
A2AMCP PurposeAgent ↔︎ agent communicationAn agent's access to external tools/data Who decides what to doThe Orchestrator (predefined flow)The LLM itself, dynamically GranularityHigh-level tasks ("research X")Low-level operations ("read_file", "search") Example use"The Orchestrator passes research to the Tech Lead""The Tech Lead readsauth/oauth.ts to imitate its structure"
A2A coordinates; MCP provides capabilities. An A2A agent typically is an A2A server externally (receives tasks from other agents) and an MCP client internally (reads data to do its job). That's exactly what we'll build.
Why MCP wins over "pre-computed JSON of the repo"
Day 2 had a simpler option: pre-compute a repo map (file tree + sample files) and pass it in the Tech Lead's payload. Works but has a ceiling:
- Speculative: you have to guess what the agent will need.
- Static: if the agent wants to read a file not included, it can't.
- Heavy: you pass too much useless context "just in case".
With MCP, the LLM decides what to read while
reasoning. If Claude sees the project uses Next.js, it can ask
to read next.config.js specifically. Without you having
anticipated that need. Reactive context, not
speculative.
The Codebase MCP server
The MCP server is an independent process exposed at
:9000. It has 4 tools any MCP client can call:
get_repo_infoReturns summarized package.json + top-level entries + git HEAD. The first call to orient yourself.
list_directory(path)Lists directory contents (relative to repo root).
read_file(path, max_bytes)Reads a text file (with server-side cap).
search(query, file_glob)Grep-like via ripgrep. Honors .gitignore by default.
Stack: official SDK + Express
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const server = new McpServer({ name: "codebase-mcp", version: "0.1.0" });
server.tool(
"read_file",
"Read a text file from the repo. Path relative to repo root.",
{
path: z.string().describe("File path relative to repo root"),
max_bytes: z.number().default(50_000),
},
async ({ path, max_bytes }) => {
const full = safePath(path); // ← anti path traversal
const buf = await readFile(full, "utf-8");
const truncated = buf.length > max_bytes;
return {
content: [{
type: "text",
text: buf.slice(0, max_bytes) +
(truncated ? `\n[…truncated]` : ""),
}],
};
},
);
safePath is not
optional
function safePath(p: string): string {
const full = resolve(REPO_ROOT, p);
const rel = relative(REPO_ROOT, full);
if (rel === ".." || rel.startsWith(`..${sep}`)) {
throw new Error(`path traversal denied: ${p}`);
}
return full;
}
Without this, a malicious or careless prompt can ask
read_file("../../../etc/passwd") and you read it.
It's the first rule of any filesystem-based MCP: every
resolved path must be verified to remain within the root sandbox.
execFile, not
exec
For search (ripgrep) and get_repo_info (git
rev-parse) we use Node's execFile, not
exec:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const runCommand = promisify(execFile);
// then:
const { stdout } = await runCommand("rg", [...args]);
execFile runs a binary with direct arguments — no shell,
no quote interpretation, no chance of injection. If your MCP tool
accepts input from the LLM (which yes, it does — the search query),
exec is a vulnerability waiting to happen.
In MCP servers, the LLM is untrusted input. Apply the same principles you'd apply to user input in a web API.
Transport: Streamable HTTP
The MCP SDK supports several transports. For distributed architecture (ours: agents in containers), Streamable HTTP is the modern standard (replacing legacy SSE). You wire it to Express in a few lines:
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => { transports[id] = transport; },
});
await server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
Each MCP client receives a unique mcp-session-id UUID.
The whole subsequent conversation travels with that header. Allows
per-client state (pagination cursor, transactions, etc.) and
multi-tenant isolation.
Direct curl smoke test
Before assembling the client agent, we validate that the server responds to JSON-RPC over HTTP:
# 1. initialize
curl -i -X POST http://localhost:9000/mcp \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05",
"capabilities":{},
"clientInfo":{"name":"smoke","version":"0.1"}
}}'
# returns mcp-session-id in header
# 2. notifications/initialized (full handshake)
curl -X POST http://localhost:9000/mcp \
-H "content-type: application/json" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. list available tools
curl -X POST http://localhost:9000/mcp \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
The MCP handshake requires initialize →
notifications/initialized → tools/list.
Three messages to discover capabilities. Conscious
overhead serving the agentic loop, not gratuitous bureaucracy.
The Project Mapper agent
Here comes the elegant part. The Project Mapper is a normal A2A agent
(Hono server at :8003, same pattern as Investigator and
Project Explorer). But internally it acts as an MCP
client — and it does that with almost no code.
The Anthropic SDK has MCP integrated
Since mid-2025, the Anthropic SDK supports mcp_servers
as a native parameter on messages.create:
const response = await claude.beta.messages.create(
{
model: "claude-sonnet-4-6",
max_tokens: 4000,
system: skillContent,
mcp_servers: [
{
type: "url",
url: "http://codebase-mcp:9000/mcp",
name: "codebase",
},
],
messages: [{ role: "user", content: "Map this repo..." }],
},
{ headers: { "anthropic-beta": "mcp-client-2025-04-04" } },
);
The SDK discovers the MCP server's tools, exposes them to Claude,
manages the tool→server→response calls in a loop automatically.
From the Project Mapper's code, calling Claude with
mcp_servers: [...] is indistinguishable from calling it
without tools — except the responses include reasoning about
repo files you never passed.
That's what changes the rules in 2025-2026. You used to have to implement the agentic loop manually: call LLM → parse tool calls → execute → feed back → repeat. Today the SDK does it for you. The line between "agent with tools" and "agent with MCP" becomes invisible.
The full handler
app.post("/tasks/send", async (c) => {
const { task_id, skill_id, payload } = TaskRequest.parse(await c.req.json());
if (skill_id !== "map-project") {
return c.json({ task_id, state: "failed", error: `unknown skill` });
}
const skillContent = readFileSync(skillPath, "utf-8");
const userPrompt = [
"Produce a ProjectMap of the codebase exposed via the `codebase` MCP server.",
"",
"Start with `get_repo_info` to orient yourself, then explore selectively:",
"list_directory on key folders, read_file on entry points and configs.",
"Aim for ≤15 tool calls. Return the ProjectMap as JSON in your response.",
].join("\n");
const response = await claude!.beta.messages.create(
{
model: MODEL,
max_tokens: 4000,
system: skillContent,
mcp_servers: [{ type: "url", url: MCP_URL, name: "codebase" }],
messages: [{ role: "user", content: userPrompt }],
},
{ headers: { "anthropic-beta": "mcp-client-2025-04-04" } },
);
const textContent = extractText(response);
const structured = extractJsonBlock(textContent);
return c.json({
task_id,
state: "completed",
result: {
map_id: `pm-${task_id}`,
map_md: textContent,
structured, // { stack_summary, project_map, exploration_log }
usage: response.usage,
},
});
});
How many lines of MCP client code did we write? Zero. The SDK absorbs everything.
The bug we hit: SDK 0.40 without MCP support
We started pinned at @anthropic-ai/sdk: ^0.40.0 in Day
1. That version is from late 2024 — before the SDK's MCP integration.
Activating live mode with mcp_servers: [...] returned error
400.
Fix: bump to ^0.92.0. MCP integration
landed around v0.50+.
docker exec project-mapper sh -c "npm view @anthropic-ai/sdk versions" | tail -5
# 0.91.0
# 0.91.1
# 0.92.0
Extrapolable lesson: in projects with rapidly
evolving SDKs (all LLM SDKs in 2025-2026 are), pinning them too tightly
saves you breaking-change surprises but blocks new features.
Modern caret ranges (^0.92) are the
balance — you keep getting patches without getting stuck.
The pipeline goes to 4 steps
With Project Mapper connected, the Orchestrator's /chat
gets a step 0:
chat → map-project (Project Mapper, via MCP) → ProjectMap
→ investigate (Investigator, with stack_summary)
→ build-brief (Project Explorer, with full project_map)
→ notify
The ProjectMap's stack_summary (small, ~200 tokens:
stack, package manager, detected conventions) flies to the Investigator
as project_context. The full project_map
(richer: file_tree, entry_points, similar_features) flies to the Project
Explorer.
// In the Orchestrator:
const mapping = await projectMapper.sendTask({
skillId: "map-project",
payload: { skill_uri: "skills/project-mapping.md" },
});
const stackSummary = mapping.result.structured?.stack_summary;
const projectMap = mapping.result.structured?.project_map;
const research = await investigator.sendTask({
payload: { ..., project_context: stackSummary }, // small
});
const briefing = await projectExplorer.sendTask({
payload: { ..., project_context: projectMap }, // big
});
Each downstream agent receives the amount of context it needs. The Investigator doesn't get its context flooded with the full file_tree; the Tech Lead does need it.
The architectural elegance
Watch what happened in Day 3, without anyone noticing:
- The Codebase MCP server doesn't know A2A exists. It's just a standard MCP server. Any MCP client can consume it: our Project Mapper, Claude Desktop, another IDE, etc.
- The Project Mapper doesn't implement an MCP client by hand. It passes a URL to the Anthropic SDK and that's it.
- The Orchestrator doesn't know the Project Mapper uses MCP. It just sees an A2A agent that returns a ProjectMap.
- A2A and MCP coexist without knowing about each other. Each does its own layer well.
This is the natural consequence of respecting each protocol's boundaries. A2A coordinates what it's good at coordinating (agents). MCP provides tools to those who need them (LLMs). When you put them together, each does its part and they don't step on each other.
A2A + MCP isn't a new architecture — it's A2A where some agents happen to be MCP clients, and MCP where some servers happen to be consumed by A2A agents. No extra layer. No new protocol. Just orthogonal composition.
Day 3 lessons
A2A and MCP are orthogonal. Don't choose between them when you can compose them.
MCP makes context reactive. Pre-computing JSON works to a point. MCP scales because the LLM decides what it needs.
The Anthropic SDK with integrated MCP eliminates ~150 lines of code per MCP-using agent. One of the highest-leverage tooling trades of 2026.
Path sandboxing is non-negotiable in MCP filesystem servers.
safePath()is the first line of any serious server.The LLM is untrusted input.
execFilenotexec. Validate schemas with Zod in every tool. Cap server-side everything (max_bytes, max_results, max_list_entries).Streamable HTTP > SSE for MCP in distributed architectures. It's the modern transport and handles sessions cleanly.
What we didn't do in Day 3 (on purpose)
- MCP write tools: the current server is read-only. When the Feature Developer needs to write, it'll be a different MCP server with stricter sandboxing. Separating read from write per server is good MCP security practice.
- ProjectMap caching: today the Project Mapper runs
on every
/chat. In live mode that's ~$0.05 unnecessary per chat. Day N will add caching by commit_sha (if HEAD didn't change, return the previous map). - Project Mapper in MODE=local: Anthropic SDK's native MCP integration doesn't exist in Ollama. Doing the agentic loop with manual MCP would be ~150 more lines. We leave it in mock when MODE=local.
Next step: the Feature Developer
Day 3 leaves the context resolved. The Tech Lead now produces briefs based on real code from the repo, not hallucinated. The cognitive chain is complete up to the brief.
The executive part is still missing: the agent that converts the brief into real code, following TDD. That's Day 4. It'll have its own drama (spoiler: not architecture-related).
Day 3 closes with: 6 services running (Orchestrator, 4 A2A agents, 1 MCP server), 4-step pipeline, agents with real repo context, and the lesson that A2A + MCP isn't summed complexity — it's orthogonal composition.
Continues: Day 4 — the Feature Developer, TDD, and an Anthropic billing block that changed the architecture more than any feature.
Comments
Loading comments…
Sign in on your dashboard to join the conversation.