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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
*.tsbuildinfo
.DS_Store
.env
.env.local

1733
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View 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
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_,
});
}
}

46
tests/config.test.ts Normal file
View 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
View 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
View 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"]
}