The governance gap
Teams adopt MCP for speed. Governance usually arrives later. Attack paths do not wait for the second phase.
The most common mistake is treating MCP security as a transport checklist only. Transport controls matter, but they do not decide whether a specific tool action should execute right now.
Problem framing
MCP security asks "who can connect?" Governance asks "what can run, under which constraints, with which evidence?"
What top sources cover vs miss
| Source | Strong coverage | Missing piece |
|---|---|---|
| MCP Security Best Practices (official) | Protocol-level hardening: token handling, redirect/SSRF protection, and authorization boundaries. | No full governance control-plane pattern for policy, approval, and post-call output handling. |
| MCP Authorization Tutorial (official) | OAuth-centered auth flow and implementation sequence for secure MCP access. | No risk-tier action model for deciding which calls should require human approval. |
| OWASP Third-Party MCP Server Guide | Threat classes like tool poisoning, tool interference, and server discovery controls. | Limited operational SLO guidance for approval queue latency, policy latency, and incident containment. |
Reference architecture
| Component | Responsibility | Failure mode if missing |
|---|---|---|
| MCP Gateway | Normalizes tool calls into a policy envelope and enforces hard blocks. | Clients call servers directly, bypassing centralized governance. |
| Policy Engine (PDP) | Returns ALLOW, DENY, REQUIRE_APPROVAL, or ALLOW_WITH_CONSTRAINTS. | Safety decisions fall back to prompt instructions and drift quickly. |
| Approval Service | Queues high-risk requests and binds approvals to policy snapshots. | Write actions run without explicit human accountability. |
| Output Safety Stage | Scans tool output for secrets/PII before model ingestion. | Approved reads can still leak sensitive data in model responses. |
| Audit Store | Stores immutable decision and execution evidence per call. | Post-incident forensics become guesswork. |
| Server Registry | Maintains approved server identities, versions, and ownership. | Shadow server adoption quietly expands attack surface. |
Decision model for tool calls
| Decision | Use case | Operational rule |
|---|---|---|
| ALLOW | Read-only calls to approved servers with valid scopes. | Execute immediately and log decision evidence. |
| DENY | Unknown servers, scope mismatch, policy violations. | Return deterministic block reason to caller. |
| REQUIRE_APPROVAL | Production writes, destructive actions, cross-tenant impact. | Hold request in approval queue with timeout behavior. |
| ALLOW_WITH_CONSTRAINTS | Medium-risk actions that are allowed with bounded runtime. | Enforce max runtime, retries, and network allowlist. |
Implementation examples
Policy should be reviewable like code. Gateway behavior should be deterministic like infrastructure.
version: v1
rules:
- id: allow-read-approved-servers
match:
labels:
mcp.action: read
mcp.server: ["github", "jira", "snowflake", "slack"]
decision: ALLOW
- id: require-approval-production-write
match:
labels:
mcp.action: write
risk_tags: ["prod"]
decision: REQUIRE_APPROVAL
constraints:
max_runtime_sec: 60
max_retries: 1
- id: constrain-medium-risk
match:
labels:
mcp.action: write
risk_tags: ["non_prod"]
decision: ALLOW_WITH_CONSTRAINTS
constraints:
max_runtime_sec: 30
network_allowlist: ["api.github.com", "api.slack.com"]
- id: deny-unregistered-server
match:
labels:
mcp.server: "*"
mcp:
deny_servers_not_in: ["github", "jira", "snowflake", "slack"]
decision: DENY// Pre-dispatch governance wrapper for MCP tool invocations
func HandleToolCall(call ToolCall) (DecisionResult, error) {
envelope := NormalizeToPolicyEnvelope(call)
decision, err := policyClient.Decide(envelope)
if err != nil {
return DecisionResult{}, err
}
switch decision.Type {
case "DENY":
audit.Log(call, decision, "blocked")
return DecisionResult{Status: "blocked", Reason: decision.Reason}, nil
case "REQUIRE_APPROVAL":
reqID := approvals.Enqueue(call, decision)
audit.Log(call, decision, "pending_approval")
return DecisionResult{Status: "pending_approval", ApprovalID: reqID}, nil
case "ALLOW_WITH_CONSTRAINTS":
constrained := ApplyConstraints(call, decision.Constraints)
output := Execute(constrained)
safe := outputSafety.Filter(output)
audit.Log(call, decision, "executed_with_constraints")
return DecisionResult{Status: "executed", Output: safe}, nil
default: // ALLOW
output := Execute(call)
safe := outputSafety.Filter(output)
audit.Log(call, decision, "executed")
return DecisionResult{Status: "executed", Output: safe}, nil
}
}Rollout and ops gates
Governance maturity is measurable. If your controls cannot be measured, they are still in prototype mode.
| Gate | Target | Block condition | Owner |
|---|---|---|---|
| Policy decision p95 latency | <= 50ms | > 150ms for 15m | Platform |
| Unapproved high-risk writes | 0 | > 0 immediate stop | Governance |
| Approval queue median wait | <= 10m | > 20m for 30m | Ops Lead |
| Unknown server connections | 0 | > 0 immediate stop | Security |
| Output QUARANTINE ratio | < 1% | > 3% for 15m | Safety Team |
Limitations and tradeoffs
More upfront platform work
Centralized governance adds architecture complexity before it reduces incident complexity.
Approval latency in high-risk flows
Human review protects systems but can delay sensitive write operations.
Output safety tuning is continuous
Classification thresholds need iterative calibration to reduce noise and misses.