Coding Agent Protection — Local MCP Broker
The Local MCP Broker is a subsystem of pavri-endpointd that sits in front
of every configured MCP server on a developer's workstation. It is the
local enforcement point for D-06 (MCP tool calls outside allowlist)
and the source of CodingActionEvent records for every observed tool call.
This page covers what the broker rewrites, how it enforces policy, what the supported transports are, and how to opt out for specific assistants or specific MCP servers.
What the broker does
When pavri-endpointd enrolls an endpoint, the broker subsystem:
- Discovers every assistant's MCP config file on the filesystem (Claude Desktop, Cursor, VS Code, JetBrains, Gemini CLI, Windsurf).
- Backs up the original config bytes alongside a SHA-256 fingerprint into the sensor's manifest.
- Rewrites each MCP server entry so the upstream URL or command
points at a sensor-managed endpoint:
- STDIO upstreams: rewritten to
command: pavri-endpointd broker-stdio --upstream-id <id>so the assistant launches a thin stub that forwards over a UDS to the broker. - HTTP+SSE-legacy / Streamable HTTP upstreams: rewritten to a
loopback URL (
http://127.0.0.1:<port>/mcp/<upstream-id>/...) that the broker exposes.
- STDIO upstreams: rewritten to
- Watches every rewritten config for drift via
fsnotify. If the file changes, the broker reads the new content, queries the file observer for the most recent attributed writer, and classifies the change. Each classification produces aConfigDriftEventwithdrift_kindset to one of:user_edit— a non-assistant process (or no observed writer) wrote the change. Emit only; the broker does not auto-clobber a user's manual edit in CAP-1.3.assistant_self_modification— the writer chain matches a registered assistant binary. No clobber. Emit and preserve the on-disk content. This is D-17 evidence.schema_unknown— the new content does not parse against the rewriter's schema. Emit only.restore_verification_failed— aRestoreattempt produced content whose SHA does not match the manifest'sOriginalContentSHA256. Emit only.
When the assistant issues a tools/call:
- The broker parses the JSON-RPC frame and looks up the session for the calling assistant.
- It evaluates
endpoint.action.mcp_tool_call:<server>.<tool>against the active policy snapshot (cached locally; see Policy authoring). - Allow → the original frame is forwarded to the real upstream and
a
CodingActionEventwithverdict = ALLOWEDis emitted. - Deny → a JSON-RPC error response is written back to the
assistant, the upstream never sees the frame, and a
CodingActionEventwithverdict = DENIEDis emitted. - No matching rule → treated as
Allow(observe-mode fallback). CAP-1.5 will gate this on the per-tenant protection profile.
Hot-swap is sub-millisecond: a snapshot apply takes effect on the next frame the broker evaluates.
Supported transports
| Transport | When | Adapter |
|---|---|---|
| STDIO | The assistant config defines command + args (most common case). | Spawns the original upstream as a child process; bidirectional newline-delimited JSON-RPC framing. |
| HTTP+SSE-legacy | The assistant config defines url and transport: sse. | Two endpoints: GET /mcp/<id>/sse (event stream from upstream), POST /mcp/<id>/messages (client → upstream). |
| Streamable HTTP | The assistant config defines url and transport: streamable_http. | Single duplex endpoint at POST /mcp/<id> with chunked request/response bodies. |
All three transports run through the same eval gate; only the framing differs.
What gets rewritten
Per assistant, the broker discovers and rewrites the following config files (existence depends on whether the assistant is installed):
| Assistant | Config path (macOS) | Config path (Linux) | Format |
|---|---|---|---|
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json | ~/.config/Claude/claude_desktop_config.json | JSON |
| Cursor | ~/.cursor/mcp.json | ~/.cursor/mcp.json | JSON |
| VS Code | <workspace>/.vscode/mcp.json and ~/Library/Application Support/Code/User/mcp.json | <workspace>/.vscode/mcp.json and ~/.config/Code/User/mcp.json | JSON |
| JetBrains | ~/Library/Application Support/JetBrains/<product>/mcp.xml | ~/.config/JetBrains/<product>/mcp.xml | XML |
| Gemini CLI | ~/.config/gemini/settings.json | ~/.config/gemini/settings.json | JSON |
| Windsurf | ~/.codeium/windsurf/mcp_config.json | ~/.codeium/windsurf/mcp_config.json | JSON |
Windows paths are resolved analogously (Windows runtime is gated to CAP-2.1; the rewriter contract compiles and tests pass cross-platform).
Within each config, only the transport endpoint (URL or command) is
mutated. Every other user field — env, headers, custom keys,
ordering, comments where the format permits — is preserved byte-stable.
Secrets
The broker never persists secret values. Env values and auth header
values are extracted as key names only and stored on the manifest
as OriginalEnvKeys / OriginalAuthHeaderKeys. At upstream-spawn time
the broker re-reads the values from the user's current environment.
This means the manifest is safe to inspect and synchronize, and a
sensor restart picks up rotated credentials without re-rewriting.
How to opt out
The broker is on by default once the sensor is enrolled. Opt-out in
CAP-1.3 is policy-driven, not env-driven; the env-var knobs and
operator-facing uninstall-broker CLI subcommand land in CAP-1.5
alongside the policy DSL.
Observation mode
The protection profile's observation_mode controls broker enforcement:
enforce(default once a deny rule is configured) —tools/callframes matching a deny rule are blocked at the broker.observe— everytools/callis forwarded; the broker still emitsCodingActionEventfor every frame, withverdict = ALLOWEDregardless of policy match. Useful for staging policy changes without breaking developer workflows.
CAP-1.5 will surface this as a per-tenant DSL knob; until then,
operators set it via the policy bundle's protection_profile.coding_agent
block.
Restoring originals
To revert rewrites manually in CAP-1.3, inspect each *.pavri.bak
file under the assistant's config directory and copy it back over the
rewritten file. The sensor's fsnotify watcher will classify this
manual revert as user_edit and emit a ConfigDriftEvent for audit;
it will not re-rewrite the file (CAP-1.3 emits-only on user edits and
defers re-rewrite to CAP-1.5).
If you need to disable the broker entirely on a specific endpoint,
unenroll the sensor (the broker subsystem only runs while enrolled).
A first-class pavri-endpointd uninstall-broker subcommand and per-
assistant disable knobs ship in CAP-1.5.
Verifying the broker is working
After enrollment:
- Inspect
claude_desktop_config.json(or your assistant's MCP config). Eachcommandfield should bepavri-endpointd broker-stdio --upstream-id <opaque-id>. Eachurlfield for HTTP-based servers should resolve to127.0.0.1. - Make a tool call from the assistant — for example, ask Claude Desktop to list files in your home directory if you have the filesystem MCP server configured.
- In the Pavri dashboard, navigate to Coding > Sessions. The
call should appear within 5 seconds with
verdict = ALLOWEDand the redacted argument set visible.
If the call does not appear, check:
- Is the sensor enrolled?
journalctl -u pavri-endpointd | grep enroll. - Did the rewrite succeed? Look for
*.pavri.baknext to the assistant's config. - Is the broker UDS listening?
ls $PAVRI_ENDPOINT_STATE_DIR/sockets/.
Performance
CAP-1.3 §11 exit gates: policycache.Evaluate p99 < 1ms cache-hit;
broker.Evaluator.EvaluateFrame (full pipeline including session
lookup, redaction, and telemetry emit) p99 < 5ms cache-hit.
Locally observed on a typical developer laptop: eval p99 ≈ 250ns, broker p99 ≈ 5µs. The broker should not be detectable in the end-to-end latency budget of any tool call that returns in fewer than several milliseconds.
Related references
- Sensor deployment — broker prerequisites and drift-handling overview.
- Policy authoring (raw) — the JSON shape of the PolicyBundle the broker consumes.
- Observers and events — what the rest of
the sensor produces alongside
CodingActionEvent.