Add dynamic agent discovery tools
All checks were successful
build / test (push) Successful in 20s
publish / npm (push) Successful in 22s

This commit is contained in:
2026-06-05 20:07:37 -03:00
parent 80261e5224
commit 3ebe88708e
9 changed files with 628 additions and 75 deletions

View File

@@ -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)}`);
}

View File

@@ -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");
},
);

View File

@@ -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
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}`;
}