osintengine 1.0.2__py3-none-any.whl

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 (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
watson/mcp_server.py ADDED
@@ -0,0 +1,601 @@
1
+ """mcp_server.py — REST + MCP Protocol API.
2
+
3
+ Write endpoints require API key auth. Read endpoints are open.
4
+ Set MCP_API_KEY env var on the server. Clients pass X-API-Key header.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ from collections import defaultdict
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from fastapi import FastAPI, HTTPException, Query, Request
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.responses import JSONResponse, PlainTextResponse
20
+ from pydantic import BaseModel, field_validator
21
+
22
+ from .graph import KnowledgeGraph
23
+
24
+ # ── API Key Auth ──────────────────────────────────────────────────
25
+
26
+ MCP_API_KEY = os.environ.get("MCP_API_KEY", "")
27
+ MCP_READ_KEY = os.environ.get("MCP_READ_KEY", "") # If set, read endpoints also require auth
28
+ MCP_DEV_MODE = os.environ.get("WATSON_DEV", "") in ("1", "true", "yes")
29
+
30
+ if not MCP_API_KEY and not MCP_DEV_MODE:
31
+ print("⚠ WARNING: MCP_API_KEY not set. Write endpoints are BLOCKED (503).")
32
+ print(" Set MCP_API_KEY env var to enable publishing, or WATSON_DEV=1 for dev mode.")
33
+
34
+ if MCP_READ_KEY:
35
+ print("🔒 MCP_READ_KEY set — read endpoints require X-API-Key header.")
36
+ else:
37
+ print("📖 MCP_READ_KEY not set — read endpoints are OPEN (public).")
38
+ print(" Set MCP_READ_KEY env var to require authentication for reads.")
39
+
40
+ # ── Rate Limiting ───────────────────────────────────────────────
41
+
42
+ _MAX_ENTITIES_PER_REQUEST = 500
43
+ _MAX_ENTITIES_PER_MINUTE = 2000
44
+ _rate_window: defaultdict[str, list[float]] = defaultdict(list)
45
+
46
+ def _check_rate_limit(key: str) -> bool:
47
+ """Return True if within limit, False if exceeded."""
48
+ now = time.time()
49
+ window = [t for t in _rate_window[key] if now - t < 60]
50
+ _rate_window[key] = window
51
+ if len(window) >= _MAX_ENTITIES_PER_MINUTE:
52
+ return False
53
+ return True
54
+
55
+ def _record_rate(key: str, count: int):
56
+ now = time.time()
57
+ _rate_window[key].extend([now] * count)
58
+
59
+ async def api_key_middleware(request: Request, call_next):
60
+ """Require API key for write endpoints. Read endpoints are open
61
+ unless MCP_READ_KEY is set.
62
+
63
+ If MCP_API_KEY is not set and not in dev mode, writes are blocked
64
+ with a clear error message rather than silently allowing everything.
65
+ """
66
+ if request.method in ("POST", "PUT", "DELETE", "PATCH"):
67
+ key = request.headers.get("X-API-Key", "")
68
+ if not MCP_API_KEY:
69
+ # No key configured — block writes unless dev mode
70
+ if MCP_DEV_MODE:
71
+ return await call_next(request)
72
+ return JSONResponse(
73
+ {"error": "server_unconfigured",
74
+ "detail": "MCP_API_KEY not set on server. Set it to enable write operations."},
75
+ status_code=503,
76
+ )
77
+ if key != MCP_API_KEY:
78
+ return JSONResponse(
79
+ {"error": "unauthorized",
80
+ "detail": "Valid X-API-Key required for write operations"},
81
+ status_code=401,
82
+ )
83
+ elif MCP_READ_KEY and request.method == "GET":
84
+ key = request.headers.get("X-API-Key", "")
85
+ if key != MCP_READ_KEY:
86
+ # Also accept the write key for reads
87
+ if MCP_API_KEY and key == MCP_API_KEY:
88
+ return await call_next(request)
89
+ return JSONResponse(
90
+ {"error": "unauthorized",
91
+ "detail": "Valid X-API-Key required for read operations (MCP_READ_KEY is set)"},
92
+ status_code=401,
93
+ )
94
+ return await call_next(request)
95
+
96
+ # ── MCP Protocol Types ─────────────────────────────────────────
97
+
98
+ class MCPTool(BaseModel):
99
+ name: str
100
+ description: str
101
+ inputSchema: dict = {}
102
+
103
+
104
+ class MCPListToolsResponse(BaseModel):
105
+ tools: list[MCPTool]
106
+
107
+
108
+ class MCPCallToolRequest(BaseModel):
109
+ name: str
110
+ arguments: dict = {}
111
+
112
+
113
+ class MCPCallToolResponse(BaseModel):
114
+ content: list[dict]
115
+
116
+
117
+ # ── App ─────────────────────────────────────────────────────────
118
+
119
+ mcp = FastAPI(
120
+ title="Watson MCP Server",
121
+ description="Community OSINT investigation knowledge graph",
122
+ version="0.1.0",
123
+ )
124
+
125
+ mcp.add_middleware(
126
+ CORSMiddleware,
127
+ allow_origins=["*"],
128
+ allow_methods=["*"],
129
+ allow_headers=["*"],
130
+ )
131
+
132
+ # API key auth — read open, write gated
133
+ mcp.middleware("http")(api_key_middleware)
134
+
135
+ graph = KnowledgeGraph()
136
+
137
+
138
+ # ── MCP Discovery ───────────────────────────────────────────────
139
+
140
+ @mcp.get("/")
141
+ async def root():
142
+ """MCP server info."""
143
+ return {
144
+ "name": "Watson MCP Server",
145
+ "version": "0.1.0",
146
+ "description": "Community OSINT investigation knowledge graph",
147
+ "protocol": "mcp",
148
+ "stats": graph.stats(),
149
+ }
150
+
151
+
152
+ @mcp.get("/.well-known/mcp", response_model=MCPListToolsResponse)
153
+ async def list_tools():
154
+ """MCP tool discovery."""
155
+ return MCPListToolsResponse(tools=[
156
+ MCPTool(
157
+ name="watson_search",
158
+ description="Search the Watson community knowledge graph for entities, cases, and relations",
159
+ inputSchema={
160
+ "type": "object",
161
+ "properties": {
162
+ "query": {"type": "string", "description": "Search term"},
163
+ "limit": {"type": "integer", "default": 20},
164
+ },
165
+ "required": ["query"],
166
+ },
167
+ ),
168
+ MCPTool(
169
+ name="watson_traverse",
170
+ description="Explore connections from an entity in the knowledge graph",
171
+ inputSchema={
172
+ "type": "object",
173
+ "properties": {
174
+ "entity_value": {"type": "string", "description": "Entity to traverse from"},
175
+ "entity_type": {"type": "string", "description": "person, domain, company, email, etc."},
176
+ },
177
+ "required": ["entity_value"],
178
+ },
179
+ ),
180
+ MCPTool(
181
+ name="watson_case",
182
+ description="Retrieve a published investigation case",
183
+ inputSchema={
184
+ "type": "object",
185
+ "properties": {
186
+ "case_id": {"type": "string", "description": "Case ID (e.g., CASE-ABC12345)"},
187
+ },
188
+ "required": ["case_id"],
189
+ },
190
+ ),
191
+ MCPTool(
192
+ name="watson_stats",
193
+ description="Get statistics about the community knowledge graph",
194
+ inputSchema={"type": "object", "properties": {}},
195
+ ),
196
+ MCPTool(
197
+ name="watson_context",
198
+ description="Check if an investigation target has prior findings in the graph",
199
+ inputSchema={
200
+ "type": "object",
201
+ "properties": {
202
+ "query": {"type": "string", "description": "Investigation target"},
203
+ },
204
+ "required": ["query"],
205
+ },
206
+ ),
207
+ ])
208
+
209
+
210
+ # ── MCP Tool Calls ──────────────────────────────────────────────
211
+
212
+ @mcp.post("/mcp/call-tool", response_model=MCPCallToolResponse)
213
+ async def call_tool(request: MCPCallToolRequest):
214
+ """Handle MCP tool calls."""
215
+ args = request.arguments
216
+
217
+ try:
218
+ if request.name == "watson_search":
219
+ entities = graph.search_entities(
220
+ args.get("query", ""),
221
+ limit=args.get("limit", 20),
222
+ )
223
+ return MCPCallToolResponse(content=[{
224
+ "type": "text",
225
+ "text": json.dumps({
226
+ "query": args.get("query"),
227
+ "results": [e.to_dict() for e in entities],
228
+ "count": len(entities),
229
+ }, indent=2),
230
+ }])
231
+
232
+ elif request.name == "watson_traverse":
233
+ result = graph.traverse(
234
+ entity_value=args["entity_value"],
235
+ entity_type=args.get("entity_type"),
236
+ )
237
+ return MCPCallToolResponse(content=[{
238
+ "type": "text",
239
+ "text": json.dumps(result, indent=2),
240
+ }])
241
+
242
+ elif request.name == "watson_case":
243
+ from pathlib import Path
244
+ cases_dir = Path.home() / "watson-cases"
245
+ case_path = cases_dir / f"{args['case_id']}.md"
246
+ if case_path.exists():
247
+ content = case_path.read_text()
248
+ return MCPCallToolResponse(content=[{
249
+ "type": "text",
250
+ "text": content,
251
+ }])
252
+ else:
253
+ return MCPCallToolResponse(content=[{
254
+ "type": "text",
255
+ "text": json.dumps({"error": "Case not found", "case_id": args["case_id"]}),
256
+ }])
257
+
258
+ elif request.name == "watson_stats":
259
+ stats = graph.stats()
260
+ return MCPCallToolResponse(content=[{
261
+ "type": "text",
262
+ "text": json.dumps(stats, indent=2),
263
+ }])
264
+
265
+ elif request.name == "watson_context":
266
+ context = graph.context_for_investigation(args["query"])
267
+ return MCPCallToolResponse(content=[{
268
+ "type": "text",
269
+ "text": json.dumps(context, indent=2),
270
+ }])
271
+
272
+ else:
273
+ raise HTTPException(404, f"Unknown tool: {request.name}")
274
+
275
+ except Exception as e:
276
+ return MCPCallToolResponse(content=[{
277
+ "type": "text",
278
+ "text": json.dumps({"error": str(e)}),
279
+ }])
280
+
281
+
282
+ # ── REST API (for non-MCP clients) ──────────────────────────────
283
+
284
+ @mcp.get("/api/search")
285
+ async def api_search(q: str = Query(...), limit: int = 20):
286
+ """Search the community graph via REST."""
287
+ entities = graph.search_entities(q, limit=limit)
288
+ return {
289
+ "query": q,
290
+ "results": [e.to_dict() for e in entities],
291
+ "count": len(entities),
292
+ }
293
+
294
+
295
+ @mcp.get("/api/traverse/{entity_value:path}")
296
+ async def api_traverse(entity_value: str, entity_type: Optional[str] = None):
297
+ """Traverse graph via REST."""
298
+ result = graph.traverse(entity_value, entity_type=entity_type)
299
+ return result
300
+
301
+
302
+ @mcp.get("/api/stats")
303
+ async def api_stats():
304
+ """Graph stats via REST."""
305
+ return graph.stats()
306
+
307
+
308
+ @mcp.get("/api/cases")
309
+ async def api_cases():
310
+ """List published cases."""
311
+ from pathlib import Path
312
+ cases_dir = Path.home() / "watson-cases"
313
+ cases = []
314
+ for f in sorted(cases_dir.glob("CASE-*.md"), reverse=True):
315
+ cases.append({
316
+ "id": f.stem,
317
+ "size": f.stat().st_size,
318
+ })
319
+ return {"cases": cases, "count": len(cases)}
320
+
321
+
322
+ @mcp.get("/api/cases/{case_id}")
323
+ async def api_case(case_id: str):
324
+ """Get a published case."""
325
+ from pathlib import Path
326
+ case_path = Path.home() / "watson-cases" / f"{case_id}.md"
327
+ if case_path.exists():
328
+ return PlainTextResponse(case_path.read_text(), media_type="text/markdown")
329
+ raise HTTPException(404, "Case not found")
330
+
331
+
332
+ # ── Publishing / Ingest ─────────────────────────────────────────
333
+
334
+ # Valid entity types that can be ingested
335
+ _VALID_ENTITY_TYPES = {
336
+ "person", "organization", "company", "domain", "email",
337
+ "ip", "ipv4", "ipv6", "wallet", "crypto_address", "phone",
338
+ "url", "hash", "md5", "sha256", "cve", "asn", "location",
339
+ }
340
+
341
+ # Max lengths
342
+ _MAX_VALUE_LEN = 500
343
+ _MAX_SOURCE_LEN = 200
344
+
345
+ # Garbage patterns — values that are obviously not real entities
346
+ _GARBAGE_PATTERNS = [
347
+ re.compile(r"^test\d*$", re.I),
348
+ re.compile(r"^asdf+$", re.I),
349
+ re.compile(r"^foo(bar)?$", re.I),
350
+ re.compile(r"^hello\d*$", re.I),
351
+ re.compile(r"^n/?a$", re.I),
352
+ re.compile(r"^unknown$", re.I),
353
+ re.compile(r"^none$", re.I),
354
+ re.compile(r"^example", re.I),
355
+ re.compile(r"^placeholder", re.I),
356
+ re.compile(r"^sample", re.I),
357
+ re.compile(r"^lorem", re.I),
358
+ re.compile(r"^(.)\1{10,}$"), # Same char 10+ times
359
+ ]
360
+
361
+ # Type-specific validation regexes
362
+ _TYPE_VALIDATORS: dict[str, re.Pattern] = {
363
+ "email": re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
364
+ "domain": re.compile(r"^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$", re.I),
365
+ "ip": re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$"),
366
+ "ipv4": re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$"),
367
+ "ipv6": re.compile(r"^([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}$", re.I),
368
+ "url": re.compile(r"^https?://", re.I),
369
+ "wallet": re.compile(r"^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39,59})$"),
370
+ "crypto_address": re.compile(r"^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39,59})$"),
371
+ "md5": re.compile(r"^[a-f0-9]{32}$", re.I),
372
+ "sha256": re.compile(r"^[a-f0-9]{64}$", re.I),
373
+ "cve": re.compile(r"^CVE-\d{4}-\d{4,}$", re.I),
374
+ }
375
+
376
+
377
+ def _validate_entity(entity: dict) -> str | None:
378
+ """Validate a single entity dict. Returns error string or None if valid.
379
+
380
+ Does NOT trust the caller — every field is checked.
381
+ """
382
+ value = str(entity.get("value", "")).strip()
383
+ type_ = str(entity.get("type", "")).strip().lower()
384
+ tier = str(entity.get("tier", "PROBABLE")).strip().upper()
385
+
386
+ # ── Value checks ──────────────────────────────
387
+ if not value:
388
+ return "entity.value is empty"
389
+ if len(value) > _MAX_VALUE_LEN:
390
+ return f"entity.value too long ({len(value)} > {_MAX_VALUE_LEN})"
391
+ if value != entity.get("value", ""):
392
+ return "entity.value has leading/trailing whitespace"
393
+
394
+ # ── Garbage detection ─────────────────────────
395
+ for pat in _GARBAGE_PATTERNS:
396
+ if pat.match(value):
397
+ return f"entity.value looks like garbage: '{value}'"
398
+
399
+ # ── Type checks ───────────────────────────────
400
+ if not type_:
401
+ return "entity.type is empty"
402
+ if type_ not in _VALID_ENTITY_TYPES:
403
+ return f"entity.type not recognized: '{type_}'. Valid: {sorted(_VALID_ENTITY_TYPES)}"
404
+
405
+ # ── Type-specific format validation ─────────────
406
+ validator = _TYPE_VALIDATORS.get(type_)
407
+ if validator and not validator.match(value):
408
+ return f"entity.value '{value}' doesn't match expected format for type '{type_}'"
409
+
410
+ # ── Tier checks ────────────────────────────────
411
+ if tier not in ("CONFIRMED", "PROBABLE", "POSSIBLE", "UNVERIFIED"):
412
+ return f"entity.tier not valid: '{tier}'"
413
+
414
+ # ── Source length ──────────────────────────────
415
+ source = str(entity.get("source", ""))
416
+ if len(source) > _MAX_SOURCE_LEN:
417
+ return f"entity.source too long ({len(source)} > {_MAX_SOURCE_LEN})"
418
+
419
+ return None
420
+
421
+
422
+ class IngestEntity(BaseModel):
423
+ value: str
424
+ type: str
425
+ source: str = ""
426
+ tier: str = "PROBABLE"
427
+ case_id: str = ""
428
+
429
+ @field_validator("value")
430
+ @classmethod
431
+ def value_not_empty(cls, v: str) -> str:
432
+ if not v or not v.strip():
433
+ raise ValueError("value cannot be empty")
434
+ if len(v.strip()) > _MAX_VALUE_LEN:
435
+ raise ValueError(f"value too long ({len(v.strip())} > {_MAX_VALUE_LEN})")
436
+ return v.strip()
437
+
438
+ @field_validator("type")
439
+ @classmethod
440
+ def type_valid(cls, v: str) -> str:
441
+ v = v.strip().lower()
442
+ if not v:
443
+ raise ValueError("type cannot be empty")
444
+ if v not in _VALID_ENTITY_TYPES:
445
+ raise ValueError(f"type '{v}' not recognized. Valid: {sorted(_VALID_ENTITY_TYPES)}")
446
+ return v
447
+
448
+ @field_validator("tier")
449
+ @classmethod
450
+ def tier_valid(cls, v: str) -> str:
451
+ v = v.strip().upper()
452
+ if v not in ("CONFIRMED", "PROBABLE", "POSSIBLE", "UNVERIFIED"):
453
+ raise ValueError(f"tier '{v}' not valid")
454
+ return v
455
+
456
+
457
+ class IngestPayload(BaseModel):
458
+ case_id: str
459
+ target: str
460
+ target_type: str = ""
461
+ findings_count: int = 0
462
+ confirmed_count: int = 0
463
+ verifiability: str = ""
464
+ date: str = ""
465
+ entities: list[IngestEntity] = []
466
+ markdown: str = ""
467
+
468
+ @field_validator("entities")
469
+ @classmethod
470
+ def entities_not_too_many(cls, v: list) -> list:
471
+ if len(v) > _MAX_ENTITIES_PER_REQUEST:
472
+ raise ValueError(f"too many entities ({len(v)} > {_MAX_ENTITIES_PER_REQUEST}) — split into multiple requests")
473
+ return v
474
+
475
+
476
+ # Track which cases have been published — persisted to JSONL
477
+ PUBLISHED_PATH = Path.home() / ".watson" / "graph" / "published.jsonl"
478
+ PUBLISHED_PATH.parent.mkdir(parents=True, exist_ok=True)
479
+
480
+ _published_cases: dict[str, dict] = {}
481
+
482
+ def _load_published():
483
+ """Load published cases from disk."""
484
+ global _published_cases
485
+ if PUBLISHED_PATH.exists():
486
+ try:
487
+ for line in PUBLISHED_PATH.read_text().splitlines():
488
+ if line.strip():
489
+ d = json.loads(line)
490
+ _published_cases[d["case_id"]] = d
491
+ except Exception:
492
+ pass
493
+
494
+ def _save_published():
495
+ """Persist published cases to disk."""
496
+ try:
497
+ lines = [json.dumps(c) + "\n" for c in _published_cases.values()]
498
+ PUBLISHED_PATH.write_text("".join(lines))
499
+ except Exception:
500
+ pass
501
+
502
+ # Load on startup
503
+ _load_published()
504
+
505
+
506
+ @mcp.post("/api/ingest")
507
+ async def api_ingest(payload: IngestPayload, request: Request):
508
+ """Ingest a published case into the community knowledge graph.
509
+
510
+ Two-layer validation: Pydantic catches schema errors; _validate_entity()
511
+ catches semantic garbage (test data, malformed values, wrong types).
512
+ Rate-limited per API key.
513
+ """
514
+ key = request.headers.get("X-API-Key", "anonymous")
515
+
516
+ # ── Rate limit check ─────────────────────────
517
+ if not _check_rate_limit(key):
518
+ raise HTTPException(429, "Rate limit exceeded — max 2000 entities/minute per key")
519
+
520
+ # ── Double-layer entity validation (belt + suspenders) ──
521
+ rejected: list[dict] = []
522
+ valid_entities: list[IngestEntity] = []
523
+
524
+ for ent in payload.entities:
525
+ # Pydantic already validated the model, but convert back to dict
526
+ # for _validate_entity's semantic checks (garbage patterns, format)
527
+ err = _validate_entity(ent.model_dump())
528
+ if err:
529
+ rejected.append({"value": ent.value, "type": ent.type, "reason": err})
530
+ else:
531
+ valid_entities.append(ent)
532
+
533
+ # ── Ingest valid entities ─────────────────────
534
+ ingested = 0
535
+ for ent in valid_entities:
536
+ if ent.tier not in ("CONFIRMED", "PROBABLE"):
537
+ continue
538
+ graph.upsert_entity(
539
+ type_=ent.type,
540
+ value=ent.value,
541
+ case_id=payload.case_id,
542
+ label=ent.value[:200],
543
+ )
544
+ ingested += 1
545
+
546
+ # Track rate
547
+ _record_rate(key, ingested)
548
+
549
+ # Track published case metadata
550
+ _published_cases[payload.case_id] = {
551
+ "case_id": payload.case_id,
552
+ "target": payload.target,
553
+ "target_type": payload.target_type,
554
+ "findings_count": payload.findings_count,
555
+ "confirmed_count": payload.confirmed_count,
556
+ "verifiability": payload.verifiability,
557
+ "date": payload.date,
558
+ "ingested_entities": ingested,
559
+ "rejected_entities": len(rejected),
560
+ }
561
+ _save_published()
562
+
563
+ return {
564
+ "status": "ingested",
565
+ "case_id": payload.case_id,
566
+ "entities_ingested": ingested,
567
+ "entities_rejected": len(rejected),
568
+ "rejected": rejected[:10], # First 10 only — don't flood response
569
+ "graph_stats": graph.stats(),
570
+ }
571
+
572
+
573
+ @mcp.get("/api/published")
574
+ async def api_published():
575
+ """List all published cases in the community graph."""
576
+ return {
577
+ "cases": list(_published_cases.values()),
578
+ "count": len(_published_cases),
579
+ }
580
+
581
+
582
+ @mcp.delete("/api/cases/{case_id}")
583
+ async def api_unpublish(case_id: str):
584
+ """Unpublish a case — remove its entities from the graph and
585
+ its metadata from the published index. Requires write API key."""
586
+ removed_entities = graph.remove_case(case_id)
587
+ was_published = case_id in _published_cases
588
+
589
+ if was_published:
590
+ del _published_cases[case_id]
591
+ _save_published()
592
+
593
+ if removed_entities == 0 and not was_published:
594
+ raise HTTPException(404, f"Case {case_id} not found in published index or graph")
595
+
596
+ return {
597
+ "status": "unpublished",
598
+ "case_id": case_id,
599
+ "entities_removed": removed_entities,
600
+ "metadata_removed": was_published,
601
+ }