@andespindola/crow-skills 0.25.0 → 0.26.0

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.
@@ -0,0 +1,744 @@
1
+ #!/usr/bin/env python3
2
+ """POSIX stdio MCP lifecycle proxy. No arbitrary process adoption or payload logs.
3
+
4
+ Session profile preserves application state with owned-tree SIGSTOP standby after
5
+ its first tools/call (memory retained). Restartable profile is opt-in: termination
6
+ releases child memory but does not preserve application state.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import ctypes
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import selectors
16
+ import signal
17
+ import stat
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import uuid
22
+ from pathlib import Path
23
+
24
+ MAX_LINE = 16 * 1024 * 1024
25
+ MAX_OUTPUT = 32 * 1024 * 1024
26
+
27
+
28
+ def canonical(value):
29
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
30
+
31
+
32
+ def id_key(value):
33
+ return (type(value).__name__, value)
34
+
35
+
36
+ def valid_id(value):
37
+ return value is None or (isinstance(value, (str, int)) and not isinstance(value, bool))
38
+
39
+
40
+ def valid_message(value):
41
+ if not isinstance(value, dict) or value.get("jsonrpc") != "2.0":
42
+ return False
43
+ if "id" in value and not valid_id(value["id"]):
44
+ return False
45
+ if "method" in value:
46
+ return (isinstance(value["method"], str) and "result" not in value
47
+ and "error" not in value and ("params" not in value
48
+ or isinstance(value["params"], (dict, list))))
49
+ return ("id" in value and (("result" in value) != ("error" in value)))
50
+
51
+
52
+ class PrivateStore:
53
+ """Directory-relative, no-follow I/O; atomic private cache/registry writes."""
54
+ def __init__(self, path):
55
+ path = os.path.abspath(os.path.expanduser(path))
56
+ fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
57
+ try:
58
+ for component in path.split("/"):
59
+ if not component:
60
+ continue
61
+ try:
62
+ nxt = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
63
+ dir_fd=fd)
64
+ except FileNotFoundError:
65
+ os.mkdir(component, 0o700, dir_fd=fd)
66
+ nxt = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
67
+ dir_fd=fd)
68
+ os.close(fd)
69
+ fd = nxt
70
+ os.fchmod(fd, 0o700)
71
+ self.fd = fd
72
+ except BaseException:
73
+ os.close(fd)
74
+ raise
75
+
76
+ def read(self, name):
77
+ try:
78
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=self.fd)
79
+ with os.fdopen(fd, "rb") as stream:
80
+ info = os.fstat(stream.fileno())
81
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_LINE:
82
+ return None
83
+ return json.loads(stream.read(MAX_LINE + 1))
84
+ except (OSError, ValueError):
85
+ return None
86
+
87
+ def write(self, name, value):
88
+ # Reject an existing symlink, including before replacement.
89
+ def check_target():
90
+ try:
91
+ info = os.stat(name, dir_fd=self.fd, follow_symlinks=False)
92
+ if not stat.S_ISREG(info.st_mode):
93
+ raise OSError("unsafe cache target")
94
+ except FileNotFoundError:
95
+ pass
96
+ check_target()
97
+ temp = ".tmp-" + uuid.uuid4().hex
98
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
99
+ 0o600, dir_fd=self.fd)
100
+ try:
101
+ with os.fdopen(fd, "wb") as stream:
102
+ stream.write(canonical(value).encode())
103
+ stream.flush()
104
+ os.fsync(stream.fileno())
105
+ check_target()
106
+ os.replace(temp, name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
107
+ finally:
108
+ try:
109
+ os.unlink(temp, dir_fd=self.fd)
110
+ except FileNotFoundError:
111
+ pass
112
+
113
+ def close(self):
114
+ os.close(self.fd)
115
+
116
+
117
+ class _DarwinBSDInfo(ctypes.Structure):
118
+ # SDK sys/proc_info.h proc_bsdinfo; no third-party dependency.
119
+ _fields_ = [("prefix", ctypes.c_uint32 * 12),
120
+ ("comm", ctypes.c_char * 16), ("name", ctypes.c_char * 32),
121
+ ("middle", ctypes.c_uint32 * 6),
122
+ ("start_sec", ctypes.c_uint64), ("start_usec", ctypes.c_uint64)]
123
+
124
+
125
+ _DARWIN_PROC = None
126
+ if sys.platform == "darwin":
127
+ try:
128
+ _DARWIN_PROC = ctypes.CDLL("/usr/lib/libproc.dylib").proc_pidinfo
129
+ _DARWIN_PROC.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64,
130
+ ctypes.c_void_p, ctypes.c_int]
131
+ _DARWIN_PROC.restype = ctypes.c_int
132
+ except OSError:
133
+ pass
134
+
135
+
136
+ def process_identity(pid):
137
+ """Kernel start identity, not PID alone or second-granularity ps time."""
138
+ try:
139
+ if sys.platform == "darwin" and _DARWIN_PROC is not None:
140
+ info = _DarwinBSDInfo()
141
+ if _DARWIN_PROC(pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info)) == ctypes.sizeof(info):
142
+ return ("darwin", info.start_sec, info.start_usec)
143
+ elif sys.platform.startswith("linux"):
144
+ fields = Path("/proc").joinpath(str(pid), "stat").read_text().rsplit(") ", 1)[1].split()
145
+ return ("linux", fields[19])
146
+ except (OSError, ValueError, IndexError):
147
+ pass
148
+ return None # Fail closed: an unverifiable process is never signaled.
149
+
150
+
151
+ def process_snapshot():
152
+ """Read ancestry and attach precise start identities to each candidate PID."""
153
+ try:
154
+ out = subprocess.run(["ps", "-axo", "pid=,ppid=,lstart="],
155
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
156
+ timeout=1, check=True, text=True).stdout
157
+ result = {}
158
+ for line in out.splitlines():
159
+ parts = line.split(None, 2)
160
+ if len(parts) == 3:
161
+ pid = int(parts[0])
162
+ identity = process_identity(pid)
163
+ if identity is not None:
164
+ result[pid] = (int(parts[1]), identity)
165
+ return result
166
+ except (OSError, ValueError, subprocess.SubprocessError):
167
+ return {}
168
+
169
+
170
+ class Proxy:
171
+ def __init__(self, options, command, input_fd=0, output_fd=1):
172
+ self.options, self.command = options, command
173
+ self.input_fd, self.output_fd = input_fd, output_fd
174
+ self.store = PrivateStore(options.state_dir)
175
+ self.registry = "runtime-" + str(os.getpid()) + "-" + uuid.uuid4().hex + ".json"
176
+ self.sel = selectors.DefaultSelector()
177
+ self.sel.register(input_fd, selectors.EVENT_READ, "host")
178
+ os.set_blocking(output_fd, False)
179
+ self.outputs = {"host": bytearray(), "child": bytearray()}
180
+ self.write_fds = {"host": output_fd}
181
+ self.transport_failed = False
182
+ self.buffers = {"host": bytearray(), "child": bytearray()}
183
+ self.dropping = {"host": False, "child": False}
184
+ self.child = None
185
+ self.owned = {}
186
+ self.pending = {}
187
+ self.server_pending = set()
188
+ self.queue = []
189
+ self.initialized = False
190
+ self.init_params = None
191
+ self.init_request = None
192
+ self.cache = None
193
+ self.cache_name = None
194
+ self.ready = False
195
+ self.handshake_id = None
196
+ self.cold_id = None
197
+ self.started = 0.0
198
+ self.startup_ms = None
199
+ self.last_activity = time.monotonic()
200
+ self.last_snapshot = 0.0
201
+ self.pinned = False
202
+ self.suspended = False
203
+ self.resume_ms = None
204
+ self.subscriptions = set()
205
+ self.session_lost = False
206
+ self.stopping = False
207
+ self.last_telemetry = 0.0
208
+ self.telemetry("cold")
209
+
210
+ def telemetry(self, state=None):
211
+ if state is not None:
212
+ self.state = state
213
+ try:
214
+ self.store.write(self.registry, {
215
+ "state": self.state, "pid": os.getpid(),
216
+ "pid_identity": process_identity(os.getpid()),
217
+ "child_pid": self.child.pid if self.child else None,
218
+ "child_identity": process_identity(self.child.pid) if self.child else None,
219
+ "descendant_pids": sorted(self.owned),
220
+ "pending_client": len(self.pending),
221
+ "pending_server": len(self.server_pending),
222
+ "subscriptions": len(self.subscriptions),
223
+ "startup_ms": self.startup_ms,
224
+ "profile": self.options.profile,
225
+ "retainedMemory": bool(self.suspended),
226
+ "resume_ms": self.resume_ms,
227
+ })
228
+ except OSError:
229
+ pass # Telemetry cannot break an otherwise valid protocol exchange.
230
+ self.last_telemetry = time.monotonic()
231
+
232
+ def queue_output(self, sink, message):
233
+ data = (canonical(message) + "\n").encode()
234
+ buffer = self.outputs[sink]
235
+ if len(buffer) + len(data) > MAX_OUTPUT:
236
+ if sink == "child":
237
+ self.crashed("MCP server input buffer exceeded; request was not retried")
238
+ else:
239
+ # The peer cannot receive even a bounded error. Close deterministically.
240
+ self.transport_failed = True
241
+ self.stopping = True
242
+ return False
243
+ was_empty = not buffer
244
+ buffer.extend(data)
245
+ if was_empty:
246
+ self.sel.register(self.write_fds[sink], selectors.EVENT_WRITE, sink + "_write")
247
+ return True
248
+
249
+ def flush_output(self, sink):
250
+ buffer = self.outputs[sink]
251
+ if not buffer or sink not in self.write_fds:
252
+ return
253
+ try:
254
+ size = os.write(self.write_fds[sink], memoryview(buffer)[:65536])
255
+ del buffer[:size]
256
+ except BlockingIOError:
257
+ return
258
+ except OSError:
259
+ if sink == "child":
260
+ self.crashed()
261
+ else:
262
+ self.transport_failed = True
263
+ self.stopping = True
264
+ return
265
+ if not buffer:
266
+ try:
267
+ self.sel.unregister(self.write_fds[sink])
268
+ except KeyError:
269
+ pass
270
+
271
+ def send(self, message):
272
+ return self.queue_output("host", message)
273
+
274
+ def error(self, ident, code, message):
275
+ self.send({"jsonrpc": "2.0", "id": ident,
276
+ "error": {"code": code, "message": message}})
277
+
278
+ def child_send(self, message):
279
+ if self.child is None or self.child.stdin is None:
280
+ return False
281
+ return self.queue_output("child", message)
282
+
283
+ def cache_save(self):
284
+ if self.cache_name and self.cache:
285
+ try:
286
+ self.store.write(self.cache_name, self.cache)
287
+ except OSError:
288
+ pass
289
+
290
+ def cache_prepare(self, params):
291
+ material = {"command": self.command, "cwd": os.getcwd(),
292
+ "env_hash": hashlib.sha256(canonical(dict(os.environ)).encode()).hexdigest(),
293
+ "initialize": params}
294
+ self.cache_name = "metadata-" + hashlib.sha256(canonical(material).encode()).hexdigest() + ".json"
295
+ candidate = self.store.read(self.cache_name)
296
+ if (isinstance(candidate, dict) and candidate.get("schema") == 1
297
+ and isinstance(candidate.get("initialize"), dict)
298
+ and isinstance(candidate.get("tools"), dict)):
299
+ self.cache = candidate
300
+ else:
301
+ self.cache = {"schema": 1, "tools": {}}
302
+
303
+ def capture_owned(self):
304
+ if not self.child:
305
+ return
306
+ snapshot = process_snapshot()
307
+ root = self.child.pid
308
+ if root in snapshot and root not in self.owned:
309
+ self.owned[root] = snapshot[root][1]
310
+ # Only extend ancestry from still-matching owned identities.
311
+ parents = {p for p, identity in self.owned.items()
312
+ if p in snapshot and snapshot[p][1] == identity}
313
+ changed = True
314
+ while changed:
315
+ changed = False
316
+ for pid, (ppid, identity) in snapshot.items():
317
+ if ppid in parents and pid not in parents:
318
+ self.owned[pid] = identity
319
+ parents.add(pid)
320
+ changed = True
321
+ self.last_snapshot = time.monotonic()
322
+
323
+ def resume_child(self):
324
+ if not self.suspended:
325
+ return
326
+ began = time.monotonic()
327
+ snapshot = process_snapshot()
328
+ for pid, identity in self.owned.items():
329
+ if pid in snapshot and snapshot[pid][1] == identity and process_identity(pid) == identity:
330
+ try:
331
+ os.kill(pid, signal.SIGCONT)
332
+ except ProcessLookupError:
333
+ pass
334
+ self.suspended = False
335
+ self.resume_ms = round((time.monotonic() - began) * 1000)
336
+ self.last_activity = time.monotonic()
337
+ self.telemetry("active")
338
+
339
+ def standby_child(self):
340
+ self.capture_owned()
341
+ snapshot = process_snapshot()
342
+ if not self.child or self.child.pid not in self.owned:
343
+ self.telemetry("pinned")
344
+ return
345
+ # Only verified descendants, never an externally connected shared browser.
346
+ live = [pid for pid, identity in self.owned.items()
347
+ if pid in snapshot and snapshot[pid][1] == identity]
348
+ if self.child.pid not in live:
349
+ self.telemetry("pinned")
350
+ return
351
+ stopped = []
352
+ try:
353
+ for pid in sorted(live, key=lambda p: p == self.child.pid):
354
+ if process_identity(pid) != self.owned[pid]:
355
+ raise ProcessLookupError("owned process identity changed")
356
+ os.kill(pid, signal.SIGSTOP)
357
+ stopped.append(pid)
358
+ except OSError:
359
+ for pid in stopped:
360
+ current = process_snapshot()
361
+ if pid in current and current[pid][1] == self.owned[pid]:
362
+ try:
363
+ os.kill(pid, signal.SIGCONT)
364
+ except ProcessLookupError:
365
+ pass
366
+ self.telemetry("pinned")
367
+ return
368
+ self.suspended = True
369
+ self.telemetry("standby")
370
+
371
+ def stop_child(self):
372
+ if self.child is None:
373
+ return
374
+ child = self.child
375
+ self.resume_child()
376
+ self.capture_owned() # Photograph descendants BEFORE first signal.
377
+ for sig, grace in [(signal.SIGTERM, .25), (signal.SIGKILL, .15)]:
378
+ snapshot = process_snapshot()
379
+ live = [p for p, identity in self.owned.items()
380
+ if p in snapshot and snapshot[p][1] == identity]
381
+ # Root last; descendants may use different process groups.
382
+ for pid in sorted(live, key=lambda p: p == child.pid):
383
+ try:
384
+ if process_identity(pid) == self.owned[pid]:
385
+ os.kill(pid, sig)
386
+ except ProcessLookupError:
387
+ pass
388
+ deadline = time.monotonic() + grace
389
+ while time.monotonic() < deadline:
390
+ if child.poll() is not None and not live:
391
+ break
392
+ time.sleep(.01)
393
+ snapshot = process_snapshot()
394
+ live = [p for p, identity in self.owned.items()
395
+ if p in snapshot and snapshot[p][1] == identity]
396
+ if not live:
397
+ break
398
+ try:
399
+ self.sel.unregister(child.stdout)
400
+ except (KeyError, ValueError):
401
+ pass
402
+ if "child" in self.write_fds:
403
+ try:
404
+ self.sel.unregister(self.write_fds.pop("child"))
405
+ except KeyError:
406
+ pass
407
+ self.outputs["child"].clear()
408
+ for stream in (child.stdin, child.stdout):
409
+ if stream:
410
+ stream.close()
411
+ try:
412
+ child.wait(timeout=.2)
413
+ except subprocess.TimeoutExpired:
414
+ pass
415
+ self.child = None
416
+ self.owned = {}
417
+ self.ready = False
418
+ self.handshake_id = None
419
+ self.cold_id = None
420
+ self.buffers["child"].clear()
421
+ self.dropping["child"] = False
422
+
423
+ def start_child(self, cold_request=None):
424
+ if self.session_lost:
425
+ return False
426
+ self.started = time.monotonic()
427
+ try:
428
+ self.child = subprocess.Popen(self.command, stdin=subprocess.PIPE,
429
+ stdout=subprocess.PIPE,
430
+ stderr=subprocess.DEVNULL,
431
+ start_new_session=True, bufsize=0)
432
+ except OSError:
433
+ self.child = None
434
+ return False
435
+ os.set_blocking(self.child.stdin.fileno(), False)
436
+ self.write_fds["child"] = self.child.stdin.fileno()
437
+ self.sel.register(self.child.stdout, selectors.EVENT_READ, "child")
438
+ self.capture_owned()
439
+ self.ready = False
440
+ self.last_activity = self.started
441
+ self.telemetry("starting")
442
+ if cold_request is not None:
443
+ self.cold_id = id_key(cold_request["id"])
444
+ self.pending[self.cold_id] = cold_request
445
+ self.child_send(cold_request)
446
+ return True # A send failure already emitted the pending request error.
447
+ self.handshake_id = "__crow_uny_initialize_" + uuid.uuid4().hex
448
+ return self.child_send({"jsonrpc": "2.0", "id": self.handshake_id,
449
+ "method": "initialize", "params": self.init_params})
450
+
451
+ def fail_pending(self, text):
452
+ requests = list(self.pending.values()) + [m for m in self.queue if "id" in m and "method" in m]
453
+ self.pending.clear()
454
+ self.queue.clear()
455
+ seen = set()
456
+ for message in requests:
457
+ key = id_key(message["id"])
458
+ if key not in seen:
459
+ self.error(message["id"], -32001, text)
460
+ seen.add(key)
461
+ for key in list(self.server_pending):
462
+ ident = key[1]
463
+ self.send({"jsonrpc": "2.0", "method": "notifications/cancelled",
464
+ "params": {"requestId": ident, "reason": "MCP server disconnected"}})
465
+ self.server_pending.clear()
466
+
467
+ def crashed(self, text="MCP server disconnected; request was not retried"):
468
+ self.fail_pending(text)
469
+ if self.pinned or self.subscriptions:
470
+ self.session_lost = True
471
+ self.subscriptions.clear()
472
+ self.stop_child()
473
+ self.telemetry("session_lost" if self.session_lost else "hibernated")
474
+
475
+ def forward_request(self, message):
476
+ if "method" not in message: # Host response to a server-originated request.
477
+ self.server_pending.discard(id_key(message.get("id")))
478
+ self.child_send(message)
479
+ return
480
+ if "id" in message:
481
+ key = id_key(message["id"])
482
+ if key in self.pending:
483
+ self.error(message["id"], -32600, "Duplicate outstanding request id")
484
+ return
485
+ self.pending[key] = message
486
+ if message["method"] == "tools/call" and self.options.profile == "session":
487
+ self.pinned = True
488
+ self.last_activity = time.monotonic()
489
+ self.child_send(message)
490
+ self.telemetry("active")
491
+
492
+ def from_host(self, message):
493
+ method = message.get("method")
494
+ if method == "initialize" and "id" in message:
495
+ if self.init_request is not None:
496
+ self.error(message["id"], -32600, "Already initialized")
497
+ return
498
+ self.init_request = message
499
+ self.init_params = message.get("params", {})
500
+ self.cache_prepare(self.init_params)
501
+ if "initialize" in self.cache:
502
+ self.send({"jsonrpc": "2.0", "id": message["id"],
503
+ "result": self.cache["initialize"]})
504
+ self.telemetry("hibernated")
505
+ elif not self.start_child(message):
506
+ if id_key(message["id"]) in self.pending:
507
+ self.fail_pending("MCP server could not start")
508
+ else:
509
+ self.error(message["id"], -32001, "MCP server could not start")
510
+ return
511
+ if method == "notifications/initialized":
512
+ self.initialized = True
513
+ if self.child and self.ready:
514
+ self.child_send(message)
515
+ return
516
+ if method == "ping" and "id" in message:
517
+ self.send({"jsonrpc": "2.0", "id": message["id"], "result": {}})
518
+ return
519
+ if method is None:
520
+ # A server can request ping/roots/etc DURING initialize. Its response
521
+ # must bypass the readiness queue or initialization deadlocks.
522
+ if self.child and id_key(message.get("id")) in self.server_pending:
523
+ self.forward_request(message)
524
+ self.telemetry("starting" if not self.ready else "active")
525
+ return
526
+ if self.init_request is None:
527
+ if "id" in message and method:
528
+ self.error(message["id"], -32002, "Initialize required")
529
+ return
530
+ if method == "tools/list" and "id" in message and (self.child is None or self.suspended):
531
+ result = self.cache["tools"].get(canonical(message.get("params", {})))
532
+ if result is not None:
533
+ self.send({"jsonrpc": "2.0", "id": message["id"], "result": result})
534
+ return
535
+ if self.session_lost:
536
+ if "id" in message and method:
537
+ self.error(message["id"], -32003, "MCP session state was lost; reconnect required")
538
+ return
539
+ # A cancellation/host response while hibernating cannot have an active peer.
540
+ if self.child is None and (method == "notifications/cancelled" or method is None):
541
+ return
542
+ if self.child is None and not self.start_child():
543
+ if "id" in message and method:
544
+ self.error(message["id"], -32001, "MCP server could not start")
545
+ return
546
+ self.resume_child()
547
+ if not self.ready:
548
+ self.queue.append(message)
549
+ return
550
+ self.forward_request(message)
551
+
552
+ def from_child(self, message):
553
+ self.last_activity = time.monotonic()
554
+ if "method" in message:
555
+ if "id" in message:
556
+ self.server_pending.add(id_key(message["id"]))
557
+ self.telemetry("active")
558
+ if message["method"] == "notifications/tools/list_changed":
559
+ self.cache["tools"].clear()
560
+ self.cache_save()
561
+ self.send(message)
562
+ return
563
+ key = id_key(message.get("id"))
564
+ if self.handshake_id is not None and key == id_key(self.handshake_id):
565
+ self.handshake_id = None
566
+ if "result" not in message or not isinstance(message["result"], dict):
567
+ self.crashed("MCP reinitialization failed; request was not retried")
568
+ return
569
+ changed = self.cache.get("initialize") != message["result"]
570
+ self.cache["initialize"] = message["result"]
571
+ if changed:
572
+ self.cache["tools"].clear()
573
+ self.cache_save()
574
+ self.child_send({"jsonrpc": "2.0", "method": "notifications/initialized"})
575
+ self.ready = True
576
+ self.startup_ms = round((time.monotonic() - self.started) * 1000)
577
+ if changed and self.initialized:
578
+ self.send({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})
579
+ queued, self.queue = self.queue, []
580
+ for request in queued:
581
+ if self.child is None:
582
+ if "id" in request and "method" in request:
583
+ self.error(request["id"], -32001, "MCP server disconnected; request was not retried")
584
+ else:
585
+ self.forward_request(request)
586
+ self.telemetry("active")
587
+ return
588
+ request = self.pending.pop(key, None)
589
+ if request is None:
590
+ return # Late/unknown responses must not collide with an internal handshake.
591
+ if key == self.cold_id:
592
+ self.cold_id = None
593
+ if "result" in message and isinstance(message["result"], dict):
594
+ self.cache["initialize"] = message["result"]
595
+ self.cache_save()
596
+ self.ready = True
597
+ self.startup_ms = round((time.monotonic() - self.started) * 1000)
598
+ else:
599
+ self.send(message)
600
+ self.crashed("MCP initialization failed")
601
+ return
602
+ if "result" in message:
603
+ method = request["method"]
604
+ params = request.get("params", {})
605
+ if method == "tools/list":
606
+ self.cache["tools"][canonical(params)] = message["result"]
607
+ self.cache_save()
608
+ if method == "resources/subscribe" and isinstance(params, dict):
609
+ self.subscriptions.add(canonical(params.get("uri")))
610
+ if method == "resources/unsubscribe" and isinstance(params, dict):
611
+ self.subscriptions.discard(canonical(params.get("uri")))
612
+ self.send(message)
613
+ if self.ready and self.queue:
614
+ queued, self.queue = self.queue, []
615
+ for request in queued:
616
+ self.forward_request(request)
617
+ self.telemetry("active")
618
+
619
+ def consume(self, source, data):
620
+ buffer = self.buffers[source]
621
+ for fragment in data.splitlines(keepends=True):
622
+ complete = fragment.endswith(b"\n")
623
+ if self.dropping[source]:
624
+ if complete:
625
+ self.dropping[source] = False
626
+ continue
627
+ buffer.extend(fragment)
628
+ if len(buffer) > MAX_LINE:
629
+ buffer.clear()
630
+ self.dropping[source] = not complete
631
+ if source == "child":
632
+ self.crashed("MCP server sent an oversized message")
633
+ return
634
+ self.error(None, -32700, "Message too large")
635
+ continue
636
+ if not complete:
637
+ continue
638
+ raw = bytes(buffer)
639
+ buffer.clear()
640
+ try:
641
+ message = json.loads(raw)
642
+ except (ValueError, UnicodeError):
643
+ if source == "child":
644
+ self.crashed("MCP server sent invalid JSON")
645
+ return
646
+ self.error(None, -32700, "Invalid JSON")
647
+ continue
648
+ if not valid_message(message):
649
+ if source == "child":
650
+ self.crashed("MCP server sent an invalid message")
651
+ return
652
+ self.error(None, -32600, "Invalid JSON-RPC message")
653
+ continue
654
+ if source == "host":
655
+ self.from_host(message)
656
+ else:
657
+ self.from_child(message)
658
+ if self.child is None:
659
+ return
660
+
661
+ def run(self):
662
+ previous = {}
663
+ def stop(signum, frame):
664
+ self.stopping = True
665
+ for sig in (signal.SIGTERM, signal.SIGINT):
666
+ previous[sig] = signal.signal(sig, stop)
667
+ try:
668
+ while not self.stopping:
669
+ for key, _ in self.sel.select(.04):
670
+ source = key.data
671
+ if source.endswith("_write"):
672
+ self.flush_output(source[:-6])
673
+ continue
674
+ if source == "child" and (self.child is None or self.child.stdout.fileno() != key.fd):
675
+ continue
676
+ try:
677
+ data = os.read(key.fd, 65536)
678
+ except OSError:
679
+ data = b""
680
+ if not data:
681
+ if source == "host":
682
+ self.stopping = True
683
+ break
684
+ self.crashed()
685
+ else:
686
+ self.consume(source, data)
687
+ now = time.monotonic()
688
+ if self.child:
689
+ if now - self.last_snapshot > .2:
690
+ self.capture_owned()
691
+ if self.child.poll() is not None:
692
+ self.crashed()
693
+ elif not self.ready and now - self.started > self.options.startup_timeout:
694
+ self.crashed("MCP startup timed out; request was not retried")
695
+ elif (self.ready and not self.pending and not self.server_pending
696
+ and not self.queue and not self.outputs["child"]
697
+ and not self.subscriptions and not self.suspended
698
+ and now - self.last_activity >= self.options.idle_seconds):
699
+ if self.pinned:
700
+ self.standby_child()
701
+ else:
702
+ self.stop_child()
703
+ self.telemetry("hibernated")
704
+ if now - self.last_telemetry > .5:
705
+ self.telemetry()
706
+ except (BrokenPipeError, KeyboardInterrupt):
707
+ pass
708
+ finally:
709
+ self.stop_child()
710
+ self.telemetry("stopped")
711
+ self.sel.close()
712
+ self.store.close()
713
+ for sig, handler in previous.items():
714
+ signal.signal(sig, handler)
715
+ return 2 if self.transport_failed else 0
716
+
717
+
718
+ def main(argv=None):
719
+ parser = argparse.ArgumentParser(description=__doc__)
720
+ sub = parser.add_subparsers(dest="action", required=True)
721
+ serve = sub.add_parser("serve")
722
+ serve.add_argument("--name", required=True)
723
+ serve.add_argument("--idle-seconds", type=float, default=120)
724
+ serve.add_argument("--startup-timeout", type=float, default=30)
725
+ serve.add_argument("--state-dir", default=str(Path.home() / ".local/state/crow-uny-mcp"))
726
+ serve.add_argument("--profile", choices=["restartable", "session"], default="session")
727
+ serve.add_argument("command", nargs=argparse.REMAINDER)
728
+ options = parser.parse_args(argv)
729
+ command = options.command
730
+ if command and command[0] == "--":
731
+ command = command[1:]
732
+ if not command or options.idle_seconds <= 0 or options.startup_timeout <= 0:
733
+ parser.error("command and positive timeouts are required")
734
+ if os.name != "posix" or not (sys.platform == "darwin" or sys.platform.startswith("linux")):
735
+ parser.error("this runtime currently requires macOS or Linux kernel process identities")
736
+ try:
737
+ return Proxy(options, command).run()
738
+ except (OSError, ValueError):
739
+ print("crow-uny-mcp: runtime setup or transport failure", file=sys.stderr)
740
+ return 2
741
+
742
+
743
+ if __name__ == "__main__":
744
+ raise SystemExit(main())