Skip to main content

MCP this, MCP that. Attack surface, gateways, and cMCP.

· 15 min read
Abdulmalik
AppSec Engineer

Your engineering, infra or security team are connecting agents to tools inside your environments, from GitHub, Stripe, Slack, cloud APIs, filesystem servers, whatever the agent needs to act.

Same session. Fine. The part that kept bothering me is quieter: once those tools are reachable, who is actually allowed to say no before the upstream runs?

I poked at agentrust-io/cmcp and filed a few issues along the way. I ran the deny/allow/verify loop in software mode (CMCP_DEV_MODE=1) so you can feel the control without TEE hardware. So let's jump into it.

Why this bothered me in the first place

So MCP is really just how the agent reaches tools. It is not browsing your repo UI the way you do. It calls tools. And those calls can read a local .env, talk to your DB, post into Slack, refund something on Stripe, or hit cloud APIs with credentials already sitting next to the workspace. A lot of that also never shows up the way a normal human login would in your SIEM.

So this is not only about secrets in a dotenv file. Once those MCP servers are in the session, the agent can reach databases, chat, PII's, cloud stuff, whatever the tools wire up. The filesystem read_file path I walk later is one concrete deny so you can feel the control. It is not the whole problem.

And if the only control you have is "someone approved this MCP server once," then you are trusting the tool descriptions that reach the model, whatever that server does after the approval, every other server in the same client session, and whatever credentials the agent can already reach.

This is not theoretical. Invariant Labs showed tool poisoning: malicious instructions buried in tool descriptions that steer the agent toward data reachable through other trusted servers. CVE-2025-54136 covered the rug-pull pattern where a benign MCP config gets approved, then swapped later without re-validation. There are more client and SDK CVEs in the wild. vulnerablemcp.info is a decent index if you want the longer list.

If you already think in layers of trust for CI and runtime, this rhyme is familiar. Artifact signatures do not prove process behavior (Runtime Trace). Approving an MCP server once does not prove the next tools/call is safe.

How teams use MCP and how they wire it

Teams use MCP so the agent can actually act: read files, run shell, hit GitHub, drive a browser, talk to whatever else you plug in. Wiring it is the boring part. You add those MCP servers in Cursor or Claude Desktop, and the agent gets tools/call into the workspace and beyond.

Here are the setups I keep coming back to. None of them need a fictional "secrets helper" server.

Filesystem MCP. @modelcontextprotocol/server-filesystem (or Cursor's own filesystem server) exposes tools like read_file / read_text_file, list_directory, search_files. Point it at a project root and the agent can open .env, ~/.aws/credentials, a kubeconfig, or a .npmrc with a registry token. Prompt injection, tool poisoning, or a confused "debug my auth" turn is enough to trigger the call. You usually find out after the keys are in the context window, or in a chat log, or pasted somewhere else.

Shell / terminal MCP. Same shape, worse blast radius. An agent that can run shell commands can cat .env, curl an exfil endpoint, or dump printenv. Approving "terminal access" once is not the same as approving every command the model invents later.

GitHub MCP. Useful for PRs and issues. Also useful for reading private repo contents, Actions logs that still hold secrets in plaintext, or opening a PR whose diff includes a token the agent just pulled from the filesystem server in the same session. Cross-server is the ugly sibling: one trusted server feeds another.

Browser MCP. Agent fills a form, pastes a token into a "debug" page, or uploads a file that should never leave the machine. Same approval model. Different egress.

For example Cursor, the path I actually exercised for this post is the filesystem one: Cursor Agent shaped traffic → filesystem-style read_file with path: ".env" → cMCP Cedar forbid → 403 POLICY_DENY before any upstream reads the file. Benign list_directory still goes through. Then TRACE verify.

.env exfil is the same class of mistake you already fight in Kubernetes, Vault, and IaC: credentials reachable by a process that should not be free to read them. Different transport.

What is missing in that setup is boring and important: tool-level deny (not only "this MCP server is approved"), and an attested audit trail that a verifier can check without trusting the operator's word.

What people mean by "MCP gateway"

Writeups from Tigera and Linx land on the same shape: put a front door in front of MCP so the agent does not talk straight to every server. Auth at that door is not the same as authz. You still need something that can deny a specific tools/call and leave an audit trail. That is the shape. Who ships it well is a different argument.

What cMCP is and how it comes into the picture

cMCP (Confidential MCP Runtime) is open source, and yes it is different from "API gateway with MCP codecs."

It sits in front of the MCP servers, runs each tools/call through a Cedar policy, and either allows or denies. Software mode (CMCP_DEV_MODE=1) is enough to feel that. Hardware TEE is the attested version. LIMITATIONS.md is clear about the gap.

Sessions also mint a signed TRACE Claim (GatewayClaim): policy hash, runtime measurement, audit tip, Ed25519 signature. Software mode signs it. TEE attests it.

Approved tools map to upstream identity. First-contact catalog drift detection is shipped. Continuous mid-session re-list is intentionally not. Cross-channel instruction splitting (tool description + tool results) is called out as a residual gap in LIMITATIONS.

Walkthrough: deny read_file, allow list_directory, verify

I ran this against cmcp-runtime v0.5.0 in software mode. The client is curl standing in for Cursor Agent: same JSON-RPC tools/call shape, with _cmcp.workflow_id set to cursor-agent so Cedar can scope the session the way a Cursor→cMCP bridge would stamp it. Cursor does not invent that string for you today. You configure the gateway client path (or a thin adapter) to send it.

The upstream is a filesystem MCP server: catalog tools named read_file and list_directory, matching @modelcontextprotocol/server-filesystem (and the older read_file alias still registered next to read_text_file). It listens on :9001 behind the gateway. No real .env on disk for the deny path. Cedar never lets that call leave the gateway.

Policy grain: this path is tool-level. Cedar forbids Resource::"read_file" entirely. It does not match on arguments.path == ".env". The curl still sends path: ".env" because that is the leak story. Path-level Cedar (deny only secret paths, allow README) needs call-argument context in the evaluation record. Tool-level is still enough to stop the filesystem read tool before upstream.

Prerequisites

  • Python 3.11+ (I used 3.14.7), pip, curl
  • Three terminals: cMCP gateway, client (curl), and the upstream filesystem MCP (list_directory needs the upstream, deny-only does not)

Layout to create

cmcp-walk/
cmcp-config.yaml
catalog.json
filesystem_mcp.py
record-approved-hashes.py
policies/
manifest.json
schema.cedarschema
cursor.cedar

Install

python3 -m venv cmcp-env
source cmcp-env/bin/activate
python3 -m pip install cmcp-runtime==0.5.0
cd cmcp-walk # your working directory with the files below

Config

cmcp-config.yaml:

attestation:
provider: auto
enforcement_mode: enforcing
policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "127.0.0.1:8443"
audit_db_path: ./audit.db

Pin listen_addr to loopback. Tokenless CMCP_DEV_MODE=1 is loopback-only on purpose. From 0.4.0 a wider bind without CMCP_BEARER_TOKEN is refused.

Cedar + schema + manifest

policies/cursor.cedar:

// Cursor Agent sessions stamp workflow_id=cursor-agent on the gateway client path.
permit (
principal,
action,
resource
) when {
context.workflow_id == "cursor-agent"
};

// Tool-level deny matching @modelcontextprotocol/server-filesystem's read_file.
// Path-level matching on arguments.path is not in this quick Cedar context.
forbid (
principal,
action,
resource == Resource::"read_file"
);

policies/schema.cedarschema:

{"cMCP":{"entityTypes":{"Principal":{"memberOfTypes":[],"shape":{"type":"Record","attributes":{"session_id":{"type":"String","required":true},"workflow_id":{"type":"String","required":true}}}},"Resource":{"memberOfTypes":[],"shape":{"type":"Record","attributes":{"tool_name":{"type":"String","required":true}}}}},"actions":{"call_tool":{"appliesTo":{"principalTypes":["cMCP::Principal"],"resourceTypes":["cMCP::Resource"],"context":{"type":"Record","attributes":{"session_max_sensitivity":{"type":"String","required":true},"workflow_id":{"type":"String","required":true}}}}}}}}

policies/manifest.json:

{
"version": "0.1.0",
"authored_at": "2026-09-20T00:00:00Z",
"author_identity": "[email protected]",
"commit_sha": "filesystem-mcp"
}

Catalog (filesystem MCP tools)

catalog.json:

[
{
"tool_name": "read_file",
"server": {
"display_name": "Filesystem MCP",
"url": "http://localhost:9001/mcp",
"tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"transport": "http-sse"
},
"approved_definition": {
"description": "Read the complete contents of a file as text. Same shape as @modelcontextprotocol/server-filesystem read_file / read_text_file.",
"input_schema": {
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read"
}
}
},
"output_schema": {
"type": "object",
"properties": {
"content": { "type": "string" }
}
}
},
"definition_hash": "sha256:9bca1022ec7ce05a43d356ce714bbdb8c58b110de77d2331c8abb9b6d1c14789",
"compliance_domain": "internal",
"requires_baa": false,
"sensitivity_level": "confidential",
"added_at": "2026-09-20T00:00:00Z",
"approved_by": "[email protected]"
},
{
"tool_name": "list_directory",
"server": {
"display_name": "Filesystem MCP",
"url": "http://localhost:9001/mcp",
"tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"transport": "http-sse"
},
"approved_definition": {
"description": "List directory contents with [FILE] or [DIR] prefixes. Same shape as @modelcontextprotocol/server-filesystem list_directory.",
"input_schema": {
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "Directory path to list"
}
}
},
"output_schema": {
"type": "object",
"properties": {
"content": { "type": "string" }
}
}
},
"definition_hash": "sha256:205d9f1c37502085b8d75e779e1b86722c6d1e9430807cdbb531862d565bf540",
"compliance_domain": "public",
"requires_baa": false,
"sensitivity_level": "public",
"added_at": "2026-09-20T00:00:00Z",
"approved_by": "[email protected]"
}
]

If you edit an approved_definition, recompute its definition_hash or the runtime rejects the entry at startup:

python3 -c "
import json, hashlib
d = {
'description': 'YOUR DESCRIPTION',
'input_schema': {'type': 'object', 'properties': {}},
'output_schema': {'type': 'object', 'properties': {}}
}
s = json.dumps(d, sort_keys=True, separators=(',', ':'), ensure_ascii=True)
print('sha256:' + hashlib.sha256(s.encode()).hexdigest())
"

Filesystem MCP upstream + hash recorder

filesystem_mcp.py:

#!/usr/bin/env python3
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass

def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
msg = json.loads(self.rfile.read(n) or b"{}")
body = json.dumps(
{
"jsonrpc": "2.0",
"id": msg.get("id"),
"result": {
"content": [
{"type": "text", "text": "[FILE] README.md\n[DIR] src"}
]
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

if __name__ == "__main__":
print("filesystem MCP listening on :9001", flush=True)
HTTPServer(("127.0.0.1", 9001), H).serve_forever()

record-approved-hashes.py:

#!/usr/bin/env python3
import json
from pathlib import Path
from cmcp_runtime.catalog.loader import load_catalog
from cmcp_runtime.policy.bundle import load_policy_bundle

approved = {
"policy_bundle_hash": load_policy_bundle("policies").bundle_hash,
"tool_catalog_hash": load_catalog("catalog.json").catalog_hash,
}
Path("approved-hashes.json").write_text(json.dumps(approved, indent=2) + "\n")
print(json.dumps(approved, indent=2))

Validate, pin hashes, start

cmcp validate-config --config cmcp-config.yaml
# expect: ✓ Config valid: cmcp-config.yaml

python3 record-approved-hashes.py

When I ran it with the files above:

{
"policy_bundle_hash": "sha256:da3ddd048f59b1a93adb4385bd5be00825e0cc1423e73e745f7dc758c2b9322a",
"tool_catalog_hash": "sha256:eeb8ada5b053184239cb9011d336106b7e3da4f26416ee6e175d7c5df27af32b"
}

Keep that approved-hashes.json. You will pin those values at verify time.

Terminal 1. Start the gateway and leave it open:

CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml

You will see the "no hardware TEE / software-only" warnings. Expected. Then uvicorn on 127.0.0.1:8443:

cmcp start in CMCP_DEV_MODE: software-only TEE warnings, listening on 127.0.0.1:8443

Gateway startup in software mode. No hardware TEE. Listen on loopback only.

Blocked call: read_file on .env (no upstream needed)

In terminal 2. This is the Cursor Agent / filesystem MCP leak call, as JSON-RPC:

curl -i -X POST http://localhost:8443/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {"path": ".env"},
"_cmcp": {"session_id": "cursor-session-001", "workflow_id": "cursor-agent"}
}
}'

When you hit deny you should see HTTP/1.1 403 Forbidden and POLICY_DENY. That is the whole point: a filesystem read_file stopped at the policy boundary before any upstream runs and before the file is read. The upstream filesystem MCP is not required for this step.

curl tools/call read_file path=.env returns HTTP 403 POLICY_DENY

read_file with path: ".env"403 Forbidden / POLICY_DENY. Cedar forbid wins before upstream.

Allowed call: list_directory (upstream)

Terminal 3. Start the filesystem MCP:

python3 filesystem_mcp.py
# filesystem MCP listening on :9001

Back in terminal 2:

curl -i -X POST http://localhost:8443/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_directory",
"arguments": {"path": "."},
"_cmcp": {"session_id": "cursor-session-001", "workflow_id": "cursor-agent"}
}
}'

You should get HTTP/1.1 200 OK, a directory listing, and a _cmcp audit block with would_have_denied: false. Policy matched workflow_id == "cursor-agent", gateway forwarded, upstream answered.

curl tools/call list_directory returns HTTP 200 with directory listing and _cmcp audit block

list_directory200 OK. Upstream filesystem MCP answers. Gateway attaches _cmcp audit metadata.

TRACE Claim and verify

Closing takes the session's internal UUID, not the label cursor-session-001:

SESSION_UUID=$(curl -s "http://localhost:8443/audit/export?session_id=cursor-session-001" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['entries'][0]['session_id'])")

curl -s -X POST "http://localhost:8443/sessions/$SESSION_UUID/close" \
| python3 -m json.tool > claim.json

My gateway.call_summary:

{
"tool_calls_total": 2,
"tool_calls_allowed": 1,
"tool_calls_denied": 1,
"tool_calls_faulted": 0,
"tools_invoked": [
"list_directory",
"read_file"
]
}
cmcp verify claim.json

Software checks PASS. Hardware attestation FAIL. Overall RESULT: FAIL (partially_verified). That is the honest software-mode answer:

cmcp verify claim.json: software checks PASS, hardware_attestation FAIL, RESULT partially_verified

Software checks PASS. Hardware attestation FAIL. partially_verified is the honest software-mode answer.

Pin the hashes you recorded before startup:

cmcp verify claim.json \
--policy-hash "$(python3 -c "import json; print(json.load(open('approved-hashes.json'))['policy_bundle_hash'])")" \
--catalog-hash "$(python3 -c "import json; print(json.load(open('approved-hashes.json'))['tool_catalog_hash'])")"

Software checks still PASS. Hardware attestation still FAIL. partially_verified is the honest answer for software mode. This walkthrough proves the policy path (deny, allow, claim shape).

Software mode (CMCP_DEV_MODE=1) still denies and allows. Verify just lands on partially_verified: signed claim, no hardware provenance. TEE is when you need that claim to stand up to auditors without trusting the operator.

Conclusion

MCP noise is real. MCP risk is also real: poisoned tools, rug pulls, client CVEs.

"MCP gateway" is the right shape when you need a front door, tool-level policy, and an attributable audit trail. Vendor essays from Tigera and Linx are useful for framing. They are not the definition of done.

I walked deny read_file + allow list_directory + verify above in software mode so you can feel the Cedar control without buying hardware first. That core path exists today. Full estate IGA and mesh routing do not. Phase 2 server attestation and some transparency work are next.

If you are wiring Cursor Agent (or Claude) to filesystem, shell, GitHub, or browser MCP tools, start with a deny-by-default catalog, treat every server as hostile until proven otherwise, and put something in the path that can say no before the upstream reads .env. Then decide whether your proof requirement is "we logged it" or "a verifier can check the enclave measurement without trusting us."

Those are different products. Call them by their real names.

Well, that's it. I hope you find this useful. Drop a comment if you hit a weird verify result or a Cedar quirk. I am curious what breaks for other setups.

Till next time, Peace be on you 🤞🏽

References


Comments