@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
159 lines • 7.94 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ToolNotFoundError } from '../tool-registry.js';
import { createNoopEmitUi } from '../tool-ui.js';
/**
* Kinds the LOOP serves, which a registry entry cannot honour on its own. A handoff tool is
* registered with a stub handler because `AgentLoop` performs the delegation, so calling it here
* would answer `{}` and delegate to nobody. Never exposed, whatever `actions` says.
*/
const LOOP_SERVED_KINDS = new Set(['agent', 'ask', 'skill', 'memory']);
/** Whether this tool may be reached over MCP at all — the same answer for listing and for calling. */
export function isExposable(spec, actions) {
if (spec === undefined || LOOP_SERVED_KINDS.has(spec.kind)) {
return false;
}
// A tool whose result is shown elsewhere (a generative-UI `ui__show_*` / `ui__render`) only
// pushes a component; over MCP there is no screen, and the model would get nothing back.
if (spec.presentation?.result?.kind === 'elsewhere') {
return false;
}
return spec.kind !== 'action' || actions === 'execute';
}
/**
* True for a Zod schema (tags its Standard Schema props with `vendor: 'zod'`). Narrows to the SDK's
* `AnyObjectSchema` so `toJsonSchemaCompat` can consume it without a cast.
*/
function isZodSchema(schema) {
return schema['~standard'].vendor === 'zod';
}
/** True when the schema carries the Standard JSON Schema converter (`~standard.jsonSchema.input`). */
function hasStandardJsonSchema(schema) {
const standard = schema['~standard'];
if (!('jsonSchema' in standard)) {
return false;
}
const converter = standard.jsonSchema;
return (typeof converter === 'object' &&
converter !== null &&
'input' in converter &&
typeof converter.input === 'function');
}
/**
* Convert a core `StandardSchemaV1` tool schema into the JSON Schema MCP clients see in `tools/list`.
* Zod schemas are converted with the SDK's compat helper (handles both v3 and v4); schemas that expose
* the Standard JSON Schema extension (Valibot/ArkType/Zod 4) hand their `input()` schema straight
* through. Anything else degrades to a permissive object schema — the loop still validates the real
* schema on call via `~standard.validate`.
*/
function toJsonSchema(schema) {
if (isZodSchema(schema)) {
return toJsonSchemaCompat(schema);
}
if (hasStandardJsonSchema(schema)) {
return schema['~standard'].jsonSchema.input();
}
return { type: 'object', properties: {}, additionalProperties: true };
}
/**
* Build a low-level MCP `Server` that exposes the agent's {@link ToolRegistry} over the wire:
*
* - `tools/list` → `registry.definitionsFor(actor, policy, allowedTools)` — the tools the acting
* actor's roles permit (fail-closed via the same `RolesPolicy` the agent loop uses), converted to
* MCP `Tool` descriptors with JSON-Schema inputs.
* - `tools/call` → `registry.invoke(name, args, ctx, policy)` — role re-check + input re-validation
* before the handler runs (the registry's own defense-in-depth), so a stale tool list can never
* bypass the gate.
*
* The acting actor comes from the verified auth's `extra.authInfo`: the MCP auth strategies
* (`authKitAuth`/`apiKeyAuth`) attach it to `extra.actor` as a typed `McpAuthInfo`, and `actorFromAuth`
* resolves it (e.g. with the library's `actorFromAuthInfo` helper) before the gate runs.
*
* Use this in the MCP provider (one server per session, connected to that session's transport).
*/
export function createMcpServer(options) {
const { registry, policy, allowedTools, actorFromAuth, idsFromRequest } = options;
const actions = options.actions ?? 'refuse';
const server = new Server({ name: options.name, version: options.version }, {
capabilities: { tools: {} },
...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
});
const buildCtx = (authInfo, sessionId, requestId) => {
const actor = actorFromAuth(authInfo);
const ids = idsFromRequest
? idsFromRequest(sessionId, requestId)
: {
threadId: `mcp:${sessionId ?? actor.id}`,
runId: `mcp:${sessionId ?? 'run'}`,
requestId: `mcp:${sessionId ?? 'req'}`,
};
// Each `tools/call` is one call of its own: no runtime replays it, so its id — and the
// idempotency key a tool writes with — is fresh per call.
const toolCallId = `mcp-call:${randomUUID()}`;
return {
actor,
...ids,
toolCallId,
idempotencyKey: `${ids.runId}:${toolCallId}`,
// A tool can tell it is answering an MCP client, not the person in the app.
pageContext: { channel: 'mcp' },
// No conversation to push into: `emitUi` accepts the push and does nothing, so a tool that
// shows UI in a chat still runs over MCP.
emitUi: createNoopEmitUi(ids.requestId),
};
};
server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
const actor = actorFromAuth(extra.authInfo);
const defs = (await registry.definitionsFor(actor, policy, allowedTools)).filter((definition) => isExposable(registry.spec(definition.name), actions));
return {
tools: await Promise.all(defs.map(async (definition) => {
const described = await options.describeTool?.({
name: definition.name,
kind: definition.kind,
description: definition.description,
});
return {
name: definition.name,
...(described?.title !== undefined ? { title: described.title } : {}),
description: definition.description,
inputSchema: toJsonSchema(definition.inputSchema),
// Clients run a read without asking and confirm a write: say which is which.
annotations: described?.annotations ?? {
readOnlyHint: definition.kind === 'read',
},
};
})),
};
});
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const ctx = buildCtx(extra.authInfo, extra.sessionId, extra.requestId);
const { name, arguments: args } = request.params;
try {
// Re-decided on the CALL, not inherited from the listing. `registry.invoke` re-checks roles
// and re-validates input, but knows nothing about this server's allow-list or its `actions`
// stance — so a caller who guesses a name would otherwise reach a tool deliberately left off
// the surface.
const spec = registry.spec(name);
if (!isExposable(spec, actions)) {
throw new ToolNotFoundError(name);
}
if (allowedTools !== undefined && !allowedTools.includes(name)) {
throw new ToolNotFoundError(name);
}
const output = await registry.invoke(name, args ?? {}, ctx, policy);
return {
content: [
{ type: 'text', text: typeof output === 'string' ? output : JSON.stringify(output) },
],
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: 'text', text: message }], isError: true };
}
});
return server;
}
//# sourceMappingURL=server.js.map