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"
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.
{
"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:
| Key | What it holds |
|---|---|
context.mcp.server | the upstream_server name you passed to serve() |
context.mcp.method | the JSON-RPC method, such as tools/call |
context.mcp.tool | the tool name, for tools/call |
context.mcp.uri | the target URI, for resources/read and resources/subscribe |
context.mcp.prompt | the prompt name, for prompts/get |
context.mcp.arg_keys | the names of the arguments sent (always a list, possibly empty) |
context.mcp.arguments | the argument values, when the call has arguments |
context.mcp.protocol_version | the protocol version from the request's _meta, when present |
context.task_id | the Watchlight-Task-Id header, when present |
context.client_capabilities | the 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:
{
"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.
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_header | How the principal is formed | What 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:
| Header | What the PEP uses it for |
|---|---|
Watchlight-Agent-Id or Watchlight-Principal-Id | the principal (only the one named by principal_header) |
Watchlight-Execution-Id and Watchlight-Parent-Execution-Id | execution ids, passed to the engine and written to the audit record |
Watchlight-Session-Id | the session id, passed to the engine |
Watchlight-Task-Id | context.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
unattributedprincipal, or contains::without being a well-formed entity reference of the formType::"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.
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:
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
200response 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.
| Code | HTTP status | What it means | What to do |
|---|---|---|---|
-32001 | 200 | A 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. |
-32002 | 400 | An 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. |
-32020 | 400 | An Mcp-Method or Mcp-Name header does not match the request body. | Make the client's headers match the body. |
-32022 | 400 | The 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. |
-32602 | 400 | The 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. |
-32603 | 502 | The 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 URI | How it is recorded |
|---|---|
has a host, such as https://user:pw@docs.example/a/b?q=1 | https://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 scheme | scheme:sha256:<12 hex>/len=<n>, where the hash covers everything after the scheme. |
| cannot be parsed | sha256:<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-Methodheader 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 devcan follow any audit file, for examplewatchlight dev --audit audit/mcp-pep.jsonl.- Other
serve()arguments.upstream_timeout_mssets how long the PEP waits for the server (default30000).audit_pathis where audit records go; without it they go to the log.principal_headerandallow_unattributedare described under Identity.
Next
- Examples:
governed_mcp_server.pymakes one allowed and one denied call, and shows that the denied one never ran. - How policy works: the full guide to writing policies.