@ictechgy/context-guard 0.4.13 → 0.4.15

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,999 @@
1
+ #!/usr/bin/env python3
2
+ """Strict local stdio MCP adapter for the ContextGuard helper CLIs.
3
+
4
+ This module deliberately has no network/client configuration surface. It is a
5
+ small JSON-RPC membrane around the sibling compressor and artifact helpers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import fcntl
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ import re
17
+ import signal
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ from typing import Any
24
+
25
+ SUPPORTED_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
26
+ MAX_MESSAGE_BYTES = 1024 * 1024
27
+ MAX_CONTENT_BYTES = 768 * 1024
28
+ MAX_RETURN_BYTES = 20 * 1024
29
+ MAX_RETRIEVE_CHARS = 20_000
30
+ MAX_RETRIEVE_LINES = 500
31
+ MAX_CALLS = 1000
32
+ MAX_ARTIFACTS = 1000
33
+ MAX_VISITED_ENTRIES = 4000
34
+ HELPER_TIMEOUT = 10.0
35
+ MAX_HELPER_STDERR = 16 * 1024
36
+ MAX_HELPER_OUTPUT = 5 * 1024 * 1024
37
+ MAX_RESPONSE_BYTES = 128 * 1024
38
+ MAX_JSON_DEPTH = 64
39
+ ARTIFACT_ID_RE = re.compile(r"^[a-f0-9]{20}$")
40
+ NAMESPACE_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
41
+ LINES_RE = re.compile(r"^[1-9][0-9]{0,6}(?::[1-9][0-9]{0,6})?$")
42
+ CONTENT_TYPES = ("json", "diff", "log", "search", "code", "prose")
43
+ MODES = ("conservative", "readable")
44
+ PROTECTED_CLASSES = (
45
+ "code_fence", "diff", "identifier", "numeric_constant", "hash", "path",
46
+ "stack_frame", "quoted_string", "json_key",
47
+ )
48
+ ERROR_MESSAGES = {
49
+ "invalid_arguments": "Invalid tool arguments.",
50
+ "input_too_large": "Content exceeds the configured byte limit.",
51
+ "result_too_large_without_fallback": "Compressed result exceeds the response limit without a stored fallback.",
52
+ "artifact_not_found": "Artifact not found in this namespace.",
53
+ "artifact_invalid": "Artifact failed integrity validation.",
54
+ "namespace_full": "Namespace artifact limit reached.",
55
+ "namespace_ambiguous": "Namespace storage is ambiguous.",
56
+ "helper_failed": "Local helper failed.",
57
+ "rate_limit_reached": "Tool call limit reached.",
58
+ "response_too_large": "Response exceeds the configured limit.",
59
+ }
60
+
61
+
62
+ class DuplicateKey(ValueError):
63
+ pass
64
+
65
+
66
+ class NonFinite(ValueError):
67
+ pass
68
+
69
+
70
+ def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
71
+ value: dict[str, Any] = {}
72
+ for key, item in pairs:
73
+ if key in value:
74
+ raise DuplicateKey(key)
75
+ value[key] = item
76
+ return value
77
+
78
+
79
+ def reject_nonfinite(_value: str) -> None:
80
+ raise NonFinite()
81
+
82
+
83
+ def compact(value: object) -> str:
84
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
85
+
86
+
87
+ def line_count(value: str) -> int:
88
+ return value.count("\n") + (1 if value and not value.endswith("\n") else 0)
89
+
90
+
91
+ def cap_utf8(value: str, limit: int) -> tuple[str, bool]:
92
+ data = value.encode("utf-8")
93
+ if len(data) <= limit:
94
+ return value, False
95
+ out: list[str] = []
96
+ used = 0
97
+ for char in value:
98
+ encoded = char.encode("utf-8")
99
+ if used + len(encoded) > limit:
100
+ break
101
+ out.append(char)
102
+ used += len(encoded)
103
+ return "".join(out), True
104
+
105
+
106
+ def is_safe_scalar(value: object) -> bool:
107
+ """Validate JSON-shaped values without recursive traversal or encoding.
108
+
109
+ Parsed wire data is attacker-controlled. Keep its nesting below a fixed
110
+ bound so neither this guard nor a later serializer can reach Python's
111
+ recursion limit. The identity set also makes the helper safe for direct
112
+ unit-test inputs containing an accidental container cycle.
113
+ """
114
+ pending: list[tuple[object, int]] = [(value, 0)]
115
+ seen: set[int] = set()
116
+ while pending:
117
+ item, depth = pending.pop()
118
+ if isinstance(item, str):
119
+ if any(0xD800 <= ord(char) <= 0xDFFF for char in item):
120
+ return False
121
+ continue
122
+ if isinstance(item, list):
123
+ if depth >= MAX_JSON_DEPTH or id(item) in seen:
124
+ return False
125
+ seen.add(id(item))
126
+ pending.extend((child, depth + 1) for child in item)
127
+ continue
128
+ if isinstance(item, dict):
129
+ if depth >= MAX_JSON_DEPTH or id(item) in seen:
130
+ return False
131
+ seen.add(id(item))
132
+ for key, child in item.items():
133
+ if not isinstance(key, str) or any(0xD800 <= ord(char) <= 0xDFFF for char in key):
134
+ return False
135
+ pending.append((child, depth + 1))
136
+ return True
137
+
138
+
139
+ def safe_utf8_bytes(value: str) -> bytes | None:
140
+ """Encode only JSON strings that can safely be returned on the wire."""
141
+ try:
142
+ if not is_safe_scalar(value):
143
+ return None
144
+ return value.encode("utf-8")
145
+ except UnicodeEncodeError:
146
+ return None
147
+
148
+
149
+ def cap_codepoints(value: str, limit: int) -> tuple[str, bool]:
150
+ return (value, False) if len(value) <= limit else (value[:limit], True)
151
+
152
+
153
+ def error_response(request_id: object, code: int, message: str) -> dict[str, object]:
154
+ return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
155
+
156
+
157
+ def tool_error(code: str) -> dict[str, object]:
158
+ return {
159
+ "schema_version": "contextguard.mcp.tool-error.v1",
160
+ "error": {"code": code, "message": ERROR_MESSAGES[code], "retryable": False},
161
+ }
162
+
163
+
164
+ def call_result(payload: dict[str, object], is_error: bool) -> dict[str, object]:
165
+ text = compact(payload)
166
+ return {"content": [{"type": "text", "text": text}], "structuredContent": payload, "isError": is_error}
167
+
168
+
169
+ def nonnegative_int(value: object) -> bool:
170
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
171
+
172
+
173
+ def exact_object(value: object, keys: set[str]) -> bool:
174
+ return isinstance(value, dict) and set(value) == keys
175
+
176
+
177
+ def valid_tool_payload(payload: object, is_error: bool) -> bool:
178
+ """The single recursive allowlist for all MCP structured tool payloads."""
179
+ if not isinstance(payload, dict) or not is_safe_scalar(payload):
180
+ return False
181
+ if is_error:
182
+ return (exact_object(payload, {"schema_version", "error"})
183
+ and payload.get("schema_version") == "contextguard.mcp.tool-error.v1"
184
+ and exact_object(payload.get("error"), {"code", "message", "retryable"})
185
+ and payload["error"].get("code") in ERROR_MESSAGES
186
+ and payload["error"].get("message") == ERROR_MESSAGES[payload["error"]["code"]]
187
+ and payload["error"].get("retryable") is False)
188
+ version = payload.get("schema_version")
189
+ if version == "contextguard.mcp.compress.v1":
190
+ if not exact_object(payload, {"schema_version", "content", "content_capped", "compression", "artifact"}):
191
+ return False
192
+ c = payload["compression"]
193
+ if not isinstance(payload["content"], str) or not isinstance(payload["content_capped"], bool) or not exact_object(c, {"content_type", "type_source", "strategy", "lossy", "bytes", "lines", "token_proxy", "redaction", "protected_policy"}):
194
+ return False
195
+ if c["content_type"] not in CONTENT_TYPES or c["type_source"] not in ("detected", "override") or c["strategy"] not in {"json-compact", "diff-keep-changes", "log-collapse-repeats", "search-dedupe", "code-whitespace", "prose-whitespace", "prose-readable-window"} or not isinstance(c["lossy"], bool):
196
+ return False
197
+ for section, keys in (("bytes", {"measurement", "original", "compressed", "returned"}), ("lines", {"measurement", "original", "compressed", "returned"})):
198
+ if not exact_object(c[section], keys) or c[section].get("measurement") != "observed" or not all(nonnegative_int(c[section][key]) for key in keys - {"measurement"}):
199
+ return False
200
+ token = c["token_proxy"]
201
+ redaction = c["redaction"]
202
+ policy = c["protected_policy"]
203
+ if not (exact_object(token, {"measurement", "method", "original", "compressed"}) and token.get("measurement") == "estimated" and token.get("method") == "chars_div_4" and nonnegative_int(token.get("original")) and nonnegative_int(token.get("compressed")) and exact_object(redaction, {"redacted_lines", "redacted_before_receipt"}) and nonnegative_int(redaction.get("redacted_lines")) and redaction.get("redacted_before_receipt") is True and exact_object(policy, {"enabled", "retrieval_required", "detected_classes"}) and isinstance(policy.get("enabled"), bool) and isinstance(policy.get("retrieval_required"), bool) and isinstance(policy.get("detected_classes"), list) and policy["detected_classes"] == sorted(set(policy["detected_classes"])) and all(item in PROTECTED_CLASSES for item in policy["detected_classes"])):
204
+ return False
205
+ artifact = payload["artifact"]
206
+ return artifact is None or (exact_object(artifact, {"artifact_id", "handle", "stored_bytes", "stored_lines", "exact_scope", "retrieve"}) and isinstance(artifact.get("artifact_id"), str) and bool(ARTIFACT_ID_RE.fullmatch(artifact["artifact_id"])) and artifact.get("handle") == "contextguard-artifact:" + artifact["artifact_id"] and nonnegative_int(artifact.get("stored_bytes")) and nonnegative_int(artifact.get("stored_lines")) and artifact.get("exact_scope") == "sanitized_accepted_input" and exact_object(artifact.get("retrieve"), {"tool", "arguments"}) and artifact["retrieve"].get("tool") == "context_guard_retrieve" and exact_object(artifact["retrieve"].get("arguments"), {"artifact_id"}) and artifact["retrieve"]["arguments"].get("artifact_id") == artifact["artifact_id"])
207
+ if version == "contextguard.mcp.retrieve.v1":
208
+ if not exact_object(payload, {"schema_version", "artifact_id", "content", "content_capped", "returned_bytes", "query", "stored"}):
209
+ return False
210
+ query = payload["query"]
211
+ stored = payload["stored"]
212
+ query_keys = {"type", "returned_lines", "matched_lines", "total_lines"}
213
+ if isinstance(query, dict) and query.get("type") == "lines":
214
+ query_keys |= {"start", "end"}
215
+ return (isinstance(payload["artifact_id"], str) and bool(ARTIFACT_ID_RE.fullmatch(payload["artifact_id"])) and isinstance(payload["content"], str) and isinstance(payload["content_capped"], bool) and nonnegative_int(payload["returned_bytes"]) and payload["returned_bytes"] == len(payload["content"].encode("utf-8")) and exact_object(query, query_keys) and query.get("type") in ("head", "lines", "pattern") and all(nonnegative_int(query[key]) for key in {"returned_lines", "matched_lines", "total_lines"}) and (query.get("type") != "lines" or nonnegative_int(query.get("start")) and nonnegative_int(query.get("end"))) and exact_object(stored, {"bytes", "lines"}) and nonnegative_int(stored.get("bytes")) and nonnegative_int(stored.get("lines")))
216
+ if version == "contextguard.mcp.stats.v1":
217
+ if not exact_object(payload, {"schema_version", "namespace", "session", "storage"}):
218
+ return False
219
+ namespace, session, storage = payload["namespace"], payload["session"], payload["storage"]
220
+ return (exact_object(namespace, {"fingerprint", "scope", "isolation"}) and isinstance(namespace.get("fingerprint"), str) and bool(re.fullmatch(r"[a-f0-9]{16}", namespace["fingerprint"])) and namespace.get("scope") == "session" and namespace.get("isolation") == "single_root_single_namespace" and exact_object(session, {"tool_calls", "tool_errors", "protocol_errors", "accepted_input_bytes", "compressed_bytes", "retrieved_bytes", "redacted_lines"}) and all(exact_object(session[key], set(TOOL_NAMES)) and all(nonnegative_int(item) for item in session[key].values()) for key in ("tool_calls", "tool_errors")) and all(nonnegative_int(session[key]) for key in {"protocol_errors", "accepted_input_bytes", "compressed_bytes", "retrieved_bytes", "redacted_lines"}) and exact_object(storage, {"artifacts_observed", "stored_bytes_observed", "visited_entries", "artifact_cap", "visited_entry_cap", "ambiguous", "artifact_cap_reached", "scan_capped"}) and all(nonnegative_int(storage[key]) for key in {"artifacts_observed", "stored_bytes_observed", "visited_entries", "artifact_cap", "visited_entry_cap"}) and isinstance(storage.get("ambiguous"), bool) and isinstance(storage.get("artifact_cap_reached"), bool) and isinstance(storage.get("scan_capped"), bool))
221
+ return False
222
+
223
+
224
+ def safe_regular(path: Path, *, executable: bool = False) -> Path:
225
+ st = os.lstat(path)
226
+ if not stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
227
+ raise ValueError("invalid trusted helper layout")
228
+ if executable and not st.st_mode & stat.S_IXUSR:
229
+ raise ValueError("invalid trusted helper layout")
230
+ return path.resolve(strict=True)
231
+
232
+
233
+ def directory_flags() -> int:
234
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
235
+ return flags | getattr(os, "O_CLOEXEC", 0)
236
+
237
+
238
+ def regular_read_flags() -> int:
239
+ return os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
240
+
241
+
242
+ class Server:
243
+ def __init__(self, root: Path, namespace: str) -> None:
244
+ if not NAMESPACE_RE.fullmatch(namespace):
245
+ raise ValueError("invalid namespace")
246
+ if root.is_symlink() or not root.is_dir():
247
+ raise ValueError("invalid root")
248
+ self.root = root.resolve(strict=True)
249
+ self.root_fd = os.open(self.root, directory_flags())
250
+ if not stat.S_ISDIR(os.fstat(self.root_fd).st_mode):
251
+ os.close(self.root_fd)
252
+ raise ValueError("invalid root")
253
+ self.namespace_fd = -1
254
+ self.lock_fd = -1
255
+ self.server_dir = Path(__file__).resolve().parent
256
+ try:
257
+ self.compress_helper, self.artifact_helper = self._trusted_layout()
258
+ self.namespace_dir = self._namespace_dir(namespace)
259
+ self.lock_fd = self._lock_namespace()
260
+ except BaseException:
261
+ self.close()
262
+ raise
263
+ self.secret = os.urandom(32)
264
+ self.state = "PRE_INIT"
265
+ self.calls = {name: 0 for name in TOOL_NAMES}
266
+ self.errors = {name: 0 for name in TOOL_NAMES}
267
+ self.protocol_errors = 0
268
+ self.accepted_input_bytes = 0
269
+ self.compressed_bytes = 0
270
+ self.retrieved_bytes = 0
271
+ self.redacted_lines = 0
272
+ self.attempts = 0
273
+
274
+ def _trusted_layout(self) -> tuple[Path, Path]:
275
+ here = self.server_dir
276
+ current = Path(__file__).name
277
+ source = current == "context_guard_mcp.py"
278
+ expected = (
279
+ ("context_guard_mcp.py", "context_compress.py", "context_escrow.py", "sanitize_output.py")
280
+ if source else
281
+ ("context-guard-mcp", "context-guard-compress", "context-guard-artifact", "context-guard-sanitize-output")
282
+ )
283
+ if current != expected[0]:
284
+ raise ValueError("invalid trusted helper layout")
285
+ alternate = (
286
+ ("context-guard-mcp", "context-guard-compress", "context-guard-artifact", "context-guard-sanitize-output")
287
+ if source else
288
+ ("context_guard_mcp.py", "context_compress.py", "context_escrow.py", "sanitize_output.py")
289
+ )
290
+ if any((here / name).exists() or (here / name).is_symlink() for name in alternate):
291
+ raise ValueError("invalid trusted helper layout")
292
+ resolved = [safe_regular(here / name, executable=not source) for name in expected]
293
+ if any(item.parent != here for item in resolved):
294
+ raise ValueError("invalid trusted helper layout")
295
+ return resolved[1], resolved[2]
296
+
297
+ def _open_private_child_dir(self, parent_fd: int, name: str) -> int:
298
+ """Create/open one storage component without ever following its name."""
299
+ try:
300
+ os.mkdir(name, 0o700, dir_fd=parent_fd)
301
+ except FileExistsError:
302
+ pass
303
+ fd = os.open(name, directory_flags(), dir_fd=parent_fd)
304
+ try:
305
+ st = os.fstat(fd)
306
+ if not stat.S_ISDIR(st.st_mode):
307
+ raise ValueError("invalid namespace storage")
308
+ os.fchmod(fd, stat.S_IMODE(st.st_mode) & 0o700)
309
+ return fd
310
+ except BaseException:
311
+ os.close(fd)
312
+ raise
313
+
314
+ def _namespace_dir(self, namespace: str) -> Path:
315
+ digest = hashlib.sha256(
316
+ b"contextguard.mcp.namespace.v1\0" + str(self.root).encode("utf-8") + b"\0" + namespace.encode("utf-8")
317
+ ).hexdigest()[:24]
318
+ parent_fd = self.root_fd
319
+ opened: list[int] = []
320
+ try:
321
+ for component in (".context-guard", "mcp", "ns-" + digest):
322
+ child = self._open_private_child_dir(parent_fd, component)
323
+ opened.append(child)
324
+ parent_fd = child
325
+ self.namespace_fd = opened[-1]
326
+ for fd in opened[:-1]:
327
+ os.close(fd)
328
+ return self.root / ".context-guard" / "mcp" / ("ns-" + digest)
329
+ except BaseException:
330
+ for fd in opened:
331
+ try:
332
+ os.close(fd)
333
+ except OSError:
334
+ pass
335
+ raise
336
+
337
+ def _lock_namespace(self) -> int:
338
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
339
+ fd = os.open(".context-guard-mcp.lock", flags, 0o600, dir_fd=self.namespace_fd)
340
+ try:
341
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
342
+ raise OSError()
343
+ os.fchmod(fd, 0o600)
344
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
345
+ return fd
346
+ except OSError:
347
+ os.close(fd)
348
+ raise ValueError("namespace is already in use")
349
+
350
+ def close(self) -> None:
351
+ if self.lock_fd >= 0:
352
+ try:
353
+ fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
354
+ except OSError:
355
+ pass
356
+ try:
357
+ os.close(self.lock_fd)
358
+ except OSError:
359
+ pass
360
+ self.lock_fd = -1
361
+ if self.namespace_fd >= 0:
362
+ try:
363
+ os.close(self.namespace_fd)
364
+ except OSError:
365
+ pass
366
+ self.namespace_fd = -1
367
+ if getattr(self, "root_fd", -1) >= 0:
368
+ try:
369
+ os.close(self.root_fd)
370
+ except OSError:
371
+ pass
372
+ self.root_fd = -1
373
+
374
+ def fingerprint(self) -> str:
375
+ return hmac.new(self.secret, self.namespace_dir.name.encode("ascii"), hashlib.sha256).hexdigest()[:16]
376
+
377
+ def run_helper(self, argv: list[str], stdin: bytes, stdout_cap: int) -> dict[str, object] | None:
378
+ env = {"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1", "LANG": "C", "LC_ALL": "C"}
379
+ deadline = time.monotonic() + HELPER_TIMEOUT
380
+ try:
381
+ proc = subprocess.Popen(
382
+ [sys.executable, *map(str, argv)], cwd=self.root, env=env, stdin=subprocess.PIPE,
383
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, close_fds=True, start_new_session=True,
384
+ )
385
+ except OSError:
386
+ return None
387
+ output = {"stdout": bytearray(), "stderr": bytearray()}
388
+ overflow = threading.Event()
389
+ writer_done = threading.Event()
390
+ writer_failed = threading.Event()
391
+
392
+ def drain(name: str, stream: Any, cap: int) -> None:
393
+ try:
394
+ while True:
395
+ part = stream.read(65536)
396
+ if not part:
397
+ return
398
+ if len(output[name]) + len(part) > cap:
399
+ overflow.set()
400
+ # Continue draining after the bounded capture is full so a
401
+ # malicious child cannot keep a pipe or a grandchild alive.
402
+ elif not overflow.is_set():
403
+ output[name].extend(part)
404
+ finally:
405
+ try:
406
+ stream.close()
407
+ except OSError:
408
+ pass
409
+
410
+ def write_stdin() -> None:
411
+ try:
412
+ assert proc.stdin is not None
413
+ view = memoryview(stdin)
414
+ while view:
415
+ written = proc.stdin.write(view)
416
+ if written is None:
417
+ written = 0
418
+ view = view[written:]
419
+ proc.stdin.flush()
420
+ except (BrokenPipeError, OSError, ValueError):
421
+ writer_failed.set()
422
+ finally:
423
+ try:
424
+ if proc.stdin is not None:
425
+ proc.stdin.close()
426
+ except OSError:
427
+ pass
428
+ writer_done.set()
429
+
430
+ threads = [
431
+ threading.Thread(target=drain, args=("stdout", proc.stdout, stdout_cap), daemon=True),
432
+ threading.Thread(target=drain, args=("stderr", proc.stderr, MAX_HELPER_STDERR), daemon=True),
433
+ ]
434
+ for thread in threads:
435
+ thread.start()
436
+ writer = threading.Thread(target=write_stdin)
437
+ writer.start()
438
+ failed = False
439
+ termination_started = False
440
+
441
+ def terminate_group() -> None:
442
+ nonlocal termination_started
443
+ if termination_started:
444
+ return
445
+ termination_started = True
446
+ pgid = proc.pid
447
+ try:
448
+ os.killpg(pgid, signal.SIGTERM)
449
+ except OSError:
450
+ try:
451
+ proc.terminate()
452
+ except OSError:
453
+ pass
454
+
455
+ # The direct helper may exit while a forked descendant keeps the
456
+ # process group and inherited pipes alive. Give the whole group a
457
+ # bounded TERM grace period, then escalate the group independently
458
+ # of the direct child's return code.
459
+ grace_deadline = time.monotonic() + 0.5
460
+ group_exists = True
461
+ while time.monotonic() < grace_deadline:
462
+ try:
463
+ os.killpg(pgid, 0)
464
+ except ProcessLookupError:
465
+ group_exists = False
466
+ break
467
+ except OSError:
468
+ break
469
+ time.sleep(min(0.01, max(0.0, grace_deadline - time.monotonic())))
470
+ if group_exists:
471
+ try:
472
+ os.killpg(pgid, signal.SIGKILL)
473
+ except ProcessLookupError:
474
+ pass
475
+ except OSError:
476
+ try:
477
+ proc.kill()
478
+ except OSError:
479
+ pass
480
+ try:
481
+ proc.wait(timeout=0.5)
482
+ except subprocess.TimeoutExpired:
483
+ try:
484
+ os.killpg(pgid, signal.SIGKILL)
485
+ except OSError:
486
+ try:
487
+ proc.kill()
488
+ except OSError:
489
+ pass
490
+
491
+ while proc.poll() is None or not writer_done.is_set():
492
+ if overflow.is_set() or writer_failed.is_set() or time.monotonic() >= deadline:
493
+ failed = True
494
+ terminate_group()
495
+ break
496
+ time.sleep(0.01)
497
+ try:
498
+ proc.wait(timeout=0.5)
499
+ except subprocess.TimeoutExpired:
500
+ failed = True
501
+ terminate_group()
502
+ if not writer_done.is_set():
503
+ failed = True
504
+ terminate_group()
505
+ try:
506
+ if proc.stdin is not None:
507
+ proc.stdin.close()
508
+ except OSError:
509
+ pass
510
+ # All pipe workers must finish: never leave daemon pipe threads or a
511
+ # process group behind after a timed-out/non-reading helper.
512
+ for thread in (*threads, writer):
513
+ thread.join(1.0)
514
+ if any(thread.is_alive() for thread in (*threads, writer)):
515
+ failed = True
516
+ terminate_group()
517
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
518
+ try:
519
+ if stream is not None:
520
+ stream.close()
521
+ except OSError:
522
+ pass
523
+ for thread in (*threads, writer):
524
+ thread.join(1.0)
525
+ if failed or overflow.is_set() or writer_failed.is_set() or any(thread.is_alive() for thread in (*threads, writer)) or proc.returncode != 0:
526
+ return None
527
+ try:
528
+ raw = bytes(output["stdout"]).decode("utf-8")
529
+ parsed = json.loads(raw, object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
530
+ if not isinstance(parsed, dict) or not is_safe_scalar(parsed):
531
+ return None
532
+ return parsed
533
+ except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
534
+ return None
535
+
536
+ def scan(self) -> dict[str, object]:
537
+ observed = 0
538
+ bytes_observed = 0
539
+ visited = 0
540
+ ambiguous = False
541
+ capped = False
542
+ names: dict[str, set[str]] = {}
543
+ try:
544
+ # scandir owns only the duplicate descriptor; the retained namespace
545
+ # descriptor remains the canonical authority for every later open.
546
+ with os.scandir(os.dup(self.namespace_fd)) as entries:
547
+ for entry in entries:
548
+ if entry.name == ".context-guard-mcp.lock":
549
+ continue
550
+ if visited >= MAX_VISITED_ENTRIES:
551
+ capped = True
552
+ ambiguous = True
553
+ break
554
+ visited += 1
555
+ match = re.fullmatch(r"([a-f0-9]{20})\.(txt|json)", entry.name)
556
+ if match is None:
557
+ ambiguous = True
558
+ continue
559
+ try:
560
+ st = os.stat(entry.name, dir_fd=self.namespace_fd, follow_symlinks=False)
561
+ if not stat.S_ISREG(st.st_mode):
562
+ ambiguous = True
563
+ continue
564
+ except OSError:
565
+ ambiguous = True
566
+ continue
567
+ names.setdefault(match.group(1), set()).add(match.group(2))
568
+ except OSError:
569
+ return {"observed": 0, "bytes": 0, "visited": 0, "ambiguous": True, "capped": False}
570
+ for artifact_id, pair in names.items():
571
+ if set(pair) != {"txt", "json"}:
572
+ ambiguous = True
573
+ continue
574
+ metadata_fd = -1
575
+ try:
576
+ txt_st = os.stat(artifact_id + ".txt", dir_fd=self.namespace_fd, follow_symlinks=False)
577
+ if not stat.S_ISREG(txt_st.st_mode):
578
+ raise ValueError()
579
+ metadata_fd = os.open(artifact_id + ".json", regular_read_flags(), dir_fd=self.namespace_fd)
580
+ st = os.fstat(metadata_fd)
581
+ if not stat.S_ISREG(st.st_mode) or st.st_size > 65536:
582
+ raise ValueError()
583
+ chunks: list[bytes] = []
584
+ remaining = st.st_size + 1
585
+ while remaining:
586
+ part = os.read(metadata_fd, min(65536, remaining))
587
+ if not part:
588
+ break
589
+ chunks.append(part)
590
+ remaining -= len(part)
591
+ os.close(metadata_fd)
592
+ metadata_fd = -1
593
+ raw_meta = b"".join(chunks)
594
+ if len(raw_meta) != st.st_size:
595
+ raise ValueError()
596
+ metadata = json.loads(raw_meta.decode("utf-8"), object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
597
+ stored = metadata.get("stored_output") if isinstance(metadata, dict) else None
598
+ valid = isinstance(stored, dict) and metadata.get("artifact_id") == artifact_id and stored.get("content_file") == artifact_id + ".txt" and stored.get("metadata_file") == artifact_id + ".json" and isinstance(stored.get("bytes"), int) and not isinstance(stored.get("bytes"), bool) and 0 <= stored["bytes"] <= MAX_CONTENT_BYTES and txt_st.st_size == stored["bytes"] and isinstance(stored.get("lines"), int) and not isinstance(stored.get("lines"), bool) and stored["lines"] >= 0 and isinstance(stored.get("sha256"), str) and bool(re.fullmatch(r"[a-f0-9]{64}", stored["sha256"]))
599
+ if not valid:
600
+ raise ValueError()
601
+ if observed < MAX_ARTIFACTS:
602
+ observed += 1
603
+ bytes_observed += stored["bytes"]
604
+ except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError, DuplicateKey, NonFinite):
605
+ ambiguous = True
606
+ finally:
607
+ if metadata_fd >= 0:
608
+ try:
609
+ os.close(metadata_fd)
610
+ except OSError:
611
+ pass
612
+ return {"observed": min(observed, MAX_ARTIFACTS), "bytes": bytes_observed, "visited": visited, "ambiguous": ambiguous, "capped": capped}
613
+
614
+ def result(self, tool: str, payload: dict[str, object], is_error: bool = False) -> dict[str, object]:
615
+ if not valid_tool_payload(payload, is_error) or self._payload_leaks_private_value(payload):
616
+ is_error = True
617
+ payload = tool_error("response_too_large")
618
+ if is_error:
619
+ self.errors[tool] += 1
620
+ result = call_result(payload, is_error)
621
+ if len(compact(result).encode("utf-8")) > MAX_RESPONSE_BYTES:
622
+ if not is_error:
623
+ self.errors[tool] += 1
624
+ return call_result(tool_error("response_too_large"), True)
625
+ return result
626
+
627
+ def _payload_leaks_private_value(self, value: object, *, content: bool = False) -> bool:
628
+ """Reject internal values structurally; do not inspect sanctioned content."""
629
+ private = {
630
+ str(self.root), str(self.namespace_dir), str(self.compress_helper),
631
+ str(self.artifact_helper), self.namespace_dir.name,
632
+ }
633
+ if isinstance(value, dict):
634
+ return any(self._payload_leaks_private_value(item, content=content or key == "content") for key, item in value.items())
635
+ if isinstance(value, list):
636
+ return any(self._payload_leaks_private_value(item, content=content) for item in value)
637
+ if isinstance(value, str) and not content:
638
+ # Exact comparison protects short values; only long internal tokens
639
+ # receive containment checking, avoiding false positives for a one-byte
640
+ # namespace such as "A" in normal protocol strings.
641
+ return any(value == item or len(item) >= 8 and item in value for item in private)
642
+ return False
643
+
644
+ def fail(self, tool: str, code: str) -> dict[str, object]:
645
+ return self.result(tool, tool_error(code), True)
646
+
647
+ def compress(self, arguments: object) -> dict[str, object]:
648
+ tool = "context_guard_compress"
649
+ if not isinstance(arguments, dict) or set(arguments) - {"content", "content_type", "mode", "protected_policy", "store"} or not isinstance(arguments.get("content"), str):
650
+ return self.fail(tool, "invalid_arguments")
651
+ content = arguments["content"]
652
+ if not is_safe_scalar(content):
653
+ return self.fail(tool, "invalid_arguments")
654
+ content_type = arguments.get("content_type")
655
+ mode = arguments.get("mode", "conservative")
656
+ protected = arguments.get("protected_policy", True)
657
+ store = arguments.get("store", True)
658
+ if content_type is not None and content_type not in CONTENT_TYPES or mode not in MODES or not isinstance(protected, bool) or not isinstance(store, bool):
659
+ return self.fail(tool, "invalid_arguments")
660
+ raw = content.encode("utf-8")
661
+ if len(raw) > MAX_CONTENT_BYTES:
662
+ return self.fail(tool, "input_too_large")
663
+ self.accepted_input_bytes += len(raw)
664
+ if store:
665
+ scan = self.scan()
666
+ if scan["ambiguous"]:
667
+ return self.fail(tool, "namespace_ambiguous")
668
+ if scan["observed"] >= MAX_ARTIFACTS:
669
+ return self.fail(tool, "namespace_full")
670
+ argv: list[str] = [str(self.compress_helper), "--json", "--max-bytes", str(MAX_CONTENT_BYTES), "--mode", str(mode)]
671
+ if content_type is not None:
672
+ argv.extend(["--type", str(content_type)])
673
+ if protected:
674
+ argv.append("--protected-policy")
675
+ compressed = self.run_helper(argv, raw, min(MAX_HELPER_OUTPUT, len(raw) * 6 + 256 * 1024))
676
+ if compressed is None:
677
+ return self.fail(tool, "helper_failed")
678
+ text = compressed.get("content")
679
+ metadata = compressed.get("metadata")
680
+ if not isinstance(text, str) or not isinstance(metadata, dict):
681
+ return self.fail(tool, "helper_failed")
682
+ bytes_meta = metadata.get("bytes")
683
+ lines_meta = metadata.get("lines")
684
+ token_meta = metadata.get("token_proxy")
685
+ redaction = metadata.get("redaction")
686
+ policy = metadata.get("protected_zone_policy")
687
+ strategy = metadata.get("strategy")
688
+ selected_type = metadata.get("content_type")
689
+ type_source = metadata.get("type_source")
690
+ if not (isinstance(bytes_meta, dict) and isinstance(lines_meta, dict) and isinstance(token_meta, dict) and isinstance(redaction, dict) and isinstance(policy, dict) and selected_type in CONTENT_TYPES and type_source in ("detected", "override") and strategy in {"json-compact", "diff-keep-changes", "log-collapse-repeats", "search-dedupe", "code-whitespace", "prose-whitespace", "prose-readable-window"} and isinstance(metadata.get("lossy"), bool)):
691
+ return self.fail(tool, "helper_failed")
692
+ def integer(source: dict[str, object], key: str) -> int | None:
693
+ value = source.get(key)
694
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
695
+ original_b, compressed_b = integer(bytes_meta, "original"), integer(bytes_meta, "compressed")
696
+ original_l, compressed_l = integer(lines_meta, "original"), integer(lines_meta, "compressed")
697
+ original_t, compressed_t = integer(token_meta, "original"), integer(token_meta, "compressed")
698
+ redacted = integer(redaction, "redacted_lines")
699
+ if None in (original_b, compressed_b, original_l, compressed_l, original_t, compressed_t, redacted):
700
+ return self.fail(tool, "helper_failed")
701
+ self.compressed_bytes += compressed_b
702
+ self.redacted_lines += redacted
703
+ preview, capped = cap_utf8(text, MAX_RETURN_BYTES)
704
+ artifact: object = None
705
+ if store:
706
+ stored = self.run_helper([str(self.artifact_helper), "--dir", str(self.namespace_dir), "store", "--json", "--max-bytes", str(MAX_CONTENT_BYTES), "--command", "context-guard-mcp compress"], raw, 256 * 1024)
707
+ if stored is None:
708
+ return self.fail(tool, "helper_failed")
709
+ artifact_id = stored.get("artifact_id")
710
+ stored_output = stored.get("stored_output")
711
+ if not isinstance(artifact_id, str) or not ARTIFACT_ID_RE.fullmatch(artifact_id) or not isinstance(stored_output, dict):
712
+ return self.fail(tool, "helper_failed")
713
+ stored_bytes, stored_lines = integer(stored_output, "bytes"), integer(stored_output, "lines")
714
+ if stored_bytes is None or stored_lines is None:
715
+ return self.fail(tool, "helper_failed")
716
+ artifact = {"artifact_id": artifact_id, "handle": "contextguard-artifact:" + artifact_id, "stored_bytes": stored_bytes, "stored_lines": stored_lines, "exact_scope": "sanitized_accepted_input", "retrieve": {"tool": "context_guard_retrieve", "arguments": {"artifact_id": artifact_id}}}
717
+ elif capped:
718
+ return self.fail(tool, "result_too_large_without_fallback")
719
+ zone_counts = policy.get("zone_counts") if protected else {}
720
+ classes = sorted(key for key in PROTECTED_CLASSES if isinstance(zone_counts, dict) and isinstance(zone_counts.get(key), int) and zone_counts[key] > 0)
721
+ payload = {"schema_version": "contextguard.mcp.compress.v1", "content": preview, "content_capped": capped, "compression": {"content_type": selected_type, "type_source": type_source, "strategy": strategy, "lossy": metadata["lossy"], "bytes": {"measurement": "observed", "original": original_b, "compressed": compressed_b, "returned": len(preview.encode("utf-8"))}, "lines": {"measurement": "observed", "original": original_l, "compressed": compressed_l, "returned": line_count(preview)}, "token_proxy": {"measurement": "estimated", "method": "chars_div_4", "original": original_t, "compressed": compressed_t}, "redaction": {"redacted_lines": redacted, "redacted_before_receipt": True}, "protected_policy": {"enabled": protected, "retrieval_required": bool(policy.get("retrieval_required")) if protected else False, "detected_classes": classes}}, "artifact": artifact}
722
+ return self.result(tool, payload)
723
+
724
+ def retrieve(self, arguments: object) -> dict[str, object]:
725
+ tool = "context_guard_retrieve"
726
+ if not isinstance(arguments, dict) or set(arguments) - {"artifact_id", "lines", "pattern", "max_lines", "max_chars"}:
727
+ return self.fail(tool, "invalid_arguments")
728
+ artifact_id = arguments.get("artifact_id")
729
+ lines = arguments.get("lines")
730
+ pattern = arguments.get("pattern")
731
+ max_lines = arguments.get("max_lines", MAX_RETRIEVE_LINES)
732
+ max_chars = arguments.get("max_chars", MAX_RETRIEVE_CHARS)
733
+ if not isinstance(artifact_id, str) or not ARTIFACT_ID_RE.fullmatch(artifact_id) or lines is not None and not isinstance(lines, str) or pattern is not None and not isinstance(pattern, str) or lines is not None and pattern is not None or not isinstance(max_lines, int) or isinstance(max_lines, bool) or not 1 <= max_lines <= MAX_RETRIEVE_LINES or not isinstance(max_chars, int) or isinstance(max_chars, bool) or not 1 <= max_chars <= MAX_RETRIEVE_CHARS:
734
+ return self.fail(tool, "invalid_arguments")
735
+ if lines is not None:
736
+ if not LINES_RE.fullmatch(lines):
737
+ return self.fail(tool, "invalid_arguments")
738
+ parts = [int(item) for item in lines.split(":")]
739
+ if len(parts) == 1:
740
+ parts.append(parts[0])
741
+ if parts[0] > parts[1] or parts[1] > 10_000_000:
742
+ return self.fail(tool, "invalid_arguments")
743
+ if pattern is not None and (not is_safe_scalar(pattern) or "\0" in pattern or not 1 <= len(pattern.encode("utf-8")) <= 512):
744
+ return self.fail(tool, "invalid_arguments")
745
+ argv = [str(self.artifact_helper), "--dir", str(self.namespace_dir), "get", artifact_id, "--json", "--max-lines", str(max_lines), "--max-chars", str(max_chars)]
746
+ if lines is not None:
747
+ argv.extend(["--lines", lines])
748
+ elif pattern is not None:
749
+ argv.extend(["--pattern", pattern])
750
+ found = self.run_helper(argv, b"", 128 * 1024)
751
+ if found is None:
752
+ # The helper intentionally keeps error detail on stderr. It is not
753
+ # safe to classify it more finely, so verify pair shape locally.
754
+ present = []
755
+ malformed = False
756
+ for name in (artifact_id + ".txt", artifact_id + ".json"):
757
+ try:
758
+ st = os.stat(name, dir_fd=self.namespace_fd, follow_symlinks=False)
759
+ present.append(True)
760
+ malformed = malformed or not stat.S_ISREG(st.st_mode)
761
+ except FileNotFoundError:
762
+ present.append(False)
763
+ except OSError:
764
+ return self.fail(tool, "artifact_invalid")
765
+ if not any(present):
766
+ return self.fail(tool, "artifact_not_found")
767
+ return self.fail(tool, "artifact_invalid")
768
+ text, query, stored = found.get("content"), found.get("query"), found.get("stored_output")
769
+ if not isinstance(text, str) or not isinstance(query, dict) or not isinstance(stored, dict):
770
+ return self.fail(tool, "artifact_invalid")
771
+ selector = query.get("selector")
772
+ if not isinstance(selector, dict) or selector.get("type") not in ("head", "lines", "pattern"):
773
+ return self.fail(tool, "artifact_invalid")
774
+ returned_lines, matched_lines, total_lines = query.get("returned_lines"), query.get("matched_lines"), query.get("total_lines")
775
+ stored_bytes, stored_lines = stored.get("bytes"), stored.get("lines")
776
+ values = (returned_lines, matched_lines, total_lines, stored_bytes, stored_lines)
777
+ if not all(isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in values):
778
+ return self.fail(tool, "artifact_invalid")
779
+ text, chars_capped = cap_codepoints(text, max_chars)
780
+ text, bytes_capped = cap_utf8(text, MAX_RETURN_BYTES)
781
+ capped = chars_capped or bytes_capped
782
+ query_out: dict[str, object] = {"type": selector["type"], "returned_lines": returned_lines, "matched_lines": matched_lines, "total_lines": total_lines}
783
+ if selector["type"] == "lines":
784
+ start, end = selector.get("start"), selector.get("end")
785
+ if not isinstance(start, int) or not isinstance(end, int):
786
+ return self.fail(tool, "artifact_invalid")
787
+ query_out.update({"start": start, "end": end})
788
+ self.retrieved_bytes += len(text.encode("utf-8"))
789
+ return self.result(tool, {"schema_version": "contextguard.mcp.retrieve.v1", "artifact_id": artifact_id, "content": text, "content_capped": bool(found.get("capped")) or capped, "returned_bytes": len(text.encode("utf-8")), "query": query_out, "stored": {"bytes": stored_bytes, "lines": stored_lines}})
790
+
791
+ def stats(self, arguments: object) -> dict[str, object]:
792
+ tool = "context_guard_stats"
793
+ if not isinstance(arguments, dict) or arguments:
794
+ return self.fail(tool, "invalid_arguments")
795
+ scan = self.scan()
796
+ storage = {"artifacts_observed": scan["observed"], "stored_bytes_observed": scan["bytes"], "visited_entries": scan["visited"], "artifact_cap": MAX_ARTIFACTS, "visited_entry_cap": MAX_VISITED_ENTRIES, "ambiguous": scan["ambiguous"], "artifact_cap_reached": scan["observed"] >= MAX_ARTIFACTS, "scan_capped": scan["capped"]}
797
+ payload = {"schema_version": "contextguard.mcp.stats.v1", "namespace": {"fingerprint": self.fingerprint(), "scope": "session", "isolation": "single_root_single_namespace"}, "session": {"tool_calls": dict(self.calls), "tool_errors": dict(self.errors), "protocol_errors": self.protocol_errors, "accepted_input_bytes": self.accepted_input_bytes, "compressed_bytes": self.compressed_bytes, "retrieved_bytes": self.retrieved_bytes, "redacted_lines": self.redacted_lines}, "storage": storage}
798
+ return self.result(tool, payload)
799
+
800
+
801
+ TOOL_NAMES = ("context_guard_compress", "context_guard_retrieve", "context_guard_stats")
802
+
803
+
804
+ def tools() -> list[dict[str, object]]:
805
+ base = {"type": "object", "additionalProperties": False}
806
+ compress_schema = dict(base, properties={"content": {"type": "string", "maxLength": MAX_CONTENT_BYTES, "description": "Content to sanitize and compress; UTF-8 bytes are capped at 786432."}, "content_type": {"type": "string", "enum": list(CONTENT_TYPES), "description": "Optional content classification override."}, "mode": {"type": "string", "enum": list(MODES), "default": "conservative", "description": "Compression mode; conservative preserves more structure."}, "protected_policy": {"type": "boolean", "default": True, "description": "Preserve detected protected zones and require retrieval when needed."}, "store": {"type": "boolean", "default": True, "description": "Store sanitized accepted input for namespace-scoped retrieval."}}, required=["content"])
807
+ retrieve_schema = dict(base, properties={"artifact_id": {"type": "string", "pattern": "^[a-f0-9]{20}$", "description": "Namespace-scoped sanitized artifact identifier."}, "lines": {"type": "string", "pattern": "^[1-9][0-9]{0,6}(?::[1-9][0-9]{0,6})?$", "description": "Optional inclusive one-based line or line range selector."}, "pattern": {"type": "string", "minLength": 1, "maxLength": 512, "description": "Optional literal UTF-8 pattern selector."}, "max_lines": {"type": "integer", "minimum": 1, "maximum": MAX_RETRIEVE_LINES, "default": MAX_RETRIEVE_LINES, "description": "Maximum returned lines, limited to 500."}, "max_chars": {"type": "integer", "minimum": 1, "maximum": MAX_RETRIEVE_CHARS, "default": MAX_RETRIEVE_CHARS, "description": "Maximum returned Unicode code points, limited to 20000."}}, required=["artifact_id"], allOf=[{"not": {"required": ["lines", "pattern"]}}])
808
+ return [
809
+ {"name": "context_guard_compress", "description": "Sanitize, compress, and optionally retain local content.", "inputSchema": compress_schema, "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False}},
810
+ {"name": "context_guard_retrieve", "description": "Retrieve sanitized content from this local namespace.", "inputSchema": retrieve_schema, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}},
811
+ {"name": "context_guard_stats", "description": "Return local session and namespace statistics.", "inputSchema": base, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}},
812
+ ]
813
+
814
+
815
+ def valid_id(value: object) -> bool:
816
+ if value is None:
817
+ return True
818
+ if isinstance(value, str):
819
+ encoded = safe_utf8_bytes(value)
820
+ return encoded is not None and len(encoded) <= 128
821
+ return isinstance(value, int) and not isinstance(value, bool) and -(2**53 - 1) <= value <= 2**53 - 1
822
+
823
+
824
+ def valid_wire_response(value: object) -> bool:
825
+ """Final exact response schema check immediately before stdout writes."""
826
+ if not isinstance(value, dict) or not is_safe_scalar(value) or set(value) != {"jsonrpc", "id", "result"} and set(value) != {"jsonrpc", "id", "error"} or value.get("jsonrpc") != "2.0" or not valid_id(value.get("id")):
827
+ return False
828
+ if "error" in value:
829
+ error = value["error"]
830
+ return exact_object(error, {"code", "message"}) and isinstance(error.get("code"), int) and not isinstance(error.get("code"), bool) and isinstance(error.get("message"), str)
831
+ result = value["result"]
832
+ if result == {}:
833
+ return True
834
+ if exact_object(result, {"protocolVersion", "capabilities", "serverInfo"}):
835
+ return result.get("protocolVersion") in SUPPORTED_VERSIONS and result.get("capabilities") == {"tools": {"listChanged": False}} and result.get("serverInfo") == {"name": "context-guard-mcp", "version": "1.0.0"}
836
+ if exact_object(result, {"tools"}):
837
+ return result.get("tools") == tools()
838
+ if not exact_object(result, {"content", "structuredContent", "isError"}) or not isinstance(result.get("isError"), bool) or not isinstance(result.get("content"), list) or len(result["content"]) != 1:
839
+ return False
840
+ text = result["content"][0]
841
+ return exact_object(text, {"type", "text"}) and text.get("type") == "text" and isinstance(text.get("text"), str) and isinstance(result.get("structuredContent"), dict) and valid_tool_payload(result["structuredContent"], result["isError"]) and text["text"] == compact(result["structuredContent"])
842
+
843
+
844
+ def base_request(value: object) -> tuple[bool, bool, object]:
845
+ if not isinstance(value, dict) or set(value) - {"jsonrpc", "method", "params", "id"} or value.get("jsonrpc") != "2.0" or not isinstance(value.get("method"), str) or "params" in value and not isinstance(value["params"], dict) or "id" in value and not valid_id(value["id"]):
846
+ return False, False, None
847
+ return True, "id" not in value, value.get("id")
848
+
849
+
850
+ def params_only_meta(params: object) -> bool:
851
+ return params is None or isinstance(params, dict) and set(params) <= {"_meta"} and ("_meta" not in params or isinstance(params["_meta"], dict))
852
+
853
+
854
+ def initialize_params(params: object) -> bool:
855
+ if not isinstance(params, dict) or set(params) - {"protocolVersion", "capabilities", "clientInfo", "_meta"} or not isinstance(params.get("protocolVersion"), str) or not isinstance(params.get("capabilities"), dict) or not isinstance(params.get("clientInfo"), dict) or "_meta" in params and not isinstance(params["_meta"], dict):
856
+ return False
857
+ info = params["clientInfo"]
858
+ return set(info) <= {"name", "version", "title", "description", "websiteUrl", "icons"} and isinstance(info.get("name"), str) and isinstance(info.get("version"), str) and all(isinstance(info[key], str) for key in ("title", "description", "websiteUrl") if key in info) and ("icons" not in info or isinstance(info["icons"], list))
859
+
860
+
861
+ def handle(server: Server, request: dict[str, object], notification: bool) -> dict[str, object] | None:
862
+ method, params, request_id = request["method"], request.get("params"), request.get("id")
863
+ def response(result: dict[str, object]) -> dict[str, object] | None:
864
+ return None if notification else {"jsonrpc": "2.0", "id": request_id, "result": result}
865
+ def failure(code: int, message: str) -> dict[str, object] | None:
866
+ if code in (-32600, -32602, -32603):
867
+ server.protocol_errors += 1
868
+ return None if notification else error_response(request_id, code, message)
869
+ if method == "ping":
870
+ return response({}) if params_only_meta(params) else failure(-32602, "Invalid params")
871
+ if method == "initialize":
872
+ if server.state != "PRE_INIT":
873
+ return failure(-32600, "Already initialized")
874
+ if not initialize_params(params):
875
+ return failure(-32602, "Invalid params")
876
+ if notification:
877
+ return None
878
+ version = params["protocolVersion"]
879
+ negotiated = version if version in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0]
880
+ server.state = "WAIT_INITIALIZED"
881
+ return response({"protocolVersion": negotiated, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "context-guard-mcp", "version": "1.0.0"}})
882
+ if method == "notifications/initialized":
883
+ if notification and not params_only_meta(params):
884
+ return failure(-32602, "Invalid params")
885
+ if notification and server.state == "WAIT_INITIALIZED":
886
+ server.state = "READY"
887
+ if notification:
888
+ return None
889
+ if server.state in {"PRE_INIT", "WAIT_INITIALIZED"}:
890
+ return failure(-32002, "Server not initialized")
891
+ return failure(-32601, "Method not found")
892
+ if server.state == "PRE_INIT" or server.state == "WAIT_INITIALIZED":
893
+ return failure(-32002, "Server not initialized")
894
+ if method == "tools/list":
895
+ if not (params is None or isinstance(params, dict) and set(params) <= {"cursor", "_meta"} and (params.get("cursor") is None) and ("_meta" not in params or isinstance(params["_meta"], dict))):
896
+ return failure(-32602, "Invalid params")
897
+ return response({"tools": tools()})
898
+ if method == "tools/call":
899
+ if not isinstance(params, dict) or set(params) - {"name", "arguments", "_meta"} or not isinstance(params.get("name"), str) or "arguments" in params and not isinstance(params["arguments"], dict) or "_meta" in params and not isinstance(params["_meta"], dict):
900
+ return failure(-32602, "Invalid params")
901
+ name = params["name"]
902
+ if name not in TOOL_NAMES:
903
+ return failure(-32602, "Invalid params")
904
+ if notification:
905
+ return None
906
+ server.attempts += 1
907
+ server.calls[name] += 1
908
+ if server.attempts > MAX_CALLS:
909
+ return response(server.fail(name, "rate_limit_reached"))
910
+ if name == "context_guard_compress":
911
+ result = server.compress(params.get("arguments", {}))
912
+ elif name == "context_guard_retrieve":
913
+ result = server.retrieve(params.get("arguments", {}))
914
+ else:
915
+ result = server.stats(params.get("arguments", {}))
916
+ return response(result)
917
+ return failure(-32601, "Method not found")
918
+
919
+
920
+ def emit(value: dict[str, object]) -> bool:
921
+ try:
922
+ if not valid_wire_response(value):
923
+ return False
924
+ wire = compact(value)
925
+ if len(wire.encode("utf-8")) > MAX_RESPONSE_BYTES:
926
+ return False
927
+ sys.stdout.write(wire + "\n")
928
+ sys.stdout.flush()
929
+ return True
930
+ except (BrokenPipeError, UnicodeEncodeError):
931
+ return False
932
+
933
+
934
+ def serve(server: Server) -> int:
935
+ stream = sys.stdin.buffer
936
+ while True:
937
+ line = stream.readline(MAX_MESSAGE_BYTES + 3)
938
+ if not line:
939
+ return 0
940
+ if len(line) == MAX_MESSAGE_BYTES + 3:
941
+ emit(error_response(None, -32600, "Message too large"))
942
+ return 1
943
+ if not line.endswith(b"\n"):
944
+ emit(error_response(None, -32700, "Parse error"))
945
+ return 1
946
+ payload = line[:-1]
947
+ if payload.endswith(b"\r"):
948
+ payload = payload[:-1]
949
+ if len(payload) > MAX_MESSAGE_BYTES:
950
+ emit(error_response(None, -32600, "Message too large"))
951
+ return 1
952
+ try:
953
+ data = json.loads(payload.decode("utf-8"), object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
954
+ except (UnicodeDecodeError, json.JSONDecodeError):
955
+ server.protocol_errors += 1
956
+ emit(error_response(None, -32700, "Parse error"))
957
+ continue
958
+ except (DuplicateKey, NonFinite, RecursionError, ValueError):
959
+ server.protocol_errors += 1
960
+ emit(error_response(None, -32600, "Invalid Request"))
961
+ continue
962
+ valid, notification, _request_id = base_request(data)
963
+ if not valid or not is_safe_scalar(data):
964
+ server.protocol_errors += 1
965
+ emit(error_response(None, -32600, "Invalid Request"))
966
+ continue
967
+ try:
968
+ result = handle(server, data, notification)
969
+ except Exception:
970
+ # A valid request must not let an implementation defect escape as a
971
+ # traceback or local detail. Notifications deliberately stay
972
+ # silent even on this path.
973
+ server.protocol_errors += 1
974
+ result = None if notification else error_response(data.get("id"), -32603, "Internal error")
975
+ if result is not None and not emit(result):
976
+ return 1
977
+
978
+
979
+ def main(argv: list[str] | None = None) -> int:
980
+ parser = argparse.ArgumentParser(description="Run the local ContextGuard stdio MCP server.")
981
+ parser.add_argument("--root", type=Path, default=Path.cwd())
982
+ parser.add_argument("--namespace", default="default")
983
+ args = parser.parse_args(argv)
984
+ try:
985
+ server = Server(args.root, args.namespace)
986
+ except (OSError, ValueError, UnicodeError):
987
+ print("context-guard-mcp: startup failed", file=sys.stderr)
988
+ return 2
989
+ try:
990
+ return serve(server)
991
+ finally:
992
+ try:
993
+ server.close()
994
+ except OSError:
995
+ pass
996
+
997
+
998
+ if __name__ == "__main__":
999
+ raise SystemExit(main())