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

81
src/api.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Thin control-plane client. Mirrors the relevant subset of
* a2a_pack.cli.api_client.ControlPlaneClient.
*/
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(`API ${status}: ${message}`);
this.name = "ApiError";
}
}
export interface AgentRow {
name: string;
version: string;
status: string;
url?: string;
description?: string;
}
export class ControlPlaneClient {
constructor(
private apiUrl: string,
private token: string | null,
) {
this.apiUrl = apiUrl.replace(/\/+$/, "");
}
private headers(): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json" };
if (this.token) h["authorization"] = `bearer ${this.token}`;
return h;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const resp = await fetch(`${this.apiUrl}${path}`, {
method,
headers: this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
let detail: string;
try {
const j: any = await resp.json();
detail = j?.detail ?? JSON.stringify(j);
} catch {
detail = await resp.text();
}
throw new ApiError(resp.status, detail);
}
if (resp.status === 204) return undefined as T;
return (await resp.json()) as T;
}
login(email: string, password: string) {
return this.request<{ access_token: string; user: { email: string } }>(
"POST",
"/v1/auth/login",
{ email, password },
);
}
signup(email: string, password: string) {
return this.request<{ access_token: string; user: { email: string } }>(
"POST",
"/v1/auth/signup",
{ email, password },
);
}
me() {
return this.request<{ email: string }>("GET", "/v1/me");
}
listAgents() {
return this.request<AgentRow[]>("GET", "/v1/agents");
}
getAgent(name: string) {
return this.request<AgentRow>("GET", `/v1/agents/${encodeURIComponent(name)}`);
}
}

242
src/cli.ts Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env node
/**
* `a2a-mcp` — install once, expose any number of a2acloud agents to your MCP
* client (Claude Code, Cursor, etc.).
*
* No-argument invocation runs the gateway over stdio. That's the form an MCP
* client launches. Subcommands manage the local agent list, auth, etc.
*/
import { Command } from "commander";
import readline from "node:readline/promises";
import { stdin as input, stdout as output, stderr } from "node:process";
import { ApiError, ControlPlaneClient } from "./api.js";
import { addAgent, loadConfig, removeAgent } from "./config.js";
import {
DEFAULT_API_URL,
clearCredentials,
loadCredentials,
resolveApiUrl,
saveCredentials,
} from "./credentials.js";
import { runStdio } from "./gateway.js";
const program = new Command();
program
.name("a2a-mcp")
.description("MCP gateway for a2acloud agents.")
.version("0.1.0");
async function client(apiOverride?: string): Promise<ControlPlaneClient> {
const creds = await loadCredentials();
const apiUrl = resolveApiUrl(apiOverride ?? creds?.apiUrl);
return new ControlPlaneClient(apiUrl, creds?.token ?? null);
}
function fail(msg: string, code = 1): never {
stderr.write(`error: ${msg}\n`);
process.exit(code);
}
async function prompt(label: string, opts: { hidden?: boolean } = {}): Promise<string> {
const rl = readline.createInterface({ input, output, terminal: true });
try {
if (opts.hidden) {
// readline doesn't natively hide; this is a best-effort hide.
const orig = (rl as any)._writeToOutput;
(rl as any)._writeToOutput = (s: string) => {
if (s.includes(label)) (output as any).write(label);
else (output as any).write("*");
};
const answer = await rl.question(label);
(rl as any)._writeToOutput = orig;
output.write("\n");
return answer;
}
return await rl.question(label);
} finally {
rl.close();
}
}
// --- run (default) --------------------------------------------------------- #
program
.command("run", { isDefault: true, hidden: true })
.description("Start the MCP gateway on stdio (invoked by your MCP client).")
.action(async () => {
try {
await runStdio();
} catch (err) {
stderr.write(`a2a-mcp: ${(err as Error).message}\n`);
process.exit(1);
}
});
// --- auth ------------------------------------------------------------------ #
program
.command("login")
.description("Authenticate against the control plane.")
.option("--email <email>")
.option("--password <password>")
.option("--api <url>", "Override control plane URL", DEFAULT_API_URL)
.action(async (opts: { email?: string; password?: string; api: string }) => {
const email = opts.email ?? (await prompt("email: "));
const password = opts.password ?? (await prompt("password: ", { hidden: true }));
const api = resolveApiUrl(opts.api);
try {
const out = await new ControlPlaneClient(api, null).login(email, password);
await saveCredentials({ apiUrl: api, token: out.access_token, email: out.user.email });
output.write(`logged in as ${out.user.email} @ ${api}\n`);
} catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
});
program
.command("logout")
.description("Forget the cached token.")
.action(async () => {
const cleared = await clearCredentials();
output.write(cleared ? "logged out\n" : "(not logged in)\n");
});
program
.command("whoami")
.description("Show the currently logged-in user.")
.action(async () => {
const creds = await loadCredentials();
if (!creds) fail("not logged in (run `a2a-mcp login`)");
try {
const me = await (await client()).me();
output.write(`${me.email} (${creds.apiUrl})\n`);
} catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
});
// --- agents ---------------------------------------------------------------- #
program
.command("agents")
.description("List agents visible to your account (control-plane truth).")
.option("--api <url>")
.action(async (opts: { api?: string }) => {
try {
const rows = await (await client(opts.api)).listAgents();
if (rows.length === 0) {
output.write("(no agents)\n");
return;
}
for (const r of rows) {
output.write(
` ${r.name} v${r.version} [${r.status}] ${r.url ?? "-"}\n`,
);
}
} catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
});
program
.command("list")
.description("List the agents this gateway exposes locally.")
.action(async () => {
const cfg = await loadConfig();
if (cfg.agents.length === 0) {
output.write("(no agents added; try `a2a-mcp add <name>`)\n");
return;
}
for (const a of cfg.agents) output.write(` ${a.name} ${a.url}\n`);
});
program
.command("add <name>")
.description("Enable an agent in this gateway (queries control plane for URL).")
.option("--url <url>", "Skip the control-plane lookup and use this URL")
.option("--api <url>")
.action(
async (name: string, opts: { url?: string; api?: string }) => {
let url = opts.url;
let description: string | undefined;
if (!url) {
try {
const row = await (await client(opts.api)).getAgent(name);
url = row.url;
description = row.description;
} catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
}
if (!url) fail(`agent ${name} has no public URL yet`);
await addAgent({
name,
url,
description,
addedAt: new Date().toISOString(),
});
output.write(`added ${name} -> ${url}\n`);
output.write("restart your MCP client to pick up the new tools.\n");
},
);
program
.command("remove <name>")
.alias("rm")
.description("Remove an agent from this gateway.")
.action(async (name: string) => {
const ok = await removeAgent(name);
output.write(ok ? `removed ${name}\n` : `${name} was not enabled\n`);
});
// --- doctor ---------------------------------------------------------------- #
program
.command("doctor")
.description("Probe each enabled agent's /mcp endpoint and report tool counts.")
.action(async () => {
const cfg = await loadConfig();
const creds = await loadCredentials();
if (cfg.agents.length === 0) {
output.write("(no agents enabled)\n");
return;
}
const { UpstreamAgent } = await import("./upstream.js");
for (const a of cfg.agents) {
const u = new UpstreamAgent({
name: a.name,
url: a.url,
token: creds?.token ?? null,
});
try {
const tools = await u.listTools();
output.write(` ${a.name}: ok (${tools.length} tools)\n`);
} catch (err) {
output.write(` ${a.name}: FAIL ${(err as Error).message}\n`);
}
}
});
// --- print-config ---------------------------------------------------------- #
program
.command("print-config")
.description("Print the JSON snippet to paste into your MCP client config.")
.action(async () => {
const snippet = {
mcpServers: {
a2a: {
command: "npx",
args: ["-y", "@a2a/mcp"],
},
},
};
output.write(JSON.stringify(snippet, null, 2) + "\n");
});
program.parseAsync(process.argv).catch((err) => {
stderr.write(`a2a-mcp: ${(err as Error).message}\n`);
process.exit(1);
});

62
src/config.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Local config at ~/.a2a/mcp.json — which agents the gateway should expose.
*
* We store the agent URL alongside the name so the gateway never needs to hit
* the control plane during startup. Adding/removing an agent is a single
* file write; the MCP client picks up the change on the next list_tools.
*/
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
export interface EnabledAgent {
name: string;
url: string;
description?: string;
addedAt: string;
}
export interface GatewayConfig {
version: 1;
agents: EnabledAgent[];
}
const cfgDir = () => path.join(os.homedir(), ".a2a");
const cfgFile = () => path.join(cfgDir(), "mcp.json");
const EMPTY: GatewayConfig = { version: 1, agents: [] };
export async function loadConfig(): Promise<GatewayConfig> {
try {
const raw = await fs.readFile(cfgFile(), "utf8");
const data = JSON.parse(raw);
if (data?.version !== 1 || !Array.isArray(data.agents)) return { ...EMPTY };
return data as GatewayConfig;
} catch (err: any) {
if (err?.code === "ENOENT") return { ...EMPTY };
throw err;
}
}
export async function saveConfig(cfg: GatewayConfig): Promise<void> {
await fs.mkdir(cfgDir(), { recursive: true });
await fs.writeFile(cfgFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
}
export async function addAgent(agent: EnabledAgent): Promise<GatewayConfig> {
const cfg = await loadConfig();
const existing = cfg.agents.findIndex((a) => a.name === agent.name);
if (existing >= 0) cfg.agents[existing] = agent;
else cfg.agents.push(agent);
await saveConfig(cfg);
return cfg;
}
export async function removeAgent(name: string): Promise<boolean> {
const cfg = await loadConfig();
const before = cfg.agents.length;
cfg.agents = cfg.agents.filter((a) => a.name !== name);
if (cfg.agents.length === before) return false;
await saveConfig(cfg);
return true;
}

61
src/credentials.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes.
*
* We deliberately *only read* this file (and overwrite it on `a2a-mcp login`)
* so a single login flow is shared between the Python and Node tooling.
*/
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
export const DEFAULT_API_URL = "https://api.a2acloud.io";
export interface Credentials {
apiUrl: string;
token: string;
email: string;
}
const credsDir = () => path.join(os.homedir(), ".a2a");
const credsFile = () => path.join(credsDir(), "credentials.json");
export async function loadCredentials(): Promise<Credentials | null> {
try {
const raw = await fs.readFile(credsFile(), "utf8");
const data = JSON.parse(raw);
if (!data?.token) return null;
return {
apiUrl: data.api_url ?? DEFAULT_API_URL,
token: data.token,
email: data.email ?? "",
};
} catch (err: any) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
export async function saveCredentials(c: Credentials): Promise<void> {
await fs.mkdir(credsDir(), { recursive: true });
await fs.writeFile(
credsFile(),
JSON.stringify({ api_url: c.apiUrl, token: c.token, email: c.email }),
{ mode: 0o600 },
);
}
export async function clearCredentials(): Promise<boolean> {
try {
await fs.unlink(credsFile());
return true;
} catch (err: any) {
if (err?.code === "ENOENT") return false;
throw err;
}
}
export function resolveApiUrl(override?: string): string {
if (override) return override;
if (process.env.A2A_API_URL) return process.env.A2A_API_URL;
return DEFAULT_API_URL;
}

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,
};
}

16
src/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export { buildGateway, runStdio, SEP } from "./gateway.js";
export type { GatewayHandle, GatewayOptions } from "./gateway.js";
export { addAgent, loadConfig, removeAgent, saveConfig } from "./config.js";
export type { EnabledAgent, GatewayConfig } from "./config.js";
export { UpstreamAgent, UpstreamError } from "./upstream.js";
export type { UpstreamCallResult, UpstreamConfig, UpstreamTool } from "./upstream.js";
export { ControlPlaneClient, ApiError } from "./api.js";
export type { AgentRow } from "./api.js";
export {
DEFAULT_API_URL,
clearCredentials,
loadCredentials,
resolveApiUrl,
saveCredentials,
} from "./credentials.js";
export type { Credentials } from "./credentials.js";

96
src/upstream.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Per-agent JSON-RPC client. Talks to the deployed agent's POST /mcp endpoint.
*
* We hand-roll the wire calls (instead of using @modelcontextprotocol/sdk's
* Client) because (a) the surface we need is tiny — tools/list, tools/call —
* and (b) we want to send the user's bearer token as a plain Authorization
* header without coupling to a transport class.
*/
const JSON_RPC = "2.0";
export interface UpstreamConfig {
name: string;
url: string;
token: string | null;
}
export interface UpstreamTool {
name: string;
description?: string;
inputSchema: unknown;
outputSchema?: unknown;
}
export interface UpstreamCallResult {
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
structuredContent?: unknown;
isError?: boolean;
}
export class UpstreamError extends Error {
constructor(
public agent: string,
public status: number,
message: string,
) {
super(`upstream ${agent}: ${message}`);
this.name = "UpstreamError";
}
}
let _nextId = 1;
export class UpstreamAgent {
constructor(private cfg: UpstreamConfig) {
this.cfg.url = cfg.url.replace(/\/+$/, "");
}
get name(): string {
return this.cfg.name;
}
private async rpc<T>(method: string, params: unknown): Promise<T> {
const id = _nextId++;
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
};
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`;
const resp = await fetch(`${this.cfg.url}/mcp`, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
}
const env: any = await resp.json();
if (env?.error) {
throw new UpstreamError(
this.cfg.name,
500,
`${env.error.code}: ${env.error.message}`,
);
}
return env?.result as T;
}
async listTools(): Promise<UpstreamTool[]> {
const out = await this.rpc<{ tools: UpstreamTool[] }>("tools/list", {});
return out?.tools ?? [];
}
async callTool(
name: string,
arguments_: Record<string, unknown>,
): Promise<UpstreamCallResult> {
return this.rpc<UpstreamCallResult>("tools/call", {
name,
arguments: arguments_,
});
}
}