Integration guide: running AgentFence around an MCP server

July 5, 2026 · View on GitHub

This guide shows how to put AgentFence in front of a Model Context Protocol (MCP) server so every tools/call request the agent makes is evaluated against your policy before the server sees it.

If you only want to evaluate JSONL traces (no proxy, no live agent), use agentfence check — see the project README. This guide is specifically about the live MCP proxy (agentfence proxy).

How AgentFence wraps an MCP server

┌────────┐   JSON-RPC over stdio   ┌──────────────┐   JSON-RPC   ┌────────────┐
│  Agent │ ──────────────────────► │  AgentFence  │ ───────────► │ MCP server │
│ (host) │ ◄────────────────────── │    proxy     │ ◄─────────── │ (your bin) │
└────────┘                         └──────────────┘              └────────────┘


                                   ┌──────────────┐
                                   │  audit log   │
                                   │   (JSONL)    │
                                   └──────────────┘

The proxy is a long-running process that:

  1. Spawns your MCP server as a subprocess.
  2. Connects the agent's stdin to the server via the proxy.
  3. Parses every newline-delimited JSON-RPC message.
  4. For tools/call: evaluates against the policy and either forwards the request (allow), responds with a BlockedByPolicy JSON-RPC error (deny), or calls the configured approver (ask).
  5. Writes one audit event per evaluated call to the JSONL audit log.
  6. Forwards everything else (initialize, ping, notifications, server responses) untouched.

Prerequisites

  • The agentfence binary on your PATH (make build or download from Releases).
  • An MCP server binary or runtime command. The proxy spawns this — it does not care what language or framework the server uses.
  • A policy YAML file (see docs/policy-language.md and examples/policy.yaml).

Wrapping an MCP server

The general invocation is:

agentfence proxy \
  --policy /path/to/policy.yaml \
  --audit-log /path/to/audit.jsonl \
  -- \
  /path/to/mcp-server arg1 arg2

Everything after -- is the command (and arguments) AgentFence will spawn. You can pass any executable that speaks MCP over stdio — node, python, a compiled binary, docker run -i ..., etc.

Useful flags:

FlagWhat it does
--policy <file>Required unless --passthrough. Policy YAML to load.
--audit-log <file>Append JSONL audit events to this file. New files are created owner-readable on Unix (0600). If omitted, audit events are discarded (the proxy never mixes audit JSONL into the agent's stdout — that channel is reserved for JSON-RPC).
--tamper-evidentHash-chain audit events. Verify later with agentfence audit verify --log <file>.
--sign-key <file>Sign each audit event with an Ed25519 private key (PEM). Verify with agentfence audit verify --pubkey <file>.
--audit-max-size <bytes> / --audit-max-age <dur> / --audit-keep <n>Rotate the audit log by size and/or age, keeping at most n rotated segments (requires --audit-log).
--audit-fsyncfsync the audit log after every event so a decision survives a crash (slower; requires --audit-log).
--audit-sink <url>Also ship audit events to an external sink; repeatable. Schemes: http(s)://…, syslog://host:port, syslog+tcp://host:port.
--no-interactiveAuto-deny every ask decision instead of prompting on the TTY (see Approval).
--approval-timeout <dur>Bound how long an interactive ask waits before falling back to deny (e.g. 30s). 0 waits indefinitely.
--log-format text|jsonOperational (stderr) log format; distinct from the audit log. json emits one structured record per line.
--metrics-listen <addr>Expose Prometheus decision/latency/error metrics at /metrics on this address (off by default; keep it on loopback).
--passthroughSkeleton mode: forward every message without policy evaluation. Useful for validating the relay; do not use in production.
--debugLog every forwarded JSON-RPC message to stderr. Off by default because MCP messages routinely contain user content.

Try the proxy in 60 seconds

Before wiring a real client, prove the gate end to end with the bundled, hermetic smoke example. It runs agentfence proxy in front of a tiny stub MCP server (no network, no npm) and shows an allowed read plus a denied write:

$ ./examples/proxy-smoke.sh
+ agentfence proxy (prevention mode) wrapping the stub MCP server

{"jsonrpc":"2.0","id":2,"result":{…}}                 # read forwarded
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,…}}    # write blocked (BlockedByPolicy)

PASS: read forwarded, write blocked by policy (BlockedByPolicy -32001).

The denied write comes back as a JSON-RPC -32001 (BlockedByPolicy) error the tool server never sees, and both decisions land in the audit log. For the confused-deputy (taint) guard, see examples/taint-scenario/. The rest of this guide shows how to point real MCP clients at the proxy.

Wrapping a remote MCP server over HTTP

For MCP servers reached over streamable HTTP / SSE (remote or hosted tools), use proxy-http instead. It listens locally and reverse-proxies to the upstream, gating tools/call with the same policy, redaction, approval, and audit behavior as the stdio proxy:

agentfence proxy-http \
  --policy /path/to/policy.yaml \
  --upstream https://mcp.example.com/mcp \
  --listen 127.0.0.1:8787 \
  --audit-log /path/to/audit.jsonl

Then point your MCP client at http://127.0.0.1:8787. Requests that are not a single tools/call (initialize, ping, notifications, the SSE GET channel) are forwarded transparently, and streamed text/event-stream responses are relayed incrementally.

Useful flags mirror proxy, plus:

FlagWhat it does
--upstream <url>Required. Absolute base URL of the MCP server to forward to.
--listen <addr>Local address to bind (default 127.0.0.1:8787). Keep it on loopback.
--on-batch reject|evaluateJSON-RPC batch (array) body handling. reject (default) refuses batches fail-closed; evaluate gates every member and forwards only if all are allowed.
--on-unparsed forward|rejectHandling for POST bodies that are not valid JSON-RPC. forward (default) preserves non-JSON-RPC traffic; reject refuses them.
--auth-token <token>Require Authorization: Bearer <token> on every request (also read from $AGENTFENCE_PROXY_AUTH_TOKEN). Empty disables auth.

Operational caveats specific to the HTTP transport — TLS being the operator's responsibility, optional bearer-token authentication, one shared session per running proxy, and how batch/oversize/unparseable bodies are handled fail-closed — are documented in threat-model.md and batch-handling.md.

GitHub Action

Gate recorded tool calls in CI with the bundled composite action. It builds agentfence, runs agentfence check, fails the job per fail-on, and writes a decision table to the job summary.

- name: AgentFence policy check
  uses: dgenio/agentfence@v0.5.0
  with:
    policy: examples/policy.yaml
    calls: examples/tool-calls.jsonl
    fail-on: deny           # deny | ask | deny,ask

Inputs: policy (required), calls (required), fail-on (default deny), audit-log, tamper-evident, approval-timeout, go-version. Outputs: total, allow, deny, ask, decisions-file. Because CI runs non-interactively, ask decisions are auto-denied and counted under deny. A copy-paste workflow is in examples/github-action-workflow.yml.

Approval

Both proxy and proxy-http resolve ask decisions interactively by default. When AgentFence is attached to a terminal, an ask rule prompts the operator on the TTY (approve <tool> [<id>]? (y/N)); answering y/yes forwards the call, and anything else — an explicit n, a closed input, or an expired --approval-timeout — denies it and returns a BlockedByPolicy response. The prompt is read from /dev/tty, never from stdin, so it cannot collide with the stdio proxy's JSON-RPC channel. If no controlling terminal is available (for example in CI or a detached service), the proxy refuses to start rather than falling back to stdin — re-run with --no-interactive for unattended use.

For unattended contexts (CI, a service with no terminal):

  • --no-interactive auto-denies every ask (recorded with the non-interactive: ask auto-denied reason) instead of prompting.
  • --approval-timeout <duration> (e.g. 30s) bounds how long an attended prompt waits before falling back to deny with the approval timeout reason.

The audit event records the resolved decision (allow/deny) together with the engine's reason for the original ask — for example a taint escalation — so the trail captures both the cause and how it was resolved.

Claude Code (CLI / Desktop)

Claude Code launches MCP servers via the mcpServers map in its settings file. Wrap the server's command with agentfence proxy:

{
  "mcpServers": {
    "filesystem": {
      "command": "agentfence",
      "args": [
        "proxy",
        "--policy", "/Users/you/.config/agentfence/policy.yaml",
        "--audit-log", "/Users/you/.local/share/agentfence/audit.jsonl",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/work"
      ]
    }
  }
}

Settings file locations:

  • macOS / Linux desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or ~/.config/Claude/claude_desktop_config.json (Linux).
  • Claude Code CLI: ~/.claude/settings.json.

After saving, restart Claude. From the agent's perspective the server still looks like one MCP server on stdio; AgentFence is invisible until a decision is enforced.

VS Code (Copilot MCP / GitHub Copilot Chat)

VS Code's MCP configuration lives in settings.json under mcp.servers (exact key may evolve with the extension; check your extension's docs). The pattern is the same — replace the command/args with the wrapped invocation:

{
  "mcp.servers": {
    "filesystem": {
      "command": "agentfence",
      "args": [
        "proxy",
        "--policy", "${userHome}/.config/agentfence/policy.yaml",
        "--audit-log", "${userHome}/.local/share/agentfence/audit.jsonl",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"
      ]
    }
  }
}

${userHome} and ${workspaceFolder} are VS Code variable substitutions — AgentFence itself does not resolve them.

Cursor

Cursor reads MCP servers from ~/.cursor/mcp.json (global, every project) or .cursor/mcp.json in a project root (that project only). Both use the same mcpServers shape — wrap the server command with agentfence proxy:

{
  "mcpServers": {
    "filesystem": {
      "command": "agentfence",
      "args": [
        "proxy",
        "--policy", "/Users/you/.config/agentfence/policy.yaml",
        "--audit-log", "/Users/you/.local/share/agentfence/audit.jsonl",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/work"
      ]
    }
  }
}

Use an absolute path for command if agentfence is not on the PATH Cursor launches with. Restart Cursor (or toggle the server) after editing the file.

Claude Desktop

Claude Desktop's config lives at ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows), using the same mcpServers map as Claude Code:

{
  "mcpServers": {
    "filesystem": {
      "command": "agentfence",
      "args": [
        "proxy",
        "--policy", "/Users/you/.config/agentfence/policy.yaml",
        "--audit-log", "/Users/you/.local/share/agentfence/audit.jsonl",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/work"
      ]
    }
  }
}

Use absolute paths throughout (Claude Desktop does not expand ~), then fully quit and reopen Claude Desktop.

Confirm gating is working

Whichever client you wired, verify AgentFence is actually in the path — don't assume. The audit log is the receipt: it only gets written when the proxy evaluates a call.

  1. Point --audit-log at a known path (as in the recipes above).

  2. In the client, trigger a tool call the policy denies — e.g. ask the agent to read .env under the filesystem pack, which denies it.

  3. Tail the audit log and look for the deny:

    tail -n 5 /Users/you/.local/share/agentfence/audit.jsonl
    # …"tool":"filesystem.read","decision":"deny","reason":"path \".env\" denied…"…
    

If a denied call shows up as a deny event (and the client reports the tool was blocked), the proxy is in the path. If the audit log stays empty, the client is still talking to the server directly — re-check the command/args wiring and that the client was fully restarted. See Troubleshooting below for the common failure modes.

Writing your first policy

A minimal policy.yaml:

version: "0.1"
defaults:
  decision: deny

tools:
  filesystem.read:
    decision: allow
  filesystem.write:
    decision: ask           # prompts on a TTY; auto-denied with --no-interactive
  github.create_issue:
    decision: ask
  github.delete_repo:
    decision: deny

redaction:
  enabled: true
  patterns:
    - name: openai_api_key
      regex: 'sk-[A-Za-z0-9_-]{20,}'
    - name: github_token
      regex: 'gh[pousr]_[A-Za-z0-9_]{20,}'

Validate it before pointing the proxy at it:

agentfence validate --policy policy.yaml

See docs/policy-language.md for the full schema: groups, wildcards, path constraints, argument constraints, URL constraints, and shell-command constraints.

Inspecting the audit log

Each evaluated tools/call produces one JSONL audit event:

{"schema_version":"2","session_id":"…","seq":1,"timestamp":"…","call_id":"42","tool":"filesystem.read","decision":"allow","reason":"tool filesystem.read matched explicit policy rule","arguments":{"path":"README.md"}}
{"schema_version":"2","session_id":"…","seq":2,"timestamp":"…","call_id":"43","tool":"filesystem.write","decision":"deny","reason":"path \".env\" denied by pattern \".env\"","arguments":{"content":"OPENAI_[REDACTED:openai_api_key]","path":".env"}}

Useful one-liners:

# All denies in the last run:
jq -c 'select(.decision=="deny")' audit.jsonl

# Top tools by call count:
jq -r '.tool' audit.jsonl | sort | uniq -c | sort -rn

# Verify the hash chain (requires --tamper-evident at write time):
agentfence audit verify --log audit.jsonl

Troubleshooting

"proxy: a downstream command is required after --" — you forgot the -- separator and the command. Compare:

# wrong
agentfence proxy --policy policy.yaml
# right
agentfence proxy --policy policy.yaml -- node server.js

--policy is required (or pass --passthrough …) — running in enforcement mode without a policy. Either provide --policy or add --passthrough (skeleton mode only — not for production).

exec: "...": executable file not found in $PATH — the downstream command is not on $PATH from the proxy's perspective. Use an absolute path, or make sure $PATH is exported through your launcher.

The agent appears to hang on a tool call — the policy probably issued an ask decision and the proxy is waiting for your y/N answer on the terminal. Answer the prompt, set --approval-timeout to bound the wait, or pass --no-interactive to auto-deny ask immediately in unattended runs. Run with --debug to see the forwarded messages on stderr.

Audit log is empty--audit-log was not passed. The proxy intentionally does not write audit JSONL to stdout because stdout is reserved for the agent's JSON-RPC channel.

audit verify reports chain absent — the audit log was written without --tamper-evident. Re-run the proxy with the flag if you want a verifiable chain.

audit verify reports PARTIAL — the log mixes unchained and chained events; only the chained suffix is integrity-protected. This happens when a log written without --tamper-evident is later fed into a chain-aware writer out of band. check/proxy refuses --tamper-evident on any existing log that is not already fully chained from event 1 (both fully-unchained logs and partial-chain logs are rejected) to prevent this; rotate the log (move or archive it) before enabling the flag.

Limitations and known issues

  • A JSON-RPC batch (array) body is forwarded transparently and is not gated; keep ask/deny rules in mind for clients that batch.
  • The threat model for the proxy is documented at the trust-boundary level only; expansion is tracked under issue #35.