initial: @a2a/mcp gateway for a2acloud agents

Local MCP server that exposes any number of deployed A2A agents to
Claude Code, Cursor, Windsurf, and other MCP clients. Reuses the
~/.a2a/credentials.json token written by the Python a2a CLI. Adding
or removing agents is a single ~/.a2a/mcp.json edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 20:33:54 -03:00
commit 9c5d8172dc
13 changed files with 2738 additions and 0 deletions

128
src/gateway.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* MCP gateway server. Fans out tools/list and tools/call to each enabled
* upstream agent (deployed on a2acloud). Tool names are prefixed with the
* agent slug so multiple agents can coexist in one MCP namespace.
*
* Naming: ``{agent}__{skill}``. Double underscore separator. Skill names
* containing ``__`` are not supported.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { loadConfig, type EnabledAgent } from "./config.js";
import { loadCredentials } from "./credentials.js";
import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js";
export const SEP = "__";
export interface GatewayOptions {
/** Override the enabled agents (otherwise read from ~/.a2a/mcp.json). */
agents?: EnabledAgent[];
/** Override the bearer token (otherwise read from ~/.a2a/credentials.json). */
token?: string | null;
/** Optional override for the underlying MCP Server (tests). */
server?: Server;
}
export interface GatewayHandle {
server: Server;
upstreams: Map<string, UpstreamAgent>;
}
export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> {
const cfg = opts.agents ?? (await loadConfig()).agents;
const token =
opts.token !== undefined
? opts.token
: (await loadCredentials())?.token ?? null;
const upstreams = new Map<string, UpstreamAgent>();
for (const a of cfg) {
upstreams.set(a.name, new UpstreamAgent({ name: a.name, url: a.url, token }));
}
const server =
opts.server ??
new Server(
{ name: "a2a-mcp", version: "0.1.0" },
{ capabilities: { tools: { listChanged: false } } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Array<UpstreamTool & { name: string }> = [];
await Promise.all(
[...upstreams.values()].map(async (u) => {
try {
const upstreamTools = await u.listTools();
for (const t of upstreamTools) {
tools.push({
...t,
name: `${u.name}${SEP}${t.name}`,
description: t.description
? `[${u.name}] ${t.description}`
: `[${u.name}] ${t.name}`,
});
}
} catch (err) {
// Silently skip an upstream that's down — surfacing an error here
// would break the client's whole tool list. The user can debug via
// `a2a-mcp doctor`.
process.stderr.write(
`a2a-mcp: skipping ${u.name}: ${(err as Error).message}\n`,
);
}
}),
);
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const fullName = req.params.name;
const idx = fullName.indexOf(SEP);
if (idx < 0) {
return _softError(`tool name missing agent prefix: ${fullName}`) as any;
}
const agentName = fullName.slice(0, idx);
const toolName = fullName.slice(idx + SEP.length);
const upstream = upstreams.get(agentName);
if (!upstream) {
return _softError(`unknown agent: ${agentName}`) as any;
}
try {
const result = await upstream.callTool(
toolName,
(req.params.arguments as Record<string, unknown>) ?? {},
);
// Pass through the upstream's CallToolResult as-is. Upstreams already
// populate content / structuredContent / isError per the MCP spec.
// Cast through `unknown` because the SDK's ServerResult union has
// additional task-shaped variants we don't produce.
return result as unknown as any;
} catch (err) {
const msg =
err instanceof UpstreamError
? err.message
: `${(err as Error).name}: ${(err as Error).message}`;
return _softError(msg) as any;
}
});
return { server, upstreams };
}
export async function runStdio(opts: GatewayOptions = {}): Promise<void> {
const { server } = await buildGateway(opts);
const transport = new StdioServerTransport();
await server.connect(transport);
}
function _softError(message: string) {
return {
content: [{ type: "text" as const, text: message }],
isError: true,
};
}