10 Commits

Author SHA1 Message Date
3ebe88708e Add dynamic agent discovery tools
All checks were successful
build / test (push) Successful in 20s
publish / npm (push) Successful in 22s
2026-06-05 20:07:37 -03:00
80261e5224 Coordinate a2amcp credential refresh
All checks were successful
build / test (push) Successful in 19s
publish / npm (push) Successful in 22s
2026-06-01 09:51:45 -03:00
f0b2d9ead8 Fix a2amcp login error reporting
All checks were successful
build / test (push) Successful in 20s
publish / npm (push) Successful in 22s
2026-05-31 21:03:15 -03:00
2814112ab3 Use Keycloak OAuth for a2amcp login
All checks were successful
build / test (push) Successful in 19s
publish / npm (push) Successful in 22s
2026-05-31 20:46:17 -03:00
1e034412ca Bump a2amcp to 0.1.7
All checks were successful
build / test (push) Successful in 19s
publish / npm (push) Successful in 22s
2026-05-31 19:56:29 -03:00
bbc7f593b1 Document hosted OAuth MCP connectors
All checks were successful
build / test (push) Successful in 19s
2026-05-31 19:26:02 -03:00
b0dd1fa28d Commit pending workspace updates
All checks were successful
build / test (push) Successful in 31s
2026-05-28 20:25:06 -03:00
91c7bf9c57 WIP a2a-mcp updates
All checks were successful
build / test (push) Successful in 30s
2026-05-23 14:33:03 -04:00
4ff51157c0 Relay upstream MCP elicitation
All checks were successful
build / test (push) Successful in 31s
2026-05-23 13:32:25 -04:00
2fd97d8d09 Handle upstream stream drops
All checks were successful
build / test (push) Successful in 29s
2026-05-23 12:20:22 -04:00
17 changed files with 1933 additions and 143 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
.codegraph/
*.log *.log
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store

View File

@@ -4,10 +4,26 @@ MCP gateway for a2acloud agents. It runs locally as a stdio MCP server and
exposes deployed A2A agents as tools to Claude Code, Cursor, and other MCP exposes deployed A2A agents as tools to Claude Code, Cursor, and other MCP
clients. clients.
This package is the **local stdio gateway**. It is different from the hosted
OAuth MCP connector endpoints used by ChatGPT/Claude web connectors:
```text
Local stdio gateway: npx -y a2amcp
Remote standard MCP: https://<agent>.a2acloud.io/mcp
Remote connector MCP: https://<agent>.a2acloud.io/connector-mcp
Remote orchestrator MCP: https://api.a2acloud.io/connector-mcp
```
Use `a2amcp` for editor clients that launch a local MCP server process. Use the
remote `/connector-mcp` URL for hosted connector UIs that need OAuth login,
Dynamic Client Registration, async job polling, and structured approval/input
interrupts.
## Quickstart ## Quickstart
```bash ```bash
npx -y a2amcp login npx -y a2amcp login
npx -y a2amcp search "security evidence"
npx -y a2amcp add <agent-name> npx -y a2amcp add <agent-name>
npx -y a2amcp doctor npx -y a2amcp doctor
``` ```
@@ -30,9 +46,10 @@ Restart the client after adding or removing agents.
## Commands ## Commands
```bash ```bash
a2amcp login # save control-plane credentials a2amcp login # sign in through Keycloak and save OAuth tokens
a2amcp whoami # show current account a2amcp whoami # show current account
a2amcp agents # list agents visible to your account a2amcp agents # list agents visible to your account
a2amcp search [query] # search visible agents by text, tag, or skill
a2amcp add <name> # expose one agent through this gateway a2amcp add <name> # expose one agent through this gateway
a2amcp list # list locally enabled agents a2amcp list # list locally enabled agents
a2amcp remove <name> # stop exposing an agent a2amcp remove <name> # stop exposing an agent
@@ -41,3 +58,40 @@ a2amcp logout # clear local credentials
``` ```
Running `a2amcp` with no command starts the MCP gateway over stdio. Running `a2amcp` with no command starts the MCP gateway over stdio.
The gateway also exposes local management tools to MCP clients:
```text
a2a_search_agents # search visible agents from inside the MCP client
a2a_add_agent # enable one matching agent dynamically
a2a_list_enabled_agents # show agents currently exposed by this gateway
```
After `a2a_add_agent`, clients that support tool-list change notifications can
refresh tools immediately. Other clients may need a manual tool refresh or
restart.
`a2amcp login` uses Keycloak Authorization Code + PKCE. It starts a local
loopback callback server, opens the browser, exchanges the code for a Keycloak
access token, and stores the token in `~/.a2a/credentials.json`. For headless
machines, pass an existing Keycloak access token:
```bash
a2amcp login --token "$KEYCLOAK_ACCESS_TOKEN"
```
## Hosted OAuth Connectors
ChatGPT and Claude-style hosted connectors should not run this stdio gateway.
Configure them directly with the remote connector URL:
```text
https://api.a2acloud.io/connector-mcp
https://<agent>.a2acloud.io/connector-mcp
```
Choose OAuth authentication. The server advertises protected-resource metadata,
the client dynamically registers with Keycloak, and the browser consent flow
issues tokens scoped to MCP access. Long-running connector calls return a
`job_id`; the client should poll `chat_result` on the orchestrator connector or
`job_result` on a leaf-agent connector.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "a2amcp", "name": "a2amcp",
"version": "0.1.6", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "a2amcp", "name": "a2amcp",
"version": "0.1.6", "version": "0.1.11",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "a2amcp", "name": "a2amcp",
"version": "0.1.6", "version": "0.1.11",
"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.", "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", "type": "module",
"bin": { "bin": {

View File

@@ -1,7 +1,4 @@
/** /** Thin control-plane client used with Keycloak bearer tokens. */
* Thin control-plane client. Mirrors the relevant subset of
* a2a_pack.cli.api_client.ControlPlaneClient.
*/
export class ApiError extends Error { export class ApiError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
super(`API ${status}: ${message}`); super(`API ${status}: ${message}`);
@@ -15,6 +12,33 @@ export interface AgentRow {
status: string; status: string;
url?: string; url?: string;
description?: string; description?: string;
card?: Record<string, any>;
}
export interface AgentSearchInputField {
name: string;
type?: string | null;
required?: boolean;
}
export interface AgentSearchSkill {
name: string;
description?: string;
tags?: string[];
input_fields?: AgentSearchInputField[];
}
export interface AgentSearchRow {
name: string;
description: string;
status: string;
public: boolean;
url?: string | null;
score?: number | null;
match_source: string;
llm_provisioning?: string | null;
setup_required?: boolean;
skills?: AgentSearchSkill[];
} }
export class ControlPlaneClient { export class ControlPlaneClient {
@@ -26,8 +50,11 @@ export class ControlPlaneClient {
} }
private headers(): Record<string, string> { private headers(): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json" }; const h: Record<string, string> = {
if (this.token) h["authorization"] = `bearer ${this.token}`; accept: "application/json",
"content-type": "application/json",
};
if (this.token) h["authorization"] = `Bearer ${this.token}`;
return h; return h;
} }
@@ -37,32 +64,12 @@ export class ControlPlaneClient {
headers: this.headers(), headers: this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}); });
const text = await resp.text();
if (!resp.ok) { if (!resp.ok) {
let detail: string; throw new ApiError(resp.status, errorDetail(text, resp.statusText));
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; if (resp.status === 204) return undefined as T;
return (await resp.json()) as T; return (text ? JSON.parse(text) : undefined) as T;
}
login(email: string, password: string) {
return this.request<{
access_token: string;
user: { id: number; email: string };
}>("POST", "/v1/auth/login", { email, password });
}
signup(email: string, password: string) {
return this.request<{
access_token: string;
user: { id: number; email: string };
}>("POST", "/v1/auth/signup", { email, password });
} }
me() { me() {
@@ -73,7 +80,43 @@ export class ControlPlaneClient {
return this.request<AgentRow[]>("GET", "/v1/agents"); return this.request<AgentRow[]>("GET", "/v1/agents");
} }
searchAgents(opts: {
query?: string;
tags?: string[];
skill?: string;
limit?: number;
}) {
const params = new URLSearchParams();
const query = opts.query?.trim();
if (query) params.set("q", query);
for (const tag of opts.tags ?? []) {
const clean = tag.trim();
if (clean) params.append("tag", clean);
}
const skill = opts.skill?.trim();
if (skill) params.set("skill", skill);
if (opts.limit !== undefined) params.set("limit", String(opts.limit));
const qs = params.toString();
return this.request<AgentSearchRow[]>(
"GET",
`/v1/agents/search${qs ? `?${qs}` : ""}`,
);
}
getAgent(name: string) { getAgent(name: string) {
return this.request<AgentRow>("GET", `/v1/agents/${encodeURIComponent(name)}`); return this.request<AgentRow>("GET", `/v1/agents/${encodeURIComponent(name)}`);
} }
} }
function errorDetail(text: string, fallback: string): string {
if (!text.trim()) return fallback;
try {
const parsed = JSON.parse(text);
const detail = parsed?.detail;
if (typeof detail === "string") return detail;
if (detail !== undefined) return JSON.stringify(detail);
return JSON.stringify(parsed);
} catch {
return text;
}
}

View File

@@ -15,15 +15,28 @@ const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string }; const pkg = require("../package.json") as { version: string };
import { ApiError, ControlPlaneClient } from "./api.js"; import { ApiError, ControlPlaneClient } from "./api.js";
import { addAgent, loadConfig, removeAgent } from "./config.js"; import { loadConfig, removeAgent } from "./config.js";
import { import {
DEFAULT_API_URL, DEFAULT_API_URL,
clearCredentials, clearCredentials,
loadCredentials, loadCredentials,
resolveApiUrl, resolveApiUrl,
saveCredentials,
} from "./credentials.js"; } from "./credentials.js";
import { runStdio } from "./gateway.js"; import { runStdio } from "./gateway.js";
import {
DEFAULT_OAUTH_CLIENT_ID,
DEFAULT_OAUTH_ISSUER,
DEFAULT_OAUTH_SCOPE,
DEFAULT_REDIRECT_PORT,
loginWithAccessToken,
loginWithBrowser,
refreshCredentialsIfNeeded,
} from "./oauth.js";
import {
enableAgentByName,
searchVisibleAgents,
summarizeAgent,
} from "./registry.js";
const program = new Command(); const program = new Command();
@@ -34,8 +47,9 @@ program
async function client(apiOverride?: string): Promise<ControlPlaneClient> { async function client(apiOverride?: string): Promise<ControlPlaneClient> {
const creds = await loadCredentials(); const creds = await loadCredentials();
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
const apiUrl = resolveApiUrl(apiOverride ?? creds?.apiUrl); const apiUrl = resolveApiUrl(apiOverride ?? creds?.apiUrl);
return new ControlPlaneClient(apiUrl, creds?.token ?? null); return new ControlPlaneClient(apiUrl, refreshed?.token ?? null);
} }
function fail(msg: string, code = 1): never { function fail(msg: string, code = 1): never {
@@ -82,25 +96,42 @@ program
program program
.command("login") .command("login")
.description("Authenticate against the control plane.") .description("Authenticate with Keycloak and cache a bearer token.")
.option("--email <email>")
.option("--password <password>")
.option("--api <url>", "Override control plane URL", DEFAULT_API_URL) .option("--api <url>", "Override control plane URL", DEFAULT_API_URL)
.action(async (opts: { email?: string; password?: string; api: string }) => { .option("--issuer <url>", "Keycloak realm issuer", DEFAULT_OAUTH_ISSUER)
const email = opts.email ?? (await prompt("email: ")); .option("--client-id <id>", "Keycloak public client id", DEFAULT_OAUTH_CLIENT_ID)
const password = opts.password ?? (await prompt("password: ", { hidden: true })); .option("--scope <scope>", "OAuth scopes to request", DEFAULT_OAUTH_SCOPE)
.option("--port <port>", "Loopback callback port", String(DEFAULT_REDIRECT_PORT))
.option("--token <token>", "Save an already-issued Keycloak access token")
.option("--no-open", "Print the login URL without opening a browser")
.action(async (opts: {
api: string;
issuer: string;
clientId: string;
scope: string;
port: string;
token?: string;
open?: boolean;
}) => {
const api = resolveApiUrl(opts.api); const api = resolveApiUrl(opts.api);
try { try {
const out = await new ControlPlaneClient(api, null).login(email, password); const common = {
const bucket = `user-${out.user.id}-files`;
await saveCredentials({
apiUrl: api, apiUrl: api,
token: out.access_token, issuer: opts.issuer,
email: out.user.email, clientId: opts.clientId,
userId: out.user.id, scope: opts.scope,
bucket, };
}); const creds = opts.token
output.write(`logged in as ${out.user.email} @ ${api}\n`); ? await loginWithAccessToken(opts.token, common)
: await loginWithBrowser({
...common,
port: Number.parseInt(opts.port, 10),
openBrowser: opts.open !== false,
onAuthorizationUrl: (url) => {
output.write(`Open this URL to sign in with Keycloak:\n${url}\n`);
},
});
output.write(`logged in as ${creds.email} @ ${api}\n`);
} catch (err) { } catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message); fail(err instanceof ApiError ? err.message : (err as Error).message);
} }
@@ -151,6 +182,45 @@ program
} }
}); });
program
.command("search [query]")
.description("Search agents visible to your account.")
.option("--tag <tag...>", "Require one or more tags")
.option("--skill <skill>", "Require a skill name")
.option("--limit <n>", "Maximum results", "8")
.option("--api <url>")
.action(
async (
query: string | undefined,
opts: { tag?: string[]; skill?: string; limit: string; api?: string },
) => {
try {
const rows = await searchVisibleAgents(await client(opts.api), {
query,
tags: opts.tag,
skill: opts.skill,
limit: Number.parseInt(opts.limit, 10),
});
if (rows.length === 0) {
output.write("(no matching agents)\n");
return;
}
const enabled = new Set((await loadConfig()).agents.map((agent) => agent.name));
for (const row of rows) {
const marker = enabled.has(row.name) ? "*" : " ";
const score = row.score !== null && row.score !== undefined
? ` score=${row.score.toFixed(3)}`
: "";
const setup = row.setup_required ? " setup-required" : "";
output.write(`${marker} ${summarizeAgent(row)}${score}${setup}\n`);
}
output.write("* already enabled locally\n");
} catch (err) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
},
);
program program
.command("list") .command("list")
.description("List the agents this gateway exposes locally.") .description("List the agents this gateway exposes locally.")
@@ -160,7 +230,9 @@ program
output.write("(no agents added; try `a2amcp add <name>`)\n"); output.write("(no agents added; try `a2amcp add <name>`)\n");
return; return;
} }
for (const a of cfg.agents) output.write(` ${a.name} ${a.url}\n`); for (const a of cfg.agents) {
output.write(` ${a.name} ${a.url}${a.mcpPath ?? "/mcp"}\n`);
}
}); });
program program
@@ -170,26 +242,21 @@ program
.option("--api <url>") .option("--api <url>")
.action( .action(
async (name: string, opts: { url?: string; api?: string }) => { async (name: string, opts: { url?: string; api?: string }) => {
let url = opts.url; try {
let description: string | undefined; const creds = await loadCredentials();
if (!url) { const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
try { const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl);
const row = await (await client(opts.api)).getAgent(name); const added = await enableAgentByName({
url = row.url; cp: opts.url ? undefined : new ControlPlaneClient(apiUrl, refreshed?.token ?? null),
description = row.description; apiUrl,
} catch (err) { name,
fail(err instanceof ApiError ? err.message : (err as Error).message); url: opts.url,
} });
output.write(`${added.replaced ? "updated" : "added"} ${name} -> ${added.endpoint}\n`);
output.write("restart your MCP client, or ask it to refresh tools, to pick up changes.\n");
} 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");
}, },
); );
@@ -210,6 +277,7 @@ program
.action(async () => { .action(async () => {
const cfg = await loadConfig(); const cfg = await loadConfig();
const creds = await loadCredentials(); const creds = await loadCredentials();
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
if (cfg.agents.length === 0) { if (cfg.agents.length === 0) {
output.write("(no agents enabled)\n"); output.write("(no agents enabled)\n");
return; return;
@@ -219,7 +287,8 @@ program
const u = new UpstreamAgent({ const u = new UpstreamAgent({
name: a.name, name: a.name,
url: a.url, url: a.url,
token: creds?.token ?? null, mcpPath: a.mcpPath,
token: refreshed?.token ?? null,
}); });
try { try {
const tools = await u.listTools(); const tools = await u.listTools();

View File

@@ -12,6 +12,7 @@ import path from "node:path";
export interface EnabledAgent { export interface EnabledAgent {
name: string; name: string;
url: string; url: string;
mcpPath?: string;
description?: string; description?: string;
addedAt: string; addedAt: string;
} }

View File

@@ -1,8 +1,7 @@
/** /**
* Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes. * Reader/writer for ~/.a2a/credentials.json — same file the Python `a2a` CLI
* * writes. New logins store Keycloak access/refresh tokens; older token-only
* We deliberately *only read* this file (and overwrite it on `a2amcp login`) * files are still readable.
* so a single login flow is shared between the Python and Node tooling.
*/ */
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
@@ -16,6 +15,11 @@ export interface Credentials {
email: string; email: string;
userId?: number; userId?: number;
bucket?: string; bucket?: string;
refreshToken?: string;
expiresAt?: number;
issuer?: string;
clientId?: string;
scope?: string;
} }
const credsDir = () => path.join(os.homedir(), ".a2a"); const credsDir = () => path.join(os.homedir(), ".a2a");
@@ -32,6 +36,12 @@ export async function loadCredentials(): Promise<Credentials | null> {
email: data.email ?? "", email: data.email ?? "",
userId: typeof data.user_id === "number" ? data.user_id : undefined, userId: typeof data.user_id === "number" ? data.user_id : undefined,
bucket: typeof data.bucket === "string" ? data.bucket : undefined, bucket: typeof data.bucket === "string" ? data.bucket : undefined,
refreshToken:
typeof data.refresh_token === "string" ? data.refresh_token : undefined,
expiresAt: typeof data.expires_at === "number" ? data.expires_at : undefined,
issuer: typeof data.oauth_issuer === "string" ? data.oauth_issuer : undefined,
clientId: typeof data.client_id === "string" ? data.client_id : undefined,
scope: typeof data.scope === "string" ? data.scope : undefined,
}; };
} catch (err: any) { } catch (err: any) {
if (err?.code === "ENOENT") return null; if (err?.code === "ENOENT") return null;
@@ -48,6 +58,11 @@ export async function saveCredentials(c: Credentials): Promise<void> {
}; };
if (c.userId !== undefined) out.user_id = c.userId; if (c.userId !== undefined) out.user_id = c.userId;
if (c.bucket !== undefined) out.bucket = c.bucket; if (c.bucket !== undefined) out.bucket = c.bucket;
if (c.refreshToken !== undefined) out.refresh_token = c.refreshToken;
if (c.expiresAt !== undefined) out.expires_at = c.expiresAt;
if (c.issuer !== undefined) out.oauth_issuer = c.issuer;
if (c.clientId !== undefined) out.client_id = c.clientId;
if (c.scope !== undefined) out.scope = c.scope;
await fs.writeFile(credsFile(), JSON.stringify(out), { mode: 0o600 }); await fs.writeFile(credsFile(), JSON.stringify(out), { mode: 0o600 });
} }

View File

@@ -11,23 +11,99 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { import {
CallToolRequestSchema, CallToolRequestSchema,
ElicitResultSchema,
ListToolsRequestSchema, ListToolsRequestSchema,
ResultSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { loadConfig, type EnabledAgent } from "./config.js"; import { loadConfig, type EnabledAgent } from "./config.js";
import { loadCredentials } from "./credentials.js"; import { ApiError, ControlPlaneClient } from "./api.js";
import { loadCredentials, resolveApiUrl, type Credentials } from "./credentials.js";
import { refreshCredentialsIfNeeded } from "./oauth.js";
import {
enableAgentByName,
searchVisibleAgents,
summarizeAgent,
} from "./registry.js";
import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js"; import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string }; const pkg = require("../package.json") as { version: string };
export const SEP = "__"; export const SEP = "__";
const SEARCH_AGENTS_TOOL = "a2a_search_agents";
const ADD_AGENT_TOOL = "a2a_add_agent";
const LIST_ENABLED_AGENTS_TOOL = "a2a_list_enabled_agents";
const MANAGEMENT_TOOLS: Array<UpstreamTool & { name: string }> = [
{
name: SEARCH_AGENTS_TOOL,
description:
"[a2a] Search agents visible to your account by text, tag, or skill before enabling them in this MCP gateway.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Free-text search query." },
tags: {
type: "array",
items: { type: "string" },
description: "Optional tags that matching agents must have.",
},
skill: { type: "string", description: "Optional skill name filter." },
limit: {
type: "integer",
minimum: 1,
maximum: 25,
default: 8,
description: "Maximum number of results.",
},
},
additionalProperties: false,
},
},
{
name: ADD_AGENT_TOOL,
description:
"[a2a] Enable an agent in this MCP gateway. Newly added agent tools appear on the next tools/list refresh.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Agent name to enable." },
url: {
type: "string",
description: "Optional direct agent base URL; skips control-plane lookup.",
},
mcp_path: {
type: "string",
description: "Optional MCP path when url is provided. Defaults to /mcp.",
},
},
required: ["name"],
additionalProperties: false,
},
},
{
name: LIST_ENABLED_AGENTS_TOOL,
description: "[a2a] List agents currently enabled in this local MCP gateway.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
];
export interface GatewayOptions { export interface GatewayOptions {
/** Override the enabled agents (otherwise read from ~/.a2a/mcp.json). */ /** Override the enabled agents (otherwise read from ~/.a2a/mcp.json). */
agents?: EnabledAgent[]; agents?: EnabledAgent[];
/** Override the bearer token (otherwise read from ~/.a2a/credentials.json). */ /** Override the bearer token (otherwise read from ~/.a2a/credentials.json). */
token?: string | null; token?: string | null;
/** Test/advanced override for one-shot upstream JSON-RPC calls. */
upstreamRequestTimeoutMs?: number;
/** Test/advanced override for idle SSE upstream tools/call streams. */
upstreamStreamIdleTimeoutMs?: number;
/** Optional control-plane API override for management tools (tests/advanced). */
apiUrl?: string;
/** Optional override for the underlying MCP Server (tests). */ /** Optional override for the underlying MCP Server (tests). */
server?: Server; server?: Server;
} }
@@ -39,36 +115,65 @@ export interface GatewayHandle {
export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> { export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> {
const cfg = opts.agents ?? (await loadConfig()).agents; const cfg = opts.agents ?? (await loadConfig()).agents;
const creds = opts.token !== undefined ? null : await loadCredentials(); let creds: Credentials | null = opts.token !== undefined ? null : await loadCredentials();
async function refreshedToken(): Promise<string | null> {
if (opts.token !== undefined) return opts.token;
if (!creds) return null;
creds = await refreshCredentialsIfNeeded(creds);
return creds.token;
}
const token = opts.token !== undefined ? opts.token : creds?.token ?? null; const token = opts.token !== undefined ? opts.token : creds?.token ?? null;
// Forward CP credentials + bucket as params._meta so upstream agents // Forward CP credentials + bucket as params._meta so upstream agents
// can act on behalf of the caller (mirrors the platform-orchestrator // can act on behalf of the caller (mirrors the platform-orchestrator
// call shape). When the user isn't logged in, _meta is omitted and // call shape). When the user isn't logged in, _meta is omitted and
// upstream sees the same unauthenticated context as before. // upstream sees the same unauthenticated context as before.
const cpJwt = creds?.token ?? null; const apiUrl = resolveApiUrl(opts.apiUrl ?? creds?.apiUrl);
const cpUrl = creds?.apiUrl ?? null; const cpUrl = opts.apiUrl ?? creds?.apiUrl ?? null;
const bucket = creds?.bucket ?? null; const bucket = creds?.bucket ?? null;
const upstreams = new Map<string, UpstreamAgent>(); const upstreams = new Map<string, UpstreamAgent>();
function makeUpstream(a: EnabledAgent): UpstreamAgent {
return new UpstreamAgent({
name: a.name, url: a.url, mcpPath: a.mcpPath, token,
tokenProvider: refreshedToken,
cpJwtProvider: refreshedToken,
cpUrl, bucket,
requestTimeoutMs: opts.upstreamRequestTimeoutMs,
streamIdleTimeoutMs: opts.upstreamStreamIdleTimeoutMs,
});
}
function upsertUpstream(a: EnabledAgent): void {
upstreams.set(a.name, makeUpstream(a));
}
async function syncUpstreamsFromConfig(): Promise<void> {
if (opts.agents) return;
const latest = (await loadConfig()).agents;
const names = new Set(latest.map((agent) => agent.name));
for (const name of upstreams.keys()) {
if (!names.has(name)) upstreams.delete(name);
}
for (const agent of latest) {
upsertUpstream(agent);
}
}
for (const a of cfg) { for (const a of cfg) {
upstreams.set( upsertUpstream(a);
a.name,
new UpstreamAgent({
name: a.name, url: a.url, token,
cpJwt, cpUrl, bucket,
}),
);
} }
const server = const server =
opts.server ?? opts.server ??
new Server( new Server(
{ name: "a2amcp", version: pkg.version }, { name: "a2amcp", version: pkg.version },
{ capabilities: { tools: { listChanged: false } } }, { capabilities: { tools: { listChanged: true } } },
); );
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Array<UpstreamTool & { name: string }> = []; await syncUpstreamsFromConfig();
const tools: Array<UpstreamTool & { name: string }> = [...MANAGEMENT_TOOLS];
await Promise.all( await Promise.all(
[...upstreams.values()].map(async (u) => { [...upstreams.values()].map(async (u) => {
try { try {
@@ -97,6 +202,98 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => { server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
const fullName = req.params.name; const fullName = req.params.name;
const args = (req.params.arguments as Record<string, unknown>) ?? {};
if (fullName === SEARCH_AGENTS_TOOL) {
try {
const rows = await searchVisibleAgents(
new ControlPlaneClient(apiUrl, await refreshedToken()),
{
query: stringArg(args.query),
tags: stringArrayArg(args.tags),
skill: stringArg(args.skill),
limit: numberArg(args.limit, 8),
},
);
const enabled = new Set((await loadConfig()).agents.map((agent) => agent.name));
return {
content: [
{
type: "text" as const,
text: rows.length
? rows
.map((row) => {
const marker = enabled.has(row.name) ? "enabled" : "available";
return `${row.name} (${marker}) - ${summarizeAgent(row)}`;
})
.join("\n")
: "No matching agents.",
},
],
structuredContent: {
agents: rows.map((row) => ({
...row,
enabled: enabled.has(row.name),
})),
},
} as any;
} catch (err) {
return _softError(errorMessage(err)) as any;
}
}
if (fullName === ADD_AGENT_TOOL) {
const name = stringArg(args.name);
if (!name) return _softError("name is required") as any;
try {
const added = await enableAgentByName({
cp: stringArg(args.url)
? undefined
: new ControlPlaneClient(apiUrl, await refreshedToken()),
apiUrl,
name,
url: stringArg(args.url),
mcpPath: stringArg(args.mcp_path),
});
upsertUpstream(added);
try {
await extra.sendNotification({
method: "notifications/tools/list_changed",
params: {},
} as any);
} catch {
// best effort; clients can still refresh tools explicitly
}
return {
content: [
{
type: "text" as const,
text: `${added.replaced ? "Updated" : "Added"} ${added.name} -> ${added.endpoint}. Refresh tools to see its MCP tools.`,
},
],
structuredContent: { agent: added },
} as any;
} catch (err) {
return _softError(errorMessage(err)) as any;
}
}
if (fullName === LIST_ENABLED_AGENTS_TOOL) {
const cfg = await loadConfig();
return {
content: [
{
type: "text" as const,
text: cfg.agents.length
? cfg.agents
.map((agent) => `${agent.name} ${agent.url.replace(/\/+$/, "")}${agent.mcpPath ?? "/mcp"}`)
.join("\n")
: "No agents enabled.",
},
],
structuredContent: { agents: cfg.agents },
} as any;
}
const idx = fullName.indexOf(SEP); const idx = fullName.indexOf(SEP);
if (idx < 0) { if (idx < 0) {
return _softError(`tool name missing agent prefix: ${fullName}`) as any; return _softError(`tool name missing agent prefix: ${fullName}`) as any;
@@ -120,7 +317,7 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
try { try {
const result = await upstream.callTool( const result = await upstream.callTool(
toolName, toolName,
(req.params.arguments as Record<string, unknown>) ?? {}, args,
{ {
progressToken, progressToken,
onNotification: async (notif) => { onNotification: async (notif) => {
@@ -130,6 +327,18 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
// best effort // best effort
} }
}, },
onRequest: async (upstreamReq) => {
const schema = upstreamReq.method === "elicitation/create"
? ElicitResultSchema
: ResultSchema;
return extra.sendRequest(
{
method: upstreamReq.method,
params: upstreamReq.params,
} as any,
schema as any,
);
},
}, },
); );
// Pass through the upstream's CallToolResult as-is. Upstreams already // Pass through the upstream's CallToolResult as-is. Upstreams already
@@ -161,3 +370,29 @@ function _softError(message: string) {
isError: true, isError: true,
}; };
} }
function stringArg(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function stringArrayArg(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
function numberArg(value: unknown, fallback: number): number {
const number = typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: fallback;
if (!Number.isFinite(number)) return fallback;
return Math.min(25, Math.max(1, Math.floor(number)));
}
function errorMessage(err: unknown): string {
return err instanceof ApiError ? err.message : (err as Error).message;
}

View File

@@ -14,3 +14,13 @@ export {
saveCredentials, saveCredentials,
} from "./credentials.js"; } from "./credentials.js";
export type { Credentials } from "./credentials.js"; export type { Credentials } from "./credentials.js";
export {
DEFAULT_OAUTH_CLIENT_ID,
DEFAULT_OAUTH_ISSUER,
DEFAULT_OAUTH_SCOPE,
DEFAULT_REDIRECT_PORT,
createPkcePair,
loginWithAccessToken,
loginWithBrowser,
refreshCredentialsIfNeeded,
} from "./oauth.js";

448
src/oauth.ts Normal file
View File

@@ -0,0 +1,448 @@
import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import type { AddressInfo } from "node:net";
import { ControlPlaneClient } from "./api.js";
import {
DEFAULT_API_URL,
type Credentials,
loadCredentials,
saveCredentials,
} from "./credentials.js";
export const DEFAULT_OAUTH_ISSUER = "https://auth.a2acloud.io/realms/a2acloud";
export const DEFAULT_OAUTH_CLIENT_ID = "a2acloud-cli";
export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read";
export const DEFAULT_REDIRECT_PORT = 41873;
const CALLBACK_PATH = "/callback";
const REFRESH_LOCK_WAIT_MS = 10_000;
const REFRESH_LOCK_STALE_MS = 30_000;
const REFRESH_LOCK_POLL_MS = 100;
type OpenIdConfiguration = {
authorization_endpoint: string;
token_endpoint: string;
};
type TokenResponse = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
error?: string;
error_description?: string;
};
export type BrowserLoginOptions = {
apiUrl?: string;
issuer?: string;
clientId?: string;
scope?: string;
port?: number;
openBrowser?: boolean;
timeoutMs?: number;
onAuthorizationUrl?: (url: string) => void;
};
export async function loginWithBrowser(
opts: BrowserLoginOptions = {},
): Promise<Credentials> {
const apiUrl = (opts.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
const issuer = (opts.issuer || DEFAULT_OAUTH_ISSUER).replace(/\/+$/, "");
const clientId = opts.clientId || DEFAULT_OAUTH_CLIENT_ID;
const scope = opts.scope || DEFAULT_OAUTH_SCOPE;
const cfg = await discoverOpenIdConfiguration(issuer);
const pkce = createPkcePair();
const state = randomUrlSafe(32);
const callback = await listenForCallback({
port: opts.port ?? DEFAULT_REDIRECT_PORT,
state,
timeoutMs: opts.timeoutMs ?? 180_000,
});
try {
const redirectUri = callback.redirectUri;
const authorizationUrl = buildAuthorizationUrl({
authorizationEndpoint: cfg.authorization_endpoint,
clientId,
redirectUri,
scope,
state,
codeChallenge: pkce.challenge,
});
opts.onAuthorizationUrl?.(authorizationUrl);
if (opts.openBrowser !== false) openBrowser(authorizationUrl);
const code = await callback.waitForCode;
const token = await exchangeAuthorizationCode({
tokenEndpoint: cfg.token_endpoint,
clientId,
redirectUri,
code,
codeVerifier: pkce.verifier,
});
const creds = await credentialsFromToken({
apiUrl,
token,
issuer,
clientId,
scope,
});
await saveCredentials(creds);
return creds;
} finally {
await callback.close();
}
}
export async function loginWithAccessToken(
token: string,
opts: {
apiUrl?: string;
issuer?: string;
clientId?: string;
scope?: string;
} = {},
): Promise<Credentials> {
const apiUrl = (opts.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
const creds = await credentialsFromToken({
apiUrl,
token: { access_token: token },
issuer: opts.issuer,
clientId: opts.clientId,
scope: opts.scope,
});
await saveCredentials(creds);
return creds;
}
export async function refreshCredentialsIfNeeded(
creds: Credentials,
minTtlSeconds = 60,
): Promise<Credentials> {
if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds;
if (credentialsFresh(creds, minTtlSeconds)) return creds;
return withRefreshLock(async () => {
const latest = await loadCredentials();
if (latest && sameOAuthClient(latest, creds)) {
if (credentialsFresh(latest, minTtlSeconds)) return latest;
if (latest.refreshToken && latest.issuer && latest.clientId) creds = latest;
}
return refreshCredentials(creds);
});
}
async function refreshCredentials(creds: Credentials): Promise<Credentials> {
const cfg = await discoverOpenIdConfiguration(creds.issuer!);
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: creds.clientId!,
refresh_token: creds.refreshToken!,
});
const resp = await fetch(cfg.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
let token: TokenResponse;
try {
token = await parseTokenResponse(resp);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`Login expired or revoked. Run \`a2amcp login\` and retry. (${detail})`,
);
}
const next: Credentials = {
...creds,
token: token.access_token!,
refreshToken: token.refresh_token || creds.refreshToken,
expiresAt: expiresAt(token.expires_in),
scope: token.scope || creds.scope,
};
await saveCredentials(next);
return next;
}
function credentialsFresh(creds: Credentials, minTtlSeconds: number): boolean {
return Boolean(creds.expiresAt && creds.expiresAt - nowSeconds() > minTtlSeconds);
}
function sameOAuthClient(a: Credentials, b: Credentials): boolean {
return (
a.apiUrl === b.apiUrl &&
a.issuer === b.issuer &&
a.clientId === b.clientId
);
}
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
const release = await acquireRefreshLock();
try {
return await fn();
} finally {
await release();
}
}
async function acquireRefreshLock(): Promise<() => Promise<void>> {
const file = refreshLockFile();
await fs.mkdir(path.dirname(file), { recursive: true });
const started = Date.now();
while (true) {
try {
const handle = await fs.open(file, "wx", 0o600);
try {
await handle.writeFile(
JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }),
);
} finally {
await handle.close();
}
return async () => {
try {
await fs.unlink(file);
} catch (err: any) {
if (err?.code !== "ENOENT") throw err;
}
};
} catch (err: any) {
if (err?.code !== "EEXIST") throw err;
await removeStaleRefreshLock(file);
if (Date.now() - started > REFRESH_LOCK_WAIT_MS) {
throw new Error("timed out waiting for credential refresh lock");
}
await sleep(REFRESH_LOCK_POLL_MS);
}
}
}
async function removeStaleRefreshLock(file: string): Promise<void> {
try {
const stat = await fs.stat(file);
if (Date.now() - stat.mtimeMs <= REFRESH_LOCK_STALE_MS) return;
await fs.unlink(file);
} catch (err: any) {
if (err?.code !== "ENOENT") throw err;
}
}
function refreshLockFile(): string {
return path.join(os.homedir(), ".a2a", "credentials-refresh.lock");
}
export async function discoverOpenIdConfiguration(
issuer: string,
): Promise<OpenIdConfiguration> {
const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
const resp = await fetch(url, { headers: { accept: "application/json" } });
if (!resp.ok) throw new Error(`OpenID discovery failed: ${resp.status}`);
const data = (await resp.json()) as Partial<OpenIdConfiguration>;
if (!data.authorization_endpoint || !data.token_endpoint) {
throw new Error("OpenID discovery document is missing authorization/token endpoints");
}
return {
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
};
}
export function createPkcePair(): { verifier: string; challenge: string } {
const verifier = randomUrlSafe(64);
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
export function buildAuthorizationUrl(opts: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
codeChallenge: string;
}): string {
const url = new URL(opts.authorizationEndpoint);
url.searchParams.set("client_id", opts.clientId);
url.searchParams.set("redirect_uri", opts.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", opts.scope);
url.searchParams.set("state", opts.state);
url.searchParams.set("code_challenge", opts.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
async function exchangeAuthorizationCode(opts: {
tokenEndpoint: string;
clientId: string;
redirectUri: string;
code: string;
codeVerifier: string;
}): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
code: opts.code,
code_verifier: opts.codeVerifier,
});
const resp = await fetch(opts.tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
return parseTokenResponse(resp);
}
async function parseTokenResponse(resp: Response): Promise<TokenResponse> {
const text = await resp.text();
const data = parseJsonObject(text) as TokenResponse;
if (!resp.ok || !data.access_token) {
const message = data.error_description || data.error || text.trim() || resp.statusText;
throw new Error(`Keycloak token exchange failed: ${message}`);
}
return data;
}
async function credentialsFromToken(opts: {
apiUrl: string;
token: TokenResponse;
issuer?: string;
clientId?: string;
scope?: string;
}): Promise<Credentials> {
const me = await new ControlPlaneClient(opts.apiUrl, opts.token.access_token!).me();
return {
apiUrl: opts.apiUrl,
token: opts.token.access_token!,
email: me.email,
userId: me.id,
bucket: `user-${me.id}-files`,
refreshToken: opts.token.refresh_token,
expiresAt: expiresAt(opts.token.expires_in),
issuer: opts.issuer,
clientId: opts.clientId,
scope: opts.token.scope || opts.scope,
};
}
function listenForCallback(opts: {
port: number;
state: string;
timeoutMs: number;
}): Promise<{
redirectUri: string;
waitForCode: Promise<string>;
close: () => Promise<void>;
}> {
let resolveCode!: (code: string) => void;
let rejectCode!: (err: Error) => void;
const waitForCode = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname !== CALLBACK_PATH) {
res.statusCode = 404;
res.end("not found");
return;
}
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (error) {
rejectCode(new Error(errorDescription || error));
finishBrowser(res, false);
return;
}
if (!code || state !== opts.state) {
rejectCode(new Error("invalid OAuth callback"));
finishBrowser(res, false);
return;
}
resolveCode(code);
finishBrowser(res, true);
});
const timer = setTimeout(() => {
rejectCode(new Error("timed out waiting for Keycloak login"));
}, opts.timeoutMs);
return new Promise((resolve, reject) => {
server.on("error", reject);
server.listen(opts.port, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
waitForCode,
close: () =>
new Promise((done) => {
clearTimeout(timer);
server.close(() => done());
}),
});
});
});
}
function finishBrowser(res: http.ServerResponse, ok: boolean): void {
res.statusCode = ok ? 200 : 400;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(
`<html><body><h1>${ok ? "a2amcp is connected" : "a2amcp login failed"}</h1>` +
"<p>You can close this tab and return to your terminal.</p></body></html>",
);
}
function openBrowser(url: string): void {
const cmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "cmd"
: "xdg-open";
const args =
process.platform === "win32"
? ["/c", "start", "", url]
: [url];
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
child.on("error", () => {});
child.unref();
}
function randomUrlSafe(bytes: number): string {
return crypto.randomBytes(bytes).toString("base64url");
}
function expiresAt(expiresIn: number | undefined): number | undefined {
return typeof expiresIn === "number" ? nowSeconds() + expiresIn : undefined;
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonObject(text: string): Record<string, unknown> {
if (!text.trim()) return {};
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}

91
src/registry.ts Normal file
View File

@@ -0,0 +1,91 @@
import { ControlPlaneClient, type AgentRow, type AgentSearchRow } from "./api.js";
import { addAgent, loadConfig, type EnabledAgent } from "./config.js";
export type EnabledAgentResult = EnabledAgent & {
endpoint: string;
replaced: boolean;
};
export function normalizeMcpPath(path: unknown): string {
if (typeof path !== "string" || !path.trim()) return "/mcp";
const trimmed = path.trim();
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function mcpTargetForAgent(
row: { name: string; url?: string | null; card?: Record<string, any> },
apiUrl: string,
): { url?: string; mcpPath: string } {
const imported = row.card?.capabilities?.a2a_cloud_import;
const mcp = imported?.mcp;
if (mcp?.mode === "generated") {
return {
url: `${apiUrl.replace(/\/+$/, "")}/v1/agents/${encodeURIComponent(row.name)}`,
mcpPath: normalizeMcpPath(mcp.path),
};
}
return {
url: mcp?.base_url || row.url || undefined,
mcpPath: normalizeMcpPath(mcp?.path || row.card?.mcp_endpoint),
};
}
export async function searchVisibleAgents(
cp: ControlPlaneClient,
opts: {
query?: string;
tags?: string[];
skill?: string;
limit?: number;
},
): Promise<AgentSearchRow[]> {
return cp.searchAgents(opts);
}
export async function enableAgentByName(opts: {
cp?: ControlPlaneClient;
apiUrl: string;
name: string;
url?: string;
mcpPath?: string;
}): Promise<EnabledAgentResult> {
const before = await loadConfig();
const existing = before.agents.find((agent) => agent.name === opts.name);
let url = opts.url?.trim();
let mcpPath = normalizeMcpPath(opts.mcpPath);
let description: string | undefined;
if (!url) {
if (!opts.cp) throw new Error("control-plane client required when url is not provided");
const row = await opts.cp.getAgent(opts.name);
const target = mcpTargetForAgent(row, opts.apiUrl);
url = target.url;
mcpPath = target.mcpPath;
description = row.description;
}
if (!url) throw new Error(`agent ${opts.name} has no public URL yet`);
const agent: EnabledAgent = {
name: opts.name,
url,
mcpPath,
description,
addedAt: new Date().toISOString(),
};
await addAgent(agent);
return {
...agent,
endpoint: `${url.replace(/\/+$/, "")}${mcpPath}`,
replaced: existing !== undefined,
};
}
export function summarizeAgent(row: AgentRow | AgentSearchRow): string {
const skills = "skills" in row && Array.isArray(row.skills)
? row.skills.map((skill) => skill.name).filter(Boolean).slice(0, 4)
: [];
const skillText = skills.length ? ` skills=${skills.join(",")}` : "";
const url = row.url || "-";
return `${row.name} [${row.status}] ${url}${skillText}`;
}

View File

@@ -7,15 +7,22 @@
* header without coupling to a transport class. * header without coupling to a transport class.
*/ */
const JSON_RPC = "2.0"; const JSON_RPC = "2.0";
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
export interface UpstreamConfig { export interface UpstreamConfig {
name: string; name: string;
url: string; url: string;
mcpPath?: string;
token: string | null; token: string | null;
tokenProvider?: () => Promise<string | null>;
requestTimeoutMs?: number;
streamIdleTimeoutMs?: number;
/** Forwarded to the upstream as ``params._meta.cp_jwt`` on tools/call. /** Forwarded to the upstream as ``params._meta.cp_jwt`` on tools/call.
* Lets the upstream agent act on the caller's behalf (file/agent CRUD * Lets the upstream agent act on the caller's behalf (file/agent CRUD
* on /v1/me/*) — same path the platform orchestrator uses. */ * on /v1/me/*) — same path the platform orchestrator uses. */
cpJwt?: string | null; cpJwt?: string | null;
cpJwtProvider?: () => Promise<string | null>;
/** Paired with ``cpJwt``. The upstream agent uses this as the base URL /** Paired with ``cpJwt``. The upstream agent uses this as the base URL
* when calling back into /v1/me/*. */ * when calling back into /v1/me/*. */
cpUrl?: string | null; cpUrl?: string | null;
@@ -39,6 +46,12 @@ export interface UpstreamCallResult {
isError?: boolean; isError?: boolean;
} }
export interface UpstreamServerRequest {
id: string | number;
method: string;
params?: Record<string, unknown>;
}
export class UpstreamError extends Error { export class UpstreamError extends Error {
constructor( constructor(
public agent: string, public agent: string,
@@ -52,43 +65,87 @@ export class UpstreamError extends Error {
let _nextId = 1; let _nextId = 1;
function normalizeMcpPath(path: string | undefined): string {
if (!path) return "/mcp";
const trimmed = path.trim();
if (!trimmed) return "/mcp";
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export class UpstreamAgent { export class UpstreamAgent {
constructor(private cfg: UpstreamConfig) { constructor(private cfg: UpstreamConfig) {
this.cfg.url = cfg.url.replace(/\/+$/, ""); this.cfg.url = cfg.url.replace(/\/+$/, "");
this.cfg.mcpPath = normalizeMcpPath(cfg.mcpPath);
} }
get name(): string { get name(): string {
return this.cfg.name; return this.cfg.name;
} }
private mcpUrl(): string {
return `${this.cfg.url}${this.cfg.mcpPath ?? "/mcp"}`;
}
private async bearerToken(): Promise<string | null> {
if (this.cfg.tokenProvider) return this.cfg.tokenProvider();
return this.cfg.token ?? null;
}
private async cpJwt(): Promise<string | null> {
if (this.cfg.cpJwtProvider) return this.cfg.cpJwtProvider();
return this.cfg.cpJwt ?? null;
}
private async rpc<T>(method: string, params: unknown): Promise<T> { private async rpc<T>(method: string, params: unknown): Promise<T> {
const id = _nextId++; const id = _nextId++;
const timeoutMs = this.cfg.requestTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
const headers: Record<string, string> = { const headers: Record<string, string> = {
"content-type": "application/json", "content-type": "application/json",
accept: "application/json", accept: "application/json",
}; };
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`; const token = await this.bearerToken();
if (token) headers["authorization"] = `Bearer ${token}`;
const resp = await fetch(`${this.cfg.url}/mcp`, { try {
method: "POST", const resp = await fetch(this.mcpUrl(), {
headers, method: "POST",
body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }), headers,
}); body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }),
signal: controller.signal,
});
if (!resp.ok) { if (!resp.ok) {
const text = await resp.text().catch(() => ""); const text = await resp.text().catch(() => "");
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText); 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;
} catch (err) {
if (timedOut) {
throw new UpstreamError(
this.cfg.name,
504,
`request timed out after ${timeoutMs}ms`,
);
}
throw err;
} finally {
clearTimeout(timeout);
} }
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[]> { async listTools(): Promise<UpstreamTool[]> {
@@ -104,17 +161,22 @@ export class UpstreamAgent {
method: string; method: string;
params?: Record<string, unknown>; params?: Record<string, unknown>;
}) => void | Promise<void>; }) => void | Promise<void>;
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
progressToken?: string | number; progressToken?: string | number;
} = {}, } = {},
): Promise<UpstreamCallResult> { ): Promise<UpstreamCallResult> {
const meta: Record<string, unknown> = {}; const meta: Record<string, unknown> = {};
if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt; const cpJwt = await this.cpJwt();
if (cpJwt) meta.cp_jwt = cpJwt;
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl; if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
if (this.cfg.bucket) meta.bucket = this.cfg.bucket; if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken; if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken;
const params: Record<string, unknown> = { name, arguments: arguments_ }; const params: Record<string, unknown> = { name, arguments: arguments_ };
if (Object.keys(meta).length > 0) params._meta = meta; if (Object.keys(meta).length > 0) params._meta = meta;
return this.streamingCallTool(params, opts.onNotification); return this.streamingCallTool(params, {
onNotification: opts.onNotification,
onRequest: opts.onRequest,
});
} }
/** /**
@@ -123,35 +185,66 @@ export class UpstreamAgent {
* a deploy + a wait-for-live-card poll), and a plain JSON POST gets * a deploy + a wait-for-live-card poll), and a plain JSON POST gets
* killed by ingress idle timeouts (~60s default on traefik). The * killed by ingress idle timeouts (~60s default on traefik). The
* upstream's SSE response interleaves any elicitation/create requests * upstream's SSE response interleaves any elicitation/create requests
* and a final tools/call result; here we drain the stream and return * and a final tools/call result; here we relay server-to-client
* the final result. (Elicit relaying to the MCP client is not wired * requests through the MCP client and return the final result.
* yet — any elicit that fires will time out on the upstream side and
* the skill will surface a soft error.)
*/ */
private async streamingCallTool( private async streamingCallTool(
params: Record<string, unknown>, params: Record<string, unknown>,
onNotification?: (notif: { handlers: {
method: string; onNotification?: (notif: {
params?: Record<string, unknown>; method: string;
}) => void | Promise<void>, params?: Record<string, unknown>;
}) => void | Promise<void>;
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
} = {},
): Promise<UpstreamCallResult> { ): Promise<UpstreamCallResult> {
const id = _nextId++; const id = _nextId++;
const idleTimeoutMs =
this.cfg.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
const controller = new AbortController();
let timedOut = false;
let idleTimer: ReturnType<typeof setTimeout> | undefined;
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
timedOut = true;
controller.abort();
}, idleTimeoutMs);
};
resetIdleTimer();
const headers: Record<string, string> = { const headers: Record<string, string> = {
"content-type": "application/json", "content-type": "application/json",
accept: "text/event-stream, application/json", accept: "text/event-stream, application/json",
}; };
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`; const token = await this.bearerToken();
if (token) headers["authorization"] = `Bearer ${token}`;
const resp = await fetch(`${this.cfg.url}/mcp`, { let resp: Response;
method: "POST", try {
headers, resp = await fetch(this.mcpUrl(), {
body: JSON.stringify({ method: "POST",
jsonrpc: JSON_RPC, id, method: "tools/call", params, headers,
}), body: JSON.stringify({
}); jsonrpc: JSON_RPC, id, method: "tools/call", params,
}),
signal: controller.signal,
});
} catch (err) {
clearTimeout(idleTimer);
if (timedOut) {
throw new UpstreamError(
this.cfg.name,
504,
`stream idle timeout after ${idleTimeoutMs}ms`,
);
}
throw err;
}
resetIdleTimer();
if (!resp.ok) { if (!resp.ok) {
const text = await resp.text().catch(() => ""); const text = await resp.text().catch(() => "");
clearTimeout(idleTimer);
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText); throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
} }
@@ -160,15 +253,20 @@ export class UpstreamAgent {
// Server fell back to JSON (older a2a-pack or non-SSE upstream). // Server fell back to JSON (older a2a-pack or non-SSE upstream).
const env: any = await resp.json(); const env: any = await resp.json();
if (env?.error) { if (env?.error) {
clearTimeout(idleTimer);
throw new UpstreamError( throw new UpstreamError(
this.cfg.name, 500, this.cfg.name, 500,
`${env.error.code}: ${env.error.message}`, `${env.error.code}: ${env.error.message}`,
); );
} }
clearTimeout(idleTimer);
return env?.result as UpstreamCallResult; return env?.result as UpstreamCallResult;
} }
const upstreamSessionId = resp.headers.get("mcp-session-id");
if (!resp.body) { if (!resp.body) {
clearTimeout(idleTimer);
throw new UpstreamError(this.cfg.name, 502, "empty stream body"); throw new UpstreamError(this.cfg.name, 502, "empty stream body");
} }
@@ -177,8 +275,24 @@ export class UpstreamAgent {
let buf = ""; let buf = "";
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); let done: boolean;
if (value) buf += decoder.decode(value, { stream: true }); let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch (err) {
if (timedOut) {
throw new UpstreamError(
this.cfg.name,
504,
`stream idle timeout after ${idleTimeoutMs}ms`,
);
}
throw err;
}
if (value) {
resetIdleTimer();
buf += decoder.decode(value, { stream: true });
}
let sep = buf.indexOf("\n\n"); let sep = buf.indexOf("\n\n");
while (sep !== -1) { while (sep !== -1) {
const frame = buf.slice(0, sep); const frame = buf.slice(0, sep);
@@ -189,6 +303,10 @@ export class UpstreamAgent {
} }
if (dataLines.length > 0) { if (dataLines.length > 0) {
const raw = dataLines.join("\n"); const raw = dataLines.join("\n");
if (raw === "[DONE]") {
sep = buf.indexOf("\n\n");
continue;
}
let msg: any; let msg: any;
try { msg = JSON.parse(raw); } catch { msg = null; } try { msg = JSON.parse(raw); } catch { msg = null; }
if (msg && msg.id === id) { if (msg && msg.id === id) {
@@ -201,19 +319,24 @@ export class UpstreamAgent {
return msg.result as UpstreamCallResult; return msg.result as UpstreamCallResult;
} }
// Otherwise it's a server→client message. Notifications // Otherwise it's a server→client message. Notifications
// (no id) → forward to the MCP client so long-running // (no id) are best-effort. Requests (has id, has method), such
// skills keep the client's tool-call timer alive. Requests // as elicitation/create, must be relayed and answered upstream.
// (has id, has method) — e.g. elicitation/create — are if (msg && typeof msg.method === "string" && !("id" in msg) && handlers.onNotification) {
// not relayed yet, so they will time out upstream.
if (msg && typeof msg.method === "string" && !("id" in msg) && onNotification) {
try { try {
await onNotification({ await handlers.onNotification({
method: msg.method, method: msg.method,
params: msg.params as Record<string, unknown> | undefined, params: msg.params as Record<string, unknown> | undefined,
}); });
} catch { } catch {
// Best-effort; never break the stream on a notify error. // Best-effort; never break the stream on a notify error.
} }
} else if (msg && typeof msg.method === "string" && "id" in msg) {
await this.relayServerRequest(upstreamSessionId, {
id: msg.id as string | number,
method: msg.method,
params: msg.params as Record<string, unknown> | undefined,
}, handlers.onRequest);
resetIdleTimer();
} }
} }
sep = buf.indexOf("\n\n"); sep = buf.indexOf("\n\n");
@@ -221,8 +344,59 @@ export class UpstreamAgent {
if (done) break; if (done) break;
} }
} finally { } finally {
clearTimeout(idleTimer);
try { reader.releaseLock(); } catch { /* ignore */ } try { reader.releaseLock(); } catch { /* ignore */ }
} }
throw new UpstreamError(this.cfg.name, 502, "stream ended without result"); throw new UpstreamError(this.cfg.name, 502, "stream ended without result");
} }
private async relayServerRequest(
upstreamSessionId: string | null,
req: UpstreamServerRequest,
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>,
): Promise<void> {
if (!upstreamSessionId) {
throw new UpstreamError(
this.cfg.name,
502,
`upstream request ${req.method} missing Mcp-Session-Id`,
);
}
let envelope: Record<string, unknown>;
try {
if (!onRequest) throw new Error(`no client request relay for ${req.method}`);
const result = await onRequest(req);
envelope = { jsonrpc: JSON_RPC, id: req.id, result };
} catch (err) {
envelope = {
jsonrpc: JSON_RPC,
id: req.id,
error: {
code: -32603,
message: err instanceof Error ? err.message : String(err),
},
};
}
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
"mcp-session-id": upstreamSessionId,
};
const token = await this.bearerToken();
if (token) headers.authorization = `Bearer ${token}`;
const resp = await fetch(this.mcpUrl(), {
method: "POST",
headers,
body: JSON.stringify(envelope),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new UpstreamError(
this.cfg.name,
resp.status,
text || `failed to deliver response for ${req.method}`,
);
}
}
} }

93
tests/api.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { ApiError, ControlPlaneClient } from "../src/api.js";
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = originalFetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("ControlPlaneClient reports non-JSON error bodies without rereading them", async () => {
globalThis.fetch = (async () =>
new Response("<h1>upstream unavailable</h1>", {
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
})) as typeof fetch;
await assert.rejects(
() => new ControlPlaneClient("https://api.example.test", "token").me(),
(err) => {
assert.ok(err instanceof ApiError);
assert.equal(err.status, 502);
assert.match(err.message, /upstream unavailable/);
assert.doesNotMatch(err.message, /Body is unusable/);
return true;
},
);
});
test("ControlPlaneClient forwards bearer tokens and extracts JSON error detail", async () => {
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = init?.headers as Record<string, string>;
assert.equal(headers.authorization, "Bearer access-token");
return new Response(JSON.stringify({ detail: "keycloak email is not verified" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
await assert.rejects(
() => new ControlPlaneClient("https://api.example.test", "access-token").me(),
/API 403: keycloak email is not verified/,
);
});
test("ControlPlaneClient searches agents with query filters", async () => {
let seenUrl = "";
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
const headers = init?.headers as Record<string, string>;
assert.equal(headers.authorization, "Bearer access-token");
return new Response(
JSON.stringify([
{
name: "soc2-evidence",
description: "SOC2 evidence agent",
status: "running",
public: true,
url: "https://soc2.example.test",
score: 0.9,
match_source: "lexical",
setup_required: false,
skills: [{ name: "collect", description: "", tags: ["security"], input_fields: [] }],
},
]),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
const rows = await new ControlPlaneClient(
"https://api.example.test",
"access-token",
).searchAgents({
query: "soc2",
tags: ["security", "evidence"],
skill: "collect",
limit: 12,
});
const url = new URL(seenUrl);
assert.equal(url.pathname, "/v1/agents/search");
assert.equal(url.searchParams.get("q"), "soc2");
assert.deepEqual(url.searchParams.getAll("tag"), ["security", "evidence"]);
assert.equal(url.searchParams.get("skill"), "collect");
assert.equal(url.searchParams.get("limit"), "12");
assert.equal(rows[0].name, "soc2-evidence");
});

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { addAgent, loadConfig, removeAgent } from "../src/config.js"; import { addAgent, loadConfig, removeAgent } from "../src/config.js";
import { loadCredentials, saveCredentials } from "../src/credentials.js";
let originalHome: string | undefined; let originalHome: string | undefined;
let tmpHome: string; let tmpHome: string;
@@ -44,3 +45,31 @@ test("removeAgent reports hit/miss", async () => {
const cfg = await loadConfig(); const cfg = await loadConfig();
assert.equal(cfg.agents.length, 0); assert.equal(cfg.agents.length, 0);
}); });
test("credentials preserve Keycloak token metadata", async () => {
await saveCredentials({
apiUrl: "https://api.example.test",
token: "access-token",
refreshToken: "refresh-token",
expiresAt: 12345,
email: "dev@example.test",
userId: 7,
bucket: "user-7-files",
issuer: "https://auth.example.test/realms/a2a",
clientId: "a2acloud-cli",
scope: "openid email mcp:invoke",
});
assert.deepEqual(await loadCredentials(), {
apiUrl: "https://api.example.test",
token: "access-token",
refreshToken: "refresh-token",
expiresAt: 12345,
email: "dev@example.test",
userId: 7,
bucket: "user-7-files",
issuer: "https://auth.example.test/realms/a2a",
clientId: "a2acloud-cli",
scope: "openid email mcp:invoke",
});
});

View File

@@ -2,6 +2,9 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import http from "node:http"; import http from "node:http";
import { AddressInfo } from "node:net"; import { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { import {
@@ -9,8 +12,8 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { buildGateway, SEP } from "../src/gateway.js"; import { buildGateway, SEP, type GatewayOptions } from "../src/gateway.js";
import type { EnabledAgent } from "../src/config.js"; import { loadConfig, type EnabledAgent } from "../src/config.js";
/** /**
* Fake upstream A2A agent: speaks the JSON-RPC subset the gateway uses * Fake upstream A2A agent: speaks the JSON-RPC subset the gateway uses
@@ -70,25 +73,65 @@ function startFakeAgent(opts: {
}); });
} }
function startSseAgent(opts: {
onCall: (body: any, res: http.ServerResponse) => void;
}): 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 chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
requests.push({ body });
if (body.method !== "tools/call") {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }));
return;
}
res.writeHead(200, { "content-type": "text/event-stream" });
opts.onCall(body, res);
});
});
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. */ /** Drive an in-process gateway Server via the SDK's request handlers. */
async function callGateway( async function callGateway(
agents: EnabledAgent[], agents: EnabledAgent[],
token: string | null, token: string | null,
method: "tools/list" | "tools/call", method: "tools/list" | "tools/call",
params: any, params: any,
opts: {
gateway?: Partial<Omit<GatewayOptions, "agents" | "token" | "server">>;
extra?: any;
} = {},
) { ) {
const mcpServer = new Server( const mcpServer = new Server(
{ name: "test", version: "0.0.0" }, { name: "test", version: "0.0.0" },
{ capabilities: { tools: { listChanged: false } } }, { capabilities: { tools: { listChanged: false } } },
); );
await buildGateway({ agents, token, server: mcpServer }); await buildGateway({ agents, token, server: mcpServer, ...opts.gateway });
// Reach into the server's registered handlers — the SDK exposes them via // 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 // a private map, so we route through the public schema-keyed lookup we
// know is wired up. // know is wired up.
const schema = method === "tools/list" ? ListToolsRequestSchema : CallToolRequestSchema; const schema = method === "tools/list" ? ListToolsRequestSchema : CallToolRequestSchema;
const handler = (mcpServer as any)._requestHandlers.get(schema.shape.method.value); const handler = (mcpServer as any)._requestHandlers.get(schema.shape.method.value);
if (!handler) throw new Error("handler not registered"); if (!handler) throw new Error("handler not registered");
return handler({ method, params }, {} as any); return handler({ method, params }, opts.extra ?? ({} as any));
} }
test("tools/list aggregates upstream tools with agent prefix", async () => { test("tools/list aggregates upstream tools with agent prefix", async () => {
@@ -109,9 +152,10 @@ test("tools/list aggregates upstream tools with agent prefix", async () => {
"tools/list", "tools/list",
{}, {},
); );
assert.equal(out.tools.length, 1); const upstreamTools = out.tools.filter((tool: any) => tool.name.includes(SEP));
assert.equal(out.tools[0].name, `greeter${SEP}greet`); assert.equal(upstreamTools.length, 1);
assert.match(out.tools[0].description, /\[greeter\]/); assert.equal(upstreamTools[0].name, `greeter${SEP}greet`);
assert.match(upstreamTools[0].description, /\[greeter\]/);
} finally { } finally {
await upstream.close(); await upstream.close();
} }
@@ -138,7 +182,9 @@ test("tools/list survives a single upstream failure", async () => {
"tools/list", "tools/list",
{}, {},
); );
const names = out.tools.map((t: any) => t.name); const names = out.tools
.map((t: any) => t.name)
.filter((name: string) => name.includes(SEP));
assert.deepEqual(names, [`good${SEP}ping`]); assert.deepEqual(names, [`good${SEP}ping`]);
} finally { } finally {
await good.close(); await good.close();
@@ -209,3 +255,303 @@ test("tools/call surfaces upstream JSON-RPC error as soft error", async () => {
await upstream.close(); await upstream.close();
} }
}); });
test("tools/call forwards upstream SSE progress notifications", async () => {
const upstream = await startSseAgent({
onCall: (body, res) => {
res.write(
`data: ${JSON.stringify({
jsonrpc: "2.0",
method: "notifications/progress",
params: { progressToken: "p1", message: "halfway" },
})}\n\n`,
);
res.end(
`data: ${JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
content: [{ type: "text", text: "done" }],
isError: false,
},
})}\n\n`,
);
},
});
const notifications: any[] = [];
try {
const result = await callGateway(
[{ name: "streamer", url: upstream.url, addedAt: "now" }],
null,
"tools/call",
{ name: `streamer${SEP}build`, arguments: {} },
{
extra: {
requestId: "req-1",
sendNotification: async (notification: any) => {
notifications.push(notification);
},
},
},
);
assert.equal(result.isError, false);
assert.deepEqual(notifications, [
{
method: "notifications/progress",
params: { progressToken: "p1", message: "halfway" },
},
]);
} finally {
await upstream.close();
}
});
test("tools/call reports upstream SSE idle timeout", async () => {
const upstream = await startSseAgent({
onCall: (_body, res) => {
res.write(": connected\n\n");
},
});
try {
const result = await callGateway(
[{ name: "slow", url: upstream.url, addedAt: "now" }],
null,
"tools/call",
{ name: `slow${SEP}build`, arguments: {} },
{ gateway: { upstreamStreamIdleTimeoutMs: 25 } },
);
assert.equal(result.isError, true);
assert.match(result.content[0].text, /stream idle timeout/);
} finally {
await upstream.close();
}
});
test("tools/call relays upstream elicitation requests", async () => {
let resolveResponse: (() => void) | undefined;
const responseSeen = new Promise<void>((resolve) => { resolveResponse = resolve; });
let upstreamResponse: any = null;
const requests: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/mcp") {
res.statusCode = 404;
res.end();
return;
}
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
requests.push({ headers: req.headers, body });
if (body.method === "tools/call") {
res.writeHead(200, {
"content-type": "text/event-stream",
"Mcp-Session-Id": "sess-1",
});
res.write(
`data: ${JSON.stringify({
jsonrpc: "2.0",
id: "ask-1",
method: "elicitation/create",
params: { message: "Name?", requestedSchema: { type: "object" } },
})}\n\n`,
);
void responseSeen.then(() => {
res.end(
`data: ${JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
content: [{ type: "text", text: upstreamResponse.result.content.answer }],
isError: false,
},
})}\n\n`,
);
});
return;
}
if (body.id === "ask-1" && req.headers["mcp-session-id"] === "sess-1") {
upstreamResponse = body;
res.statusCode = 202;
res.end();
resolveResponse?.();
return;
}
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }));
});
});
const upstream = await new Promise<{ url: string; close: () => Promise<void> }>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
const clientRequests: any[] = [];
try {
const result = await callGateway(
[{ name: "human", url: upstream.url, addedAt: "now" }],
null,
"tools/call",
{ name: `human${SEP}run_demo`, arguments: {} },
{
extra: {
requestId: "req-elicitation",
sendNotification: async () => {},
sendRequest: async (request: any) => {
clientRequests.push(request);
return { action: "accept", content: { answer: "Alice" } };
},
},
},
);
assert.equal(result.isError, false);
assert.equal(result.content[0].text, "Alice");
assert.equal(clientRequests[0].method, "elicitation/create");
assert.deepEqual(upstreamResponse.result, {
action: "accept",
content: { answer: "Alice" },
});
assert.ok(requests.some((r) => r.body.id === "ask-1"));
} finally {
await upstream.close();
}
});
function startFakeControlPlane(): Promise<{ url: string; close: () => Promise<void>; requests: any[] }> {
const requests: any[] = [];
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
requests.push({ method: req.method, url: req.url, auth: req.headers.authorization });
if (req.method === "GET" && req.url?.startsWith("/v1/agents/search")) {
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify([
{
name: "soc2-evidence",
description: "Collect SOC2 evidence",
status: "running",
public: true,
url: "https://soc2.example.test",
score: 0.91,
match_source: "lexical",
setup_required: false,
skills: [{ name: "collect", description: "", tags: ["security"], input_fields: [] }],
},
]),
);
return;
}
if (req.method === "GET" && req.url === "/v1/agents/soc2-evidence") {
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
name: "soc2-evidence",
version: "1.0.0",
status: "running",
url: "https://soc2.example.test",
description: "Collect SOC2 evidence",
card: {
capabilities: {
a2a_cloud_import: {
mcp: { mode: "generated", path: "/mcp" },
},
},
},
}),
);
return;
}
res.statusCode = 404;
res.end("not found");
});
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())),
});
});
});
}
async function withTempHome<T>(fn: () => Promise<T>): Promise<T> {
const originalHome = process.env.HOME;
const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "a2a-mcp-gateway-"));
process.env.HOME = tmpHome;
try {
return await fn();
} finally {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
await fs.rm(tmpHome, { recursive: true, force: true });
}
}
test("tools/list includes a2a management tools", async () => {
const out = await callGateway([], "token", "tools/list", {});
const names = out.tools.map((tool: any) => tool.name);
assert.ok(names.includes("a2a_search_agents"));
assert.ok(names.includes("a2a_add_agent"));
assert.ok(names.includes("a2a_list_enabled_agents"));
});
test("a2a_search_agents returns visible control-plane results", async () => {
const cp = await startFakeControlPlane();
try {
const result = await callGateway(
[],
"access-token",
"tools/call",
{ name: "a2a_search_agents", arguments: { query: "soc2", tags: ["security"] } },
{ gateway: { apiUrl: cp.url } },
);
assert.equal(result.isError, undefined);
assert.equal(result.structuredContent.agents[0].name, "soc2-evidence");
assert.equal(result.structuredContent.agents[0].enabled, false);
assert.equal(cp.requests[0].auth, "Bearer access-token");
assert.match(cp.requests[0].url, /q=soc2/);
assert.match(cp.requests[0].url, /tag=security/);
} finally {
await cp.close();
}
});
test("a2a_add_agent persists control-plane agent and reports list change", async () => {
await withTempHome(async () => {
const cp = await startFakeControlPlane();
const notifications: any[] = [];
try {
const result = await callGateway(
[],
"access-token",
"tools/call",
{ name: "a2a_add_agent", arguments: { name: "soc2-evidence" } },
{
gateway: { apiUrl: cp.url },
extra: {
requestId: "req-add",
sendNotification: async (notification: any) => {
notifications.push(notification);
},
sendRequest: async () => ({}),
},
},
);
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /Added soc2-evidence/);
assert.equal(notifications[0].method, "notifications/tools/list_changed");
const cfg = await loadConfig();
assert.equal(cfg.agents[0].name, "soc2-evidence");
assert.equal(cfg.agents[0].url, `${cp.url}/v1/agents/soc2-evidence`);
assert.equal(cfg.agents[0].mcpPath, "/mcp");
} finally {
await cp.close();
}
});
});

181
tests/oauth.test.ts Normal file
View File

@@ -0,0 +1,181 @@
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 { loadCredentials, saveCredentials } from "../src/credentials.js";
import {
buildAuthorizationUrl,
createPkcePair,
refreshCredentialsIfNeeded,
} from "../src/oauth.js";
let originalHome: string | undefined;
let tmpHome: string;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
originalHome = process.env.HOME;
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "a2a-mcp-oauth-"));
process.env.HOME = tmpHome;
globalThis.fetch = originalFetch;
});
afterEach(async () => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
globalThis.fetch = originalFetch;
await fs.rm(tmpHome, { recursive: true, force: true });
});
test("PKCE authorization URL requests Keycloak code flow", () => {
const pkce = createPkcePair();
assert.ok(pkce.verifier.length > 40);
assert.ok(pkce.challenge.length > 40);
const url = new URL(
buildAuthorizationUrl({
authorizationEndpoint: "https://auth.example.test/auth",
clientId: "a2acloud-cli",
redirectUri: "http://127.0.0.1:41873/callback",
scope: "openid email mcp:invoke",
state: "state-123",
codeChallenge: pkce.challenge,
}),
);
assert.equal(url.searchParams.get("client_id"), "a2acloud-cli");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
assert.equal(url.searchParams.get("scope"), "openid email mcp:invoke");
});
test("refreshCredentialsIfNeeded refreshes expired Keycloak access token", async () => {
const calls: Array<{ url: string; body?: string }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, body: init?.body?.toString() });
if (url.endsWith("/.well-known/openid-configuration")) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://auth.example.test/auth",
token_endpoint: "https://auth.example.test/token",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
access_token: "fresh-token",
refresh_token: "fresh-refresh",
expires_in: 3600,
scope: "openid email mcp:invoke",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
await saveCredentials({
apiUrl: "https://api.example.test",
token: "old-token",
refreshToken: "refresh-token",
expiresAt: 1,
email: "dev@example.test",
issuer: "https://auth.example.test",
clientId: "a2acloud-cli",
});
const refreshed = await refreshCredentialsIfNeeded((await loadCredentials())!);
assert.equal(refreshed.token, "fresh-token");
assert.equal(refreshed.refreshToken, "fresh-refresh");
assert.equal((await loadCredentials())!.token, "fresh-token");
assert.ok(calls.some((call) => call.body?.includes("grant_type=refresh_token")));
});
test("refreshCredentialsIfNeeded serializes concurrent refreshes", async () => {
let tokenCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://auth.example.test/auth",
token_endpoint: "https://auth.example.test/token",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
tokenCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 25));
return new Response(
JSON.stringify({
access_token: `fresh-token-${tokenCalls}`,
refresh_token: `fresh-refresh-${tokenCalls}`,
expires_in: 3600,
scope: "openid email mcp:invoke",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
await saveCredentials({
apiUrl: "https://api.example.test",
token: "old-token",
refreshToken: "refresh-token",
expiresAt: 1,
email: "dev@example.test",
issuer: "https://auth.example.test",
clientId: "a2acloud-cli",
});
const stale = (await loadCredentials())!;
const [first, second] = await Promise.all([
refreshCredentialsIfNeeded(stale),
refreshCredentialsIfNeeded({ ...stale }),
]);
assert.equal(tokenCalls, 1);
assert.equal(first.token, "fresh-token-1");
assert.equal(second.token, "fresh-token-1");
assert.equal((await loadCredentials())!.refreshToken, "fresh-refresh-1");
});
test("refreshCredentialsIfNeeded reports stale Keycloak sessions clearly", async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://auth.example.test/auth",
token_endpoint: "https://auth.example.test/token",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
error: "invalid_grant",
error_description: "Session doesn't have required client",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
await saveCredentials({
apiUrl: "https://api.example.test",
token: "old-token",
refreshToken: "refresh-token",
expiresAt: 1,
email: "dev@example.test",
issuer: "https://auth.example.test",
clientId: "a2acloud-cli",
});
await assert.rejects(
refreshCredentialsIfNeeded((await loadCredentials())!),
/Login expired or revoked\. Run `a2amcp login` and retry\. .*Session doesn't have required client/,
);
assert.equal((await loadCredentials())!.token, "old-token");
});