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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/hook.js +22 -0
- package/src/commands/init.js +12 -11
- package/src/commands/remove.js +2 -0
- package/src/generator/express.js +26 -1
- package/src/generator/fastapi.js +26 -1
- package/src/generator/manifest.js +20 -4
- package/src/parser/express.js +43 -1
- package/src/parser/fastapi_extract.py +132 -32
- package/src/server/condenser.js +145 -0
- package/src/server/crystallize.js +89 -0
- package/src/server/idle.js +42 -0
- package/src/server/stdio.js +248 -8
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +215 -20
- package/templates/fastapi-router.txt +208 -35
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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})
|