Metadata-Version: 2.4
Name: watchlight-mcp
Version: 0.4.4
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
License-File: LICENSE
Summary: Watchlight MCP Runtime PEP — deny denied MCP tool calls before they reach the server. In-process Watchlight engine, zero infrastructure.
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://docs.watchlight.ai
Project-URL: Homepage, https://watchlight.ai

# Watchlight MCP Runtime PEP

A **Policy Enforcement Point** for the [Model Context Protocol](https://modelcontextprotocol.io).
It sits in front of an upstream MCP server and makes one guarantee:

> **No governed MCP action reaches the target system until Watchlight has made a
> deterministic runtime authorization decision — and only an explicit PERMIT
> forwards it.**

Every `tools/call` is authorized **in-process** by the real Watchlight engine
before it can reach the upstream server. A denied call is blocked *before it
executes* — not detected after. Zero infrastructure: no database, no network
hop, no sidecar.

## Install

```bash
pip install watchlight-mcp
```

## Run

```python
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 for it (used in policy + audit)
    policy_files=["policies/allow-read.json"],   # Cedar policies, wl-apdp shape
    audit_path="audit/mcp-pep.jsonl",            # value-free JSONL audit (omit → logs)
)
```

Point your MCP client at `http://127.0.0.1:9700/mcp` instead of the server
directly, and have it send `Watchlight-Agent-Id: <agent>` on every request.
Governed `tools/call`s are authorized (one without that header is refused);
everything else is forwarded transparently.

## What it enforces

- **MCP `2026-07-28`** Streamable HTTP: the JSON-RPC body is authoritative, and
  the `Mcp-Method` / `Mcp-Name` / `MCP-Protocol-Version` routing headers are
  validated against it — a spoofed header cannot route a governed call around
  enforcement.
- **Fail-closed**: a deny, a runtime error, a malformed body, a header/body
  mismatch, or an unsupported protocol version all block the action. The upstream
  is contacted *only* on an explicit permit.
- **Value-free audit and logs**: the audit record has no field for tool
  arguments, tokens, or credentials — only the decision and safe correlation ids.
  The process log is value-free at every level too: tool name, decision and ids,
  never an argument value. A resource URI is recorded as its scheme and host
  plus a SHA-256 prefix of the path (host-less URIs such as `data:` or
  `mailto:` as the scheme plus a hash).

## Identity

The PEP reads client-asserted `Watchlight-*` headers to build the authorization
principal and execution context — the Developer-Edition trust model. The
principal comes from exactly one header:

- **`Watchlight-Agent-Id`** (default) → `Agent::"<id>"`. A request that also (or
  instead) carries `Watchlight-Principal-Id` is refused.
- **`Watchlight-Principal-Id`**, only when you opt in with
  `serve(..., principal_header="Watchlight-Principal-Id")` → a Cedar entity ref
  such as `User::"alice"`. A request carrying `Watchlight-Agent-Id` is refused.

`Watchlight-Execution-Id`, `Watchlight-Parent-Execution-Id`,
`Watchlight-Session-Id` and `Watchlight-Task-Id` are read as execution context.
A request that repeats any of these headers, or sends an empty or malformed one,
is refused with JSON-RPC error `-32002` (HTTP 400) and never forwarded. So is an
identity value that contains `,`, is a malformed `Type::"id"`, or names the
reserved `unattributed` principal.

**Identity is mandatory.** A governed call (`tools/call`, `resources/read`,
`resources/subscribe`, `prompts/get`) without an identity header is refused
with `-32002`; `initialize`, `ping`, `tools/list` and other pass-through methods
need none. To decide such calls as the reserved `Agent::"unattributed"`
principal instead (the 0.4.3 behaviour), pass `allow_unattributed=True` to
`serve` / `serve_background`. `serve_stdio` refuses to start without
`agent_id` or `principal_id` unless `allow_unattributed=True`.

Anyone who can reach the listener can assert these headers. Bind to loopback,
or put a gateway in front that strips or overwrites **all** of them.

## Responses

| Outcome | JSON-RPC error | HTTP status |
|---|---|---|
| Permit | none (the server's response is relayed) | the server's status |
| Denied by policy, or a fail-closed engine error | `-32001` `not authorized` | 200 |
| Identity header refused, or no identity on a governed call | `-32002` | 400 |
| `Mcp-Method` / `Mcp-Name` disagrees with the body | `-32020` | 400 |
| Unsupported `MCP-Protocol-Version` | `-32022` | 400 |
| Malformed or ambiguous body, missing tool name | `-32602` | 400 |
| Upstream server unreachable | `-32603` | 502 |

---

© Watchlight AI. The embedded runtime is proprietary; see LICENSE.

