UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

161 lines • 8.33 kB
import { McpToolSource } from './tool-source.js'; /** * Connects to each configured MCP server and registers what it exports into the shared * {@link ToolRegistry} — the same registry `@aiTool` discovery writes to, so an imported tool goes * through every gate a hand-written one does. * * The provider calls {@link start} inside `app.booted()`, AFTER tool discovery has registered the * app's own tools, which is what lets the name-collision check below see them. * * The registry is keyed by name and nothing else, so a name is OWNED by whoever registered it * first: the application, or one named server. A second claimant is refused rather than allowed to * replace a live handler, because the substitution is invisible from everywhere else — the model * goes on calling `deploy`, and `deploy` now reaches somebody else's server. */ export class McpToolImporter { servers; registry; logger; sources = new Map(); /** Local tool name → what is registered under it, so ownership survives a {@link refresh}. */ owners = new Map(); constructor(servers, registry, logger) { this.servers = servers; this.registry = registry; this.logger = logger; // A name identifies a server in the logs, in `refresh(name)`, in the ownership map and in the // default tool prefix. Two servers sharing one leaves every one of those pointing at a single // arbitrary member of the pair — a collision that IS statically detectable, so it is refused // here rather than surfacing as one server's tools mysteriously missing. const names = servers.map((config) => config.name); const duplicate = names.find((name, index) => names.indexOf(name) !== index); if (duplicate !== undefined) { throw new Error(`Two MCP servers are configured as "${duplicate}". Give each server a distinct name.`); } } /** Open each configured server and import from all of them. */ async start() { for (const config of this.servers) { this.sources.set(config.name, new McpToolSource(config, this.logger)); } return this.refresh(); } async close() { await Promise.all([...this.sources.values()].map((source) => source.close())); } /** * Re-import from one server, or from all of them — the way back for a server that was down at * boot, or whose exports have changed since. * * A re-import is the server's CURRENT answer to `tools/list`, so it removes as well as adds: a * tool the server has dropped or renamed is unregistered, because going on offering the model a * tool whose next call fails remotely is worse than not offering it at all. Only names this * importer registered for THAT server are eligible — never the application's own, never another * server's. * * A server that could not be reached prunes nothing. Its tools are unknown, not gone, and a * network blip is not a reason to withdraw a working tool from the model. * * Returns how many tools the servers it re-imported from just claimed, which is not the * registry's total: a name skipped for a collision is not counted, and a scoped refresh says * nothing about what the other servers hold. */ async refresh(serverName) { const configs = this.servers.filter((config) => this.sources.has(config.name) && (serverName === undefined || config.name === serverName)); if (serverName !== undefined && configs.length === 0) { // A scoped refresh that matched nothing re-imported nothing, and `0` reads identically to a // server that answered with no tools — so the name is reported rather than left to look like // an answer. const open = [...this.sources.keys()]; this.logger?.warn(`MCP refresh("${serverName}") matched no open MCP server — nothing was re-imported. Open servers: ${open.length > 0 ? open.join(', ') : '(none)'}.`); return 0; } // Connecting and listing is the slow part and the servers are independent, so it runs in // parallel — but registration below replays in CONFIGURATION order, because which server owns a // name two of them both export has to be the same answer on every boot. const listed = await Promise.all(configs.map(async (config) => ({ config, tools: await this.importFrom(config) }))); // Pruning runs BEFORE any registration, so a name one server has just dropped is free for the // next server in configuration order to claim within this same refresh. for (const { config, tools } of listed) { if (tools !== undefined) { this.prune(config.name, new Set(tools.map((tool) => tool.spec.name))); } } let total = 0; for (const { config, tools } of listed) { if (tools !== undefined) { total += this.register(config, tools); } } return total; } /** Every tool currently imported, and where each came from — what the servers export NOW. */ importedTools() { return [...this.owners.values()]; } /** * A server that is unreachable costs its own tools and nothing else: the model is simply never * offered them. Booting the whole application on the availability of a third-party process is the * wrong trade — until a server declares `required: true`, which says the app is not useful * without it and should fail loudly instead. * * `undefined` is a server that could not be reached, as distinct from one that exports no tools: * the first has already been reported here, the second is worth reporting on registration. */ async importFrom(config) { const source = this.sources.get(config.name); if (source === undefined) { return undefined; } try { return await source.import(); } catch (error) { const message = `MCP server "${config.name}" could not be imported: ${error instanceof Error ? error.message : String(error)}`; if (config.required === true) { throw new Error(message, { cause: error }); } this.logger?.warn(`${message} — its tools are unavailable in this process.`); return undefined; } } /** * Drop what `serverName` used to export and no longer does. Keyed on ownership rather than on the * name's prefix: `namespace: false` imports under bare names, where a prefix says nothing about * who registered one. */ prune(serverName, exported) { for (const [name, owner] of [...this.owners]) { if (owner.serverName !== serverName || exported.has(name)) { continue; } this.owners.delete(name); this.registry.unregister(name); this.logger?.warn(`MCP server "${serverName}": tool "${name}" is no longer exported — unregistered.`); } } register(config, tools) { let count = 0; for (const tool of tools) { const owner = this.owners.get(tool.spec.name); if (owner === undefined && this.registry.has(tool.spec.name)) { this.logger?.warn(`MCP server "${config.name}": tool "${tool.spec.name}" is already registered by this application — skipped. Namespace the server's tools to import it alongside.`); continue; } if (owner !== undefined && owner.serverName !== config.name) { this.logger?.warn(`MCP server "${config.name}": tool "${tool.spec.name}" is already imported from MCP server "${owner.serverName}" — skipped. Namespace one of the two servers to import both.`); continue; } this.registry.register(tool.spec, tool.handler); this.owners.set(tool.spec.name, { name: tool.spec.name, serverName: config.name, remoteName: tool.remoteName, description: tool.spec.description, }); count += 1; } return count; } } //# sourceMappingURL=importer.js.map