meshcode 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
+ Requires-Dist: mcp[cli]>=1.0.0
22
+ Requires-Dist: websockets>=12.0
21
23
 
22
24
  # MeshCode
23
25
 
@@ -829,29 +829,45 @@ def connect(project, name, hook_target="claude", role=""):
829
829
  comms_path = str(Path(__file__).resolve())
830
830
 
831
831
  if hook_target == "claude":
832
- settings_path = Path.home() / ".claude" / "settings.json"
833
- hook_cmd = f"python3 {comms_path} check"
834
-
835
- settings = {}
836
- if settings_path.exists():
832
+ # Primary path: register MeshCode as an MCP server in ~/.mcp.json
833
+ # This replaces the AppleScript nudge entirely with native MCP delivery.
834
+ mcp_path = Path.home() / ".mcp.json"
835
+ server_id = f"meshcode-{project}-{name}"
836
+
837
+ # Load API key for the MCP server env
838
+ creds_path = Path.home() / ".meshcode" / "credentials.json"
839
+ api_key = ""
840
+ if creds_path.exists():
837
841
  try:
838
- settings = json.loads(settings_path.read_text())
839
- except:
842
+ api_key = json.loads(creds_path.read_text()).get("api_key", "")
843
+ except Exception:
840
844
  pass
841
845
 
842
- hooks = settings.setdefault("hooks", {})
843
- post_hooks = hooks.setdefault("PostToolUse", [])
844
-
845
- already = any(hook_cmd in h.get("command", "") for h in post_hooks)
846
- if not already:
847
- post_hooks.append({"command": hook_cmd, "matcher": ""})
848
- settings_path.parent.mkdir(parents=True, exist_ok=True)
849
- settings_path.write_text(json.dumps(settings, indent=2))
850
- print(f"[MESHCODE] Hook PostToolUse instalado en {settings_path}")
851
- else:
852
- print(f"[MESHCODE] Hook PostToolUse ya existe")
853
-
854
- print(f"[MESHCODE] Claude Code conectado a {project} como {name}")
846
+ mcp_cfg = {}
847
+ if mcp_path.exists():
848
+ try:
849
+ mcp_cfg = json.loads(mcp_path.read_text())
850
+ except Exception:
851
+ mcp_cfg = {}
852
+
853
+ servers = mcp_cfg.setdefault("mcpServers", {})
854
+ servers[server_id] = {
855
+ "command": "python3",
856
+ "args": ["-m", "meshcode.meshcode_mcp", "serve"],
857
+ "env": {
858
+ "MESHCODE_PROJECT": project,
859
+ "MESHCODE_AGENT": name,
860
+ "MESHCODE_ROLE": actual_role,
861
+ "MESHCODE_API_KEY": api_key,
862
+ "SUPABASE_URL": SUPABASE_URL,
863
+ "SUPABASE_KEY": SUPABASE_KEY,
864
+ },
865
+ }
866
+ mcp_path.write_text(json.dumps(mcp_cfg, indent=2))
867
+ print(f"[MESHCODE] MCP server '{server_id}' registrado en {mcp_path}")
868
+ print(f"[MESHCODE] Reinicia Claude Code para cargar el MCP server.")
869
+ print(f"[MESHCODE] Verifica con `/mcp` dentro de Claude Code.")
870
+ print(f"[MESHCODE] Tools disponibles: meshcode_send, meshcode_read, meshcode_check, meshcode_status, ...")
855
871
 
856
872
  elif hook_target == "codex":
857
873
  config_path = Path.cwd() / ".meshcode.json"
@@ -873,11 +889,16 @@ def connect(project, name, hook_target="claude", role=""):
873
889
  return
874
890
 
875
891
  # Print quickstart
876
- print(f"\n[MESHCODE] ✓ Setup completo. Comandos útiles:")
877
- print(f" Leer: python3 {comms_path} read {project} {name}")
878
- print(f" Enviar: python3 {comms_path} send {project} {name}:<destino> '<mensaje>'")
879
- print(f" Board: python3 {comms_path} board {project}")
880
- print(f" Watch: python3 {comms_path} watch {project} {name}")
892
+ print(f"\n[MESHCODE] ✓ Setup completo.")
893
+ if hook_target == "claude":
894
+ print(f" 1. Reinicia Claude Code (cierra y vuelve a abrir)")
895
+ print(f" 2. Dentro de Claude, ejecuta `/mcp` para verificar que el server '{name}' esté listed")
896
+ print(f" 3. Empieza a usar las tools: meshcode_send, meshcode_read, etc.")
897
+ print(f" Sin terminal extra. Sin watch loops. Sin AppleScript.")
898
+ else:
899
+ print(f" Leer: python3 {comms_path} read {project} {name}")
900
+ print(f" Enviar: python3 {comms_path} send {project} {name}:<destino> '<mensaje>'")
901
+ print(f" Board: python3 {comms_path} board {project}")
881
902
  print()
882
903
 
883
904
 
@@ -0,0 +1,2 @@
1
+ """MeshCode MCP server — exposes meshcode tools to MCP clients (Claude Code, etc)."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,17 @@
1
+ """Run via: python -m meshcode_mcp serve"""
2
+ import sys
3
+ from .server import run_server
4
+
5
+
6
+ def main():
7
+ args = sys.argv[1:]
8
+ if args and args[0] == "serve":
9
+ run_server()
10
+ else:
11
+ print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
12
+ print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
13
+ sys.exit(1)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
@@ -0,0 +1,227 @@
1
+ """Thin Supabase REST backend used by both the MCP server and tests.
2
+
3
+ Reuses the helpers from comms_v4.py without going through subprocess.
4
+ Zero deps beyond stdlib (urllib).
5
+ """
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ from typing import Any, Dict, List, Optional
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import quote
12
+ from urllib.request import Request, urlopen
13
+
14
+ SUPABASE_URL = os.environ.get(
15
+ "SUPABASE_URL",
16
+ "https://wwgzzmydrwrjgaebspdo.supabase.co",
17
+ )
18
+ SUPABASE_KEY = os.environ.get(
19
+ "SUPABASE_KEY",
20
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Ind3Z3p6bXlkcndyamdhZWJzcGRvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2NzY1NDc3MCwiZXhwIjoyMDgzMjMwNzcwfQ.0SBXfb8OtyHmfaKW3dFQ6JYbcLUzCS1d4oXg4V-RAag",
21
+ )
22
+ SCHEMA = "meshcode"
23
+
24
+
25
+ def _now_iso() -> str:
26
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S+00:00")
27
+
28
+
29
+ def _headers(*, prefer: Optional[str] = None, content_profile: bool = True) -> Dict[str, str]:
30
+ h = {
31
+ "apikey": SUPABASE_KEY,
32
+ "Authorization": f"Bearer {SUPABASE_KEY}",
33
+ "Content-Type": "application/json",
34
+ }
35
+ if content_profile:
36
+ h["Accept-Profile"] = SCHEMA
37
+ h["Content-Profile"] = SCHEMA
38
+ if prefer:
39
+ h["Prefer"] = prefer
40
+ return h
41
+
42
+
43
+ def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str] = None) -> Any:
44
+ url = f"{SUPABASE_URL}/rest/v1/{path}"
45
+ body = json.dumps(data).encode() if data else None
46
+ req = Request(url, data=body, method=method, headers=_headers(prefer=prefer))
47
+ try:
48
+ with urlopen(req, timeout=10) as resp:
49
+ raw = resp.read().decode()
50
+ return json.loads(raw) if raw.strip() else None
51
+ except HTTPError as e:
52
+ err = e.read().decode()
53
+ try:
54
+ err_obj = json.loads(err)
55
+ return {"_error": err_obj.get("message", err[:200]), "_code": e.code}
56
+ except Exception:
57
+ return {"_error": err[:200], "_code": e.code}
58
+ except URLError as e:
59
+ return {"_error": str(e.reason), "_code": 0}
60
+
61
+
62
+ def sb_select(table: str, filters: str = "", order: Optional[str] = None, limit: Optional[int] = None) -> List[Dict]:
63
+ params = []
64
+ if filters:
65
+ params.append(filters)
66
+ if order:
67
+ params.append(f"order={order}")
68
+ if limit:
69
+ params.append(f"limit={limit}")
70
+ path = f"{table}?{'&'.join(params)}" if params else table
71
+ result = _request("GET", path)
72
+ if isinstance(result, dict) and result.get("_error"):
73
+ return []
74
+ return result or []
75
+
76
+
77
+ def sb_insert(table: str, row: Dict, *, upsert: bool = False, on_conflict: Optional[str] = None) -> Any:
78
+ prefer = "return=representation"
79
+ if upsert:
80
+ prefer += ",resolution=merge-duplicates"
81
+ path = table
82
+ if on_conflict:
83
+ path += f"?on_conflict={on_conflict}"
84
+ return _request("POST", path, data=row, prefer=prefer)
85
+
86
+
87
+ def sb_update(table: str, filters: str, updates: Dict) -> Any:
88
+ return _request("PATCH", f"{table}?{filters}", data=updates, prefer="return=representation")
89
+
90
+
91
+ def sb_rpc(fn_name: str, params: Dict) -> Any:
92
+ url = f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}"
93
+ body = json.dumps(params).encode()
94
+ req = Request(url, data=body, method="POST", headers=_headers(content_profile=False))
95
+ try:
96
+ with urlopen(req, timeout=10) as resp:
97
+ raw = resp.read().decode()
98
+ return json.loads(raw) if raw.strip() else None
99
+ except HTTPError as e:
100
+ err = e.read().decode()
101
+ try:
102
+ return {"_error": json.loads(err).get("message", err[:200])}
103
+ except Exception:
104
+ return {"_error": err[:200]}
105
+ except URLError as e:
106
+ return {"_error": str(e.reason)}
107
+
108
+
109
+ # ============================================================
110
+ # Project + agent helpers
111
+ # ============================================================
112
+
113
+ def get_project_id(project_name: str) -> Optional[str]:
114
+ rows = sb_select("mc_projects", f"name=eq.{quote(project_name)}")
115
+ if rows:
116
+ return rows[0]["id"]
117
+ result = sb_insert("mc_projects", {"name": project_name})
118
+ if isinstance(result, list) and result:
119
+ return result[0]["id"]
120
+ return None
121
+
122
+
123
+ def register_agent(project: str, name: str, role: str = "") -> Dict:
124
+ project_id = get_project_id(project)
125
+ if not project_id:
126
+ return {"error": f"Project '{project}' not found"}
127
+
128
+ result = sb_rpc("mc_register_agent", {
129
+ "p_project_id": project_id,
130
+ "p_name": name,
131
+ "p_role": role,
132
+ "p_status": "online",
133
+ })
134
+
135
+ if not result or (isinstance(result, dict) and result.get("error")):
136
+ return result or {"error": "Failed to register agent"}
137
+
138
+ sb_update("mc_agents",
139
+ f"project_id=eq.{project_id}&name=eq.{quote(name)}",
140
+ {"task": role, "last_heartbeat": _now_iso()})
141
+
142
+ return {
143
+ "registered": True,
144
+ "project_id": project_id,
145
+ "agent_name": name,
146
+ "agent_id": result.get("agent_id") if isinstance(result, dict) else None,
147
+ }
148
+
149
+
150
+ def send_message(project_id: str, from_agent: str, to_agent: str, payload: Any, msg_type: str = "msg") -> Dict:
151
+ if not isinstance(payload, dict):
152
+ payload = {"text": str(payload)}
153
+ msg = {
154
+ "project_id": project_id,
155
+ "from_agent": from_agent,
156
+ "to_agent": to_agent,
157
+ "type": msg_type,
158
+ "payload": payload,
159
+ "read": False,
160
+ }
161
+ result = sb_insert("mc_messages", msg)
162
+ if isinstance(result, dict) and result.get("_error"):
163
+ return {"error": result["_error"]}
164
+ if isinstance(result, list) and result:
165
+ return {"sent": True, "msg_id": result[0].get("id")}
166
+ return {"sent": True}
167
+
168
+
169
+ def read_inbox(project_id: str, agent: str, mark_read: bool = True) -> List[Dict]:
170
+ messages = sb_select(
171
+ "mc_messages",
172
+ f"project_id=eq.{project_id}&to_agent=eq.{quote(agent)}&read=eq.false",
173
+ order="created_at.asc",
174
+ )
175
+ if mark_read and messages:
176
+ for m in messages:
177
+ sb_update("mc_messages", f"id=eq.{m['id']}", {"read": True})
178
+
179
+ # Auto-ACK senders
180
+ ack_targets = {m["from_agent"] for m in messages if m.get("type") not in ("ack", "broadcast")}
181
+ for sender in ack_targets:
182
+ sb_insert("mc_messages", {
183
+ "project_id": project_id,
184
+ "from_agent": agent,
185
+ "to_agent": sender,
186
+ "type": "ack",
187
+ "payload": {"text": f"{agent} read your message"},
188
+ "read": False,
189
+ })
190
+ return messages
191
+
192
+
193
+ def count_pending(project_id: str, agent: str) -> int:
194
+ pending = sb_select(
195
+ "mc_messages",
196
+ f"project_id=eq.{project_id}&to_agent=eq.{quote(agent)}&read=eq.false&type=neq.ack",
197
+ limit=1000,
198
+ )
199
+ return len(pending)
200
+
201
+
202
+ def get_board(project_id: str) -> List[Dict]:
203
+ return sb_select("mc_agents", f"project_id=eq.{project_id}", order="registered_at.asc")
204
+
205
+
206
+ def heartbeat(project_id: str, agent: str) -> Dict:
207
+ result = sb_rpc("mc_heartbeat", {"p_project_id": project_id, "p_agent_name": agent})
208
+ return result or {}
209
+
210
+
211
+ def set_status(project_id: str, agent: str, status: str, task: str = "") -> Dict:
212
+ updates = {"status": status, "last_heartbeat": _now_iso()}
213
+ if task:
214
+ updates["task"] = task
215
+ result = sb_update("mc_agents", f"project_id=eq.{project_id}&name=eq.{quote(agent)}", updates)
216
+ if isinstance(result, dict) and result.get("_error"):
217
+ return {"error": result["_error"]}
218
+ return {"ok": True, "status": status}
219
+
220
+
221
+ def get_history(project_id: str, limit: int = 20) -> List[Dict]:
222
+ return sb_select(
223
+ "mc_messages",
224
+ f"project_id=eq.{project_id}&type=neq.ack",
225
+ order="created_at.desc",
226
+ limit=limit,
227
+ )
@@ -0,0 +1,181 @@
1
+ """Supabase Realtime listener for the MeshCode MCP server.
2
+
3
+ Connects to Supabase Realtime via WebSocket (no supabase-py dep — uses
4
+ the `websockets` package directly to keep the install light).
5
+
6
+ When a new mc_message INSERT arrives for the current agent, it:
7
+ 1. Pushes the message into an in-memory deque (consumed by meshcode_check tool)
8
+ 2. Calls the registered notify_callback (FastMCP session.send_resource_updated)
9
+ """
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ from collections import deque
14
+ from typing import Any, Awaitable, Callable, Deque, Dict, Optional
15
+
16
+ try:
17
+ import websockets
18
+ WEBSOCKETS_AVAILABLE = True
19
+ except ImportError:
20
+ WEBSOCKETS_AVAILABLE = False
21
+
22
+ log = logging.getLogger("meshcode-mcp.realtime")
23
+
24
+
25
+ class RealtimeListener:
26
+ """Connects to Supabase Realtime and forwards mc_messages INSERTs to a callback."""
27
+
28
+ def __init__(
29
+ self,
30
+ supabase_url: str,
31
+ supabase_key: str,
32
+ project_id: str,
33
+ agent_name: str,
34
+ notify_callback: Optional[Callable[[Dict], Awaitable[None]]] = None,
35
+ ):
36
+ self.supabase_url = supabase_url
37
+ self.supabase_key = supabase_key
38
+ self.project_id = project_id
39
+ self.agent_name = agent_name
40
+ self.notify_callback = notify_callback
41
+
42
+ # Last 100 unread messages — drained by meshcode_check tool
43
+ self.queue: Deque[Dict] = deque(maxlen=100)
44
+ self._task: Optional[asyncio.Task] = None
45
+ self._stop = asyncio.Event()
46
+ self._connected = False
47
+
48
+ @property
49
+ def ws_url(self) -> str:
50
+ host = self.supabase_url.replace("https://", "").replace("http://", "").rstrip("/")
51
+ return f"wss://{host}/realtime/v1/websocket?apikey={self.supabase_key}&vsn=1.0.0"
52
+
53
+ async def start(self) -> None:
54
+ if not WEBSOCKETS_AVAILABLE:
55
+ log.warning("websockets package not installed — Realtime disabled")
56
+ return
57
+ if self._task and not self._task.done():
58
+ return
59
+ self._stop.clear()
60
+ self._task = asyncio.create_task(self._run(), name="meshcode-realtime")
61
+
62
+ async def stop(self) -> None:
63
+ self._stop.set()
64
+ if self._task:
65
+ self._task.cancel()
66
+ try:
67
+ await self._task
68
+ except (asyncio.CancelledError, Exception):
69
+ pass
70
+
71
+ async def _run(self) -> None:
72
+ """Outer loop: reconnect with exponential backoff on disconnect."""
73
+ backoff = 1
74
+ while not self._stop.is_set():
75
+ try:
76
+ await self._connect_and_listen()
77
+ backoff = 1 # reset on clean disconnect
78
+ except asyncio.CancelledError:
79
+ return
80
+ except Exception as e:
81
+ log.warning(f"Realtime connection error: {e}; reconnecting in {backoff}s")
82
+ try:
83
+ await asyncio.wait_for(self._stop.wait(), timeout=backoff)
84
+ return # stop signaled
85
+ except asyncio.TimeoutError:
86
+ pass
87
+ backoff = min(backoff * 2, 30)
88
+
89
+ async def _connect_and_listen(self) -> None:
90
+ """Single connection lifecycle: connect, subscribe, listen."""
91
+ async with websockets.connect(self.ws_url, ping_interval=20, ping_timeout=10) as ws:
92
+ self._connected = True
93
+ log.info(f"Realtime connected for agent={self.agent_name}")
94
+
95
+ # Phoenix channel join: phoenix realtime topic
96
+ topic = f"realtime:{self.project_id}-{self.agent_name}"
97
+ join_msg = {
98
+ "topic": topic,
99
+ "event": "phx_join",
100
+ "payload": {
101
+ "config": {
102
+ "postgres_changes": [
103
+ {
104
+ "event": "INSERT",
105
+ "schema": "meshcode",
106
+ "table": "mc_messages",
107
+ "filter": f"to_agent=eq.{self.agent_name}",
108
+ }
109
+ ]
110
+ }
111
+ },
112
+ "ref": "1",
113
+ }
114
+ await ws.send(json.dumps(join_msg))
115
+
116
+ # Heartbeat task to keep the connection alive
117
+ heartbeat_task = asyncio.create_task(self._heartbeat(ws))
118
+ try:
119
+ async for raw in ws:
120
+ if self._stop.is_set():
121
+ break
122
+ try:
123
+ msg = json.loads(raw)
124
+ except Exception:
125
+ continue
126
+ await self._handle_message(msg)
127
+ finally:
128
+ heartbeat_task.cancel()
129
+ self._connected = False
130
+
131
+ async def _heartbeat(self, ws) -> None:
132
+ """Send phoenix heartbeats every 25s to keep the channel alive."""
133
+ ref = 0
134
+ while not self._stop.is_set():
135
+ await asyncio.sleep(25)
136
+ ref += 1
137
+ try:
138
+ await ws.send(json.dumps({
139
+ "topic": "phoenix",
140
+ "event": "heartbeat",
141
+ "payload": {},
142
+ "ref": str(ref),
143
+ }))
144
+ except Exception:
145
+ return
146
+
147
+ async def _handle_message(self, msg: Dict[str, Any]) -> None:
148
+ event = msg.get("event")
149
+ payload = msg.get("payload") or {}
150
+
151
+ # postgres_changes payload structure:
152
+ # {"event": "postgres_changes", "payload": {"data": {"record": {...}, "type": "INSERT", ...}}}
153
+ if event == "postgres_changes":
154
+ data = payload.get("data") or {}
155
+ if data.get("type") == "INSERT":
156
+ record = data.get("record") or {}
157
+ if record.get("to_agent") == self.agent_name:
158
+ enriched = {
159
+ "from": record.get("from_agent"),
160
+ "type": record.get("type", "msg"),
161
+ "ts": record.get("created_at"),
162
+ "payload": record.get("payload", {}),
163
+ "id": record.get("id"),
164
+ }
165
+ self.queue.append(enriched)
166
+ log.info(f"new message from {enriched['from']}")
167
+ if self.notify_callback:
168
+ try:
169
+ await self.notify_callback(enriched)
170
+ except Exception as e:
171
+ log.warning(f"notify_callback failed: {e}")
172
+
173
+ def drain(self) -> list:
174
+ """Pop and return all queued messages."""
175
+ out = list(self.queue)
176
+ self.queue.clear()
177
+ return out
178
+
179
+ @property
180
+ def is_connected(self) -> bool:
181
+ return self._connected
@@ -0,0 +1,272 @@
1
+ """MeshCode MCP server — exposes meshcode tools to MCP clients (Claude Code, etc).
2
+
3
+ Tools: meshcode_send, meshcode_broadcast, meshcode_read, meshcode_check,
4
+ meshcode_status, meshcode_register, meshcode_set_status
5
+ Resources: meshcode://inbox, meshcode://board, meshcode://history
6
+
7
+ Run with:
8
+ MESHCODE_PROJECT=my-app MESHCODE_AGENT=backend python -m meshcode_mcp serve
9
+ """
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import sys
15
+ from contextlib import asynccontextmanager
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from . import backend as be
19
+ from .realtime import RealtimeListener
20
+
21
+ try:
22
+ from mcp.server.fastmcp import FastMCP
23
+ except ImportError:
24
+ print(
25
+ "[meshcode-mcp] ERROR: mcp package not installed. Run: pip install 'mcp[cli]>=1.0'",
26
+ file=sys.stderr,
27
+ )
28
+ sys.exit(2)
29
+
30
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr,
31
+ format="[meshcode-mcp] %(message)s")
32
+ log = logging.getLogger("meshcode-mcp")
33
+
34
+
35
+ # ============================================================
36
+ # Server context — agent identity from env vars
37
+ # ============================================================
38
+
39
+ PROJECT_NAME = os.environ.get("MESHCODE_PROJECT", "")
40
+ AGENT_NAME = os.environ.get("MESHCODE_AGENT", "")
41
+ AGENT_ROLE = os.environ.get("MESHCODE_ROLE", "")
42
+
43
+ if not PROJECT_NAME or not AGENT_NAME:
44
+ print(
45
+ "[meshcode-mcp] ERROR: MESHCODE_PROJECT and MESHCODE_AGENT env vars required",
46
+ file=sys.stderr,
47
+ )
48
+ sys.exit(2)
49
+
50
+
51
+ # Resolve project_id once at startup; auto-register the agent
52
+ _PROJECT_ID: Optional[str] = be.get_project_id(PROJECT_NAME)
53
+ if not _PROJECT_ID:
54
+ print(f"[meshcode-mcp] ERROR: project '{PROJECT_NAME}' not found", file=sys.stderr)
55
+ sys.exit(2)
56
+
57
+ _register_result = be.register_agent(PROJECT_NAME, AGENT_NAME, AGENT_ROLE or "MCP-connected agent")
58
+ if isinstance(_register_result, dict) and _register_result.get("error"):
59
+ print(f"[meshcode-mcp] WARNING: register failed: {_register_result['error']}", file=sys.stderr)
60
+
61
+
62
+ # ============================================================
63
+ # Realtime listener (created in lifespan)
64
+ # ============================================================
65
+
66
+ _REALTIME: Optional[RealtimeListener] = None
67
+
68
+
69
+ async def _on_new_message(msg: Dict[str, Any]) -> None:
70
+ """Best-effort: try to push an MCP resource-updated notification.
71
+
72
+ If the underlying session supports send_resource_updated_notification,
73
+ use it. If not (older MCP versions or unsupported), the message is
74
+ still queued and meshcode_check / next-tool-call will surface it.
75
+ """
76
+ try:
77
+ srv = mcp._mcp_server # FastMCP exposes the lowlevel server here
78
+ ctx = getattr(srv, "request_context", None)
79
+ if ctx and getattr(ctx, "session", None):
80
+ from pydantic import AnyUrl
81
+ await ctx.session.send_resource_updated(AnyUrl("meshcode://inbox"))
82
+ log.info(f"sent MCP resource_updated notification for msg from {msg.get('from')}")
83
+ except Exception as e:
84
+ log.debug(f"send_resource_updated unavailable: {e}")
85
+
86
+
87
+ @asynccontextmanager
88
+ async def lifespan(_app):
89
+ """Start the Realtime listener when the MCP server boots; stop on shutdown."""
90
+ global _REALTIME
91
+ _REALTIME = RealtimeListener(
92
+ supabase_url=be.SUPABASE_URL,
93
+ supabase_key=be.SUPABASE_KEY,
94
+ project_id=_PROJECT_ID,
95
+ agent_name=AGENT_NAME,
96
+ notify_callback=_on_new_message,
97
+ )
98
+ await _REALTIME.start()
99
+ log.info(f"lifespan started — Realtime listener active for {AGENT_NAME}")
100
+ try:
101
+ yield {"realtime": _REALTIME}
102
+ finally:
103
+ log.info("lifespan shutdown — stopping Realtime listener")
104
+ await _REALTIME.stop()
105
+
106
+
107
+ # ============================================================
108
+ # FastMCP server
109
+ # ============================================================
110
+
111
+ mcp = FastMCP(name=f"meshcode-{PROJECT_NAME}-{AGENT_NAME}", lifespan=lifespan)
112
+
113
+
114
+ # ----------------- TOOLS -----------------
115
+
116
+ @mcp.tool()
117
+ def meshcode_send(to: str, payload: Dict[str, Any]) -> Dict[str, Any]:
118
+ """Send a message to another agent in the meshwork.
119
+
120
+ Args:
121
+ to: Name of the recipient agent.
122
+ payload: Structured message payload (use protocol keys: need/done/fyi/blocked).
123
+ """
124
+ return be.send_message(_PROJECT_ID, AGENT_NAME, to, payload, msg_type="msg")
125
+
126
+
127
+ @mcp.tool()
128
+ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
129
+ """Send a message to ALL agents in the meshwork (except yourself).
130
+
131
+ Args:
132
+ payload: Structured message payload.
133
+ """
134
+ agents = be.get_board(_PROJECT_ID)
135
+ sent = 0
136
+ for a in agents:
137
+ if a["name"] != AGENT_NAME:
138
+ be.send_message(_PROJECT_ID, AGENT_NAME, a["name"], payload, msg_type="broadcast")
139
+ sent += 1
140
+ return {"broadcast": True, "agents_notified": sent}
141
+
142
+
143
+ @mcp.tool()
144
+ def meshcode_read() -> Dict[str, Any]:
145
+ """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders."""
146
+ messages = be.read_inbox(_PROJECT_ID, AGENT_NAME)
147
+ return {
148
+ "count": len(messages),
149
+ "messages": [
150
+ {
151
+ "from": m["from_agent"],
152
+ "type": m.get("type", "msg"),
153
+ "ts": m.get("created_at"),
154
+ "payload": m.get("payload", {}),
155
+ }
156
+ for m in messages
157
+ ],
158
+ }
159
+
160
+
161
+ @mcp.tool()
162
+ def meshcode_check() -> Dict[str, Any]:
163
+ """Quick poll: returns pending message count + any messages buffered by the
164
+ Realtime listener since the last check.
165
+
166
+ Use this to check if there's anything new without marking messages as read.
167
+ Useful as the first call in a tool loop or after a long thinking phase.
168
+ """
169
+ pending = be.count_pending(_PROJECT_ID, AGENT_NAME)
170
+ realtime_buffered = _REALTIME.drain() if _REALTIME else []
171
+ return {
172
+ "pending": pending,
173
+ "agent": AGENT_NAME,
174
+ "project": PROJECT_NAME,
175
+ "realtime_connected": _REALTIME.is_connected if _REALTIME else False,
176
+ "buffered": realtime_buffered,
177
+ }
178
+
179
+
180
+ @mcp.tool()
181
+ def meshcode_status() -> Dict[str, Any]:
182
+ """Get the meshwork status board: all agents with their status, role, and current task."""
183
+ agents = be.get_board(_PROJECT_ID)
184
+ return {
185
+ "project": PROJECT_NAME,
186
+ "agents": [
187
+ {
188
+ "name": a["name"],
189
+ "role": a.get("role", ""),
190
+ "status": a.get("status", "?"),
191
+ "task": a.get("task", ""),
192
+ "last_heartbeat": a.get("last_heartbeat"),
193
+ }
194
+ for a in agents
195
+ ],
196
+ }
197
+
198
+
199
+ @mcp.tool()
200
+ def meshcode_register(role: str = "") -> Dict[str, Any]:
201
+ """Re-register this agent in the meshwork. Use if you got disconnected or
202
+ want to update your role description.
203
+ """
204
+ return be.register_agent(PROJECT_NAME, AGENT_NAME, role or AGENT_ROLE)
205
+
206
+
207
+ @mcp.tool()
208
+ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
209
+ """Update your status in the board.
210
+
211
+ Args:
212
+ status: One of: working, idle, standby, blocked, done, online.
213
+ task: Optional human-readable task description.
214
+ """
215
+ return be.set_status(_PROJECT_ID, AGENT_NAME, status, task)
216
+
217
+
218
+ # ----------------- RESOURCES -----------------
219
+
220
+ @mcp.resource("meshcode://inbox")
221
+ def inbox_resource() -> str:
222
+ """Current pending messages for this agent. Read-only — does NOT mark as read."""
223
+ pending = be.sb_select(
224
+ "mc_messages",
225
+ f"project_id=eq.{_PROJECT_ID}&to_agent=eq.{AGENT_NAME}&read=eq.false",
226
+ order="created_at.asc",
227
+ )
228
+ return json.dumps({
229
+ "count": len(pending),
230
+ "messages": [
231
+ {
232
+ "from": m["from_agent"],
233
+ "type": m.get("type", "msg"),
234
+ "ts": m.get("created_at"),
235
+ "payload": m.get("payload", {}),
236
+ }
237
+ for m in pending
238
+ ],
239
+ }, indent=2)
240
+
241
+
242
+ @mcp.resource("meshcode://board")
243
+ def board_resource() -> str:
244
+ """Snapshot of the meshwork board (all agents and their status)."""
245
+ agents = be.get_board(_PROJECT_ID)
246
+ return json.dumps({
247
+ "project": PROJECT_NAME,
248
+ "agents": agents,
249
+ }, indent=2, default=str)
250
+
251
+
252
+ @mcp.resource("meshcode://history")
253
+ def history_resource() -> str:
254
+ """Recent message history for this meshwork (last 20 non-ack messages)."""
255
+ history = be.get_history(_PROJECT_ID, limit=20)
256
+ return json.dumps({
257
+ "project": PROJECT_NAME,
258
+ "history": history,
259
+ }, indent=2, default=str)
260
+
261
+
262
+ # ============================================================
263
+ # Entry point
264
+ # ============================================================
265
+
266
+ def run_server():
267
+ """Start the MCP server on stdio (default for Claude Code)."""
268
+ print(
269
+ f"[meshcode-mcp] Starting server for {AGENT_NAME}@{PROJECT_NAME}",
270
+ file=sys.stderr,
271
+ )
272
+ mcp.run()
@@ -0,0 +1,86 @@
1
+ """Tests for meshcode_mcp backend helpers (stdlib only — no pytest)."""
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ import unittest
7
+
8
+ # Use service role key for testing
9
+ os.environ.setdefault("SUPABASE_URL", "https://wwgzzmydrwrjgaebspdo.supabase.co")
10
+
11
+ # Test against an isolated project
12
+ TEST_PROJECT = f"mcp-test-{int(time.time())}"
13
+ TEST_AGENT_A = "test-agent-a"
14
+ TEST_AGENT_B = "test-agent-b"
15
+
16
+ from . import backend as be # noqa: E402
17
+
18
+
19
+ class BackendTests(unittest.TestCase):
20
+ project_id = None
21
+
22
+ @classmethod
23
+ def setUpClass(cls):
24
+ cls.project_id = be.get_project_id(TEST_PROJECT)
25
+ assert cls.project_id, "Failed to create test project"
26
+
27
+ @classmethod
28
+ def tearDownClass(cls):
29
+ # Cleanup: delete the test project (cascades to agents + messages)
30
+ from urllib.parse import quote
31
+ be._request("DELETE", f"mc_projects?id=eq.{cls.project_id}")
32
+
33
+ def test_01_register_agent_a(self):
34
+ result = be.register_agent(TEST_PROJECT, TEST_AGENT_A, "Test agent A")
35
+ self.assertTrue(result.get("registered"), f"Register failed: {result}")
36
+ self.assertEqual(result.get("agent_name"), TEST_AGENT_A)
37
+
38
+ def test_02_register_agent_b(self):
39
+ result = be.register_agent(TEST_PROJECT, TEST_AGENT_B, "Test agent B")
40
+ self.assertTrue(result.get("registered"))
41
+
42
+ def test_03_send_message(self):
43
+ result = be.send_message(
44
+ self.project_id, TEST_AGENT_A, TEST_AGENT_B,
45
+ {"need": "test message", "priority": "normal"},
46
+ )
47
+ self.assertTrue(result.get("sent"))
48
+ self.assertIsNotNone(result.get("msg_id"))
49
+
50
+ def test_04_count_pending(self):
51
+ pending = be.count_pending(self.project_id, TEST_AGENT_B)
52
+ self.assertGreaterEqual(pending, 1, "Expected at least 1 pending message")
53
+
54
+ def test_05_read_inbox(self):
55
+ messages = be.read_inbox(self.project_id, TEST_AGENT_B)
56
+ self.assertGreaterEqual(len(messages), 1)
57
+ msg = messages[0]
58
+ self.assertEqual(msg["from_agent"], TEST_AGENT_A)
59
+ self.assertEqual(msg["payload"]["need"], "test message")
60
+
61
+ def test_06_inbox_empty_after_read(self):
62
+ # After read, pending should be 0 (the auto-ACK goes back to A, not B)
63
+ pending = be.count_pending(self.project_id, TEST_AGENT_B)
64
+ self.assertEqual(pending, 0)
65
+
66
+ def test_07_get_board(self):
67
+ agents = be.get_board(self.project_id)
68
+ names = [a["name"] for a in agents]
69
+ self.assertIn(TEST_AGENT_A, names)
70
+ self.assertIn(TEST_AGENT_B, names)
71
+
72
+ def test_08_set_status(self):
73
+ result = be.set_status(self.project_id, TEST_AGENT_A, "working", "running tests")
74
+ self.assertTrue(result.get("ok"), f"set_status failed: {result}")
75
+
76
+ def test_09_heartbeat(self):
77
+ result = be.heartbeat(self.project_id, TEST_AGENT_A)
78
+ self.assertEqual(result.get("status"), "alive")
79
+
80
+ def test_10_get_history(self):
81
+ history = be.get_history(self.project_id, limit=10)
82
+ self.assertGreaterEqual(len(history), 1)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main(verbosity=2)
@@ -0,0 +1,95 @@
1
+ """Tests for the Realtime listener — verifies WebSocket connect, subscribe,
2
+ INSERT event handling, and queue draining.
3
+ """
4
+ import asyncio
5
+ import os
6
+ import time
7
+ import unittest
8
+
9
+ from . import backend as be
10
+ from .realtime import RealtimeListener, WEBSOCKETS_AVAILABLE
11
+
12
+ TEST_PROJECT = f"mcp-rt-test-{int(time.time())}"
13
+ TEST_AGENT = "rt-listener"
14
+ SENDER = "rt-sender"
15
+
16
+
17
+ @unittest.skipUnless(WEBSOCKETS_AVAILABLE, "websockets package not installed")
18
+ class RealtimeTests(unittest.TestCase):
19
+ project_id = None
20
+
21
+ @classmethod
22
+ def setUpClass(cls):
23
+ cls.project_id = be.get_project_id(TEST_PROJECT)
24
+ be.register_agent(TEST_PROJECT, TEST_AGENT, "Realtime test target")
25
+ be.register_agent(TEST_PROJECT, SENDER, "Realtime test sender")
26
+
27
+ @classmethod
28
+ def tearDownClass(cls):
29
+ be._request("DELETE", f"mc_projects?id=eq.{cls.project_id}")
30
+
31
+ def test_listener_connects_and_receives(self):
32
+ """Connect listener, send message via REST, verify it lands in the queue."""
33
+ async def run():
34
+ received = []
35
+
36
+ async def callback(msg):
37
+ received.append(msg)
38
+
39
+ listener = RealtimeListener(
40
+ supabase_url=be.SUPABASE_URL,
41
+ supabase_key=be.SUPABASE_KEY,
42
+ project_id=self.project_id,
43
+ agent_name=TEST_AGENT,
44
+ notify_callback=callback,
45
+ )
46
+ await listener.start()
47
+
48
+ # Wait for the WebSocket connection to be established
49
+ for _ in range(20):
50
+ if listener.is_connected:
51
+ break
52
+ await asyncio.sleep(0.5)
53
+ self.assertTrue(listener.is_connected, "Listener failed to connect")
54
+
55
+ # Give Phoenix a moment to process the join + subscribe
56
+ await asyncio.sleep(2)
57
+
58
+ # Send a message via REST (simulates another agent)
59
+ be.send_message(self.project_id, SENDER, TEST_AGENT, {"need": "rt-test"})
60
+
61
+ # Wait up to 10s for the message to arrive via Realtime
62
+ for _ in range(20):
63
+ if listener.queue:
64
+ break
65
+ await asyncio.sleep(0.5)
66
+
67
+ await listener.stop()
68
+
69
+ # Verify it landed in the queue
70
+ self.assertGreater(len(listener.queue) + len(received), 0,
71
+ "Listener did not receive any messages")
72
+ if listener.queue:
73
+ msg = listener.queue[0]
74
+ self.assertEqual(msg.get("from"), SENDER)
75
+ self.assertEqual(msg.get("payload", {}).get("need"), "rt-test")
76
+
77
+ asyncio.run(run())
78
+
79
+ def test_drain_clears_queue(self):
80
+ """drain() returns and clears the queue."""
81
+ listener = RealtimeListener(
82
+ supabase_url=be.SUPABASE_URL,
83
+ supabase_key=be.SUPABASE_KEY,
84
+ project_id=self.project_id,
85
+ agent_name=TEST_AGENT,
86
+ )
87
+ listener.queue.append({"from": "x", "payload": {}})
88
+ listener.queue.append({"from": "y", "payload": {}})
89
+ drained = listener.drain()
90
+ self.assertEqual(len(drained), 2)
91
+ self.assertEqual(len(listener.queue), 0)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ unittest.main(verbosity=2)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
+ Requires-Dist: mcp[cli]>=1.0.0
22
+ Requires-Dist: websockets>=12.0
21
23
 
22
24
  # MeshCode
23
25
 
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ meshcode/__init__.py
4
+ meshcode/cli.py
5
+ meshcode/comms_v4.py
6
+ meshcode.egg-info/PKG-INFO
7
+ meshcode.egg-info/SOURCES.txt
8
+ meshcode.egg-info/dependency_links.txt
9
+ meshcode.egg-info/entry_points.txt
10
+ meshcode.egg-info/requires.txt
11
+ meshcode.egg-info/top_level.txt
12
+ meshcode/meshcode_mcp/__init__.py
13
+ meshcode/meshcode_mcp/__main__.py
14
+ meshcode/meshcode_mcp/backend.py
15
+ meshcode/meshcode_mcp/realtime.py
16
+ meshcode/meshcode_mcp/server.py
17
+ meshcode/meshcode_mcp/test_backend.py
18
+ meshcode/meshcode_mcp/test_realtime.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ meshcode = meshcode.cli:main
3
+ meshcode-mcp = meshcode.meshcode_mcp.__main__:main
@@ -0,0 +1,2 @@
1
+ mcp[cli]>=1.0.0
2
+ websockets>=12.0
@@ -4,13 +4,17 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.9"
12
12
  authors = [{name = "MeshCode", email = "hello@meshcode.io"}]
13
13
  keywords = ["ai", "agents", "communication", "realtime", "supabase", "claude", "codex"]
14
+ dependencies = [
15
+ "mcp[cli]>=1.0.0",
16
+ "websockets>=12.0",
17
+ ]
14
18
  classifiers = [
15
19
  "Development Status :: 4 - Beta",
16
20
  "Intended Audience :: Developers",
@@ -25,6 +29,7 @@ classifiers = [
25
29
 
26
30
  [project.scripts]
27
31
  meshcode = "meshcode.cli:main"
32
+ meshcode-mcp = "meshcode.meshcode_mcp.__main__:main"
28
33
 
29
34
  [project.urls]
30
35
  Homepage = "https://meshcode.io"
@@ -1,10 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- meshcode/__init__.py
4
- meshcode/cli.py
5
- meshcode/comms_v4.py
6
- meshcode.egg-info/PKG-INFO
7
- meshcode.egg-info/SOURCES.txt
8
- meshcode.egg-info/dependency_links.txt
9
- meshcode.egg-info/entry_points.txt
10
- meshcode.egg-info/top_level.txt
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- meshcode = meshcode.cli:main
File without changes
File without changes
File without changes
File without changes