Govern an MCP Server
The Watchlight MCP Runtime PEP is a Policy Enforcement Point you put in front of an MCP server. It makes one guarantee:
No governed MCP tool call reaches the server until Watchlight has made a deterministic authorization decision — and only an explicit
PERMITforwards it.
A denied tools/call is blocked before it executes — not detected after.
The decision is made in-process by the same Watchlight engine the
Developer Edition ships; there is no database, no network hop,
no sidecar.
MCP client ──POST /mcp──▶ [ Watchlight MCP PEP ] ──▶ your MCP server
│
▼ authorize (in-process, fail-closed)
PERMIT → forward · DENY → blocked (never forwarded)
1. Install
python -m venv .venv && source .venv/bin/activate
pip install watchlight-mcp
Prebuilt wheels are published for Linux (x86_64, aarch64) and macOS (Apple
Silicon, Intel), for CPython 3.9 and newer. No Rust toolchain is required —
pip selects the right wheel for your platform.
watchlight-mcp is a compiled extension embedding the real Watchlight runtime.
It links its own TLS stack (rustls + aws-lc-rs); nothing else needs to be
installed.
2. Write a policy
Policies are Cedar, in the same shape the engine uses. This one
permits only read-oriented tools on a server we'll call github; everything else
is denied by default (fail-closed).
{
"id": "allow-github-read",
"name": "allow-github-read",
"description": "Permit only read-oriented GitHub MCP tools.",
"code": "permit(principal, action, resource) when { context.mcp.tool == \"search_repositories\" || context.mcp.tool == \"get_file_contents\" || context.mcp.tool == \"list_issues\" };"
}
The PEP puts the MCP tool name in context.mcp.tool, the server name in
context.mcp.server, and the tool arguments in context.mcp.arguments — so
policies can gate on the tool, the target, or the argument shape. See
Policies for the full authoring guide.
3. Run the PEP in front of your server
import watchlight_mcp
watchlight_mcp.serve(
listen_addr="127.0.0.1:9700",
upstream_url="http://localhost:3000/mcp", # the MCP server you are governing
upstream_server="github", # a stable name (used in policy + audit)
policy_files=["policies/allow-github-read.json"],
audit_path="audit/mcp-pep.jsonl", # value-free JSONL (omit → logs)
)
python run_pep.py
Then point your agent or MCP client at http://127.0.0.1:9700/mcp instead of
the server directly. Governed tools/calls are authorized; every other method
(tools/list, server/discover, …) is forwarded transparently.
The Developer Edition takes the acting identity from client-asserted
Watchlight-* request headers, so anyone who can reach the listener can assert
any principal. Bind the PEP to loopback (or an otherwise trusted local
socket). The governed platform plane adds signed attestation of these claims.
4. See it enforce
You can prove the guarantee with curl alone. Governed calls carry the standard
MCP routing headers plus the Watchlight execution context.
Allowed — get_file_contents is permitted, so it reaches the server and runs:
curl -s http://127.0.0.1:9700/mcp \
-H 'content-type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: get_file_contents' \
-H 'Watchlight-Agent-Id: demo-agent' -H 'Watchlight-Execution-Id: exec_1' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"get_file_contents","arguments":{"path":"README.md"}}}'
# → the server's real result
Denied — delete_repository matches no policy, so it is blocked and the
server never sees it:
curl -s http://127.0.0.1:9700/mcp \
-H 'content-type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: delete_repository' \
-H 'Watchlight-Agent-Id: demo-agent' -H 'Watchlight-Execution-Id: exec_2' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"delete_repository","arguments":{"owner":"acme","repo":"prod"}}}'
# → {"jsonrpc":"2.0","id":2,"error":{"code":-32001,"message":"..."}}
The audit trail records the decision without any argument values:
{"timestamp":"…","json_rpc_request_id":"1","watchlight_execution_id":"exec_1","principal":"Agent::\"demo-agent\"","mcp_method":"tools/call","tool":"get_file_contents","upstream":"github","decision":"permit","reason":"…","authorization_latency_us":666}
{"timestamp":"…","json_rpc_request_id":"2","watchlight_execution_id":"exec_2","principal":"Agent::\"demo-agent\"","mcp_method":"tools/call","tool":"delete_repository","upstream":"github","decision":"deny","reason":"…","authorization_latency_us":202}
The audit record has no field for tool arguments, tokens, or credentials — by construction there is nothing to leak.
5. Watch every decision live — watchlight dev
The watchlight dev console tails .watchlight/audit.jsonl
and streams every decision as it happens. Point the PEP's audit_path at that
file and run the console beside it — the same console renders both governed-tool
and MCP decisions:
watchlight_mcp.serve(
listen_addr="127.0.0.1:9700",
upstream_url="http://localhost:3000/mcp",
upstream_server="github",
policy_files=["policies/allow-github-read.json"],
audit_path=".watchlight/audit.jsonl", # ← the file `watchlight dev` tails
)
# terminal 1 — the PEP
python run_pep.py
# terminal 2 — the live console, from the same directory
watchlight dev # → http://127.0.0.1:7000
Now every tools/call decision streams into the console the instant it
happens — the tool, the upstream it fronts, and the reason a call was denied.
The ALLOWs, and the DENYs blocked before they reached the server, both appear
live.
watchlight dev defaults to .watchlight/audit.jsonl, but it can tail any path:
watchlight dev --audit audit/mcp-pep.jsonl.
Try it without a real server
If you just want to see enforcement, this ~50-line stand-in speaks the same
POST /mcp shape and records which tools actually executed — so you can prove a
denied call never reached it:
import json, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
EXEC_LOG = sys.argv[2] if len(sys.argv) > 2 else "executed.log"
class H(BaseHTTPRequestHandler):
def log_message(self, *_): pass
def do_POST(self):
if self.path != "/mcp":
self.send_response(404); self.end_headers(); return
req = json.loads(self.rfile.read(int(self.headers.get("content-length", 0))) or b"{}")
if req.get("method") == "tools/call":
tool = (req.get("params") or {}).get("name")
open(EXEC_LOG, "a").write(tool + "\n") # ground truth: it ran
result = {"resultType": "complete", "content": [{"type": "text", "text": f"EXECUTED {tool}"}]}
else:
result = {"resultType": "complete"}
body = json.dumps({"jsonrpc": "2.0", "id": req.get("id"), "result": result}).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)
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1] if len(sys.argv) > 1 else 3000)), H).serve_forever()
python sample_mcp_server.py 3000 executed.log & # the "server"
python run_pep.py # the PEP, in front of it
# run the allowed + denied curls above, then:
cat executed.log # → only "get_file_contents"; the denied tool never ran
How it enforces
The PEP targets MCP spec 2026-07-28 (Streamable HTTP) and is deliberately
strict:
- The body is authoritative. Requests are classified by the JSON-RPC body
method, never a header — a spoofed
Mcp-Methodcannot route a governed call around enforcement. For a governed call theMcp-Method/Mcp-Name/MCP-Protocol-Versionheaders are validated against the body. - Fail-closed everywhere. A deny, a runtime error, a malformed or ambiguous body (e.g. a duplicate JSON key), a header/body mismatch, or an unsupported protocol version all block the action — the server is contacted only on an explicit permit.
- Value-free audit. The audit record structurally cannot contain tool arguments, tokens, or secrets.
- Transparent pass-through. Non-governed methods (
tools/list,server/discover, subscriptions, …) are forwarded verbatim, streaming JSON or SSE responses back unchanged.
Configuration
serve() argument | Meaning |
|---|---|
listen_addr | Address the PEP listens on (bind to loopback). |
upstream_url | The MCP server's Streamable HTTP endpoint (its full /mcp URL). |
upstream_server | A stable name for the server, used in the resource URI and audit. |
policy_files | Cedar policy files (the shape shown above). |
audit_path | JSONL audit file. Omit to send audit to logs instead. |
upstream_timeout_ms | Request timeout to the server (default 30000). |
What's enforced vs. advisory
action is the MCP tool name (the tool is the verb), and resource is
mcp://<server>/<tool>. The identity headers are client-asserted in the
Developer Edition (advisory trust); the governed platform plane verifies them
cryptographically. See the Field & Behavior Reference for what
each field does and does not do.