Skip to main content
Glama
README.md
# a2a-mcp-bridge

Expose any [A2A (Agent2Agent)](https://a2aproject.github.io/A2A/) agent to Claude — and any other [MCP](https://modelcontextprotocol.io) client — over stdio or HTTP.

Point it at an A2A endpoint and the agent shows up as four callable tools. The bridge handles the parts of A2A that don't map cleanly onto MCP: multi-turn memory for stateless deployments, long-running turns that would otherwise look like a hang, and responses that interleave the agent's internal reasoning with its actual answer.

```
MCP client  ──stdio/HTTP──▶  a2a-mcp-bridge  ──JSON-RPC──▶  A2A agent
```

## Install

```bash
git clone https://github.com/parampratap-star/a2a-mcp-bridge.git
cd a2a-mcp-bridge
npm install
```

Node 18 or newer.

## Configure

Two variables are required. There is no default endpoint — a baked-in one would point at someone else's agent.

```bash
cp .env.example .env
```

```bash
A2A_BASE_URL=https://your-a2a-host.example.com
A2A_AGENT_PATH=/api-endpoint/your-agent
```

The bridge derives three URLs from those:

| Derived | Used for |
|---|---|
| `…/jsonrpc` | `message/send` calls |
| `…/jsonrpc/sse` | streaming, where the deployment supports it |
| `…/.well-known/agent-card.json` | capability discovery |

Start either entrypoint without these and it exits `78` with setup instructions rather than failing at the first request.

See [`.env.example`](.env.example) for the full list, including auth, timeouts, and history limits.

## Connect it to Claude

Add to your MCP client config (for Claude Desktop, `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "my-a2a-agent": {
      "command": "node",
      "args": ["/absolute/path/to/a2a-mcp-bridge/server.js"],
      "env": {
        "A2A_BASE_URL": "https://your-a2a-host.example.com",
        "A2A_AGENT_PATH": "/api-endpoint/your-agent",
        "A2A_AGENT_LABEL": "My Agent",
        "A2A_AGENT_DESCRIPTION": "Answers questions about our internal platform."
      }
    }
  }
}
```

`env` is not optional. MCP clients spawn the server with a minimal environment rather than inheriting your shell, so anything the bridge needs has to be declared here.

`A2A_AGENT_LABEL` and `A2A_AGENT_DESCRIPTION` are worth setting: they become the tool's title and description, which is what the model reads when deciding whether to reach for it.

## Tools

| Tool | What it does |
|---|---|
| `ask_agent` | Send a message, get the reply. Keeps multi-turn context automatically. |
| `get_last_trace` | Full detail of the last turn — every internal tool call, citations, raw parts. |
| `get_agent_card` | The agent's declared capabilities, skills, and security schemes. |
| `reset_conversation` | Clear the stored transcript. |

`ask_agent` returns structured content alongside the text: `answer`, `tool_calls[]`, `citations`, `followups`, `data`, `partial`, `context_id`, `turn`, `elapsed_ms`.

## What the bridge actually handles

Three things make A2A awkward to consume directly from MCP, and each is a deliberate piece of this codebase rather than an accident.

**Stateless deployments still get multi-turn memory.** Some A2A servers echo `contextId` back but never reload prior turns — every `message/send` arrives cold. Verified against the reference deployment: seeding a fact and asking for it again with a matching `contextId`, `taskId`, or `parentMessageId` all failed to recall it. So the transcript is kept client-side in [`conversation.js`](conversation.js) and replayed as context, bounded by turn count and character budget. Pass `include_history: false` to send a bare message instead.

**Long turns don't look like hangs.** A turn that invokes the agent's own tools runs 15–60 seconds. MCP clients reset their request timeout on each progress notification, so the bridge ticks every 5s while a call is in flight — but only for clients that sent a `progressToken`, per the spec.

**Reasoning is separated from the answer.** A response is a *sequence* of parts: internal tool invocations arrive first as `THOUGHT` parts, then the answer, then `data` blocks carrying citations. Concatenating them yields a reply that opens with "Getting help from the tool…" and ends in a JSON blob. [`normalizeResult`](a2a-client.js) splits them into a trace, an answer, and citations.

There's a wrinkle worth knowing about: some turns put the real answer *inside* a part tagged `THOUGHT`, leaving no answer part at all. The bridge promotes such a part only when it clears two independent guards — it doesn't open in a first-person deliberation register, and it isn't flagged `parallelToolCallEnabled`. Both must pass, so the failure mode is an empty answer rather than the model's reasoning leaking out as its reply.

## HTTP transport

```bash
npm run start:http
```

| Route | Transport |
|---|---|
| `GET /sse` + `POST /messages?sessionId=…` | Legacy SSE |
| `ALL /mcp` | Streamable HTTP |
| `GET /health` | Liveness and session counts |

Binds `127.0.0.1:3737` by default (`MCP_HOST`, `MCP_PORT`).

**Origin checking is on by default.** A server bound to loopback is still reachable by any page your browser visits — a hostile site can point a hostname it controls at `127.0.0.1` and become same-origin, then drive every tool here. Requests carrying a non-local `Origin` get a 403. Browserless clients send no `Origin` and are unaffected. Set `MCP_ALLOWED_ORIGINS` to a comma-separated list to permit specific origins, or `*` to disable the check.

Idle Streamable HTTP sessions are reaped after `MCP_SESSION_TTL_MS` (default 30 min), since the spec makes the client's `DELETE` optional. The SSE stream emits a keepalive comment every 20s to stay under undici's ~5-minute body-inactivity timeout, which otherwise kills the stream with `Body Timeout Error`.

## Tests

```bash
npm test
```

Fully offline — the suite covers the response normaliser, conversation memory, and config validation without needing an endpoint. CI runs it on Node 18, 20, 22, and 24.

The `scripts/smoke-*` entries hit a real endpoint and need configuration:

```bash
npm run smoke:mcp    # full stdio handshake against a live agent
npm run smoke:http   # both HTTP transports
```

## Worked example

This was built against a UnifyApps A2A deployment. A real configuration looks like:

```bash
A2A_BASE_URL=https://<your-org-host>
A2A_AGENT_PATH=/api-endpoint/<your-agent-slug>
A2A_AGENT_LABEL=AI-FDE
A2A_AGENT_DESCRIPTION=Helps build UnifyApps solutions — data objects, automations, workflows, integrations, and connectors.
```

Find your own values in the agent's URL: everything up to the host is `A2A_BASE_URL`, and the path segment identifying the agent is `A2A_AGENT_PATH`. Confirm the pair resolves by fetching `${A2A_BASE_URL}${A2A_AGENT_PATH}/.well-known/agent-card.json` — if that returns an agent card, the bridge will work.

## License

MIT — see [LICENSE](LICENSE).

TDQS

A4.2/5.0

Scored across 4 tools

Disambiguation5/5

Each tool serves a clearly distinct purpose: ask_agent for interaction, get_last_trace for inspecting the latest turn, get_agent_card for metadata, and reset_conversation for state management. There is no overlap between them.

Naming Consistency5/5

All tool names follow the same verb_noun snake_case pattern (ask_agent, get_last_trace, get_agent_card, reset_conversation). The style is uniform and predictable.

Tool Count5/5

Four tools is a well-scoped set for a single-agent bridge. Each tool covers an essential function without redundancy or bloat.

Completeness5/5

The tool surface covers the full lifecycle of interacting with the A2A agent: sending messages, inspecting agent capabilities, reviewing trace details, and resetting conversation state. No obvious dead ends or missing operations for this narrow domain.

Maintenance

ActivityMaintained
ResponsivenessNo issues