How it works

A reference monitor at the protocol boundary

eunox is a transparent Go-based MCP proxy and service stack that speaks the protocol on both ends and evaluates capability policy in the middle. The decision happens before the upstream is contacted, and every event is signed and audited.

On this page

Architecture

The proxy sits between any MCP host (Claude Desktop, Cursor, Windsurf, LangChain, Go agents, …) and any upstream MCP server. It is transparent on the protocol — the host sees an MCP server, the upstream sees an MCP client.

                       ┌──────────────────────────────────────────┐
   MCP host            │                eunox-mcp                 │         Upstream MCP server
 (Claude / Cursor /    │                                          │     (filesystem, postgres, github,
  Windsurf / agent)    │   ┌────────────┐    ┌─────────────────┐  │       slack, fetch, your own…)
        │              │   │  Policy    │    │  Audit sink     │  │                  │
        │  tools/call  │   │  engine    │    │  (OCSF + HMAC)  │  │                  │
        ├──────────────┼──►│  evaluate  │───►│ ~/.eunox/audit  │  │                  │
        │              │   │ conditions │    │ .jsonl          │  │                  │
        │              │   └─────┬──────┘    └─────────────────┘  │                  │
        │  CapDenied   │         │ allow                          │                  │
        │◄─────────────┼─────────┼────────────────────────────────┼──────────────────►
        │              │         │                                │     forward call │
        │              │         │   ┌────────────────────────┐   │                  │
        │              │         └──►│  StdioProxy / HTTP     │───┼────────►         │
        │              │             │  transport             │   │                  │
        │              │             └────────────────────────┘   │                  │
        │              └──────────────────────────────────────────┘                  │

Two transports are supported: stdio (Claude / Cursor / Windsurf) and HTTP (LangChain, Go runtimes, and other HTTP clients). The same policy file works for both. ipRange is enforceable only on HTTP — stdio sessions have no source IP.

Request flow

  1. The host issues an MCP call — tools/call, resources/read, resources/subscribe, prompts/get, or sampling/createMessage. List methods (tools/list, resources/list, prompts/list) are filtered to permitted entries only. Unrecognized methods are denied without forwarding.
  2. The proxy looks up the matching capability in the manifest. Capabilities absent from the manifest are denied by default (fail-closed). If a match is found, argumentSchema is validated first; then each condition is evaluated in order — the first deny wins.
  3. If allowed, the call is forwarded to the upstream. The upstream's response is post-processed by any directives on the capability (e.g. redactFields) before being returned to the host. An unparseable upstream response triggers a sanitized error — unredacted data is never returned.
  4. If denied, the upstream is never contacted. The host receives a structured JSON-RPC error: -32001 AUTHORIZATION_FAILED, -32002 CAPABILITY_DENIED, -32003 CONDITION_FAILED, or -32602 INVALID_PARAMS (schema failure).
  5. Every decision — allow or deny — is written to the OCSF audit log, signed with HMAC-SHA256.
host ──tools/call──►  proxy ──► [parse] ──► [match capability] ──► [evaluate conditions]
                                                                          │
                                                ┌─────────────────────────┴─────────────┐
                                                ▼                                       ▼
                                            allow                                    deny
                                                │                                       │
                                                ▼                                       │
                                  forward to upstream ───response──► [post-process] ──┐ │
                                                                                      │ │
host ◄────────────────── result / CapabilityDenied ──────────────────────────────────┴─┘
                                                                       │
                                                                       ▼
                                                          OCSF audit (~/.eunox/audit.jsonl)

Schema parity — one shape, zero drift

The policy file is shared across the Go implementation, including pkg/capability, pkg/enforcement, and eunox-mcp. The CLI and hosted services consume the same manifest and condition definitions. This means:

Audit log internals

Each event conforms to the OCSF API Activity class (class_uid: 6003). The signer:

The log rotates at 100 MiB. Rotated files are named audit.jsonl.<ISO-timestamp>. verifyAuditEvent() recomputes the HMAC for round-trip integrity checks, and eunox-mcp stats aggregates denials across active and rotated logs into an ASCII histogram grouped by conditionType and denialCode.

Why HMAC and not a digital signature? Local audit is tamper-evident on-device — anyone with read access to the key can verify, anyone without it cannot forge. The key is stored at ~/.eunox/audit.key (mode 0600) and can be relocated with --audit-key-path or EUNOX_AUDIT_KEY_PATH.

Gateway — one proxy, many upstreams

Over HTTP, a single eunox-mcp process can front every MCP server a host talks to. Each upstream is declared in a YAML gateway config and served on its own /mcp/<name> route, governed by its own capability manifest, all writing to one tamper-evident audit tape:

eunox-mcp proxy --config gateway.yaml
  ├─ POST /mcp/filesystem   → stdio upstream, policy filesystem.yaml
  └─ POST /mcp/stripe       → https://mcp.stripe.com, policy stripe.yaml

Each manifest carries its own version, so policies are reviewed and versioned independently. A route may pin an expected version (expectVersion); the gateway fails closed — refusing to start — if the loaded manifest's version differs. Every audit record is stamped with the handling upstream, the in-force policy_version, and a policy_sha256 digest, so the shared tape proves which policy version of which upstream allowed or denied each call. A single remote upstream is just a gateway with one route.

The same fail-closed posture runs through the rest of the config. A route declared with no manifest allows every call, so the gateway warns loudly at startup unless you opt into that openness with enforcement: audit. Sessions are bound to the route they were opened on — a request that reuses another route's session id is refused rather than silently crossing upstreams. And secrets interpolate with ${VAR} while a literal $ is left untouched, so a token containing $ is never corrupted on the way in.

Hosts that speak remote MCP (Claude Code, Cursor, VS Code/Copilot, Cline, Roo, Windsurf) connect by URL; stdio-only hosts attach through a small per-route stdio↔HTTP bridge. When a JWKS endpoint is configured, the bearer token is intersected against each route's own manifest (JWT ∩ manifest), per upstream.

Enforcement guarantees

Enforcement runs on the arguments the agent actually sent — before the upstream is called. The guarantee is: "the agent sent this tool call with these arguments, and it was allowed/denied by this policy." It is not a guarantee about what the upstream server does internally — for that, instrument the upstream as well.

No telemetry, no phone-home. eunox opens no analytics, usage-reporting, or update-check connections. The only outbound traffic is what your config declares — the upstream MCP server, and, when configured, the JWKS endpoint and Redis. Everything else, including the audit log and its signing key, stays on the machine running the proxy.

Three practical notes:

See docs/architecture.md for the architecture overview and docs/capability-manifest-guide.md for the full manifest reference.

Try it in 60 seconds →