Skip to main content

MCP Servers and the Memory Backend

llmenv treats MCP (Model Context Protocol) servers as a first-class config concept. Servers are declared once under mcp:, attached to scopes via tags (the same selection model as bundles), and rendered by each adapter into its agent-native config (for Claude Code: upserted into mcpServers in .claude.json).

llmenv's own memory backend is configured separately under memory:. It is a single networked service, not a generic MCP entry β€” its implementation (ICM, Infinite Context Memory) is deliberately hidden behind the memory: vocabulary.

For the config-field reference, see Configuration β†’ mcp: and memory:. This page covers the runtime model: the selection mechanism, the memory topology, the security model, and the tag-scoped-memory env var contract.

Selection model​

Every mcp entry carries tags. A server is included in the materialized output when any of its tags is present in the active tag set for the current environment β€” identical to how bundle entries fire. Scopes (network/host/ user/project) emit the tags; the intersection decides what is active.

mcp:
- name: playwright
when: [base] # active whenever the `base` tag is
command: npx
args: ["-y", "@playwright/mcp@latest"]

Server kinds​

A static server is either stdio (a local launch command) or remote (an HTTP/SSE URL):

mcp:
- name: playwright
when: [base]
type: stdio # default
command: npx
args: ["-y", "@playwright/mcp@latest"]
env:
DISPLAY: ":0"

- name: weather
when: [base]
type: http # http | sse
url: "https://weather.example.com/mcp"

Memory backend (memory:)​

(added in v1.0.0)

The memory backend is a single service that one host runs locally while every host β€” including the one running it β€” reaches over the network. The daemon (icm serve) is stdio-only, so on the server host llmenv wraps it in mcp-proxy to expose it on a TCP port; agents everywhere connect to that port.

  • On the designated server host, llmenv launches a local mcp-proxy bound to 0.0.0.0:<port> that bridges the stdio daemon onto the network.
  • Every agent, on every host, is configured with a remote client pointed at the server host's address: http://<addr>:<port>.

The server host needs mcp-proxy available β€” it's the stdio↔network bridge that exposes the icm serve daemon on a TCP port. llmenv resolves it one of two ways:

  • if mcp-proxy is on PATH, it's run directly (e.g. uv tool install mcp-proxy, pipx install mcp-proxy, or any install that lands it on PATH);
  • otherwise llmenv runs it on demand via uvx (uvx mcp-proxy), which fetches and caches it without a persistent install.

So the server host needs either mcp-proxy or uvx installed. If neither is present, llmenv export fails with an error telling you to install one or remove the memory: block. Client hosts need neither β€” they only open an HTTP connection to the server.

The server host's address comes from the top-level host: table:

host:
fixed:
addr: "fixed.local" # IP or resolvable hostname

features:
memory:
server_host: fixed # key into the `host:` table
port: 7878
when: [base] # activates the backend (same model as bundles)
default_topics: ["context-{project}", preferences]

How the topology is resolved​

  1. Scopes are evaluated against the current environment; the active host-scope ids and the active tag set are computed.
  2. If any of memory.when is active, the backend is selected: every agent gets a remote client at http://<addr>:<port> built from the host-table address.
  3. If this host matches server_host (its id is among the matched host scopes), the CLI also launches the local mcp-proxy bound to 0.0.0.0:<port>.

Proxy lifecycle on the server host​

(added in v3.8.0)

Every llmenv export on the server host checks that the proxy is up, so the check runs on each shell prompt and has to be both cheap and correct about what "up" means.

The port decides whether the proxy is running. llmenv opens a TCP connection to the bind address; if something answers, the proxy is up and nothing is started. The pidfile at $XDG_STATE_HOME/llmenv/mcp-proxy.pid records which process to signal β€” it is never treated as evidence that the proxy is alive. A missing or stale pidfile alongside a live proxy therefore doesn't cause a second one to be launched, and a pidfile naming a process that is no longer running is cleared rather than left to mislead.

A freshly spawned proxy is polled, not slept on. llmenv waits up to 5 s for the new process to open its socket, checking every 50 ms, so a fast start returns immediately and a slow one still succeeds. The budget is sized for the uvx path, which pays uv's resolve cost on top of interpreter startup (~2 s, more on a cold cache) β€” the direct PATH install binds in well under a second. If the proxy exits before binding, that's reported at once instead of waiting the budget out.

A spawn is guarded by a lockfile next to the pidfile, so several shells redrawing their prompts at once start one proxy rather than one each. The lockfile records the pid that holds it; if that process is gone β€” killed with ^C mid-start, say β€” the next export reclaims the lock instead of failing against it.

listen_host may be IPv6. llmenv brackets it when building the bind address (::1 and port 9092 become [::1]:9092), which is what both mcp-proxy and the liveness probe expect. Write the plain address in config; don't bracket it yourself.

The proxy's stderr is kept, at $XDG_STATE_HOME/llmenv/mcp-proxy.log (owner-readable only, and llmenv refuses to write through a symlink or FIFO left at that path). It rotates to mcp-proxy.log.1 once it passes 1 MiB, keeping one generation of history. When the proxy fails to start, llmenv quotes the last lines of that log in the warning, which is usually enough to see the cause directly:

warning: failed to ensure mcp-proxy running: mcp-proxy (pid 32097) exited
(exit status: 1) before binding to 0.0.0.0:9092; last lines of
/Users/you/.local/state/llmenv/mcp-proxy.log:
Traceback (most recent call last):
ImportError: cannot import name 'request_ctx' from 'mcp.server.lowlevel.server'

A failure here is a warning, not an error: llmenv export still emits its environment variables so the shell hook keeps working without the memory backend.

Placing a host on a network manually​

Network auto-detection (gateway MAC, SSID, CIDR) doesn't always work β€” a VPN, a captive network, or an unrecognized gateway can all leave the network scope unmatched, so the memory tag never activates and clients can't find the server.

Because the memory backend activates on any active tag, you can attach its tag to a host scope instead of relying on the network scope. A host scope matches by hostname (always reliable) and can emit the same tag the network scope would have:

scope:
network:
- id: home
match: { gateway_mac: "aa:bb:cc:dd:ee:ff" }
tags: [home] # fires when the gateway is detected
host:
- id: laptop
match: { hostname: laptop }
tags: [home] # always fires on this host β€” manual fallback

features:
memory:
server_host: fixed
port: 7878
when: [home] # active via either route

With this, laptop always emits home, so its agents always get the memory client URL β€” even when the network can't be auto-detected. The host that matches server_host additionally launches the local proxy.

Codebase memory (codebase_memory:)​

(added in v3.6.0)

codebase-memory-mcp is a local code-intelligence MCP server β€” a knowledge graph of a codebase's functions, classes, and call chains. Unlike the memory backend above, it has no remote-serve mode: it always runs as a local stdio process per project, so features.codebase_memory: entries carry no server_host/port β€” just activation tags and an optional index-path override.

features:
codebase_memory:
- when: [my-project]
index_path: null # optional; default <state_dir>/codebase-memory

llmenv always computes two environment variables when launching the server, never left to the user:

  • CBM_CACHE_DIR β€” the index storage directory
  • CBM_ALLOWED_ROOT β€” the current working directory, so index_repository can't be steered outside the intended project

Multiple codebase_memory entries may be active simultaneously β€” each is an independent local process, not a shared resource like the memory backend, so there's no "at most one active" restriction.

On SessionStart, llmenv fires a fire-and-forget codebase-memory-mcp cli index_repository call for the active project. This registers it with the server's own background auto-watch (auto_watch, upstream default true), which re-indexes on git changes automatically β€” llmenv doesn't implement its own reindex scheduling on top of that.

The index_repository name guard​

(added in v3.11.0)

index_repository takes an optional name that overrides the project key its index is stored under. codebase-memory-mcp doesn't check whether that key already belongs to a different repository, and a full reindex deletes and recreates the index file β€” so one call can replace an unrelated project's index with the current repo's data (upstream #1578). CBM_ALLOWED_ROOT doesn't prevent this: it bounds the tree that gets read, not the key that gets written, and the default CBM_CACHE_DIR is one directory shared by every project you've indexed.

When codebase-memory-mcp is active, llmenv registers a PreToolUse hook that denies any index_repository call carrying a name, explaining why in the deny reason. Calls without name β€” including llmenv's own SessionStart auto-index β€” are unaffected, so the tool stays auto-allowed and no per-session prompt appears.

This covers Claude Code and opencode, both of which receive the MCP server. Claude Code matches the hook to that one tool; opencode's plugin API has no per-tool matcher, so the hook runs on every tool call there and filters by name itself β€” which is why it is registered only when the MCP is wired.

If you genuinely need a custom project key, run codebase-memory-mcp yourself so overwriting an existing index is a deliberate choice:

codebase-memory-mcp cli index_repository '{"repo_path": "/path/to/repo", "name": "custom-key"}'

codebase_memory and memory (ICM) are fully independent: both can be active at once, and llmenv does not coordinate between them.

See Configuration β†’ features.codebase_memory: for the full field reference.

Security considerations​

The memory backend has no transport security and no access control:

  • The proxy binds to 0.0.0.0:<port> (all interfaces), and every client connects over plaintext http:// β€” there is no TLS, so anything stored in memory crosses the wire in the clear.
  • There is no authentication. Any host that can reach <addr>:<port> can read and write the memory backend. Access is gated only by network reachability β€” that is the trust model.

Deploy it only on a network you trust (home LAN, a private VPN, a firewalled subnet). Do not expose the port to the public internet, and do not point the host: addr at a publicly routable address. If you need to bridge hosts across an untrusted network, tunnel the port over SSH or a VPN rather than opening it directly.

Diagnostics​

List the MCP servers that resolve for the current environment:

llmenv mcp-ls # alias: llmenv mcps

llmenv doctor flags orphaned MCP config:

  • a server (or the memory backend) whose tags are never emitted by any scope (it can never activate),
  • a memory server_host with no entry in the host: table.
llmenv doctor

Troubleshooting​

Wrong role on a host​

Which host runs the memory server keys off whether the current host matches a host-scope whose id equals server_host. Verify the active scopes and tags:

llmenv scope-ls
llmenv tag-ls

Client can't reach the server​

Confirm the host: entry resolves and the port is open on the server host:

nc -vz fixed.local 7878

Server not activating​

The server only renders when one of its tags is active. Check that a scope in the current environment emits a matching tag (llmenv tag-ls).

Proxy won't start​

(added in v3.8.0)

The warning from llmenv export quotes the tail of the proxy's log. For the full output, read it directly:

tail -50 "${XDG_STATE_HOME:-$HOME/.local/state}/llmenv/mcp-proxy.log"

A common cause is a dependency resolution mcp-proxy can't import β€” it declares an open-ended mcp requirement, so uvx mcp-proxy can pick a combination that fails at import time. Pinning the install sidesteps it:

uv tool install mcp-proxy --with "mcp<2"

To reproduce a cold start deliberately, stop the proxy and let the next export bring it back:

pkill -f 'mcp-proxy --host'
llmenv export >/dev/null

Tag-scoped memory and the env var contract​

llmenv bridges the active scope into memory so that context can be stored once and recalled in any environment sharing the same tags β€” even across different projects. Two mechanisms carry this:

LLMENV_ICM_CONTEXT​

On every llmenv export, llmenv emits LLMENV_ICM_CONTEXT: a markdown chunk encoding the active tags, the firing bundles, and (when a project marker is active) the project name and description. Its shape:

## llmenv context
Active tags: `office`, `rust`
Bundles: `base`, `office-tools`

Store scope-specific memory under keyword `llmenv-tag:<tag>` (per tag)
or `llmenv-bundle:<bundle>` (per bundle) so it is retrievable across
projects. On each turn, llmenv auto-recalls memory under these tags'
`llmenv-tag:<tag>` and bundles' `llmenv-bundle:<bundle>` keywords
across all projects.

**Project:** MyApp β€” Customer-facing API

Agents read this to learn which tags are live and how to key memory so it follows the tag rather than the project.

Keyword convention​

  • llmenv-tag:<tag> β€” memory keyed to a tag. Stored once, retrieved in any environment where that tag is active. The TurnStart hook recalls this keyword automatically across all projects (see Lifecycle hooks).
  • llmenv-bundle:<bundle> β€” memory keyed to a bundle, retrieved whenever that bundle fires. The TurnStart hook recalls this keyword automatically across all projects (parallel to llmenv-tag:<tag>).

Lifecycle hooks​

llmenv provides engine-neutral lifecycle hooks (hook-run command) for three neutral events:

  • SessionStart β€” hook-run session_start injects the session wake-up pack (icm_wake_up) containing your critical memories (by importance and recency)
  • TurnStart β€” hook-run turn_start injects recalled context at the start of each agent turn (icm_memory_recall). It issues a project-scoped recall for the active tags, then one project-unfiltered recall per active tag keyed on llmenv-tag:<tag>, and one project-unfiltered recall per active bundle keyed on llmenv-bundle:<bundle> β€” so memory stored under a tag or bundle in one project surfaces when the same tag or bundle activates in another
  • SessionEnd β€” hook-run session_end stores the active scope context (icm_memory_store) when the session closes

The Claude Code adapter registers SessionStart/SessionEnd unconditionally β€” hook-run itself no-ops cheaply when no memory backend is configured, so this costs nothing for users who only want session logging and not ICM memory. TurnStart is not yet wired into settings.json (tracked in #499); running hook-run turn_start manually still works, but Claude Code doesn't call it automatically on every prompt today.

Each hook talks to the memory backend over MCP. Failures degrade gracefully: a missing or unreachable backend logs a warning and exits cleanly (exit code 0) so hooks never block the agent. See docs/commands.md for details.

Session logging​

hook-run session_start/hook-run session_end also drive session_log: β€” llmenv's separate event-stream feature that records lifecycle/scope (and, with verbose: true, every prompt and tool call) to a local file and/or ICM's transcript store. It shares the same MCP-only-access rule as the memory backend above, but is otherwise independent: it has its own on/off switch, doesn't require features.memory: to be configured, and a down ICM never blocks its file sink. See the session_log: reference for the full field list and icm_transcript_search query recipes.

SessionStart injection​

The Claude Code adapter registers a SessionStart hook. Alongside check-stale (drift detection), llmenv records the active tag/bundle set to a 0600 state file (icm.json in the state dir) so the hook can surface the keyword convention to the agent at startup. The hook-run session_start command is also invoked at session start to inject ICM memory.

LLMENV_ICM_CONTEXT is one of several vars export emits. The full set β€” LLMENV_ACTIVE_SCOPES, LLMENV_ACTIVE_TAGS, LLMENV_ACTIVE_BUNDLES, LLMENV_ACTIVE_PROJECT, LLMENV_PROJECT_ROOT, LLMENV_ICM_CONTEXT β€” is documented in the README and Concepts.