Skip to content

Commit 68ef6d0

Browse files
authored
Merge pull request #6 from salem221094/feat/mcp-server
feat: MCP server mode (`catchme mcp`)
2 parents d914e24 + 29a825d commit 68ef6d0

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

catchme/mcp_server.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""CatchMe MCP Server — exposes the activity tree as MCP tools over stdio.
2+
3+
Start with: catchme mcp
4+
5+
Register in Claude Desktop (claude_desktop_config.json)::
6+
7+
{
8+
"mcpServers": {
9+
"catchme": {
10+
"command": "catchme",
11+
"args": ["mcp"]
12+
}
13+
}
14+
}
15+
16+
Tools
17+
-----
18+
search_activity(query, date="") — Natural-language search over screen history.
19+
list_days() — List all recorded days with summaries.
20+
get_session(session_id) — Full detail for one session node.
21+
get_tree(date) — Full activity tree JSON for a given date.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import json
27+
import logging
28+
29+
log = logging.getLogger(__name__)
30+
31+
32+
def _require_mcp():
33+
try:
34+
import mcp # noqa: F401
35+
except ImportError as e:
36+
raise ImportError(
37+
"The 'mcp' package is required for MCP server mode.\n"
38+
"Install it with: pip install 'catchme[mcp]'"
39+
) from e
40+
41+
42+
def serve() -> None:
43+
"""Start the CatchMe MCP stdio server. Blocks until the host disconnects."""
44+
_require_mcp()
45+
46+
from mcp.server import Server
47+
from mcp.server.stdio import stdio_server
48+
from mcp.types import TextContent, Tool
49+
50+
from .pipelines.retrieve import retrieve, _load_all_trees, _node_index
51+
52+
server = Server("catchme")
53+
54+
# ── Tool definitions ────────────────────────────────────────────────────
55+
56+
@server.list_tools()
57+
async def list_tools() -> list[Tool]:
58+
return [
59+
Tool(
60+
name="search_activity",
61+
description=(
62+
"Search your recorded screen activity using natural language. "
63+
"Returns the answer and the source node IDs used to build it. "
64+
"Optionally scope to a specific date (YYYY-MM-DD)."
65+
),
66+
inputSchema={
67+
"type": "object",
68+
"properties": {
69+
"query": {
70+
"type": "string",
71+
"description": "Natural language question about your activity.",
72+
},
73+
"date": {
74+
"type": "string",
75+
"description": "Optional ISO date to restrict search (YYYY-MM-DD).",
76+
},
77+
},
78+
"required": ["query"],
79+
},
80+
),
81+
Tool(
82+
name="list_days",
83+
description="List all days that have recorded activity, with top-level summaries.",
84+
inputSchema={"type": "object", "properties": {}},
85+
),
86+
Tool(
87+
name="get_session",
88+
description=(
89+
"Get full detail for a specific session node, including app and "
90+
"location breakdown. Use list_days first to find session IDs."
91+
),
92+
inputSchema={
93+
"type": "object",
94+
"properties": {
95+
"session_id": {
96+
"type": "string",
97+
"description": "The node_id of the session (e.g. '2026-04-15::s0').",
98+
}
99+
},
100+
"required": ["session_id"],
101+
},
102+
),
103+
Tool(
104+
name="get_tree",
105+
description="Return the full raw activity tree JSON for a given date (YYYY-MM-DD).",
106+
inputSchema={
107+
"type": "object",
108+
"properties": {
109+
"date": {
110+
"type": "string",
111+
"description": "ISO date string, e.g. '2026-04-15'.",
112+
}
113+
},
114+
"required": ["date"],
115+
},
116+
),
117+
]
118+
119+
# ── Tool handlers ───────────────────────────────────────────────────────
120+
121+
@server.call_tool()
122+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
123+
if name == "search_activity":
124+
return await _handle_search(arguments)
125+
if name == "list_days":
126+
return _handle_list_days()
127+
if name == "get_session":
128+
return _handle_get_session(arguments)
129+
if name == "get_tree":
130+
return _handle_get_tree(arguments)
131+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
132+
133+
# ── search_activity ─────────────────────────────────────────────────────
134+
135+
async def _handle_search(args: dict) -> list[TextContent]:
136+
query = args.get("query", "").strip()
137+
date_hint = args.get("date", "").strip()
138+
if date_hint:
139+
query = f"{query} on {date_hint}"
140+
if not query:
141+
return [TextContent(type="text", text="Error: query is required.")]
142+
143+
answer = "No relevant information found."
144+
sources: list[str] = []
145+
try:
146+
for step in retrieve(query):
147+
if step.get("type") == "answer":
148+
answer = step.get("content", answer)
149+
sources = step.get("sources", [])
150+
except Exception as exc:
151+
log.exception("retrieve() failed")
152+
return [TextContent(type="text", text=f"Error during retrieval: {exc}")]
153+
154+
result = {"answer": answer, "sources": sources}
155+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
156+
157+
# ── list_days ────────────────────────────────────────────────────────────
158+
159+
def _handle_list_days() -> list[TextContent]:
160+
trees = _load_all_trees()
161+
if not trees:
162+
return [TextContent(type="text", text=json.dumps([]))]
163+
days = []
164+
for t in trees:
165+
node = t.get("tree", {})
166+
days.append({
167+
"date": t.get("date", node.get("title", "?")),
168+
"node_id": node.get("node_id", ""),
169+
"summary": (node.get("summary") or "")[:300],
170+
"session_count": len(node.get("children", [])),
171+
})
172+
return [TextContent(type="text", text=json.dumps(days, ensure_ascii=False, indent=2))]
173+
174+
# ── get_session ──────────────────────────────────────────────────────────
175+
176+
def _handle_get_session(args: dict) -> list[TextContent]:
177+
session_id = args.get("session_id", "").strip()
178+
if not session_id:
179+
return [TextContent(type="text", text="Error: session_id is required.")]
180+
trees = _load_all_trees()
181+
idx: dict = {}
182+
for t in trees:
183+
_node_index(t.get("tree", {}), idx)
184+
node = idx.get(session_id)
185+
if not node:
186+
return [TextContent(type="text", text=f"Session '{session_id}' not found.")]
187+
188+
def _slim(n: dict, depth: int = 0) -> dict:
189+
"""Return a summary-only view to keep payload reasonable."""
190+
out = {
191+
"node_id": n.get("node_id"),
192+
"kind": n.get("kind"),
193+
"title": n.get("title"),
194+
"summary": (n.get("summary") or "")[:500],
195+
}
196+
if depth < 2:
197+
out["children"] = [_slim(c, depth + 1) for c in n.get("children", [])]
198+
return out
199+
200+
return [TextContent(type="text", text=json.dumps(_slim(node), ensure_ascii=False, indent=2))]
201+
202+
# ── get_tree ─────────────────────────────────────────────────────────────
203+
204+
def _handle_get_tree(args: dict) -> list[TextContent]:
205+
date = args.get("date", "").strip()
206+
if not date:
207+
return [TextContent(type="text", text="Error: date is required (YYYY-MM-DD).")]
208+
trees = _load_all_trees()
209+
matched = [t for t in trees if t.get("date") == date]
210+
if not matched:
211+
return [TextContent(type="text", text=f"No activity tree found for date '{date}'.")]
212+
tree_data = matched[0].get("tree", {})
213+
return [TextContent(type="text", text=json.dumps(tree_data, ensure_ascii=False, indent=2))]
214+
215+
# ── run ──────────────────────────────────────────────────────────────────
216+
217+
import asyncio
218+
219+
async def _run():
220+
async with stdio_server() as (read_stream, write_stream):
221+
await server.run(read_stream, write_stream, server.create_initialization_options())
222+
223+
asyncio.run(_run())

catchme/run.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,25 @@ def cmd_ask(query: str) -> None:
463463
_p(f" {RED}error:{RST} {step.get('message', 'unknown error')}")
464464

465465

466+
# ── mcp (MCP stdio server) ──
467+
468+
469+
def cmd_mcp() -> None:
470+
"""Start CatchMe as an MCP stdio server for Claude Desktop, Cursor, Hermes, etc."""
471+
import logging
472+
473+
logging.basicConfig(level=logging.WARNING, format=" %(levelname)s %(message)s")
474+
475+
try:
476+
from catchme.mcp_server import serve
477+
except ImportError as exc:
478+
_p(f" {RED}MCP server error:{RST} {exc}")
479+
_p(f" {DIM}Install the MCP extra:{RST} {CYAN}pip install 'catchme[mcp]'{RST}")
480+
sys.exit(1)
481+
482+
serve()
483+
484+
466485
# ── cost ──
467486

468487

@@ -627,6 +646,7 @@ def _print_help() -> None:
627646
catchme web Start web viewer (frontend)
628647
catchme web -p 9000 Start web viewer on custom port
629648
catchme ask -- <question> Ask about your activity history
649+
catchme mcp Start MCP stdio server (Claude Desktop, Cursor, …)
630650
catchme cost Show LLM token usage
631651
catchme disk Show disk usage and event count
632652
catchme ram Show RAM usage of catchme processes
@@ -638,6 +658,10 @@ def _print_help() -> None:
638658
{BOLD}Config{RST}
639659
Web port is read from {DIM}catchme/services/config.json → web.port{RST}
640660
Override with {CYAN}--port{RST} / {CYAN}-p{RST} flag.
661+
662+
{BOLD}MCP Setup (Claude Desktop){RST}
663+
Add to claude_desktop_config.json:
664+
{{"mcpServers": {{"catchme": {{"command": "catchme", "args": ["mcp"]}}}}}}
641665
""")
642666

643667

@@ -679,6 +703,9 @@ def main() -> None:
679703
sys.exit(1)
680704
cmd_ask(query=" ".join(rest))
681705

706+
elif cmd == "mcp":
707+
cmd_mcp()
708+
682709
elif cmd == "cost":
683710
cmd_cost()
684711

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ dev = [
4545
"pytest>=7.0",
4646
"ruff>=0.4",
4747
]
48+
mcp = [
49+
"mcp[cli]>=1.0",
50+
]
4851

4952
[project.scripts]
5053
catchme = "catchme.run:main"

0 commit comments

Comments
 (0)