@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
272 lines • 12.6 kB
JavaScript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { invokeWithTransientRetry } from '../tool-retry.js';
import { mcpInputSchema } from './tool-input.js';
import { resolveMcpToolKind } from './tool-kind.js';
import { localToolName } from './tool-name.js';
import { isPreExecutionMcpError, isTransientMcpError } from './transient.js';
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const CLIENT_INFO = { name: 'adonis-agora-agent-mcp', version: '1.0.0' };
/** A remote tool that answered with `isError` — a business failure of the tool, not of the transport. */
export class McpToolCallError extends Error {
serverName;
toolName;
constructor(serverName, toolName, message) {
super(message);
this.serverName = serverName;
this.toolName = toolName;
this.name = 'McpToolCallError';
}
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* `content` is optional in the compatibility result shape the SDK still accepts, so it is narrowed
* rather than trusted: a server that answers without it yields no blocks instead of a crash.
*/
function toContentBlocks(content) {
return Array.isArray(content) ? content : [];
}
/**
* One MCP server, as a source of tools.
*
* Holds a single client and reconnects on demand. The connection is deliberately not treated as
* durable state: a remote server may restart, be redeployed, or drop an idle connection, and the
* first symptom is a failed tool call. So a call that fails transiently drops the client, and the
* retry — core's `invokeWithTransientRetry`, the same one the agent loop wraps every tool with —
* reconnects on its next attempt.
*
* Every timeout here is the SDK's own per-request timeout rather than a race of our own, so a
* timed-out request is also CANCELLED on the wire instead of being abandoned.
*/
export class McpToolSource {
config;
logger;
client;
connecting;
constructor(config, logger) {
this.config = config;
this.logger = logger;
}
get name() {
return this.config.name;
}
/** Connect if needed, list the server's tools, and map the selected ones onto specs + handlers. */
async import() {
const client = await this.ensureClient();
const { tools } = await client.listTools(undefined, { timeout: this.requestTimeoutMs() });
const imported = [];
for (const tool of tools) {
if (!this.isSelected(tool.name) || !(await this.passesScreen(tool))) {
continue;
}
const mapped = this.toImportedTool(tool);
if (mapped !== undefined) {
imported.push(mapped);
}
}
return imported;
}
async close() {
const client = this.client ?? (await this.connecting?.catch(() => undefined));
this.client = undefined;
this.connecting = undefined;
await client?.close();
}
/** Runs the configured screen; a refusal or a screen that throws both leave the tool out. */
async passesScreen(tool) {
const screen = this.config.screen;
if (screen === undefined) {
return true;
}
let reason;
try {
const verdict = await screen({
server: this.config.name,
name: tool.name,
...(tool.title !== undefined ? { title: tool.title } : {}),
...(tool.description !== undefined ? { description: tool.description } : {}),
inputSchema: tool.inputSchema,
});
if (verdict.allowed) {
return true;
}
reason = verdict.reason;
}
catch (error) {
reason = `the screen failed: ${error instanceof Error ? error.message : String(error)}`;
}
this.logger?.warn(`MCP server "${this.config.name}": tool "${tool.name}" was refused by the tool screen — skipped (${reason})`);
return false;
}
isSelected(remoteName) {
const { include, exclude } = this.config;
if (include !== undefined && !include.includes(remoteName)) {
return false;
}
return exclude === undefined || !exclude.includes(remoteName);
}
/**
* A tool whose schema won't compile — or whose `pattern` can backtrack exponentially — is
* dropped, not imported with a permissive stand-in: the model would then be free to send that
* remote tool any arguments at all, and the call would still look validated from this side.
*/
toImportedTool(tool) {
const name = localToolName(this.config.name, tool.name, this.config.namespace);
let inputSchema;
try {
inputSchema = mcpInputSchema(tool.inputSchema, {
...(this.config.validator !== undefined ? { validator: this.config.validator } : {}),
...(this.config.rejectUnsafePatterns !== undefined
? { rejectUnsafePatterns: this.config.rejectUnsafePatterns }
: {}),
});
}
catch (error) {
this.logger?.warn(`MCP server "${this.config.name}": tool "${tool.name}" has an input schema this process will not validate against — skipped (${error instanceof Error ? error.message : String(error)})`);
return undefined;
}
const spec = {
name,
kind: resolveMcpToolKind({
name: tool.name,
...(tool.description !== undefined ? { description: tool.description } : {}),
...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}),
}, this.config.kind),
description: tool.description ?? `The "${tool.name}" tool on MCP server ${this.config.name}.`,
inputSchema,
...(this.config.roles !== undefined ? { roles: this.config.roles } : {}),
...(this.config.ability !== undefined ? { ability: this.config.ability } : {}),
...(this.config.enabled !== undefined ? { enabled: this.config.enabled } : {}),
};
const handler = {
execute: (input) => this.callTool(tool.name, input, spec.kind),
...(this.config.canUse !== undefined ? { canUse: this.config.canUse } : {}),
};
return { spec, handler, remoteName: tool.name, serverName: this.config.name };
}
callTool(remoteName, input, kind) {
return invokeWithTransientRetry(() => this.callOnce(remoteName, input), this.retrySetting(kind), {
onRetry: (attempt, error) => {
this.logger?.warn(`MCP server "${this.config.name}": tool "${remoteName}" failed transiently on attempt ${attempt}, reconnecting (${error instanceof Error ? error.message : String(error)})`);
},
});
}
async callOnce(remoteName, input) {
const result = await this.request(remoteName, input);
const content = toContentBlocks(result.content);
if (result.isError === true) {
throw new McpToolCallError(this.config.name, remoteName, this.errorText(content));
}
if (result.structuredContent !== undefined) {
return result.structuredContent;
}
// The overwhelmingly common shape is one text block; handing the model the bare string keeps it
// from having to read past an envelope that carries nothing. Anything richer (images, embedded
// resources, several blocks) is passed through as MCP modelled it.
const only = content.length === 1 ? content[0] : undefined;
return only?.type === 'text' ? only.text : { content };
}
/** The wire call alone, so only a TRANSPORT failure can recycle the client — never a tool's own. */
async request(remoteName, input) {
const client = await this.ensureClient();
try {
return await client.callTool({ name: remoteName, ...(isRecord(input) ? { arguments: input } : {}) }, CallToolResultSchema, { timeout: this.requestTimeoutMs() });
}
catch (error) {
if (isTransientMcpError(error)) {
this.dropClient();
}
throw error;
}
}
errorText(content) {
const text = content
.filter((part) => part.type === 'text')
.map((part) => part.text ?? '')
.join('\n')
.trim();
return text === '' ? `MCP tool "${this.config.name}" reported an error` : text;
}
ensureClient() {
if (this.client !== undefined) {
return Promise.resolve(this.client);
}
// One in-flight connect shared by every caller: a turn can call several of a server's tools at
// once, and each opening its own connection would leave all but one orphaned.
this.connecting ??= this.connect().finally(() => {
this.connecting = undefined;
});
return this.connecting;
}
async connect() {
const client = new Client(this.config.clientInfo ?? CLIENT_INFO);
const transport = await this.createTransport();
await client.connect(transport, {
timeout: this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
});
client.onclose = () => {
if (this.client === client) {
this.client = undefined;
}
};
this.client = client;
return client;
}
createTransport() {
const transport = this.config.transport;
switch (transport.type) {
case 'stdio':
return new StdioClientTransport({
command: transport.command,
...(transport.args !== undefined ? { args: transport.args } : {}),
...(transport.env !== undefined ? { env: transport.env } : {}),
...(transport.cwd !== undefined ? { cwd: transport.cwd } : {}),
});
case 'http':
// The SDK's own transports expose `sessionId` as `string | undefined` where the `Transport`
// interface declares the property optional, which `exactOptionalPropertyTypes` reads as a
// mismatch — the class does implement the interface it is declared against.
return new StreamableHTTPClientTransport(new URL(transport.url), transport.headers !== undefined ? { requestInit: { headers: transport.headers } } : {});
case 'custom':
return transport.create();
}
}
dropClient() {
const client = this.client;
this.client = undefined;
void client?.close().catch(() => undefined);
}
/**
* The configured policy with a classifier filled in. The library's default classifier recognizes
* local database lock contention, which is not how a remote server fails — leaving it in place
* would mean the policy is on and never matches anything.
*
* WHICH CLASSIFIER, AND WHY IT DEPENDS ON THE KIND. An `action` is a tool whose effects happen on
* somebody else's machine and are invisible from here, which is why one waits for a human at all.
* Most of what {@link isTransientMcpError} accepts cannot tell "the request never arrived" from
* "it ran and the answer was lost" — a timeout, a reset, a 503 — so retrying on it would spend one
* approval on two remote effects. An `action` therefore retries only
* {@link isPreExecutionMcpError}: failures that prove nothing ran. A `read` keeps the broader
* policy, where a second identical call costs a round trip.
*
* A host that supplied its own `classify` has taken that judgement over, and it is honoured for
* every kind.
*/
retrySetting(kind) {
const setting = this.config.transientRetry;
if (setting === false) {
return false;
}
const fallback = kind === 'action' ? isPreExecutionMcpError : isTransientMcpError;
return { ...setting, classify: setting?.classify ?? fallback };
}
requestTimeoutMs() {
return this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
}
//# sourceMappingURL=tool-source.js.map