homelab-brain/mac-clients/chatlog_mcp.py
Homelab Cursor 2fa011fa2a chatlogger: add tailscale-only HTTP API + Hermes MCP wrapper
- chatlogger/api.py — read-only HTTP API (stdlib http.server, bearer-token
  auth, bound to 100.123.47.7:8770) with /list_chats, /recent, /search,
  /since, /stats, /healthz
- chatlogger/chatlogger-api.service — hardened systemd unit
- chatlogger/api.env.example — env template (token + bind + db)
- mac-clients/chatlog_mcp.py — stdio MCP wrapper for Hermes Agent on the
  Mac (4 tools: chatlog_list_chats / recent / search / since), uses only
  Python stdlib, talks to the REST API via Tailscale.

Hermes registers as 4th MCP server (chatlog) — verified live with
76 tool(s) from 4 server(s) in gateway.log.
2026-05-01 12:55:38 +02:00

320 lines
10 KiB
Python

#!/usr/bin/env python3
"""Chatlog MCP stdio wrapper for the Hermes Agent.
Talks to the read-only HTTP API exposed by the Arakawa Concierge chat
logger on CT 116 over Tailscale. Provides four tools so Hermes can
answer questions about live Telegram group conversations:
chatlog_list_chats — overview of every group being recorded
chatlog_recent — last N messages of a group (or globally)
chatlog_search — FTS5 keyword search over all logged messages
chatlog_since — every message of the last N hours (default 12)
Configuration via environment variables (set in the Hermes config or
launchd plist):
CHATLOG_API_BASE default http://100.123.47.7:8770
CHATLOG_API_TOKEN required, bearer token for the API
This wrapper has no third-party dependencies; only the Python standard
library is used so it can run with /usr/bin/python3 on macOS.
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
API_BASE = os.environ.get("CHATLOG_API_BASE", "http://100.123.47.7:8770").rstrip("/")
API_TOKEN = os.environ.get("CHATLOG_API_TOKEN", "")
HTTP_TIMEOUT = float(os.environ.get("CHATLOG_API_TIMEOUT", "10"))
def _http_get(path: str, params: dict | None = None) -> dict:
qs = ""
if params:
clean = {k: v for k, v in params.items() if v is not None and v != ""}
if clean:
qs = "?" + urllib.parse.urlencode(clean)
url = f"{API_BASE}{path}{qs}"
req = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {API_TOKEN}",
},
)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code} on {path}: {body}") from None
except urllib.error.URLError as e:
raise RuntimeError(f"connection failed: {e.reason}") from None
def _format_chats(payload: dict) -> str:
chats = payload.get("chats", [])
if not chats:
return "Keine aufgezeichneten Chats."
lines = [f"{len(chats)} aufgezeichnete Chats:"]
for c in chats:
lines.append(
f"- {c.get('title') or '(ohne Titel)'} "
f"(id={c['chat_id']}, {c['message_count']} Nachrichten)"
)
return "\n".join(lines)
def _format_messages_payload(payload: dict, header: str) -> str:
transcript = payload.get("transcript", "").strip()
count = payload.get("count", 0)
if count == 0 or not transcript:
return f"{header} — keine Treffer."
return f"{header} ({count} Treffer):\n\n{transcript}"
# --------------------- tool handlers ----------------------------------
def tool_list_chats(_args: dict) -> str:
return _format_chats(_http_get("/list_chats"))
def tool_recent(args: dict) -> str:
chat = args.get("chat")
limit = int(args.get("limit", 30))
payload = _http_get("/recent", {"chat": chat, "limit": limit})
chat_label = f"chat='{chat}'" if chat else "alle Chats"
return _format_messages_payload(
payload, f"Letzte {limit} Nachrichten ({chat_label})"
)
def tool_search(args: dict) -> str:
query = (args.get("query") or "").strip()
if not query:
return "Fehler: 'query' darf nicht leer sein."
chat = args.get("chat")
limit = int(args.get("limit", 30))
payload = _http_get(
"/search", {"query": query, "chat": chat, "limit": limit}
)
chat_label = f", chat='{chat}'" if chat else ""
return _format_messages_payload(
payload, f"Suche '{query}'{chat_label}"
)
def tool_since(args: dict) -> str:
hours = int(args.get("hours", 12))
chat = args.get("chat")
limit = int(args.get("limit", 500))
payload = _http_get(
"/since", {"hours": hours, "chat": chat, "limit": limit}
)
chat_label = f", chat='{chat}'" if chat else ""
return _format_messages_payload(
payload, f"Nachrichten der letzten {hours}h{chat_label}"
)
TOOLS = [
{
"name": "chatlog_list_chats",
"description": (
"List every Telegram group that the Arakawa Concierge logger "
"is currently recording. Returns the chat title, internal id "
"and message count. Always call this first when you don't yet "
"know which group the user is asking about."
),
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
},
},
{
"name": "chatlog_recent",
"description": (
"Return the most recent messages of a recorded Telegram group "
"as a chronological transcript. Use this for questions like "
"'Was wurde zuletzt geschrieben?' or 'Aktueller Stand?'. "
"If 'chat' is omitted, returns recent messages across all groups."
),
"inputSchema": {
"type": "object",
"properties": {
"chat": {
"type": "string",
"description": (
"Numeric chat_id, or a case-insensitive substring "
"of the chat title (e.g. 'Airbnb', 'G 210', 'Concierge')."
),
},
"limit": {
"type": "integer",
"description": "Maximum messages to return (1-200, default 30).",
},
},
"required": [],
},
},
{
"name": "chatlog_search",
"description": (
"Full-text search over every logged Telegram message (FTS5). "
"Use this for questions like 'wer hat X gesagt?', "
"'was wurde zu Y vereinbart?', 'gab es eine Zusage zu Z?'. "
"Supports SQLite FTS5 query syntax (single words, phrases in "
"double-quotes, AND/OR, prefix with *)."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "FTS5 search expression.",
},
"chat": {
"type": "string",
"description": "Optional chat filter (numeric id or title substring).",
},
"limit": {
"type": "integer",
"description": "Maximum hits to return (1-100, default 30).",
},
},
"required": ["query"],
},
},
{
"name": "chatlog_since",
"description": (
"Return every recorded message of the last N hours, oldest "
"first. Use this for 'was war heute?' / 'was wurde seit "
"gestern besprochen?' / 'gibt es offene Fragen seit X Stunden?'."
),
"inputSchema": {
"type": "object",
"properties": {
"hours": {
"type": "integer",
"description": "Look-back window in hours (1-720, default 12).",
},
"chat": {
"type": "string",
"description": "Optional chat filter (numeric id or title substring).",
},
"limit": {
"type": "integer",
"description": "Maximum messages to return (1-5000, default 500).",
},
},
"required": [],
},
},
]
HANDLERS = {
"chatlog_list_chats": tool_list_chats,
"chatlog_recent": tool_recent,
"chatlog_search": tool_search,
"chatlog_since": tool_since,
}
# --------------------- MCP plumbing -----------------------------------
async def handle_request(request: dict) -> dict | None:
method = request.get("method")
req_id = request.get("id")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "chatlog", "version": "1.0.0"},
},
}
if method in ("notifications/initialized", "initialized"):
return None
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"tools": TOOLS},
}
if method == "tools/call":
params = request.get("params", {})
name = params.get("name")
args = params.get("arguments", {}) or {}
handler = HANDLERS.get(name)
if handler is None:
text = f"Unbekanntes Tool: {name}"
else:
try:
text = handler(args)
except Exception as exc: # noqa: BLE001
text = f"Fehler bei {name}: {exc}"
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
async def main() -> None:
if not API_TOKEN:
sys.stderr.write(
"chatlog_mcp: CHATLOG_API_TOKEN env var is empty — refusing to start.\n"
)
sys.exit(2)
loop = asyncio.get_event_loop()
reader = asyncio.StreamReader()
proto = asyncio.StreamReaderProtocol(reader)
await loop.connect_read_pipe(lambda: proto, sys.stdin)
out = sys.stdout.buffer
while True:
try:
line = await reader.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError:
continue
response = await handle_request(request)
if response is not None:
out.write((json.dumps(response, ensure_ascii=False) + "\n").encode("utf-8"))
out.flush()
except (EOFError, BrokenPipeError):
break
except Exception as exc: # noqa: BLE001
sys.stderr.write(f"chatlog_mcp error: {exc}\n")
sys.stderr.flush()
if __name__ == "__main__":
asyncio.run(main())