Skip to content

OpenAPI mode

An OpenAPI document is the other catalogue an agent reads. A tool that is never registered is a tool the model never sees; an operation that is never in the served document is an operation the agent never calls. mcp-authz/openapi applies the same policy, the same boot-time checks and the same audit events to an HTTP API, keyed on operationId instead of a tool name.

import { createOpenApiFetch, recordOperations } from 'mcp-authz/openapi';
export default createOpenApiFetch({
spec,
permissions: PERMISSIONS, // { getCase: 'cases:read', deleteCase: 'cases:delete', … }
resourceServerUrl: new URL(process.env.API_PUBLIC_URL!),
oauthMetadata,
policy,
emitter: 'cases-api',
onAudit: (event) => logger.info(event),
upstream: (request) => app.fetch(request), // your API, unchanged
});

Dana fetches /openapi.json and gets three read operations. Alice fetches the same path and gets the write and the delete too. Dana calling DELETE /cases/C1234 anyway is refused with the permission she lacks — the smaller document was a context saving, never the boundary.

recordOperations(spec) needs the document and nothing else — no running server, no introspection:

import { recordOperations } from 'mcp-authz/openapi';
import { toPermissionsModule } from 'mcp-authz/testing';
writeFileSync('permissions.ts', toPermissionsModule(recordOperations(spec)));

Every operation is priced TODO:unassigned, which no role grants, so the boot refuses until a person has decided what each one costs.

An operation with no operationId is refused rather than named get /cases/{id} for you: a generated fallback is a second naming scheme that only some operations use, and it changes under a path rename, so the map silently stops matching the route it was written for.

  • A route the document does not describe, with a 404 that says so. A capability nobody priced is reachable by everyone or by nobody, with no error to read. Routes you deliberately keep out of the spec — a health check, static files — belong outside this wrapper rather than behind it.
  • An operation the map does not price, at construction.
  • A map entry naming an operation the document no longer has, at construction, because a renamed operationId leaves an entry behind that stops gating anything.
  • A permission no role grants, through the same reconciliation the MCP entry points run.

/openapi.json (configurable through specPath) is served per caller, authenticated: it is a different document per person, and an anonymous one would have to be the whole thing. A path item left with no operations is dropped; components is left whole, because pruning it means walking the $ref graph and a wrongly-pruned schema breaks the document where an unused one costs a few hundred tokens.

Events are the ones described in Audit and decisions, with kind: 'operation', the operationId as name and the concrete path as resource. The phase follows the response status rather than whether a promise rejected — what an auditor is asking is whether the action happened, and over HTTP that is the status.