Models get the tools. Never the keys.

An MCP server that exposes internal databases and APIs to frontier models through delegated OAuth 2.1 with mandatory PKCE. Tool discovery is filtered by grants, credentials stay with a server-side broker, and every call is written to an append-only audit trail.

Credentials stay below the secret boundary. Nothing above it can read them.
customer.lookup read:customers invoice.search read:billing invoice.summary read:billing ticket.create write:support ticket.update_status write:support ticket.get read:support customer.open_invoices read:billing audit.self read:audit

The numbers a security reviewer asks for first.

last 24 h · tenant demo-internal

Secrets exposed to a model
0
  • tool schemas0
  • tool arguments0
  • responses0
  • audit rows0

The broker hands out clients, not credentials.

Invocations, allow vs deny
18,204
allow 17,961deny 243
Active MCP sessions
37 subjects

One session per subject. A revoked grant sends list_changed right away.

Gate p95
4.2 ms

Token verify plus RBAC decision.

Five layers, each with one job and one thing it must never do.

Hover or focus a layer to open it.

Issues tokens. Never verifies them for MCP.

Authorization code flow with mandatory PKCE, EdDSA-signed JWTs, rotating refresh tokens, discovery metadata and JWKS.

grant
authorization_code + S256
alg
EdDSA
refresh
rotating, reuse = revoke family

Verifies bearers. Never issues one.

RFC 9728 protected-resource metadata, a correct WWW-Authenticate challenge, and a two-part decision: the token scope and the role grant must both allow the tool.

decision
scope ∧ grant
on fail
403 + audit row

Filters discovery. Never runs SQL.

A model only sees the tools its subject is granted. When a grant changes, connected sessions get list_changed live.

visible
granted tools only
schemas
zod, strict

Holds secrets. Never returns one.

Mints short-lived upstream access and returns a client. Named queries come from an allowlist; the model never writes SQL.

returns
client, never credential
sql
allowlisted named queries

Appends. Never updates, never deletes.

One redacted row per invocation, allow or deny. Append-only is enforced by triggers in the database, not by convention.

rows
1 per call, allow + deny
payload
redacted at write
audit row 40,221 · deny Arguments are redacted before the row is written. The raw values never reach the database.

Invariants the verification pass proves, not promises.

No credential in any tool schema, argument or response.

Checked by a property test over every registered tool definition.

src/server/credentials/

An unauthenticated request gets a 401 with a correct challenge.

It points the client to the protected-resource metadata, so it can discover the authorization server on its own.

src/server/auth/gate/

A tool that isn't granted is invisible, not just forbidden.

Discovery and invocation run the same policy function, so the two can't disagree.

src/server/tools/

The audit table rejects UPDATE and DELETE at the database.

Proven through the UI: a revoked call still shows up, with its payload redacted.

src/server/audit/

Tool catalogue

8 tools · 2 upstreams · 4 roles

Tools exposed through the gateway, their scope, upstream, granted roles and 24 hour call volume
ToolScopeUpstreamGranted toCalls 24hDeny
customer.lookupread:customerslegacy_dbanalystsupportadmin6,2120.4%
invoice.searchread:billinglegacy_dbanalystadmin4,9071.1%
ticket.createwrite:supportinternal_apisupportadmin2,3183.9%
ticket.update_statuswrite:supportinternal_apisupport1,7762.2%
invoice.summaryread:billinglegacy_dbanalystfinance2,9910.2%

Hand your model a gateway, not a keyring.

mcp.json
{
  "mcpServers": {
    "internal-tools": {
      "url": "https://gateway.local/mcp",
      "auth": "oauth"
    }
  }
}