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
-
The host issues an MCP call —
tools/call,resources/read,resources/subscribe,prompts/get, orsampling/createMessage. List methods (tools/list,resources/list,prompts/list) are filtered to permitted entries only. Unrecognized methods are denied without forwarding. -
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,
argumentSchemais validated first; then each condition is evaluated in order — the first deny wins. -
If allowed, the call is forwarded to the upstream. The upstream's
response is post-processed by any
directiveson the capability (e.g.redactFields) before being returned to the host. An unparseable upstream response triggers a sanitized error — unredacted data is never returned. -
If denied, the upstream is never contacted. The host
receives a structured JSON-RPC error:
-32001AUTHORIZATION_FAILED,-32002CAPABILITY_DENIED,-32003CONDITION_FAILED, or-32602INVALID_PARAMS (schema failure). - 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:
-
Unknown condition types are denied at two points:
eunox-mcp validaterejects the policy, and the runtime engine refuses to evaluate it. -
The same enforcement engine is reused by the Go packages under
pkg/for in-process use.
Audit log internals
Each event conforms to the
OCSF
API Activity class (class_uid: 6003). The signer:
-
generates a 32-byte random key on first run and stores it at
~/.eunox/audit.keywith mode0600; -
computes
HMAC-SHA256(key, SHA-256(canonical-json(event))); -
writes the event with its signature as a single JSON line to
~/.eunox/audit.jsonl.
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.
~/.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:
- Absent entries are denied by default. Any capability not listed in the manifest is denied for every enforced MCP method. Unrecognized MCP methods are also denied and never forwarded.
-
allowedOperationsis a first-word check — it splits on whitespace and matches the first token against the allowlist. It is not a SQL parser. Pair withargumentSchemaif you need stricter parsing. -
enforcement: auditstages a rule without blocking. Mark a single capability entryenforcement: auditand the denial it would produce is logged and the call forwarded — observe mode for rolling out a new tool or a stricter condition before it blocks. It never opens the allowlist, bypasses the kill switch, or downgrades a JWT-scope denial; flip toenforceonce the rule runs clean.
See docs/architecture.md for the architecture overview and docs/capability-manifest-guide.md for the full manifest reference.