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.
Build the map from the document
Section titled “Build the map from the document”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.
What it refuses
Section titled “What it refuses”- 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
operationIdleaves an entry behind that stops gating anything. - A permission no role grants, through the same reconciliation the MCP entry points run.
The filtered document
Section titled “The filtered document”/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.