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.
- {meshcode-1.0.0 → meshcode-1.1.0}/PKG-INFO +3 -1
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode/comms_v4.py +46 -25
- meshcode-1.1.0/meshcode/meshcode_mcp/__init__.py +2 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/__main__.py +17 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/backend.py +227 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/realtime.py +181 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/server.py +272 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/test_backend.py +86 -0
- meshcode-1.1.0/meshcode/meshcode_mcp/test_realtime.py +95 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode.egg-info/PKG-INFO +3 -1
- meshcode-1.1.0/meshcode.egg-info/SOURCES.txt +18 -0
- meshcode-1.1.0/meshcode.egg-info/entry_points.txt +3 -0
- meshcode-1.1.0/meshcode.egg-info/requires.txt +2 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/pyproject.toml +6 -1
- meshcode-1.0.0/meshcode.egg-info/SOURCES.txt +0 -10
- meshcode-1.0.0/meshcode.egg-info/entry_points.txt +0 -2
- {meshcode-1.0.0 → meshcode-1.1.0}/README.md +0 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode/__init__.py +0 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode/cli.py +0 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-1.0.0 → meshcode-1.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcode
|
|
3
|
-
Version: 1.
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
839
|
-
except:
|
|
842
|
+
api_key = json.loads(creds_path.read_text()).get("api_key", "")
|
|
843
|
+
except Exception:
|
|
840
844
|
pass
|
|
841
845
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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.
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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,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.
|
|
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
|
|
@@ -4,13 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshcode"
|
|
7
|
-
version = "1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|