Skip to main content

Govern an MCP server

The Watchlight MCP PEP is a policy enforcement point that you put in front of an MCP server. Your MCP client talks to the PEP instead of the server. The PEP checks each call against your policies and passes it on only when a policy explicitly permits it. A denied tools/call is therefore blocked before it executes, rather than detected after the fact.

MCP client ──POST /mcp──▶ [ Watchlight MCP PEP ] ──▶ your MCP server
│
▼ authorize (in-process, fail-closed)
PERMIT → forward · DENY → blocked

The PEP governs the MCP methods that act on something: tools/call, resources/read, resources/subscribe and prompts/get. Every other method, such as initialize or tools/list, is passed through to the server unchanged. The decision is made inside the PEP's own process, by the same engine the rest of the Developer Edition uses, so no separate authorization service is needed. The PEP implements MCP spec 2026-07-28. This page describes watchlight-mcp 0.4.4.

1. Install and run it​

pip install "watchlight-mcp>=0.4.4"
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=".watchlight/audit.jsonl", # ← the file `watchlight dev` tails
)

Point your MCP client at http://127.0.0.1:9700/mcp instead of at the server. The client must also send a Watchlight-Agent-Id: <agent> header on every request, because the PEP refuses a governed call that does not say who is making it. The Identity section explains the rules.

2. Write a policy​

Policies are written in Cedar, in the same shape the engine uses everywhere. The policy below permits only two read-oriented tools. Any other tool is denied, because Cedar denies everything that no policy permits.

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\" };"
}

For each governed call, the PEP builds an authorization request. For a tools/call, the action is the tool name (the tool is the verb) and the resource is mcp://<server>/<tool>. For the other governed methods, the action is the method name itself (for example resources/read), and the resource is mcp://<server>/<uri> or mcp://<server>/<prompt>.

The PEP also places details of the call under context, so a policy can decide based on the tool, its target or the shape of its arguments:

KeyWhat it holds
context.mcp.serverthe upstream_server name you passed to serve()
context.mcp.methodthe JSON-RPC method, such as tools/call
context.mcp.toolthe tool name, for tools/call
context.mcp.urithe target URI, for resources/read and resources/subscribe
context.mcp.promptthe prompt name, for prompts/get
context.mcp.arg_keysthe names of the arguments sent (always a list, possibly empty)
context.mcp.argumentsthe argument values, when the call has arguments
context.mcp.protocol_versionthe protocol version from the request's _meta, when present
context.task_idthe Watchlight-Task-Id header, when present
context.client_capabilitiesthe capabilities the client declared in the request's _meta, when present. The client reports these about itself, so treat them as a hint, never as proof of identity

The policy engine sees argument values so that a policy can check them, but the values are never written to the audit trail or the log (see What is logged).

The PEP reads exactly one policy object from each policy file. Each object must have a string name and a string code; id and description are optional. From Developer Edition 0.13.0, govern.load() reads a file of this shape too, so a single file can govern both an in-process agent and the PEP. Be careful with files that contain only code: the governor loads them, but the PEP refuses to start with them. See policy files for the full comparison.

Here is a second example, which permits reading resources only under one directory:

policies/allow-docs-resources.json
{
"id": "allow-docs-resources",
"name": "allow-docs-resources",
"description": "Permit reading only resources under the docs/ prefix.",
"code": "permit(principal, action, resource) when { context.mcp.uri like \"file:///repo/docs/*\" };"
}

3. See it enforce​

This request asks the server to delete a repository. No policy permits delete_repository, so the PEP refuses it:

curl -s http://127.0.0.1:9700/mcp -H 'content-type: application/json' \
-H 'Watchlight-Agent-Id: demo-agent' \
-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":"not authorized"}}

The MCP server never received the call. The PEP wrote an audit record that says who asked, what was asked and how the decision was reached. Here policy_effect is default-deny, which means no policy permitted the call. The record has no field for argument values, tokens or credentials:

{"timestamp":"…","json_rpc_request_id":"2","principal":"Agent::\"demo-agent\"","agent_id":"demo-agent","mcp_method":"tools/call","tool":"delete_repository","upstream":"github","decision":"deny","reason":"…","policy_effect":"default-deny","authorization_latency_us":…}

Run watchlight dev from the same directory to watch these decisions arrive live, next to your governed tools.

Bind it to loopback

In the Developer Edition, the PEP learns who is calling from Watchlight-* request headers that the client sets itself. Nothing checks those headers, so anyone who can reach the listener can claim to be any principal. That is safe on a loopback address such as 127.0.0.1, where only processes on the same machine can connect.

If you bind to any other address, put a gateway in front of the PEP. The gateway must authenticate the caller (for example a reverse proxy doing mTLS or OIDC, or the Enterprise plane). It must remove or overwrite every incoming Watchlight-* header, not only the identity header, and it must set the identity header (Watchlight-Agent-Id by default) itself, from the identity it authenticated, so that the PEP decides for the caller the gateway verified. Also restrict the PEP's address at the network layer, for example with a firewall rule or security group, so that only the gateway can reach it. Serving over TLS, described below, encrypts the connection, but it does not tell the PEP who the caller is.

Identity​

Every governed call is decided for a principal: the agent or user making the call. The PEP takes the principal from exactly one request header, which you choose when you start it with the principal_header argument. It never reads the principal from anywhere else.

principal_headerHow the principal is formedWhat happens to the other identity header
Watchlight-Agent-Id (the default)The value becomes Agent::"<value>".A request that carries Watchlight-Principal-Id is refused.
Watchlight-Principal-Id (opt-in)A value written as a Cedar entity reference, such as User::"alice", is used as sent. Any other value becomes Agent::"<value>".A request that carries Watchlight-Agent-Id is refused.

The opt-in form is meant for a trusted gateway that asserts principals other than agents, such as users:

watchlight_mcp.serve(
...,
principal_header="Watchlight-Principal-Id", # opt in; Watchlight-Agent-Id is then refused
)

Reading only one header matters for security. If a gateway overwrites the configured header, a client cannot get around it by adding the other one.

Identity is mandatory. If a governed call (tools/call, resources/read, resources/subscribe or prompts/get) arrives without the identity header, the PEP refuses it with JSON-RPC error -32002 and HTTP status 400, and does not forward it. Methods that are passed through, such as initialize, ping, tools/list and notifications, do not need an identity.

If you really want governed calls without an identity to be decided rather than refused, pass allow_unattributed=True to serve() or serve_background(). Such a call is then decided as the reserved principal Agent::"unattributed", and your policies decide what that principal may do. Because the name is reserved, no client can claim it: a request with Watchlight-Agent-Id: unattributed is refused.

The PEP reads the following headers and ignores every other Watchlight-* header:

HeaderWhat the PEP uses it for
Watchlight-Agent-Id or Watchlight-Principal-Idthe principal (only the one named by principal_header)
Watchlight-Execution-Id and Watchlight-Parent-Execution-Idexecution ids, passed to the engine and written to the audit record
Watchlight-Session-Idthe session id, passed to the engine
Watchlight-Task-Idcontext.task_id, and the audit record

The PEP checks these headers before it does anything else with a request. If a check fails, the request is refused with JSON-RPC error -32002 and HTTP status 400. Nothing is authorized and nothing is forwarded, and this applies to pass-through methods as well as governed ones. A request is refused when:

  • it carries the identity header that the PEP is not configured to read;
  • any header in the table above appears more than once;
  • a header in the table is empty, is longer than 512 bytes, or contains anything other than printable ASCII characters;
  • an identity value contains a comma, names the reserved unattributed principal, or contains :: without being a well-formed entity reference of the form Type::"id".

The error message names the header that was refused, but never repeats its value. Headers that only look like Watchlight headers, such as watchlight_agent_id or X-Watchlight-Agent-Id, are not read. The PEP removes them before forwarding a request, so the server never receives an identity that the PEP did not decide on.

Changed in 0.4.4

Before 0.4.4, a governed call without an identity was decided as an unattributed principal, and Watchlight-Principal-Id was accepted alongside Watchlight-Agent-Id (and took precedence over it). A client that relied on either behaviour now receives error -32002.

Govern a stdio server​

Many local MCP servers, including the reference git, filesystem and GitHub servers, communicate over standard input and output (stdio) rather than HTTP. serve() cannot sit in front of those, but serve_stdio() can. Your MCP client launches the PEP as if it were the server, and the PEP starts the real server as a child process and relays messages between the two:

run_pep_stdio.py
import watchlight_mcp

watchlight_mcp.serve_stdio(
upstream_command=["python", "-m", "mcp_server_git", "-r", "/path/to/repo"],
upstream_server="git",
agent_id="git-agent", # or principal_id='User::"alice"'
policy_files=["policies/allow-git-read.json"],
audit_path=".watchlight/audit.jsonl",
)

Because the server runs as a private child process, there is no network port that an agent could use to reach it directly, and no routing headers to check.

There are no request headers in stdio mode either, so you set the identity when you start the PEP, with agent_id or principal_id. The value must follow the same rules as the identity headers above. The PEP refuses to start if you set both. It also refuses to start if you set neither, unless you pass allow_unattributed=True, in which case calls are decided as the reserved Agent::"unattributed" principal.

In stdio mode, standard output carries only the MCP protocol. Logs and audit records go to standard error, or to the audit file if you set audit_path.

If you pass a stdio:// address, or any other address that is not http:// or https://, as the upstream_url of serve(), the PEP refuses to start and tells you to use serve_stdio(). You find the mistake straight away instead of on the first permitted call.

Run in the background, and hot-swap policies​

serve() blocks until you stop it. serve_background() starts the PEP on a background thread and returns a handle that you use to control it. It loads the certificates and binds the listening address before it returns, so a bad address or certificate raises an error in your code straight away:

with watchlight_mcp.serve_background(
listen_addr="127.0.0.1:9700",
upstream_url="http://localhost:3000/mcp",
upstream_server="github",
policy_files=["policies/allow-github-read.json"],
) as pep:
...
pep.reload_policies(["policies/allow-github-read.json",
"policies/allow-issues.json"]) # atomic hot-swap

reload_policies() builds a new engine from the files you give it and then swaps it in as a single step. Requests that are already being decided finish under the old policies. If a new file cannot be read or does not compile, the call raises an error and the running policies stay exactly as they were. The handle also has stop(), and a running property that tells you whether the server thread is still alive.

Serve over HTTPS​

By default serve() listens over plain HTTP, which is fine for a loopback address. To have the PEP handle TLS itself, pass a PEM certificate chain and its private key. You must pass both or neither. The example binds one specific private interface rather than 0.0.0.0 (every interface), because only the gateway described in the loopback caution should be able to reach the PEP:

watchlight_mcp.serve(
listen_addr="10.0.1.20:9700", # one private interface, reachable only by the gateway
upstream_url="https://internal-mcp.corp:3000/mcp",
upstream_server="internal",
policy_files=["policies/allow-read.json"],
tls_cert="certs/pep-cert.pem", # PEM chain, leaf first
tls_key="certs/pep-key.pem",
upstream_ca="certs/corp-ca.pem", # trust a private https:// upstream
)

upstream_ca lets the PEP trust an https:// server whose certificate comes from a private certificate authority. It is added to the public root certificates, not used instead of them. If the server's certificate cannot be verified, the PEP does not fall back to an unverified connection. A permitted call then fails with JSON-RPC error -32603, message upstream MCP server unavailable, and HTTP status 502.

Read the loopback caution above again before you bind to a non-loopback address. Encryption does not make the identity header trustworthy: the PEP must still sit behind a gateway that authenticates the caller and sets the header, and the network must let only that gateway reach the PEP.

Error codes​

Every refusal is a standard JSON-RPC error, and the code is the same over HTTP and stdio. Over HTTP, the HTTP status depends on the kind of refusal, so not every refusal is a 200:

  • A policy decision to deny is a normal 200 response that carries the JSON-RPC error, because the request itself was valid.
  • A request that the PEP cannot evaluate as sent, such as one with a missing identity or a malformed body, gets status 400.
  • A permitted call whose server cannot be reached gets status 502.
CodeHTTP statusWhat it meansWhat to do
-32001200A policy denied the call, or an engine error occurred and the PEP failed closed. The server was never contacted. The message is always not authorized, so a client cannot learn why; the reason is on the audit trail.This is expected behaviour. Report it and stop. Do not retry.
-32002400An identity or execution-context header was refused, or a governed call carried no identity. Nothing was forwarded.Send exactly one valid identity header, the one the PEP is configured to read.
-32020400An Mcp-Method or Mcp-Name header does not match the request body.Make the client's headers match the body.
-32022400The MCP-Protocol-Version header names a version the PEP does not support. The error's data lists the supported versions and the one requested.Send a supported version.
-32602400The JSON-RPC body is malformed, contains a duplicate object key, or is a governed call without params.name (or params.uri for resources/read and resources/subscribe).Fix the request.
-32603502The call was permitted, or passed through, but the server could not be reached.Retry with backoff.

A permitted call returns the server's own response and HTTP status, unchanged. Because a deny arrives with status 200, your client should check the JSON-RPC error code rather than relying on the HTTP status alone.

What is logged​

The PEP writes two kinds of output: the audit trail (to audit_path, or to the log when you leave it out) and its own process log. Neither ever contains argument values, request bodies or response bodies from the server, at any log level, including TRACE.

An audit record holds the principal, the method, the tool or prompt name, the decision and the reason for it, the policy that decided (when one did), and correlation ids. Values that the caller chooses freely are checked before they are written. The JSON-RPC id, the method, the tool or prompt name, the protocol version and the traceparent are written as sent only when they are short and look like identifiers; otherwise the record shows [withheld: N bytes] instead. The identity and execution-context header values are written as sent, because they have already passed the header checks in Identity.

A resource URI can contain a file path, a token or other data, so the audit record stores a reduced form of it. The policy still sees the full URI when it makes its decision.

The URIHow it is recorded
has a host, such as https://user:pw@docs.example/a/b?q=1https://docs.example/sha256:<12 hex>/len=<n>. The scheme, host and port are kept, and the path is replaced by a hash. The user name, password, query string and fragment are dropped. A URI with an empty path, or a path of just /, is recorded as https://docs.example/.
has no host, such as file:///…, data:, mailto:, urn: or a custom schemescheme:sha256:<12 hex>/len=<n>, where the hash covers everything after the scheme.
cannot be parsedsha256:<12 hex>/len=<n>

In these forms, <12 hex> is the first 12 hexadecimal characters of the SHA-256 hash of the hidden part, and <n> is its length in characters. The hash has no secret key. That lets you match equal values across records, but it also means that someone who can guess a short value can confirm the guess by hashing it.

Worth knowing​

  • The request body is what counts. The PEP decides what kind of request it has from the JSON-RPC body, never from a header, so a forged Mcp-Method header cannot route a governed call around enforcement. The routing headers are optional. When a client does send them, they must match the body, or the call is refused.
  • Anything unexpected blocks the call. The PEP blocks a call on a deny, an engine error, a malformed or ambiguous body, a mismatch between headers and body, an unsupported protocol version, or a missing or refused identity.
  • A port that is in use is an error. serve() fails if the port is already taken. The Developer Edition keeps no list of running PEPs, so stop the old one or choose another port.
  • watchlight dev can follow any audit file, for example watchlight dev --audit audit/mcp-pep.jsonl.
  • Other serve() arguments. upstream_timeout_ms sets how long the PEP waits for the server (default 30000). audit_path is where audit records go; without it they go to the log. principal_header and allow_unattributed are described under Identity.

Next​

  • Examples: governed_mcp_server.py makes one allowed and one denied call, and shows that the denied one never ran.
  • How policy works: the full guide to writing policies.