MCP security
This page lists what the MCP gateway of the Control Plane enforces on every MCP call, how credentials, provenance and suspension are handled, the threats this covers, and the steps a developer and a tenant administrator follow to harden an MCP server. It is written for developers, tenant administrators and security officers.
Checks of the gateway
POST /v1/mcp/gateway/{server_id} runs the checks below in this order, for each message of a batch. A refusal never reaches the server and is audited.
| Order | Check | Refusal |
|---|---|---|
| 1 | Authentication of the caller, OpenFGA relation can_invoke on the server, operations role | HTTP 401 or 403 |
| 2 | Message is JSON-RPC 2.0 | -32600 |
| 3 | Server active | -32003 "MCP server disabled" |
| 4 | Isolation per project (ADR-0165) | -32003 mcp_server_project_mismatch |
| 5 | Access list of the server (allowed_principals; owner and agents for an MCP Runtime instance) | -32003 mcp_principal_not_allowed |
| 6 | Credential bound to an application: the server must be one of its mcp_servers | -32003 with the code of the refusal |
| 7 | Method among initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, ping | -32601 |
| 8 | tools/call: tool in allowed_tools (glob), scope tools:invoke | -32003 |
| 9 | tools/call: DLP of the arguments with the tenant rules; a block rule refuses | -32003 dlp_blocked |
| 10 | Policy decision mcp.call (OPA) with method and target, kill switches of the SOC | HTTP 403 |
| 11 | tools/call: quota reservation of one tool call | HTTP 429 quota_exceeded |
| 12 | resources/read: URI in allowed_resources (glob) | -32003 |
| 13 | Egress: internal address only, checked again at connect time | HTTP 403 mcp_online_forbidden |
| 14 | Answer: tools/list and resources/list filtered by the allow lists; tools/call result masked by the DLP; tools/call and resources/read results screened for prompt injection | -32003 dlp_blocked or prompt_injection_blocked |
| 15 | Usage event (tool call, duration) and audit event mcp.<method> with the target and the DLP findings |
When the Tool Gateway calls an MCP tool (POST /v1/tools/{name}/invoke), it evaluates tool.invoke and meters the
call itself; the MCP gateway then skips its own policy decision, quota and usage event.
Egress: internal servers only
- A server is reached at an internal address only (ADR-0170): an internal host of
EAI_MCP_INTERNAL_HOSTS, a server hosted by the Navin Manager (its instances named inEAI_EGRESS_PRIVATE_ALLOWLIST), or an MCP Runtime instance at its in-cluster proxy address. - The address is checked when the server is registered and again at every call: every address the host resolves
to must be private. A public destination answers 403
mcp_online_forbidden, is audited assecurity.egress.deniedand counted ineai_mcp_online_refused_total. - The egress restriction of the tenant (allowed hosts) still applies.
- The address of an MCP Runtime instance is written by the lifecycle controller, never by a tenant, and must match the in-cluster proxy pattern; the CiliumNetworkPolicy of the instance limits who reaches the proxy and where the MCP container may go.
Credentials through the broker
- Registration takes references only:
credential_refisopenbao://...orenv://...;auth.typeconnectornames a connector whose credentials the Credential Broker holds. - The gateway resolves the secret at call time, server side, and puts it in the header (
bearer,api_key) or in the query parameter (query_param) of the outbound request only. Answers returnauth.type,header,param,credential_refandconnector, never a value. - A server hosted by the Navin Manager gets its own token, kept at
openbao://mcp/hosted/<id>; no answer and no audit event holds it. - An MCP Runtime instance receives a short JWT per call (subject the caller, audience the instance) and its own temporary credentials, revoked when it sleeps or ends. See MCP Runtime per user and project.
Signature and provenance
| Content | Proof |
|---|---|
| Tool schemas | sha256 of description, input and output schemas per tool; each new or changed schema is a catalogue version with an approval (mcp_tool_version) |
| Marketplace packs with MCP servers | Publication gates: signature, SBOM, vulnerability scan, provenance, licence, permissions, tests (Marketplace) |
| Images of the MCP Runtime | Internal registry of EAI_MCP_RUNTIME_IMAGE_REGISTRIES, pinned by digest; Kyverno refuses an MCPServer without digest |
| Presets of the Navin library | sha256 digest of every item in catalog/navin/index.json, checked by python tools/navin_library/sync.py --check |
Quarantine and suspension
| Mechanism | Effect | How |
|---|---|---|
| Disable a server | Calls answer "MCP server disabled" at once; enabling again is a promotion request approved by a second person | POST /v1/mcp/servers/{server_id}/status |
| Suspend a marketplace installation | Its tools blocked, its agents suspended, its MCP servers disabled; resuming needs a four eyes approval | POST /v1/marketplace/installations/{installation_id}/status |
| Kill switch of a package | Every installation of the package suspended | POST /v1/marketplace/packages/{name}/status |
SOC order DISABLE_TOOL on target mcp | Policy decisions touching the server refused, across restarts of every replica | POST /v1/soc/orders (SOC runbook) |
Detection mcp_compromised | Severity sev1, playbook DISABLE_TOOL on the MCP target | SOC detections |
| Hosted server no longer hosted | Disabled at the next sync | POST /v1/mcp/hosted/sync |
| Failing MCP Runtime instance | Destroyed at once, credentials revoked | Lifecycle controller |
Threats
| Threat | Control | Evidence |
|---|---|---|
| Data of a project sent to an online MCP service | Internal addresses only, checked at registration and at every call | Audit security.egress.denied, eai_mcp_online_refused_total |
| Agent of one project calling a server of another project | Isolation per project, refusal before any upstream call | Audit mcp.call with deny and the reason |
| Member of a project using the credentials of another member | Access list per server, one MCP Runtime instance per user with its own credentials and Cedar policy | Audit mcp.call principal_not_allowed |
| Rug pull: a server changes a tool description or schema | New catalogue version with approval at each discovery; allowed_tools enforced by the gateway | new_versions of the discovery, approvals |
| Tool poisoning or prompt injection in a result | Untrusted content screening of tools/call and resources/read results | prompt_injection_blocked |
| Exfiltration of sensitive data in arguments or results | DLP with the tenant rules: arguments inspected (block rules), results masked | dlp field of the audit event |
| Secret leak through the registry | References only; secrets resolved server side at call time | auth of the answers without value |
| Replay of a token on another instance | One audience per instance, short lifetime | EAI_MCP_RUNTIME_TOKEN_TTL_SECONDS |
| Abuse of calls | Quota reservation per tool call, usage events | 429 quota_exceeded, usage |
| Compromised server | Disable, SOC kill switch, marketplace suspension | Kill switch state, audit |
| Tampered image or pack | Digest pinning, signature and provenance gates | Marketplace gates, Kyverno |
Harden a server and its project
-
Keep the allow lists short. Register the server with the exact
allowed_toolsandallowed_resourcesit needs, never*in production.Expected result: GET /v1/mcp/servers/{server_id}/tools lists only those tools.
If it fails: the list cannot be edited in place; delete the server and register it again.
-
Restrict the callers. PUT /v1/mcp/servers/{server_id}/access with the users and agents that need it.
client.mcp.set_access("files-server", ["agent:support-bot", "user:alice"])Expected result: any other caller gets
mcp_principal_not_allowed.If it fails: 409
mcp_instance_access_managedfor an MCP Runtime entry (owner and agents only). -
Attach the server to its project. Register it with
project; a server without a project is shared by the whole tenant.Expected result: the server is not listed from another project.
If it fails: 403
mcp_project_forbiddenwhen the body and the request name different projects. -
Set DLP block rules for the data the tool must never receive. Use the DLP rules of the tenant (GET /v1/dlp/rules, POST /v1/dlp/rules); the gateway applies them to the arguments and the results.
Expected result: a tools/call with a blocked value answers
dlp_blocked.If it fails: test the rule with POST /v1/dlp/inspect.
-
Write a policy for
mcp.call. Deny by default, allow the methods and tools the project needs, test each rule (Policy development).Expected result: an unlisted tool is refused with HTTP 403 by the policy decision.
If it fails: run the policy tests (
enterprise-ai test authz). -
Approve tool versions after a review. Read the schemas of each
pending_approvalversion in GET /v1/mcp/catalog before approving.Expected result: every version in use is
approved.If it fails: reject the version and disable the server until it is fixed.
-
Watch the signals. Follow
eai_mcp_calls_totalby outcome,eai_mcp_online_refused_totaland the audit eventsmcp.*; check the integrity of the audit chain withenterprise-ai audit verify.Expected result: refusals are explained by a known change.
If it fails: open an incident and follow the SOC runbook.
Never put a secret value in a registration body, a manifest or a preset field: secret fields accept references only, and a value written there would be readable by every reader of the registry.
Verify
- A call to a tool outside
allowed_toolsanswers -32003 and GET /v1/audit/events holdsmcp.tools.callwithdenyandnot in allow list. - A registration with a public URL answers 403
mcp_online_forbidden.
Next steps
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
-32003 dlp_blocked on a legitimate call | A tenant block rule matches the arguments or the result | Check the findings in the audit event; adjust the rule with the security officer |
-32003 prompt_injection_blocked | The result of the server holds instructions aimed at the model | Fix the content served by the tool |
| HTTP 403 on every call after an incident | SOC kill switch or suspension active | Read GET /v1/soc/kill-switches; recovery follows the SOC runbook |
HTTP 403 mcp_online_forbidden | Public host | Host the server internally |
| -32003 "scope tools:invoke required" | Key or token without the scope | Issue a key with tools:invoke |