Agents

MCP vs A2A: what is the difference? (with code)

Two agents. One asked the other for release notes. The second one read a git repo through tools, wrote the notes and sent them back, while the first one watched the progress live. Two protocols made that happen, and everyone online mixes them up.

MCP or A2A? Which one do you need? Can you use both? Here is a one-sentence rule, four differences, a decision test, and then a working demo you can run with no API key.

What is the difference between MCP and A2A?#

MCP is how an agent uses tools. A2A is how an agent talks to another agent.

  • MCP is the tool belt. A tool does exactly what you tell it: read this file, run this query. It does not think. You call it, it answers.
  • A2A is the phone call. On the other end is a colleague, another agent, with its own brain, its own tools and its own opinions. You do not tell it how to do the job. You say what you want, it goes away, works, maybe asks a question, and comes back with a result.

Tools do. Colleagues decide.

If MCP is new to you, start with what is MCP and MCP vs API.

What are the four differences?#

MCPA2A
Who is on the other end?A tool (deterministic)An agent (autonomous)
Shape of a callRequest, response, doneA task with a life: submitted, working, completed or failed
How do I find it?tools/listAn agent card at /.well-known/agent-card.json
How much do I see inside?Everything, schema includedNothing, on purpose

The shape of a call is the big one. An MCP call is request and response. An A2A call creates a task, and the task moves through states. The SDK defines submitted, working, input required, auth required, completed, failed, canceled and rejected, and a client can stream the updates live. That is why A2A fits long jobs like "research this, it will take ten minutes".

Discovery differs too. An MCP client asks the server for its tool list. An A2A client reads the agent card, a small JSON file that says who the agent is, which skills it has and how to reach it. Think of a business card.

Opacity is on purpose: an A2A agent hides which model it runs and which tools it uses, so two companies' agents can cooperate without sharing their secrets.

A2A is an open protocol. Google created it and handed it to the Linux Foundation, and version 1.0, the first stable specification, was released in 2026.

What are we going to build?#

One chain, both protocols, each in the right place:

Orchestratorwants release notesRepo Analystan agent with toolsrepo-scoutan MCP tool server
The orchestrator talks A2A to the analyst. The analyst talks MCP to the tools.

The orchestrator does not know git or our tools. It finds the analyst, delegates the goal over A2A and watches the task. The analyst connects over MCP to the repo-scout server from the MCP server tutorial, calls git_log and list_todos, writes the notes and returns them as an artifact.

Set it up:

bash
mkdir mcp-vs-a2a && cd mcp-vs-a2a
npm init -y
npm install @a2a-js/sdk @modelcontextprotocol/client express
npm install -D typescript tsx @types/node @types/express

Use the same tsconfig.json as the MCP tutorial and set "type": "module".

How do I write the "brain" of an agent with no AI?#

There is no model in this demo, on purpose. You can run it without an API key and watch the protocols instead of the model. The analyst's brain is one plain function:

ts
// The "brain" of the analyst: plain code, so no API key is needed.
// In a real agent this is the one function you swap for a model.
export interface Commit {
  sha: string;
  date: string;
  author: string;
  subject: string;
}

export function summarize(commits: Commit[], todos: string): string {
  const groups: Record<string, Commit[]> = {
    Features: [],
    Fixes: [],
    Other: [],
  };
  for (const c of commits) {
    const s = c.subject.toLowerCase();
    const key = /\b(add|new|support|introduce)\b/.test(s)
      ? "Features"
      : /\b(fix|bug|handle|patch)\b/.test(s)
        ? "Fixes"
        : "Other";
    groups[key]!.push(c);
  }
  const lines = ["# Release notes", ""];
  for (const [title, list] of Object.entries(groups)) {
    if (!list.length) continue;
    lines.push(
      `## ${title}`,
      ...list.map((c) => `- ${c.subject} (${c.sha})`),
      "",
    );
  }
  const open = todos.split("\n").filter((l) => /TODO|FIXME/.test(l));
  lines.push(
    `## Known gaps (${open.length})`,
    ...open.map((l) => `- ${l}`),
  );
  return lines.join("\n");
}

In a real agent this is the one function you replace with a model call. Everything around it stays the same.

How does an agent use tools over MCP?#

ts
// MCP is how this agent uses TOOLS. A2A (below) is how other
// agents talk to THIS agent.
async function gatherFromRepo(
  limit: number,
): Promise<{ commits: Commit[]; todos: string }> {
  const mcp = new Client({ name: "repo-analyst", version: "1.0.0" });
  await mcp.connect(
    new StreamableHTTPClientTransport(new URL(SCOUT_URL)),
  );
  try {
    const log = await mcp.callTool({
      name: "git_log",
      arguments: { limit },
    });
    const todos = await mcp.callTool({
      name: "list_todos",
      arguments: {},
    });
    const commits = (log.structuredContent as { commits: Commit[] })
      .commits;
    const todoText = (
      todos.content as { type: string; text: string }[]
    )
      .map((c) => c.text)
      .join("\n");
    return { commits, todos: todoText };
  } finally {
    await mcp.close();
  }
}

Create a client, connect to repo-scout over HTTP, call git_log and list_todos, close. From the analyst's point of view repo-scout is a tool: no opinions, it runs git and returns lines. And because git_log has an output schema, the analyst gets typed commits back in structuredContent.

What is an A2A agent card?#

ts
// The agent card is the A2A business card: who I am, what I can
// do, where to reach me.
const card: AgentCard = {
  name: "Repo Analyst",
  description:
    "Reads a git repository and writes release notes. Delegate to it, don't micromanage it.",
  supportedInterfaces: [
    {
      url: `http://localhost:${PORT}/`,
      protocolBinding: "JSONRPC",
      tenant: "",
      protocolVersion: A2A_PROTOCOL_VERSION,
    },
  ],
  provider: { organization: "DevAIper", url: "https://devaiper.com" },
  version: "1.0.0",
  capabilities: {
    streaming: true,
    pushNotifications: false,
    extensions: [],
    extendedAgentCard: false,
  },
  securitySchemes: {},
  securityRequirements: [],
  defaultInputModes: ["text"],
  defaultOutputModes: ["text"],
  skills: [
    {
      id: "release-notes",
      name: "Release notes",
      description:
        "Summarise recent commits and open TODOs into release notes.",
      tags: ["git", "release"],
      examples: ["Write release notes for the last 10 commits"],
      inputModes: ["text"],
      outputModes: ["text"],
      securityRequirements: [],
    },
  ],
  documentationUrl: "",
  signatures: [],
};

The card is a business card: a name, a description, where to reach the agent and which protocol binding it speaks (here JSON-RPC), its capabilities (streaming is on) and its skills. Other agents read the card to decide whether to call you, so write the description for a colleague who has thirty seconds.

How does an A2A task work: submitted, working, artifact, completed?#

A2A hands the executor a request context and an event bus. You publish what happens, in order:

ts
const WORKING = TaskState.TASK_STATE_WORKING;
const COMPLETED = TaskState.TASK_STATE_COMPLETED;
const FAILED = TaskState.TASK_STATE_FAILED;

class AnalystExecutor implements AgentExecutor {
  async execute(ctx: RequestContext, bus: ExecutionEventBus) {
    const say = (state: TaskState, text?: string) =>
      bus.publish(statusUpdate(ctx, state, text));

    bus.publish(newTask(ctx)); // 1. a task is born
    try {
      say(WORKING, "Reading the repository through MCP tools..."); // 2.
      const { commits, todos } = await gatherFromRepo(10);
      say(WORKING, `Got ${commits.length} commits. Writing notes...`);

      const notes = summarize(commits, todos); // 3. the result
      bus.publish(artifactEvent(ctx, "release-notes.md", notes));
      say(COMPLETED);
    } catch (err) {
      const why = (err as Error).message;
      say(FAILED, `Could not reach the repo tools: ${why}`);
    }
  }

  // This demo finishes in about a second: nothing to cancel.
  async cancelTask() {}
}
  1. A task is born, in state submitted.
  2. Status updates with plain-words messages, so the caller sees progress live.
  3. An artifact: the result as a named file, here release-notes.md.
  4. completed, or failed with a reason.

Look at the catch. If the MCP server is down, the agent does not crash and does not hang. It publishes failed with an explanation. A good agent fails out loud, in the protocol, so the caller can decide what to do.

The SDK's message shapes are verbose, so I wrapped them in four small helpers (newTask, statusUpdate, artifactEvent, textOf) in one file you open once and then forget:

ts
// The SDK's message shapes are verbose (generated from the protocol
// definition). We wrap them once here and forget about them.
import { type Part, Role, TaskState } from "@a2a-js/sdk";
import { AgentEvent, type RequestContext } from "@a2a-js/sdk/server";
import { randomUUID } from "node:crypto";

export const textPart = (
  value: string,
  mediaType = "text/plain",
) => ({
  content: { $case: "text" as const, value },
  metadata: undefined,
  filename: "",
  mediaType,
});

const now = () => new Date().toISOString();

// A message from the user (what the orchestrator sends).
export const userRequest = (text: string) => ({
  tenant: "",
  message: {
    messageId: randomUUID(),
    role: Role.ROLE_USER,
    parts: [textPart(text)],
    taskId: "",
    contextId: "",
    extensions: [],
    metadata: undefined,
    referenceTaskIds: [],
  },
  configuration: undefined,
  metadata: undefined,
});

// 1. A task is born, in state "submitted".
export const newTask = (ctx: RequestContext) =>
  AgentEvent.task({
    id: ctx.taskId,
    contextId: ctx.contextId,
    status: {
      state: TaskState.TASK_STATE_SUBMITTED,
      timestamp: now(),
      message: undefined,
    },
    artifacts: [],
    history: [ctx.userMessage],
    metadata: undefined,
  });

// 2. A status update, optionally with a plain-words message.
export const statusUpdate = (
  ctx: RequestContext,
  state: TaskState,
  text?: string,
) =>
  AgentEvent.statusUpdate({
    taskId: ctx.taskId,
    contextId: ctx.contextId,
    status: {
      state,
      timestamp: now(),
      message: text
        ? {
            role: Role.ROLE_AGENT,
            messageId: randomUUID(),
            parts: [textPart(text)],
            taskId: ctx.taskId,
            contextId: ctx.contextId,
            extensions: [],
            metadata: undefined,
            referenceTaskIds: [],
          }
        : undefined,
    },
    metadata: undefined,
  });

// 3. The result: a named file (artifact) with its content.
export const artifactEvent = (
  ctx: RequestContext,
  name: string,
  markdown: string,
) =>
  AgentEvent.artifactUpdate({
    taskId: ctx.taskId,
    contextId: ctx.contextId,
    artifact: {
      artifactId: randomUUID(),
      name,
      description: name,
      parts: [textPart(markdown, "text/markdown")],
      metadata: undefined,
      extensions: [],
    },
    lastChunk: true,
    append: false,
    metadata: undefined,
  });

// Join the text parts of a message or artifact (what the orchestrator prints).
export const textOf = (parts: Part[] | undefined) =>
  (parts ?? [])
    .map((p) => (p.content?.$case === "text" ? p.content.value : ""))
    .join("");

Then serve the agent: a request handler with the card, a task store and the executor, behind Express.

ts
const handler = new DefaultRequestHandler(
  card,
  new InMemoryTaskStore(),
  new AnalystExecutor(),
);
const app = express();
app.use(
  `/${AGENT_CARD_PATH}`,
  agentCardHandler({ agentCardProvider: handler }),
);
app.use(
  "/",
  jsonRpcHandler({
    requestHandler: handler,
    userBuilder: UserBuilder.noAuthentication,
  }),
);
app.listen(PORT, "127.0.0.1", () =>
  console.error(
    `repo-analyst (A2A) on http://localhost:${PORT}/  card: /${AGENT_CARD_PATH}`,
  ),
);

The in-memory task store forgets everything when the process restarts. The SDK has database-backed stores for real deployments.

How do I delegate to an A2A agent?#

The orchestrator, the A2A client:

ts
import { ClientFactory } from "@a2a-js/sdk/client";
import { taskStateToJSON } from "@a2a-js/sdk";
import { textOf, userRequest } from "./a2a-helpers.js";

const AGENT_URL = process.env.AGENT_URL ?? "http://localhost:41241";

// A2A step 1: find the agent by reading its card.
const client = await new ClientFactory().createFromUrl(AGENT_URL);
const card = await client.getAgentCard();
console.log(
  `Found agent: ${card.name} (${card.skills.map((s) => s.name).join(", ")})`,
);

// A2A step 2: delegate a goal in plain language, watch the task.
const stream = client.sendMessageStream(
  userRequest("Write release notes for the last 10 commits"),
);

for await (const event of stream) {
  const e = event.payload;
  switch (e?.$case) {
    case "task":
      console.log(`[task] ${e.value.id} created`);
      break;
    case "statusUpdate": {
      const st = e.value.status!;
      const msg = textOf(st.message?.parts);
      console.log(`[status] ${taskStateToJSON(st.state)} ${msg}`);
      break;
    }
    case "artifactUpdate": {
      const art = e.value.artifact!;
      console.log(`\n--- artifact: ${art.name} ---`);
      console.log(textOf(art.parts));
      break;
    }
  }
}

Step one is discover: give the factory a URL, it fetches the agent card and picks a transport the agent supports. Nothing about the agent's API is hard-coded, because the card told us. Step two is delegate: send a goal in plain language and iterate over a stream of events: a task when it is created, status updates while it works and an artifact with the result.

What does it look like when it runs?#

Three terminals. First the MCP server from the other tutorial, then the analyst, then the orchestrator:

bash
# terminal 1: the MCP server
REPO_SCOUT_REPO=/path/to/your/repo npm run http   # in the repo-scout project

# terminal 2: the analyst (A2A server)
npx tsx src/analyst.ts

# terminal 3: the orchestrator (A2A client)
npx tsx src/orchestrator.ts

The agent card is plain JSON, so you can read it with curl:

bash
curl -s localhost:41241/.well-known/agent-card.json
text
{
    "name": "Repo Analyst",
    "description": "Reads a git repository and writes release notes. Delegate to it, don't micromanage it.",
    "supportedInterfaces": [
        {
            "url": "http://localhost:41241/",
            "protocolBinding": "JSONRPC",
            "tenant": "",
            "protocolVersion": "1.0"
        }
    ],
    "provider": {
        "organization": "DevAIper",
        "url": "https://devaiper.com"
    },
    "version": "1.0.0",
    "capabilities": {
        "streaming": true,
        "pushNotifications": false,
        "extensions": [],
        "extendedAgentCard": false
    },
...

And the orchestrator's output, from a real run:

text
Found agent: Repo Analyst (Release notes)
[task] e575eb72-eaae-40b8-9cbd-0bab9ebcf06d created
[status] TASK_STATE_WORKING Reading the repository through MCP tools...
[status] TASK_STATE_WORKING Got 3 commits. Writing notes...

--- artifact: release-notes.md ---
# Release notes

## Features
- Add shipping calculation (07aa2bf)
- Add discount codes (0870206)

## Other
- Initial commit: cart total (ca4a3bf)

## Known gaps (3)
- discount.js:1:// TODO: support percentage discounts
- shipping.js:1:// TODO: free shipping above 50
- shipping.js:2:// FIXME: handle international addresses
[status] TASK_STATE_COMPLETED 

What happens when the tool server goes down?#

Stop the MCP server and run the orchestrator again. Same task, same flow, but the failure travels through the protocol as a state:

text
Found agent: Repo Analyst (Release notes)
[task] f4366165-d7ce-4795-93be-35e5c25979d5 created
[status] TASK_STATE_WORKING Reading the repository through MCP tools...
[status] TASK_STATE_FAILED Could not reach the repo tools: fetch failed

The caller learns what happened and why, with no stack trace on a server somewhere.

Do I need MCP, A2A, or both?#

Ask one question: does the other side need to decide how to do the work?

  • No: it is a tool or a data source. Use MCP.
  • Yes, and it is your own code in your own app. Just call it: a function, a queue, or an agent wrapped as an MCP tool is simpler, and you can ship it today.
  • Yes, and it is another team's or company's agent, or the job is long-running. That is what A2A is for: a real task lifecycle, live updates, cancellation and a clear failed state.

In my opinion, most teams only need MCP. If you own both sides, a new protocol buys you little. That is my view, not the spec's, so weigh it against your situation.

And you can combine them, exactly like the demo: A2A between the agents, MCP inside each agent for its tools. Each protocol in its own layer. For the security side of tool servers, read MCP security risks, and for the bigger picture of when you need an agent at all, AI agents vs workflows.

Official docs#

Tested in October 2026 with Node 24, TypeScript 7, @a2a-js/sdk 1.3.0 (A2A spec v1.0), @modelcontextprotocol/client 2.3.1 and Express 5. The demo needs the repo-scout server from the MCP tutorial running on port 3333.

Frequently asked questions

What is the difference between MCP and A2A?

MCP standardizes how an AI agent uses tools and data sources. A2A standardizes how one agent talks to another agent. A tool does exactly what it is told; an A2A agent decides for itself how to do the work.

Does A2A replace MCP?

No. They solve different problems in different layers. A common design uses A2A between agents and MCP inside each agent for its own tools.

Do I need an LLM to use A2A?

No. A2A is a protocol and does not care what runs behind the agent. The demo in this article has no model at all: its decision logic is plain code, and you would swap that one function for a model call in a real agent.

Who maintains the A2A protocol?

A2A is an open protocol. Google created it and handed it to the Linux Foundation, which governs it today. Version 1.0, the first stable specification, was released in 2026.

Do I need A2A or is MCP enough?

If the other side is a tool or data source, use MCP. If you own both sides, a function call, a queue or an agent wrapped as an MCP tool is simpler. Reach for A2A when the other side is an agent you do not control, or the job is long-running and needs live task updates.