From 3ebe88708e4f13c87b9a528f39e05ea395b7de6a Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 5 Jun 2026 20:07:37 -0300 Subject: [PATCH] Add dynamic agent discovery tools --- README.md | 14 +++ package-lock.json | 4 +- package.json | 2 +- src/api.ts | 49 +++++++++ src/cli.ts | 110 ++++++++++--------- src/gateway.ts | 239 +++++++++++++++++++++++++++++++++++++++--- src/registry.ts | 91 ++++++++++++++++ tests/api.test.ts | 43 ++++++++ tests/gateway.test.ts | 151 +++++++++++++++++++++++++- 9 files changed, 628 insertions(+), 75 deletions(-) create mode 100644 src/registry.ts diff --git a/README.md b/README.md index 5dd41e4..1e04996 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ interrupts. ```bash npx -y a2amcp login +npx -y a2amcp search "security evidence" npx -y a2amcp add 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 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 # expose one agent through this gateway a2amcp list # list locally enabled agents a2amcp remove # stop exposing an agent @@ -57,6 +59,18 @@ 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 diff --git a/package-lock.json b/package-lock.json index 84dcec8..2da1c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "a2amcp", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "a2amcp", - "version": "0.1.10", + "version": "0.1.11", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/package.json b/package.json index 82852d2..59fdb42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "a2amcp", - "version": "0.1.10", + "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": { diff --git a/src/api.ts b/src/api.ts index 064299c..8d323f1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,32 @@ export interface AgentRow { card?: Record; } +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 { constructor( private apiUrl: string, @@ -54,6 +80,29 @@ export class ControlPlaneClient { return this.request("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( + "GET", + `/v1/agents/search${qs ? `?${qs}` : ""}`, + ); + } + getAgent(name: string) { return this.request("GET", `/v1/agents/${encodeURIComponent(name)}`); } diff --git a/src/cli.ts b/src/cli.ts index 01b8a46..b40e7c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ 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, @@ -32,6 +32,11 @@ import { loginWithBrowser, refreshCredentialsIfNeeded, } from "./oauth.js"; +import { + enableAgentByName, + searchVisibleAgents, + summarizeAgent, +} from "./registry.js"; const program = new Command(); @@ -52,30 +57,6 @@ function fail(msg: string, code = 1): never { 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 }, - 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 { const rl = readline.createInterface({ input, output, terminal: true }); try { @@ -201,6 +182,45 @@ program } }); +program + .command("search [query]") + .description("Search agents visible to your account.") + .option("--tag ", "Require one or more tags") + .option("--skill ", "Require a skill name") + .option("--limit ", "Maximum results", "8") + .option("--api ") + .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.") @@ -222,33 +242,21 @@ program .option("--api ") .action( async (name: string, opts: { url?: string; api?: string }) => { - let url = opts.url; - let mcpPath = "/mcp"; - let description: string | undefined; - if (!url) { - try { - const creds = await loadCredentials(); - const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null; - const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl); - const row = await new ControlPlaneClient(apiUrl, refreshed?.token ?? null).getAgent(name); - const target = mcpTargetForAgent(row, apiUrl); - url = target.url; - mcpPath = target.mcpPath; - 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, - 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"); }, ); diff --git a/src/gateway.ts b/src/gateway.ts index e091085..e130b31 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -17,14 +17,81 @@ import { } from "@modelcontextprotocol/sdk/types.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 { + 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 = [ + { + 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). */ @@ -35,6 +102,8 @@ export interface GatewayOptions { 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; } @@ -58,33 +127,53 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise(); + + 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 { + 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, mcpPath: a.mcpPath, token, - tokenProvider: refreshedToken, - cpJwtProvider: refreshedToken, - cpUrl, bucket, - requestTimeoutMs: opts.upstreamRequestTimeoutMs, - streamIdleTimeoutMs: opts.upstreamStreamIdleTimeoutMs, - }), - ); + 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 = []; + await syncUpstreamsFromConfig(); + const tools: Array = [...MANAGEMENT_TOOLS]; await Promise.all( [...upstreams.values()].map(async (u) => { try { @@ -113,6 +202,98 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise { const fullName = req.params.name; + const args = (req.params.arguments as Record) ?? {}; + 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; @@ -136,7 +317,7 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise) ?? {}, + args, { progressToken, onNotification: async (notif) => { @@ -189,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; +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..57a4969 --- /dev/null +++ b/src/registry.ts @@ -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 }, + 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 { + return cp.searchAgents(opts); +} + +export async function enableAgentByName(opts: { + cp?: ControlPlaneClient; + apiUrl: string; + name: string; + url?: string; + mcpPath?: string; +}): Promise { + 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}`; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 32a3380..857f6dc 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -48,3 +48,46 @@ test("ControlPlaneClient forwards bearer tokens and extracts JSON error detail", /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; + 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"); +}); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index aff81f1..4dc18ed 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -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 { @@ -10,7 +13,7 @@ import { } from "@modelcontextprotocol/sdk/types.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 @@ -149,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(); } @@ -178,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(); @@ -414,3 +420,138 @@ test("tools/call relays upstream elicitation requests", async () => { await upstream.close(); } }); + +function startFakeControlPlane(): Promise<{ url: string; close: () => Promise; 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(fn: () => Promise): Promise { + 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(); + } + }); +});