@heretek-ai/epistemic-swarm 0.2.2 → 0.3.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.
Files changed (138) hide show
  1. package/.agents/skills/brainstorming/SKILL.md +13 -0
  2. package/.agents/skills/code_audit/SKILL.md +13 -0
  3. package/.agents/skills/epistemic_search/SKILL.md +13 -0
  4. package/.agents/skills/grilling/SKILL.md +13 -0
  5. package/.agents/skills/oss_scout/SKILL.md +13 -0
  6. package/.agents/skills/research_cache/SKILL.md +13 -0
  7. package/.agents/skills/swarm_config/SKILL.md +13 -0
  8. package/.claude-plugin/plugin.json +15 -5
  9. package/.omp/README.md +39 -0
  10. package/.omp/SYSTEM.md +12 -0
  11. package/.omp/commands/audit.md +10 -0
  12. package/.omp/commands/brainstorming.md +12 -0
  13. package/.omp/commands/grill.md +9 -0
  14. package/.omp/commands/scout.md +10 -0
  15. package/.omp/commands/swarm-config.md +10 -0
  16. package/.omp/commands/swarm.md +9 -0
  17. package/.omp/hooks/post/epistemic-audit.ts +16 -0
  18. package/.omp/hooks/pre/epistemic-redirect.ts +21 -0
  19. package/.omp/prompts/brainstorming.md +8 -0
  20. package/.omp/prompts/swarm.md +8 -0
  21. package/MARKETPLACE.md +8 -0
  22. package/README.md +53 -8
  23. package/bin/cli.js +27 -3
  24. package/config/domain_packs/biopharma.json +23 -0
  25. package/config/domain_packs/legal.json +19 -0
  26. package/config/domain_packs/quant.json +19 -0
  27. package/config/mcp-research-servers.json +7 -0
  28. package/config/mcp_launcher.py +48 -137
  29. package/config/opencode-snippet.json +58 -3
  30. package/config/searxng_mcp.py +42 -83
  31. package/extensions/pi/index.js +196 -28
  32. package/install.sh +20 -4
  33. package/package.json +40 -5
  34. package/plugins/antigravity/README.md +28 -0
  35. package/plugins/antigravity/agents/alpha-thesis.md +6 -0
  36. package/plugins/antigravity/agents/beta-antithesis.md +7 -0
  37. package/plugins/antigravity/agents/brainstormer.md +7 -0
  38. package/plugins/antigravity/agents/epistemic-auditor.md +5 -0
  39. package/plugins/antigravity/hooks.json +23 -0
  40. package/plugins/antigravity/mcp_config.json +33 -0
  41. package/plugins/antigravity/plugin.json +21 -0
  42. package/plugins/antigravity/rules/epistemic-integrity.md +6 -0
  43. package/plugins/antigravity/skills/brainstorming/SKILL.md +13 -0
  44. package/plugins/antigravity/skills/code_audit/SKILL.md +13 -0
  45. package/plugins/antigravity/skills/epistemic_search/SKILL.md +13 -0
  46. package/plugins/antigravity/skills/grilling/SKILL.md +13 -0
  47. package/plugins/antigravity/skills/oss_scout/SKILL.md +13 -0
  48. package/plugins/antigravity/skills/research_cache/SKILL.md +13 -0
  49. package/plugins/antigravity/skills/swarm_config/SKILL.md +13 -0
  50. package/plugins/codex/AGENTS.md.snippet +10 -0
  51. package/plugins/codex/README.md +37 -0
  52. package/plugins/codex/config.toml.snippet +28 -0
  53. package/plugins/codex/openai.yaml +24 -0
  54. package/plugins/codex/skills/brainstorming/SKILL.md +13 -0
  55. package/plugins/codex/skills/code_audit/SKILL.md +13 -0
  56. package/plugins/codex/skills/epistemic_search/SKILL.md +13 -0
  57. package/plugins/codex/skills/grilling/SKILL.md +13 -0
  58. package/plugins/codex/skills/oss_scout/SKILL.md +13 -0
  59. package/plugins/codex/skills/research_cache/SKILL.md +13 -0
  60. package/plugins/codex/skills/swarm_config/SKILL.md +13 -0
  61. package/plugins/gemini/GEMINI.md +15 -0
  62. package/plugins/gemini/README.md +19 -0
  63. package/plugins/gemini/commands/audit.toml +6 -0
  64. package/plugins/gemini/commands/brainstorming.toml +10 -0
  65. package/plugins/gemini/commands/grill.toml +6 -0
  66. package/plugins/gemini/commands/scout.toml +7 -0
  67. package/plugins/gemini/commands/swarm-config.toml +7 -0
  68. package/plugins/gemini/commands/swarm.toml +8 -0
  69. package/plugins/gemini/gemini-extension.json +38 -0
  70. package/plugins/gemini/hooks/hooks.json +11 -0
  71. package/plugins/gemini/skills/brainstorming/SKILL.md +13 -0
  72. package/plugins/gemini/skills/code_audit/SKILL.md +13 -0
  73. package/plugins/gemini/skills/epistemic_search/SKILL.md +13 -0
  74. package/plugins/gemini/skills/grilling/SKILL.md +13 -0
  75. package/plugins/gemini/skills/oss_scout/SKILL.md +13 -0
  76. package/plugins/gemini/skills/research_cache/SKILL.md +13 -0
  77. package/plugins/gemini/skills/swarm_config/SKILL.md +13 -0
  78. package/plugins/opencode/index.js +335 -118
  79. package/prompts/agent_brainstormer.md +97 -0
  80. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  81. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  82. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  83. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  84. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  85. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  86. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  87. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  88. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  89. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  90. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  91. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  92. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  93. package/runner/auctioneer.py +169 -0
  94. package/runner/auditor_engine.py +127 -12
  95. package/runner/claim_store.py +361 -0
  96. package/runner/claim_witness.py +183 -0
  97. package/runner/living_dossiers.py +355 -0
  98. package/runner/mcp_protocol.py +188 -0
  99. package/runner/mcp_server.py +567 -0
  100. package/runner/pcrb.py +212 -0
  101. package/runner/pcrb_verify.py +237 -0
  102. package/runner/refinement.py +335 -0
  103. package/runner/research_swarm.py +378 -94
  104. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  105. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  106. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  107. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  108. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  109. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  110. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  111. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  112. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  113. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  114. package/runner/tests/fixtures/auction_objective.json +35 -0
  115. package/runner/tests/fixtures/borderline_claims.json +27 -0
  116. package/runner/tests/fixtures/divergence_objectives.json +31 -0
  117. package/runner/tests/test_auction_order.py +173 -0
  118. package/runner/tests/test_claim_store.py +162 -0
  119. package/runner/tests/test_claim_witness.py +186 -0
  120. package/runner/tests/test_domain_packs.py +217 -0
  121. package/runner/tests/test_fleet_seam.py +113 -0
  122. package/runner/tests/test_living_dossiers.py +204 -0
  123. package/runner/tests/test_mcp_server.py +212 -0
  124. package/runner/tests/test_pcrb.py +240 -0
  125. package/runner/tests/test_refinement.py +255 -0
  126. package/runner/tests/test_swarm.py +173 -15
  127. package/scripts/auction_experiment.py +180 -0
  128. package/scripts/build_adapters.py +183 -0
  129. package/scripts/divergence_experiment.py +184 -0
  130. package/skills/brainstorming/SKILL.md +106 -0
  131. package/skills/brainstorming/__init__.py +1 -0
  132. package/skills/brainstorming/scripts/brainstorm.py +200 -0
  133. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  134. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  135. package/skills/swarm_config/SKILL.md +1 -1
  136. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  137. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  138. package/skills/swarm_config/configure.py +45 -11
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Living Dossiers: claim degradation ledger (Stream C).
4
+
5
+ Epistemic core idea: a `[VERIFIED]` claim is a time-bounded LOAN against its
6
+ cached source, not a permanent fact. When a source is retracted or revised,
7
+ the loan is called — regardless of whether the local cache still contains a
8
+ matching quote.
9
+
10
+ Source-of-truth discipline (same as the rest of IUMBTEMS):
11
+ - Retraction events are FILES: `.research/retractions/<hash>.json`
12
+ `{hash, event: RETRACTED|REVISED, at, note, supersedes?}`
13
+ - Status transitions are an append-only ledger: `.research/ledger/claim_status.json`
14
+ - Dossiers are NEVER mutated in place — they are the historical record.
15
+ - Requeue instructions land in `.research/requeue.json` for the next swarm pass.
16
+
17
+ Degradation rules (applied after quote verification):
18
+ RETRACTED source -> claim.status = STALE (loan called; quote match irrelevant)
19
+ REVISED source -> claim.status = SUSPECT (needs human/agent re-review + requeue)
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ from dataclasses import dataclass, asdict
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
29
+
30
+ SCRIPT_DIR = Path(__file__).resolve().parent
31
+ PROJECT_ROOT = SCRIPT_DIR.parent
32
+ if str(PROJECT_ROOT) not in sys.path:
33
+ sys.path.insert(0, str(PROJECT_ROOT))
34
+
35
+ from runner.claim_witness import ( # noqa: E402
36
+ STATUS_LIVE,
37
+ STATUS_STALE,
38
+ STATUS_SUSPECT,
39
+ ClaimWitness,
40
+ )
41
+
42
+ EVENT_RETRACTED = "RETRACTED"
43
+ EVENT_REVISED = "REVISED"
44
+ VALID_EVENTS = (EVENT_RETRACTED, EVENT_REVISED)
45
+
46
+
47
+ @dataclass
48
+ class RetractionEvent:
49
+ hash: str
50
+ event: str
51
+ at: str
52
+ note: str = ""
53
+ supersedes: Optional[str] = None
54
+
55
+ def to_dict(self) -> Dict[str, Any]:
56
+ return asdict(self)
57
+
58
+
59
+ @dataclass
60
+ class StatusEvent:
61
+ scope_id: str
62
+ claim_id: str
63
+ from_status: str
64
+ to_status: str
65
+ reason: str
66
+ at: str
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ return asdict(self)
70
+
71
+
72
+ # ---- retraction events (files are the record) ---------------------------
73
+
74
+
75
+ def write_retraction(
76
+ base_dir: Path,
77
+ source_hash: str,
78
+ event: str,
79
+ note: str = "",
80
+ at: Optional[str] = None,
81
+ supersedes: Optional[str] = None,
82
+ ) -> Path:
83
+ """Record a RETRACTED/REVISED event for a cached source."""
84
+ if event not in VALID_EVENTS:
85
+ raise ValueError(f"event must be one of {VALID_EVENTS}, got {event!r}")
86
+ retr_dir = Path(base_dir) / "retractions"
87
+ retr_dir.mkdir(parents=True, exist_ok=True)
88
+ payload = RetractionEvent(
89
+ hash=source_hash,
90
+ event=event,
91
+ at=at or datetime.now(timezone.utc).isoformat(),
92
+ note=note,
93
+ supersedes=supersedes,
94
+ )
95
+ path = retr_dir / f"{source_hash}.json"
96
+ # Last write wins per hash (a REVISED may follow a RETRACTED); the ledger
97
+ # keeps the full history of claim transitions either way.
98
+ with open(path, "w", encoding="utf-8") as f:
99
+ json.dump(payload.to_dict(), f, indent=2)
100
+ return path
101
+
102
+
103
+ def load_retractions(base_dir: Path) -> Dict[str, RetractionEvent]:
104
+ """Load all retraction events, keyed by source hash."""
105
+ retr_dir = Path(base_dir) / "retractions"
106
+ out: Dict[str, RetractionEvent] = {}
107
+ if not retr_dir.exists():
108
+ return out
109
+ for path in sorted(retr_dir.glob("*.json")):
110
+ try:
111
+ with open(path, "r", encoding="utf-8") as f:
112
+ data = json.load(f)
113
+ except (OSError, json.JSONDecodeError):
114
+ continue
115
+ src_hash = data.get("hash") or path.stem
116
+ event = str(data.get("event", "")).upper()
117
+ if event not in VALID_EVENTS:
118
+ continue
119
+ out[src_hash] = RetractionEvent(
120
+ hash=src_hash,
121
+ event=event,
122
+ at=data.get("at") or "",
123
+ note=data.get("note") or "",
124
+ supersedes=data.get("supersedes"),
125
+ )
126
+ return out
127
+
128
+
129
+ # ---- degradation -------------------------------------------------------
130
+
131
+
132
+ def apply_degradation(
133
+ claims: Iterable[ClaimWitness],
134
+ retractions: Dict[str, RetractionEvent],
135
+ scope_id: str = "unknown",
136
+ now: Optional[str] = None,
137
+ ) -> Tuple[List[ClaimWitness], List[StatusEvent]]:
138
+ """Mark claims STALE/SUSPECT per retraction events.
139
+
140
+ Returns (degraded_claims, status_events). ClaimWitness objects are
141
+ updated in memory only — callers persist to the ledger, never into the
142
+ source dossier files.
143
+ """
144
+ now = now or datetime.now(timezone.utc).isoformat()
145
+ events: List[StatusEvent] = []
146
+ out: List[ClaimWitness] = []
147
+
148
+ for claim in claims:
149
+ prior = claim.status or STATUS_LIVE
150
+ src_hash = claim.source_hash
151
+ ev = retractions.get(src_hash) if src_hash else None
152
+
153
+ new_status = prior
154
+ reason = ""
155
+ if ev is not None:
156
+ if ev.event == EVENT_RETRACTED:
157
+ new_status = STATUS_STALE
158
+ reason = f"source {src_hash} RETRACTED: {ev.note or 'no note'}"
159
+ elif ev.event == EVENT_REVISED:
160
+ new_status = STATUS_SUSPECT
161
+ reason = f"source {src_hash} REVISED: {ev.note or 'no note'}"
162
+
163
+ if new_status != prior:
164
+ claim.status = new_status
165
+ events.append(
166
+ StatusEvent(
167
+ scope_id=scope_id,
168
+ claim_id=claim.claim_id,
169
+ from_status=prior,
170
+ to_status=new_status,
171
+ reason=reason,
172
+ at=now,
173
+ )
174
+ )
175
+ out.append(claim)
176
+
177
+ return out, events
178
+
179
+
180
+ # ---- ledger + requeue sidecars -----------------------------------------
181
+
182
+
183
+ def append_ledger(base_dir: Path, events: List[StatusEvent]) -> Path:
184
+ """Append status transitions to the append-only ledger (never rewrite)."""
185
+ ledger_dir = Path(base_dir) / "ledger"
186
+ ledger_dir.mkdir(parents=True, exist_ok=True)
187
+ path = ledger_dir / "claim_status.json"
188
+ existing: List[Dict[str, Any]] = []
189
+ if path.exists():
190
+ try:
191
+ with open(path, "r", encoding="utf-8") as f:
192
+ existing = json.load(f)
193
+ if not isinstance(existing, list):
194
+ existing = []
195
+ except (OSError, json.JSONDecodeError):
196
+ existing = []
197
+ existing.extend(e.to_dict() for e in events)
198
+ with open(path, "w", encoding="utf-8") as f:
199
+ json.dump(existing, f, indent=2)
200
+ return path
201
+
202
+
203
+ def queue_requeue(base_dir: Path, scope_id: str, reason: str = "claim degradation") -> Path:
204
+ """Record that a scope needs a re-run on the next swarm pass."""
205
+ path = Path(base_dir) / "requeue.json"
206
+ entries: List[Dict[str, Any]] = []
207
+ if path.exists():
208
+ try:
209
+ with open(path, "r", encoding="utf-8") as f:
210
+ entries = json.load(f)
211
+ if not isinstance(entries, list):
212
+ entries = []
213
+ except (OSError, json.JSONDecodeError):
214
+ entries = []
215
+ if not any(e.get("scope_id") == scope_id for e in entries):
216
+ entries.append(
217
+ {
218
+ "scope_id": scope_id,
219
+ "reason": reason,
220
+ "queued_at": datetime.now(timezone.utc).isoformat(),
221
+ }
222
+ )
223
+ with open(path, "w", encoding="utf-8") as f:
224
+ json.dump(entries, f, indent=2)
225
+ return path
226
+
227
+
228
+ def load_requeue(base_dir: Path) -> List[Dict[str, Any]]:
229
+ path = Path(base_dir) / "requeue.json"
230
+ if not path.exists():
231
+ return []
232
+ try:
233
+ with open(path, "r", encoding="utf-8") as f:
234
+ data = json.load(f)
235
+ return data if isinstance(data, list) else []
236
+ except (OSError, json.JSONDecodeError):
237
+ return []
238
+
239
+
240
+ # ---- one-pass staleness check (the probe's unit of work) ----------------
241
+
242
+
243
+ def check_staleness(base_dir: Path, scope_ids: Optional[List[str]] = None) -> Dict[str, Any]:
244
+ """Run one degradation pass over scope dossiers.
245
+
246
+ For each scope with alpha/beta dossiers, fold claims, join against
247
+ retraction events, write the ledger, mirror into the claim store, and
248
+ queue re-runs for scopes that degraded. Dossiers themselves are left
249
+ untouched.
250
+
251
+ Returns a summary dict suitable for JSON tool output.
252
+ """
253
+ from runner.claim_witness import claims_from_dossier, load_dossier
254
+
255
+ base_dir = Path(base_dir)
256
+ retractions = load_retractions(base_dir)
257
+ scratch = base_dir / "scratchpads"
258
+
259
+ if scope_ids is None:
260
+ if not scratch.exists():
261
+ return {"scopes": [], "retractions": len(retractions), "degraded": 0}
262
+ scope_dirs = sorted(p for p in scratch.iterdir() if p.is_dir())
263
+ else:
264
+ scope_dirs = [scratch / s for s in scope_ids]
265
+
266
+ all_events: List[StatusEvent] = []
267
+ per_scope: List[Dict[str, Any]] = []
268
+ degraded_scopes = set()
269
+
270
+ for scope_dir in scope_dirs:
271
+ if not scope_dir.is_dir():
272
+ continue
273
+ scope_id = scope_dir.name
274
+ scope_event_count = 0
275
+ claim_sets = 0
276
+
277
+ for dossier_name in ("alpha_dossier.json", "beta_dossier.json"):
278
+ dpath = scope_dir / dossier_name
279
+ if not dpath.exists():
280
+ continue
281
+ claim_sets += 1
282
+ dossier = load_dossier(dpath)
283
+ claims = claims_from_dossier(dossier)
284
+ degraded, events = apply_degradation(
285
+ claims, retractions, scope_id=scope_id
286
+ )
287
+ all_events.extend(events)
288
+ scope_event_count += len(events)
289
+ if events:
290
+ degraded_scopes.add(scope_id)
291
+
292
+ per_scope.append(
293
+ {
294
+ "scope_id": scope_id,
295
+ "claim_sets": claim_sets,
296
+ "status_events": scope_event_count,
297
+ "degraded": scope_id in degraded_scopes,
298
+ }
299
+ )
300
+
301
+ if all_events:
302
+ append_ledger(base_dir, all_events)
303
+ # Mirror into the derived claim store (best-effort; never fatal).
304
+ try:
305
+ from runner.claim_store import ClaimStore
306
+
307
+ store = ClaimStore(base_dir)
308
+ for e in all_events:
309
+ store.record_status_event(
310
+ e.scope_id, e.claim_id, e.from_status, e.to_status, e.reason, e.at
311
+ )
312
+ store.close()
313
+ except Exception as exc: # noqa: BLE001
314
+ print(f"[living-dossiers] claim store mirror skipped: {exc}", file=sys.stderr)
315
+
316
+ for scope_id in sorted(degraded_scopes):
317
+ queue_requeue(base_dir, scope_id, reason="claim degradation (retraction event)")
318
+
319
+ return {
320
+ "retractions": len(retractions),
321
+ "scopes": per_scope,
322
+ "degraded_scopes": sorted(degraded_scopes),
323
+ "status_events": len(all_events),
324
+ }
325
+
326
+
327
+ def main(argv: Optional[List[str]] = None) -> int:
328
+ import argparse
329
+
330
+ parser = argparse.ArgumentParser(description="IUMBTEMS living dossiers ledger")
331
+ parser.add_argument("--dir", default=".research")
332
+ sub = parser.add_subparsers(dest="command")
333
+
334
+ rep = sub.add_parser("report", help="Record a retraction/retraction event")
335
+ rep.add_argument("--hash", required=True)
336
+ rep.add_argument("--event", required=True, choices=list(VALID_EVENTS))
337
+ rep.add_argument("--note", default="")
338
+
339
+ sub.add_parser("check", help="Run one staleness/degradation pass")
340
+ args = parser.parse_args(argv)
341
+
342
+ base = Path(args.dir)
343
+ if args.command == "report":
344
+ path = write_retraction(base, args.hash, args.event, note=args.note)
345
+ print(json.dumps({"status": "recorded", "path": str(path)}))
346
+ return 0
347
+ if args.command == "check":
348
+ print(json.dumps(check_staleness(base), indent=2))
349
+ return 0
350
+ print(json.dumps(check_staleness(base), indent=2))
351
+ return 0
352
+
353
+
354
+ if __name__ == "__main__":
355
+ sys.exit(main())
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared MCP stdio JSON-RPC protocol loop for IUMBTEMS.
4
+
5
+ Extracted from the duplicated hand-rolled servers in config/mcp_launcher.py
6
+ (the zero-key fallback) and config/searxng_mcp.py so the first-party tool
7
+ server (runner/mcp_server.py) can share one framing/dispatch implementation.
8
+
9
+ Protocol contract (kept byte-compatible with the previous inline loops):
10
+ - protocolVersion: "2024-11-05"
11
+ - Methods: initialize, notifications/initialized (no reply), tools/list,
12
+ tools/call. Unknown methods get JSON-RPC error -32601.
13
+ - Framing: Content-Length header frames AND newline-delimited JSON are both
14
+ accepted; a reply is framed the same way its request arrived.
15
+ - tools/call success payload: {"content": [{"type": "text", "text": ...}]}.
16
+
17
+ Resilience note (intentional delta from the old inline loops): a tool handler
18
+ that raises now returns JSON-RPC error -32603 instead of killing the server.
19
+ Malformed messages are logged and skipped; any other stream error ends the
20
+ loop (Content-Length desync is not recoverable).
21
+ """
22
+
23
+ import json
24
+ import sys
25
+ from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple
26
+
27
+ PROTOCOL_VERSION = "2024-11-05"
28
+
29
+
30
+ class ToolSpec:
31
+ """One MCP tool: public manifest fields plus the handler behind it."""
32
+
33
+ def __init__(
34
+ self,
35
+ name: str,
36
+ description: str,
37
+ input_schema: Dict[str, Any],
38
+ handler: Callable[[Dict[str, Any]], str],
39
+ aliases: Tuple[str, ...] = (),
40
+ ):
41
+ self.name = name
42
+ self.description = description
43
+ self.input_schema = input_schema
44
+ self.handler = handler
45
+ self.aliases = tuple(aliases)
46
+
47
+ def to_manifest(self) -> Dict[str, Any]:
48
+ return {
49
+ "name": self.name,
50
+ "description": self.description,
51
+ "inputSchema": self.input_schema,
52
+ }
53
+
54
+
55
+ def read_message(stream: TextIO) -> Optional[Tuple[Dict[str, Any], bool]]:
56
+ """Read one JSON-RPC message. Returns (request, use_headers); None on EOF."""
57
+ while True:
58
+ line = stream.readline()
59
+ if not line:
60
+ return None
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+ if line.startswith("Content-Length:"):
65
+ length = int(line.split(":", 1)[1].strip())
66
+ stream.readline() # blank separator line
67
+ body = stream.read(length)
68
+ return json.loads(body), True
69
+ return json.loads(line), False
70
+
71
+
72
+ def write_message(stream: TextIO, message: Dict[str, Any], use_headers: bool) -> None:
73
+ """Write one JSON-RPC message, matching the request's framing."""
74
+ out = json.dumps(message)
75
+ if use_headers:
76
+ stream.write(f"Content-Length: {len(out)}\r\n\r\n{out}")
77
+ else:
78
+ stream.write(out + "\n")
79
+ stream.flush()
80
+
81
+
82
+ class StdioJsonRpcServer:
83
+ """Dispatch loop over a fixed tool registry."""
84
+
85
+ def __init__(
86
+ self,
87
+ server_name: str,
88
+ version: str,
89
+ tools: List[ToolSpec],
90
+ protocol_version: str = PROTOCOL_VERSION,
91
+ log_prefix: Optional[str] = None,
92
+ ):
93
+ self.server_name = server_name
94
+ self.version = version
95
+ self.tools = list(tools)
96
+ self.protocol_version = protocol_version
97
+ self.log_prefix = log_prefix or f"[{server_name}]"
98
+ self._by_name: Dict[str, ToolSpec] = {}
99
+ for spec in self.tools:
100
+ self._by_name[spec.name] = spec
101
+ for alias in spec.aliases:
102
+ self._by_name[alias] = spec
103
+
104
+ # --- request handling -------------------------------------------------
105
+
106
+ def handle(self, req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
107
+ """Return a response dict, or None for notifications (no reply)."""
108
+ req_id = req.get("id")
109
+ method = req.get("method")
110
+ params = req.get("params") or {}
111
+
112
+ if method is not None and str(method).startswith("notifications/"):
113
+ return None
114
+
115
+ if method == "initialize":
116
+ return self._reply(
117
+ req_id,
118
+ {
119
+ "protocolVersion": self.protocol_version,
120
+ "serverInfo": {
121
+ "name": self.server_name,
122
+ "version": self.version,
123
+ },
124
+ "capabilities": {"tools": {}},
125
+ },
126
+ )
127
+ if method == "tools/list":
128
+ return self._reply(
129
+ req_id, {"tools": [t.to_manifest() for t in self.tools]}
130
+ )
131
+ if method == "tools/call":
132
+ return self._call(req_id, params)
133
+ return self._error(req_id, -32601, f"Method {method} not handled")
134
+
135
+ def _call(self, req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
136
+ tool_name = params.get("name")
137
+ args = params.get("arguments") or {}
138
+ spec = self._by_name.get(tool_name)
139
+ if spec is None:
140
+ return self._error(req_id, -32601, f"Tool {tool_name} not found")
141
+ try:
142
+ text = spec.handler(args)
143
+ except Exception as exc: # noqa: BLE001 - surface, do not kill the server
144
+ return self._error(req_id, -32603, f"{type(exc).__name__}: {exc}")
145
+ return self._reply(
146
+ req_id, {"content": [{"type": "text", "text": text}]}
147
+ )
148
+
149
+ @staticmethod
150
+ def _reply(req_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
151
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
152
+
153
+ @staticmethod
154
+ def _error(req_id: Any, code: int, message: str) -> Dict[str, Any]:
155
+ return {
156
+ "jsonrpc": "2.0",
157
+ "id": req_id,
158
+ "error": {"code": code, "message": message},
159
+ }
160
+
161
+ # --- stdio loop -------------------------------------------------------
162
+
163
+ def serve_forever(
164
+ self,
165
+ stdin: Optional[TextIO] = None,
166
+ stdout: Optional[TextIO] = None,
167
+ stderr: Optional[TextIO] = None,
168
+ ) -> None:
169
+ stdin = stdin if stdin is not None else sys.stdin
170
+ stdout = stdout if stdout is not None else sys.stdout
171
+ stderr = stderr if stderr is not None else sys.stderr
172
+ while True:
173
+ try:
174
+ parsed = read_message(stdin)
175
+ if parsed is None:
176
+ break
177
+ req, use_headers = parsed
178
+ res = self.handle(req)
179
+ if res is not None:
180
+ write_message(stdout, res, use_headers)
181
+ except json.JSONDecodeError as exc:
182
+ stderr.write(f"{self.log_prefix} Skipping malformed message: {exc}\n")
183
+ stderr.flush()
184
+ continue
185
+ except Exception as exc: # noqa: BLE001 - stream desync ends the loop
186
+ stderr.write(f"{self.log_prefix} Error handling request: {exc}\n")
187
+ stderr.flush()
188
+ break