""" Android MCP Server Exposes Android phone data to Hermes via MCP stdio protocol. Tools: get_events — poll event bus (incremental, since_id) get_latest — most recent N events get_stats — event queue stats query_wechat — SQLCipher query on WeChat EnMicroMsg.db (requires root) list_event_apps — distinct apps that have posted events get_ui_tree — dump current foreground UI tree via uiautomator (requires root) frida_call — inject Frida JS script into a running app (requires root + frida-server) Usage (spawned by Hermes as MCP subprocess): /path/to/venv/bin/python android_mcp.py Config via env vars: EVENT_BUS_DB path to events.db (default: ~/.hermes/event_bus/events.db) WECHAT_DB path to EnMicroMsg.db (default: auto-detect) WECHAT_KEY SQLCipher key (default: read from ~/.hermes/event_bus/wechat_key) SQLCIPHER_BIN path to sqlcipher binary (default: auto-detect) """ from __future__ import annotations import json import logging import os import re import sqlite3 import subprocess import sys import time from pathlib import Path from typing import Any, Optional logger = logging.getLogger(__name__) def _load_hermes_env(): """Load ~/.hermes/.env before any module-level env reads.""" termux_home = Path("/data/data/com.termux/files/home") home = termux_home if termux_home.exists() else Path(os.environ.get("HOME", str(termux_home))) env_file = home / ".hermes" / ".env" if not env_file.exists(): return for raw in env_file.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") k = k.strip(); v = v.strip().strip('"').strip("'") if k and not os.environ.get(k): os.environ[k] = v _load_hermes_env() # ── paths ────────────────────────────────────────────────────────────────────── _TERMUX_HOME = Path("/data/data/com.termux/files/home") _HERMES_BASE = (_TERMUX_HOME if _TERMUX_HOME.exists() else Path(os.environ.get("HOME", str(_TERMUX_HOME)))) / ".hermes" HERMES_HOME = Path(os.environ.get("HERMES_HOME", str(_HERMES_BASE))) EVENT_BUS_DB = Path(os.environ.get("EVENT_BUS_DB", str(HERMES_HOME / "event_bus" / "events.db"))) WECHAT_DB_CANDIDATES = [ "/data/data/com.tencent.mm/MicroMsg", # needs root ] WECHAT_KEY_FILE = HERMES_HOME / "event_bus" / "wechat_key" SQLCIPHER_CANDIDATES = [ "/data/data/com.termux/files/usr/bin/sqlcipher", "/usr/bin/sqlcipher", ] # ── helpers ──────────────────────────────────────────────────────────────────── def _event_db() -> sqlite3.Connection: conn = sqlite3.connect(EVENT_BUS_DB) conn.row_factory = sqlite3.Row return conn def _read_wechat_key() -> str: env_key = os.environ.get("WECHAT_KEY", "") if env_key: return env_key if WECHAT_KEY_FILE.exists(): return WECHAT_KEY_FILE.read_text().strip() # fallback to known key (update via `echo > ~/.hermes/event_bus/wechat_key`) return "e03ce81" def _find_wechat_db() -> Optional[str]: env_path = os.environ.get("WECHAT_DB", "") if env_path and Path(env_path).exists(): return env_path try: result = subprocess.run( ["su", "-c", f"find {WECHAT_DB_CANDIDATES[0]} -name 'EnMicroMsg.db' 2>/dev/null | head -1"], capture_output=True, text=True, timeout=5 ) path = result.stdout.strip() if path: return path except Exception: pass return None def _find_sqlcipher() -> Optional[str]: for p in SQLCIPHER_CANDIDATES: if Path(p).exists(): return p return None def _run_sqlcipher(sql: str, db_path: str, key: str) -> dict[str, Any]: sqlcipher = _find_sqlcipher() if not sqlcipher: return {"error": "sqlcipher binary not found", "candidates": SQLCIPHER_CANDIDATES} script = ( f'PRAGMA key="{key}";\n' f'PRAGMA cipher_compatibility=1;\n' f'PRAGMA cipher_page_size=1024;\n' f'.mode json\n' f'{sql.rstrip(";").strip()};\n' ) try: result = subprocess.run( ["su", "-c", f'{sqlcipher} "{db_path}"'], input=script, capture_output=True, text=True, timeout=15 ) if result.returncode != 0: return {"error": result.stderr.strip() or "sqlcipher error", "sql": sql} # Parse JSON output (.mode json) raw = result.stdout.strip() if not raw: return {"rows": [], "count": 0} try: rows = json.loads(raw) if not isinstance(rows, list): rows = [rows] return {"rows": rows, "count": len(rows)} except json.JSONDecodeError: # Fall back to raw output return {"raw": raw} except subprocess.TimeoutExpired: return {"error": "query timed out (15s)"} except Exception as e: return {"error": str(e)} # ── MCP server ───────────────────────────────────────────────────────────────── def _build_server(): from mcp.server.fastmcp import FastMCP mcp = FastMCP( "android-phone", instructions=( "Provides real-time data from the Android phone: " "system events (notifications, UI, clipboard), " "WeChat message history, and event queue stats. " "Use get_events(since_id=) for incremental polling. " "Use query_wechat(sql=...) for structured WeChat data." ), ) # ── tool: get_events ────────────────────────────────────────────────────── @mcp.tool() def get_events( since_id: int = 0, limit: int = 50, type_filter: str = "", app_filter: str = "", source_filter: str = "", ) -> str: """ Poll the Android event bus for new events. Returns events with id > since_id. Store the last returned id and pass it back next call to get only new events (incremental polling). Args: since_id: Return only events with id greater than this value (default 0 = all). limit: Maximum number of events to return (max 500, default 50). type_filter: Filter by event type, e.g. 'notification', 'ui_event', 'clipboard'. app_filter: Filter by Android package name, e.g. 'com.tencent.mm'. source_filter: Filter by event source: 'apk' (real-time from APK) or 'monitor' (dumpsys polling). """ limit = min(max(1, limit), 500) sql = "SELECT id, type, app, data, ts, source FROM events WHERE id > ?" params: list[Any] = [since_id] if type_filter: sql += " AND type = ?" params.append(type_filter) if app_filter: sql += " AND app = ?" params.append(app_filter) if source_filter: sql += " AND source = ?" params.append(source_filter) sql += " ORDER BY id ASC LIMIT ?" params.append(limit) try: with _event_db() as conn: rows = conn.execute(sql, params).fetchall() events = [ {"id": r["id"], "type": r["type"], "app": r["app"], "data": json.loads(r["data"]), "ts": r["ts"], "source": r["source"] or "monitor"} for r in rows ] result = { "events": events, "count": len(events), "next_since_id": events[-1]["id"] if events else since_id, "has_more": len(events) == limit, } except Exception as e: result = {"error": str(e), "events": [], "count": 0} return json.dumps(result, ensure_ascii=False) # ── tool: get_latest ────────────────────────────────────────────────────── @mcp.tool() def get_latest( limit: int = 20, type_filter: str = "", app_filter: str = "", source_filter: str = "", ) -> str: """ Get the most recent events from the Android event bus. Useful for getting context on what's happening on the phone right now, without needing to track since_id. Args: limit: Number of recent events to return (max 200, default 20). type_filter: Filter by event type, e.g. 'notification', 'ui_event'. app_filter: Filter by Android package name, e.g. 'com.tencent.mm'. source_filter: Filter by source: 'apk' (real-time) or 'monitor' (dumpsys). """ limit = min(max(1, limit), 200) sql = "SELECT id, type, app, data, ts, source FROM events WHERE 1=1" params: list[Any] = [] if type_filter: sql += " AND type = ?" params.append(type_filter) if app_filter: sql += " AND app = ?" params.append(app_filter) if source_filter: sql += " AND source = ?" params.append(source_filter) sql += " ORDER BY id DESC LIMIT ?" params.append(limit) try: with _event_db() as conn: rows = conn.execute(sql, params).fetchall() events = [ {"id": r["id"], "type": r["type"], "app": r["app"], "data": json.loads(r["data"]), "ts": r["ts"], "source": r["source"] or "monitor"} for r in reversed(rows) ] result = {"events": events, "count": len(events)} except Exception as e: result = {"error": str(e), "events": [], "count": 0} return json.dumps(result, ensure_ascii=False) # ── tool: get_stats ─────────────────────────────────────────────────────── @mcp.tool() def get_stats() -> str: """ Get Android event bus statistics: total event count, latest event, database path, and available event types. """ try: with _event_db() as conn: total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] last = conn.execute( "SELECT id, type, app, ts FROM events ORDER BY id DESC LIMIT 1" ).fetchone() types = conn.execute( "SELECT type, COUNT(*) as cnt FROM events GROUP BY type ORDER BY cnt DESC" ).fetchall() result = { "total_events": total, "last_event_id": last["id"] if last else None, "last_event_type": last["type"] if last else None, "last_event_app": last["app"] if last else None, "last_event_ts": last["ts"] if last else None, "event_types": [{"type": r["type"], "count": r["cnt"]} for r in types], "db_path": str(EVENT_BUS_DB), } except Exception as e: result = {"error": str(e)} return json.dumps(result, ensure_ascii=False) # ── tool: list_event_apps ───────────────────────────────────────────────── @mcp.tool() def list_event_apps() -> str: """ List all Android apps that have posted events to the bus, with event counts per app. Useful for knowing what's active. """ try: with _event_db() as conn: rows = conn.execute( "SELECT app, COUNT(*) as cnt FROM events WHERE app IS NOT NULL " "GROUP BY app ORDER BY cnt DESC" ).fetchall() result = {"apps": [{"app": r["app"], "count": r["cnt"]} for r in rows]} except Exception as e: result = {"error": str(e)} return json.dumps(result, ensure_ascii=False) # ── tool: get_ui_tree ───────────────────────────────────────────────────── @mcp.tool() def get_ui_tree(package: str = "") -> str: """ Dump the current foreground app's UI tree via uiautomator. Returns XML with all visible UI nodes (text, resource-id, bounds, etc.). Requires root. Use this to understand what's on screen right now. Args: package: Optional package name filter. If provided, only nodes from this app are returned (e.g. 'com.tencent.mm'). """ tmp = "/sdcard/agent_ui_dump.xml" try: result = subprocess.run( ["su", "-c", f"uiautomator dump {tmp} 2>/dev/null && cat {tmp}"], capture_output=True, text=True, timeout=10 ) xml = result.stdout.strip() if not xml: return json.dumps({"error": "uiautomator dump returned empty output", "stderr": result.stderr[:200]}) # Strip the "UI hierchary dumped to..." line if present if xml.startswith("UI hierchary") or xml.startswith("UI hierarchy"): xml = xml.split("\n", 1)[-1].strip() if package: # Quick filter: return only if package appears in tree if package not in xml: return json.dumps({"error": f"package {package!r} not found in UI tree", "hint": "App may not be in foreground"}) return json.dumps({"xml": xml, "length": len(xml)}, ensure_ascii=False) except subprocess.TimeoutExpired: return json.dumps({"error": "uiautomator timed out (10s)"}) except Exception as e: return json.dumps({"error": str(e)}) # ── tool: frida_call ────────────────────────────────────────────────────── @mcp.tool() def frida_call( package: str, script: str, timeout: int = 10, ) -> str: """ Inject a Frida JavaScript script into a running Android app and return the output. Requires root and frida-server running on device. The script should call send() to return data. Example: script="Java.perform(function(){ send(Java.use('android.os.Build').MODEL); })" Args: package: Target app package name, e.g. 'com.tencent.mm'. script: Frida JavaScript to inject. Use send() to return values. timeout: Max seconds to wait for script output (default 10). """ # Delegates to frida_bridge.py on Mac via ADB reverse tcp:8767 tcp:8767 # The bridge uses frida Python (17.9.10) to connect to frida-server on device via USB BRIDGE_URL = "http://127.0.0.1:8767/frida_call" import urllib.request payload = json.dumps({"package": package, "script": script, "timeout": timeout}).encode() try: req = urllib.request.Request(BRIDGE_URL, data=payload, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout + 10) as resp: return resp.read().decode() except Exception as e: return json.dumps({"error": f"frida_bridge unreachable: {e}. Ensure frida_bridge.py is running on Mac and adb reverse tcp:8767 tcp:8767 is set."}) # ── tool: query_wechat ──────────────────────────────────────────────────── @mcp.tool() def query_wechat( sql: str, key: str = "", db_path: str = "", ) -> str: """ Run a read-only SQL query on WeChat's EnMicroMsg.db (SQLCipher encrypted). Requires root. Returns JSON rows. Useful tables: message — columns: msgId, type, isSend, talker, content, createTime rcontact — columns: conRemark, nickname, username chatroom — columns: displayname, chatroomname Example: sql="SELECT talker, content, createTime FROM message ORDER BY createTime DESC LIMIT 20" Args: sql: SQL SELECT query to run. key: SQLCipher key (optional, reads from ~/.hermes/event_bus/wechat_key if omitted). db_path: Path to EnMicroMsg.db (optional, auto-detected if omitted). """ # Safety: only allow SELECT stripped = sql.strip().upper() if not stripped.startswith("SELECT") and not stripped.startswith("PRAGMA"): return json.dumps({"error": "Only SELECT and PRAGMA queries are allowed."}) effective_key = key or _read_wechat_key() effective_db = db_path or _find_wechat_db() if not effective_db: return json.dumps({"error": "WeChat DB not found. Set WECHAT_DB env or pass db_path."}) return json.dumps(_run_sqlcipher(sql, effective_db, effective_key), ensure_ascii=False) # ── tool: get_wechat_messages ───────────────────────────────────────────── @mcp.tool() def get_wechat_messages( contact: str = "", limit: int = 20, since_ts: int = 0, include_groups: bool = False, ) -> str: """ Get WeChat messages from the local SQLite database (requires root). Returns messages from the message table with sender, content, and timestamp. Optionally filter by contact wxid/username, or restrict to direct messages only. Args: contact: Filter by talker wxid, e.g. 'wxid_abc123' or '@chatroom' suffix for groups. Leave empty to return recent messages from all chats. limit: Number of messages to return (default 20, max 200). since_ts: Only return messages after this Unix timestamp in milliseconds. include_groups: If False (default), exclude group chat messages (talker ending in @chatroom). """ limit = min(max(1, limit), 200) sql_parts = ["SELECT msgId, type, isSend, talker, content, createTime FROM message WHERE 1=1"] params: list[Any] = [] if contact: sql_parts.append("AND talker = ?") params.append(contact) elif not include_groups: sql_parts.append("AND talker NOT LIKE '%@chatroom'") if since_ts: sql_parts.append("AND createTime > ?") params.append(since_ts) sql_parts.append("ORDER BY createTime DESC LIMIT ?") params.append(limit) sql = " ".join(sql_parts) key = _read_wechat_key() db = _find_wechat_db() if not db: return json.dumps({"error": "WeChat DB not found."}) result = _run_sqlcipher(sql, db, key) if "rows" in result: # Reverse to chronological order result["rows"] = list(reversed(result["rows"])) return json.dumps(result, ensure_ascii=False) # ── tool: get_wechat_contacts ───────────────────────────────────────────── @mcp.tool() def get_wechat_contacts( query: str = "", limit: int = 50, ) -> str: """ Search WeChat contacts from the local SQLite database (requires root). Returns contacts with wxid, nickname, remark name, and type info. Args: query: Optional search string matched against nickname, remark (conRemark), or username (wxid). Leave empty to return all contacts. limit: Maximum number of contacts to return (default 50, max 500). """ limit = min(max(1, limit), 500) if query: sql = ( "SELECT username, alias, nickname, conRemark, type, imgFlag " "FROM rcontact " "WHERE (nickname LIKE ? OR conRemark LIKE ? OR username LIKE ?) " "AND type != 0 " "ORDER BY conRemark, nickname LIMIT ?" ) like = f"%{query}%" params: list[Any] = [like, like, like, limit] else: sql = ( "SELECT username, alias, nickname, conRemark, type, imgFlag " "FROM rcontact WHERE type != 0 " "ORDER BY conRemark, nickname LIMIT ?" ) params = [limit] key = _read_wechat_key() db = _find_wechat_db() if not db: return json.dumps({"error": "WeChat DB not found."}) return json.dumps(_run_sqlcipher(sql, db, key), ensure_ascii=False) # ── tool: get_wechat_chatrooms ──────────────────────────────────────────── @mcp.tool() def get_wechat_chatrooms(limit: int = 30) -> str: """ List WeChat group chats (chatrooms) with display name and member count. Requires root. Args: limit: Maximum number of chatrooms to return (default 30, max 200). """ limit = min(max(1, limit), 200) sql = ( "SELECT chatroomname, displayname, membercount, chatroomowner " "FROM chatroom ORDER BY membercount DESC LIMIT ?" ) key = _read_wechat_key() db = _find_wechat_db() if not db: return json.dumps({"error": "WeChat DB not found."}) return json.dumps(_run_sqlcipher(sql, db, key), ensure_ascii=False) # ── tool: search_memory ─────────────────────────────────────────────────── @mcp.tool() def search_memory( query: str, person: str = "", since: int = 0, until: int = 0, source_app: str = "", limit: int = 20, ) -> str: """ Search the personal Memory Graph for messages and entities. Uses FTS5 keyword search with fallback to LIKE for CJK text. Args: query: Keyword or phrase to search for. person: Filter by contact name/wxid (optional). since: Unix timestamp lower bound (optional). until: Unix timestamp upper bound (optional). source_app: Filter by app, e.g. 'wechat' or 'telegram' (optional). limit: Max results to return (default 20, max 100). """ try: import sys _base = os.path.dirname(__file__) if _base not in sys.path: sys.path.insert(0, _base) from memory_graph import search_memory as _search, get_stats limit = min(max(1, limit), 100) results = _search(query, person=person, since=since, until=until, source_app=source_app, limit=limit) return json.dumps({ "results": results, "count": len(results), "stats": get_stats(), }, ensure_ascii=False, default=str) except ImportError: return json.dumps({"error": "memory_graph.py not found in event_bus dir"}) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def ingest_wechat_to_memory(limit: int = 2000) -> str: """ Bulk-ingest WeChat contacts and text messages into the Memory Graph. Run this once to populate the graph, then use search_memory to query. Args: limit: Max number of recent text messages to ingest (default 2000). """ try: import sys _base = os.path.dirname(__file__) if _base not in sys.path: sys.path.insert(0, _base) from memory_graph import init_db, ingest_wechat_contacts, ingest_wechat_messages, get_stats init_db() key = _read_wechat_key() db = _find_wechat_db() if not db: return json.dumps({"error": "WeChat DB not found"}) # Contacts contacts_sql = "SELECT username, nickname, conRemark, type FROM rcontact WHERE type != 0 AND username != '' LIMIT 1000" contacts = _run_sqlcipher(contacts_sql, db, key) c_result = ingest_wechat_contacts(contacts if isinstance(contacts, list) else []) # Text messages only (type=1) limit = min(max(1, limit), 5000) msgs_sql = ( f"SELECT msgId, type, isSend, talker, content, createTime " f"FROM message WHERE type=1 AND content != '' " f"AND content NOT LIKE '%%' " f"ORDER BY createTime DESC LIMIT {limit}" ) messages = _run_sqlcipher(msgs_sql, db, key) m_result = ingest_wechat_messages(messages if isinstance(messages, list) else []) return json.dumps({ "contacts": c_result, "messages": m_result, "stats": get_stats(), }, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── tool: semantic_search ───────────────────────────────────────────────── @mcp.tool() def semantic_search_memory(query: str, limit: int = 10) -> str: """ Semantic similarity search over Memory Graph using Kimi embeddings. Finds messages semantically related to the query, even without exact keywords. Args: query: Natural language query (Chinese/English). limit: Max results to return (default 10). """ try: import sys _base = os.path.dirname(__file__) if _base not in sys.path: sys.path.insert(0, _base) import memory_graph as mg # Inject API key from env env_path = os.path.expanduser("~/.hermes/.env") if os.path.exists(env_path): for line in open(env_path): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip()) mg.KIMI_API_KEY = os.environ.get("KIMI_API_KEY", "") mg.KIMI_BASE_URL = os.environ.get("KIMI_BASE_URL", "https://api.kimi.com/coding/v1") if not mg.KIMI_API_KEY: return json.dumps({"error": "KIMI_API_KEY not set in ~/.hermes/.env"}) results = mg.semantic_search(query, limit=min(max(1, limit), 50)) return json.dumps({ "results": results, "count": len(results), "query": query, }, ensure_ascii=False, default=str) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def embed_memory_messages(limit: int = 100) -> str: """ Generate Kimi embeddings for un-embedded messages in Memory Graph. Call this after ingest_wechat_to_memory to enable semantic search. Args: limit: Max number of messages to embed per call (default 100, max 500). """ try: import sys _base = os.path.dirname(__file__) if _base not in sys.path: sys.path.insert(0, _base) import memory_graph as mg env_path = os.path.expanduser("~/.hermes/.env") if os.path.exists(env_path): for line in open(env_path): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip()) mg.KIMI_API_KEY = os.environ.get("KIMI_API_KEY", "") mg.KIMI_BASE_URL = os.environ.get("KIMI_BASE_URL", "https://api.kimi.com/coding/v1") result = mg.embed_messages(limit=min(max(1, limit), 500)) stats = mg.get_stats() return json.dumps({"result": result, "stats": stats}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── tool: send_wechat_message ───────────────────────────────────────────── @mcp.tool() def send_wechat_message(to: str, content: str, timeout: int = 30) -> str: """ Send a WeChat text message to a contact or group. Uses Accessibility Service (ADB input) — works without Frida hooks. Workflow: 1. Open WeChat chat with 'to' via deep link intent 2. Wait for chat window to appear 3. Type content into the input field using adb input text 4. Send via ENTER key Args: to: WeChat username/wxid (e.g. 'wxid_xxxx'), or nickname for contacts. content: Message text to send. timeout: Seconds to wait for chat window (default 30). """ def _run(cmd: str) -> str: r = subprocess.run(["su", "-c", cmd], capture_output=True, text=True, timeout=10) return r.stdout.strip() # Step 1: Resolve wxid if nickname given if not to.startswith("wxid_") and not to.endswith("@chatroom"): key = _read_wechat_key() db = _find_wechat_db() if db: escaped = to.replace("'", "''") rows = _run_sqlcipher( f"SELECT username FROM rcontact WHERE type != 0 AND " f"(nickname LIKE '%{escaped}%' OR conRemark LIKE '%{escaped}%' " f"OR username = '{escaped}') LIMIT 1", db, key ) if isinstance(rows, list) and rows: to = rows[0].get("username", to) # Step 2: Open WeChat chat via intent intent_cmd = ( f"am start -n com.tencent.mm/.ui.LauncherUI " f"--es username '{to}' -a android.intent.action.VIEW" ) _run(intent_cmd) # Step 3: Wait for chatting window import time for _ in range(timeout // 2): ui = _run("dumpsys window windows 2>/dev/null | grep 'mCurrentFocus'") if "tencent.mm" in ui.lower(): break time.sleep(2) time.sleep(1) # Let the chat input field settle # Step 4: Type message (escape special chars for shell) safe = content.replace("'", "\\'").replace(" ", "%s").replace("&", "\\&") _run(f"input text '{safe}'") time.sleep(0.5) # Step 5: Send via ENTER (WeChat sends on Enter in the chat input field) _run("input keyevent KEYCODE_ENTER") time.sleep(0.5) # Verify by checking if the input field cleared (message sent) ui_after = _run("uiautomator dump /dev/stdout 2>/dev/null | grep -o 'EditText[^>]*' | head -1") return json.dumps({ "status": "sent", "to": to, "content": content[:50] + ("..." if len(content) > 50 else ""), "method": "accessibility_adb", }, ensure_ascii=False) # ── tool: send_message (semantic router) ───────────────────────────────── @mcp.tool() def send_message(to: str, content: str, app: str = "auto") -> str: """ Send a message to a contact, automatically routing to the right app. Args: to: Contact name, wxid, phone number, or username. content: Message text to send. app: 'wechat', 'telegram', or 'auto' (default: auto-detect from contact data). """ # For now, route all to WeChat (Telegram support added in S6-02) resolved_app = app if app == "auto": # Try WeChat first (most common) key = _read_wechat_key() db = _find_wechat_db() if db: escaped = to.replace("'", "''") rows = _run_sqlcipher( f"SELECT username FROM rcontact WHERE type != 0 AND " f"(username='{escaped}' OR nickname LIKE '%{escaped}%' OR conRemark LIKE '%{escaped}%') LIMIT 1", db, key ) if isinstance(rows, list) and rows: resolved_app = "wechat" if resolved_app == "auto": resolved_app = "wechat" # default if resolved_app == "wechat": # Delegate to send_wechat_message return send_wechat_message(to=to, content=content) return json.dumps({"error": f"app '{resolved_app}' not supported yet (WeChat only)"}) # ── tool: create_event ──────────────────────────────────────────────────── @mcp.tool() def create_event(title: str, start_time: str, end_time: str = "", description: str = "", location: str = "") -> str: """ Create a calendar event via Android ContentProvider (CalendarContract). Args: title: Event title. start_time: ISO 8601 or natural time string, e.g. '2026-06-15 14:00'. end_time: Event end time (optional, defaults to +1 hour). description: Event description (optional). location: Event location (optional). """ import re as _re from datetime import datetime, timedelta def parse_ts(s: str) -> int: for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M", "%Y-%m-%d"): try: return int(datetime.strptime(s.strip(), fmt).timestamp() * 1000) except ValueError: continue return int(datetime.now().timestamp() * 1000) start_ms = parse_ts(start_time) end_ms = parse_ts(end_time) if end_time else start_ms + 3600000 try: # Find primary calendar account cal_query = subprocess.run( ["su", "-c", "content query --uri content://com.android.calendar/calendars --projection _id,account_name,name 2>/dev/null | head -5"], capture_output=True, text=True, timeout=10 ) # Insert event (uses default calendar ID=1) insert_cmd = ( f"content insert --uri content://com.android.calendar/events " f"--bind dtstart:l:{start_ms} " f"--bind dtend:l:{end_ms} " f"--bind title:s:{repr(title)} " f"--bind description:s:{repr(description)} " f"--bind eventLocation:s:{repr(location)} " f"--bind calendar_id:l:1 " f"--bind eventTimezone:s:Asia/Shanghai " f"--bind hasAlarm:l:0" ) result = subprocess.run( ["su", "-c", insert_cmd], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: event_id = result.stdout.strip().replace("Result: id=", "").strip() return json.dumps({"status": "created", "event_id": event_id, "title": title, "start_ms": start_ms, "end_ms": end_ms}) else: return json.dumps({"error": result.stderr[:300] or "insert failed"}) except Exception as e: return json.dumps({"error": str(e)}) # ── tool: read_document ─────────────────────────────────────────────────── @mcp.tool() def read_document(query: str = "", source: str = "", limit: int = 20) -> str: """ Search Memory Graph and recent events for relevant documents/messages. Args: query: Search keyword or phrase. source: Filter by app source ('wechat', 'event_bus', etc.). limit: Max results (default 20). """ results = [] # Search Memory Graph if query: try: import sys _base = os.path.dirname(__file__) if _base not in sys.path: sys.path.insert(0, _base) from memory_graph import search_memory as _search mem_results = _search(query, source_app=source, limit=limit) for r in mem_results: results.append({ "source": r.get("app", "wechat"), "content": r.get("content", ""), "talker": r.get("talker", ""), "nickname": r.get("nickname", ""), "ts": r.get("ts", 0), "type": "message", }) except Exception as e: results.append({"error": f"memory_graph: {e}"}) # Also search event bus try: with sqlite3.connect(EVENT_BUS_DB) as conn: sql = "SELECT type, app, data, ts FROM events WHERE 1=1" params = [] if query: sql += " AND data LIKE ?" params.append(f"%{query}%") if source: sql += " AND app = ?" params.append(source) sql += f" ORDER BY ts DESC LIMIT {limit}" for row in conn.execute(sql, params).fetchall(): results.append({ "source": "event_bus", "type": row[0], "app": row[1], "data": row[2][:200] if row[2] else "", "ts": row[3], }) except Exception: pass return json.dumps({ "results": results[:limit], "count": len(results), "query": query, }, ensure_ascii=False, default=str) # ── Sprint 8: Connector tools ────────────────────────────────────────────── @mcp.tool() def connector_read_wechat_messages(contact: str = "", limit: int = 50, since_ts: int = 0) -> str: """Read WeChat messages via WeChatConnector (config-driven, via Knowledge Base).""" try: sys.path.insert(0, EVENT_BUS_DIR) from connectors import WeChatConnector wc = WeChatConnector() rows = wc.read_messages(contact=contact, limit=limit, since_ts=since_ts) return json.dumps({"rows": rows, "count": len(rows)}, ensure_ascii=False, default=str) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def connector_read_wechat_contacts(limit: int = 100) -> str: """Read WeChat contacts via WeChatConnector.""" try: sys.path.insert(0, EVENT_BUS_DIR) from connectors import WeChatConnector wc = WeChatConnector() rows = wc.read_contacts(limit=limit) return json.dumps({"rows": rows, "count": len(rows)}, ensure_ascii=False, default=str) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def run_discovery_agent(app: str = "wechat") -> str: """Run the Discovery Sub-agent for an app — detects version, DB schema, validates hooks.""" try: sys.path.insert(0, EVENT_BUS_DIR) from connectors import DiscoveryAgent agent = DiscoveryAgent(app) result = agent.run() return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def get_connector_kb(app: str = "wechat") -> str: """Return the Connector Knowledge Base for an app.""" try: sys.path.insert(0, EVENT_BUS_DIR) from connectors import _load_kb return json.dumps(_load_kb(app), ensure_ascii=False, indent=2) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 10: Persona tools ─────────────────────────────────────────────── @mcp.tool() def get_persona() -> str: """Return the user's persona profile from ~/.hermes/persona.yaml.""" try: sys.path.insert(0, EVENT_BUS_DIR) from persona import get_persona as _get_persona return json.dumps(_get_persona(), ensure_ascii=False, indent=2) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def update_persona(key: str, value: str) -> str: """Update a persona field. key supports dot-notation e.g. 'preferences.work_hours'.""" try: sys.path.insert(0, EVENT_BUS_DIR) from persona import update_persona as _update_persona import json as _json try: parsed_value = _json.loads(value) except Exception: parsed_value = value result = _update_persona(key, parsed_value) return json.dumps({"ok": True, "updated": result}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def get_persona_prompt() -> str: """Generate the Hermes system prompt fragment from persona.yaml.""" try: sys.path.insert(0, EVENT_BUS_DIR) from persona import generate_system_prompt_fragment return generate_system_prompt_fragment() except Exception as e: return f"[persona error: {e}]" @mcp.tool() def infer_persona_from_behavior() -> str: """Auto-update persona (important_contacts, preferred_apps) from memory_graph data.""" try: sys.path.insert(0, EVENT_BUS_DIR) from persona import infer_from_memory_graph result = infer_from_memory_graph() return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 11: Cloud Knowledge tools ────────────────────────────────────── @mcp.tool() def upload_operation_knowledge(intent: str, app: str, app_version: str, method: str, success_rate: float = 1.0) -> str: """Upload a successful operation's structural knowledge to cloud.""" try: sys.path.insert(0, EVENT_BUS_DIR) from cloud_knowledge import make_knowledge_entry, upload_knowledge entry = make_knowledge_entry( intent=intent, app=app, app_version=app_version, execution_path=[{"method": method, "step": 1, "result": "success"}], success_rate=success_rate, ) result = upload_knowledge(entry) return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def pull_cloud_knowledge(app: str, intent: str, app_version: str = "") -> str: """Pull verified execution paths from cloud Knowledge Base.""" try: sys.path.insert(0, EVENT_BUS_DIR) from cloud_knowledge import pull_knowledge entries = pull_knowledge(app, intent, app_version) return json.dumps({"entries": entries, "count": len(entries)}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def retry_cloud_uploads() -> str: """Retry any locally-pending cloud knowledge uploads.""" try: sys.path.insert(0, EVENT_BUS_DIR) from cloud_knowledge import retry_pending_uploads result = retry_pending_uploads() return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 6-03: System Contacts ContentProvider ────────────────────────── @mcp.tool() def get_system_contacts(query: str = "", limit: int = 50) -> str: """Read system contacts from Android ContactsContract ContentProvider. Returns name, phone numbers, and contact ID. Optionally filter by name query.""" try: import re as _re def _parse_content_rows(output: str) -> list[dict]: rows = [] current: dict = {} for line in output.splitlines(): m = _re.match(r"\s*Row:\s*\d+\s+(.+)", line) if m: if current: rows.append(current) current = {} for kv in m.group(1).split(", "): if "=" in kv: k, _, v = kv.partition("=") current[k.strip()] = v.strip() if current: rows.append(current) return rows # Query names + IDs cmd_contacts = "content query --uri content://com.android.contacts/contacts --projection display_name:_id" r = subprocess.run(["sh", "-c", cmd_contacts], capture_output=True, text=True, timeout=15) contacts = _parse_content_rows(r.stdout) # Filter by query if query: q_lower = query.lower() contacts = [c for c in contacts if q_lower in c.get("display_name", "").lower()] contacts = contacts[:limit] if not contacts: return json.dumps({"contacts": [], "count": 0}) # Enrich with phone numbers cmd_phones = ("content query --uri content://com.android.contacts/data " "--projection display_name:data1:mimetype " "--where \"mimetype='vnd.android.cursor.item/phone_v2'\"") rp = subprocess.run(["sh", "-c", cmd_phones], capture_output=True, text=True, timeout=15) phones_raw = _parse_content_rows(rp.stdout) phone_map: dict[str, list[str]] = {} for row in phones_raw: name = row.get("display_name", "") phone = row.get("data1", "") if name and phone: phone_map.setdefault(name, []).append(phone) result = [] for c in contacts: name = c.get("display_name", "") result.append({ "id": c.get("_id", ""), "name": name, "phones": phone_map.get(name, []), }) return json.dumps({"contacts": result, "count": len(result)}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 6-04: System Calendar ContentProvider ────────────────────────── @mcp.tool() def get_calendar_events(days_ahead: int = 7, days_back: int = 1) -> str: """Read system calendar events from CalendarContract ContentProvider. Returns title, start/end times, location, description for upcoming events.""" try: import time as _time now_ms = int(_time.time() * 1000) start_ms = now_ms - days_back * 86400000 end_ms = now_ms + days_ahead * 86400000 cmd = (f"content query --uri content://com.android.calendar/events " f"--projection title:dtstart:dtend:eventLocation:description " f"--where \"dtstart>={start_ms} AND dtstart<={end_ms}\" " f"--sort \"dtstart ASC\"") r = subprocess.run(["sh", "-c", cmd], capture_output=True, text=True, timeout=15) import re as _re events = [] for line in r.stdout.splitlines(): m = _re.match(r"\s*Row:\s*\d+\s+(.+)", line) if m: ev: dict = {} for kv in m.group(1).split(", "): if "=" in kv: k, _, v = kv.partition("=") ev[k.strip()] = v.strip() if ev: # Convert ms timestamps for tf in ("dtstart", "dtend"): if tf in ev: try: import datetime as _dt ev[tf + "_iso"] = _dt.datetime.fromtimestamp( int(ev[tf]) / 1000).isoformat() except Exception: pass events.append(ev) return json.dumps({"events": events, "count": len(events)}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 5-03: Create task via Android Tasks/Reminders ───────────────── @mcp.tool() def create_task(title: str, deadline: str = "", note: str = "") -> str: """Create a task/reminder in the system Tasks app (Google Tasks or Samsung Reminders). deadline: ISO datetime string or empty. note: optional description.""" try: import time as _time import datetime as _dt # Parse deadline due_ms = "" if deadline: try: dt = _dt.datetime.fromisoformat(deadline.replace("Z", "+00:00")) due_ms = str(int(dt.timestamp() * 1000)) except Exception: pass # Try Google Tasks ContentProvider first bind = f"--bind title:s:{title}" if note: bind += f" --bind notes:s:{note}" if due_ms: bind += f" --bind due:l:{due_ms}" r = subprocess.run( ["sh", "-c", f"content insert --uri content://com.google.android.gms.tasks/tasks {bind}"], capture_output=True, text=True, timeout=10 ) if "error" not in r.stderr.lower() and r.returncode == 0: return json.dumps({"ok": True, "method": "google_tasks", "title": title}) # Fallback: use am start to open Tasks app with pre-filled title intent_cmd = (f"am start -a android.intent.action.INSERT " f"-d content://com.android.task/ " f"--es title {title!r}") if note: intent_cmd += f" --es description {note!r}" r2 = subprocess.run(["sh", "-c", intent_cmd], capture_output=True, text=True, timeout=10) if "Success" in r2.stdout or r2.returncode == 0: return json.dumps({"ok": True, "method": "intent", "title": title, "note": "Task created via Intent; verify in Tasks app"}) return json.dumps({"ok": False, "error": r2.stderr[:200] or r.stderr[:200], "hint": "No Tasks account configured on device"}) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 6-01: MMKV reader ─────────────────────────────────────────────── @mcp.tool() def read_wechat_mmkv(mmkv_file: str = "mmkv.default", max_keys: int = 50) -> str: """Read key-value pairs from WeChat MMKV storage files. MMKV files are in /data/data/com.tencent.mm/files/mmkv/. Returns readable string and integer key-value pairs.""" try: mmkv_dir = "/data/data/com.tencent.mm/files/mmkv" target = f"{mmkv_dir}/{mmkv_file}" r = subprocess.run(["su", "-c", f"ls {mmkv_dir}"], capture_output=True, text=True, timeout=5) available = [f for f in r.stdout.splitlines() if not f.endswith(".crc")] # Copy file to readable location tmp = f"/data/local/tmp/mmkv_read_{mmkv_file.replace('/', '_')}" cp = subprocess.run(["su", "-c", f"cp {target} {tmp} && chmod 644 {tmp}"], capture_output=True, text=True, timeout=5) if cp.returncode != 0: return json.dumps({"error": f"Cannot read {target}", "available": available}) # Parse MMKV binary format # MMKV format: 4-byte header (size), then protobuf-like key-value pairs # Key: varint len + bytes; Value: varint len + bytes (or int32/int64 varint) with open(tmp, "rb") as f: raw = f.read() # Skip 4-byte header pos = 4 results = [] seen_keys: set = set() def read_varint(data: bytes, offset: int): result = 0 shift = 0 while offset < len(data): b = data[offset] offset += 1 result |= (b & 0x7F) << shift shift += 7 if not (b & 0x80): break return result, offset while pos < len(raw) and len(results) < max_keys: if pos >= len(raw): break try: key_len, pos = read_varint(raw, pos) if key_len <= 0 or key_len > 256 or pos + key_len > len(raw): pos += 1 continue key = raw[pos:pos + key_len] pos += key_len try: key_str = key.decode("utf-8") except Exception: key_str = key.hex() val_len, pos = read_varint(raw, pos) if val_len < 0 or pos + val_len > len(raw): pos += 1 continue val = raw[pos:pos + val_len] pos += val_len if key_str in seen_keys: continue seen_keys.add(key_str) # Try to decode value as string, then int try: val_str = val.decode("utf-8") if val_str.isprintable(): results.append({"key": key_str, "value": val_str, "type": "string"}) continue except Exception: pass if len(val) <= 8: try: int_val = int.from_bytes(val, "little") results.append({"key": key_str, "value": int_val, "type": "int"}) continue except Exception: pass results.append({"key": key_str, "value": val.hex()[:64], "type": "bytes"}) except Exception: pos += 1 continue # Cleanup temp subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({ "file": mmkv_file, "available_files": available, "entries": results, "count": len(results), }, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) # ── Sprint 6-02: Telegram SQLite connector ──────────────────────────────── @mcp.tool() def get_telegram_messages(limit: int = 50, query: str = "", chat_id: int = 0) -> str: """Read messages from Telegram's local SQLite database (cache4.db). Requires Telegram to be installed and logged in. query: optional text filter. chat_id: filter by specific chat (0 = all). Returns messages with sender name, text, date.""" try: TG_PKG = "org.telegram.messenger.web" TG_DB = f"/data/data/{TG_PKG}/files/cache4.db" TG_DB2 = f"/data/data/{TG_PKG}/databases/cache4.db" tmp = "/data/local/tmp/tg_cache4.db" # Find the database db_path = None for p in [TG_DB, TG_DB2]: r = subprocess.run(["su", "-c", f"test -f {p} && echo exists"], capture_output=True, text=True, timeout=5) if "exists" in r.stdout: db_path = p break if not db_path: # List all files to help locate r2 = subprocess.run(["su", "-c", f"find /data/data/{TG_PKG} -name '*.db' 2>/dev/null"], capture_output=True, text=True, timeout=10) return json.dumps({ "error": "Telegram database not found — app may not be logged in yet", "found_dbs": r2.stdout.strip().splitlines(), "hint": "Launch Telegram and log in first", }) # Copy to accessible location subprocess.run(["su", "-c", f"cp {db_path} {tmp} && chmod 644 {tmp}"], capture_output=True, text=True, timeout=10) import sqlite3 as _sq conn = _sq.connect(tmp) conn.row_factory = _sq.Row # Build query sql = """ SELECT m.mid, m.uid, m.date, m.message, m.from_id, COALESCE(u.first_name || ' ' || COALESCE(u.last_name,''), u.name, '') as sender_name, COALESCE(u.username, '') as sender_username FROM messages m LEFT JOIN users u ON m.from_id = u.uid WHERE m.message != '' AND m.message IS NOT NULL """ params: list = [] if chat_id: sql += " AND m.uid = ?" params.append(chat_id) if query: sql += " AND m.message LIKE ?" params.append(f"%{query}%") sql += f" ORDER BY m.date DESC LIMIT {limit}" try: rows = conn.execute(sql, params).fetchall() except _sq.OperationalError: # Try alternative table/column names rows = [] try: alt = conn.execute( f"SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() tables = [r["name"] for r in alt] conn.close() subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({"error": "schema mismatch", "tables": tables}) except Exception: pass conn.close() subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({"error": "Telegram DB schema not recognized"}) messages = [] for r in rows: import datetime as _dt try: ts_iso = _dt.datetime.fromtimestamp(r["date"]).isoformat() except Exception: ts_iso = str(r["date"]) messages.append({ "mid": r["mid"], "chat_id": r["uid"], "from_id": r["from_id"], "sender": r["sender_name"].strip() or r["sender_username"] or str(r["from_id"]), "text": r["message"], "ts": r["date"], "ts_iso": ts_iso, }) conn.close() subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({"messages": messages, "count": len(messages)}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) @mcp.tool() def get_telegram_contacts(query: str = "", limit: int = 100) -> str: """Read Telegram contacts from local database. Requires Telegram logged in.""" try: TG_PKG = "org.telegram.messenger.web" tmp = "/data/local/tmp/tg_cache4.db" r = subprocess.run(["su", "-c", f"find /data/data/{TG_PKG} -name 'cache4.db' 2>/dev/null | head -1"], capture_output=True, text=True, timeout=10) db_path = r.stdout.strip() if not db_path: return json.dumps({"error": "Telegram not logged in — database not found"}) subprocess.run(["su", "-c", f"cp {db_path} {tmp} && chmod 644 {tmp}"], capture_output=True, text=True, timeout=10) import sqlite3 as _sq conn = _sq.connect(tmp) conn.row_factory = _sq.Row sql = "SELECT uid, first_name, last_name, username, phone FROM users WHERE 1=1" params: list = [] if query: sql += " AND (first_name LIKE ? OR last_name LIKE ? OR username LIKE ?)" params += [f"%{query}%"] * 3 sql += f" ORDER BY first_name LIMIT {limit}" try: rows = conn.execute(sql, params).fetchall() except _sq.OperationalError as e: conn.close() subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({"error": str(e)}) contacts = [{"uid": r["uid"], "name": f"{r['first_name'] or ''} {r['last_name'] or ''}".strip(), "username": r["username"], "phone": r["phone"]} for r in rows] conn.close() subprocess.run(["su", "-c", f"rm -f {tmp}"], capture_output=True, timeout=3) return json.dumps({"contacts": contacts, "count": len(contacts)}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}) return mcp def main(): verbose = "--verbose" in sys.argv or "-v" in sys.argv logging.basicConfig( level=logging.INFO if verbose else logging.WARNING, stream=sys.stderr, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) os.environ.setdefault("HERMES_QUIET", "1") try: server = _build_server() except ImportError as exc: sys.stderr.write(f"android_mcp: requires 'mcp' package: {exc}\n") sys.exit(2) try: server.run() except KeyboardInterrupt: sys.exit(0) except Exception as exc: logger.exception("android_mcp crashed") sys.stderr.write(f"android_mcp error: {exc}\n") sys.exit(1) if __name__ == "__main__": main()