Skip to content

Commit a8f02de

Browse files
committed
Fix Windows MCP robustness and bump 0.4.3rc2
1 parent 03f5939 commit a8f02de

14 files changed

Lines changed: 408 additions & 27 deletions

docs/guide/faq.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,26 @@ cccc daemon stop # Stop existing instance
160160
cccc daemon start # Start fresh
161161
```
162162

163-
### Port 8848 is in use
163+
### Port 8848 is unavailable
164164

165165
```bash
166166
CCCC_WEB_PORT=9000 cccc
167167
```
168168

169+
On Windows, Hyper-V / WSL / WinNAT / HNS can reserve a TCP port even when no
170+
process is listening on it. If `8848` still fails to start and you do not see an
171+
owning PID, check the excluded port ranges:
172+
173+
```powershell
174+
netsh interface ipv4 show excludedportrange protocol=tcp
175+
```
176+
177+
If `8848` falls inside one of those ranges, start CCCC on a different port:
178+
179+
```powershell
180+
cccc web --port 9000
181+
```
182+
169183
### MCP not working
170184

171185
```bash

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cccc-pair"
7-
version = "0.4.3rc1"
7+
version = "0.4.3rc2"
88
description = "Global multi-agent delivery kernel with working groups, scopes, and an append-only collaboration ledger"
99
readme = { file = "README.md", content-type = "text/markdown" }
1010
requires-python = ">=3.9"

src/cccc/cli/common.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ..kernel.system_prompt import render_system_prompt
3939
from ..paths import ensure_home
4040
from ..ports.im.config_schema import canonicalize_im_config
41+
from ..ports.web.bind_preflight import ensure_tcp_port_bindable
4142
from ..util.conv import coerce_bool
4243
from ..util.process import SOFT_TERMINATE_SIGNAL, best_effort_signal_pid, pid_is_alive, terminate_pid
4344

@@ -459,6 +460,17 @@ def _stop_daemon() -> None:
459460
pass
460461
daemon_process = None
461462

463+
# Keep runtime binding aligned with remote_access settings/UI.
464+
host, port = _resolve_web_server_binding()
465+
log_level = str(os.environ.get("CCCC_WEB_LOG_LEVEL") or "").strip() or "info"
466+
reload_mode = _env_flag("CCCC_WEB_RELOAD", default=False)
467+
468+
try:
469+
ensure_tcp_port_bindable(host=host, port=port)
470+
except RuntimeError as e:
471+
print(f"[cccc] Error: {e}", file=sys.stderr)
472+
return 1
473+
462474
# Start daemon
463475
print("[cccc] Starting daemon...", file=sys.stderr)
464476
if not _start_daemon():
@@ -470,11 +482,6 @@ def _stop_daemon() -> None:
470482
monitor_thread = threading.Thread(target=_monitor_daemon, daemon=True)
471483
monitor_thread.start()
472484

473-
# Keep runtime binding aligned with remote_access settings/UI.
474-
host, port = _resolve_web_server_binding()
475-
log_level = str(os.environ.get("CCCC_WEB_LOG_LEVEL") or "").strip() or "info"
476-
reload_mode = _env_flag("CCCC_WEB_RELOAD", default=False)
477-
478485
# Run web. Let uvicorn own signal handling; set a bounded graceful timeout to
479486
# avoid hanging forever on long-lived connections (e.g. SSE/WebSocket).
480487
import uvicorn

src/cccc/cli/system_cmds.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def cmd_mcp(args: argparse.Namespace) -> int:
172172

173173
def cmd_setup(args: argparse.Namespace) -> int:
174174
"""Setup CCCC MCP for agent runtimes (configure MCP, print guidance)."""
175-
import shutil
175+
from ..kernel.runtime import get_cccc_mcp_stdio_command
176176

177177
runtime = str(args.runtime or "").strip()
178178
project_path = Path(args.path or ".").resolve()
@@ -204,12 +204,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
204204

205205
results: dict[str, Any] = {"mcp": {}, "notes": []}
206206

207-
# Find cccc executable path for MCP config
208-
cccc_path = shutil.which("cccc") or sys.executable
209-
if cccc_path == sys.executable:
210-
cccc_cmd = [sys.executable, "-m", "cccc.ports.mcp.main"]
211-
else:
212-
cccc_cmd = ["cccc", "mcp"]
207+
cccc_cmd = get_cccc_mcp_stdio_command()
213208

214209
def _cmd_line(parts: list[str]) -> str:
215210
return " ".join(shlex.quote(p) for p in parts)

src/cccc/daemon/mcp_install.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import subprocess
77
from pathlib import Path
88

9+
from ..kernel.runtime import get_cccc_mcp_stdio_command
910
from ..util.conv import coerce_bool
1011
from ..util.fs import read_json
1112

@@ -106,10 +107,11 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
106107
return True
107108
if is_mcp_installed(runtime):
108109
return True
110+
cccc_cmd = get_cccc_mcp_stdio_command()
109111
try:
110112
if runtime == "claude":
111113
result = subprocess.run(
112-
["claude", "mcp", "add", "-s", "user", "cccc", "--", "cccc", "mcp"],
114+
["claude", "mcp", "add", "-s", "user", "cccc", "--", *cccc_cmd],
113115
capture_output=True,
114116
text=True,
115117
cwd=str(cwd),
@@ -119,7 +121,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
119121

120122
if runtime == "codex":
121123
result = subprocess.run(
122-
["codex", "mcp", "add", "cccc", "--", "cccc", "mcp"],
124+
["codex", "mcp", "add", "cccc", "--", *cccc_cmd],
123125
capture_output=True,
124126
text=True,
125127
cwd=str(cwd),
@@ -129,7 +131,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
129131

130132
if runtime == "droid":
131133
result = subprocess.run(
132-
["droid", "mcp", "add", "--type", "stdio", "cccc", "cccc", "mcp"],
134+
["droid", "mcp", "add", "--type", "stdio", "cccc", *cccc_cmd],
133135
capture_output=True,
134136
text=True,
135137
cwd=str(cwd),
@@ -139,7 +141,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
139141

140142
if runtime == "amp":
141143
result = subprocess.run(
142-
["amp", "mcp", "add", "cccc", "cccc", "mcp"],
144+
["amp", "mcp", "add", "cccc", *cccc_cmd],
143145
capture_output=True,
144146
text=True,
145147
cwd=str(cwd),
@@ -149,7 +151,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
149151

150152
if runtime == "auggie":
151153
result = subprocess.run(
152-
["auggie", "mcp", "add", "cccc", "--", "cccc", "mcp"],
154+
["auggie", "mcp", "add", "cccc", "--", *cccc_cmd],
153155
capture_output=True,
154156
text=True,
155157
cwd=str(cwd),
@@ -159,7 +161,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
159161

160162
if runtime == "neovate":
161163
result = subprocess.run(
162-
["neovate", "mcp", "add", "-g", "cccc", "cccc", "mcp"],
164+
["neovate", "mcp", "add", "-g", "cccc", *cccc_cmd],
163165
capture_output=True,
164166
text=True,
165167
cwd=str(cwd),
@@ -169,7 +171,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
169171

170172
if runtime == "gemini":
171173
result = subprocess.run(
172-
["gemini", "mcp", "add", "-s", "user", "cccc", "cccc", "mcp"],
174+
["gemini", "mcp", "add", "-s", "user", "cccc", *cccc_cmd],
173175
capture_output=True,
174176
text=True,
175177
cwd=str(cwd),
@@ -179,7 +181,7 @@ def ensure_mcp_installed(runtime: str, cwd: Path, *, auto_mcp_runtimes: tuple[st
179181

180182
if runtime == "kimi":
181183
result = subprocess.run(
182-
["kimi", "mcp", "add", "cccc", "--command", "cccc", "mcp"],
184+
["kimi", "mcp", "add", "cccc", "--command", *cccc_cmd],
183185
capture_output=True,
184186
text=True,
185187
cwd=str(cwd),

src/cccc/kernel/runtime.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from __future__ import annotations
33

44
import shutil
5+
import sys
56
from dataclasses import dataclass
7+
from pathlib import Path
68
from typing import Any, Dict, List, Optional
79

810

@@ -166,6 +168,23 @@ def get_runtime_command(name: str) -> List[str]:
166168
return [config["command"]]
167169

168170

171+
def get_cccc_mcp_stdio_command() -> List[str]:
172+
"""Return the most stable command line for launching `cccc mcp`.
173+
174+
Prefer an absolute path to the installed `cccc` entrypoint when available.
175+
On Windows this avoids relying on runtime-specific PATH inheritance for MCP
176+
child processes. Fall back to the current Python interpreter otherwise.
177+
"""
178+
cccc_path = shutil.which("cccc")
179+
if cccc_path:
180+
try:
181+
cccc_path = str(Path(cccc_path).resolve())
182+
except Exception:
183+
cccc_path = str(cccc_path)
184+
return [cccc_path, "mcp"]
185+
return [sys.executable, "-m", "cccc.ports.mcp.main"]
186+
187+
169188
def get_runtime_command_with_flags(name: str) -> List[str]:
170189
"""Get the command with recommended flags for autonomous operation."""
171190
commands = {

src/cccc/ports/mcp/common.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,67 @@ class _RuntimeContext:
3434
actor_id: str
3535

3636

37+
def _proc_parent_pid_windows(pid: int) -> int:
38+
if pid <= 0 or os.name != "nt":
39+
return 0
40+
try:
41+
import ctypes
42+
from ctypes import wintypes
43+
44+
TH32CS_SNAPPROCESS = 0x00000002
45+
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
46+
47+
class PROCESSENTRY32W(ctypes.Structure):
48+
_fields_ = [
49+
("dwSize", wintypes.DWORD),
50+
("cntUsage", wintypes.DWORD),
51+
("th32ProcessID", wintypes.DWORD),
52+
("th32DefaultHeapID", ctypes.c_size_t),
53+
("th32ModuleID", wintypes.DWORD),
54+
("cntThreads", wintypes.DWORD),
55+
("th32ParentProcessID", wintypes.DWORD),
56+
("pcPriClassBase", ctypes.c_long),
57+
("dwFlags", wintypes.DWORD),
58+
("szExeFile", wintypes.WCHAR * 260),
59+
]
60+
61+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
62+
kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
63+
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
64+
kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]
65+
kernel32.Process32FirstW.restype = wintypes.BOOL
66+
kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]
67+
kernel32.Process32NextW.restype = wintypes.BOOL
68+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
69+
kernel32.CloseHandle.restype = wintypes.BOOL
70+
71+
snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
72+
if snapshot in (0, INVALID_HANDLE_VALUE, None):
73+
return 0
74+
try:
75+
entry = PROCESSENTRY32W()
76+
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
77+
if not kernel32.Process32FirstW(snapshot, ctypes.byref(entry)):
78+
return 0
79+
while True:
80+
if int(entry.th32ProcessID or 0) == int(pid):
81+
parent = int(entry.th32ParentProcessID or 0)
82+
return 0 if parent == int(pid) else parent
83+
if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)):
84+
break
85+
finally:
86+
kernel32.CloseHandle(snapshot)
87+
except Exception:
88+
return 0
89+
return 0
90+
91+
3792
def _proc_parent_pid(pid: int) -> int:
38-
if pid <= 0 or os.name != "posix":
93+
if pid <= 0:
94+
return 0
95+
if os.name == "nt":
96+
return _proc_parent_pid_windows(pid)
97+
if os.name != "posix":
3998
return 0
4099
try:
41100
status_path = Path("/proc") / str(pid) / "status"

src/cccc/ports/mcp/main.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,27 @@ def _encode_cursor(offset: int) -> str:
7373
return str(max(0, int(offset)))
7474

7575

76+
def _stdin_buffer() -> Any:
77+
return getattr(sys.stdin, "buffer", None)
78+
79+
80+
def _stdout_buffer() -> Any:
81+
return getattr(sys.stdout, "buffer", None)
82+
83+
7684
def _read_message() -> Optional[Dict[str, Any]]:
7785
"""Read a single JSON-RPC message from stdin."""
7886
try:
79-
line = sys.stdin.readline()
87+
raw_stdin = _stdin_buffer()
88+
if raw_stdin is not None:
89+
raw_line = raw_stdin.readline()
90+
if not raw_line:
91+
return None
92+
line = raw_line.decode("utf-8")
93+
else:
94+
line = sys.stdin.readline()
95+
if not line:
96+
return None
8097
if not line:
8198
return None
8299
return json.loads(line.strip())
@@ -86,7 +103,13 @@ def _read_message() -> Optional[Dict[str, Any]]:
86103

87104
def _write_message(msg: Dict[str, Any]) -> None:
88105
"""Write a single JSON-RPC message to stdout."""
89-
sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
106+
payload = json.dumps(msg, ensure_ascii=False) + "\n"
107+
raw_stdout = _stdout_buffer()
108+
if raw_stdout is not None:
109+
raw_stdout.write(payload.encode("utf-8"))
110+
raw_stdout.flush()
111+
return
112+
sys.stdout.write(payload)
90113
sys.stdout.flush()
91114

92115

0 commit comments

Comments
 (0)