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