Skip to main content

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 PERMIT forwards 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.

Compiled component

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).

policies/allow-github-read.json
{
"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

run_pep.py
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.

Trust boundary

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.

Allowedget_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

Denieddelete_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:

audit/mcp-pep.jsonl
{"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:

run_pep.py
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.

Watch any audit file

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:

sample_mcp_server.py
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-Method cannot route a governed call around enforcement. For a governed call the Mcp-Method / Mcp-Name / MCP-Protocol-Version headers 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() argumentMeaning
listen_addrAddress the PEP listens on (bind to loopback).
upstream_urlThe MCP server's Streamable HTTP endpoint (its full /mcp URL).
upstream_serverA stable name for the server, used in the resource URI and audit.
policy_filesCedar policy files (the shape shown above).
audit_pathJSONL audit file. Omit to send audit to logs instead.
upstream_timeout_msRequest 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.