@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
359 lines • 18.6 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { personalAgentGate } from '../src/a2a/gate.js';
import { DefaultToolAuthorizer } from '../src/authorizer.js';
import { actorFromAuthInfo } from '../src/mcp/actor.js';
import { McpAuthError, resolveMcpAuth } from '../src/mcp/auth.js';
import { mcpResourceUrl, normalizeMcpPath, protectedResourceMetadata, protectedResourceMetadataUrl, publicOrigin, wwwAuthenticateChallenge, } from '../src/mcp/discovery.js';
import { registerOAuthResource } from '../src/mcp/oauth-resource.js';
import { missingScopes } from '../src/mcp/scope.js';
import { createMcpServer, isExposable } from '../src/mcp/server.js';
import { ToolRegistry } from '../src/tool-registry.js';
/**
* Wires the agent's {@link ToolRegistry} to the outside world over the Model Context Protocol
* (Streamable HTTP). Reads `config/mcp.ts` and mounts:
*
* - `POST|GET|DELETE {path}` — the MCP endpoint (initialize/session resume, SSE stream, close).
* - `GET /.well-known/oauth-protected-resource{path}` — RFC 9728 protected-resource metadata, mounted
* only when `auth.oauth` is set, so MCP clients can discover how to authenticate.
*
* …once for the top-level config and once per entry of `endpoints`: each is a protected resource of
* its own, with its registry, auth, sessions and metadata.
*
* A refused request answers `401` (or `403` for an {@link McpAuthError} with that status) with a
* `WWW-Authenticate: Bearer …` challenge; with OAuth it carries `resource_metadata`, which is what makes
* an MCP client discover the authorization server and open the login. URLs are built from
* `config.publicUrl` when set, else from the request's protocol and `Host`.
*
* Every request is authenticated: the bearer token is verified via `config.auth` (e.g. `authKitAuth()`),
* the resolved actor attached to the transport's `AuthInfo`, and `tools/list` / `tools/call` run against
* the SAME `ToolRegistry` singleton the agent loop uses — so a role-checked tool stays role-checked, and
* a fail-closed gate stays fail-closed, no matter the surface.
*/
export default class McpProvider {
app;
/** Every mounted endpoint — the top-level one first. */
#endpoints = [];
/** `config.publicUrl` reduced to its origin; `undefined` → derive it from each request. */
#publicOrigin;
constructor(app) {
this.app = app;
}
async boot() {
const config = this.app.config.get('mcp', {});
const defaultRoles = config.defaultRoles ?? ['ADMIN'];
// Personal agents (a Poppy session's token, see `authKitAuth`) reach only the tools of the scopes
// their user granted — the same gate as the agent loop. A no-op for every other actor.
const authorizer = personalAgentGate(config.authorizer ??
new DefaultToolAuthorizer(defaultRoles, { emptyRoles: config.emptyRoles ?? 'allow' }));
const ctx = { app: this.app };
const auth = config.auth !== undefined ? await resolveMcpAuth(config.auth, ctx) : undefined;
this.#publicOrigin =
config.publicUrl !== undefined ? publicOrigin(config.publicUrl) : undefined;
const base = {
path: normalizeMcpPath(config.path),
name: config.name,
version: config.version,
...(config.instructions !== undefined ? { instructions: config.instructions } : {}),
registry: await this.app.container.make(ToolRegistry),
authorizer,
auth,
// The actor fallback for open/dev mode (no `auth`): a fixed identity, or fail-closed (reject).
openActor: config.actor,
...(config.allowedTools !== undefined ? { allowedTools: config.allowedTools } : {}),
...(config.actions !== undefined ? { actions: config.actions } : {}),
...(config.describeTool !== undefined ? { describeTool: config.describeTool } : {}),
stateless: config.stateless === true,
metadataRouteName: 'mcp.oauth_protected_resource',
transports: new Map(),
};
this.#endpoints.push(base);
for (const extra of config.endpoints ?? []) {
const path = normalizeMcpPath(extra.path);
this.#endpoints.push({
...base,
path,
name: extra.name ?? base.name,
...(extra.instructions !== undefined ? { instructions: extra.instructions } : {}),
registry: extra.registry ? await extra.registry(ctx) : base.registry,
auth: extra.auth !== undefined ? await resolveMcpAuth(extra.auth, ctx) : base.auth,
...(extra.allowedTools !== undefined ? { allowedTools: extra.allowedTools } : {}),
...(extra.actions !== undefined ? { actions: extra.actions } : {}),
...(extra.describeTool !== undefined ? { describeTool: extra.describeTool } : {}),
metadataRouteName: extra.metadataRouteName ?? `mcp.${path.split('/').join('.')}.oauth_protected_resource`,
transports: new Map(),
});
}
const router = await this.app.container.make('router');
const middleware = config.middleware ?? [];
for (const endpoint of this.#endpoints) {
const route = `/${endpoint.path}`;
const routes = [
router.post(route, (c) => this.#handlePost(c, endpoint)),
router.get(route, (c) => endpoint.stateless ? this.#methodNotAllowed(c, endpoint) : this.#handleGet(c, endpoint)),
router.delete(route, (c) => endpoint.stateless
? this.#methodNotAllowed(c, endpoint)
: this.#handleDelete(c, endpoint)),
];
if (middleware.length > 0)
for (const r of routes)
r.use(middleware);
const oauth = endpoint.auth?.oauth;
if (oauth) {
// Announce this server to the authorization server in the same process (authkit's `mcp`):
// tokens it issues for this URL are then bound to it, with no URL repeated in its config.
registerOAuthResource(this.#publicOrigin !== undefined
? { url: mcpResourceUrl(this.#publicOrigin, endpoint.path) }
: { path: `/${endpoint.path}` });
router
.get(`/.well-known/oauth-protected-resource/${endpoint.path}`, async (c) => {
// `oauth` may be lazy (authkit needs a fully booted app to resolve) — resolve it on request.
const meta = typeof oauth === 'function' ? await oauth() : oauth;
// Public and user-free; MCP clients running in a browser fetch it cross-origin.
c.response.header('access-control-allow-origin', '*');
return c.response.json(protectedResourceMetadata(meta, this.#origin(c), endpoint.path));
})
.as(endpoint.metadataRouteName);
}
}
}
async shutdown() {
const transports = this.#endpoints.flatMap((endpoint) => [...endpoint.transports.values()]);
await Promise.all(transports.map((transport) => transport.close()));
for (const endpoint of this.#endpoints)
endpoint.transports.clear();
}
// ── auth helpers ──────────────────────────────────────────────────────────
/** The public origin: `config.publicUrl` when set, else the request's own protocol and `Host`. */
#origin(ctx) {
return (this.#publicOrigin ??
`${ctx.request.protocol()}://${ctx.request.headers().host ?? 'localhost'}`);
}
/**
* Refuse the request with `status` and the RFC 6750 / RFC 9728 challenge. `hadToken` picks the error
* code (no token → none, per RFC 6750 §3.1); `resource_metadata` is added when the auth exposes OAuth.
* With no auth (the fail-closed open-mode path) it is still a bare `Bearer` challenge.
*/
#refuse(ctx, endpoint, status, message, hadToken, scope, error) {
const challenge = wwwAuthenticateChallenge({
...(error !== undefined
? { error }
: status === 403
? { error: 'insufficient_scope', ...(scope !== undefined ? { scope } : {}) }
: hadToken
? { error: 'invalid_token' }
: {}),
...(endpoint.auth?.oauth !== undefined
? { resourceMetadataUrl: protectedResourceMetadataUrl(this.#origin(ctx), endpoint.path) }
: {}),
});
ctx.response.header('WWW-Authenticate', challenge);
ctx.response.status(status).json({ error: message });
return null;
}
/**
* Verify the request's `Authorization: Bearer` token against the endpoint's auth, or fall back to
* the open-mode `actor`. Returns the `AuthInfo` to attach to the transport, or `null` once it has
* replied 401/403.
*/
async #authenticate(ctx, endpoint) {
const header = ctx.request.header('authorization');
const token = /^Bearer\s/i.test(header ?? '') ? header?.slice(7).trim() : undefined;
const auth = endpoint.auth;
if (auth !== undefined) {
if (!token) {
return this.#refuse(ctx, endpoint, 401, 'missing bearer token', false);
}
try {
return await auth.verify(token, {
resource: mcpResourceUrl(this.#origin(ctx), endpoint.path),
});
}
catch (error) {
const status = error instanceof McpAuthError ? error.status : 401;
const message = error instanceof Error ? error.message : 'unauthorized';
const code = error instanceof McpAuthError ? error.error : undefined;
return this.#refuse(ctx, endpoint, status, message, true, undefined, code);
}
}
if (endpoint.openActor !== undefined) {
return { token: '', clientId: '', scopes: [], extra: { actor: endpoint.openActor } };
}
return this.#refuse(ctx, endpoint, 401, 'unauthorized: no auth configured and no fallback actor', false);
}
/**
* A personal agent calling a tool it is offered but whose scope its user did not grant: `403` with
* `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` (RFC 6750 §3.1, the MCP scope
* step-up, Poppy §4.3) instead of a tool error — the client asks the user for that scope. Returns
* `true` once it has replied.
*/
async #refuseMissingScope(ctx, endpoint, authInfo, body) {
const messages = Array.isArray(body) ? body : [body];
const actor = authInfo.extra.actor;
for (const message of messages) {
const call = message;
if (call?.method !== 'tools/call' || typeof call.params?.name !== 'string')
continue;
const spec = endpoint.registry.spec(call.params.name);
// Only a tool this server serves: anything else stays the server's "unknown tool".
if (!isExposable(spec, endpoint.actions ?? 'refuse') || spec === undefined)
continue;
if (endpoint.allowedTools !== undefined && !endpoint.allowedTools.includes(spec.name))
continue;
const missing = await missingScopes(actor, spec, endpoint.authorizer);
if (missing !== null) {
this.#refuse(ctx, endpoint, 403, `insufficient_scope: "${spec.name}" needs the scope "${missing.join(' ')}"`, true, missing.join(' '));
return true;
}
}
return false;
}
// ── route handlers ────────────────────────────────────────────────────────
#newServer(endpoint) {
return createMcpServer({
name: endpoint.name,
version: endpoint.version,
...(endpoint.instructions !== undefined ? { instructions: endpoint.instructions } : {}),
registry: endpoint.registry,
policy: endpoint.authorizer,
...(endpoint.allowedTools !== undefined ? { allowedTools: endpoint.allowedTools } : {}),
...(endpoint.actions !== undefined ? { actions: endpoint.actions } : {}),
...(endpoint.describeTool !== undefined ? { describeTool: endpoint.describeTool } : {}),
actorFromAuth: actorFromAuthInfo,
});
}
async #handlePost(ctx, endpoint) {
const req = ctx.request.request;
const res = ctx.response.response;
const body = ctx.request.all();
const sessionId = ctx.request.header('mcp-session-id');
const authInfo = await this.#authenticate(ctx, endpoint);
if (authInfo === null)
return;
req.auth = authInfo;
if (await this.#refuseMissingScope(ctx, endpoint, authInfo, body))
return;
try {
let transport;
if (endpoint.stateless) {
// No session: this request gets its own transport and server, closed once answered — and a
// plain JSON answer, since there is no stream to keep open.
const stateless = new StreamableHTTPServerTransport({ enableJsonResponse: true });
const server = this.#newServer(endpoint);
res.on('close', () => {
void stateless.close();
void server.close();
});
await server.connect(stateless);
await stateless.handleRequest(req, res, body);
}
else if (sessionId && endpoint.transports.has(sessionId)) {
// Session resume — reuse the existing transport (already connected to its own server).
transport = endpoint.transports.get(sessionId);
await transport.handleRequest(req, res, body);
}
else if (!sessionId && isInitializeRequest(body)) {
// New session — create transport + server, connect, then handle so responses flow through it.
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
if (transport)
endpoint.transports.set(sid, transport);
},
});
transport.onclose = () => {
const sid = transport?.sessionId;
if (sid && endpoint.transports.get(sid) === transport) {
endpoint.transports.delete(sid);
}
};
const server = this.#newServer(endpoint);
await server.connect(transport);
await transport.handleRequest(req, res, body);
}
else {
res.statusCode = 400;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: no valid session ID' },
id: null,
}));
}
}
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: error instanceof Error ? error.message : 'Internal error',
},
id: null,
}));
}
}
await this.#awaitFinish(res);
}
async #handleGet(ctx, endpoint) {
const req = ctx.request.request;
const res = ctx.response.response;
const sessionId = ctx.request.header('mcp-session-id');
const authInfo = await this.#authenticate(ctx, endpoint);
if (authInfo === null)
return;
req.auth = authInfo;
const transport = sessionId ? endpoint.transports.get(sessionId) : undefined;
if (transport === undefined) {
res.statusCode = 400;
res.end('Invalid or missing session ID');
await this.#awaitFinish(res);
return;
}
await transport.handleRequest(req, res);
await this.#awaitFinish(res);
}
async #handleDelete(ctx, endpoint) {
const req = ctx.request.request;
const res = ctx.response.response;
const sessionId = ctx.request.header('mcp-session-id');
const authInfo = await this.#authenticate(ctx, endpoint);
if (authInfo === null)
return;
req.auth = authInfo;
const transport = sessionId ? endpoint.transports.get(sessionId) : undefined;
if (transport === undefined) {
res.statusCode = 400;
res.end('Invalid or missing session ID');
await this.#awaitFinish(res);
return;
}
await transport.handleRequest(req, res);
await this.#awaitFinish(res);
}
/**
* Stateless mode has no SSE stream to open and no session to close (MCP Streamable HTTP §2.2).
* Authenticated first: an unauthenticated caller gets the 401 that starts its login, not a 405.
*/
async #methodNotAllowed(ctx, endpoint) {
if ((await this.#authenticate(ctx, endpoint)) === null)
return;
ctx.response.header('allow', 'POST');
return ctx.response.status(405).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed: this MCP server is stateless.' },
id: null,
});
}
/** Resolve only when the raw response is finished so Adonis doesn't try to handle the response again. */
#awaitFinish(res) {
return new Promise((resolve) => {
res.on('finish', () => resolve());
res.on('close', () => resolve());
});
}
}
//# sourceMappingURL=mcp_provider.js.map