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