13 Commits
v0.1.3 ... main

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
45e12324df release: 0.1.6 — relay notifications/progress to MCP client
All checks were successful
build / test (push) Successful in 30s
publish / npm (push) Successful in 33s
Long-running upstream skills emit MCP notifications/progress on the SSE
stream. Without forwarding, the gateway swallows them and the MCP
client (Claude Code, Cursor) aborts the tools/call on its idle timer
after ~60s — even though the HTTP stream stays warm.

Gateway now passes the SDK's extra.sendNotification through to
UpstreamAgent.callTool. Any non-response frame with a method but no id
becomes a notification on the client side. Each tools/call carries a
progressToken (client-supplied or request-id fallback) so the client
can correlate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:37:58 -03:00
09b588326c release: 0.1.5 — SSE-stream tools/call to dodge ingress timeout
All checks were successful
build / test (push) Successful in 28s
publish / npm (push) Successful in 32s
agent-builder.build (and any long-running skill) takes minutes — LLM
loop + sandbox tests + deploy + wait-for-live-card. A plain JSON POST
to /mcp gets killed by ingress idle timeouts (~60s on traefik) and the
gateway sees "fetch failed" while the upstream is still running.

UpstreamAgent.callTool now negotiates ``Accept: text/event-stream`` on
tools/call. We drain SSE frames, return the final tools/call result,
and fall back to plain JSON if the upstream doesn't speak SSE (older
a2a-pack). Elicit relaying isn't wired yet; for now any
elicitation/create frame is ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:12:53 -03:00
024f14000e release: 0.1.4 — forward cp_jwt + bucket via params._meta
All checks were successful
build / test (push) Successful in 29s
publish / npm (push) Successful in 30s
On login the gateway now captures user.id from /v1/auth/login, derives
the user's MinIO bucket (user-<id>-files), and persists both in
~/.a2a/credentials.json. UpstreamAgent.callTool injects
``params._meta = { cp_jwt, cp_url, bucket }`` on every tools/call so
upstream agents see the same caller context the platform orchestrator
provides. Unauthenticated clients omit _meta — back-compat preserved.

Requires a2a-pack with the matching _meta handler in
``a2a_pack/mcp/server.py`` (companion change in apps/a2a).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:53:07 -03:00
17 changed files with 2083 additions and 118 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
.codegraph/
*.log
*.tsbuildinfo
.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
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
```bash
npx -y a2amcp login
npx -y a2amcp search "security evidence"
npx -y a2amcp add <agent-name>
npx -y a2amcp doctor
```
@@ -30,9 +46,10 @@ Restart the client after adding or removing agents.
## Commands
```bash
a2amcp login # save control-plane credentials
a2amcp login # sign in through Keycloak and save OAuth tokens
a2amcp whoami # show current 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 list # list locally enabled agents
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.
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",
"version": "0.1.3",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "a2amcp",
"version": "0.1.3",
"version": "0.1.11",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",

View File

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

View File

@@ -1,7 +1,4 @@
/**
* Thin control-plane client. Mirrors the relevant subset of
* a2a_pack.cli.api_client.ControlPlaneClient.
*/
/** Thin control-plane client used with Keycloak bearer tokens. */
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(`API ${status}: ${message}`);
@@ -15,6 +12,33 @@ export interface AgentRow {
status: string;
url?: 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 {
@@ -26,8 +50,11 @@ export class ControlPlaneClient {
}
private headers(): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json" };
if (this.token) h["authorization"] = `bearer ${this.token}`;
const h: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
};
if (this.token) h["authorization"] = `Bearer ${this.token}`;
return h;
}
@@ -37,45 +64,59 @@ export class ControlPlaneClient {
headers: this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await resp.text();
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);
throw new ApiError(resp.status, errorDetail(text, resp.statusText));
}
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 },
);
return (text ? JSON.parse(text) : undefined) as T;
}
me() {
return this.request<{ email: string }>("GET", "/v1/me");
return this.request<{ id: number; email: string }>("GET", "/v1/me");
}
listAgents() {
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) {
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 };
import { ApiError, ControlPlaneClient } from "./api.js";
import { addAgent, loadConfig, removeAgent } from "./config.js";
import { loadConfig, removeAgent } from "./config.js";
import {
DEFAULT_API_URL,
clearCredentials,
loadCredentials,
resolveApiUrl,
saveCredentials,
} from "./credentials.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();
@@ -34,8 +47,9 @@ program
async function client(apiOverride?: string): Promise<ControlPlaneClient> {
const creds = await loadCredentials();
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
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 {
@@ -82,18 +96,42 @@ program
program
.command("login")
.description("Authenticate against the control plane.")
.option("--email <email>")
.option("--password <password>")
.description("Authenticate with Keycloak and cache a bearer token.")
.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 }));
.option("--issuer <url>", "Keycloak realm issuer", DEFAULT_OAUTH_ISSUER)
.option("--client-id <id>", "Keycloak public client id", DEFAULT_OAUTH_CLIENT_ID)
.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);
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`);
const common = {
apiUrl: api,
issuer: opts.issuer,
clientId: opts.clientId,
scope: opts.scope,
};
const creds = opts.token
? 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) {
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
@@ -144,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
.command("list")
.description("List the agents this gateway exposes locally.")
@@ -153,7 +230,9 @@ program
output.write("(no agents added; try `a2amcp add <name>`)\n");
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
@@ -163,26 +242,21 @@ program
.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);
}
try {
const creds = await loadCredentials();
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl);
const added = await enableAgentByName({
cp: opts.url ? undefined : new ControlPlaneClient(apiUrl, refreshed?.token ?? null),
apiUrl,
name,
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");
},
);
@@ -203,6 +277,7 @@ program
.action(async () => {
const cfg = await loadConfig();
const creds = await loadCredentials();
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
if (cfg.agents.length === 0) {
output.write("(no agents enabled)\n");
return;
@@ -212,7 +287,8 @@ program
const u = new UpstreamAgent({
name: a.name,
url: a.url,
token: creds?.token ?? null,
mcpPath: a.mcpPath,
token: refreshed?.token ?? null,
});
try {
const tools = await u.listTools();

View File

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

View File

@@ -1,8 +1,7 @@
/**
* Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes.
*
* We deliberately *only read* this file (and overwrite it on `a2amcp login`)
* so a single login flow is shared between the Python and Node tooling.
* Reader/writer for ~/.a2a/credentials.json — same file the Python `a2a` CLI
* writes. New logins store Keycloak access/refresh tokens; older token-only
* files are still readable.
*/
import { promises as fs } from "node:fs";
import os from "node:os";
@@ -14,6 +13,13 @@ export interface Credentials {
apiUrl: string;
token: string;
email: string;
userId?: number;
bucket?: string;
refreshToken?: string;
expiresAt?: number;
issuer?: string;
clientId?: string;
scope?: string;
}
const credsDir = () => path.join(os.homedir(), ".a2a");
@@ -28,6 +34,14 @@ export async function loadCredentials(): Promise<Credentials | null> {
apiUrl: data.api_url ?? DEFAULT_API_URL,
token: data.token,
email: data.email ?? "",
userId: typeof data.user_id === "number" ? data.user_id : 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) {
if (err?.code === "ENOENT") return null;
@@ -37,11 +51,19 @@ export async function loadCredentials(): Promise<Credentials | null> {
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 },
);
const out: Record<string, unknown> = {
api_url: c.apiUrl,
token: c.token,
email: c.email,
};
if (c.userId !== undefined) out.user_id = c.userId;
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 });
}
export async function clearCredentials(): Promise<boolean> {

View File

@@ -11,23 +11,99 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ElicitResultSchema,
ListToolsRequestSchema,
ResultSchema,
} from "@modelcontextprotocol/sdk/types.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";
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string };
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 {
/** 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;
/** 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). */
server?: Server;
}
@@ -39,25 +115,65 @@ export interface GatewayHandle {
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;
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;
// Forward CP credentials + bucket as params._meta so upstream agents
// can act on behalf of the caller (mirrors the platform-orchestrator
// call shape). When the user isn't logged in, _meta is omitted and
// upstream sees the same unauthenticated context as before.
const apiUrl = resolveApiUrl(opts.apiUrl ?? creds?.apiUrl);
const cpUrl = opts.apiUrl ?? creds?.apiUrl ?? null;
const bucket = creds?.bucket ?? null;
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) {
upstreams.set(a.name, new UpstreamAgent({ name: a.name, url: a.url, token }));
upsertUpstream(a);
}
const server =
opts.server ??
new Server(
{ name: "a2amcp", version: pkg.version },
{ capabilities: { tools: { listChanged: false } } },
{ capabilities: { tools: { listChanged: true } } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Array<UpstreamTool & { name: string }> = [];
await syncUpstreamsFromConfig();
const tools: Array<UpstreamTool & { name: string }> = [...MANAGEMENT_TOOLS];
await Promise.all(
[...upstreams.values()].map(async (u) => {
try {
@@ -84,8 +200,100 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (req) => {
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
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);
if (idx < 0) {
return _softError(`tool name missing agent prefix: ${fullName}`) as any;
@@ -96,10 +304,42 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
if (!upstream) {
return _softError(`unknown agent: ${agentName}`) as any;
}
// Honor the client's progressToken if it supplied one; else fall
// back to the request id. Either way, when upstream emits
// notifications/progress we forward them via the SDK's
// sendNotification so clients like Claude Code reset their idle
// tool-call timer and don't abort long builds.
const clientToken = (req.params._meta as any)?.progressToken as
| string
| number
| undefined;
const progressToken = clientToken ?? (extra.requestId as string | number);
try {
const result = await upstream.callTool(
toolName,
(req.params.arguments as Record<string, unknown>) ?? {},
args,
{
progressToken,
onNotification: async (notif) => {
try {
await extra.sendNotification(notif as any);
} catch {
// 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
// populate content / structuredContent / isError per the MCP spec.
@@ -130,3 +370,29 @@ function _softError(message: string) {
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,
} 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,11 +7,30 @@
* header without coupling to a transport class.
*/
const JSON_RPC = "2.0";
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
export interface UpstreamConfig {
name: string;
url: string;
mcpPath?: string;
token: string | null;
tokenProvider?: () => Promise<string | null>;
requestTimeoutMs?: number;
streamIdleTimeoutMs?: number;
/** 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
* on /v1/me/*) — same path the platform orchestrator uses. */
cpJwt?: string | null;
cpJwtProvider?: () => Promise<string | null>;
/** Paired with ``cpJwt``. The upstream agent uses this as the base URL
* when calling back into /v1/me/*. */
cpUrl?: string | null;
/** User's MinIO bucket (``user-<id>-files``). Forwarded as
* ``params._meta.bucket`` so agents that touch the workspace
* (agent-builder, file tools) get a context whose
* ``ctx.workspace.bucket`` resolves to the right bucket. */
bucket?: string | null;
}
export interface UpstreamTool {
@@ -27,6 +46,12 @@ export interface UpstreamCallResult {
isError?: boolean;
}
export interface UpstreamServerRequest {
id: string | number;
method: string;
params?: Record<string, unknown>;
}
export class UpstreamError extends Error {
constructor(
public agent: string,
@@ -40,43 +65,87 @@ export class UpstreamError extends Error {
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 {
constructor(private cfg: UpstreamConfig) {
this.cfg.url = cfg.url.replace(/\/+$/, "");
this.cfg.mcpPath = normalizeMcpPath(cfg.mcpPath);
}
get name(): string {
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> {
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> = {
"content-type": "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`, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }),
});
try {
const resp = await fetch(this.mcpUrl(), {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }),
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
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;
} 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[]> {
@@ -87,10 +156,247 @@ export class UpstreamAgent {
async callTool(
name: string,
arguments_: Record<string, unknown>,
opts: {
onNotification?: (notif: {
method: string;
params?: Record<string, unknown>;
}) => void | Promise<void>;
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
progressToken?: string | number;
} = {},
): Promise<UpstreamCallResult> {
return this.rpc<UpstreamCallResult>("tools/call", {
name,
arguments: arguments_,
const meta: Record<string, unknown> = {};
const cpJwt = await this.cpJwt();
if (cpJwt) meta.cp_jwt = cpJwt;
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken;
const params: Record<string, unknown> = { name, arguments: arguments_ };
if (Object.keys(meta).length > 0) params._meta = meta;
return this.streamingCallTool(params, {
onNotification: opts.onNotification,
onRequest: opts.onRequest,
});
}
/**
* tools/call always negotiates SSE on the wire — upstream agents can
* take minutes (agent-builder.build runs an LLM loop + sandbox tests +
* a deploy + a wait-for-live-card poll), and a plain JSON POST gets
* killed by ingress idle timeouts (~60s default on traefik). The
* upstream's SSE response interleaves any elicitation/create requests
* and a final tools/call result; here we relay server-to-client
* requests through the MCP client and return the final result.
*/
private async streamingCallTool(
params: Record<string, unknown>,
handlers: {
onNotification?: (notif: {
method: string;
params?: Record<string, unknown>;
}) => void | Promise<void>;
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
} = {},
): Promise<UpstreamCallResult> {
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> = {
"content-type": "application/json",
accept: "text/event-stream, application/json",
};
const token = await this.bearerToken();
if (token) headers["authorization"] = `Bearer ${token}`;
let resp: Response;
try {
resp = await fetch(this.mcpUrl(), {
method: "POST",
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) {
const text = await resp.text().catch(() => "");
clearTimeout(idleTimer);
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
}
const ctype = resp.headers.get("content-type") || "";
if (!ctype.includes("text/event-stream")) {
// Server fell back to JSON (older a2a-pack or non-SSE upstream).
const env: any = await resp.json();
if (env?.error) {
clearTimeout(idleTimer);
throw new UpstreamError(
this.cfg.name, 500,
`${env.error.code}: ${env.error.message}`,
);
}
clearTimeout(idleTimer);
return env?.result as UpstreamCallResult;
}
const upstreamSessionId = resp.headers.get("mcp-session-id");
if (!resp.body) {
clearTimeout(idleTimer);
throw new UpstreamError(this.cfg.name, 502, "empty stream body");
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
while (true) {
let done: boolean;
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");
while (sep !== -1) {
const frame = buf.slice(0, sep);
buf = buf.slice(sep + 2);
const dataLines: string[] = [];
for (const ln of frame.split("\n")) {
if (ln.startsWith("data:")) dataLines.push(ln.slice(5).trimStart());
}
if (dataLines.length > 0) {
const raw = dataLines.join("\n");
if (raw === "[DONE]") {
sep = buf.indexOf("\n\n");
continue;
}
let msg: any;
try { msg = JSON.parse(raw); } catch { msg = null; }
if (msg && msg.id === id) {
if (msg.error) {
throw new UpstreamError(
this.cfg.name, 500,
`${msg.error.code}: ${msg.error.message}`,
);
}
return msg.result as UpstreamCallResult;
}
// Otherwise it's a server→client message. Notifications
// (no id) are best-effort. Requests (has id, has method), such
// as elicitation/create, must be relayed and answered upstream.
if (msg && typeof msg.method === "string" && !("id" in msg) && handlers.onNotification) {
try {
await handlers.onNotification({
method: msg.method,
params: msg.params as Record<string, unknown> | undefined,
});
} catch {
// 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");
}
if (done) break;
}
} finally {
clearTimeout(idleTimer);
try { reader.releaseLock(); } catch { /* ignore */ }
}
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 { addAgent, loadConfig, removeAgent } from "../src/config.js";
import { loadCredentials, saveCredentials } from "../src/credentials.js";
let originalHome: string | undefined;
let tmpHome: string;
@@ -44,3 +45,31 @@ test("removeAgent reports hit/miss", async () => {
const cfg = await loadConfig();
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 http from "node:http";
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 {
@@ -9,8 +12,8 @@ import {
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { buildGateway, SEP } from "../src/gateway.js";
import type { EnabledAgent } from "../src/config.js";
import { buildGateway, SEP, type GatewayOptions } from "../src/gateway.js";
import { loadConfig, type EnabledAgent } from "../src/config.js";
/**
* 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. */
async function callGateway(
agents: EnabledAgent[],
token: string | null,
method: "tools/list" | "tools/call",
params: any,
opts: {
gateway?: Partial<Omit<GatewayOptions, "agents" | "token" | "server">>;
extra?: any;
} = {},
) {
const mcpServer = new Server(
{ name: "test", version: "0.0.0" },
{ 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
// a private map, so we route through the public schema-keyed lookup we
// know is wired up.
const schema = method === "tools/list" ? ListToolsRequestSchema : CallToolRequestSchema;
const handler = (mcpServer as any)._requestHandlers.get(schema.shape.method.value);
if (!handler) throw new Error("handler not registered");
return handler({ method, params }, {} as any);
return handler({ method, params }, opts.extra ?? ({} as any));
}
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",
{},
);
assert.equal(out.tools.length, 1);
assert.equal(out.tools[0].name, `greeter${SEP}greet`);
assert.match(out.tools[0].description, /\[greeter\]/);
const upstreamTools = out.tools.filter((tool: any) => tool.name.includes(SEP));
assert.equal(upstreamTools.length, 1);
assert.equal(upstreamTools[0].name, `greeter${SEP}greet`);
assert.match(upstreamTools[0].description, /\[greeter\]/);
} finally {
await upstream.close();
}
@@ -138,7 +182,9 @@ test("tools/list survives a single upstream failure", async () => {
"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`]);
} finally {
await good.close();
@@ -209,3 +255,303 @@ test("tools/call surfaces upstream JSON-RPC error as soft error", async () => {
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");
});