@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
273 lines • 12.9 kB
JavaScript
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { turnChannel } from '../genui/channels.js';
import { GenuiRunRecorder } from '../genui-telemetry.js';
import { REMEMBER_TOOL_DESCRIPTION, REMEMBER_TOOL_NAME, rememberInputSchema, writeMemory, } from '../memory.js';
import { defaultScopeResolver } from '../skills.js';
import { ToolNotFoundError } from '../tool-registry.js';
import { errorText } from './log.js';
/** Kinds the LOOP serves; a registry entry of one of them cannot be honoured over MCP. */
const LOOP_SERVED_KINDS = new Set(['agent', 'ask', 'skill', 'memory']);
const b64url = (data) => Buffer.from(data).toString('base64url');
/**
* Signs and checks the bearer tokens the engine registers its tools endpoint with (`mcp.add`). A
* token names an actor and an OpenCode server, and expires; it is HMAC-SHA256 over its claims with
* a key derived from the configured secret (or the app's `APP_KEY`).
*
* A token on its own runs nothing: the endpoint also needs the call to come from a session that is
* running a turn of that actor right now (see `OpenCodeTurns.callContext`), and an `action` tool
* needs an approval the turn granted (see `OpenCodeTurns.spendApproval`).
*/
export class OpenCodeToolsTokens {
ttlMs;
key;
constructor(secret, ttlMs) {
this.ttlMs = ttlMs;
this.key = createHash('sha256')
.update('agora:opencode:tools\0')
.update(secret ?? randomBytes(32).toString('hex'))
.digest();
}
mint(actor, server, now = Date.now()) {
const claims = { v: 1, actor, server, exp: now + this.ttlMs };
const body = b64url(JSON.stringify(claims));
return `${body}.${this.sign(body)}`;
}
verify(token, now = Date.now()) {
const [body, signature, extra] = token.split('.');
if (body === undefined || signature === undefined || extra !== undefined)
return null;
const expected = Buffer.from(this.sign(body));
const given = Buffer.from(signature);
if (expected.length !== given.length || !timingSafeEqual(expected, given))
return null;
try {
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (claims?.v !== 1 ||
typeof claims.server !== 'string' ||
typeof claims.exp !== 'number' ||
typeof claims.actor?.id !== 'string' ||
claims.exp <= now) {
return null;
}
return claims;
}
catch {
return null;
}
}
sign(body) {
return createHmac('sha256', this.key).update(body).digest('base64url');
}
}
/** True for a Zod schema (its Standard Schema props say `vendor: 'zod'`). */
function isZodSchema(schema) {
return schema['~standard'].vendor === 'zod';
}
/** A tool's input schema as the JSON Schema MCP clients read — as the `config/mcp.ts` surface does. */
function toJsonSchema(schema) {
if (isZodSchema(schema))
return toJsonSchemaCompat(schema);
const converter = schema['~standard'].jsonSchema;
if (typeof converter?.input === 'function') {
return converter.input();
}
return { type: 'object', properties: {}, additionalProperties: true };
}
const asText = (output) => (typeof output === 'string' ? output : JSON.stringify(output));
class RefusedError extends Error {
}
/**
* The MCP endpoint OpenCode sessions reach the app's tools through — `POST <agent path>/opencode/mcp`,
* mounted by the engine when `tools` is set. Stateless Streamable HTTP (one server per request), so
* it answers from any process.
*
* - `tools/list`: the registry's tools the token's actor may reach (roles, `enabled`, `canUse`), the
* kinds only the loop serves left out; with `_meta` naming a running turn, its agent's allow-list
* too. Plus `remember`, when the agent's memory provider writes.
* - `tools/call`: only for a call `_meta` ties to a turn of the token's actor running on that
* session; the turn's allow-list and the registry's own checks apply, an `action` runs only against
* an approval the turn granted, and the tool's `ctx` is the turn's (thread, run, `emitUi` into its
* stream and message).
*/
export class OpenCodeMcpEndpoint {
turns;
tokens;
/** Genui telemetry of the UI calls OpenCode makes (its code mode calls tools here, not in the loop). */
genui = new GenuiRunRecorder();
constructor(turns, tokens) {
this.turns = turns;
this.tokens = tokens;
}
/** Mounts the endpoint next to the agent's routes. */
adapter() {
return {
name: 'opencode-mcp',
mount: (host) => {
host.post('opencode/mcp', (ctx) => this.handle(ctx));
// Stateless: no server-sent stream to resume, no session to close.
host.get('opencode/mcp', (ctx) => ctx.response.status(405).header('allow', 'POST').json({ error: 'method not allowed' }));
},
};
}
/** The claims of a request's bearer token, or `null`. */
authenticate(authorization) {
const token = /^Bearer\s+/i.test(authorization ?? '')
? authorization?.replace(/^Bearer\s+/i, '').trim()
: undefined;
return token ? this.tokens.verify(token) : null;
}
async handle(ctx) {
const claims = this.authenticate(ctx.request.header('authorization'));
if (claims === null) {
ctx.response.header('WWW-Authenticate', 'Bearer error="invalid_token"');
ctx.response.status(401).json({ error: 'unauthorized' });
return;
}
const req = ctx.request.request;
const res = ctx.response.response;
const server = this.server(claims);
// No `sessionIdGenerator`: stateless, so any process answers any request.
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
res.on('close', () => {
void transport.close();
void server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, ctx.request.body());
}
catch (error) {
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32603, message: errorText(error, 'Internal error') },
id: null,
}));
}
}
await new Promise((resolve) => {
if (res.writableFinished)
return resolve();
res.on('finish', () => resolve());
res.on('close', () => resolve());
});
}
/** One MCP server for one request, answering as the token's actor. */
server(claims) {
const server = new Server({ name: 'agora-opencode', version: '1.0.0' }, { capabilities: { tools: {} } });
const { actor } = claims;
const call = (meta, requestId) => this.turns.callContext({
actor,
serverKey: claims.server,
requestId: String(requestId ?? ''),
meta: meta,
});
server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
const turn = await call(request.params?._meta, extra.requestId);
const input = turn?.input ?? {};
const deps = this.turns.depsFor(input);
const allowed = turn !== undefined ? this.turns.allowedTools(turn.input) : undefined;
const definitions = (await this.turns.registry.definitionsFor(actor, deps.rolesPolicy, allowed)).filter((definition) => !LOOP_SERVED_KINDS.has(definition.kind));
const tools = definitions.map((definition) => ({
name: definition.name,
description: definition.description,
inputSchema: toJsonSchema(definition.inputSchema),
}));
if (this.turns.memoryWritable(input)) {
tools.push({
name: REMEMBER_TOOL_NAME,
description: REMEMBER_TOOL_DESCRIPTION,
inputSchema: toJsonSchema(rememberInputSchema),
});
}
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params;
try {
const turn = await call(request.params._meta, extra.requestId);
if (turn === undefined) {
throw new RefusedError('This endpoint serves OpenCode turns: no turn of yours is running on this session.');
}
const output = name === REMEMBER_TOOL_NAME && this.turns.memoryWritable(turn.input)
? await this.remember(turn, args)
: await this.invoke(turn, actor, name, args, String(extra.requestId ?? ''));
return { content: [{ type: 'text', text: asText(output) }] };
}
catch (error) {
return {
content: [{ type: 'text', text: errorText(error, 'the tool failed') }],
isError: true,
};
}
});
return server;
}
async invoke(turn, actor, name, args, requestId) {
const spec = this.turns.registry.spec(name);
if (spec === undefined || LOOP_SERVED_KINDS.has(spec.kind))
throw new ToolNotFoundError(name);
const allowed = this.turns.allowedTools(turn.input);
if (allowed !== undefined && !allowed.includes(name))
throw new ToolNotFoundError(name);
if (spec.kind === 'action' &&
!(await this.turns.spendApproval(turn.runId, turn.ctx.threadId, name))) {
throw new RefusedError(`"${name}" is an action and nobody approved this call; ask for the permission first.`);
}
const deps = this.turns.depsFor(turn.input);
const ctx = {
actor,
threadId: turn.ctx.threadId,
runId: turn.runId,
requestId: `mcp:${turn.runId}:${requestId}`,
emitUi: turn.ctx.emitUi,
...(turn.ctx.agentName !== undefined ? { agentName: turn.ctx.agentName } : {}),
...(turn.input.pageContext !== undefined ? { pageContext: turn.input.pageContext } : {}),
};
const invoke = () => this.turns.registry.invoke(name, args ?? {}, ctx, deps.rolesPolicy, allowed !== undefined ? { allowedTools: allowed } : {});
return this.genui.track({
store: deps.store,
marker: spec.genui,
runId: turn.runId,
threadId: turn.ctx.threadId,
actorRef: actor.id,
...(turn.ctx.agentName !== undefined ? { agentName: turn.ctx.agentName } : {}),
channel: turnChannel(turn.input.pageContext),
modelId: turn.input.model ?? deps.modelId ?? 'opencode',
call: { id: `mcp:${turn.runId}:${requestId}`, name, input: args ?? {} },
}, invoke);
}
/** `remember`: one fact, at the actor's own scope only — as the loop serves it. */
async remember(turn, args) {
const memory = this.turns.depsFor(turn.input).memory;
if (memory === undefined)
throw new ToolNotFoundError(REMEMBER_TOOL_NAME);
const parsed = await rememberInputSchema['~standard'].validate(args ?? {});
if (parsed.issues !== undefined) {
throw new RefusedError(`remember: ${parsed.issues.map((issue) => issue.message).join('; ')}`);
}
const ctx = {
actor: turn.input.actor,
threadId: turn.ctx.threadId,
...(turn.ctx.agentName !== undefined ? { agentName: turn.ctx.agentName } : {}),
};
const scopes = await (memory.scopes ?? defaultScopeResolver).resolve(ctx);
const outcome = await writeMemory({
config: memory,
digest: { scopes, entries: [], omitted: 0, pinnedOmitted: 0 },
call: parsed.value,
ctx,
runId: turn.runId,
});
if (!outcome.ok)
return `Not recorded: ${outcome.error}`;
return `Recorded "${outcome.record.key}".`;
}
}
//# sourceMappingURL=mcp.js.map