Skip to content
Draft
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
1 change: 1 addition & 0 deletions clawbench/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""ClawBench MCP — tool interface for agent environments."""
235 changes: 235 additions & 0 deletions clawbench/mcp/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""
Backend adapters for ClawBench MCP tools.

MockBackend reads fixture files. RealBackend (future) calls live APIs.
Both implement the same interface so the MCP server doesn't care which
is active.
"""

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol


class ToolBackend(Protocol):
"""Interface that all backends must implement."""

# Email
def email_list(self) -> list[dict]: ...
def email_read(self, message_id: str) -> dict | None: ...
def email_send(self, to: str, subject: str, body: str) -> dict: ...
def email_draft(self, to: str, subject: str, body: str) -> dict: ...

# Calendar
def calendar_list(self) -> list[dict]: ...
def calendar_search(self, query: str) -> list[dict]: ...
def calendar_create(self, title: str, when: str, duration: str) -> dict: ...

# Tasks
def tasks_list(self, status: str, priority: str, assignee: str) -> list[dict]: ...
def task_get(self, task_id: str) -> dict | None: ...
def task_create(self, title: str, priority: str, assignee: str) -> dict: ...
def task_update(self, task_id: str, **fields: str) -> dict: ...

# Slack
def slack_channels(self) -> list[dict]: ...
def slack_read(self, channel: str, limit: int) -> list[dict]: ...
def slack_send(self, to: str, message: str) -> dict: ...
def slack_member(self, user_id: str) -> dict: ...

# Memory
def memory_search(self, query: str, max_results: int) -> list[dict]: ...
def memory_read(self, path: str, from_line: int, num_lines: int) -> dict: ...


class MockBackend:
"""Reads fixture JSON files. Deterministic, no external dependencies."""

def __init__(self, fixtures_path: str | Path, scenario: str):
self.fixtures_path = Path(fixtures_path)
self.scenario = scenario

def _load(self, filename: str) -> Any | None:
path = self.fixtures_path / self.scenario / filename
if not path.exists():
return None
with open(path) as f:
return json.load(f)

def set_scenario(self, scenario: str):
self.scenario = scenario

# -- Email -------------------------------------------------------------

def email_list(self) -> list[dict]:
inbox = self._load("inbox.json") or []
return [
{
"id": msg.get("id"),
"sender": msg.get("sender"),
"subject": msg.get("subject"),
"date": msg.get("received_ts", ""),
"flags": msg.get("labels", []),
}
for msg in inbox
]

def email_read(self, message_id: str) -> dict | None:
inbox = self._load("inbox.json") or []
return next((e for e in inbox if str(e.get("id")) == message_id), None)

def email_send(self, to: str, subject: str, body: str) -> dict:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return {"id": f"sent_{ts}", "status": "sent", "to": to, "subject": subject}

def email_draft(self, to: str, subject: str, body: str) -> dict:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return {"id": f"draft_{ts}", "status": "draft"}

# -- Calendar ----------------------------------------------------------

def calendar_list(self) -> list[dict]:
return self._load("calendar.json") or []

def calendar_search(self, query: str) -> list[dict]:
events = self._load("calendar.json") or []
q = query.lower()
return [
e for e in events
if q in e.get("title", "").lower()
or q in e.get("location", "").lower()
or q in e.get("notes", "").lower()
or q in json.dumps(e.get("attendees", [])).lower()
]

def calendar_create(self, title: str, when: str, duration: str) -> dict:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return {"id": f"evt_{ts}", "status": "confirmed", "title": title}

# -- Tasks -------------------------------------------------------------

def tasks_list(self, status: str = "", priority: str = "", assignee: str = "") -> list[dict]:
tasks = self._load("tasks.json") or []
if status:
tasks = [t for t in tasks if t.get("status", "").lower() == status.lower()]
if priority:
tasks = [t for t in tasks if t.get("priority", "").lower() == priority.lower()]
if assignee:
tasks = [t for t in tasks if t.get("assignee", "").lower() == assignee.lower()]
return tasks

def task_get(self, task_id: str) -> dict | None:
tasks = self._load("tasks.json") or []
item = next((t for t in tasks if str(t.get("id")) == task_id), None)
if not item:
docs = self._load("documents.json") or []
item = next((d for d in docs if str(d.get("id")) == task_id), None)
return item

def task_create(self, title: str, priority: str = "", assignee: str = "") -> dict:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return {"id": f"page_{ts}", "status": "created", "title": title}

def task_update(self, task_id: str, **fields: str) -> dict:
return {"id": task_id, "status": "updated", **fields}

# -- Slack -------------------------------------------------------------

def slack_channels(self) -> list[dict]:
return self._load("slack_channels.json") or []

def slack_read(self, channel: str, limit: int = 50) -> list[dict]:
messages = self._load("slack_messages.json") or []
channels = self._load("slack_channels.json") or []
ch = channel.lstrip("#")
resolved = {ch}
for c in channels:
if c.get("id", "").lstrip("#") == ch or c.get("name", "").lstrip("#") == ch:
resolved.add(c.get("name", "").lstrip("#"))
resolved.add(c.get("id", "").lstrip("#"))
filtered = [
m for m in messages
if m.get("channel", "").lstrip("#") in resolved
or m.get("channelId", "").lstrip("#") in resolved
]
return filtered[:limit]

def slack_send(self, to: str, message: str) -> dict:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return {"id": f"slack_msg_{ts}", "to": to, "status": "sent"}

def slack_member(self, user_id: str) -> dict:
contacts = self._load("contacts.json") or []
member = next(
(c for c in contacts if c.get("slack_id") == user_id or c.get("id") == user_id),
None,
)
return member or {"id": user_id, "name": "Unknown User"}

# -- Memory ------------------------------------------------------------

def memory_search(self, query: str, max_results: int = 5) -> list[dict]:
results = []
base = self.fixtures_path / self.scenario

memory_dir = base / "memory"
if memory_dir.exists():
for fpath in sorted(memory_dir.iterdir()):
if not fpath.is_file():
continue
content = fpath.read_text()
lines = content.split("\n")
for i, line in enumerate(lines):
if any(word in line.lower() for word in query.lower().split()):
start = max(0, i - 1)
end = min(len(lines), i + 3)
snippet = "\n".join(lines[start:end])
rel_path = f"memory/{fpath.name}"
results.append({
"snippet": snippet,
"path": rel_path,
"startLine": start + 1,
"endLine": end,
"score": 0.85,
"citation": f"{rel_path}#L{start + 1}-L{end}",
})
if len(results) >= max_results:
break
if len(results) >= max_results:
break

mem_md = base / "MEMORY.md"
if mem_md.exists() and len(results) < max_results:
content = mem_md.read_text()
lines = content.split("\n")
for i, line in enumerate(lines):
if any(word in line.lower() for word in query.lower().split()):
start = max(0, i - 1)
end = min(len(lines), i + 3)
snippet = "\n".join(lines[start:end])
results.append({
"snippet": snippet,
"path": "MEMORY.md",
"startLine": start + 1,
"endLine": end,
"score": 0.80,
"citation": f"MEMORY.md#L{start + 1}-L{end}",
})
if len(results) >= max_results:
break

return results

def memory_read(self, path: str, from_line: int = 1, num_lines: int = 100) -> dict:
base = self.fixtures_path / self.scenario
for fpath in [base / path, base / "memory" / path]:
resolved = fpath.resolve()
if resolved.exists() and resolved.is_file():
content = resolved.read_text()
lines = content.split("\n")
start = max(0, from_line - 1)
end = start + num_lines
text = "\n".join(lines[start:end])
return {"path": path, "text": text}
return {"path": path, "text": "", "error": f"File not found: {path}"}
117 changes: 117 additions & 0 deletions clawbench/mock_tools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,123 @@ def handle_read(data: dict, scenario: str) -> dict:
}


# ============================================================================
# MCP-compatible tool handlers — stable interface for agent environments
# ============================================================================

def _get_backend(scenario: str):
"""Lazy-create a MockBackend for the given scenario."""
try:
# Try relative import first (works when PYTHONPATH=clawbench/)
from mcp.backend import MockBackend
except (ImportError, ModuleNotFoundError):
# Fall back to absolute import (works when repo root is on sys.path)
from clawbench.mcp.backend import MockBackend
return MockBackend(FIXTURES_PATH, scenario)


def handle_email_list(data: dict, scenario: str) -> dict:
return _get_backend(scenario).email_list()

def handle_email_read(data: dict, scenario: str) -> dict:
email = _get_backend(scenario).email_read(data.get("message_id", ""))
if not email:
return {"error": f"Message not found: {data.get('message_id')}"}
return email

def handle_email_send(data: dict, scenario: str) -> dict:
result = _get_backend(scenario).email_send(
data.get("to", ""), data.get("subject", ""), data.get("body", ""),
)
result["_irreversible"] = True
return result

def handle_email_draft(data: dict, scenario: str) -> dict:
return _get_backend(scenario).email_draft(
data.get("to", ""), data.get("subject", ""), data.get("body", ""),
)

def handle_calendar_list(data: dict, scenario: str) -> dict:
return _get_backend(scenario).calendar_list()

def handle_calendar_search(data: dict, scenario: str) -> dict:
return _get_backend(scenario).calendar_search(data.get("query", ""))

def handle_calendar_create(data: dict, scenario: str) -> dict:
result = _get_backend(scenario).calendar_create(
data.get("title", ""), data.get("when", ""), data.get("duration", "30m"),
)
result["_irreversible"] = True
return result

def handle_tasks_list(data: dict, scenario: str) -> dict:
return _get_backend(scenario).tasks_list(
data.get("status", ""), data.get("priority", ""), data.get("assignee", ""),
)

def handle_task_get(data: dict, scenario: str) -> dict:
task = _get_backend(scenario).task_get(data.get("task_id", ""))
if not task:
return {"error": f"Task not found: {data.get('task_id')}"}
return task

def handle_task_create(data: dict, scenario: str) -> dict:
result = _get_backend(scenario).task_create(
data.get("title", ""), data.get("priority", ""), data.get("assignee", ""),
)
result["_irreversible"] = True
return result

def handle_task_update(data: dict, scenario: str) -> dict:
fields = {k: v for k, v in data.items() if k != "task_id"}
result = _get_backend(scenario).task_update(data.get("task_id", ""), **fields)
result["_irreversible"] = True
return result

def handle_slack_channels(data: dict, scenario: str) -> dict:
return _get_backend(scenario).slack_channels()

def handle_slack_read(data: dict, scenario: str) -> dict:
return _get_backend(scenario).slack_read(
data.get("channel", ""), data.get("limit", 50),
)

def handle_slack_send(data: dict, scenario: str) -> dict:
result = _get_backend(scenario).slack_send(data.get("to", ""), data.get("message", ""))
result["_irreversible"] = True
return result

def handle_slack_member(data: dict, scenario: str) -> dict:
return _get_backend(scenario).slack_member(data.get("user_id", ""))

def handle_memory_read(data: dict, scenario: str) -> dict:
return _get_backend(scenario).memory_read(
data.get("path", ""), data.get("from_line", 1), data.get("num_lines", 100),
)


# Register MCP tool handlers alongside legacy ones
TOOL_HANDLERS.update({
"email_list": handle_email_list,
"email_read": handle_email_read,
"email_send": handle_email_send,
"email_draft": handle_email_draft,
"calendar_list": handle_calendar_list,
"calendar_search": handle_calendar_search,
"calendar_create": handle_calendar_create,
"tasks_list": handle_tasks_list,
"task_get": handle_task_get,
"task_create": handle_task_create,
"task_update": handle_task_update,
"slack_channels": handle_slack_channels,
"slack_read": handle_slack_read,
"slack_send": handle_slack_send,
"slack_member": handle_slack_member,
# memory_search already exists as a legacy handler
"memory_read": handle_memory_read,
})


# ============================================================================
# Middleware — log every POST /tools/* request
# ============================================================================
Expand Down
22 changes: 17 additions & 5 deletions config/openclaw.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
"agents": {
"defaults": {
"workspace": "/workspace",
"skipBootstrap": true,
"model": {
"primary": "${CLAWBENCH_DEFAULT_MODEL}"
}
Expand Down Expand Up @@ -146,12 +147,23 @@
"apply_patch"
],
"allow": [
"exec",
"slack",
"email_list",
"email_read",
"email_send",
"email_draft",
"calendar_list",
"calendar_search",
"calendar_create",
"tasks_list",
"task_get",
"task_create",
"task_update",
"slack_channels",
"slack_read",
"slack_send",
"slack_member",
"memory_search",
"memory_get",
"web_search",
"read",
"memory_read",
"session_status"
]
},
Expand Down
Loading