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:
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/expvalidated; 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:
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:
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:
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 oldoutbound_http/outbound_networkpair); the transport-level host/port allowlist still appliesfilesystem_read/filesystem_write— path-scoped (paths: [...])secrets_read— resolve secret URIs of namedschemes:(vault,env,file, …)credential_issue— issue credentials of namedkinds:config_read— resolve config URIs of namedschemes:audit_write— emit audit events to the host pipelinemetric_emit— emit metric points beyond the per-plugin observability triadcluster_peer_read/cluster_leadership_acquire/cluster_lock_acquirehttp_route_serve— serve HTTP routes (implicit forclass: http_route)transport_listen— run a long-lived listener thread, incl. TLS / mTLS termination (implicit forclass: 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:
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:
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:
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 vaultsecret_providerplugin (provides_schemes: ["vault"]).cred://<plugin_id>/<target>[#part]— per-request credential lookup from acredential_issuerplugin: 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:
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.