Add dynamic agent discovery tools
This commit is contained in:
14
README.md
14
README.md
@@ -23,6 +23,7 @@ interrupts.
|
||||
|
||||
```bash
|
||||
npx -y a2amcp login
|
||||
npx -y a2amcp search "security evidence"
|
||||
npx -y a2amcp add <agent-name>
|
||||
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 <name> # expose one agent through this gateway
|
||||
a2amcp list # list locally enabled agents
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
49
src/api.ts
49
src/api.ts
@@ -15,6 +15,32 @@ export interface AgentRow {
|
||||
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 {
|
||||
constructor(
|
||||
private apiUrl: string,
|
||||
@@ -54,6 +80,29 @@ export class ControlPlaneClient {
|
||||
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)}`);
|
||||
}
|
||||
|
||||
110
src/cli.ts
110
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<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> {
|
||||
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 <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.")
|
||||
@@ -222,33 +242,21 @@ program
|
||||
.option("--api <url>")
|
||||
.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");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
239
src/gateway.ts
239
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<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). */
|
||||
@@ -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<GatewayHa
|
||||
// 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 cpUrl = creds?.apiUrl ?? null;
|
||||
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, 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<UpstreamTool & { name: string }> = [];
|
||||
await syncUpstreamsFromConfig();
|
||||
const tools: Array<UpstreamTool & { name: string }> = [...MANAGEMENT_TOOLS];
|
||||
await Promise.all(
|
||||
[...upstreams.values()].map(async (u) => {
|
||||
try {
|
||||
@@ -113,6 +202,98 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
||||
|
||||
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;
|
||||
@@ -136,7 +317,7 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
||||
try {
|
||||
const result = await upstream.callTool(
|
||||
toolName,
|
||||
(req.params.arguments as Record<string, unknown>) ?? {},
|
||||
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;
|
||||
}
|
||||
|
||||
91
src/registry.ts
Normal file
91
src/registry.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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<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");
|
||||
});
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user