Add MCP server: expose memwalk as tools for Claude Code / opencode / agents

New `memwalk mcp` subcommand starts a stdio MCP server exposing four
tools: ask, standup, status, update. The underlying memba Session is
loaded lazily on the first call that needs the model, then reused for
the lifetime of the process — first query ~2s, subsequent <500ms.

- memwalk/mcp_server.py — Server + tool list/dispatch using mcp SDK
- memwalk/cli.py        — `memwalk mcp` subcommand wires it up
- pyproject.toml        — mcp>=1.0.0 dependency
- README.md             — setup instructions for Claude Code and opencode

Verified locally: tools/list returns all 4, tools/call status returns
the expected JSON without loading the model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-16 14:04:25 +03:00
co-authored by Claude Opus 4.7
parent 564478710e
commit 9877edd610
4 changed files with 253 additions and 0 deletions
+49
View File
@@ -53,6 +53,55 @@ saved to `~/.memwalk/current.memb` via memba.
`memwalk ask` and `memwalk standup` load that state and query it. The model
recalls themes, projects, and trajectory across processes and reboots.
## Use from an agent (MCP)
memwalk ships an MCP server so Claude Code / opencode / any MCP-aware
agent can query your memory as native tools.
```bash
memwalk mcp # starts a stdio MCP server
```
Tools exposed: `ask(question)`, `standup()`, `status()`, `update()`.
The Session loads lazily on the first call that needs it, then stays in
memory — first call ~2 s, subsequent calls <500 ms.
### Configure Claude Code
Easiest way (Claude Code CLI):
```bash
claude mcp add memwalk -- memwalk mcp
```
Or by hand, in `~/.claude/mcp_servers.json` (path may vary by version):
```json
{
"mcpServers": {
"memwalk": {
"command": "memwalk",
"args": ["mcp"]
}
}
}
```
Restart Claude Code. Tools appear as `mcp__memwalk__ask`,
`mcp__memwalk__standup`, etc.
### Configure opencode
opencode uses its own MCP block in `opencode.json`:
```json
{
"mcpServers": {
"memwalk": { "command": "memwalk", "args": ["mcp"] }
}
}
```
## Layout
```
+16
View File
@@ -167,6 +167,22 @@ def status() -> None:
console.print(table)
# ── mcp ───────────────────────────────────────────────────────────
@cli.command()
def mcp() -> None:
"""Run as an MCP server (stdio) for Claude Code / opencode / Hermes / etc.
The Session is loaded lazily on the first tool call that needs it, then
reused — so subsequent queries are fast. Configure your agent to launch
this command; for Claude Code add to ~/.claude/mcp_servers.json:
{"mcpServers": {"memwalk": {"command": "memwalk", "args": ["mcp"]}}}
"""
from .mcp_server import main as mcp_main
mcp_main()
# ── prune ─────────────────────────────────────────────────────────
@cli.command()
+187
View File
@@ -0,0 +1,187 @@
"""
MCP server — exposes memwalk as tools for Claude Code / opencode / any
MCP-aware agent. Runs over stdio.
Tools:
ask(question) — query the current memwalk state, returns the answer text
standup() — generate standup notes from accumulated activity
status() — config + state metadata (no model load required)
update() — refresh state from git/bash (slow, on demand)
Lifetime model: the underlying memba Session is loaded lazily on the first
tool call that needs it, then reused for the rest of the process — so the
first query pays ~2s of model+state load, subsequent queries are <500ms.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
from . import __version__
from .config import load_config, read_last_update
from .ingest import open_session, update as run_update
_server = Server("memwalk")
# Singleton session — created on first call that needs it
_session = None
_QUERY_FRAMING = (
"Drawing on the work activity I shared with you earlier, please answer "
"this clearly and concretely:\n\n"
)
def _get_session():
"""Lazy-load (or reload) the memba Session."""
global _session
if _session is None:
cfg = load_config()
_session = open_session(cfg)
return _session
def _reset_session() -> None:
"""Drop the cached session — used after `update` so next query sees fresh state."""
global _session
_session = None
# ── Tool declarations ────────────────────────────────────────────
@_server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="ask",
description=(
"Query the user's accumulated work memory. Returns the model's "
"natural-language answer based on git commits and shell activity "
"previously ingested by memwalk. Use for questions like "
"'what was I working on last week?', 'which project saw the most "
"activity?', 'when did I start branch X?'."
),
inputSchema={
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "Natural-language question about the user's recent work.",
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens to generate (default 400).",
"default": 400,
},
},
"required": ["question"],
},
),
Tool(
name="standup",
description=(
"Generate concise daily-standup notes from the user's recent "
"activity: what they did yesterday (grouped by project), planned "
"next steps, and any blockers visible in commit messages."
),
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="status",
description=(
"Return memwalk configuration and state metadata as JSON. "
"Cheap — does not load the model. Useful for sanity-checking "
"whether memwalk has up-to-date data."
),
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="update",
description=(
"Ingest new git+bash activity into the state. Slow (a few seconds — "
"loads the model). Call only when the user explicitly asks for a "
"refresh, or when status() shows the last update is stale."
),
inputSchema={"type": "object", "properties": {}},
),
]
# ── Tool dispatch ────────────────────────────────────────────────
@_server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name == "ask":
question = arguments.get("question", "").strip()
if not question:
return [TextContent(type="text", text="error: question is required")]
sess = await asyncio.to_thread(_get_session)
answer = await asyncio.to_thread(
sess.chat,
_QUERY_FRAMING + question,
arguments.get("max_tokens", 400),
)
return [TextContent(type="text", text=answer)]
if name == "standup":
sess = await asyncio.to_thread(_get_session)
standup_q = (
"Generate my daily standup notes. Cover: what I worked on yesterday "
"(grouped by project), what I plan today based on the trajectory, and "
"any blockers visible in the activity. Be concise — bullet points, "
"no preamble."
)
answer = await asyncio.to_thread(
sess.chat, _QUERY_FRAMING + standup_q, 500
)
return [TextContent(type="text", text=answer)]
if name == "status":
cfg = load_config()
last = read_last_update(cfg)
info = {
"version": __version__,
"model_path": str(cfg.model_path),
"state_file": str(cfg.state_path),
"state_bytes": cfg.state_path.stat().st_size if cfg.state_path.exists() else 0,
"last_update": last.isoformat() if last else None,
"scan_paths": [str(p) for p in cfg.git.scan_paths],
"bash_enabled": cfg.bash.enabled,
}
return [TextContent(type="text", text=json.dumps(info, indent=2))]
if name == "update":
cfg = load_config()
result = await asyncio.to_thread(run_update, cfg)
_reset_session() # next ask/standup sees the freshly written state
summary = (
f"Ingested {result['ingested']} events "
f"({result['git']} commits + {result['bash']} shell sessions) "
f"in {result['elapsed_s']:.1f}s. "
f"State now {result['state_size']:,} bytes."
) if result["ingested"] else (
f"No new activity since {result['since'].strftime('%Y-%m-%d %H:%M')}."
)
return [TextContent(type="text", text=summary)]
return [TextContent(type="text", text=f"unknown tool: {name}")]
# ── Entry point ──────────────────────────────────────────────────
async def _run() -> None:
async with stdio_server() as (read, write):
await _server.run(read, write, _server.create_initialization_options())
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()
+1
View File
@@ -27,6 +27,7 @@ dependencies = [
"memba>=0.1.0",
"typer>=0.9.0",
"rich>=13.0.0",
"mcp>=1.0.0",
"tomli>=2.0;python_version<'3.11'",
]