Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions claw/core/mcp_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from typing import Any, Protocol

from claude_agent_sdk.types import McpHttpServerConfig
from claude_agent_sdk.types import McpHttpServerConfig, McpServerConfig, McpStdioServerConfig

from claw.core.auth import get_proxy_headers

Expand All @@ -19,6 +19,7 @@ class _HasMcpConfig(Protocol):
uc_mcp_connections: list[str]
managed_mcps: list[str]
custom_mcps: dict[str, str]
CLAW_MEMORY_VOLUME_PATH: str


def uc_connection_to_mcp(
Expand Down Expand Up @@ -116,13 +117,12 @@ def build_mcp_servers(
workspace_host: str,
sp_token: str,
user_pat: str,
) -> dict[str, McpHttpServerConfig]:
"""Build all MCP server configs: external + managed + custom.
) -> dict[str, McpServerConfig]:
"""Build all MCP server configs: external + managed + custom + memory.

Returns an empty dict when nothing is configured, so the agent
runs with only its local tools.
Always includes the claw-memory stdio server for persistent memory.
"""
result: dict[str, McpHttpServerConfig] = {}
result: dict[str, McpServerConfig] = {}

# External (UC connections)
for name in config.uc_mcp_connections:
Expand All @@ -134,4 +134,17 @@ def build_mcp_servers(
# Custom (arbitrary HTTP)
result.update(build_custom_mcp_servers(config, workspace_host, sp_token, user_pat))

# Memory — stdio MCP for read/write MEMORY.md on UC Volume
host = workspace_host.rstrip("/")
result["claw-memory"] = McpStdioServerConfig(
type="stdio",
command="python",
args=["-m", "claw.core.memory_mcp"],
env={
"CLAW_MEMORY_VOLUME_PATH": config.CLAW_MEMORY_VOLUME_PATH,
"DATABRICKS_HOST": host,
"DATABRICKS_TOKEN": user_pat,
},
)

return result
63 changes: 63 additions & 0 deletions claw/core/memory_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Stdio MCP server exposing read/write of MEMORY.md on UC Volume.

Tools:
- read_memory — download MEMORY.md from the configured UC Volume path
- write_memory — upload content to MEMORY.md on the configured UC Volume path

Config via env vars: CLAW_MEMORY_VOLUME_PATH, DATABRICKS_HOST, DATABRICKS_TOKEN
"""

from __future__ import annotations

import io
import os

from databricks.sdk import WorkspaceClient
from databricks.sdk.errors import NotFound
from mcp.server import FastMCP

mcp = FastMCP("claw-memory")


def _volume_path() -> str:
"""Return the full UC Volume path to MEMORY.md."""
base = os.environ["CLAW_MEMORY_VOLUME_PATH"].rstrip("/")
return f"{base}/MEMORY.md"


def _get_ws() -> WorkspaceClient:
"""Create a WorkspaceClient from env vars."""
return WorkspaceClient(
host=os.environ["DATABRICKS_HOST"],
token=os.environ["DATABRICKS_TOKEN"],
)


@mcp.tool()
def read_memory_tool() -> str:
"""Read the agent's persistent memory (MEMORY.md) from the UC Volume.

Returns the full content as a string, or empty string if no memory exists yet.
"""
ws = _get_ws()
try:
response = ws.files.download(_volume_path())
return response.contents.read().decode("utf-8")
except NotFound:
return ""


@mcp.tool()
def write_memory_tool(content: str) -> str:
"""Write updated memory content to MEMORY.md on the UC Volume.

Overwrites the entire file. Pass the complete desired content.
"""
ws = _get_ws()
buf = io.BytesIO(content.encode("utf-8"))
ws.files.upload(_volume_path(), contents=buf, overwrite=True)
return "Memory written successfully."


if __name__ == "__main__":
mcp.run(transport="stdio")
19 changes: 4 additions & 15 deletions claw/core/slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from claw.core.agent import create_client, iter_response, send_message
from claw.models import Session as SessionModel
from claw.core.mcp_mapper import build_mcp_servers
from claw.core.memory import read_memory, write_memory
from claw.core.sessions import close_session, get_active_sessions, get_recent_messages, get_session, get_sessions_to_evict, save_message, update_last_poll_ts, upsert_session
from claw.core.slack_client import fetch_history, fetch_thread_replies, open_self_dm, reply_to_thread, resolve_identity

Expand Down Expand Up @@ -226,22 +225,15 @@ async def _handle_message(self, message: dict) -> None:
# Track this thread as active
self._active_threads.add(thread_ts)

# Read memory
memory = read_memory(self._ws, self._config)

# Build MCP servers
# Build MCP servers (includes claw-memory for persistent memory)
mcp_servers = build_mcp_servers(
self._config,
self._ws.config.host,
sp_token=self._ws.config.token,
user_pat=self._config.DATABRICKS_USER_PAT,
)

# Build prompt with memory context
if memory:
prompt = f"Memory:\n{memory}\n\nUser message:\n{text}"
else:
prompt = text
prompt = text

# Get or create client (reuse from cache if available)
client_is_new = thread_ts not in self._clients
Expand Down Expand Up @@ -270,7 +262,7 @@ async def _handle_message(self, message: dict) -> None:
# Send prompt and spawn background task for response collection
await send_message(client, prompt)
self._response_tasks[thread_ts] = asyncio.create_task(
self._run_response_loop(thread_ts, channel, memory, text)
self._run_response_loop(thread_ts, channel, text)
)

except Exception:
Expand Down Expand Up @@ -330,7 +322,7 @@ async def _evict_lra_client(self) -> None:
except Exception:
logger.exception("Failed to close session for evicted thread %s", lra_ts)

async def _run_response_loop(self, thread_ts: str, channel: str, memory: str, original_text: str) -> None:
async def _run_response_loop(self, thread_ts: str, channel: str, original_text: str) -> None:
"""Background task: collect iter_response chunks, reply, update DB."""
try:
client = self._clients[thread_ts]
Expand Down Expand Up @@ -391,9 +383,6 @@ async def _run_response_loop(self, thread_ts: str, channel: str, memory: str, or
gen.is_error,
)

# Write memory
write_memory(self._ws, self._config, memory)

# Reply in thread
result = await reply_to_thread(
self._ws,
Expand Down
31 changes: 31 additions & 0 deletions openspec/changes/memory-mcp/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Memory MCP Server — stdio MCP for MEMORY.md on UC Volume

## Problem
The current memory implementation injects read_memory into the prompt and calls write_memory with stale content at the end of every response loop. This is broken — write_memory writes back the *original* memory, not any updates the agent made. The agent has no way to actually persist memory changes.

## Solution
Replace the manual read/write calls with a stdio MCP server that exposes `read_memory` and `write_memory` as tools the agent can call directly.

### 1. `claw/core/memory_mcp.py` — stdio MCP server
- Uses `mcp.server.FastMCP` to create a stdio MCP server with two tools:
- `read_memory() -> str` — downloads MEMORY.md from UC Volume via `ws.files.download()`
- `write_memory(content: str) -> str` — uploads MEMORY.md via `ws.files.upload()`
- Config via env vars: `CLAW_MEMORY_VOLUME_PATH`, `DATABRICKS_HOST`, `DATABRICKS_TOKEN`
- Runnable as `python -m claw.core.memory_mcp`

### 2. `claw/core/mcp_mapper.py` — add "claw-memory" entry
- Add `McpStdioServerConfig` entry to `build_mcp_servers()` return dict
- Update type annotations to return `dict[str, McpServerConfig]` (union of HTTP + stdio)

### 3. `claw/core/slack_poller.py` — remove broken memory calls
- Remove `read_memory` call and prompt injection (lines ~229-243)
- Remove `write_memory` call at end of `_run_response_loop` (line ~395)
- Remove `memory` parameter from `_run_response_loop`
- Remove imports of `read_memory`/`write_memory`

### 4. `pyproject.toml` — no changes needed
- `mcp` is already a transitive dependency of `claude-agent-sdk`

## Tests
- `tests/test_memory_mcp.py` — unit tests for `read_memory_tool` and `write_memory_tool` (mock WorkspaceClient)
- `tests/test_mcp_mapper.py` — verify "claw-memory" key in `build_mcp_servers()` output
28 changes: 28 additions & 0 deletions openspec/changes/memory-mcp/specs/mcp_mapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Spec: mcp_mapper.py changes

## Changes to `build_mcp_servers()`

### Return type
Change from `dict[str, McpHttpServerConfig]` to `dict[str, McpServerConfig]` (union type).

### New entry
Add "claw-memory" stdio server entry after custom MCPs:

```python
servers["claw-memory"] = McpStdioServerConfig(
type="stdio",
command="python",
args=["-m", "claw.core.memory_mcp"],
env={
"CLAW_MEMORY_VOLUME_PATH": config.CLAW_MEMORY_VOLUME_PATH,
"DATABRICKS_HOST": workspace_host,
"DATABRICKS_TOKEN": user_pat,
},
)
```

### Protocol update
`_HasMcpConfig` needs `CLAW_MEMORY_VOLUME_PATH: str` attribute.

### Import
Add `McpStdioServerConfig, McpServerConfig` to imports from `claude_agent_sdk.types`.
27 changes: 27 additions & 0 deletions openspec/changes/memory-mcp/specs/memory_mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Spec: claw/core/memory_mcp.py

## Module
`claw.core.memory_mcp` — stdio MCP server exposing read/write of MEMORY.md on UC Volume.

## Public API (tool functions)

### `read_memory_tool() -> str`
- Creates `WorkspaceClient(host=DATABRICKS_HOST, token=DATABRICKS_TOKEN)`
- Calls `ws.files.download("{CLAW_MEMORY_VOLUME_PATH}/MEMORY.md")`
- Returns content as UTF-8 string
- Returns empty string on `NotFound`

### `write_memory_tool(content: str) -> str`
- Creates `WorkspaceClient(host=DATABRICKS_HOST, token=DATABRICKS_TOKEN)`
- Calls `ws.files.upload("{CLAW_MEMORY_VOLUME_PATH}/MEMORY.md", contents=BytesIO(content), overwrite=True)`
- Returns confirmation string

## Server setup
- `mcp = FastMCP("claw-memory")`
- Register both tools with `@mcp.tool()`
- `__main__` block runs `mcp.run(transport="stdio")`

## Config
- `CLAW_MEMORY_VOLUME_PATH` — env var, base path on UC Volume
- `DATABRICKS_HOST` — env var, workspace URL
- `DATABRICKS_TOKEN` — env var, PAT
12 changes: 12 additions & 0 deletions openspec/changes/memory-mcp/specs/slack_poller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Spec: slack_poller.py changes

## Removals

1. Remove import: `from claw.core.memory import read_memory, write_memory`
2. In `_handle_message`:
- Remove `memory = read_memory(self._ws, self._config)` call
- Remove the `if memory: ... else: ...` prompt wrapping — just use `prompt = text`
- Remove `memory` arg from `_run_response_loop` call
3. In `_run_response_loop`:
- Remove `memory` parameter from signature
- Remove `write_memory(self._ws, self._config, memory)` call
14 changes: 0 additions & 14 deletions tests/test_e2e_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,6 @@ class TestFullPipeline:
@patch("claw.core.slack_poller.iter_response")
@patch("claw.core.slack_poller.send_message", new_callable=AsyncMock)
@patch("claw.core.slack_poller.create_client", new_callable=AsyncMock)
@patch("claw.core.slack_poller.write_memory")
@patch("claw.core.slack_poller.read_memory", return_value="existing memory")
@patch("claw.core.slack_poller.build_mcp_servers", return_value={})
@patch("claw.core.slack_poller.upsert_session")
@patch("claw.core.slack_poller.update_last_poll_ts")
Expand All @@ -158,8 +156,6 @@ async def test_poll_triggers_agent_and_reply(
mock_update_poll_ts,
mock_upsert_session,
mock_build_mcp,
mock_read_mem,
mock_write_mem,
mock_create_client,
mock_send_message,
mock_iter_response,
Expand Down Expand Up @@ -212,12 +208,6 @@ async def test_poll_triggers_agent_and_reply(
assert reply_args[3] == "200.001" # thread_ts
assert reply_args[4] == "Hello from Claw" # concatenated response

# Verify memory was read
mock_read_mem.assert_called_once()

# Verify memory was written
mock_write_mem.assert_called_once()

# Verify session was upserted (at least for initial + final update)
assert mock_upsert_session.call_count >= 1

Expand All @@ -232,8 +222,6 @@ class TestThreadContinuity:
@patch("claw.core.slack_poller.iter_response")
@patch("claw.core.slack_poller.send_message", new_callable=AsyncMock)
@patch("claw.core.slack_poller.create_client", new_callable=AsyncMock)
@patch("claw.core.slack_poller.write_memory")
@patch("claw.core.slack_poller.read_memory", return_value="")
@patch("claw.core.slack_poller.build_mcp_servers", return_value={})
@patch("claw.core.slack_poller.upsert_session")
@patch("claw.core.slack_poller.get_session")
Expand All @@ -244,8 +232,6 @@ async def test_second_message_resumes_sdk_session(
mock_get_session,
mock_upsert_session,
mock_build_mcp,
mock_read_mem,
mock_write_mem,
mock_create_client,
mock_send_message,
mock_iter_response,
Expand Down
Loading
Loading