sparda-mcp 0.5.4 → 0.7.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 +4 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +42 -1
- package/src/commands/init.js +42 -13
- package/src/commands/negentropy.js +234 -0
- package/src/commands/remove.js +17 -0
- package/src/commands/seed.js +282 -0
- package/src/commands/sync.js +29 -16
- package/src/detect.js +41 -1
- package/src/generator/express.js +2 -1
- package/src/generator/nextjs.js +113 -0
- package/src/index.js +12 -1
- package/src/parser/nextjs.js +248 -0
- package/src/server/stdio.js +1 -1
- package/templates/nextjs-router.txt +383 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// parser/nextjs.js — App Router route-handler extraction.
|
|
2
|
+
// The filesystem IS the router: a route.{js,ts} file's directory chain is the
|
|
3
|
+
// URL. We walk app/ (groups stripped, dynamic segments mapped to :params) and
|
|
4
|
+
// AST-parse each route file for its exported HTTP-verb handlers. Catch-all,
|
|
5
|
+
// optional catch-all, parallel slots and interception segments are skipped
|
|
6
|
+
// with a reason (scan-report), never guessed.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { parse } from '@babel/parser';
|
|
10
|
+
|
|
11
|
+
const VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
12
|
+
const ROUTE_FILES = new Set([
|
|
13
|
+
'route.js',
|
|
14
|
+
'route.ts',
|
|
15
|
+
'route.mjs',
|
|
16
|
+
'route.jsx',
|
|
17
|
+
'route.tsx',
|
|
18
|
+
]);
|
|
19
|
+
const EXCLUDE = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.sparda']);
|
|
20
|
+
|
|
21
|
+
export function parseNextProject(cwd, appDir) {
|
|
22
|
+
const routes = [];
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const seen = new Set(); // `${METHOD} ${path}` — group collapse can collide: (a)/x + (b)/x → /x
|
|
25
|
+
|
|
26
|
+
walk(path.resolve(cwd, appDir), []);
|
|
27
|
+
return { routes, skipped };
|
|
28
|
+
|
|
29
|
+
function walk(dir, segments) {
|
|
30
|
+
let items;
|
|
31
|
+
try {
|
|
32
|
+
items = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const abs = path.join(dir, item.name);
|
|
38
|
+
if (item.isDirectory()) {
|
|
39
|
+
const name = item.name;
|
|
40
|
+
if (EXCLUDE.has(name)) continue;
|
|
41
|
+
if (name.startsWith('_')) continue; // private folder — excluded from routing by Next itself
|
|
42
|
+
if (name.startsWith('@')) {
|
|
43
|
+
skipped.push({
|
|
44
|
+
reason: `parallel route slot ${name} — not a URL segment, skipped`,
|
|
45
|
+
file: rel(abs),
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (/^\(\.{1,3}\)/.test(name)) {
|
|
50
|
+
skipped.push({
|
|
51
|
+
reason: `intercepting route ${name} — UI-layer routing, skipped`,
|
|
52
|
+
file: rel(abs),
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (/^\[\[\.\.\..+\]\]$/.test(name) || /^\[\.\.\..+\]$/.test(name)) {
|
|
57
|
+
skipped.push({
|
|
58
|
+
reason: `catch-all segment ${name} — variable-arity paths not supported in v1`,
|
|
59
|
+
file: rel(abs),
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (/^\(.+\)$/.test(name)) {
|
|
64
|
+
walk(abs, segments); // route group — organizational only, stripped from the URL
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const m = name.match(/^\[(.+)\]$/);
|
|
68
|
+
walk(abs, [...segments, m ? `:${m[1]}` : name]);
|
|
69
|
+
} else if (item.isFile() && ROUTE_FILES.has(item.name)) {
|
|
70
|
+
const urlPath = '/' + segments.join('/');
|
|
71
|
+
if (urlPath === '/mcp' || urlPath.startsWith('/mcp/')) {
|
|
72
|
+
skipped.push({
|
|
73
|
+
reason: `self-referential path ${urlPath} blocked`,
|
|
74
|
+
file: rel(abs),
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
parseRouteFile(abs, urlPath || '/');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseRouteFile(absFile, urlPath) {
|
|
84
|
+
let src;
|
|
85
|
+
try {
|
|
86
|
+
src = fs.readFileSync(absFile, 'utf8');
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let ast;
|
|
91
|
+
try {
|
|
92
|
+
ast = parse(src, {
|
|
93
|
+
sourceType: 'unambiguous',
|
|
94
|
+
plugins: ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: true }]],
|
|
95
|
+
attachComment: true,
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
skipped.push({
|
|
99
|
+
reason: `parse error in ${rel(absFile)}: ${e.message.slice(0, 80)}`,
|
|
100
|
+
file: rel(absFile),
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const node of ast.program.body) {
|
|
106
|
+
if (node.type !== 'ExportNamedDeclaration') continue;
|
|
107
|
+
const found = []; // [{ verb, fnNode|null }]
|
|
108
|
+
|
|
109
|
+
const decl = node.declaration;
|
|
110
|
+
if (decl?.type === 'FunctionDeclaration' && VERBS.has(decl.id?.name)) {
|
|
111
|
+
found.push({ verb: decl.id.name, fnNode: decl });
|
|
112
|
+
} else if (decl?.type === 'VariableDeclaration') {
|
|
113
|
+
for (const d of decl.declarations) {
|
|
114
|
+
if (d.id?.type !== 'Identifier' || !VERBS.has(d.id.name)) continue;
|
|
115
|
+
const isFn =
|
|
116
|
+
d.init?.type === 'ArrowFunctionExpression' ||
|
|
117
|
+
d.init?.type === 'FunctionExpression';
|
|
118
|
+
found.push({ verb: d.id.name, fnNode: isFn ? d.init : null });
|
|
119
|
+
}
|
|
120
|
+
} else if (!decl && node.specifiers?.length) {
|
|
121
|
+
// export { GET } [from './impl'] — verb confirmed, body out of reach
|
|
122
|
+
for (const spec of node.specifiers) {
|
|
123
|
+
const name = spec.exported?.name ?? spec.exported?.value;
|
|
124
|
+
if (VERBS.has(name)) found.push({ verb: name, fnNode: null });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const { verb, fnNode } of found) {
|
|
129
|
+
const method = verb.toLowerCase();
|
|
130
|
+
const key = `${verb} ${urlPath}`;
|
|
131
|
+
if (seen.has(key)) {
|
|
132
|
+
skipped.push({
|
|
133
|
+
reason: `route group collision: ${key} already extracted from another group`,
|
|
134
|
+
file: rel(absFile),
|
|
135
|
+
line: node.loc?.start.line,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
seen.add(key);
|
|
140
|
+
|
|
141
|
+
const description = (node.leadingComments ?? [])
|
|
142
|
+
.map((c) =>
|
|
143
|
+
c.value
|
|
144
|
+
.replace(/^\*+/gm, '')
|
|
145
|
+
.replace(/^\s*\*\s?/gm, '')
|
|
146
|
+
.trim(),
|
|
147
|
+
)
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join(' ')
|
|
150
|
+
.slice(0, 400);
|
|
151
|
+
|
|
152
|
+
const params = [...urlPath.matchAll(/:(\w+)/g)].map((mm) => ({
|
|
153
|
+
name: mm[1],
|
|
154
|
+
in: 'path',
|
|
155
|
+
type: 'string',
|
|
156
|
+
required: true,
|
|
157
|
+
description: 'path parameter',
|
|
158
|
+
}));
|
|
159
|
+
const taken = new Set(params.map((x) => x.name));
|
|
160
|
+
for (const q of queryParamsOf(fnNode)) {
|
|
161
|
+
if (taken.has(q)) continue;
|
|
162
|
+
taken.add(q);
|
|
163
|
+
params.push({
|
|
164
|
+
name: q,
|
|
165
|
+
in: 'query',
|
|
166
|
+
type: 'string',
|
|
167
|
+
required: false,
|
|
168
|
+
description: 'query parameter',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mutating = method !== 'get';
|
|
173
|
+
let confidence = fnNode ? 'high' : 'low'; // re-exported handler body is unreadable here
|
|
174
|
+
if (mutating) {
|
|
175
|
+
params.push({
|
|
176
|
+
name: 'body',
|
|
177
|
+
in: 'body',
|
|
178
|
+
type: 'object',
|
|
179
|
+
required: false,
|
|
180
|
+
description: 'JSON body — schema not statically detected',
|
|
181
|
+
});
|
|
182
|
+
confidence = 'low';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
routes.push({
|
|
186
|
+
method,
|
|
187
|
+
path: urlPath,
|
|
188
|
+
handlerName: verb,
|
|
189
|
+
sourceFile: rel(absFile),
|
|
190
|
+
sourceLine: node.loc?.start.line ?? 0,
|
|
191
|
+
params,
|
|
192
|
+
description,
|
|
193
|
+
mutating,
|
|
194
|
+
confidence,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rel(abs) {
|
|
201
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Query params only surface in the handler body — the canonical App Router
|
|
206
|
+
// shapes are `searchParams.get('x')` / `.getAll('x')`, whether searchParams
|
|
207
|
+
// came from `new URL(request.url)` or `request.nextUrl`. Plain recursive AST
|
|
208
|
+
// walk (no scope needed), bounded to 15 like the Express scanner.
|
|
209
|
+
function queryParamsOf(fnNode) {
|
|
210
|
+
const found = [];
|
|
211
|
+
if (!fnNode) return found;
|
|
212
|
+
visit(fnNode.body);
|
|
213
|
+
return [...new Set(found)];
|
|
214
|
+
|
|
215
|
+
function visit(node) {
|
|
216
|
+
if (!node || typeof node !== 'object' || found.length >= 15) return;
|
|
217
|
+
if (Array.isArray(node)) {
|
|
218
|
+
for (const n of node) visit(n);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
node.type === 'CallExpression' &&
|
|
223
|
+
node.callee?.type === 'MemberExpression' &&
|
|
224
|
+
!node.callee.computed &&
|
|
225
|
+
(node.callee.property?.name === 'get' || node.callee.property?.name === 'getAll') &&
|
|
226
|
+
node.arguments[0]?.type === 'StringLiteral'
|
|
227
|
+
) {
|
|
228
|
+
const obj = node.callee.object;
|
|
229
|
+
const isSearchParams =
|
|
230
|
+
(obj.type === 'Identifier' && obj.name === 'searchParams') ||
|
|
231
|
+
(obj.type === 'MemberExpression' &&
|
|
232
|
+
!obj.computed &&
|
|
233
|
+
obj.property?.name === 'searchParams');
|
|
234
|
+
if (isSearchParams) found.push(node.arguments[0].value);
|
|
235
|
+
}
|
|
236
|
+
for (const k of Object.keys(node)) {
|
|
237
|
+
if (
|
|
238
|
+
k === 'loc' ||
|
|
239
|
+
k === 'range' ||
|
|
240
|
+
k === 'leadingComments' ||
|
|
241
|
+
k === 'trailingComments'
|
|
242
|
+
)
|
|
243
|
+
continue;
|
|
244
|
+
const v = node[k];
|
|
245
|
+
if (v && typeof v === 'object') visit(v);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/server/stdio.js
CHANGED
|
@@ -1059,7 +1059,7 @@ async function waitForHost(port, key, framework, ctx) {
|
|
|
1059
1059
|
}
|
|
1060
1060
|
}
|
|
1061
1061
|
if (attempt === 0) {
|
|
1062
|
-
const startCmd = framework === '
|
|
1062
|
+
const startCmd = framework === 'fastapi' ? 'fastapi dev' : 'npm run dev';
|
|
1063
1063
|
console.error(
|
|
1064
1064
|
`[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`,
|
|
1065
1065
|
);
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// SPARDA ROUTER (Next.js App Router) — Auto-generated. DO NOT EDIT.
|
|
3
|
+
// Regenerate: npx sparda-mcp init • Remove: npx sparda-mcp remove
|
|
4
|
+
// File-based injection: this catch-all handler IS the /mcp router.
|
|
5
|
+
// Web-standard Request/Response only — zero imports, zero dependencies.
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
const SPARDA_TOOLS = __TOOLS_JSON__;
|
|
9
|
+
const SPARDA_POLICIES = __SPARDING_POLICIES__;
|
|
10
|
+
|
|
11
|
+
const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
|
|
12
|
+
const SPARDA_PORT = __PORT__;
|
|
13
|
+
|
|
14
|
+
// never statically optimized, always on the Node.js runtime (process, timers)
|
|
15
|
+
export const dynamic = 'force-dynamic';
|
|
16
|
+
export const runtime = 'nodejs';
|
|
17
|
+
|
|
18
|
+
// Module-scope state survives requests on a long-lived server (next start /
|
|
19
|
+
// next dev). On serverless each instance keeps its own gauges — RAM-only by
|
|
20
|
+
// design, the durable memory lives in sparda.json via the bridge.
|
|
21
|
+
const SPARDA_STATS = {};
|
|
22
|
+
const SPARDA_EVENTS = [];
|
|
23
|
+
let SPARDA_SEQ = 0;
|
|
24
|
+
// immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
|
|
25
|
+
const SPARDA_QUARANTINE = {};
|
|
26
|
+
const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
|
|
27
|
+
// recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
|
|
28
|
+
const SPARDA_RECYCLE = { servedByCircle: 0, paidFull: 0 };
|
|
29
|
+
function spardaRecycleRate() {
|
|
30
|
+
const total = SPARDA_RECYCLE.servedByCircle + SPARDA_RECYCLE.paidFull;
|
|
31
|
+
return total ? Math.round((SPARDA_RECYCLE.servedByCircle * 100) / total) : 0;
|
|
32
|
+
}
|
|
33
|
+
// thermodynamic route classification — observation only, never a guess.
|
|
34
|
+
const SPARDA_PURITY = {};
|
|
35
|
+
function spardaHash(s) {
|
|
36
|
+
let h = 0x811c9dc5; // FNV-1a, capped: a fingerprint, not a checksum of megabytes
|
|
37
|
+
for (let i = 0; i < s.length && i < 65536; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
38
|
+
return h;
|
|
39
|
+
}
|
|
40
|
+
function spardaObservePurity(tool, argsig, body) {
|
|
41
|
+
const pu = SPARDA_PURITY[tool] ?? (SPARDA_PURITY[tool] = { sigs: {}, repeats: 0, mismatches: 0 });
|
|
42
|
+
const h = spardaHash(body);
|
|
43
|
+
const known = pu.sigs[argsig];
|
|
44
|
+
if (known === undefined) {
|
|
45
|
+
if (Object.keys(pu.sigs).length >= 20) return; // bounded: enough sigs to judge
|
|
46
|
+
pu.sigs[argsig] = h;
|
|
47
|
+
} else if (known === h) pu.repeats += 1;
|
|
48
|
+
else { pu.mismatches += 1; pu.sigs[argsig] = h; } // the latest real answer is the truth
|
|
49
|
+
}
|
|
50
|
+
function spardaPuritySnapshot() {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [name, spec] of Object.entries(SPARDA_TOOLS)) {
|
|
53
|
+
if (spec.method !== 'GET') { out[name] = { class: 'erasing', repeats: 0, mismatches: 0 }; continue; }
|
|
54
|
+
const pu = SPARDA_PURITY[name];
|
|
55
|
+
out[name] = {
|
|
56
|
+
class: !pu ? 'unknown' : pu.mismatches > 0 ? 'volatile' : pu.repeats >= 3 ? 'pure' : 'unknown',
|
|
57
|
+
repeats: pu ? pu.repeats : 0,
|
|
58
|
+
mismatches: pu ? pu.mismatches : 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
// ── horizontal scale: Quorum Sensing × G-Map CRDT (same contract as Express) ──
|
|
64
|
+
const SPARDA_PEERS = (process.env.SPARDA_PEERS ?? '').split(',').map((s) => s.trim().replace(/\/$/, '')).filter((s) => /^https?:\/\//.test(s));
|
|
65
|
+
const SPARDA_GOSSIP_MS = Math.max(50, Number(process.env.SPARDA_GOSSIP_MS) || 30000);
|
|
66
|
+
const SPARDA_GOSSIP_TIMEOUT_MS = Math.max(250, Number(process.env.SPARDA_GOSSIP_TIMEOUT_MS) || 2000);
|
|
67
|
+
function spardaGossipSnapshot() {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [name, pu] of Object.entries(SPARDA_PURITY)) out[name] = { repeats: pu.repeats, mismatches: pu.mismatches };
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function spardaMergeGossip(remote) {
|
|
73
|
+
if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return 0;
|
|
74
|
+
let merged = 0;
|
|
75
|
+
for (const [name, rv] of Object.entries(remote)) {
|
|
76
|
+
if (!SPARDA_TOOLS[name] || !rv || typeof rv !== 'object') continue;
|
|
77
|
+
const r = Number(rv.repeats), m = Number(rv.mismatches);
|
|
78
|
+
if (!Number.isFinite(r) || !Number.isFinite(m) || r < 0 || m < 0) continue;
|
|
79
|
+
const pu = SPARDA_PURITY[name] ?? (SPARDA_PURITY[name] = { sigs: {}, repeats: 0, mismatches: 0 });
|
|
80
|
+
if (r > pu.repeats) pu.repeats = r;
|
|
81
|
+
if (m > pu.mismatches) pu.mismatches = m;
|
|
82
|
+
merged += 1;
|
|
83
|
+
}
|
|
84
|
+
return merged;
|
|
85
|
+
}
|
|
86
|
+
function spardaGossipTick() {
|
|
87
|
+
if (SPARDA_PEERS.length === 0) return;
|
|
88
|
+
const body = JSON.stringify(spardaGossipSnapshot());
|
|
89
|
+
for (const peer of SPARDA_PEERS) {
|
|
90
|
+
fetch(`${peer}/mcp/gossip`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json', 'x-sparda-key': SPARDA_LOCAL_KEY },
|
|
93
|
+
body,
|
|
94
|
+
signal: AbortSignal.timeout(SPARDA_GOSSIP_TIMEOUT_MS),
|
|
95
|
+
}).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function spardaEvent(source, tool, status, message) {
|
|
99
|
+
SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
|
|
100
|
+
if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
|
|
101
|
+
}
|
|
102
|
+
// hot-reload guard: dev re-evaluates this module; process listeners and timers
|
|
103
|
+
// must be registered exactly once per process.
|
|
104
|
+
if (!globalThis.__SPARDA_WIRED__) {
|
|
105
|
+
globalThis.__SPARDA_WIRED__ = true;
|
|
106
|
+
process.on('uncaughtExceptionMonitor', (err) => spardaEvent('process', null, null, err && err.message ? err.message : err));
|
|
107
|
+
if (SPARDA_PEERS.length > 0) {
|
|
108
|
+
spardaEvent('gossip', null, null, `horizontal scale: gossiping to ${SPARDA_PEERS.length} peer(s) every ${SPARDA_GOSSIP_MS}ms`);
|
|
109
|
+
setInterval(spardaGossipTick, SPARDA_GOSSIP_MS).unref();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
|
|
114
|
+
const SPARDA_PENDING = {};
|
|
115
|
+
const SPARDA_CONFIRM_TTL_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120000);
|
|
116
|
+
function spardaNonce() {
|
|
117
|
+
return 'cfm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
118
|
+
}
|
|
119
|
+
function spardaSweepPending() {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
for (const k of Object.keys(SPARDA_PENDING)) if (now > SPARDA_PENDING[k].expiresAt) delete SPARDA_PENDING[k];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function spardaProof(tool, spec, args) {
|
|
125
|
+
const checks = {
|
|
126
|
+
knownTool: spec !== undefined,
|
|
127
|
+
enabled: spec ? spec.enabled : false,
|
|
128
|
+
loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
|
|
129
|
+
methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
|
|
130
|
+
pathParamsPresent: true,
|
|
131
|
+
hasBodyForWrite: true,
|
|
132
|
+
reversibleHint: false,
|
|
133
|
+
quarantineSafe: true,
|
|
134
|
+
};
|
|
135
|
+
const reasons = [];
|
|
136
|
+
if (!checks.knownTool) {
|
|
137
|
+
reasons.push('unknown tool');
|
|
138
|
+
return { version: 'sparding-proof/v0.1', risk: 'blocked', decision: 'block', reasons, checks };
|
|
139
|
+
}
|
|
140
|
+
if (!checks.enabled) reasons.push('tool disabled (write-safety)');
|
|
141
|
+
if (!checks.loopSafe) reasons.push('self-referential tool blocked (loop protection)');
|
|
142
|
+
const quarantined = SPARDA_QUARANTINE[tool];
|
|
143
|
+
if (quarantined) {
|
|
144
|
+
if (Date.now() < quarantined.until) {
|
|
145
|
+
checks.quarantineSafe = false;
|
|
146
|
+
reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
|
|
147
|
+
} else {
|
|
148
|
+
delete SPARDA_QUARANTINE[tool];
|
|
149
|
+
if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const name of spec.pathParams ?? []) {
|
|
153
|
+
if (args[name] === undefined) {
|
|
154
|
+
checks.pathParamsPresent = false;
|
|
155
|
+
reasons.push(`missing path param: ${name}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (checks.methodClass === 'write' && args.body === undefined) checks.hasBodyForWrite = false;
|
|
159
|
+
if (spec.method === 'GET') checks.reversibleHint = true;
|
|
160
|
+
else checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t) => t.method === 'GET' && t.path === spec.path);
|
|
161
|
+
|
|
162
|
+
let risk = 'low';
|
|
163
|
+
let decision = 'allow';
|
|
164
|
+
if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
|
|
165
|
+
return { version: 'sparding-proof/v0.1', risk: 'blocked', decision: 'block', reasons, checks };
|
|
166
|
+
}
|
|
167
|
+
const policies = SPARDA_POLICIES ?? {};
|
|
168
|
+
if (checks.methodClass === 'read') {
|
|
169
|
+
const readPolicy = policies.reads ?? 'allow';
|
|
170
|
+
if (readPolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('read policy blocks execution'); }
|
|
171
|
+
else if (readPolicy === 'require_human') { decision = 'require_human'; risk = 'medium'; reasons.push('read policy requires human confirmation'); }
|
|
172
|
+
} else {
|
|
173
|
+
const isDelete = spec.method === 'DELETE';
|
|
174
|
+
const deletePolicy = policies.deletes ?? 'block';
|
|
175
|
+
const writePolicy = policies.writes ?? 'require_human';
|
|
176
|
+
if (isDelete && deletePolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('delete policy blocks execution'); }
|
|
177
|
+
else if (isDelete && deletePolicy === 'require_human') { decision = 'require_human'; risk = 'high'; reasons.push('delete operation requires human confirmation'); }
|
|
178
|
+
else if (isDelete && deletePolicy === 'allow') { decision = 'allow'; risk = 'low'; }
|
|
179
|
+
else if (writePolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('write policy blocks execution'); }
|
|
180
|
+
else if (writePolicy === 'require_human') { decision = 'require_human'; risk = 'medium'; reasons.push('write operation requires human confirmation'); }
|
|
181
|
+
else { decision = 'allow'; risk = 'low'; }
|
|
182
|
+
}
|
|
183
|
+
return { version: 'sparding-proof/v0.1', risk, decision, reasons, checks };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function spardaRecord(tool, status, ms) {
|
|
187
|
+
const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
|
|
188
|
+
if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
|
|
189
|
+
spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
|
|
190
|
+
}
|
|
191
|
+
st.calls += 1;
|
|
192
|
+
st.totalMs += ms;
|
|
193
|
+
if (status >= 500) st.errors += 1;
|
|
194
|
+
else if (status >= 400) st.clientErrors += 1;
|
|
195
|
+
st.lastStatus = status;
|
|
196
|
+
st.lastTs = new Date().toISOString();
|
|
197
|
+
if (status >= 500) st.consecutive5xx += 1;
|
|
198
|
+
else if (status < 400) st.consecutive5xx = 0;
|
|
199
|
+
if (st.consecutive5xx >= 3 && !SPARDA_QUARANTINE[tool]) {
|
|
200
|
+
SPARDA_QUARANTINE[tool] = { since: new Date().toISOString(), until: Date.now() + SPARDA_QUARANTINE_MS, reason: `${st.consecutive5xx} consecutive 5xx` };
|
|
201
|
+
spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const spardaJson = (status, obj) =>
|
|
206
|
+
new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json' } });
|
|
207
|
+
|
|
208
|
+
// SPARDA owns its endpoints' body parsing: 64KB cap and a JSON 400 on malformed
|
|
209
|
+
// bodies — never a framework HTML error page.
|
|
210
|
+
async function spardaReadJson(request) {
|
|
211
|
+
let text;
|
|
212
|
+
try { text = await request.text(); } catch { return { error: spardaJson(400, { error: 'unreadable body' }) }; }
|
|
213
|
+
if (text.length > 65536) return { error: spardaJson(413, { error: 'payload too large (64KB max)' }) };
|
|
214
|
+
if (!text) return { data: {} };
|
|
215
|
+
try { return { data: JSON.parse(text) }; } catch {
|
|
216
|
+
spardaEvent('router', null, 400, 'invalid JSON body');
|
|
217
|
+
return { error: spardaJson(400, { error: 'invalid JSON body' }) };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// the actual host-route call, shared by the allow-path and the confirm-path.
|
|
222
|
+
// The "host" is this same Next server: SPARDA proxies to 127.0.0.1 like on
|
|
223
|
+
// Express, so handlers run with their full middleware/context untouched.
|
|
224
|
+
async function spardaExecute(tool, spec, args, proof, t0) {
|
|
225
|
+
try {
|
|
226
|
+
let url = spec.path.replace(/:(\w+)/g, (_, name) => encodeURIComponent(String(args[name])));
|
|
227
|
+
const query = [];
|
|
228
|
+
for (const [k, v] of Object.entries(args)) {
|
|
229
|
+
if (k === 'body' || (spec.pathParams ?? []).includes(k) || v === undefined) continue;
|
|
230
|
+
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
231
|
+
}
|
|
232
|
+
if (query.length) url += `?${query.join('&')}`;
|
|
233
|
+
const init = { method: spec.method, headers: {} };
|
|
234
|
+
if (spec.method !== 'GET' && args.body !== undefined) {
|
|
235
|
+
init.headers['content-type'] = 'application/json';
|
|
236
|
+
init.body = JSON.stringify(args.body);
|
|
237
|
+
}
|
|
238
|
+
SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
|
|
239
|
+
const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
240
|
+
const text = await upstream.text();
|
|
241
|
+
let data; try { data = JSON.parse(text); } catch { data = text; }
|
|
242
|
+
spardaRecord(tool, upstream.status, Date.now() - t0);
|
|
243
|
+
if (spec.method === 'GET' && upstream.status === 200) {
|
|
244
|
+
spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e) => e[1] !== undefined).sort()), text);
|
|
245
|
+
}
|
|
246
|
+
if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
|
|
247
|
+
return spardaJson(200, { upstreamStatus: upstream.status, data, spardingProof: proof });
|
|
248
|
+
} catch (err) {
|
|
249
|
+
spardaRecord(tool, err.status ?? 502, Date.now() - t0);
|
|
250
|
+
spardaEvent('invoke', tool, err.status ?? 502, err.message);
|
|
251
|
+
return spardaJson(err.status ?? 502, { error: err.message, spardingProof: proof });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function spardaInvoke(request) {
|
|
256
|
+
const t0 = Date.now();
|
|
257
|
+
const read = await spardaReadJson(request);
|
|
258
|
+
if (read.error) return read.error;
|
|
259
|
+
const reqBody = read.data ?? {};
|
|
260
|
+
const tool = reqBody.tool;
|
|
261
|
+
if (reqBody.args !== undefined && (reqBody.args === null || typeof reqBody.args !== 'object' || Array.isArray(reqBody.args))) {
|
|
262
|
+
return spardaJson(400, { error: 'args must be a JSON object', got: reqBody.args === null ? 'null' : Array.isArray(reqBody.args) ? 'array' : typeof reqBody.args });
|
|
263
|
+
}
|
|
264
|
+
const args = reqBody.args ?? {};
|
|
265
|
+
const spec = SPARDA_TOOLS[tool];
|
|
266
|
+
const proof = spardaProof(tool, spec, args);
|
|
267
|
+
|
|
268
|
+
if (proof.decision === 'block') {
|
|
269
|
+
if (!proof.checks.knownTool) return spardaJson(404, { error: `unknown tool: ${tool}`, spardingProof: proof });
|
|
270
|
+
if (!proof.checks.enabled)
|
|
271
|
+
return spardaJson(403, { error: `tool disabled (write-safety): ${tool}`, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
|
|
272
|
+
if (!proof.checks.loopSafe)
|
|
273
|
+
return spardaJson(400, { error: 'self-referential tool blocked (loop protection)', spardingProof: proof });
|
|
274
|
+
if (!proof.checks.quarantineSafe) {
|
|
275
|
+
const quarantined = SPARDA_QUARANTINE[tool];
|
|
276
|
+
SPARDA_RECYCLE.servedByCircle += 1; // the doomed host call was never paid
|
|
277
|
+
return spardaJson(503, {
|
|
278
|
+
error: `tool quarantined (immune system): ${tool}`,
|
|
279
|
+
reason: quarantined ? quarantined.reason : '',
|
|
280
|
+
retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
|
|
281
|
+
spardingProof: proof,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const status = proof.reasons.some((r) => r.includes('policy blocks execution')) ? 403 : 400;
|
|
285
|
+
return spardaJson(status, { error: proof.reasons[0] || 'blocked', spardingProof: proof });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (proof.decision === 'require_human') {
|
|
289
|
+
spardaSweepPending();
|
|
290
|
+
const confirm = spardaNonce();
|
|
291
|
+
SPARDA_PENDING[confirm] = { tool, args, expiresAt: Date.now() + SPARDA_CONFIRM_TTL_MS, createdAt: new Date().toISOString() };
|
|
292
|
+
let siblingName = null;
|
|
293
|
+
for (const [n, t] of Object.entries(SPARDA_TOOLS)) { if (t.method === 'GET' && t.path === spec.path) { siblingName = n; break; } }
|
|
294
|
+
spardaEvent('confirm', tool, 202, `gated, awaiting confirmation (risk: ${proof.risk})`);
|
|
295
|
+
return spardaJson(202, {
|
|
296
|
+
status: 'awaiting_confirmation',
|
|
297
|
+
confirm,
|
|
298
|
+
expiresInMs: SPARDA_CONFIRM_TTL_MS,
|
|
299
|
+
preview: {
|
|
300
|
+
tool,
|
|
301
|
+
method: spec.method,
|
|
302
|
+
path: spec.path,
|
|
303
|
+
willSendBody: spec.method !== 'GET' && args.body !== undefined,
|
|
304
|
+
reversibleHint: proof.checks.reversibleHint,
|
|
305
|
+
inspectFirst: siblingName ? { tool: siblingName, why: 'GET on the same path — read current state before committing this write' } : null,
|
|
306
|
+
},
|
|
307
|
+
instruction: `NOT EXECUTED. ${spec.method} ${spec.path} is gated by policy (${proof.reasons[0] || 'require_human'}); the host route was not touched.${siblingName ? ` First call "${siblingName}" to inspect current state, then` : ' To'} confirm via POST /invoke/confirm { "confirm": "${confirm}" }. Single-use, expires in ${Math.round(SPARDA_CONFIRM_TTL_MS / 1000)}s.`,
|
|
308
|
+
spardingProof: proof,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return spardaExecute(tool, spec, args, proof, t0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function spardaConfirm(request) {
|
|
316
|
+
const t0 = Date.now();
|
|
317
|
+
const read = await spardaReadJson(request);
|
|
318
|
+
if (read.error) return read.error;
|
|
319
|
+
const confirm = (read.data ?? {}).confirm;
|
|
320
|
+
if (typeof confirm !== 'string' || !confirm) {
|
|
321
|
+
return spardaJson(400, { error: 'missing confirm token (expected { confirm: "..." })' });
|
|
322
|
+
}
|
|
323
|
+
spardaSweepPending();
|
|
324
|
+
const pending = SPARDA_PENDING[confirm];
|
|
325
|
+
if (pending) delete SPARDA_PENDING[confirm]; // consume first — a token can never replay
|
|
326
|
+
if (!pending) return spardaJson(409, { error: 'unknown or expired confirmation token', hint: 're-issue the write via /invoke to mint a fresh token' });
|
|
327
|
+
if (Date.now() > pending.expiresAt) return spardaJson(409, { error: 'confirmation token expired', hint: 're-issue the write via /invoke' });
|
|
328
|
+
const spec = SPARDA_TOOLS[pending.tool];
|
|
329
|
+
const proof = spardaProof(pending.tool, spec, pending.args); // re-judge at commit time
|
|
330
|
+
if (proof.decision === 'block') {
|
|
331
|
+
return spardaJson(403, { error: 'confirmation no longer valid', reason: proof.reasons[0] || 'blocked', spardingProof: proof });
|
|
332
|
+
}
|
|
333
|
+
spardaEvent('confirm', pending.tool, 200, 'confirmed -> executing');
|
|
334
|
+
return spardaExecute(pending.tool, spec, pending.args, proof, t0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function spardaHandle(request, ctx) {
|
|
338
|
+
try {
|
|
339
|
+
if (request.headers.get('x-sparda-key') !== SPARDA_LOCAL_KEY) {
|
|
340
|
+
return spardaJson(401, { error: 'unauthorized' });
|
|
341
|
+
}
|
|
342
|
+
const params = ctx?.params ? await ctx.params : {};
|
|
343
|
+
const seg = Array.isArray(params.sparda) ? params.sparda : [];
|
|
344
|
+
const sub = '/' + seg.join('/');
|
|
345
|
+
const method = request.method;
|
|
346
|
+
|
|
347
|
+
if (sub === '/tools' && method === 'GET') return spardaJson(200, SPARDA_TOOLS);
|
|
348
|
+
if (sub === '/stats' && method === 'GET')
|
|
349
|
+
return spardaJson(200, {
|
|
350
|
+
uptimeSec: Math.round(process.uptime()),
|
|
351
|
+
stats: SPARDA_STATS,
|
|
352
|
+
quarantine: SPARDA_QUARANTINE,
|
|
353
|
+
recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() },
|
|
354
|
+
purity: spardaPuritySnapshot(),
|
|
355
|
+
});
|
|
356
|
+
if (sub === '/events' && method === 'GET') {
|
|
357
|
+
const since = Number(new URL(request.url).searchParams.get('since') ?? 0) || 0;
|
|
358
|
+
return spardaJson(200, { seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e) => e.seq > since) });
|
|
359
|
+
}
|
|
360
|
+
if (sub === '/gossip' && method === 'POST') {
|
|
361
|
+
const read = await spardaReadJson(request);
|
|
362
|
+
if (read.error) return read.error;
|
|
363
|
+
spardaMergeGossip(read.data);
|
|
364
|
+
return new Response(null, { status: 204 });
|
|
365
|
+
}
|
|
366
|
+
if (sub === '/invoke' && method === 'POST') return spardaInvoke(request);
|
|
367
|
+
if (sub === '/invoke/confirm' && method === 'POST') return spardaConfirm(request);
|
|
368
|
+
if (sub === '/invoke' || sub === '/invoke/confirm')
|
|
369
|
+
return spardaJson(405, { error: 'method not allowed', allow: 'POST' });
|
|
370
|
+
return spardaJson(404, { error: 'not found', path: sub });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// error envelope: the stack stays server-side, correlated to /events by errorId
|
|
373
|
+
const errorId = 'err_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
374
|
+
spardaEvent('router', null, (err && err.status) || 500, `[${errorId}] ${err && err.message ? err.message : err}`);
|
|
375
|
+
return spardaJson((err && err.status) || 500, { error: 'internal error', errorId });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function GET(request, ctx) { return spardaHandle(request, ctx); }
|
|
380
|
+
export async function POST(request, ctx) { return spardaHandle(request, ctx); }
|
|
381
|
+
export async function PUT(request, ctx) { return spardaHandle(request, ctx); }
|
|
382
|
+
export async function PATCH(request, ctx) { return spardaHandle(request, ctx); }
|
|
383
|
+
export async function DELETE(request, ctx) { return spardaHandle(request, ctx); }
|