All articles
2026-04-22Security8 min

The MCPG security model

How identity, policy, capabilities, signing, and audit fit together — and why each piece exists.

Security in MCPG is layered. Each layer has a specific job; together they form the production-readiness floor. This article walks through each layer, what it does, and why it exists.

Layer 1: Identity

Every request resolves to a Caller before anything else happens. The five identity plugins (api-key, basic, mtls, oidc, workload) all produce the same Caller shape:

rust
struct Caller {
    subject: String,        // "user:alice@acme.com" or "spiffe://acme.org/sa/cron"
    audience: Option<String>,
    attributes: HashMap<String, Value>,
    auth_method: AuthMethod,  // ApiKey | Basic | Mtls | Oidc | Workload
}

Why this matters: every downstream layer sees the same struct. Policy plugins don't care whether the caller authenticated via OIDC or mTLS — they just see subject: "user:alice@acme.com" and decide.

Failure modes guarded against:

  • SSRF on JWKS URLs (OIDC plugin uses an SSRF-safe HTTP client)
  • DNS rebinding to internal hosts (gateway-wide DNS guard)
  • Replay attacks (JWT iat/exp validated; bearer tokens checked against a TTL'd cache)
  • Header injection from upstream proxies (mTLS plugin requires trusted-proxy CIDR list; rejects header from anywhere else)

Layer 2: Policy

Policy plugins receive Caller + Request + Resource and return Decision:

rust
enum Decision {
    Permit,
    PermitWithObligations(Vec<Obligation>),
    Deny(DenyReason),
}

Three engines: Cedar, OPA, Casbin. They compose — chain them and every engine must permit. Obligations stack: Cedar might require redacting email; OPA might require redacting phone; both happen.

Why three engines: teams have different opinions about policy languages, and migration between engines is painful. We support all three so you can pick what your team already knows.

Per-tool policy is the killer feature. You can write:

cedar
permit (
  principal,
  action == Action::"github.delete_repo",
  resource
)
when { context.approval_token != null };

…and chain a tool-gate-slack-approval plugin that intercepts before dispatch, fires a Slack interactive message, blocks the call until a human clicks "approve", then sets context.approval_token. No code changes needed.

Layer 3: Capabilities

A plugin declares the host capabilities it needs in its manifest; the operator grants them per-entry in the gateway config. The grant lives directly on the plugin entry as granted_capabilities: — there is no separate wiring block:

yaml
plugins:
  - id: redact-emails
    class: transform
    source:
      oci: "ghcr.io/mcpg-dev/source-code/plugins/redact-emails:1.0.0"
    granted_capabilities:
      - type: network_outbound
      - type: metric_emit
      - type: secrets_read
        schemes: ["vault"]      # path/scheme-scoped grants carry their args inline

The gateway refuses to start if a plugin requires a capability that isn't in its granted_capabilities. Grants are subset-checked: the operator's paths / schemes / kinds must be a superset of what the plugin asks for.

The capability set (each is a type: discriminant under granted_capabilities):

  • network_outbound — outbound HTTP and raw TCP (collapses the old outbound_http / outbound_network pair); the transport-level host/port allowlist still applies
  • filesystem_read / filesystem_write — path-scoped (paths: [...])
  • secrets_read — resolve secret URIs of named schemes: (vault, env, file, …)
  • credential_issue — issue credentials of named kinds:
  • config_read — resolve config URIs of named schemes:
  • audit_write — emit audit events to the host pipeline
  • metric_emit — emit metric points beyond the per-plugin observability triad
  • cluster_peer_read / cluster_leadership_acquire / cluster_lock_acquire
  • http_route_serve — serve HTTP routes (implicit for class: http_route)
  • transport_listen — run a long-lived listener thread, incl. TLS / mTLS termination (implicit for class: transport)
  • unbounded_subscriptions — lift the per-plugin subscription cap

Capabilities are enforced by the loader. WASM plugins are sandboxed at the runtime level (no syscalls except those exposed by the host). Native (cdylib) plugins are constrained by Rust's lack of unsafe escape hatches in the SDK API surface — but we trust them more than WASM, so you should sign and pin native plugin sources.

Layer 4: Plugin signing

Every plugin artifact is Ed25519-signed. Trust is declared per plugin entry — each entry carries its own signature: block with the verification policy and the trusted public keys that artifact must verify against. There is no global trust pool, so plugins from different vendors carry different keys without cross-trust:

yaml
plugins:
  - id: dev.mcpg.rate-limit
    class: tool_gate
    source:
      oci: "ghcr.io/mcpg-dev/source-code/plugins/rate-limit:1.0.0"
    signature:
      policy: enforce          # disabled | warn | enforce — refuse to load on a bad/missing sig
      sha256: "ab12…"          # optional content-hash pin
      trusted_keys:
        - id: prod-signing-key
          pem: |
            -----BEGIN PUBLIC KEY-----
            MCowBQYDK2VwAyEA...
            -----END PUBLIC KEY-----

The gateway-wide default lives at gateway.plugin_registry.default_signature_policy: (ships as warn for first-rollout safety; flip to enforce once keys are wired across all entries). Sign and push artifacts with the mcpg plugin CLI extension:

lua
mcpg plugin sign <artifact> --key prod-signing-key.pem
mcpg plugin push <artifact> ghcr.io/acme/mcpg-plugins/redact-emails:1.0.0

A separate MCPGRevocationList CRD lets operators block specific artifact hashes fleet-wide without redeploying. The gateway can also load a local revocation list via gateway.plugin_registry.revocation_list_path:; a matching SHA-256 is refused even when its Ed25519 signature is valid.

Layer 5: Audit ledger

Every policy decision, every plugin lifecycle event, every operator action writes an audit row. The ledger is:

  • Per-org chained — each row references the previous row's hash via BLAKE3
  • Ed25519-signed — chain head signed periodically
  • Retention-bounded — Community 30d, Pro 90d, Team 180d, Enterprise 7 years
  • Tamper-evident — modifying any row breaks the chain; tampering is detectable

The audit chain is per-org because cross-tenant tampering would be trivially detectable across orgs (you'd have to forge multiple signatures simultaneously).

Layer 6: Transport

Inbound transport is Streamable HTTP + SSE — the MCP spec's transport. TLS is terminated at the gateway listener directly, or by an upstream proxy with mTLS headers re-injected. Listener TLS lives under gateway.server.tls:

yaml
gateway:
  server:
    bind_address: "0.0.0.0:8787"
    allowed_origins: ["https://gateway.example.com"]   # exhaustive; wildcard is rejected
    tls:
      cert_path: "/etc/mcpg/certs/server.crt"
      key_path: "/etc/mcpg/certs/server.key"

Outbound transport varies by backend kind (HTTP, gRPC, NATS, …). Outbound TLS validates against the system trust store. The gateway's SSRF / DNS-rebinding guard applies to every outbound call — private-range and loopback targets are refused unless an operator explicitly opts in via the upstream's upstream_safety posture — so a compromised tool descriptor can't pivot the gateway into your internal network. Tightening allowed_origins to an exhaustive list (no wildcard) closes the inbound CORS surface for the same reason.

Layer 7: Secret resolution

Secrets never live in config files. The gateway resolves them at runtime through scheme-prefixed URIs handled by secret_provider / credential_issuer plugins — placed wherever a value would otherwise be hard-coded (a binding header, a backend field). The well-known forms:

  • ${env.VAR} — process environment, resolved once at config-load.
  • vault://<path>#<field> — HashiCorp Vault, handled by the vault secret_provider plugin (provides_schemes: ["vault"]).
  • cred://<plugin_id>/<target>[#part] — per-request credential lookup from a credential_issuer plugin: outbound OAuth tokens (cred://dev.mcpg.credential.oauth-client-credentials/notion), Vault dynamic DB creds (cred://vault-dynamic-db/orders#username), and so on.

So a binding header reads:

yaml
mcp:
  capabilities:
    tools:
      - name: account.lookup
        description: Look up an account by id.
        backend:
          kind: http
          url: "https://accounts.internal/v1/accounts/${arguments.account_id}"
          method: get
          headers:
            Authorization: "Bearer ${env.ACCOUNTS_API_TOKEN}"   # or cred:// / vault://

${env.X} is resolved once at config-load; cred:// and vault:// resolve per request, so a token rotation reaches in-flight calls on the next dispatch without a reload. cred:// only resolves from config-origin positions — a request argument can't smuggle one in. Vault lease auto-renewal (dynamic DB credentials), native event watch (Vault 1.13+), and TTL-bounded caching are all built in.

What's not yet a layer

A few things are on the roadmap, not in production:

  • Source-side payload encryption — currently CP encrypts payloads at rest; a planned change moves encryption to the gateway, so the CP never sees plaintext.
  • Gateway-side quota refusal — currently CP drops over-quota batches at ingest; pre-dispatch refusal is planned.
  • WASM cp-client plugin path — currently cp-client is in-process Rust under a Cargo feature; shipping it as a plugin is planned.