meshcode 1.8.4__tar.gz → 1.8.8__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.8.4 → meshcode-1.8.8}/PKG-INFO +1 -1
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/__init__.py +1 -1
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/comms_v4.py +61 -4
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/invites.py +2 -2
- meshcode-1.8.8/meshcode/meshcode_mcp/__main__.py +39 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/backend.py +16 -9
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/realtime.py +41 -5
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/server.py +68 -2
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/run_agent.py +28 -1
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/secrets.py +15 -8
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/setup_clients.py +143 -20
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-1.8.4 → meshcode-1.8.8}/pyproject.toml +1 -1
- meshcode-1.8.4/meshcode/meshcode_mcp/__main__.py +0 -17
- {meshcode-1.8.4 → meshcode-1.8.8}/README.md +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/cli.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/launcher.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/launcher_install.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/preferences.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/protocol_v2.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/self_update.py +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-1.8.4 → meshcode-1.8.8}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MeshCode — Real-time communication between AI agents."""
|
|
2
|
-
__version__ = "1.8.
|
|
2
|
+
__version__ = "1.8.8"
|
|
@@ -28,6 +28,14 @@ import sys
|
|
|
28
28
|
import time
|
|
29
29
|
import subprocess
|
|
30
30
|
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
# Force UTF-8 stdio so unicode chars (✓, →, etc.) in CLI output don't crash
|
|
33
|
+
# on Windows cp1252. Safe no-op on POSIX.
|
|
34
|
+
try:
|
|
35
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
36
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
31
39
|
from pathlib import Path
|
|
32
40
|
from urllib.request import Request, urlopen
|
|
33
41
|
from urllib.error import URLError, HTTPError
|
|
@@ -39,8 +47,8 @@ from urllib.parse import quote
|
|
|
39
47
|
# Production defaults baked in. The publishable key is the anon/public key
|
|
40
48
|
# (RLS-protected, safe to ship — same one the frontend at meshcode.io uses
|
|
41
49
|
# in the browser). Override via env vars or ~/.meshcode/env if you self-host.
|
|
42
|
-
_DEFAULT_SUPABASE_URL = "https://
|
|
43
|
-
_DEFAULT_SUPABASE_KEY = "
|
|
50
|
+
_DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
|
|
51
|
+
_DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
|
|
44
52
|
|
|
45
53
|
def _load_env_file():
|
|
46
54
|
"""Read SUPABASE_URL/KEY from ~/.meshcode/env if present (overrides defaults)."""
|
|
@@ -190,12 +198,61 @@ def ensure_sessions():
|
|
|
190
198
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
191
199
|
|
|
192
200
|
|
|
201
|
+
def _load_api_key_for_cli() -> str:
|
|
202
|
+
"""Pull the user's api key from the keychain (or env fallback) so the
|
|
203
|
+
CLI can call SECURITY DEFINER RPCs that use api_key auth instead of
|
|
204
|
+
relying on the publishable anon key + RLS, which is what CLI verbs
|
|
205
|
+
used to do and which always failed because the CLI has no JWT context.
|
|
206
|
+
"""
|
|
207
|
+
# 1) explicit env override
|
|
208
|
+
k = os.environ.get("MESHCODE_API_KEY", "").strip()
|
|
209
|
+
if k:
|
|
210
|
+
return k
|
|
211
|
+
# 2) keychain via secrets module
|
|
212
|
+
try:
|
|
213
|
+
import importlib
|
|
214
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
215
|
+
profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or secrets_mod.DEFAULT_PROFILE
|
|
216
|
+
key = secrets_mod.get_api_key(profile=profile) or ""
|
|
217
|
+
if key:
|
|
218
|
+
return key
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
return ""
|
|
222
|
+
|
|
223
|
+
|
|
193
224
|
def get_project_id(project_name):
|
|
194
|
-
"""
|
|
225
|
+
"""Resolve a project's UUID for the authenticated CLI user.
|
|
226
|
+
|
|
227
|
+
Resolution order:
|
|
228
|
+
1. api_key + mc_resolve_project RPC (SECURITY DEFINER, the only
|
|
229
|
+
path that actually works for an authenticated CLI session)
|
|
230
|
+
2. legacy publishable-anon SELECT (only useful for shared/public
|
|
231
|
+
projects; will fail under RLS for owned projects)
|
|
232
|
+
3. legacy publishable-anon INSERT (will fail under RLS — kept as
|
|
233
|
+
last resort for backwards compat with very old test scripts)
|
|
234
|
+
"""
|
|
235
|
+
api_key = _load_api_key_for_cli()
|
|
236
|
+
if api_key:
|
|
237
|
+
try:
|
|
238
|
+
r = sb_rpc("mc_resolve_project", {
|
|
239
|
+
"p_api_key": api_key,
|
|
240
|
+
"p_project_name": project_name,
|
|
241
|
+
})
|
|
242
|
+
if isinstance(r, dict) and r.get("project_id"):
|
|
243
|
+
return r["project_id"]
|
|
244
|
+
# Some deployments return rows
|
|
245
|
+
if isinstance(r, list) and r and r[0].get("project_id"):
|
|
246
|
+
return r[0]["project_id"]
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
195
250
|
rows = sb_select("mc_projects", f"name=eq.{quote(project_name)}")
|
|
196
251
|
if rows:
|
|
197
252
|
return rows[0]["id"]
|
|
198
|
-
|
|
253
|
+
|
|
254
|
+
# Last resort: try to create. Will fail under RLS for unauthenticated
|
|
255
|
+
# contexts, but kept for legacy callers + admin tooling.
|
|
199
256
|
result = sb_insert("mc_projects", {"name": project_name})
|
|
200
257
|
if result and len(result) > 0:
|
|
201
258
|
return result[0]["id"]
|
|
@@ -41,8 +41,8 @@ from urllib.request import Request, urlopen
|
|
|
41
41
|
# Supabase RPC helpers
|
|
42
42
|
# ============================================================
|
|
43
43
|
|
|
44
|
-
_DEFAULT_SUPABASE_URL = "https://
|
|
45
|
-
_DEFAULT_SUPABASE_KEY = "
|
|
44
|
+
_DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
|
|
45
|
+
_DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _sb() -> Dict[str, str]:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Run via: python -m meshcode_mcp serve"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# CRITICAL Windows fix: the MCP protocol uses stdio JSON-RPC. On Windows
|
|
6
|
+
# stdin/stdout default to cp1252 encoding and block-buffered when piped.
|
|
7
|
+
# - cp1252 → any non-ASCII char in a JSON message raises UnicodeEncodeError
|
|
8
|
+
# and corrupts the byte stream → Claude Code times out the handshake.
|
|
9
|
+
# - Block-buffered → JSON-RPC bytes get stuck in the kernel buffer until
|
|
10
|
+
# the buffer fills, so Claude Code never sees the handshake response.
|
|
11
|
+
# Both must be fixed BEFORE FastMCP touches stdio.
|
|
12
|
+
try:
|
|
13
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n", line_buffering=True)
|
|
14
|
+
sys.stdin.reconfigure(encoding="utf-8", errors="replace", newline="\n")
|
|
15
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
# Belt and suspenders: also export PYTHONIOENCODING in case any subprocess
|
|
19
|
+
# we spawn inherits this env.
|
|
20
|
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
21
|
+
# Tell self_update / other modules: we are the MCP serve subprocess; do
|
|
22
|
+
# NOT auto-update from inside this process.
|
|
23
|
+
os.environ.setdefault("MESHCODE_MCP_SERVE", "1")
|
|
24
|
+
|
|
25
|
+
from .server import run_server
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
args = sys.argv[1:]
|
|
30
|
+
if args and args[0] == "serve":
|
|
31
|
+
run_server()
|
|
32
|
+
else:
|
|
33
|
+
print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
|
|
34
|
+
print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -13,8 +13,8 @@ from urllib.parse import quote
|
|
|
13
13
|
from urllib.request import Request, urlopen
|
|
14
14
|
|
|
15
15
|
# Bake in production defaults — RLS-protected publishable key, safe to ship.
|
|
16
|
-
_DEFAULT_SUPABASE_URL = "https://
|
|
17
|
-
_DEFAULT_SUPABASE_KEY = "
|
|
16
|
+
_DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
|
|
17
|
+
_DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
|
|
18
18
|
|
|
19
19
|
def _load_env_file() -> Dict[str, str]:
|
|
20
20
|
env_path = Path.home() / ".meshcode" / "env"
|
|
@@ -22,7 +22,7 @@ def _load_env_file() -> Dict[str, str]:
|
|
|
22
22
|
return {}
|
|
23
23
|
out: Dict[str, str] = {}
|
|
24
24
|
try:
|
|
25
|
-
for line in env_path.read_text().splitlines():
|
|
25
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
26
26
|
line = line.strip()
|
|
27
27
|
if line.startswith("export "):
|
|
28
28
|
line = line[7:]
|
|
@@ -59,14 +59,14 @@ def _headers(*, prefer: Optional[str] = None, content_profile: bool = True) -> D
|
|
|
59
59
|
|
|
60
60
|
def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str] = None) -> Any:
|
|
61
61
|
url = f"{SUPABASE_URL}/rest/v1/{path}"
|
|
62
|
-
body = json.dumps(data).encode() if data else None
|
|
62
|
+
body = json.dumps(data).encode("utf-8") if data else None
|
|
63
63
|
req = Request(url, data=body, method=method, headers=_headers(prefer=prefer))
|
|
64
64
|
try:
|
|
65
65
|
with urlopen(req, timeout=10) as resp:
|
|
66
|
-
raw = resp.read().decode()
|
|
66
|
+
raw = resp.read().decode("utf-8")
|
|
67
67
|
return json.loads(raw) if raw.strip() else None
|
|
68
68
|
except HTTPError as e:
|
|
69
|
-
err = e.read().decode()
|
|
69
|
+
err = e.read().decode("utf-8", errors="replace")
|
|
70
70
|
try:
|
|
71
71
|
err_obj = json.loads(err)
|
|
72
72
|
return {"_error": err_obj.get("message", err[:200]), "_code": e.code}
|
|
@@ -107,14 +107,14 @@ def sb_update(table: str, filters: str, updates: Dict) -> Any:
|
|
|
107
107
|
|
|
108
108
|
def sb_rpc(fn_name: str, params: Dict) -> Any:
|
|
109
109
|
url = f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}"
|
|
110
|
-
body = json.dumps(params).encode()
|
|
110
|
+
body = json.dumps(params).encode("utf-8")
|
|
111
111
|
req = Request(url, data=body, method="POST", headers=_headers(content_profile=False))
|
|
112
112
|
try:
|
|
113
113
|
with urlopen(req, timeout=10) as resp:
|
|
114
|
-
raw = resp.read().decode()
|
|
114
|
+
raw = resp.read().decode("utf-8")
|
|
115
115
|
return json.loads(raw) if raw.strip() else None
|
|
116
116
|
except HTTPError as e:
|
|
117
|
-
err = e.read().decode()
|
|
117
|
+
err = e.read().decode("utf-8", errors="replace")
|
|
118
118
|
try:
|
|
119
119
|
return {"_error": json.loads(err).get("message", err[:200])}
|
|
120
120
|
except Exception:
|
|
@@ -139,6 +139,13 @@ def get_project_id(project_name: str) -> Optional[str]:
|
|
|
139
139
|
|
|
140
140
|
def register_agent(project: str, name: str, role: str = "") -> Dict:
|
|
141
141
|
project_id = get_project_id(project)
|
|
142
|
+
if not project_id:
|
|
143
|
+
# Fallback: many spawn paths (setup_clients.py) bake the project
|
|
144
|
+
# uuid into the env so we can register even when the name lookup
|
|
145
|
+
# fails (RLS, schema cache, network blip, anon-key visibility).
|
|
146
|
+
env_pid = os.environ.get("MESHCODE_PROJECT_ID", "").strip()
|
|
147
|
+
if env_pid:
|
|
148
|
+
project_id = env_pid
|
|
142
149
|
if not project_id:
|
|
143
150
|
return {"error": f"Project '{project}' not found"}
|
|
144
151
|
|
|
@@ -10,9 +10,20 @@ When a new mc_message INSERT arrives for the current agent, it:
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import ssl
|
|
13
14
|
from collections import deque
|
|
14
15
|
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
|
|
15
16
|
|
|
17
|
+
try:
|
|
18
|
+
import certifi
|
|
19
|
+
_SSL_CTX = ssl.create_default_context(cafile=certifi.where())
|
|
20
|
+
except Exception:
|
|
21
|
+
# Fallback to system trust store if certifi isn't available.
|
|
22
|
+
try:
|
|
23
|
+
_SSL_CTX = ssl.create_default_context()
|
|
24
|
+
except Exception:
|
|
25
|
+
_SSL_CTX = None
|
|
26
|
+
|
|
16
27
|
try:
|
|
17
28
|
import websockets
|
|
18
29
|
WEBSOCKETS_AVAILABLE = True
|
|
@@ -42,11 +53,15 @@ class RealtimeListener:
|
|
|
42
53
|
# Last 100 unread messages — drained by meshcode_check tool
|
|
43
54
|
self.queue: Deque[Dict] = deque(maxlen=100)
|
|
44
55
|
self._task: Optional[asyncio.Task] = None
|
|
45
|
-
|
|
56
|
+
# asyncio.Event() in Py3.10+ no longer requires a running loop, but
|
|
57
|
+
# on older Python or certain Windows event-loop policies it can
|
|
58
|
+
# raise. Defer creation to start() which is always called from
|
|
59
|
+
# inside the running event loop.
|
|
60
|
+
self._stop: Optional[asyncio.Event] = None
|
|
46
61
|
self._connected = False
|
|
47
62
|
# Event fired whenever a new message is appended to the queue.
|
|
48
|
-
# meshcode_wait awaits this instead of polling
|
|
49
|
-
self.message_event
|
|
63
|
+
# meshcode_wait awaits this instead of polling -> zero-cost idle.
|
|
64
|
+
self.message_event: Optional[asyncio.Event] = None
|
|
50
65
|
|
|
51
66
|
@property
|
|
52
67
|
def ws_url(self) -> str:
|
|
@@ -57,13 +72,18 @@ class RealtimeListener:
|
|
|
57
72
|
if not WEBSOCKETS_AVAILABLE:
|
|
58
73
|
log.warning("websockets package not installed — Realtime disabled")
|
|
59
74
|
return
|
|
75
|
+
if self._stop is None:
|
|
76
|
+
self._stop = asyncio.Event()
|
|
77
|
+
if self.message_event is None:
|
|
78
|
+
self.message_event = asyncio.Event()
|
|
60
79
|
if self._task and not self._task.done():
|
|
61
80
|
return
|
|
62
81
|
self._stop.clear()
|
|
63
82
|
self._task = asyncio.create_task(self._run(), name="meshcode-realtime")
|
|
64
83
|
|
|
65
84
|
async def stop(self) -> None:
|
|
66
|
-
self._stop
|
|
85
|
+
if self._stop is not None:
|
|
86
|
+
self._stop.set()
|
|
67
87
|
if self._task:
|
|
68
88
|
self._task.cancel()
|
|
69
89
|
try:
|
|
@@ -91,7 +111,18 @@ class RealtimeListener:
|
|
|
91
111
|
|
|
92
112
|
async def _connect_and_listen(self) -> None:
|
|
93
113
|
"""Single connection lifecycle: connect, subscribe, listen."""
|
|
94
|
-
|
|
114
|
+
# Cap the initial TLS handshake at 10s. Without this, websockets.connect
|
|
115
|
+
# can hang indefinitely behind a corporate firewall / DNS stall, which
|
|
116
|
+
# blocks the MCP lifespan startup -> Claude Code times out the spawn
|
|
117
|
+
# -> "Failed to reconnect".
|
|
118
|
+
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10}
|
|
119
|
+
if _SSL_CTX is not None and self.ws_url.startswith("wss://"):
|
|
120
|
+
connect_kwargs["ssl"] = _SSL_CTX
|
|
121
|
+
ws = await asyncio.wait_for(
|
|
122
|
+
websockets.connect(self.ws_url, **connect_kwargs),
|
|
123
|
+
timeout=10.0,
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
95
126
|
self._connected = True
|
|
96
127
|
log.info(f"Realtime connected for agent={self.agent_name}")
|
|
97
128
|
|
|
@@ -130,6 +161,11 @@ class RealtimeListener:
|
|
|
130
161
|
finally:
|
|
131
162
|
heartbeat_task.cancel()
|
|
132
163
|
self._connected = False
|
|
164
|
+
finally:
|
|
165
|
+
try:
|
|
166
|
+
await ws.close()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
133
169
|
|
|
134
170
|
async def _heartbeat(self, ws) -> None:
|
|
135
171
|
"""Send phoenix heartbeats every 25s to keep the channel alive."""
|
|
@@ -212,8 +212,61 @@ def _flip_status(status: str, task: str = "") -> bool:
|
|
|
212
212
|
log.warning(f"set_status RPC threw: {e}")
|
|
213
213
|
return False
|
|
214
214
|
|
|
215
|
-
if not _flip_status("
|
|
216
|
-
print(f"[meshcode-mcp] WARNING: could not flip status to
|
|
215
|
+
if not _flip_status("idle", ""):
|
|
216
|
+
print(f"[meshcode-mcp] WARNING: could not flip status to idle", file=sys.stderr)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ============================================================
|
|
220
|
+
# Working/idle status decorator for @mcp.tool() functions.
|
|
221
|
+
# Fire-and-forget flips so tool execution is never blocked.
|
|
222
|
+
# ============================================================
|
|
223
|
+
import functools as _functools
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _async_flip_status(status: str, task: str = "") -> None:
|
|
227
|
+
try:
|
|
228
|
+
await asyncio.to_thread(_flip_status, status, task)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
log.debug(f"flip_status({status}) failed: {e}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _schedule_flip(status: str, task: str = "") -> None:
|
|
234
|
+
try:
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
loop.create_task(_async_flip_status(status, task))
|
|
237
|
+
except RuntimeError:
|
|
238
|
+
# No running loop (sync context) — run in a throwaway thread
|
|
239
|
+
import threading
|
|
240
|
+
threading.Thread(
|
|
241
|
+
target=_flip_status, args=(status, task), daemon=True
|
|
242
|
+
).start()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def with_working_status(func):
|
|
246
|
+
name = func.__name__
|
|
247
|
+
skip = (name == "meshcode_wait")
|
|
248
|
+
if asyncio.iscoroutinefunction(func):
|
|
249
|
+
@_functools.wraps(func)
|
|
250
|
+
async def awrapper(*args, **kwargs):
|
|
251
|
+
if not skip:
|
|
252
|
+
_schedule_flip("working", name)
|
|
253
|
+
try:
|
|
254
|
+
return await func(*args, **kwargs)
|
|
255
|
+
finally:
|
|
256
|
+
if not skip:
|
|
257
|
+
_schedule_flip("idle", "")
|
|
258
|
+
return awrapper
|
|
259
|
+
else:
|
|
260
|
+
@_functools.wraps(func)
|
|
261
|
+
def swrapper(*args, **kwargs):
|
|
262
|
+
if not skip:
|
|
263
|
+
_schedule_flip("working", name)
|
|
264
|
+
try:
|
|
265
|
+
return func(*args, **kwargs)
|
|
266
|
+
finally:
|
|
267
|
+
if not skip:
|
|
268
|
+
_schedule_flip("idle", "")
|
|
269
|
+
return swrapper
|
|
217
270
|
|
|
218
271
|
|
|
219
272
|
# ============================================================
|
|
@@ -513,6 +566,7 @@ except Exception:
|
|
|
513
566
|
# ----------------- TOOLS -----------------
|
|
514
567
|
|
|
515
568
|
@mcp.tool()
|
|
569
|
+
@with_working_status
|
|
516
570
|
def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
|
|
517
571
|
sensitive: bool = False) -> Dict[str, Any]:
|
|
518
572
|
"""Send a message to another agent in the meshwork.
|
|
@@ -548,6 +602,7 @@ def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
|
|
|
548
602
|
|
|
549
603
|
|
|
550
604
|
@mcp.tool()
|
|
605
|
+
@with_working_status
|
|
551
606
|
def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
552
607
|
"""Send a message to ALL agents in the meshwork (except yourself).
|
|
553
608
|
|
|
@@ -564,6 +619,7 @@ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
564
619
|
|
|
565
620
|
|
|
566
621
|
@mcp.tool()
|
|
622
|
+
@with_working_status
|
|
567
623
|
def meshcode_read(include_acks: bool = False) -> Dict[str, Any]:
|
|
568
624
|
"""Read all pending (unread) messages for this agent. Marks them as read and ACKs senders.
|
|
569
625
|
|
|
@@ -608,6 +664,7 @@ def _detect_global_done(messages: List[Dict[str, Any]]) -> Optional[Dict[str, An
|
|
|
608
664
|
|
|
609
665
|
|
|
610
666
|
@mcp.tool()
|
|
667
|
+
@with_working_status
|
|
611
668
|
async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False) -> Dict[str, Any]:
|
|
612
669
|
"""LONG-POLL: Block until a new message arrives for this agent (or timeout).
|
|
613
670
|
|
|
@@ -689,6 +746,7 @@ async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False)
|
|
|
689
746
|
|
|
690
747
|
|
|
691
748
|
@mcp.tool()
|
|
749
|
+
@with_working_status
|
|
692
750
|
def meshcode_done(reason: str) -> Dict[str, Any]:
|
|
693
751
|
"""Broadcast a GLOBAL DONE signal to every other agent in the meshwork.
|
|
694
752
|
|
|
@@ -716,6 +774,7 @@ def meshcode_done(reason: str) -> Dict[str, Any]:
|
|
|
716
774
|
|
|
717
775
|
|
|
718
776
|
@mcp.tool()
|
|
777
|
+
@with_working_status
|
|
719
778
|
def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
|
|
720
779
|
"""Quick poll: returns pending message count + any messages buffered by the
|
|
721
780
|
Realtime listener since the last check.
|
|
@@ -739,6 +798,7 @@ def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
|
|
|
739
798
|
|
|
740
799
|
|
|
741
800
|
@mcp.tool()
|
|
801
|
+
@with_working_status
|
|
742
802
|
def meshcode_status() -> Dict[str, Any]:
|
|
743
803
|
"""Get the meshwork status board: all agents with their status, role, and current task."""
|
|
744
804
|
agents = be.get_board(_PROJECT_ID)
|
|
@@ -758,6 +818,7 @@ def meshcode_status() -> Dict[str, Any]:
|
|
|
758
818
|
|
|
759
819
|
|
|
760
820
|
@mcp.tool()
|
|
821
|
+
@with_working_status
|
|
761
822
|
def meshcode_register(role: str = "") -> Dict[str, Any]:
|
|
762
823
|
"""Re-register this agent in the meshwork. Use if you got disconnected or
|
|
763
824
|
want to update your role description.
|
|
@@ -777,6 +838,7 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
|
|
|
777
838
|
|
|
778
839
|
|
|
779
840
|
@mcp.tool()
|
|
841
|
+
@with_working_status
|
|
780
842
|
def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
|
|
781
843
|
"""Re-initialize this MCP session for a different (project, agent). Use ONLY
|
|
782
844
|
if you need to dynamically switch context — normally the env vars set at
|
|
@@ -795,6 +857,7 @@ def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
|
|
|
795
857
|
|
|
796
858
|
|
|
797
859
|
@mcp.tool()
|
|
860
|
+
@with_working_status
|
|
798
861
|
def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
|
|
799
862
|
priority: str = "normal", parent_task_id: Optional[str] = None) -> Dict[str, Any]:
|
|
800
863
|
"""Create a task in the meshwork's shared backlog. Use this when you (typically
|
|
@@ -816,6 +879,7 @@ def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
|
|
|
816
879
|
|
|
817
880
|
|
|
818
881
|
@mcp.tool()
|
|
882
|
+
@with_working_status
|
|
819
883
|
def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
|
|
820
884
|
"""List tasks in the meshwork. Use this to discover work you can pick up.
|
|
821
885
|
|
|
@@ -829,6 +893,7 @@ def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
|
|
|
829
893
|
|
|
830
894
|
|
|
831
895
|
@mcp.tool()
|
|
896
|
+
@with_working_status
|
|
832
897
|
def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
|
|
833
898
|
"""Atomically claim an open task. If the task is already claimed by someone
|
|
834
899
|
else (or assigned to a different agent), this returns an error and another
|
|
@@ -842,6 +907,7 @@ def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
|
|
|
842
907
|
|
|
843
908
|
|
|
844
909
|
@mcp.tool()
|
|
910
|
+
@with_working_status
|
|
845
911
|
def meshcode_task_complete(task_id: str, summary: str = "") -> Dict[str, Any]:
|
|
846
912
|
"""Mark a task as done. Only the claimer can complete it.
|
|
847
913
|
|
|
@@ -94,7 +94,11 @@ def _detect_editor() -> Optional[str]:
|
|
|
94
94
|
return override
|
|
95
95
|
print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
# Detection order: most likely to be installed + best per-workspace MCP
|
|
98
|
+
# support first. codex is last because its MCP config is GLOBAL only —
|
|
99
|
+
# launching it doesn't take a workspace-scoped .mcp.json, so it should
|
|
100
|
+
# only be picked if nothing else is available.
|
|
101
|
+
for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
|
|
98
102
|
if shutil.which(cmd):
|
|
99
103
|
return cmd
|
|
100
104
|
return None
|
|
@@ -108,6 +112,16 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
108
112
|
except Exception:
|
|
109
113
|
pass
|
|
110
114
|
|
|
115
|
+
# Detect: are we already inside a Claude Code session? os.execvp(claude)
|
|
116
|
+
# from inside an existing claude won't work — claude needs a fresh
|
|
117
|
+
# interactive terminal it owns. Refuse with a clear message.
|
|
118
|
+
if os.environ.get("CLAUDECODE") == "1" or os.environ.get("CLAUDE_CODE_SESSION"):
|
|
119
|
+
print("[meshcode] ERROR: meshcode run cannot bootstrap a new agent from inside an", file=sys.stderr)
|
|
120
|
+
print("[meshcode] existing Claude Code session.", file=sys.stderr)
|
|
121
|
+
print("[meshcode] Open a fresh Terminal / iTerm window and run the command there.", file=sys.stderr)
|
|
122
|
+
print(f"[meshcode] (or copy this exact line into a new terminal: meshcode run {agent})", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
111
125
|
found = _find_agent_workspace(agent, project)
|
|
112
126
|
if not found:
|
|
113
127
|
return 2
|
|
@@ -152,6 +166,19 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
152
166
|
elif editor == "code":
|
|
153
167
|
# VS Code (Cline reads .vscode/mcp.json from workspace cwd).
|
|
154
168
|
cmd = [editor, str(ws)]
|
|
169
|
+
elif editor == "windsurf":
|
|
170
|
+
# Windsurf (Codeium fork of VS Code) — reads workspace cwd the same
|
|
171
|
+
# way VS Code does; global MCP config at ~/.codeium/windsurf/mcp_config.json.
|
|
172
|
+
cmd = [editor, str(ws)]
|
|
173
|
+
elif editor == "codex":
|
|
174
|
+
# Codex CLI reads ~/.codex/config.toml globally. There's no per-
|
|
175
|
+
# workspace flag, so launching is just `codex` with cwd set to the
|
|
176
|
+
# workspace dir so any file ops the agent does land there.
|
|
177
|
+
try:
|
|
178
|
+
os.chdir(ws)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
cmd = [editor]
|
|
155
182
|
else:
|
|
156
183
|
cmd = [editor, str(ws)]
|
|
157
184
|
|
|
@@ -84,6 +84,13 @@ def _probe_keyring() -> bool:
|
|
|
84
84
|
except ImportError:
|
|
85
85
|
_KEYRING_AVAILABLE = False
|
|
86
86
|
_KEYRING_BACKEND_NAME = "(keyring not installed)"
|
|
87
|
+
except Exception:
|
|
88
|
+
# Defensive: a broken Win32 Credential Manager, locked profile,
|
|
89
|
+
# or weird security policy can make even keyring.get_keyring()
|
|
90
|
+
# raise. Never let it bubble up — secrets module must always
|
|
91
|
+
# import cleanly so the MCP server can boot.
|
|
92
|
+
_KEYRING_AVAILABLE = False
|
|
93
|
+
_KEYRING_BACKEND_NAME = "(keyring probe failed)"
|
|
87
94
|
return _KEYRING_AVAILABLE
|
|
88
95
|
|
|
89
96
|
|
|
@@ -113,7 +120,7 @@ def _load_index() -> Dict[str, Any]:
|
|
|
113
120
|
if not PROFILE_INDEX.exists():
|
|
114
121
|
return {"profiles": {}}
|
|
115
122
|
try:
|
|
116
|
-
return json.loads(PROFILE_INDEX.read_text())
|
|
123
|
+
return json.loads(PROFILE_INDEX.read_text(encoding="utf-8"))
|
|
117
124
|
except Exception:
|
|
118
125
|
return {"profiles": {}}
|
|
119
126
|
|
|
@@ -121,7 +128,7 @@ def _load_index() -> Dict[str, Any]:
|
|
|
121
128
|
def _save_index(idx: Dict[str, Any]) -> None:
|
|
122
129
|
PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
|
|
123
130
|
tmp = PROFILE_INDEX.with_suffix(".tmp")
|
|
124
|
-
tmp.write_text(json.dumps(idx, indent=2))
|
|
131
|
+
tmp.write_text(json.dumps(idx, indent=2), encoding="utf-8")
|
|
125
132
|
try:
|
|
126
133
|
os.chmod(tmp, 0o600)
|
|
127
134
|
except Exception:
|
|
@@ -174,7 +181,7 @@ def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
|
|
|
174
181
|
# Fallback: mode-600 JSON file (legacy path, secured)
|
|
175
182
|
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
176
183
|
fallback_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
-
fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2))
|
|
184
|
+
fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2), encoding="utf-8")
|
|
178
185
|
try:
|
|
179
186
|
os.chmod(fallback_path, 0o600)
|
|
180
187
|
except Exception:
|
|
@@ -204,7 +211,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
|
|
|
204
211
|
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
205
212
|
if fallback_path.exists():
|
|
206
213
|
try:
|
|
207
|
-
data = json.loads(fallback_path.read_text())
|
|
214
|
+
data = json.loads(fallback_path.read_text(encoding="utf-8"))
|
|
208
215
|
if isinstance(data, dict) and data.get("api_key"):
|
|
209
216
|
return data["api_key"]
|
|
210
217
|
except Exception:
|
|
@@ -213,7 +220,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
|
|
|
213
220
|
# Legacy pre-1.4.1 path: only valid for the default profile
|
|
214
221
|
if profile == DEFAULT_PROFILE and LEGACY_CREDS_FILE.exists():
|
|
215
222
|
try:
|
|
216
|
-
data = json.loads(LEGACY_CREDS_FILE.read_text())
|
|
223
|
+
data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
|
|
217
224
|
if isinstance(data, dict) and data.get("api_key"):
|
|
218
225
|
return data["api_key"]
|
|
219
226
|
except Exception:
|
|
@@ -299,13 +306,13 @@ def migrate_legacy_credentials() -> bool:
|
|
|
299
306
|
# Nothing to migrate, but still drop the flag so we don't re-check forever
|
|
300
307
|
try:
|
|
301
308
|
MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
-
MIGRATION_FLAG.write_text("no legacy file")
|
|
309
|
+
MIGRATION_FLAG.write_text("no legacy file", encoding="utf-8")
|
|
303
310
|
except Exception:
|
|
304
311
|
pass
|
|
305
312
|
return False
|
|
306
313
|
|
|
307
314
|
try:
|
|
308
|
-
data = json.loads(LEGACY_CREDS_FILE.read_text())
|
|
315
|
+
data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
|
|
309
316
|
except Exception:
|
|
310
317
|
return False
|
|
311
318
|
api_key = (data or {}).get("api_key") if isinstance(data, dict) else None
|
|
@@ -327,7 +334,7 @@ def migrate_legacy_credentials() -> bool:
|
|
|
327
334
|
MIGRATION_FLAG.write_text(json.dumps({
|
|
328
335
|
"migrated_at_unix": int(__import__("time").time()),
|
|
329
336
|
"backend": _KEYRING_BACKEND_NAME or "(unknown)",
|
|
330
|
-
}))
|
|
337
|
+
}), encoding="utf-8")
|
|
331
338
|
except Exception:
|
|
332
339
|
pass
|
|
333
340
|
|
|
@@ -93,9 +93,9 @@ def _load_supabase_env() -> Dict[str, str]:
|
|
|
93
93
|
elif k == "SUPABASE_KEY" and not key:
|
|
94
94
|
key = v
|
|
95
95
|
if not url:
|
|
96
|
-
url = "https://
|
|
96
|
+
url = "https://gjinagyyjttyxnaoavnz.supabase.co"
|
|
97
97
|
if not key:
|
|
98
|
-
key = "
|
|
98
|
+
key = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
|
|
99
99
|
return {"SUPABASE_URL": url, "SUPABASE_KEY": key}
|
|
100
100
|
|
|
101
101
|
|
|
@@ -147,6 +147,12 @@ def _build_server_block(project: str, project_id: str, agent: str, role: str,
|
|
|
147
147
|
"MESHCODE_KEYCHAIN_PROFILE": keychain_profile,
|
|
148
148
|
"SUPABASE_URL": sb["SUPABASE_URL"],
|
|
149
149
|
"SUPABASE_KEY": sb["SUPABASE_KEY"],
|
|
150
|
+
# Force UTF-8 stdio so the MCP JSON-RPC channel doesn't crash on
|
|
151
|
+
# Windows cp1252 when the server emits any non-ASCII char.
|
|
152
|
+
"PYTHONIOENCODING": "utf-8",
|
|
153
|
+
# Mark this Python process as the MCP serve subprocess so other
|
|
154
|
+
# parts of the package (e.g. self_update) skip auto-update inside it.
|
|
155
|
+
"MESHCODE_MCP_SERVE": "1",
|
|
150
156
|
}
|
|
151
157
|
# Belt-and-suspenders fallback: if the OS doesn't have a keychain backend
|
|
152
158
|
# the MCP server can't read the key from there, so we still need to pass
|
|
@@ -172,6 +178,93 @@ def _atomic_write_json(path: Path, data: dict) -> None:
|
|
|
172
178
|
tmp.replace(path)
|
|
173
179
|
|
|
174
180
|
|
|
181
|
+
def _toml_escape(s: str) -> str:
|
|
182
|
+
"""Escape a string for a TOML basic string literal."""
|
|
183
|
+
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _render_codex_block(server_id: str, server_block: Dict[str, Any]) -> str:
|
|
187
|
+
"""Render a [mcp_servers.<server_id>] section (plus [.env] subtable) for
|
|
188
|
+
Codex CLI's ~/.codex/config.toml format. Returns a text block starting
|
|
189
|
+
with a leading newline so it appends cleanly to existing files."""
|
|
190
|
+
cmd = _toml_escape(str(server_block.get("command", "")))
|
|
191
|
+
args = server_block.get("args", []) or []
|
|
192
|
+
env = server_block.get("env", {}) or {}
|
|
193
|
+
# Quote the section name only if it contains special chars. server_id is
|
|
194
|
+
# our deterministic "meshcode-<project>-<agent>" slug; hyphens require
|
|
195
|
+
# quoting in TOML bare keys, so always quote.
|
|
196
|
+
section = f'"{_toml_escape(server_id)}"'
|
|
197
|
+
lines = []
|
|
198
|
+
lines.append("")
|
|
199
|
+
lines.append(f"[mcp_servers.{section}]")
|
|
200
|
+
lines.append(f'command = "{cmd}"')
|
|
201
|
+
args_rendered = ", ".join(f'"{_toml_escape(str(a))}"' for a in args)
|
|
202
|
+
lines.append(f"args = [{args_rendered}]")
|
|
203
|
+
if env:
|
|
204
|
+
lines.append("")
|
|
205
|
+
lines.append(f"[mcp_servers.{section}.env]")
|
|
206
|
+
for k, v in env.items():
|
|
207
|
+
lines.append(f'{k} = "{_toml_escape(str(v))}"')
|
|
208
|
+
lines.append("")
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _atomic_write_toml_codex(path: Path, server_id: str, server_block: Dict[str, Any]) -> None:
|
|
213
|
+
"""Read/replace/write ~/.codex/config.toml preserving all other sections.
|
|
214
|
+
|
|
215
|
+
Codex CLI uses TOML with [mcp_servers.<name>] tables (optionally a
|
|
216
|
+
[mcp_servers.<name>.env] subtable). We don't want to round-trip the
|
|
217
|
+
entire TOML file (stdlib can only READ TOML via tomllib, not write),
|
|
218
|
+
so we do surgical text replacement:
|
|
219
|
+
|
|
220
|
+
* If [mcp_servers."<server_id>"] already exists, find that section
|
|
221
|
+
header and delete everything from it up to (but not including) the
|
|
222
|
+
next top-level-ish section header that is NOT a sub-table of the
|
|
223
|
+
same server (i.e. next `[` that doesn't start with
|
|
224
|
+
`[mcp_servers."<server_id>".`), or EOF.
|
|
225
|
+
* Then append the freshly rendered block.
|
|
226
|
+
* If no prior section, just append to end.
|
|
227
|
+
|
|
228
|
+
Atomic via tempfile + replace.
|
|
229
|
+
"""
|
|
230
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
text = path.read_text() if path.exists() else ""
|
|
232
|
+
|
|
233
|
+
quoted = f'"{_toml_escape(server_id)}"'
|
|
234
|
+
header = f"[mcp_servers.{quoted}]"
|
|
235
|
+
subheader_prefix = f"[mcp_servers.{quoted}."
|
|
236
|
+
|
|
237
|
+
lines = text.splitlines(keepends=True)
|
|
238
|
+
out: list = []
|
|
239
|
+
i = 0
|
|
240
|
+
removed = False
|
|
241
|
+
while i < len(lines):
|
|
242
|
+
stripped = lines[i].lstrip()
|
|
243
|
+
if not removed and (stripped.startswith(header) or stripped.startswith(subheader_prefix)):
|
|
244
|
+
# Skip this section and any following lines until we hit a new
|
|
245
|
+
# top-level `[` section that is NOT our server's sub-table.
|
|
246
|
+
i += 1
|
|
247
|
+
while i < len(lines):
|
|
248
|
+
s2 = lines[i].lstrip()
|
|
249
|
+
if s2.startswith("[") and not s2.startswith(subheader_prefix) and not s2.startswith(header):
|
|
250
|
+
break
|
|
251
|
+
i += 1
|
|
252
|
+
removed = True
|
|
253
|
+
continue
|
|
254
|
+
out.append(lines[i])
|
|
255
|
+
i += 1
|
|
256
|
+
|
|
257
|
+
new_text = "".join(out)
|
|
258
|
+
# Ensure exactly one trailing newline before we append our block.
|
|
259
|
+
if new_text and not new_text.endswith("\n"):
|
|
260
|
+
new_text += "\n"
|
|
261
|
+
new_text += _render_codex_block(server_id, server_block)
|
|
262
|
+
|
|
263
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
264
|
+
tmp.write_text(new_text)
|
|
265
|
+
tmp.replace(path)
|
|
266
|
+
|
|
267
|
+
|
|
175
268
|
# ============================================================
|
|
176
269
|
# WORKSPACE MODEL (1.3.0+) — one isolated dir per agent
|
|
177
270
|
# ============================================================
|
|
@@ -325,6 +418,21 @@ CLIENT_CONFIG_PATHS = {
|
|
|
325
418
|
"Linux": Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
|
|
326
419
|
"Windows": Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
|
|
327
420
|
},
|
|
421
|
+
# Windsurf (Codeium): global MCP config in ~/.codeium/windsurf/mcp_config.json
|
|
422
|
+
# (JSON, same {"mcpServers": {...}} shape as Cursor/Cline). Cross-platform —
|
|
423
|
+
# Windsurf stores user config under ~/.codeium regardless of OS.
|
|
424
|
+
"windsurf": {
|
|
425
|
+
"Darwin": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
|
|
426
|
+
"Linux": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
|
|
427
|
+
"Windows": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
|
|
428
|
+
},
|
|
429
|
+
# OpenAI Codex CLI: ~/.codex/config.toml with [mcp_servers.<name>] tables.
|
|
430
|
+
# This is NOT JSON — handled by the custom TOML writer below.
|
|
431
|
+
"codex": {
|
|
432
|
+
"Darwin": Path.home() / ".codex" / "config.toml",
|
|
433
|
+
"Linux": Path.home() / ".codex" / "config.toml",
|
|
434
|
+
"Windows": Path.home() / ".codex" / "config.toml",
|
|
435
|
+
},
|
|
328
436
|
}
|
|
329
437
|
|
|
330
438
|
CLIENT_DISPLAY_NAMES = {
|
|
@@ -332,8 +440,13 @@ CLIENT_DISPLAY_NAMES = {
|
|
|
332
440
|
"cursor": "Cursor",
|
|
333
441
|
"cline": "Cline (VS Code)",
|
|
334
442
|
"claude-desktop": "Claude Desktop",
|
|
443
|
+
"windsurf": "Windsurf (Codeium)",
|
|
444
|
+
"codex": "OpenAI Codex CLI",
|
|
335
445
|
}
|
|
336
446
|
|
|
447
|
+
# Clients whose config file is NOT JSON and need a custom writer.
|
|
448
|
+
NON_JSON_CLIENTS = {"codex"}
|
|
449
|
+
|
|
337
450
|
|
|
338
451
|
def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
|
|
339
452
|
"""LEGACY: write to the user's GLOBAL MCP config file. All agents that
|
|
@@ -355,25 +468,35 @@ def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
|
|
|
355
468
|
print(f"[meshcode] ERROR: '{client}' is not supported on {os_name}", file=sys.stderr)
|
|
356
469
|
return 2
|
|
357
470
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
try:
|
|
361
|
-
existing = json.loads(config_path.read_text())
|
|
362
|
-
except json.JSONDecodeError:
|
|
363
|
-
print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
|
|
364
|
-
return 2
|
|
365
|
-
|
|
366
|
-
if not isinstance(existing, dict):
|
|
367
|
-
existing = {}
|
|
471
|
+
server_id = f"meshcode-{project}-{agent}"
|
|
472
|
+
server_block = _build_server_block(project, project_id, agent, role, api_key, sb)
|
|
368
473
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
474
|
+
if client in NON_JSON_CLIENTS:
|
|
475
|
+
# Dispatch to per-format writers (preserves other sections).
|
|
476
|
+
if client == "codex":
|
|
477
|
+
_atomic_write_toml_codex(config_path, server_id, server_block)
|
|
478
|
+
else:
|
|
479
|
+
print(f"[meshcode] ERROR: no writer registered for non-JSON client '{client}'", file=sys.stderr)
|
|
480
|
+
return 2
|
|
481
|
+
else:
|
|
482
|
+
existing: Dict[str, Any] = {}
|
|
483
|
+
if config_path.exists():
|
|
484
|
+
try:
|
|
485
|
+
existing = json.loads(config_path.read_text())
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
|
|
488
|
+
return 2
|
|
489
|
+
|
|
490
|
+
if not isinstance(existing, dict):
|
|
491
|
+
existing = {}
|
|
492
|
+
|
|
493
|
+
servers = existing.setdefault("mcpServers", {})
|
|
494
|
+
if not isinstance(servers, dict):
|
|
495
|
+
print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
|
|
496
|
+
return 2
|
|
373
497
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
_atomic_write_json(config_path, existing)
|
|
498
|
+
servers[server_id] = server_block
|
|
499
|
+
_atomic_write_json(config_path, existing)
|
|
377
500
|
|
|
378
501
|
display = CLIENT_DISPLAY_NAMES[client]
|
|
379
502
|
print(f"[meshcode] MCP server '{server_id}' added to {display} GLOBAL config")
|
|
@@ -404,7 +527,7 @@ def setup(*args) -> int:
|
|
|
404
527
|
print("Usage:", file=sys.stderr)
|
|
405
528
|
print(" meshcode setup <project> <agent> [role] # workspace flow (recommended)", file=sys.stderr)
|
|
406
529
|
print(" meshcode setup <client> <project> <agent> [role] # legacy global flow", file=sys.stderr)
|
|
407
|
-
print(" Clients: claude-code, cursor, cline, claude-desktop", file=sys.stderr)
|
|
530
|
+
print(" Clients: claude-code, cursor, cline, claude-desktop, windsurf, codex", file=sys.stderr)
|
|
408
531
|
return 1
|
|
409
532
|
|
|
410
533
|
if args[0] in CLIENT_CONFIG_PATHS:
|
|
@@ -1,17 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|