Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mnemo mcp-server — hardened MCP STDIO mode

v0.4.0-rc3, Task B2. Defends against the OX-MCP “exfiltrate-then-act” disclosure (2026-04-24) by refusing inherited secrets, JSON-injection argv, and untrusted parent processes BEFORE any engine state is constructed.

Why this exists

The default mnemo startup path is convenient: it reads OPENAI_API_KEY, MNEMO_ENCRYPTION_KEY, MNEMO_POSTGRES_URL, and a stack of CLI flags straight from the environment. That’s fine for local development. It is not fine when an attacker can spawn the binary inside someone else’s shell — the OX-MCP disclosure showed how a poisoned Claude Code session can cause the host to exec an MCP server with the attacker’s manifest attached and the user’s secrets visible.

mnemo mcp-server --manifest <path> is a hardened entry point with a narrower trust boundary:

  • All privileged knobs (keystore, audit log destination, allowed tools, allowed agents, allowed parents) live in a TOML manifest the operator controls.
  • Sensitive env vars are an automatic refusal.
  • --config-style argv injection is an automatic refusal.
  • Non-TTY parents that aren’t on the manifest’s allow-list are an automatic refusal.

The manifest

keystore_path     = "/etc/mnemo/keystore.toml"
audit_log_path    = "/var/log/mnemo/audit.jsonl"
allowed_tools     = ["mnemo.recall", "mnemo.verify"]
allowed_agents    = ["claude-prod"]
allowed_parents   = ["claude", "systemd"]

A full annotated example lives at examples/mcp-server/manifest.toml.

Keystore

The manifest’s keystore_path points at a chmod-restricted TOML file:

key_id  = "mnemo-prov-2026-04"
key_hex = "<64 hex chars / 32 bytes / openssl rand -hex 32>"

The hardened mode loads this file at startup and attaches a ProvenanceSigner (B1) to the engine. Every recall(..., with_provenance=true) returns a verifiable HMAC receipt. Rotate by writing a new file with a fresh key_id and updating the manifest.

The safe-spawn gauntlet

Before constructing any engine state, the binary runs three checks:

  1. Inherited secrets. Refuses if the env carries any of: ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, MNEMO_ENCRYPTION_KEY. Run the binary through a secret-clearing wrapper (env -i, systemd Environment=). Override at your own risk: MNEMO_REJECT_INHERITED_SECRETS=0.
  2. Args-based config. Refuses if argv contains --config, --config-json, --inline-config, -c, or --secret (in any =value form). All config must live in the manifest.
  3. Untrusted parent. When stdin is not a TTY, the parent process basename (set via MNEMO_PARENT_BASENAME) must appear in manifest.allowed_parents. If the variable is unset the check is skipped — running interactively (TTY present) also lifts the check.

Each refusal exits non-zero with a stderr message that names the violating key/arg/parent.

Running it

# 1. Clear the env, set the parent assertion, pass the manifest.
env -i \
  PATH="$PATH" HOME="$HOME" \
  MNEMO_PARENT_BASENAME=systemd \
  mnemo mcp-server --manifest /etc/mnemo/manifest.toml

Under systemd:

[Service]
Type=simple
Environment=MNEMO_PARENT_BASENAME=systemd
ExecStart=/usr/local/bin/mnemo mcp-server --manifest /etc/mnemo/manifest.toml
ProtectSystem=strict
PrivateTmp=true
NoNewPrivileges=true

Verifying it

A quick “does the gauntlet actually fire” smoke test:

ANTHROPIC_API_KEY=leak mnemo mcp-server --manifest /etc/mnemo/manifest.toml
# refused to start: inherited sensitive env var "ANTHROPIC_API_KEY" ...

mnemo mcp-server --manifest /etc/mnemo/manifest.toml --config-json '{}'
# refused to start: command-line carries config-style argument ...

The full integration suite that exercises every refusal path lives in crates/mnemo-cli/tests/safe_spawn_integration.rs.

Role-aware tool filter (v0.4.2 — A1)

Mnemo’s MCP server aligns with the 2025-11-25 MCP authorization spec role-based annotations. The manifest can declare an optional [role_filter] block that gates tools/list (filters the advertised catalog) and tools/call (denies disallowed calls with a spec-compliant -32601).

[role_filter]
caller_roles = ["auditor"]
default      = "deny_all"

[role_filter.allow]
"mnemo.recall"   = ["auditor", "agent"]
"mnemo.verify"   = ["auditor"]
"mnemo.remember" = ["agent"]
"mnemo.forget"   = ["agent"]

[role_filter.deny]
"mnemo.delegate" = ["auditor"]

Rules:

  • Deny always wins. A tool that appears in both allow and deny for the same role is denied.
  • default = "allow_all" (the implicit default) lets any tool not named in allow/deny through. Use deny_all for a strict allow-list.
  • caller_roles declares the role assignment the operator has made for the binary itself. In stdio transport this is the entire caller identity; in future HTTP transports it composes with roles inferred from the Authorization header.
  • Every denied call emits an McpRoleDenied { caller_id, tool_name, attempted_at, reason } row to audit_log_path.
  • Omitting the block keeps pre-v0.4.2 behaviour byte-for-byte. Every advertised tool stays reachable and no audit events are emitted.

The filter contract (RoleFilter trait + ManifestRoleFilter impl) is public, so a custom filter can replace the manifest-driven default at test time. See crates/mnemo-mcp/src/role_filter.rs and the three integration tests under crates/mnemo-mcp/tests/ (role_filter_allow_deny.rs, role_filter_audit_event.rs, role_filter_no_block_when_unset.rs).

What this does NOT cover

  • Capability-leased reads (the old B2 follow-up) are not shipped: the never-wired lease store was removed as dead code. The design — per-read lease tokens gating forget_subject — is captured in #126 for a future authenticated, multi-caller transport where a lease has real cross-caller value (on stdio the operator is the only caller).
  • The DPDPA consent-token-per-write path (B4).
  • The Letta-protocol-compat surface (B5).
  • Per-tool-method enforcement of the role filter at tools/call dispatch — the manifest schema, the filter trait/impl, and the audit emission are shipped in v0.4.2; threading the filter through every MnemoServer tool method body is still pending. The mnemo-envelope OTel exporter kind that a later step depends on is not built — see docs/roadmap/planned-crates.md.

For the threat model and the full design notes, see the rationale at the top of crates/mnemo-cli/src/safe_spawn.rs.

Compatibility note (v0.4.3 — U1)

The MCP wire-protocol version mnemo’s server speaks (2024-11-05, with the 2025-11-25 authorization spec layered on top) is independent of the client SDK version your agent uses. SDK refreshes are common and don’t require a mnemo-side rev unless the spec itself moves.

The current version-skew matrix tracks tested combinations of the four official client SDKs:

  • mcp-python (refreshed 2026-05-01)
  • mcp-go (refreshed 2026-05-01)
  • mcp-ruby (refreshed 2026-05-02)
  • mcp-csharp (refreshed 2026-05-02)

If your agent hits an SDK-side incompatibility, consult the matrix first — most issues land on a row that documents which mnemo cut shipped against that SDK pair. The matrix is enforced in CI by crates/mnemo-mcp/tests/sdk_matrix_doc_present.rs, so the doc itself cannot silently disappear ahead of an SDK-bump release.

MCP 2026 Roadmap alignment (v0.4.4 — U1)

Superseded as a statement of current direction. This section maps mnemo against the March 2026 roadmap. The 2026-07-28 spec release came after it. For what mnemo actually implements today, revision by revision, see MCP 2026-07-28 conformance. The mapping below is kept as history.

The MCP 2026 Roadmap (published 2026-03-09 by lead maintainer David Soria Parra) reorganises the protocol’s direction around four priority areas. The honest mnemo stance against each is below — spec-context anchor, not compliance claim.

MCP 2026 priorityWhat it coversmnemo stance
Transport Evolution and ScalabilityStateless Streamable HTTP, .well-known server-discovery metadata, multi-tenant gateway behaviorFollower. mnemo speaks MCP via the rmcp = "3.0" workspace dep. SEPs land in rmcp first; mnemo upgrades when they’re stable, not before.
Agent CommunicationTasks-primitive lifecycle gaps; agent ↔ agent semantics outside the tool/resource layerObserver. mnemo’s mnemo.delegate + ACL/permission model is the existing surface; further coupling to a Tasks primitive waits on the SEP outcome.
Governance MaturationContributor ladder + WG delegation for the spec itselfObserver. Not a downstream surface mnemo participates in; we follow the spec the WGs ship.
Enterprise ReadinessAudit trails, SSO-integrated auth, gateway behavior, configuration portabilityAligned-by-design. Operator-held HMAC keystore (keystore_path in the manifest), AES-256-GCM at-rest content encryption (MNEMO_ENCRYPTION_KEY), mnemo-compliance crate’s DPDPA consent-token-per-write surface, dual DuckDB / PostgreSQL backend portability, and the role-aware tool filter (v0.4.2 §“Role-aware tool filter”) together form the attestable memory layer regulated-workflow buyers can defend today — independent of any one cloud’s audit boundary.

The honest framing: mnemo claims alignment-by-design with one of four priorities, not roadmap compliance. The other three priorities are spec-evolution work where mnemo follows rmcp’s implementation of the SEPs as they’re written. Buyers reading the roadmap should hear “mnemo’s existing audit story already serves the Enterprise Readiness ask,” not “mnemo is MCP-2026-ready.”

AMP / memorywire conformance (v0.4.13)

Alongside the MCP STDIO surface, mnemo ships an AMP / memorywire interop adapter in the mnemo-amp crate. AMP models memory as 5 operations (remember / recall / forget / merge / expire) over 4 memory types (episodic / semantic / procedural / working), carried in a self-describing JSON envelope validated against a JSON-Schema 2020-12 document (mnemo_amp::schema()).

The adapter is a MemoryStore-conformant surface over a real MnemoEngine. Two ops are deliberately thin compositions over existing primitives rather than assumed engine methods:

  • merge folds N records into one consolidated record (remember with SourceType::Consolidation) and retires the originals (forget with the Consolidate strategy). It is not mnemo’s engine.merge, which is a branch-timeline merge.
  • expire sets expires_at and runs the existing run_ttl_sweep lifecycle path (there is no engine.expire).

A fan-out AmpRouter broadcasts writes to several backends and fuses multi-adapter recall with Reciprocal Rank Fusion. An optional HITL diff-and-approve hook gates long-term (semantic / procedural) writes and records each approval as a Decision event in mnemo’s hash-chained audit log, so the approve trail is tamper-evident and replayable.

Conformance mirrors the cross-adapter suite: recall@5 on a small labelled corpus end-to-end against the embedded DuckDB backend, and RRF-holds-under-rank-0-injection vs max-fusion (RRF keeps a genuinely-relevant item on top; max-fusion is fooled by an adversarial rank-0 injection). Run the end-to-end smoke binary with cargo run --release --bin amp_conformance -p mnemo-amp.

Honest scope: the crate provides the wire format, the schema document, and the engine-backed surface. AMP transport framing (HTTP / stdio, .well-known schema discovery) is left to the embedding application.