sparda-mcp 0.3.0 → 0.5.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.
@@ -13,9 +13,11 @@ import asyncio
13
13
  import time
14
14
  import datetime
15
15
  import os
16
+ import zlib
16
17
 
17
18
  # json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
18
19
  SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
20
+ SPARDA_POLICIES = json.loads(__SPARDING_POLICIES__)
19
21
  SPARDA_LOCAL_KEY = "__LOCAL_KEY__"
20
22
  SPARDA_PORT = __PORT__
21
23
 
@@ -26,6 +28,50 @@ SPARDA_START = time.time()
26
28
  # immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
27
29
  SPARDA_QUARANTINE = {}
28
30
  SPARDA_QUARANTINE_MS = int(os.environ.get("SPARDA_QUARANTINE_MS", "60000"))
31
+ # recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
32
+ # Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
33
+ SPARDA_RECYCLE = {"servedByCircle": 0, "paidFull": 0}
34
+
35
+ def sparda_recycle_rate():
36
+ total = SPARDA_RECYCLE["servedByCircle"] + SPARDA_RECYCLE["paidFull"]
37
+ return round(SPARDA_RECYCLE["servedByCircle"] * 100 / total) if total else 0
38
+
39
+ # thermodynamic route classification: a GET whose repeated identical args keep
40
+ # returning identical bodies is observed-pure (its result pre-exists — recyclable);
41
+ # writes erase by definition. Observation only, never a guess.
42
+ SPARDA_PURITY = {}
43
+
44
+ def sparda_observe_purity(tool, argsig, body_bytes):
45
+ pu = SPARDA_PURITY.setdefault(tool, {"sigs": {}, "repeats": 0, "mismatches": 0})
46
+ h = zlib.crc32(body_bytes[:65536]) # a fingerprint, not a checksum of megabytes
47
+ known = pu["sigs"].get(argsig)
48
+ if known is None:
49
+ if len(pu["sigs"]) >= 20: # bounded: enough sigs to judge
50
+ return
51
+ pu["sigs"][argsig] = h
52
+ elif known == h:
53
+ pu["repeats"] += 1
54
+ else:
55
+ pu["mismatches"] += 1
56
+ pu["sigs"][argsig] = h # the latest real answer is the truth
57
+
58
+ def sparda_purity_snapshot():
59
+ out = {}
60
+ for name, spec in SPARDA_TOOLS.items():
61
+ if spec.get("method") != "GET":
62
+ out[name] = {"class": "erasing", "repeats": 0, "mismatches": 0}
63
+ continue
64
+ pu = SPARDA_PURITY.get(name)
65
+ if not pu:
66
+ cls = "unknown"
67
+ elif pu["mismatches"] > 0:
68
+ cls = "volatile"
69
+ elif pu["repeats"] >= 3:
70
+ cls = "pure"
71
+ else:
72
+ cls = "unknown"
73
+ out[name] = {"class": cls, "repeats": pu["repeats"] if pu else 0, "mismatches": pu["mismatches"] if pu else 0}
74
+ return out
29
75
 
30
76
  def sparda_event(source, tool, status, message):
31
77
  global SPARDA_SEQ
@@ -40,14 +86,17 @@ def sparda_event(source, tool, status, message):
40
86
  SPARDA_EVENTS.pop(0)
41
87
 
42
88
  def sparda_record(tool, status, ms):
43
- st = SPARDA_STATS.setdefault(tool, {"calls": 0, "errors": 0, "totalMs": 0, "lastStatus": None, "lastTs": None, "consecutive5xx": 0})
89
+ st = SPARDA_STATS.setdefault(tool, {"calls": 0, "errors": 0, "clientErrors": 0, "totalMs": 0, "lastStatus": None, "lastTs": None, "consecutive5xx": 0})
44
90
  # innate immunity: latency far beyond the learned baseline is an antigen
45
91
  if st["calls"] >= 5 and ms > max((st["totalMs"] / st["calls"]) * 10, 200):
46
92
  sparda_event("immune", tool, status, f"latency anomaly: {ms}ms vs ~{round(st['totalMs'] / st['calls'])}ms baseline")
47
93
  st["calls"] += 1
48
94
  st["totalMs"] += ms
49
- if status >= 400:
95
+ # 5xx = server failure (feeds the immune system); 4xx = a valid client answer (404 etc), tracked apart so the count doesn't lie
96
+ if status >= 500:
50
97
  st["errors"] += 1
98
+ elif status >= 400:
99
+ st["clientErrors"] += 1
51
100
  st["lastStatus"] = status
52
101
  st["lastTs"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
53
102
  if status >= 500:
@@ -62,6 +111,120 @@ def sparda_record(tool, status, ms):
62
111
  }
63
112
  sparda_event("immune", tool, 503, f"quarantined after {st['consecutive5xx']} consecutive 5xx (cooldown {SPARDA_QUARANTINE_MS}ms)")
64
113
 
114
+ def sparda_proof(tool, spec, args):
115
+ checks = {
116
+ "knownTool": spec is not None,
117
+ "enabled": spec.get("enabled", False) if spec else False,
118
+ "loopSafe": not spec.get("path", "").startswith("/mcp") if spec else False,
119
+ "methodClass": "read" if spec and spec.get("method") == "GET" else "write",
120
+ "pathParamsPresent": True,
121
+ "hasBodyForWrite": True,
122
+ "reversibleHint": False,
123
+ "quarantineSafe": True,
124
+ }
125
+
126
+ reasons = []
127
+
128
+ if not checks["knownTool"]:
129
+ reasons.append("unknown tool")
130
+ return {
131
+ "version": "sparding-proof/v0.1",
132
+ "risk": "blocked",
133
+ "decision": "block",
134
+ "reasons": reasons,
135
+ "checks": checks,
136
+ }
137
+
138
+ if not checks["enabled"]:
139
+ reasons.append("tool disabled (write-safety)")
140
+ if not checks["loopSafe"]:
141
+ reasons.append("self-referential tool blocked (loop protection)")
142
+
143
+ quarantined = SPARDA_QUARANTINE.get(tool)
144
+ if quarantined:
145
+ if (time.time() * 1000) < quarantined["until"]:
146
+ checks["quarantineSafe"] = False
147
+ reasons.append(f"tool quarantined (immune system): {quarantined['reason']}")
148
+ else:
149
+ del SPARDA_QUARANTINE[tool]
150
+ if tool in SPARDA_STATS:
151
+ SPARDA_STATS[tool]["consecutive5xx"] = 2
152
+
153
+ for name in spec.get("pathParams", []):
154
+ if args.get(name) is None:
155
+ checks["pathParamsPresent"] = False
156
+ reasons.append(f"missing path param: {name}")
157
+
158
+ if checks["methodClass"] == "write":
159
+ if args.get("body") is None:
160
+ checks["hasBodyForWrite"] = False
161
+
162
+ if spec.get("method") == "GET":
163
+ checks["reversibleHint"] = True
164
+ else:
165
+ checks["reversibleHint"] = any(t.get("method") == "GET" and t.get("path") == spec.get("path") for t in SPARDA_TOOLS.values())
166
+
167
+ risk = "low"
168
+ decision = "allow"
169
+
170
+ if not checks["enabled"] or not checks["loopSafe"] or not checks["quarantineSafe"] or not checks["pathParamsPresent"]:
171
+ decision = "block"
172
+ risk = "blocked"
173
+ return {
174
+ "version": "sparding-proof/v0.1",
175
+ "risk": risk,
176
+ "decision": decision,
177
+ "reasons": reasons,
178
+ "checks": checks,
179
+ }
180
+
181
+ policies = SPARDA_POLICIES or {}
182
+ if checks["methodClass"] == "read":
183
+ read_policy = policies.get("reads", "allow")
184
+ if read_policy == "block":
185
+ decision = "block"
186
+ risk = "blocked"
187
+ reasons.append("read policy blocks execution")
188
+ elif read_policy == "require_human":
189
+ decision = "require_human"
190
+ risk = "medium"
191
+ reasons.append("read policy requires human confirmation")
192
+ else:
193
+ is_delete = spec.get("method") == "DELETE"
194
+ delete_policy = policies.get("deletes", "block")
195
+ write_policy = policies.get("writes", "require_human")
196
+
197
+ if is_delete and delete_policy == "block":
198
+ decision = "block"
199
+ risk = "blocked"
200
+ reasons.append("delete policy blocks execution")
201
+ elif is_delete and delete_policy == "require_human":
202
+ decision = "require_human"
203
+ risk = "high"
204
+ reasons.append("delete operation requires human confirmation")
205
+ elif is_delete and delete_policy == "allow":
206
+ decision = "allow"
207
+ risk = "low"
208
+ elif write_policy == "block":
209
+ decision = "block"
210
+ risk = "blocked"
211
+ reasons.append("write policy blocks execution")
212
+ elif write_policy == "require_human":
213
+ decision = "require_human"
214
+ risk = "medium"
215
+ reasons.append("write operation requires human confirmation")
216
+ else:
217
+ decision = "allow"
218
+ risk = "low"
219
+
220
+ return {
221
+ "version": "sparding-proof/v0.1",
222
+ "risk": risk,
223
+ "decision": decision,
224
+ "reasons": reasons,
225
+ "checks": checks,
226
+ }
227
+
65
228
  sparda_router = APIRouter()
66
229
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
67
230
 
@@ -88,7 +251,7 @@ async def get_tools(request: Request):
88
251
  async def get_stats(request: Request):
89
252
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
90
253
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
91
- return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE}
254
+ return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE, "recycle": {**SPARDA_RECYCLE, "ratePct": sparda_recycle_rate()}, "purity": sparda_purity_snapshot()}
92
255
 
93
256
  @sparda_router.get("/events")
94
257
  async def get_events(request: Request):
@@ -114,42 +277,51 @@ async def invoke_tool(request: Request):
114
277
  args = body.get("args", {})
115
278
 
116
279
  spec = SPARDA_TOOLS.get(tool)
117
- if not spec:
118
- return JSONResponse(status_code=404, content={"error": f"unknown tool: {tool}"})
119
-
120
- if not spec.get("enabled"):
121
- return JSONResponse(status_code=403, content={
122
- "error": f"tool disabled (write-safety): {tool}",
123
- "hint": "Enable it in sparda.json, then re-run: npx sparda-mcp init"
124
- })
125
-
126
- path = spec.get("path")
127
- if path.startswith("/mcp"):
128
- return JSONResponse(status_code=400, content={"error": "self-referential tool blocked (loop protection)"})
280
+ proof = sparda_proof(tool, spec, args)
129
281
 
130
- quarantined = SPARDA_QUARANTINE.get(tool)
131
- if quarantined:
132
- now_ms = time.time() * 1000
133
- if now_ms < quarantined["until"]:
134
- return JSONResponse(status_code=503, content={
135
- "error": f"tool quarantined (immune system): {tool}",
136
- "reason": quarantined["reason"],
137
- "retryInMs": round(quarantined["until"] - now_ms),
282
+ if proof["decision"] == "block":
283
+ status = 400
284
+ error = "bad request"
285
+ if not proof["checks"]["knownTool"]:
286
+ status = 404
287
+ error = f"unknown tool: {tool}"
288
+ elif not proof["checks"]["enabled"]:
289
+ status = 403
290
+ error = f"tool disabled (write-safety): {tool}"
291
+ return JSONResponse(status_code=status, content={
292
+ "error": error,
293
+ "hint": "Enable it in sparda.json, then re-run: npx sparda-mcp init",
294
+ "spardingProof": proof
295
+ })
296
+ elif not proof["checks"]["loopSafe"]:
297
+ status = 400
298
+ error = "self-referential tool blocked (loop protection)"
299
+ elif not proof["checks"]["quarantineSafe"]:
300
+ status = 503
301
+ quarantined = SPARDA_QUARANTINE.get(tool)
302
+ error = f"tool quarantined (immune system): {tool}"
303
+ SPARDA_RECYCLE["servedByCircle"] += 1
304
+ return JSONResponse(status_code=status, content={
305
+ "error": error,
306
+ "reason": quarantined["reason"] if quarantined else "",
307
+ "retryInMs": round(quarantined["until"] - time.time() * 1000) if quarantined else 0,
308
+ "spardingProof": proof
138
309
  })
139
- # half-open: cooldown elapsed — allow one probe; a single new 5xx re-quarantines
140
- del SPARDA_QUARANTINE[tool]
141
- if tool in SPARDA_STATS:
142
- SPARDA_STATS[tool]["consecutive5xx"] = 2
310
+ else:
311
+ status = 400
312
+ error = proof["reasons"][0] if proof["reasons"] else "blocked"
313
+ if any("policy blocks execution" in r for r in proof["reasons"]):
314
+ status = 403
315
+ return JSONResponse(status_code=status, content={"error": error, "spardingProof": proof})
143
316
 
144
317
  t0 = time.time()
145
318
  try:
146
319
  path_params = spec.get("pathParams", [])
320
+ path = spec.get("path")
147
321
 
148
322
  def repl(match):
149
323
  name = match.group(1)
150
324
  val = args.get(name)
151
- if val is None:
152
- raise HTTPException(status_code=400, detail=f"missing path param: {name}")
153
325
  return urllib.parse.quote(str(val))
154
326
 
155
327
  resolved_path = re.sub(r'\{(\w+)\}', repl, path)
@@ -173,6 +345,7 @@ async def invoke_tool(request: Request):
173
345
  headers["content-type"] = "application/json"
174
346
  body_bytes = json.dumps(args["body"]).encode("utf-8")
175
347
 
348
+ SPARDA_RECYCLE["paidFull"] += 1 # the host route is about to be exercised — full price
176
349
  loop = asyncio.get_running_loop()
177
350
  status, content_type, data_bytes = await loop.run_in_executor(
178
351
  executor, sync_fetch, url, method, headers, body_bytes
@@ -185,14 +358,14 @@ async def invoke_tool(request: Request):
185
358
  data = text_data
186
359
 
187
360
  sparda_record(tool, status, round((time.time() - t0) * 1000))
361
+ if method == "GET" and status == 200:
362
+ # canonical argsig: sorted items, so the AI's argument order never splits a signature
363
+ argsig = json.dumps(sorted((k, str(v)) for k, v in args.items() if v is not None))
364
+ sparda_observe_purity(tool, argsig, data_bytes)
188
365
  if status >= 500:
189
366
  sparda_event("invoke", tool, status, text_data[:200])
190
- return {"upstreamStatus": status, "data": data}
191
- except HTTPException as e:
192
- sparda_record(tool, e.status_code, round((time.time() - t0) * 1000))
193
- sparda_event("invoke", tool, e.status_code, e.detail)
194
- return JSONResponse(status_code=e.status_code, content={"error": e.detail})
367
+ return {"upstreamStatus": status, "data": data, "spardingProof": proof}
195
368
  except Exception as e:
196
369
  sparda_record(tool, 502, round((time.time() - t0) * 1000))
197
370
  sparda_event("invoke", tool, 502, str(e))
198
- return JSONResponse(status_code=502, content={"error": str(e)})
371
+ return JSONResponse(status_code=502, content={"error": str(e), "spardingProof": proof})