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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user