Deterministic authorization for MCP tool calls.
MCP Visor is a self-hosted policy enforcement proxy for AI agents. It evaluates valid JSON-RPC tools/call requests that include an id before relay and applies allow, deny, approval, redaction, chain, and session-taint rules without an LLM. Notification-form tools/call (no id), duplicate method keys, and JSON-RPC batches containing tools/call are blocked at the proxy; recognizable malformed tools/call attempts with an id fail closed without relay.
MCP Visor is not a model guardrail. It is an action boundary. Models can request actions. MCP Visor decides whether those actions are allowed.
Model guardrails try to shape what the model says or thinks inside the context window. MCP Visor controls what the agent is allowed to do after a tool call is requested. System prompts can describe intended behavior; MCP Visor enforces policy outside the model.
Paper: MCP Visor: Deterministic Runtime Enforcement · Writing: Spec engineering · Runtime policy enforcement
MCP Visor is the production enforcement layer of a broader research program on governing autonomous agents at the boundary.
| Repo | Question it answers | Status |
|---|---|---|
| mcp-visor | What may an agent do? (runtime policy at the MCP tools/call boundary) |
Production |
| agent-identity-plane | Who is acting, for whom, through which chain? (identity + provenance) | v0.1.0 |
| authority-graph-simulator | What authority can an agent reach? (counterfactual delegation analysis) | Prototype |
| capability-delta-receipts | What capability can an agent acquire? (trajectory-level capability accounting) | Prototype |
This binary is the tools/call action boundary. Agent Identity Plane authenticates callers and carries actor-chain provenance via a visor-gateway; it is not yet an in-proxy token gate. The two prototypes are standalone proofs. Capability-delta semantics also exist here as an opt-in evaluator (-capability-eval or settings.capability_accounting); the authority-graph is not in the proxy.
The Trust Plane governs not just what authority an agent is given, but what new authority its discoveries make possible — authorization must be re-evaluated when an agent materially increases its effective capabilities, even when nominal permissions are unchanged.
AI agents can now read files, call APIs, run commands, query databases, and modify infrastructure through MCP tools. Prompt-only controls are useful guidance, but they are not an execution boundary.
MCP Visor adds that boundary at the MCP tools/call layer:
AI agent → MCP Visor → policy decision → MCP server
Valid tools/call requests with IDs are evaluated before relay. Notification-form tools/call is dropped without response, including if sent in the post-initialize handshake slot. Duplicate method keys and JSON-RPC batches containing tools/call are blocked before relay. Non-tools MCP notifications and batches still forward unchanged.
go install github.com/themayursinha/mcp-visor/cmd/mcp-visor@latestPre-built binaries and checksums are available on the Releases page.
# Run the built-in demo proxy
mcp-visor serve --demo
# Supplemental lint. Do not combine --strict with --no-warnings.
# This is not yet a complete fail-closed policy gate; see the threat model.
mcp-visor lint --strict examples/policies/session-taint-egress.yaml
# Proxy a real MCP server through a policy boundary.
# Allows fail closed unless -audit-log points at a writable JSONL file.
mcp-visor serve -server <your-mcp-server> -policy policy.yaml -audit-log ./audit.jsonlTwo-minute action-boundary demo: go run ./examples/demo-runner
Monitor vs delayed detection (same http_post to evil.example; unmediated server observes it, Visor denies before relay): go run ./examples/monitor-vs-proof
Local Proof Console (not a product dashboard): go run ./examples/demo-runner -ui then open the printed URL. Default bind is loopback. To reach it from another machine on a tailnet, bind a CGNAT address in 100.64.0.0/10 (not 0.0.0.0):
go run ./examples/demo-runner -ui -ui-addr "${TAILSCALE_BIND_ADDRESS}:9092"- Secret reads followed by outbound exfiltration
- Access to configured sensitive paths such as
/project/.env, SSH keys, kubeconfigs, credentials, and key files; basename-only and policy-urigaps are documented in the threat model - Unsafe shell commands and command-injection patterns
- Unknown or newly introduced tools under default-deny policy
- High-risk actions without human approval
- Tool sequences that look safe individually but become dangerous together
- Tool-output-induced recipient expansion:
allow_recipientis an exact, fail-closed mailbox slot. Session history does not promote a denied recipient into authority. - PATH→SHELL amplification:
require_path_literaldenies path-class arguments that contain shell grammar before relay. Schema-valid, tool-authorized calls still fail closed when a path would interpolate as a command fragment. - Cross-principal writes:
allow_resource_owneris an exact, fail-closed principal slot. A mandate to act for alice does not authorize cancelling bob's reservation. Session history does not promote a denied owner into authority. - Destructive path outside mandate:
allow_path_slotdenies PATH/TARGET-class arguments that are not under the mandate glob. A schema-valid cleanup of/tmpthat names$HOMEis denied before relay. - Authority-expanding destination:
allow_destinationis an exact, fail-closed host slot. A mandate to reachdocs.internaldoes not authorizehttp_postorweb_fetchtoevil.example. Session history does not promote a denied host into authority. - Untrusted execution environment:
allow_working_directorydenies CWD-class arguments that are not under the mandate glob. A schema-valid decoder run whosecwdis an attacker extract directory is denied before relay. - Untrusted credential custody:
deny_secretdenies SECRET-class arguments before relay. A schema-validconfigure_secretwhoseapi_keyis a valid replacement key is still denied. Credential validity is not custody. - Authority-expanding application:
allow_applicationis an exact, fail-closed application slot. A mandate to syncstaging-ordersdoes not authorizeargocd_syncofproduction-payments. A tool-provider token is not caller authority. - Unauthorized skill promotion:
allow_skillis an exact, fail-closed skill-identity slot. A mandate to installworkspace-lintdoes not authorizeinstall_skillofattacker-registry. Experience cannot manufacture a new skill name. - Permission-bypass delegation:
deny_permission_bypassis a fail-closed spawn-flag slot. A mandate to create a worker does not authorizespawn_agentwith skip-permissions. Explicitly-off values are not a bypass. Delegation graphs are out of model. - Unauthorized configuration activation:
allow_activationis an exact, fail-closed executable-or-host slot. A mandate to register/usr/bin/nodeormcp.internaldoes not authorizeregister_mcpof/bin/shor169.254.169.254. Describing infrastructure is not instantiating it.
MCP Visor tracks session state, not just individual calls.
file_read("/customer-secrets/tokens.csv")
→ session tainted: sensitive_file_accessed
→ http_post(...) denied by block_sensitive_egress
→ audit log records source, taint, policy rule, sink, and decision
Example policy:
taints:
- name: "sensitive_file_accessed"
source_tools: ["file_read"]
source_patterns: ["**/customer-secrets/**"]
egress_controls:
- name: "block_sensitive_egress"
when_tainted: "sensitive_file_accessed"
sink_tools: ["http_post", "slack_send_message"]
action: denyFull example: examples/policies/session-taint-egress.yaml
version: "1.0"
default_action: deny
servers:
- name: "filesystem"
allowed: true
tools:
- name: "file_read"
allowed: true
rules:
- type: deny_path
patterns: ["**/.env", "**/*.pem", "/etc/passwd"]
- type: allow_path
patterns: ["/home/**", "/tmp/**"]
- name: "shell_exec"
allowed: true
risk: critical
approval_required: true
rules:
- type: deny_command_pattern
patterns: ["bash\\s+-i\\s+>&", "rm\\s+-rf\\s+/"]
tool_chains:
- name: "prevent_exfiltration"
sources:
- server: "*"
tool_pattern: "file_read"
sinks:
- server: "*"
tool_pattern: "(http_post|slack_send_message)"
action: deny
within_calls: 3More policies: examples/policies/ · Interop policies: examples/policies/interop/ · Schema reference: docs/policy-model.md
| Capability | Purpose |
|---|---|
| Default-deny policy | Unknown servers and tools fail closed |
| Argument rules | Restrict paths, commands, queries, recipients, sizes, and repos |
| Pattern redaction | Replace configured matches in arguments and textual Content[].Text output; this is not full structured-payload or complete private-key sanitization |
| Tool-chain detection | Block dangerous sequences such as read → exfiltrate |
| Session taints | Change later authorization decisions after sensitive context is touched |
| Human approval | Gate critical tools before execution |
| Durable allow commit | Terminal allows are appended and fsync'd to the JSONL sink before relay (H19) |
| Stdio identity pin | Optional stdio_invocation_sha256_v1 binds a local invocation; registry runners are unpinnable |
| Audit log | Hash-linked JSONL; allows are a durable commit; denies and other selected events are written without the same fsync |
| Policy linting | Validate YAML policy before deployment |
Advanced capabilities include signed decision receipts, Vault Transit signing, webhooks, and experimental remote transport, SIEM, metrics/OTLP, and local dashboard surfaces. The --trace formatters are not yet connected to runtime message paths. See docs/complexity-budget.md and docs/threat-model.md.
- Deterministic: no LLM in the allow/deny path
- Fail closed: unknown tools are denied by default; a missing or non-durable
-audit-logdenies every allow - Layered: optional stdio identity → redaction → policy → taint-aware egress → chain detection → approval → durable allow commit → post-allow taint marking → relay
- Observable: every terminal allow is a standalone JSONL
tool_call_allowedrecord, fully appended andSync()'d before relay. Denies, approvals-required, taints, and session events are also JSONL but are not equivalentlyfsync'd. The hash chain recovers when reopening a healthy file; incomplete or corrupt tails fail closed. This is not a signed repudiation control, and it does not survive an untrusted audit directory. - Self-hosted: single Go binary; no SaaS dependency required
- Operator-controlled: optional telemetry exports to your Prometheus, OTLP, webhook, or SIEM stack
- Not a host sandbox: Visor authorizes MCP
tools/call. It does not confine arbitrary filesystem, network, or subprocesses.
mcp-visor serve [flags] Run the proxy
mcp-visor lint [--strict] <policy> Supplemental policy validation; not a complete fail-closed gate
mcp-visor version Print version
Common flags: -server, -policy, -audit-log, -approval-dir, -approval-cli, -demo
Advanced flags: -server-url, -webhook-url, -siem-target, -vault-addr, -metrics-addr, -otel-endpoint, -dashboard, -trace, -capability-eval
Full reference: mcp-visor serve -h
Architecture · Policy model · Threat model · Complexity budget · Interoperability · Qwen-MM-Plugins demo · Harness
go build ./cmd/mcp-visor/ # build
go test ./... # test
harness/check.sh # fmt + vet + test + evidence manifest
make bench # benchmarks- v1.0: Proxy, policy engine, redaction, approval, audit, chain detection
- v1.1: Identity/time policies, partial engine hot-reload, CLI approval, experimental remote transport
- v1.2: Session taints and egress controls
- v1.3: Documentation truth, security verification, interoperability evidence, and release hardening
- v1.4: Durable allow-commit before relay, optional stdio identity attestation, Qwen third-party demo, documentation truth
- Future: sandboxing, richer telemetry, optional policy engines — only if deployment evidence demands them
See CONTRIBUTING.md. Run harness/check.sh before PRs.
MIT — see LICENSE