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:
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user