An MCP server fronting a plain-markdown vault for durable, multi-tenant agent memory. One server process serves many agents concurrently; each tool call carries its own scope (agent, user, …) so files are namespaced on disk while a human can still browse and hand-edit the vault directly with any editor (Obsidian works well for it). The name comes from Muninn, Odin's raven "memory" — twin to Huginn, "thought" — a fitting name for a long-term memory server.
Status: released. The current capability specs live under
openspec/specs/; proposed and archived changes live underopenspec/changes/.
- Exposes a small, generic tool surface (nine tools — see Tools).
- Speaks the official MCP protocol over both HTTP (default,
127.0.0.1:8000) and stdio via thermcpSDK. - Namespaces each agent's files with a configurable VFS suffix scheme and enforces a single server-wide policy across two regions (inside / outside the agents folder).
- Writes atomically (temp-file + fsync + rename), prevents path traversal, honours
.ignore/.gitignore/.obsidianignore(nested, per-directory) and hidden-file filters, and keeps all logs on stderr.
See docs/security.md for the trust model.
cargo install muninn # from crates.io (once published)
cargo install --path . # from a checkoutPre-built release binaries for x86_64-unknown-linux-gnu, aarch64-apple-darwin,
and x86_64-pc-windows-msvc are attached to each tagged release.
A minimal multi-arch image (linux/amd64, linux/arm64) is published to the
GitHub Container Registry on every tagged release. It is a statically linked
binary on scratch (no shell or OS userland) running as a non-root user.
docker pull ghcr.io/progamesigner/muninn:latestTags per release: :<version> (e.g. :0.1.0), the moving :latest, and an
immutable :sha-<gitsha>.
The published image is built with the tantivy recall backend and
defaults MUNINN_RECALL_BACKEND=tantivy, so BM25 recall works out of the box;
set MUNINN_RECALL_BACKEND=simple (or off) to opt out. The lightweight
simple backend is not published as an image — build it from source
(cargo build) or use a release binary.
Run it with a vault mounted at /vault (the image sets MUNINN_ROOT_DIR=/vault
and binds the HTTP transport to 0.0.0.0:8000):
# The mounted vault must be writable by the image's non-root UID (65532).
docker run --rm -p 8000:8000 \
-e MUNINN_HTTP_BEARER=change-me \
-v "$PWD/vault:/vault" \
ghcr.io/progamesigner/muninn:latestSet MUNINN_HTTP_BEARER for any deployment reachable off-host — the endpoint
is otherwise unauthenticated (a startup WARN is logged). Override any
configuration variable with -e.
Per-tenant scoped tokens. When one server is shared by several agents, mount
a tokens file and set MUNINN_HTTP_TOKENS_FILE so each client's bearer is
confined to its own scopes (see docs/security.md for the
grant model):
# tokens.json: { "tokens": [ { "token": "…", "scopes": { "agent": "jarvis", "user": "*" } } ] }
docker run --rm -p 8000:8000 \
-e MUNINN_HTTP_TOKENS_FILE=/run/secrets/tokens.json \
-v "$PWD/tokens.json:/run/secrets/tokens.json:ro" \
-v "$PWD/vault:/vault" \
ghcr.io/progamesigner/muninn:latestReachable by hostname (Kubernetes, ingress). The http transport applies
DNS-rebinding protection and, by default, only accepts the loopback hosts
localhost, 127.0.0.1, and ::1 in the inbound Host header. When clients
reach the server through a Kubernetes Service DNS name or an ingress hostname,
set MUNINN_HTTP_ALLOWED_HOSTS to those hostnames or every request is
rejected with 403:
docker run --rm -p 8000:8000 \
-e MUNINN_HTTP_BEARER=change-me \
-e MUNINN_HTTP_ALLOWED_HOSTS=muninn.default.svc.cluster.local,muninn.example.com \
-v "$PWD/vault:/vault" \
ghcr.io/progamesigner/muninn:latestA bare hostname matches any port; add :port to pin one. The single value *
disables Host validation entirely — only appropriate when an upstream proxy or
ingress already enforces Host trust.
Health checks. The image has no shell, so it cannot carry a Docker
HEALTHCHECK. Probe the HTTP routes from your orchestrator instead. There are
two: GET /healthz (liveness — succeeds as soon as the process is up, never
gated on the recall index) and GET /readyz (readiness — 503 until the recall
index has finished its eager build, 200 thereafter, or immediately when recall
is disabled). Both are reachable without a bearer token.
# Kubernetes — the kubelet probes over HTTP, nothing runs inside the container.
livenessProbe:
httpGet: { path: /healthz, port: 8000 }
readinessProbe:
httpGet: { path: /readyz, port: 8000 }
# A long cold build over a large vault fits a startupProbe with a high
# failureThreshold, after which liveness/readiness take over.
startupProbe:
httpGet: { path: /readyz, port: 8000 }
failureThreshold: 60
periodSeconds: 5A Docker Compose healthcheck: runs its command inside the container and so
cannot work against this image (no shell, no wget/curl). Probe /healthz
from outside instead — the orchestrator, a sidecar, or an external monitor.
Every published image carries the full set of dynamic OCI labels
(org.opencontainers.image.{created,revision,version,title,description,source,url,authors,documentation,vendor}):
docker inspect ghcr.io/progamesigner/muninn:latest -f '{{json .Config.Labels}}'# HTTP transport (default), loopback, no auth — ideal for local development.
MUNINN_ROOT_DIR=/path/to/vault muninn
# Inspect the effective configuration without starting the server.
MUNINN_ROOT_DIR=/path/to/vault muninn --print-configEverything is configured through environment variables. Every CLI flag mirrors —
and overrides — the matching variable (--root-dir, --policy, --http-bind, …).
MUNINN_ROOT_DIR is the only required variable; all others have defaults.
| Variable | Default | Description |
|---|---|---|
MUNINN_ROOT_DIR |
(required) | Absolute path to the vault root. Must exist, be a directory, and be canonicalisable. |
MUNINN_AGENTS_DIR |
Agents |
Agents folder relative to the root. . or empty means the vault root itself is the agents folder. Must be relative with no traversal. |
MUNINN_VFS_SCHEME |
<agent>.<user> |
VFS suffix scheme. Each <ident> becomes a required scope parameter on every tool call. Empty string disables suffixing. |
MUNINN_SESSION_CONTEXT_TEMPLATE_FILE |
<root>/AGENT_SESSION_CONTEXT.md |
Path to the global full session-context template (see Session context). Relative paths resolve against the vault root. Need not exist — falls back to the compiled-in default. |
MUNINN_SESSION_BOOTSTRAP_TEMPLATE_FILE |
<root>/AGENT_SESSION_BOOTSTRAP.md |
Path to the global lean session-bootstrap template (see Session context). Relative paths resolve against the vault root. Need not exist — falls back to the compiled-in default. |
MUNINN_MEMORY_LAYOUT_TEMPLATE_FILE |
<root>/AGENT_MEMORY_LAYOUT.md |
Path to the global memory-layout template (see Session context). Relative paths resolve against the vault root. Need not exist — falls back to the compiled-in default. |
MUNINN_POLICY |
namespaced |
One of scoped, namespaced, readonly, readwrite (see Policies). |
MUNINN_TRANSPORT |
http |
http or stdio. |
MUNINN_HTTP_BIND |
127.0.0.1:8000 |
HTTP bind address (http transport only). |
MUNINN_HTTP_BEARER |
(unset) | If set, POST/GET /mcp requires Authorization: Bearer <token>. The static bearer carries the all-scopes grant. When both this and MUNINN_HTTP_TOKENS_FILE are unset → unauthenticated (a startup WARN is logged). |
MUNINN_HTTP_TOKENS_FILE |
(unset) | Path to a JSON file mapping bearer tokens to per-scope grants: { "tokens": [ { "token": "…", "scopes": { "agent": "jarvis", "user": "*" } } ] }. Grant keys must be the active scheme's placeholders; values are exact strings or *; duplicate tokens union their grants. Requests presenting a scoped token may only name scopes its grant covers (scope_denied / HTTP 403 on /v1/context otherwise); unknown bearers get 401. Read once at startup (rotation requires a restart); invalid files fail startup; token values never appear in logs or --print-config. Ignored under the stdio transport. |
MUNINN_HTTP_ALLOWED_HOSTS |
(unset) | Comma-separated Host allow-list for the http transport's DNS-rebinding protection. Unset → loopback only (localhost, 127.0.0.1, ::1). List the cluster/ingress hostnames clients use (e.g. muninn.default.svc.cluster.local,muninn.example.com:8000); a bare hostname matches any port. The single value * disables Host validation (logs a WARN) — only for deployments that terminate Host trust at an upstream proxy. |
MUNINN_TIMEZONE |
UTC |
IANA timezone used to date diary entries. |
MUNINN_HONOR_IGNORE_FILES |
true |
Honour .ignore / .gitignore / .obsidianignore (nested, composed per-directory like git) for list and direct addressing. Strict boolean (true/false). |
MUNINN_INCLUDE_HIDDEN |
false |
Include dotfiles/dot-directories. Strict boolean. |
MUNINN_INCLUDE_HIDDEN_GLOBS |
(empty) | Comma-separated gitignore-style globs (relative to the vault root) whose matches — and their whole subtree — are exempt from hidden filtering while other dotfiles stay excluded. E.g. .obsidian/**,**/.config. Ignore-file rules still apply unless also disabled. |
MUNINN_LOG |
warn,muninn=info |
tracing env-filter directive. Logs always go to stderr. |
MUNINN_RECALL_BACKEND |
simple |
Content-recall backend: simple (case-insensitive substring + regex), tantivy (BM25 ranking + frontmatter property filters; requires the recall-tantivy build feature, else falls back to simple), or off (no recall_memory_notes tool). |
MUNINN_RECALL_WATCH_DEBOUNCE_MS |
500 |
Filesystem-watcher debounce window for external edits. |
MUNINN_RECALL_REGEX_SCAN_BYTES |
67108864 |
Byte cap on a regex-only recall scan before it truncates (the result is flagged). |
MUNINN_RECALL_MAX_RESIDENT_SCOPES |
256 |
Cap on resident per-scope indexes before idle ones are evicted (they rebuild on next use). |
MUNINN_RECALL_FRESHNESS_MS |
2000 |
How long an index stays fresh before a query re-runs the stat-diff reconcile (the watcher can mark it dirty sooner). |
The scheme defines how scope keys are spelled into directory segments and
filename stems. With the default <agent>.<user> scheme and caller
{agent: "jarvis", user: "tony"}, the virtual path Agents/tasks/plan.md
resolves to:
<root>/Agents/jarvis.tony/tasks/plan.jarvis.tony.md
The scope appears both as the per-scope directory and as a suffix on the file stem, so a human opening the vault in any editor can immediately see whose file is whose, and another scope's file is structurally unaddressable.
Default config (MUNINN_AGENTS_DIR=Agents, MUNINN_VFS_SCHEME=<agent>.<user>):
vault/
├── Agents/ ← agent-owned region (scoped, suffixed)
│ └── jarvis.tony/
│ ├── PERSONA.jarvis.tony.md
│ ├── MEMORY.jarvis.tony.md
│ ├── HEARTBEAT.jarvis.tony.md
│ └── diary/2026-05-25.jarvis.tony.md
└── Actions/release.md ← human-owned region (shared, no suffix)
Vault root as agents folder (MUNINN_AGENTS_DIR=.): the whole vault is the
agents folder, there is no "outside" region, and wrapper tools resolve to
PERSONA.jarvis.tony.md at the vault root.
| Tool | Purpose |
|---|---|
list_memory_notes |
Paginated listing of virtual paths visible to the scope (limit default 200, max 1000; opaque cursor; optional path_prefix). |
read_memory_note |
Read a note by virtual path. |
read_memory_notes |
Read 1–20 notes in one call. Returns one notes entry per requested path, in request order: { path, content } on success or { path, error: { code, message } } on failure. Per-path semantics match read_memory_note; a per-path failure never fails the call (only malformed arguments do). |
write_memory_note |
Atomic full-file write; returns the byte count. With append: true, content is appended verbatim instead (exact bytes, no separator; a missing note is created), serialised under the per-target lock; the byte count is the note's total size after the write. |
edit_memory_note |
Atomic search/replace; the search string must occur exactly once. |
delete_memory_note |
Delete a single file (never directories). |
rename_memory_note |
Move a note from path to new_path, rewriting every visible incoming link (aliases, headings, and embeds preserved) and the note's own self-references to resolve to the new location. The destination must not already exist (destination_exists); all preconditions are validated before the first write. |
read_note_properties |
Read a note's frontmatter properties as a JSON object in { properties }. Absent, unterminated, or malformed frontmatter yields an empty object — never an error. Read gating matches read_memory_note; root core files are readable. |
update_note_properties |
Merge a JSON object into a note's frontmatter atomically under the per-target lock: each key is upserted with its JSON value, an explicit null deletes a key, and the body stays byte-identical. The block is created when absent, removed when the merge empties it, and re-serialized in normalized form (sorted keys; comments not preserved); a leading fence that is not valid YAML is refused with invalid_argument. The note must exist; write gating matches the generic write tools (root core files stay wrapper-only). Returns the full post-update { properties }. |
load_session_context |
Render the scope's session-context (see Session context); returns { rendered, missing }. |
evolve_core_persona |
Atomic write to one of the five foundational files (persona/prompt/rules/user/memory), selected by which. Enforces line caps: USER.md ≤ 100, MEMORY.md ≤ 200. |
update_task_heartbeat |
Atomic write to HEARTBEAT.md. |
append_diary_entry |
Append a timestamped section to diary/<YYYY-MM-DD>.md; a newly created file opens with a # <YYYY-MM-DD> H1, and an optional title makes the heading ## <HH:MM:SS> — <title>. |
recall_memory_notes |
Search notes by content within the caller's visible set; returns ranked { path, score (0–1), snippets, modified_at }. Supply at least one of query (full-text), regex, filters (frontmatter properties), or the modified_after/modified_before time bounds. Paginated like list_memory_notes. Present unless MUNINN_RECALL_BACKEND=off. |
Every tool's input schema includes the scope parameters derived from the active
scheme; introspect them via the standard MCP tools/list call.
recall_memory_notes finds notes by content so an agent need not list-and-read to
locate them. It is backed by an in-memory index, the way Obsidian works —
nothing is written to disk; the index is built eagerly at startup, updated
synchronously on the server's own writes, and reconciled against external edits by
a filesystem watcher with a stat-diff backstop. The index is disposable: delete
the process and it rebuilds from the vault.
Isolation is structural: each scope has its own in-memory index plus one shared-region index, so a query opens only the caller's index and (policy permitting) the shared one — another scope's content is unreachable, not merely filtered.
Two backends, selected by MUNINN_RECALL_BACKEND:
-
simple(default) — case-insensitive substringqueryandregex. No ranking beyond match count; frontmatter propertyfiltersreturnunsupported. -
tantivy— BM25-ranked full-text, snippet generation, and frontmatter propertyfilters(eq,contains,exists, numeric/lexical comparisons). Requires building with the feature; otherwise the server logs a fallback tosimple:cargo build --release --features recall-tantivy
Cross-index scores are normalized to 0–1 per index before merging. On a large
vault the eager cold build is gated by GET /readyz (see Container image); a regex-only query is byte-capped and flags truncation in its result.
Every hit carries modified_at (RFC 3339, UTC), and the optional
modified_after/modified_before arguments bound results by modification time.
Each accepts an RFC 3339 timestamp or a bare YYYY-MM-DD date interpreted as
start of day in MUNINN_TIMEZONE, forming a half-open interval
(modified_after ≤ mtime < modified_before). A time bound is a sufficient
predicate on its own: with no query/regex/filters, hits come straight from
the in-memory manifest with no content scan, ordered by modified_at descending
with score: 1.0 and empty snippets — "what did I work on recently?" costs no
reads. Combined with a content predicate, the bounds filter the hits and score
ordering stands. The compared time is the filesystem mtime — restores and sync
tools may carry old timestamps — and the behavior is identical on both backends.
Inside the agents folder the root level is wrapper-only: the core files
(PERSONA.md, PROMPT.md, RULES.md, USER.md, MEMORY.md, HEARTBEAT.md)
are changed only through evolve_core_persona / update_task_heartbeat.
write_memory_note / edit_memory_note / delete_memory_note may only target
paths under a subfolder; a root-level target is rejected with path_not_permitted.
Reads of root files remain allowed.
Notes may reference each other with Obsidian [[wikilink]] syntax and relative
markdown links [text](path.md). Write the shortest unambiguous note name
([[rust]], or [[topics/rust]] when a bare basename is shared by two visible
notes) — the server resolves it against your visible set (your own scope plus the
shared region) exactly as Obsidian resolves by basename.
Links round-trip transparently. On read you only ever see clean shortest names; on
write a link to your own scoped note is rewritten to its on-disk suffixed form so a
human browsing the vault in Obsidian can follow it, and a link to a shared note is
left clean. Aliases ([[t|alias]]), headings ([[t#h]]), and embeds (![[t]])
are preserved. A link that does not resolve is left verbatim as a dangling link.
Because a suffixed link would expose your scope's existence, a note in the shared
region that links to one of your own scoped notes is rejected with
write_denied; link shared notes from shared notes, or scoped notes from scoped
notes.
A session-context template weaves the five foundational files
(PERSONA/PROMPT/RULES/USER/MEMORY) together with operator prose into a
single rendered bootstrap. It is an ordinary markdown document with {{…}}
placeholders:
{{files.persona}},{{files.prompt}},{{files.rules}},{{files.user}},{{files.memory}}— the foundational file contents (a missing file renders a sentinel){{scope.<key>}}— a scope value (e.g.{{scope.agent}});<key>is any scheme placeholder{{scope_directive}}— the server-generated banner naming the active scope keys{{onboarding_directive}}— empty unless foundational files are missing, then a directive to interview the user and commit them viaevolve_core_persona
The renderer produces two kinds, each with its own template family:
- Full context (
AGENT_SESSION_CONTEXT.md): orders the sectionsPERSONA → RULES → MEMORY → USER → PROMPT, then the onboarding directive and a pointer to the layout. For the model to pull its full operating context. - Lean bootstrap (
AGENT_SESSION_BOOTSTRAP.md): scope banner +PERSONA+RULES+ onboarding directive + pointers to the full context and the layout — small enough to fit a SessionStart byte budget.
The layout (vault structure, the core-files-vs-filesystem distinction, the
path-addressing rule, and the documented line caps) lives in its own
AGENT_MEMORY_LAYOUT.md, served on demand rather than embedded in every render.
External-tool facts (camera, SSH, etc.) belong in PROMPT.md. Operators who
supply their own templates fully control that prose.
Each template is resolved per request, first hit wins (the bootstrap and layout follow the same layering with their own filenames and env vars):
- a per-scope file inside the agents folder (suffix-resolved like any scoped file)
- the global file at
MUNINN_SESSION_CONTEXT_TEMPLATE_FILE/MUNINN_SESSION_BOOTSTRAP_TEMPLATE_FILE/MUNINN_MEMORY_LAYOUT_TEMPLATE_FILE - a compiled-in default
Nothing errors on absence — a fresh vault renders an instructions-only bootstrap.
Unknown {{…}} tokens are left literal. The renders are exposed through MCP
resources/prompt/tool and plain-HTTP endpoints:
| Surface | Shape | For |
|---|---|---|
load_session_context tool |
{ rendered, missing } (full context) |
the model pulling its own context mid-session |
session-context resource |
muninn://session-context/{…} (full context) |
client auto-attach |
session-bootstrap resource |
muninn://session-bootstrap/{…} (lean) |
session-start injection |
session-layout resource |
muninn://session-layout/{…} (layout) |
on-demand vault mechanics |
session-context prompt |
required args follow the scheme | user slash-command |
GET /v1/context |
text/markdown (or { rendered, missing } JSON) |
full context without MCP |
GET /v1/bootstrap |
text/markdown (or JSON) |
lean session-start hook without MCP |
GET /v1/layout |
text/markdown (or JSON) |
layout without MCP |
Versioned, stateless, read-only HTTP routes (HTTP transport only). /v1/context
renders the full context, /v1/bootstrap the lean bootstrap (the recommended
SessionStart hook target), and /v1/layout the vault-mechanics guidance. Each
VFS-scheme placeholder is a query parameter; the scope is bound in scheme order:
# Lean bootstrap — markdown by default, drop straight into a system prompt.
curl 'http://127.0.0.1:8000/v1/bootstrap?agent=jarvis&user=tony'
# Full context as JSON ({ rendered, missing }) via content negotiation.
curl -H 'Accept: application/json' \
'http://127.0.0.1:8000/v1/context?agent=jarvis&user=tony'
# Vault layout and conventions.
curl 'http://127.0.0.1:8000/v1/layout?agent=jarvis&user=tony'All three share one behavior: missing, empty, or unexpected scope parameters
return 400 with a { "error": … } body; absent foundational files are never
errors. They sit behind the same authentication gate as /mcp (add
-H "Authorization: Bearer <token>" when MUNINN_HTTP_BEARER or
MUNINN_HTTP_TOKENS_FILE is configured); a scoped token requesting a scope
outside its grant gets 403. Only the /healthz and /readyz probes are
always reachable.
To bootstrap this context automatically at the start of every session — via a
Claude Code / Codex SessionStart hook, an opencode plugin, or the MCP
fallback for other clients — see
docs/session-context-hooks.md.
There are two regions per server: inside the agents folder (scoped, suffix-applied) and outside it (shared, no suffix). One server-wide policy governs both:
| Policy | Inside agents folder | Outside agents folder |
|---|---|---|
scoped |
own-scope read/write | denied |
namespaced (default) |
own-scope read/write | read-only |
readonly |
own-scope read-only | read-only |
readwrite |
own-scope read/write | read/write |
Anything resolving outside the vault root is always denied.
Every MCP client connects over one of the two transports:
- stdio sidecar — the client launches
muninnitself, so it owns the process lifecycle. You supplyMUNINN_ROOT_DIRandMUNINN_TRANSPORT=stdiothrough the client's env block. The binary must be onPATH(or give an absolute path). - Local HTTP — you run
muninnyourself (HTTP is the default transport, on127.0.0.1:8000) and point the client athttp://127.0.0.1:8000/mcp. Start the server before the client connects. - Docker sidecar — a stdio sidecar that needs no local
muninnbinary: the client launchesdocker run -i ... ghcr.io/progamesigner/muninn:latest, which speaks stdio over the container's stdin/stdout. The image already setsMUNINN_ROOT_DIR=/vault, so mount your vault there.
The snippets below show all three per client. When MUNINN_HTTP_BEARER
is set, attach Authorization: Bearer <token> through whatever header mechanism
the client exposes (noted inline where relevant).
claude_desktop_config.json (stdio only):
{
"mcpServers": {
"muninn": {
"command": "muninn",
"env": {
"MUNINN_ROOT_DIR": "/Users/me/vault",
"MUNINN_TRANSPORT": "stdio"
}
},
"muninn-docker": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MUNINN_TRANSPORT=stdio",
"-v", "/Users/me/vault:/vault",
"ghcr.io/progamesigner/muninn:latest"
]
}
}
}Add it from the CLI — stdio (note the -- before the server command):
claude mcp add muninn \
--env MUNINN_ROOT_DIR=/path/to/vault --env MUNINN_TRANSPORT=stdio \
-- muninn
# …or HTTP against an already-running server:
claude mcp add --transport http muninn http://127.0.0.1:8000/mcpOr commit a project-scoped .mcp.json (mcpServers key; command/env for
stdio, type/url for HTTP):
{
"mcpServers": {
"muninn-stdio": {
"command": "muninn",
"env": { "MUNINN_ROOT_DIR": "/path/to/vault", "MUNINN_TRANSPORT": "stdio" }
},
"muninn-http": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp"
},
"muninn-docker": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MUNINN_TRANSPORT=stdio",
"-v", "/path/to/vault:/vault",
"ghcr.io/progamesigner/muninn:latest"
]
}
}
}~/.codex/config.toml — each server is a [mcp_servers.<name>] table. A command
key makes it a stdio server; a url key (no command) makes it streamable HTTP:
# stdio sidecar
[mcp_servers.muninn]
command = "muninn"
env = { MUNINN_ROOT_DIR = "/path/to/vault", MUNINN_TRANSPORT = "stdio" }
# …or HTTP against an already-running server (use one table or the other)
[mcp_servers.muninn]
url = "http://127.0.0.1:8000/mcp"
# bearer_token_env_var = "MUNINN_TOKEN" # if MUNINN_HTTP_BEARER is set
# …or a Docker stdio sidecar (no local binary needed)
[mcp_servers.muninn-docker]
command = "docker"
args = ["run", "-i", "--rm", "-e", "MUNINN_TRANSPORT=stdio", "-v", "/path/to/vault:/vault", "ghcr.io/progamesigner/muninn:latest"]codex mcp add muninn -- muninn is the stdio shortcut; HTTP servers are added
by editing config.toml.
Edit the MCP config at ~/.gemini/antigravity/mcp_config.json (in the IDE: Agent
panel → MCP Servers → Manage MCP Servers → View raw config). The
top-level key is mcpServers, and HTTP uses serverUrl — not url:
{
"mcpServers": {
"muninn-stdio": {
"command": "muninn",
"env": { "MUNINN_ROOT_DIR": "/path/to/vault", "MUNINN_TRANSPORT": "stdio" }
},
"muninn-http": {
"serverUrl": "http://127.0.0.1:8000/mcp"
},
"muninn-docker": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MUNINN_TRANSPORT=stdio",
"-v", "/path/to/vault:/vault",
"ghcr.io/progamesigner/muninn:latest"
]
}
}
}Workspace .vscode/mcp.json — Copilot (agent mode) reads VS Code's MCP config.
The top-level key is servers (not mcpServers) and every entry carries an
explicit type. Optional inputs prompt for secrets:
{
"inputs": [
{ "type": "promptString", "id": "muninn-token", "description": "Muninn bearer", "password": true }
],
"servers": {
"muninn-stdio": {
"type": "stdio",
"command": "muninn",
"env": { "MUNINN_ROOT_DIR": "${workspaceFolder}/vault", "MUNINN_TRANSPORT": "stdio" }
},
"muninn-http": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp",
"headers": { "Authorization": "Bearer ${input:muninn-token}" }
},
"muninn-docker": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MUNINN_TRANSPORT=stdio",
"-v", "${workspaceFolder}/vault:/vault",
"ghcr.io/progamesigner/muninn:latest"
]
}
}
}Drop the inputs block and the headers line when no bearer is configured.
VS Code's native MCP support uses the same .vscode/mcp.json format shown for
Copilot above (the servers key, type: stdio / type: http). The only
difference is where it can live: per workspace in .vscode/mcp.json, or for all
workspaces in your user profile — open the latter via the command palette
(MCP: Open User Configuration) rather than editing it by hand.
opencode.json — servers live under the mcp key. OpenCode names the transports
local/remote, takes command as an array, and uses environment (not
env):
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"muninn-local": {
"type": "local",
"command": ["muninn"],
"enabled": true,
"environment": { "MUNINN_ROOT_DIR": "/path/to/vault", "MUNINN_TRANSPORT": "stdio" }
},
"muninn-remote": {
"type": "remote",
"url": "http://127.0.0.1:8000/mcp",
"enabled": true
},
"muninn-docker": {
"type": "local",
"command": ["docker", "run", "-i", "--rm", "-e", "MUNINN_TRANSPORT=stdio", "-v", "/path/to/vault:/vault", "ghcr.io/progamesigner/muninn:latest"],
"enabled": true
}
}
}For a bearer-protected remote, add "headers": { "Authorization": "Bearer …" }.
Most clients accept the canonical mcpServers block — command/env for a
stdio sidecar, url for Local HTTP:
{
"mcpServers": {
"muninn-stdio": {
"command": "muninn",
"env": { "MUNINN_ROOT_DIR": "/path/to/vault", "MUNINN_TRANSPORT": "stdio" }
},
"muninn-http": {
"url": "http://127.0.0.1:8000/mcp"
},
"muninn-docker": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MUNINN_TRANSPORT=stdio",
"-v", "/path/to/vault:/vault",
"ghcr.io/progamesigner/muninn:latest"
]
}
}
}For poking the HTTP transport directly, without an MCP client:
# Liveness and readiness probes.
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/readyz
# An MCP request (Streamable HTTP requires the dual Accept header).
curl -X POST http://127.0.0.1:8000/mcp \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'When MUNINN_HTTP_BEARER is set, add -H "Authorization: Bearer <token>".
Because the on-disk layout is plain markdown, a human can open the vault in any
editor — Obsidian is a convenient choice — and hand-edit any Agents/<scope>/...
file directly. The agent will see
the human's edits as if it had written them itself — this is the supported channel
for curating or correcting an agent's memory. Creating plan.jarvis.tony.md by
hand makes it appear to the jarvis.tony scope as the virtual note plan.md.
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features