Skip to main content
← Back to blog

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.

Day 3 — A2A + MCP together

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:

  1. A Codebase MCP server that exposes the repo as Model Context Protocol — agents can list directories, read files, and search content dynamically.
  2. 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 reads auth/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:

ToolWhat it does 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 initializenotifications/initializedtools/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

  1. A2A and MCP are orthogonal. Don't choose between them when you can compose them.

  2. MCP makes context reactive. Pre-computing JSON works to a point. MCP scales because the LLM decides what it needs.

  3. The Anthropic SDK with integrated MCP eliminates ~150 lines of code per MCP-using agent. One of the highest-leverage tooling trades of 2026.

  4. Path sandboxing is non-negotiable in MCP filesystem servers. safePath() is the first line of any serious server.

  5. The LLM is untrusted input. execFile not exec. Validate schemas with Zod in every tool. Cap server-side everything (max_bytes, max_results, max_list_entries).

  6. 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…