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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
1733
package-lock.json
generated
Normal file
1733
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@a2a/mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"a2a-mcp": "dist/cli.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"prepare": "npm run build",
|
||||
"test": "node --test --import tsx tests/*.test.ts",
|
||||
"dev": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
81
src/api.ts
Normal file
81
src/api.ts
Normal 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
242
src/cli.ts
Normal 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
62
src/config.ts
Normal 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
61
src/credentials.ts
Normal 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
128
src/gateway.ts
Normal 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
16
src/index.ts
Normal 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
96
src/upstream.ts
Normal 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_,
|
||||
});
|
||||
}
|
||||
}
|
||||
46
tests/config.test.ts
Normal file
46
tests/config.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { addAgent, loadConfig, removeAgent } from "../src/config.js";
|
||||
|
||||
let originalHome: string | undefined;
|
||||
let tmpHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalHome = process.env.HOME;
|
||||
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "a2a-mcp-"));
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalHome !== undefined) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
await fs.rm(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("loadConfig returns empty when file is missing", async () => {
|
||||
const cfg = await loadConfig();
|
||||
assert.deepEqual(cfg, { version: 1, agents: [] });
|
||||
});
|
||||
|
||||
test("addAgent persists and replaces by name", async () => {
|
||||
await addAgent({ name: "a", url: "https://x", addedAt: "t1" });
|
||||
await addAgent({ name: "b", url: "https://y", addedAt: "t1" });
|
||||
await addAgent({ name: "a", url: "https://z", addedAt: "t2" });
|
||||
const cfg = await loadConfig();
|
||||
assert.deepEqual(
|
||||
cfg.agents.map((a) => `${a.name}:${a.url}`),
|
||||
["a:https://z", "b:https://y"],
|
||||
);
|
||||
});
|
||||
|
||||
test("removeAgent reports hit/miss", async () => {
|
||||
await addAgent({ name: "a", url: "https://x", addedAt: "t" });
|
||||
assert.equal(await removeAgent("missing"), false);
|
||||
assert.equal(await removeAgent("a"), true);
|
||||
const cfg = await loadConfig();
|
||||
assert.equal(cfg.agents.length, 0);
|
||||
});
|
||||
211
tests/gateway.test.ts
Normal file
211
tests/gateway.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { buildGateway, SEP } from "../src/gateway.js";
|
||||
import type { EnabledAgent } from "../src/config.js";
|
||||
|
||||
/**
|
||||
* Fake upstream A2A agent: speaks the JSON-RPC subset the gateway uses
|
||||
* (initialize, tools/list, tools/call). One handler per server instance.
|
||||
*/
|
||||
function startFakeAgent(opts: {
|
||||
tools: Array<{ name: string; description: string; inputSchema: any; outputSchema?: any }>;
|
||||
onCall: (name: string, args: any) => any;
|
||||
authHeader?: string;
|
||||
}): Promise<{ url: string; close: () => Promise<void>; requests: any[] }> {
|
||||
const requests: any[] = [];
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/mcp") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const auth = req.headers["authorization"];
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
requests.push({ auth, body });
|
||||
let result: any;
|
||||
if (body.method === "tools/list") {
|
||||
result = { tools: opts.tools };
|
||||
} else if (body.method === "tools/call") {
|
||||
try {
|
||||
result = opts.onCall(body.params.name, body.params.arguments);
|
||||
} catch (err) {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
error: { code: -32603, message: (err as Error).message },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
result = {};
|
||||
}
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Drive an in-process gateway Server via the SDK's request handlers. */
|
||||
async function callGateway(
|
||||
agents: EnabledAgent[],
|
||||
token: string | null,
|
||||
method: "tools/list" | "tools/call",
|
||||
params: any,
|
||||
) {
|
||||
const mcpServer = new Server(
|
||||
{ name: "test", version: "0.0.0" },
|
||||
{ capabilities: { tools: { listChanged: false } } },
|
||||
);
|
||||
await buildGateway({ agents, token, server: mcpServer });
|
||||
// Reach into the server's registered handlers — the SDK exposes them via
|
||||
// a private map, so we route through the public schema-keyed lookup we
|
||||
// know is wired up.
|
||||
const schema = method === "tools/list" ? ListToolsRequestSchema : CallToolRequestSchema;
|
||||
const handler = (mcpServer as any)._requestHandlers.get(schema.shape.method.value);
|
||||
if (!handler) throw new Error("handler not registered");
|
||||
return handler({ method, params }, {} as any);
|
||||
}
|
||||
|
||||
test("tools/list aggregates upstream tools with agent prefix", async () => {
|
||||
const upstream = await startFakeAgent({
|
||||
tools: [
|
||||
{
|
||||
name: "greet",
|
||||
description: "Greet someone",
|
||||
inputSchema: { type: "object", properties: { who: { type: "string" } } },
|
||||
},
|
||||
],
|
||||
onCall: () => ({ content: [{ type: "text", text: "hi" }], isError: false }),
|
||||
});
|
||||
try {
|
||||
const out = await callGateway(
|
||||
[{ name: "greeter", url: upstream.url, addedAt: "now" }],
|
||||
null,
|
||||
"tools/list",
|
||||
{},
|
||||
);
|
||||
assert.equal(out.tools.length, 1);
|
||||
assert.equal(out.tools[0].name, `greeter${SEP}greet`);
|
||||
assert.match(out.tools[0].description, /\[greeter\]/);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/list survives a single upstream failure", async () => {
|
||||
const good = await startFakeAgent({
|
||||
tools: [
|
||||
{
|
||||
name: "ping",
|
||||
description: "p",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
onCall: () => ({ content: [], isError: false }),
|
||||
});
|
||||
try {
|
||||
const out = await callGateway(
|
||||
[
|
||||
{ name: "broken", url: "http://127.0.0.1:1", addedAt: "now" },
|
||||
{ name: "good", url: good.url, addedAt: "now" },
|
||||
],
|
||||
null,
|
||||
"tools/list",
|
||||
{},
|
||||
);
|
||||
const names = out.tools.map((t: any) => t.name);
|
||||
assert.deepEqual(names, [`good${SEP}ping`]);
|
||||
} finally {
|
||||
await good.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/call routes by agent prefix and forwards the bearer token", async () => {
|
||||
const upstream = await startFakeAgent({
|
||||
tools: [],
|
||||
onCall: (name, args) => ({
|
||||
content: [{ type: "text", text: `called ${name} with ${args.who}` }],
|
||||
structuredContent: { result: `hello ${args.who}` },
|
||||
isError: false,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const result = await callGateway(
|
||||
[{ name: "greeter", url: upstream.url, addedAt: "now" }],
|
||||
"tkn-123",
|
||||
"tools/call",
|
||||
{ name: `greeter${SEP}greet`, arguments: { who: "world" } },
|
||||
);
|
||||
assert.equal(result.isError, false);
|
||||
assert.equal(result.structuredContent.result, "hello world");
|
||||
const req = upstream.requests.find((r) => r.body.method === "tools/call");
|
||||
assert.equal(req.auth, "Bearer tkn-123");
|
||||
assert.equal(req.body.params.name, "greet"); // prefix stripped
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/call with unknown agent returns soft error", async () => {
|
||||
const result = await callGateway([], null, "tools/call", {
|
||||
name: `ghost${SEP}skill`,
|
||||
arguments: {},
|
||||
});
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /unknown agent: ghost/);
|
||||
});
|
||||
|
||||
test("tools/call without prefix returns soft error", async () => {
|
||||
const result = await callGateway([], null, "tools/call", {
|
||||
name: "no_prefix",
|
||||
arguments: {},
|
||||
});
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /missing agent prefix/);
|
||||
});
|
||||
|
||||
test("tools/call surfaces upstream JSON-RPC error as soft error", async () => {
|
||||
const upstream = await startFakeAgent({
|
||||
tools: [],
|
||||
onCall: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await callGateway(
|
||||
[{ name: "boom", url: upstream.url, addedAt: "now" }],
|
||||
null,
|
||||
"tools/call",
|
||||
{ name: `boom${SEP}explode`, arguments: {} },
|
||||
);
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /kaboom/);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user