UNPKG

@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
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