2 Commits
v0.1.9 ... 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
11 changed files with 818 additions and 80 deletions

View File

@@ -23,6 +23,7 @@ interrupts.
```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
``` ```
@@ -48,6 +49,7 @@ Restart the client after adding or removing agents.
a2amcp login # sign in through Keycloak and save OAuth tokens 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
@@ -57,6 +59,18 @@ 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 `a2amcp login` uses Keycloak Authorization Code + PKCE. It starts a local
loopback callback server, opens the browser, exchanges the code for a Keycloak loopback callback server, opens the browser, exchanges the code for a Keycloak
access token, and stores the token in `~/.a2a/credentials.json`. For headless access token, and stores the token in `~/.a2a/credentials.json`. For headless

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "a2amcp", "name": "a2amcp",
"version": "0.1.9", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "a2amcp", "name": "a2amcp",
"version": "0.1.9", "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.9", "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

@@ -15,6 +15,32 @@ export interface AgentRow {
card?: Record<string, any>; 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 {
constructor( constructor(
private apiUrl: string, private apiUrl: string,
@@ -54,6 +80,29 @@ 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)}`);
} }

View File

@@ -15,7 +15,7 @@ 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,
@@ -32,6 +32,11 @@ import {
loginWithBrowser, loginWithBrowser,
refreshCredentialsIfNeeded, refreshCredentialsIfNeeded,
} from "./oauth.js"; } from "./oauth.js";
import {
enableAgentByName,
searchVisibleAgents,
summarizeAgent,
} from "./registry.js";
const program = new Command(); const program = new Command();
@@ -52,30 +57,6 @@ function fail(msg: string, code = 1): never {
process.exit(code); process.exit(code);
} }
function normalizeMcpPath(path: unknown): string {
if (typeof path !== "string" || !path.trim()) return "/mcp";
const trimmed = path.trim();
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
function mcpTargetForAgent(
row: { name: string; url?: string; 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,
mcpPath: normalizeMcpPath(mcp?.path || row.card?.mcp_endpoint),
};
}
async function prompt(label: string, opts: { hidden?: boolean } = {}): Promise<string> { async function prompt(label: string, opts: { hidden?: boolean } = {}): Promise<string> {
const rl = readline.createInterface({ input, output, terminal: true }); const rl = readline.createInterface({ input, output, terminal: true });
try { try {
@@ -201,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.")
@@ -222,33 +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 mcpPath = "/mcp"; const creds = await loadCredentials();
let description: string | undefined; const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null;
if (!url) { const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl);
try { const added = await enableAgentByName({
const creds = await loadCredentials(); cp: opts.url ? undefined : new ControlPlaneClient(apiUrl, refreshed?.token ?? null),
const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null; apiUrl,
const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl); name,
const row = await new ControlPlaneClient(apiUrl, refreshed?.token ?? null).getAgent(name); url: opts.url,
const target = mcpTargetForAgent(row, apiUrl); });
url = target.url; output.write(`${added.replaced ? "updated" : "added"} ${name} -> ${added.endpoint}\n`);
mcpPath = target.mcpPath; output.write("restart your MCP client, or ask it to refresh tools, to pick up changes.\n");
description = row.description; } catch (err) {
} catch (err) { fail(err instanceof ApiError ? err.message : (err as Error).message);
fail(err instanceof ApiError ? err.message : (err as Error).message);
}
} }
if (!url) fail(`agent ${name} has no public URL yet`);
await addAgent({
name,
url,
mcpPath,
description,
addedAt: new Date().toISOString(),
});
output.write(`added ${name} -> ${url.replace(/\/+$/, "")}${mcpPath}\n`);
output.write("restart your MCP client to pick up the new tools.\n");
}, },
); );

View File

@@ -17,14 +17,81 @@ import {
} 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, type Credentials } from "./credentials.js"; import { ApiError, ControlPlaneClient } from "./api.js";
import { loadCredentials, resolveApiUrl, type Credentials } from "./credentials.js";
import { refreshCredentialsIfNeeded } from "./oauth.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). */
@@ -35,6 +102,8 @@ export interface GatewayOptions {
upstreamRequestTimeoutMs?: number; upstreamRequestTimeoutMs?: number;
/** Test/advanced override for idle SSE upstream tools/call streams. */ /** Test/advanced override for idle SSE upstream tools/call streams. */
upstreamStreamIdleTimeoutMs?: number; 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;
} }
@@ -58,33 +127,53 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
// 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 cpUrl = creds?.apiUrl ?? null; const apiUrl = resolveApiUrl(opts.apiUrl ?? creds?.apiUrl);
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, mcpPath: a.mcpPath, token,
tokenProvider: refreshedToken,
cpJwtProvider: refreshedToken,
cpUrl, bucket,
requestTimeoutMs: opts.upstreamRequestTimeoutMs,
streamIdleTimeoutMs: opts.upstreamStreamIdleTimeoutMs,
}),
);
} }
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 {
@@ -113,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;
@@ -136,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) => {
@@ -189,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

@@ -1,5 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import http from "node:http"; import http from "node:http";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
@@ -7,6 +10,7 @@ import { ControlPlaneClient } from "./api.js";
import { import {
DEFAULT_API_URL, DEFAULT_API_URL,
type Credentials, type Credentials,
loadCredentials,
saveCredentials, saveCredentials,
} from "./credentials.js"; } from "./credentials.js";
@@ -15,6 +19,9 @@ export const DEFAULT_OAUTH_CLIENT_ID = "a2acloud-cli";
export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read"; export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read";
export const DEFAULT_REDIRECT_PORT = 41873; export const DEFAULT_REDIRECT_PORT = 41873;
const CALLBACK_PATH = "/callback"; 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 = { type OpenIdConfiguration = {
authorization_endpoint: string; authorization_endpoint: string;
@@ -116,20 +123,40 @@ export async function refreshCredentialsIfNeeded(
minTtlSeconds = 60, minTtlSeconds = 60,
): Promise<Credentials> { ): Promise<Credentials> {
if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds; if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds;
if (creds.expiresAt && creds.expiresAt - nowSeconds() > minTtlSeconds) return creds; if (credentialsFresh(creds, minTtlSeconds)) return creds;
const cfg = await discoverOpenIdConfiguration(creds.issuer); 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({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: creds.clientId, client_id: creds.clientId!,
refresh_token: creds.refreshToken, refresh_token: creds.refreshToken!,
}); });
const resp = await fetch(cfg.token_endpoint, { const resp = await fetch(cfg.token_endpoint, {
method: "POST", method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/x-www-form-urlencoded" },
body, body,
}); });
const token = await parseTokenResponse(resp); 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 = { const next: Credentials = {
...creds, ...creds,
token: token.access_token!, token: token.access_token!,
@@ -141,6 +168,74 @@ export async function refreshCredentialsIfNeeded(
return 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( export async function discoverOpenIdConfiguration(
issuer: string, issuer: string,
): Promise<OpenIdConfiguration> { ): Promise<OpenIdConfiguration> {
@@ -338,6 +433,10 @@ function nowSeconds(): number {
return Math.floor(Date.now() / 1000); 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> { function parseJsonObject(text: string): Record<string, unknown> {
if (!text.trim()) return {}; if (!text.trim()) return {};
try { try {

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

@@ -48,3 +48,46 @@ test("ControlPlaneClient forwards bearer tokens and extracts JSON error detail",
/API 403: keycloak email is not verified/, /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

@@ -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 {
@@ -10,7 +13,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { buildGateway, SEP, type GatewayOptions } 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
@@ -149,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();
} }
@@ -178,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();
@@ -414,3 +420,138 @@ test("tools/call relays upstream elicitation requests", async () => {
await upstream.close(); 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();
}
});
});

View File

@@ -93,3 +93,89 @@ test("refreshCredentialsIfNeeded refreshes expired Keycloak access token", async
assert.equal((await loadCredentials())!.token, "fresh-token"); assert.equal((await loadCredentials())!.token, "fresh-token");
assert.ok(calls.some((call) => call.body?.includes("grant_type=refresh_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");
});