@andespindola/crow-skills 0.25.0 → 0.26.1
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.
- package/catalog.json +16 -4
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/broadcast.py +15 -11
- package/crow_skills/cli.py +150 -3
- package/crow_skills/guide.py +16 -1
- package/crow_skills/mcp.py +632 -0
- package/docs/cli.md +22 -0
- package/package.json +1 -1
- package/release.json +77 -17
- package/skills/crow-agent-security/references/limites-agente.md +3 -1
- package/skills/crow-behaviour/SKILL.md +18 -0
- package/skills/crow-behaviour/references/cross-session-capacity.md +38 -0
- package/skills/crow-domain/SKILL.md +1 -1
- package/skills/crow-infra/SKILL.md +3 -3
- package/skills/crow-performance/SKILL.md +1 -1
- package/skills/crow-reverse/SKILL.md +2 -2
- package/skills/crow-reverse/references/binario-nativo.md +30 -0
- package/skills/crow-reverse/references/diferenca-entre-versoes.md +20 -0
- package/skills/crow-reverse/references/javascript-e-bundle.md +22 -0
- package/skills/crow-reverse/references/mecanismo-de-analise.md +23 -0
- package/skills/crow-reverse/references/pacote-e-arquivo.md +19 -0
- package/skills/crow-reverse/references/protocolo-e-formato.md +23 -0
- package/skills/crow-reverse/references/triagem-de-artefato.md +24 -0
- package/skills/crow-skill-author/SKILL.md +1 -1
- package/skills/crow-skill-author/references/medicao.md +22 -2
- package/skills/crow-uny-mcp/SKILL.md +43 -0
- package/skills/crow-uny-mcp/references/lifecycle.md +29 -0
- package/skills/crow-uny-mcp/scripts/crow_mcp.py +744 -0
|
@@ -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())
|