-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbridge.py
More file actions
2169 lines (2012 loc) · 108 KB
/
Copy pathbridge.py
File metadata and controls
2169 lines (2012 loc) · 108 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# # SPDX-License-Identifier: GPL-3.0-or-later
# bridge.py
# ──────────────────────────────────────────────────────────────────────────
# ZeroScript Bridge
# Local WebSocket <-> Roblox Studio MCP server.
# The browser extension talks to this over ws://127.0.0.1:<PORT>.
#
# What this bridge exposes to Kimi (aggregated into one tools/list):
# - Every MCP server declared in config.json (by default: roblox), each
# spawned as a stdio child and routed by tool name.
#
# Design goals (robustness first):
# - Each MCP stdio process is read by ONE dedicated thread; responses are
# matched by JSON-RPC id (no "read the next line and hope" races).
# - stderr is drained so a child never blocks on a full pipe.
# - A dead server is auto-restarted and the failing call retried once.
# - Tool calls are locked PER SERVER, so a slow server never blocks another.
# - Every call ALWAYS produces a reply: a result OR a structured error.
# Nothing ever hangs the agentic loop silently.
# ──────────────────────────────────────────────────────────────────────────
import asyncio
import json
import os
import queue
import shutil
import subprocess
import sys
import threading
import time
try:
# Sibling script (same folder as bridge.py, which Python puts on sys.path
# automatically) - reused here purely to detect a Studio version bump
# (see _current_studio_exe below), not to launch anything.
import launch_studio_mcp as _studio_scan
except Exception:
_studio_scan = None
try:
import websockets
except ImportError:
print("[bridge] Missing dependency. Run: pip install websockets")
sys.exit(1)
# Windows consoles often default to a legacy codepage (cp1252): printing
# non-ASCII text then raises UnicodeEncodeError INSIDE the WS handler, which
# kills the connection. Force UTF-8 (best effort). We also keep all console
# output strictly ASCII (no arrows / dots) so nothing garbles on a console that
# stayed on a legacy codepage anyway.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# Packaged macOS .app builds run windowed: PyInstaller hands us None std streams
# and any print() would then crash the bridge. Replace them with devnull sinks
# so the bridge runs identically in dev and packaged form.
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w", encoding="utf-8")
def _enable_ansi_colors():
"""On Windows, turn on ANSI escape processing so color codes render instead
of printing as literal gibberish like "<ESC>[92m". Returns True on success."""
if sys.platform != "win32":
return True
try:
import ctypes
k = ctypes.windll.kernel32
h = k.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_uint32()
if not k.GetConsoleMode(h, ctypes.byref(mode)):
return False
# ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
return bool(k.SetConsoleMode(h, mode.value | 0x0004))
except Exception:
return False
HOST = "127.0.0.1"
# Keep in sync with zeroscript-extension/manifest.json "version" - printed at
# startup so a user's terminal output alone tells us which build they're on.
BRIDGE_VERSION = "1.0.2"
PORT = int(os.environ.get("ZS_BRIDGE_PORT", "17613"))
def _base_dir():
"""Directory for config.json + logs. Dev: next to bridge.py. Packaged app
(PyInstaller): NEXT TO THE EXECUTABLE - __file__ would point into a read-only
temp/_internal dir where writes silently vanish. The one exception is Linux
AppImage, whose whole mount is read-only too: there the data lives in
~/.zeroscript. ZS_DATA_DIR overrides everything (power users)."""
override = os.environ.get("ZS_DATA_DIR")
if override:
return os.path.abspath(override)
if getattr(sys, "frozen", False):
if sys.platform.startswith("linux") and os.environ.get("APPDIR"):
return os.path.expanduser("~/.zeroscript")
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
BASE = _base_dir()
# Directory the bridge executable/script itself sits in. Same as BASE except on
# Linux AppImage (the executable is inside the read-only mount, data is not).
HERE = (os.path.dirname(os.path.abspath(sys.executable))
if getattr(sys, "frozen", False)
else os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE, "config.json")
def _sibling_exe(script_stem):
"""Packaged builds ship small helper scripts (launch_studio_mcp.py, ...) as
sibling executables next to the bridge binary. A bare '.py' command in
config.json maps to that bundled executable. Returns the path, or None in
dev mode / when the sibling binary is missing.
Two packaged layouts are supported: the plain PyInstaller one (exact name,
e.g. launch_studio_mcp.exe) and Tauri sidecars, which Tauri ships with a
target-triple suffix (e.g. launch_studio_mcp-x86_64-pc-windows-msvc.exe)."""
if not getattr(sys, "frozen", False):
return None
candidates = [os.path.join(HERE, script_stem)]
if sys.platform == "win32" and not script_stem.lower().endswith(".exe"):
candidates.append(os.path.join(HERE, script_stem + ".exe"))
for c in candidates:
if os.path.isfile(c):
return c
import glob
pattern = os.path.join(HERE, script_stem + "-*")
if sys.platform == "win32":
pattern += ".exe"
try:
hits = sorted(glob.glob(pattern))
except Exception:
hits = []
return hits[0] if hits else None
# The primary server. It is always present, added by the installer, and can
# never be edited/removed through the extension (it is what ZeroScript is FOR).
PRIMARY_SERVER_ID = "roblox"
if _enable_ansi_colors():
C = {
"reset": "\033[0m", "dim": "\033[2m", "gr": "\033[92m",
"yl": "\033[93m", "rd": "\033[91m", "cy": "\033[96m",
# Bold white-on-red: for a non-technical user, an "ACTION NEEDED" step
# must look nothing like the routine cyan/yellow status noise around
# it, or it gets scrolled past unread (seen live 2026-07-13 - the
# toggle instruction and the boot banner's own yellow re-explanation
# of the SAME step were visually indistinguishable). Bright-yellow-bg
# with black text was tried first but reads as low-contrast/washed
# out on several real terminal color schemes (also seen live) - white
# on red is the universal high-contrast "act now" pairing.
"act": "\033[1m\033[97m\033[41m",
}
else:
C = {k: "" for k in ("reset", "dim", "gr", "yl", "rd", "cy", "act")}
# Every run appends here (never truncated), so a whole test session - across
# multiple restarts - stays in one file the user can just send us. Each
# process start writes a banner (see main()) so restarts are easy to spot.
os.makedirs(BASE, exist_ok=True)
LOGS_DIR = os.path.join(BASE, "logs")
os.makedirs(LOGS_DIR, exist_ok=True)
LOG_PATH = os.path.join(LOGS_DIR, "bridge_debug.log")
try:
_log_file = open(LOG_PATH, "a", encoding="utf-8", errors="replace")
except Exception:
_log_file = None
class _Spinner:
"""Terminal-only progress indicator for waits that can run several seconds
(server launch/handshake, Studio attach grace period) so the console never
just sits there looking dead - the #1 thing that makes a user assume the
bridge hung and close the window. Purely cosmetic: writes over its own line
with \\r, never touches bridge_debug.log, and is skipped entirely when
stdout isn't a real console (redirected to a file, no ANSI)."""
FRAMES = "|/-\\"
# Only ONE spinner may animate at a time: server launches now run in
# PARALLEL (see MCPManager.start_all), and several spinners fighting over
# the same console line with \r produced interleaved garbage. Whoever
# acquires this lock animates; the others silently skip (the log lines
# around them still tell the story).
_active = threading.Lock()
def __init__(self, label):
self.label = label
self._stop = threading.Event()
self._thread = None
self._owns_lock = False
def __enter__(self):
if sys.stdout.isatty() and _Spinner._active.acquire(blocking=False):
self._owns_lock = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def __exit__(self, *exc):
self._stop.set()
if self._thread:
self._thread.join(timeout=1.0)
# Wipe the spinner line so the next log() line doesn't get glued
# onto trailing spinner characters.
print("\r" + " " * (len(self.label) + 4) + "\r", end="", flush=True)
if self._owns_lock:
_Spinner._active.release()
def _run(self):
i = 0
while not self._stop.is_set():
frame = self.FRAMES[i % len(self.FRAMES)]
print(f"\r{C['dim']}{self.label} {frame}{C['reset']}", end="", flush=True)
i += 1
self._stop.wait(0.15)
def _clear_spinner_line():
"""Wipe whatever a live _Spinner (running on its own thread, mid-frame) left
on the current console line via bare \\r writes, so the next print() below
doesn't get glued onto its trailing characters - seen live 2026-07-14: an
action_banner() fired while '[roblox] starting... -' was still mid-line and
the red box rendered smashed onto it instead of starting on a fresh line.
\\033[K (clear to end of line) doesn't depend on knowing the spinner's label
length the way Spinner.__exit__'s own wipe does."""
if sys.stdout.isatty():
print("\r\033[K", end="", flush=True)
def log(msg, color="dim", terminal=True):
"""terminal=False: written to bridge_debug.log only, not the console. Use
for noisy/technical detail (raw stderr from child MCP servers, per-call
traces) that would bury the handful of lines a non-technical user actually
needs to read. Nothing is ever lost - it all still lands in the file."""
if terminal:
_clear_spinner_line()
ts = time.strftime("%H:%M:%S")
print(f"{C['dim']}{ts}{C['reset']} {C.get(color,'')}{msg}{C['reset']}", flush=True)
if _log_file:
try:
_log_file.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
_log_file.flush()
except Exception:
pass
def action_banner(lines):
"""Print a step the USER must physically go do, styled so it cannot be
mistaken for routine status/warning noise (see the 'act' color above).
Framed with blank lines so it visually stands alone in a scrolling
terminal - a non-technical user should be able to glance at the window
and immediately spot this without reading everything above it.
Every line (header, content, footer) is padded to the SAME width so the
yellow block renders as one clean rectangle - an earlier version padded
each line to a fixed guess independently, which produced a ragged block
with mismatched edges on a real console (seen live 2026-07-13)."""
header = "ACTION NEEDED"
width = max([len(header) + 8] + [len(ln) for ln in lines]) + 2
top = f">>> {header} " + ">" * max(0, width - len(header) - 5)
_clear_spinner_line()
print()
print(f"{C['act']} {top.ljust(width)}{C['reset']}")
for ln in lines:
print(f"{C['act']} {ln.ljust(width)}{C['reset']}")
print(f"{C['act']} {'>' * width}{C['reset']}")
print()
if _log_file:
try:
_log_file.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} ACTION NEEDED: "
f"{' | '.join(lines)}\n")
_log_file.flush()
except Exception:
pass
# Roblox Studio exposes its built-in MCP server on this loopback port. StudioMCP
# (and our bridge, via it) reaches Studio through it.
STUDIO_MCP_PORT = 13469
def _port_owner(port):
"""(pid, name, path) of the process LISTENING on `port`, or None. Win32 only."""
if sys.platform != "win32":
return None
# BOTH stacks: "-p TCP" alone is IPv4-only, and a squatter listening on
# [::1]:<port> (IPv6 loopback) was then completely invisible to this probe
# even while Get-NetTCPConnection showed it plainly (the likely reason the
# boot-time squatter check stayed silent on a machine where ropilot
# provably held the port - see the 2026-07-13 live report).
out = ""
for proto in ("TCP", "TCPv6"):
try:
out += subprocess.run(
["netstat", "-ano", "-p", proto],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=8,
).stdout
except Exception:
pass
if not out:
return None
pid = None
# v4 lines end the local address in ":<port>", v6 in "]:<port>" - matching
# on the ":<port> " suffix (with the column gap) covers both shapes.
needle = f":{port} "
for line in out.splitlines():
if "LISTENING" in line and needle in line:
parts = line.split()
if parts and parts[-1].isdigit():
pid = parts[-1]
break
if not pid:
return None
name, path = "?", ""
try:
ps = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"$p=Get-Process -Id {pid} -ErrorAction SilentlyContinue; "
f"if($p){{$p.Name; $p.Path}}"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=8,
).stdout.splitlines()
ps = [l.strip() for l in ps if l.strip()]
if ps:
name = ps[0]
path = ps[1] if len(ps) > 1 else ""
except Exception:
pass
return (pid, name, path)
def _roblox_studio_app_running():
"""True/False whether a real Roblox Studio window process exists, or None
if this can't be determined (non-Windows, or the check itself failed)."""
if sys.platform != "win32":
return None
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq RobloxStudioBeta.exe"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=8,
).stdout
except Exception:
return None
return "RobloxStudioBeta.exe" in out
def _kill_orphan_studio_mcp():
"""Kill leftover StudioMCP.exe processes from a PREVIOUS session/crash.
StudioMCP.exe is Roblox's own MCP proxy; launch_studio_mcp.py spawns one
as a direct child every time the bridge starts. If an earlier restart's
tree-kill missed the grandchild (a reparenting race), or Studio itself
crashed and left its own StudioMCP.exe running (seen live 2026-07-11:
RobloxStudioBeta.exe zombied after two RobloxCrashHandler.exe events),
the orphan keeps LISTENING on Studio's MCP port. Every StudioMCP.exe we
launch afterward - even a freshly restarted one - just connects to that
zombie instead of a real Studio, so the bridge reports "Studio connected"
forever even with Studio fully closed. studio_watch's auto-restart cannot
fix this on its own: restarting our proxy still lands on the same zombie.
Only acts when NO real Studio app is running at all - in that state any
existing StudioMCP.exe is unambiguously orphaned (a legitimate one only
exists to serve a live Studio), so it is safe to auto-kill without asking.
If Studio IS running (or this can't be determined), this is a no-op: a
live StudioMCP.exe might be legitimately serving it, so nothing is
touched - this must never risk killing a working connection.
"""
if sys.platform != "win32":
return
if _roblox_studio_app_running() is not False:
return
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq StudioMCP.exe"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=8,
).stdout
except Exception:
return
if "StudioMCP.exe" not in out:
return
log("Found leftover StudioMCP.exe process(es) with no Roblox Studio running - "
"cleaning them up (known cause of a phantom 'Studio connected' state).", "yl")
try:
subprocess.run(["taskkill", "/F", "/IM", "StudioMCP.exe"],
capture_output=True, text=True, timeout=8)
except Exception as e:
log(f"could not clean up orphaned StudioMCP.exe: {e}", "rd")
def _descendant_pids(root_pid):
"""Set of PIDs = root_pid + every descendant, or None if the process tree
could not be read (in which case callers must NOT make kill decisions)."""
if sys.platform != "win32":
return None
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | ForEach-Object "
"{ \"$($_.ProcessId) $($_.ParentProcessId)\" }"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10,
).stdout
except Exception:
return None
children = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
children.setdefault(int(parts[1]), []).append(int(parts[0]))
if not children:
return None
pids = {int(root_pid)}
stack = [int(root_pid)]
while stack:
for c in children.get(stack.pop(), []):
if c not in pids:
pids.add(c)
stack.append(c)
return pids
def _reclaim_studio_port(client):
"""Kill a StudioMCP.exe that owns Studio's MCP port but is NOT our own child.
The deadlock this breaks (reported live, survives every restart combo):
a zombie StudioMCP.exe from a crashed session keeps LISTENING on 13469.
The user reopens Studio -> its MCP plugin does its ONE-SHOT registration
against the ZOMBIE (wasted). The user restarts the bridge -> Studio is now
running, so _kill_orphan_studio_mcp's safety guard skips the cleanup, and
check_studio_port waves the zombie through too (its path IS under Roblox).
Our fresh StudioMCP can't own the port, Studio never re-registers on its
own -> 0 tools forever, no restart order can fix it by hand.
Ownership is decided by PID, not heuristics: we know the PID of the
launcher we spawned (client.proc), so a StudioMCP.exe holding the port
outside that process tree is a leftover by definition - Studio open or
not. If the process tree can't be read, we do nothing (never risk killing
our own healthy child on bad data). Returns True if a zombie was killed;
the caller must then restart the roblox proxy (safe here even with Studio
open: the plugin's single registration already went to the zombie, so
there is no attempt left for a restart to collide with) AND tell the user
to open Assistant Settings > MCP Servers so the plugin re-registers.
"""
owner = _port_owner(STUDIO_MCP_PORT)
if not owner:
return False
pid, name, path = owner
# Only ever kill a StudioMCP.exe. Studio itself holding the port is fine;
# a non-Roblox squatter is check_studio_port's (interactive) job.
if "studiomcp" not in (name or "").lower():
return False
try:
pid_i = int(pid)
except (TypeError, ValueError):
return False
if client is not None and client.proc is not None and client.is_alive():
tree = _descendant_pids(client.proc.pid)
if tree is None or pid_i in tree:
return False # ours, or unknowable - leave it alone
log(f"port {STUDIO_MCP_PORT} is held by a StudioMCP.exe (pid {pid_i}) that this "
"bridge did NOT launch - a leftover from a previous session. Studio "
"registered to it, so our proxy sees 0 tools.", "yl")
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid_i)],
capture_output=True, text=True, timeout=8)
except Exception as e:
log(f"could not kill the leftover StudioMCP.exe: {e}", "rd")
return False
log(f"killed the leftover StudioMCP.exe (pid {pid_i}) to free Studio's MCP port.", "cy")
return True
def _process_cmdline(pid):
"""Full command line of `pid`, or "" if it can't be read. Win32 only.
Used to tell OUR OWN kind of process (a python running bridge.py) apart
from an unrelated app that merely happens to listen on the same port -
the process NAME is just "python"/"py"/"pythonw", far too generic to kill
on. The command line is what proves it is a leftover bridge."""
if sys.platform != "win32":
return ""
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\" "
f"-ErrorAction SilentlyContinue).CommandLine"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=8,
).stdout
except Exception:
return ""
return (out or "").strip()
def _reclaim_bridge_port():
"""Free OUR OWN listen port (17613) from a leftover bridge before we bind.
The common failure (reported live, WinError 10048 on bind): the user
relaunches start.bat while an earlier bridge.py is still running - window
closed with the X instead of Ctrl+C, a previous crash that left a detached
python, or a double double-click. The old process still holds the port, so
websockets.serve() dies on bind with a cryptic (localised) OSError and the
whole bridge exits code 1.
We reuse _port_owner (already generic over the port) and only ever kill a
process we can PROVE is another bridge.py - never a same-name innocent
(some unrelated python listening on 17613): the guard is the command line
containing "bridge.py", plus an explicit self-exclusion by PID. Anything
else (a non-python app, or a python whose cmdline we can't read) is left
alone and surfaced to the user by the caller's friendly bind-error path.
Returns True if a leftover bridge was killed."""
owner = _port_owner(PORT)
if not owner:
return False
pid, name, path = owner
try:
pid_i = int(pid)
except (TypeError, ValueError):
return False
if pid_i == os.getpid():
return False # never kill ourselves (defensive; we haven't bound yet)
# Must look like a python interpreter running bridge.py (dev), or our own
# packaged executable (frozen). Killing on the port alone would murder
# whatever legitimately owns 17613.
exe_stem = os.path.splitext(os.path.basename(sys.executable))[0].lower()
if ("python" not in (name or "").lower() and "py" != (name or "").lower()
and exe_stem not in (name or "").lower()):
return False
cmdline = _process_cmdline(pid_i)
if "bridge.py" not in cmdline.lower() and exe_stem not in cmdline.lower():
log(f"port {PORT} is held by pid {pid_i} ('{name}') but it does not look "
f"like a ZeroScript bridge - leaving it alone.", "yl")
return False
log(f"port {PORT} is held by a leftover ZeroScript bridge (pid {pid_i}) from a "
"previous session - killing it so this one can start.", "yl")
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid_i)],
capture_output=True, text=True, timeout=8)
except Exception as e:
log(f"could not kill the leftover bridge (pid {pid_i}): {e}", "rd")
return False
log(f"killed the leftover bridge (pid {pid_i}); the port is free now.", "cy")
return True
def _kill_port_squatter():
"""Kill a NON-Roblox process holding Studio's MCP port, no questions asked.
Called only when the child's stderr has PROVEN the port is hijacked (see
MCPClient.saw_foreign_ws_host - StudioMCP connected to a foreign host and
could not parse its protocol; the ropilot case). At that point there is no
ambiguity left to justify check_studio_port's interactive prompt, and the
prompt was itself a trap: many users never answer it, and the one-shot boot
check often runs a beat before a background helper (ropilot) grabs the
port. Here we have hard evidence, so kill the squatter outright. Returns
(killed, name) so the caller can tell the user which app to uninstall /
remove from startup, since it will otherwise reclaim the port on next boot.
"""
owner = _port_owner(STUDIO_MCP_PORT)
if owner:
pid, name, path = owner
if "roblox" in (path or "").lower() or "studiomcp" in (name or "").lower():
return False, None # legitimate Studio-side owner; not a squatter
log(f"port {STUDIO_MCP_PORT} is hijacked by '{name}' (pid {pid}, {path}).", "yl")
log(" StudioMCP connected to it instead of Roblox Studio - that is why "
"there are 0 tools.", "yl")
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
capture_output=True, text=True, timeout=8)
except Exception as e:
log(f"could not kill '{name}': {e}", "rd")
return False, name
log(f"killed '{name}' so Studio can use the port.", "cy")
return True, name
# We could NOT resolve who owns the port, yet StudioMCP's stderr proved the
# port is hijacked (this function is only called under that proof). This is
# the state that used to fail SILENTLY: _port_owner returning None (e.g. a
# squatter listening on IPv6 loopback that an IPv4-only netstat missed, or
# any netstat quirk) left the user staring at 0 tools with no explanation.
# Never be silent here. Try a name-based fallback for the known offender
# (ropilot ships a background helper that squats this port), then always
# tell the user what we know.
log(f"port {STUDIO_MCP_PORT} is hijacked (StudioMCP could not talk to Roblox "
"Studio on it) but the owning process could not be identified by port.", "yl")
# ropilot is a multi-process app (validated live 2026-07-13): the port is
# held by ropilot-infra-helper.exe, supervised by ropilot-infra.exe. Kill
# both so the supervisor can't just respawn the helper and re-grab the port.
killed_name = None
for img in ("ropilot-infra-helper.exe", "ropilot-infra.exe", "ropilot.exe"):
try:
res = subprocess.run(["taskkill", "/F", "/IM", img],
capture_output=True, text=True, timeout=8)
except Exception:
continue
if res.returncode == 0:
killed_name = img
log(f"killed '{img}' (known port squatter) so Studio can use the port.", "cy")
if killed_name:
return True, killed_name
log(" Could not auto-kill it. Find it manually: run netstat -ano | "
f"findstr {STUDIO_MCP_PORT} then end that PID in Task Manager.", "yl")
return False, None
def _print_squatter_hint(name):
"""After killing a port squatter (e.g. ropilot), tell the user how to stop
it coming back - it is a background helper that respawns on the next boot
and re-grabs the port before Studio, which is why a PC reboot never fixed
this class of 0-tools report."""
app = name or "the other app"
action_banner([
f"'{app}' fights Roblox Studio for its connection - it will keep",
"coming back after every restart until you remove it.",
f"1. Uninstall '{app}' (or remove it from Windows startup).",
"2. In Roblox Studio: Assistant Settings > MCP Servers,",
" turn OFF then back ON 'Enable Studio as MCP server'.",
])
def _print_reregister_hint():
"""The one user action that completes a zombie-kill recovery: Studio's MCP
plugin registers only once per boot and that attempt went to the zombie,
so after the kill + proxy restart the user must make it register again."""
# Opening the panel alone is technically enough to re-register, but we tell
# the user to toggle OFF/ON to be sure - a toggle strictly implies opening
# the panel, so it can never do less, and it removes any ambiguity about
# whether "just looking at it" counted. Same wording as the squatter/no-place
# banners so all three read as one identical instruction, not three variants.
action_banner([
"Go to Roblox Studio now.",
"Turn OFF then back ON: Assistant Settings > MCP Servers",
" > 'Enable Studio as MCP server'",
"Wait about 10 seconds - this window will turn green.",
])
def check_studio_port():
"""Warn (and optionally kill) a NON-Roblox process squatting Studio's MCP port.
A third-party tool (e.g. "ropilot") that binds 13469 before Studio does
hijacks the MCP channel: StudioMCP connects to IT instead of Studio, the
handshake succeeds but tools/list never answers -> the bridge sees 0 tools.
This is silent and brutal to diagnose, so we surface it up front.
"""
owner = _port_owner(STUDIO_MCP_PORT)
if not owner:
return False
pid, name, path = owner
# The legitimate holder is Studio itself / a Roblox helper: its path lives
# under a "...\Roblox\..." folder. Anything else is an intruder.
if "roblox" in (path or "").lower():
return False
where = path or name
log(f"port {STUDIO_MCP_PORT} (Studio's MCP port) is held by a non-Roblox process:", "yl")
log(f" {name} (pid {pid}) {where}", "yl")
log(" This will block Studio's tools (the bridge will see 0 tools).", "yl")
try:
ans = input(" Kill this process so Studio can use the port? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
ans = ""
if ans in ("y", "yes", "o", "oui"):
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
capture_output=True, text=True, timeout=8)
log(f"killed {name} (pid {pid}). Studio can use the port now.", "cy")
# Tell the user the finishing step IMMEDIATELY, here, instead of only
# after the ~48s server-launch grace loop that follows: killing the
# squatter frees the port, but Studio's MCP plugin registers only
# once per boot and that attempt already went to the squatter, so it
# will NOT re-attach on its own - a toggle is needed. Printing this
# now (not 48s later, after start_all's grace loop) is what turns a
# ~1-minute "why is nothing happening" wait into an act-right-away
# instruction. Uses action_banner (not log) so a non-technical user
# visually cannot miss it among the surrounding status lines - seen
# live indistinguishable when both used the same plain color.
action_banner([
"Go to Roblox Studio now.",
"Turn OFF then back ON: Assistant Settings > MCP Servers",
" > 'Enable Studio as MCP server'",
"Wait about 10 seconds - this window will turn green.",
])
return True # a squatter WAS killed -> Studio must reclaim the port
except Exception as e:
log(f"could not kill it: {e}", "rd")
else:
log("left it running. Close it yourself, then restart the bridge.", "yl")
return False
_TRANSIENT_STUDIO_MARKERS = (
"no roblox studio instance", "no active studio", "studio instance is connect",
"studio instance connected", "not connected to", "no studio instance",
)
def _looks_like_transient_studio_drop(text):
low = (text or "").lower()
return any(m in low for m in _TRANSIENT_STUDIO_MARKERS)
# ── config.json read / write (for extension-driven add/remove) ──────────────
def _read_config():
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
if isinstance(cfg, dict):
cfg.setdefault("mcpServers", {})
return cfg
except Exception as e:
log(f"config.json unreadable ({e}) - starting from a fresh one", "yl")
return {"mcpServers": {PRIMARY_SERVER_ID: {"command": "launch_studio_mcp.py", "args": []}}}
def _write_config(cfg):
"""Atomic write so a crash mid-write never leaves a truncated config.json."""
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, CONFIG_PATH)
def config_add_server(server_id, command, args=None, env=None):
"""Add/replace an addon server in config.json. Refuses to touch the primary
(roblox) server. Returns (ok, error)."""
sid = (server_id or "").strip()
if not sid:
return False, "server id is required"
if sid == PRIMARY_SERVER_ID:
return False, f"'{PRIMARY_SERVER_ID}' is the primary server and cannot be edited"
if not (command or "").strip():
return False, "a command is required"
cfg = _read_config()
spec = {"command": command.strip(), "args": list(args or [])}
if env:
spec["env"] = dict(env)
cfg["mcpServers"][sid] = spec
try:
_write_config(cfg)
except Exception as e:
return False, f"could not write config.json: {e}"
return True, None
def config_remove_server(server_id):
"""Remove an addon server from config.json. Refuses the primary server."""
sid = (server_id or "").strip()
if sid == PRIMARY_SERVER_ID:
return False, f"'{PRIMARY_SERVER_ID}' is the primary server and cannot be removed"
cfg = _read_config()
if sid not in cfg.get("mcpServers", {}):
return False, f"server '{sid}' is not in the config"
del cfg["mcpServers"][sid]
try:
_write_config(cfg)
except Exception as e:
return False, f"could not write config.json: {e}"
return True, None
def restart_self():
"""Replace this process with a fresh one so config.json is reloaded from
scratch. Children are killed first to free their stdio pipes / ports before
the new instance claims them. Never returns on success (os.execv)."""
log("restarting bridge to load new server config...", "yl")
try:
for c in mgr.clients.values():
c.stop()
except Exception:
pass
if _log_file:
try:
_log_file.flush()
except Exception:
pass
argv = list(sys.argv)
if getattr(sys, "frozen", False):
# Packaged app: sys.executable IS the bridge binary and argv[0] already
# points at it - do NOT prepend it as a pseudo-interpreter the way the
# dev-mode branch does for 'python bridge.py'.
cmd = [sys.executable] + argv[1:]
else:
# sys.argv[0] may be relative ('bridge.py'); make it absolute so the
# restart works regardless of the current working directory.
script = os.path.abspath(argv[0]) if argv else os.path.abspath(__file__)
cmd = [sys.executable, script] + argv[1:]
try:
os.execv(sys.executable, cmd)
except Exception as e:
# execv failed (rare) - fall back to spawning a detached copy and exiting
# so the user still ends up with a running, up-to-date bridge.
log(f"in-place restart failed ({e}); spawning a fresh bridge...", "rd")
try:
subprocess.Popen(cmd, cwd=HERE)
except Exception as e2:
log(f"could not spawn a fresh bridge: {e2} - please restart it manually", "rd")
os._exit(0)
# ══════════════════════════════════════════════════════════════════════════
# HARDENED MCP CLIENT (one per server in config.json)
# ══════════════════════════════════════════════════════════════════════════
class MCPClient:
def __init__(self, server_id, command, args, env=None):
self.id = server_id
self.command = command
self.args = list(args or [])
self.env = env or {}
self.proc = None
self.req_id = 1
self.write_lock = threading.Lock()
self.call_lock = threading.Lock() # serialize tool calls (single stdio pipe)
self.pending = {} # id -> queue.Queue (one slot)
self.pend_lock = threading.Lock()
self.tools_cache = []
self.start_lock = threading.Lock()
self._reader_thread = None
# Crash-loop forensics (read by server_watch). The auto-restart used to
# hide a server that something else kills over and over: the terminal
# showed an endless quiet restart cycle with no explanation at all. We
# keep just enough state to NAME the problem in the terminal instead:
# - last_exit: exit code from the final _reader EOF (crash vs kill hint)
# - stderr_tail: the last few stderr lines (usually the actual reason -
# port bind failure, missing dependency, crash trace)
# - restart_times: recent auto-restart timestamps (loop detector input)
# - loop_warned_at: throttle so the big red banner prints once per
# cooldown, not every 5s poll
self.last_exit = None
self.stderr_tail = []
self.restart_times = []
self.loop_warned_at = 0.0
# Set when the configured command itself couldn't be launched at all
# (e.g. 'uvx' not installed / not on PATH). This is NOT a crash - the
# process never existed, so last_exit/stderr_tail stay empty and the
# generic crash-loop banner used to print "the server printed no error
# output before dying", which is misleading for a config problem the
# user can fix in seconds. Kept across restarts so the banner can name
# the real cause instead.
self.start_error = None
# Set when StudioMCP's stderr shows it connected to a FOREIGN WS host on
# Studio's MCP port (not Studio). The unmistakable signature is a parse
# error on the host's messages ("missing field `type`") - Studio speaks
# the expected protocol, a squatter like ropilot speaks its own. This is
# a timing-independent proof that the port is hijacked, unlike the
# one-shot check_studio_port() boot probe which can miss a squatter that
# grabs the port a moment after boot (seen live 2026-07-13: ropilot took
# the port ~1s after the boot check ran, so nothing was flagged).
self.saw_foreign_ws_host = False
# Set once when this server is switched from a Node.js-based command to
# Roblox Studio's built-in StudioMCP (see _needs_node_fallback).
self._fallback_applied = False
# ── lifecycle ─────────────────────────────────────────────────────────
def _resolve(self, s):
return os.path.expandvars(os.path.expanduser(str(s)))
def _needs_node_fallback(self):
"""True when this server NEEDS Node.js to run but none is installed.
Packaged ZeroScript cannot promise users a Node runtime, and a config
whose command runs 'npx/npm/node' (e.g. the @chrrxs/robloxstudio-mcp
server) is exactly the kind of thing that used to die in a silent
restart loop on machines without Node. For the Roblox MCP server we
then fall back to the StudioMCP binary that ships INSIDE Roblox Studio
itself, which needs no Node at all. Applied at most once per process."""
if getattr(self, "_fallback_applied", False):
return False
if shutil.which("node") is not None or shutil.which("npx") is not None:
return False
blob = f"{self.command} {' '.join(self.args)}".lower()
return "roblox" in blob and any(t in blob for t in ("npx", "npm", "node"))
def _apply_node_fallback(self):
"""Switch this server to Roblox Studio's built-in StudioMCP launcher
(no Node needed) and tell the user what happened + how to get the
custom MCP back."""
self._fallback_applied = True
if getattr(sys, "frozen", False):
self.command = _sibling_exe("launch_studio_mcp") or "launch_studio_mcp"
else:
self.command = os.path.join(HERE, "launch_studio_mcp.py")
self.args = []
log(f"[{self.id}] Node.js is not installed - falling back to Roblox "
"Studio's built-in StudioMCP server (no Node needed).", "yl")
action_banner([
"Node.js is not installed on this PC.",
"The custom Roblox MCP server needs it, so ZeroScript",
"switched to Roblox Studio's built-in MCP server.",
"Enable it in Studio: Assistant Settings > MCP Servers",
" > 'Enable Studio as MCP server'",
"To use the custom MCP again: install Node.js, then",
"restart the bridge (or fix the server in config.json).",
])
def start(self):
with self.start_lock:
if self.is_alive():
return
if self._needs_node_fallback():
self._apply_node_fallback()
cmd = [self._resolve(self.command)] + [self._resolve(a) for a in self.args]
# A bare .py command (relative paths resolve against the bridge dir)
# is run with the SAME interpreter the bridge itself uses, so it works
# even on installs where only the `py` launcher exists (no `python`
# on PATH). This is how the Studio MCP launcher is wired by default.
if cmd[0].lower().endswith(".py"):
script = cmd[0]
if not os.path.isabs(script):
script = os.path.join(HERE, script)
# Packaged app: sys.executable is the bridge binary itself, not a
# Python interpreter - a bare .py command must map to the sibling
# executable bundled next to the bridge (e.g. launch_studio_mcp.py
# -> launch_studio_mcp.exe).
bundled = _sibling_exe(os.path.splitext(os.path.basename(script))[0])
if bundled:
cmd = [bundled] + cmd[1:]
elif getattr(sys, "frozen", False):
self.start_error = (
f"cannot run '{script}' from the packaged app: no bundled "
f"executable for it. Edit config.json to point at a real command."
)
log(f"[{self.id}] {self.start_error}", "rd")
raise FileNotFoundError(self.start_error)
else:
cmd = [sys.executable, script] + cmd[1:]
# On Windows, npx/npm/yarn/pnpm/bunx are .cmd shims that Popen can't
# launch directly (WinError 2). Run them through cmd.exe so any
# node-based MCP server "just works" from config.json.
if sys.platform == "win32":
base = os.path.basename(cmd[0]).lower()
if base in ("npx", "npm", "yarn", "pnpm", "bunx"):
cmd = ["cmd.exe", "/c"] + cmd
env = dict(os.environ)
for k, v in self.env.items():
env[k] = self._resolve(v)
log(f"[{self.id}] launching ({' '.join(cmd)})", "cy")
with _Spinner(f" [{self.id}] starting..."):
try:
self.proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
encoding="utf-8",
errors="replace",
cwd=HERE,
env=env,
)
except FileNotFoundError:
# The OS couldn't find cmd[0] at all - this is a config
# problem (missing dependency, typo, not on PATH), not a
# transient crash. Auto-restart will keep retrying (the
# user may install it later), but name the real cause so
# it doesn't just look like an endless silent restart loop.
self.start_error = (
f"command not found: '{cmd[0]}' - is it installed and on PATH? "
f"(configured for server '{self.id}' in config.json)"
)
log(f"[{self.id}] {self.start_error}", "rd")
raise
except OSError as e:
self.start_error = f"could not launch '{cmd[0]}': {e}"
log(f"[{self.id}] {self.start_error}", "rd")
raise
else:
self.start_error = None
with self.pend_lock:
self.pending.clear()
self.saw_foreign_ws_host = False # fresh process, fresh verdict
self._reader_thread = threading.Thread(target=self._reader, args=(self.proc,), daemon=True)
self._reader_thread.start()
threading.Thread(target=self._stderr_drain, args=(self.proc,), daemon=True).start()
# MCP handshake.
self._request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "zeroscript-bridge", "version": "1.0"},
}, timeout=30)
self._notify("notifications/initialized")
# Some MCP servers (notably Roblox's StudioMCP) advertise 0 tools at
# the instant initialize returns, because they connect to their
# backend (the running Studio) a moment AFTER the stdio handshake.
# A single tools/list then caches an empty list forever. So if we
# get nothing, retry for a few seconds to let the backend attach.
# Short per-attempt timeout so the bridge never looks frozen if the
# server stays silent (e.g. Studio not open yet); ~12s total budget.
for _ in range(12):
if self.refresh_tools(timeout=3):
break