iflow-mcp-deeflect-smart-spawn 0.1.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.
Binary file
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1 @@
1
+ {"name": "iflow-mcp-deeflect-smart-spawn", "version": "0.1.0", "type": "module", "bin": {"iflow-mcp-deeflect-smart-spawn": "src/index.ts"}, "scripts": {"dev": "bun run --hot src/index.ts", "start": "bun run src/index.ts", "typecheck": "bunx tsc -p tsconfig.json --noEmit", "test": "bun test", "live:smoke": "bun run scripts/live-smoke.ts"}, "dependencies": {"@modelcontextprotocol/sdk": "^1.18.1"}, "devDependencies": {"@types/bun": "latest", "tsx": "^4.21.0"}}
@@ -0,0 +1,121 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ function parseTextPayload(result: any): any {
7
+ const part = result?.content?.find((x: any) => x?.type === "text");
8
+ if (!part?.text) throw new Error("No text payload in tool response");
9
+ return JSON.parse(part.text);
10
+ }
11
+
12
+ async function waitForCompletion(client: Client, runId: string, timeoutMs: number): Promise<any> {
13
+ const start = Date.now();
14
+ while (Date.now() - start < timeoutMs) {
15
+ const status = await client.callTool({
16
+ name: "smartspawn_run_status",
17
+ arguments: { run_id: runId },
18
+ });
19
+ const payload = parseTextPayload(status);
20
+ const state = String(payload.status ?? "");
21
+ const pct = Number(payload?.progress?.percent ?? 0);
22
+ const done = Number(payload?.progress?.done_nodes ?? 0);
23
+ const total = Number(payload?.progress?.total_nodes ?? 0);
24
+ console.log(`[status] ${state} ${done}/${total} (${pct}%)`);
25
+ if (["completed", "failed", "canceled"].includes(state)) return payload;
26
+ await Bun.sleep(3500);
27
+ }
28
+ throw new Error(`Run timed out after ${timeoutMs}ms`);
29
+ }
30
+
31
+ async function main(): Promise<void> {
32
+ if (!process.env["OPENROUTER_API_KEY"]) {
33
+ throw new Error("OPENROUTER_API_KEY is not set in environment");
34
+ }
35
+
36
+ const mcpHome = join(process.cwd(), ".smart-spawn-mcp-live");
37
+ rmSync(mcpHome, { recursive: true, force: true });
38
+
39
+ const transport = new StdioClientTransport({
40
+ command: "bun",
41
+ args: ["run", "src/index.ts"],
42
+ cwd: process.cwd(),
43
+ env: {
44
+ OPENROUTER_API_KEY: process.env["OPENROUTER_API_KEY"]!,
45
+ SMART_SPAWN_API_URL: process.env["SMART_SPAWN_API_URL"] ?? "https://ss.deeflect.com/api",
46
+ SMART_SPAWN_MCP_HOME: process.env["SMART_SPAWN_MCP_HOME"] ?? mcpHome,
47
+ MAX_PARALLEL_RUNS: process.env["MAX_PARALLEL_RUNS"] ?? "2",
48
+ MAX_PARALLEL_NODES_PER_RUN: process.env["MAX_PARALLEL_NODES_PER_RUN"] ?? "4",
49
+ MAX_USD_PER_RUN: process.env["MAX_USD_PER_RUN"] ?? "2",
50
+ NODE_TIMEOUT_SECONDS: process.env["NODE_TIMEOUT_SECONDS"] ?? "180",
51
+ RUN_TIMEOUT_SECONDS: process.env["RUN_TIMEOUT_SECONDS"] ?? "900",
52
+ },
53
+ stderr: "pipe",
54
+ });
55
+
56
+ const client = new Client({
57
+ name: "smart-spawn-live-smoke",
58
+ version: "1.0.0",
59
+ });
60
+
61
+ transport.stderr?.on("data", (chunk) => {
62
+ const text = String(chunk ?? "").trim();
63
+ if (text) console.log(`[server] ${text}`);
64
+ });
65
+
66
+ await client.connect(transport);
67
+
68
+ try {
69
+ const tools = await client.listTools();
70
+ console.log(`[tools] ${tools.tools.length} tools available`);
71
+
72
+ const create = await client.callTool({
73
+ name: "smartspawn_run_create",
74
+ arguments: {
75
+ task: "Build a tiny Node.js hello-world API, add 3 curl tests, and provide final implementation summary.",
76
+ mode: "swarm",
77
+ budget: "low",
78
+ context: "nodejs,typescript,api,testing",
79
+ merge: { style: "concise" },
80
+ },
81
+ });
82
+ const created = parseTextPayload(create);
83
+ const runId = String(created.run_id ?? "");
84
+ if (!runId) throw new Error("run_create did not return run_id");
85
+ console.log(`[run] created ${runId}`);
86
+
87
+ const finalStatus = await waitForCompletion(client, runId, 6 * 60 * 1000);
88
+ console.log(`[run] final status: ${finalStatus.status}`);
89
+ if (finalStatus.status !== "completed") {
90
+ throw new Error(`Run ended in non-completed state: ${finalStatus.status}`);
91
+ }
92
+
93
+ const resultRes = await client.callTool({
94
+ name: "smartspawn_run_result",
95
+ arguments: { run_id: runId, include_raw: true },
96
+ });
97
+ const result = parseTextPayload(resultRes);
98
+
99
+ console.log(`[result] artifacts=${Array.isArray(result.artifacts) ? result.artifacts.length : 0}`);
100
+ console.log(
101
+ `[cost] prompt=${result?.cost?.prompt_tokens ?? 0}, completion=${result?.cost?.completion_tokens ?? 0}, usd_estimate=${result?.cost?.usd_estimate ?? 0}`
102
+ );
103
+ const merged = String(result?.merged_output ?? "");
104
+ console.log(`[merged-preview]\n${merged.slice(0, 900)}${merged.length > 900 ? "\n...[truncated]" : ""}`);
105
+
106
+ const mergedArtifact = await client.callTool({
107
+ name: "smartspawn_artifact_get",
108
+ arguments: { run_id: runId, node_id: "merged" },
109
+ });
110
+ const mergedArtifactPayload = parseTextPayload(mergedArtifact);
111
+ console.log(`[artifact] merged bytes=${mergedArtifactPayload?.metadata?.bytes ?? 0} path=${mergedArtifactPayload?.metadata?.path ?? "n/a"}`);
112
+ } finally {
113
+ await client.close();
114
+ }
115
+ }
116
+
117
+ main().catch((error) => {
118
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
119
+ console.error(message);
120
+ process.exit(1);
121
+ });
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export interface McpConfig {
5
+ openRouterApiKey: string;
6
+ smartSpawnApiUrl: string;
7
+ homeDir: string;
8
+ dbPath: string;
9
+ artifactsDir: string;
10
+ maxParallelRuns: number;
11
+ maxParallelNodesPerRun: number;
12
+ maxUsdPerRun: number;
13
+ nodeTimeoutSeconds: number;
14
+ runTimeoutSeconds: number;
15
+ pollIntervalMs: number;
16
+ }
17
+
18
+ function parsePositiveInt(raw: string | undefined, fallback: number): number {
19
+ const n = Number(raw);
20
+ if (!Number.isFinite(n) || n <= 0) return fallback;
21
+ return Math.floor(n);
22
+ }
23
+
24
+ function parsePositiveFloat(raw: string | undefined, fallback: number): number {
25
+ const n = Number(raw);
26
+ if (!Number.isFinite(n) || n <= 0) return fallback;
27
+ return n;
28
+ }
29
+
30
+ function resolveHomePath(raw: string | undefined): string {
31
+ if (!raw || !raw.trim()) return join(process.cwd(), ".smart-spawn-mcp");
32
+ if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
33
+ return raw;
34
+ }
35
+
36
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): McpConfig {
37
+ const openRouterApiKey = env["OPENROUTER_API_KEY"] ?? "";
38
+ const homeDir = resolveHomePath(env["SMART_SPAWN_MCP_HOME"]);
39
+ const dbPath = join(homeDir, "db.sqlite");
40
+ const artifactsDir = join(homeDir, "artifacts");
41
+
42
+ return {
43
+ openRouterApiKey,
44
+ smartSpawnApiUrl: env["SMART_SPAWN_API_URL"] ?? "https://ss.deeflect.com/api",
45
+ homeDir,
46
+ dbPath,
47
+ artifactsDir,
48
+ maxParallelRuns: parsePositiveInt(env["MAX_PARALLEL_RUNS"], 2),
49
+ maxParallelNodesPerRun: parsePositiveInt(env["MAX_PARALLEL_NODES_PER_RUN"], 4),
50
+ maxUsdPerRun: parsePositiveFloat(env["MAX_USD_PER_RUN"], 5),
51
+ nodeTimeoutSeconds: parsePositiveInt(env["NODE_TIMEOUT_SECONDS"], 180),
52
+ runTimeoutSeconds: parsePositiveInt(env["RUN_TIMEOUT_SECONDS"], 1800),
53
+ pollIntervalMs: parsePositiveInt(env["POLL_INTERVAL_MS"], 1200),
54
+ };
55
+ }
package/src/db.ts ADDED
@@ -0,0 +1,448 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { ArtifactRecord, NodeRecord, PlannedNode, RunCreateInput, RunRecord, RunStatus } from "./types.ts";
4
+
5
+ function nowIso(): string {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ function parseJson<T>(raw: string): T {
10
+ return JSON.parse(raw) as T;
11
+ }
12
+
13
+ export class McpStore {
14
+ private db: Database;
15
+
16
+ constructor(dbPath: string) {
17
+ this.db = new Database(dbPath, { create: true, strict: false });
18
+ this.initSchema();
19
+ }
20
+
21
+ initSchema(): void {
22
+ this.db.exec(`
23
+ PRAGMA journal_mode = WAL;
24
+ PRAGMA synchronous = NORMAL;
25
+
26
+ CREATE TABLE IF NOT EXISTS runs (
27
+ id TEXT PRIMARY KEY,
28
+ task TEXT NOT NULL,
29
+ mode TEXT NOT NULL,
30
+ budget TEXT NOT NULL,
31
+ context TEXT,
32
+ params_json TEXT NOT NULL,
33
+ status TEXT NOT NULL,
34
+ error TEXT,
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ started_at TEXT,
38
+ finished_at TEXT
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS nodes (
42
+ id TEXT PRIMARY KEY,
43
+ run_id TEXT NOT NULL,
44
+ kind TEXT NOT NULL,
45
+ wave INTEGER NOT NULL,
46
+ depends_on_json TEXT NOT NULL,
47
+ task TEXT NOT NULL,
48
+ model TEXT NOT NULL,
49
+ prompt TEXT NOT NULL,
50
+ meta_json TEXT NOT NULL,
51
+ status TEXT NOT NULL,
52
+ retry_count INTEGER NOT NULL DEFAULT 0,
53
+ max_retries INTEGER NOT NULL DEFAULT 2,
54
+ error TEXT,
55
+ started_at TEXT,
56
+ finished_at TEXT,
57
+ tokens_prompt INTEGER NOT NULL DEFAULT 0,
58
+ tokens_completion INTEGER NOT NULL DEFAULT 0,
59
+ cost_usd REAL NOT NULL DEFAULT 0,
60
+ FOREIGN KEY (run_id) REFERENCES runs(id)
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS events (
64
+ id TEXT PRIMARY KEY,
65
+ run_id TEXT NOT NULL,
66
+ node_id TEXT,
67
+ level TEXT NOT NULL,
68
+ message TEXT NOT NULL,
69
+ ts TEXT NOT NULL,
70
+ FOREIGN KEY (run_id) REFERENCES runs(id)
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS artifacts (
74
+ id TEXT PRIMARY KEY,
75
+ run_id TEXT NOT NULL,
76
+ node_id TEXT NOT NULL,
77
+ type TEXT NOT NULL,
78
+ path TEXT NOT NULL,
79
+ bytes INTEGER NOT NULL,
80
+ sha256 TEXT NOT NULL,
81
+ created_at TEXT NOT NULL,
82
+ FOREIGN KEY (run_id) REFERENCES runs(id)
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
86
+ CREATE INDEX IF NOT EXISTS idx_nodes_run_id ON nodes(run_id);
87
+ CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
88
+ CREATE INDEX IF NOT EXISTS idx_artifacts_run_id ON artifacts(run_id);
89
+ CREATE INDEX IF NOT EXISTS idx_events_run_id ON events(run_id, ts);
90
+ `);
91
+ }
92
+
93
+ createRun(input: RunCreateInput): RunRecord {
94
+ const id = randomUUID();
95
+ const now = nowIso();
96
+ const budget = input.budget ?? "medium";
97
+ const context = input.context ?? null;
98
+ const params = JSON.stringify(input);
99
+
100
+ this.db
101
+ .query(
102
+ `INSERT INTO runs (id, task, mode, budget, context, params_json, status, error, created_at, updated_at, started_at, finished_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, 'queued', NULL, ?, ?, NULL, NULL)`
104
+ )
105
+ .run(id, input.task, input.mode, budget, context, params, now, now);
106
+
107
+ return this.getRun(id)!;
108
+ }
109
+
110
+ getRun(runId: string): RunRecord | null {
111
+ const row = this.db
112
+ .query(
113
+ `SELECT id, task, mode, budget, context, params_json, status, error, created_at, updated_at, started_at, finished_at
114
+ FROM runs WHERE id = ? LIMIT 1`
115
+ )
116
+ .get(runId) as any;
117
+ if (!row) return null;
118
+ return this.mapRun(row);
119
+ }
120
+
121
+ listRuns(status?: RunStatus, limit = 20): RunRecord[] {
122
+ const safeLimit = Math.max(1, Math.min(limit, 200));
123
+ const rows = status
124
+ ? (this.db
125
+ .query(
126
+ `SELECT id, task, mode, budget, context, params_json, status, error, created_at, updated_at, started_at, finished_at
127
+ FROM runs WHERE status = ? ORDER BY created_at DESC LIMIT ?`
128
+ )
129
+ .all(status, safeLimit) as any[])
130
+ : (this.db
131
+ .query(
132
+ `SELECT id, task, mode, budget, context, params_json, status, error, created_at, updated_at, started_at, finished_at
133
+ FROM runs ORDER BY created_at DESC LIMIT ?`
134
+ )
135
+ .all(safeLimit) as any[]);
136
+
137
+ return rows.map((r) => this.mapRun(r));
138
+ }
139
+
140
+ listActiveRuns(limit: number): RunRecord[] {
141
+ const rows = this.db
142
+ .query(
143
+ `SELECT id, task, mode, budget, context, params_json, status, error, created_at, updated_at, started_at, finished_at
144
+ FROM runs WHERE status IN ('queued', 'running') ORDER BY created_at ASC LIMIT ?`
145
+ )
146
+ .all(limit) as any[];
147
+ return rows.map((r) => this.mapRun(r));
148
+ }
149
+
150
+ updateRunStatus(runId: string, status: RunStatus, error: string | null = null): void {
151
+ const now = nowIso();
152
+ if (status === "running") {
153
+ this.db
154
+ .query(
155
+ `UPDATE runs
156
+ SET status = ?, error = ?, updated_at = ?, started_at = COALESCE(started_at, ?)
157
+ WHERE id = ?`
158
+ )
159
+ .run(status, error, now, now, runId);
160
+ return;
161
+ }
162
+
163
+ if (status === "completed" || status === "failed" || status === "canceled") {
164
+ this.db
165
+ .query(
166
+ `UPDATE runs
167
+ SET status = ?, error = ?, updated_at = ?, finished_at = ?
168
+ WHERE id = ?`
169
+ )
170
+ .run(status, error, now, now, runId);
171
+ return;
172
+ }
173
+
174
+ this.db
175
+ .query(`UPDATE runs SET status = ?, error = ?, updated_at = ? WHERE id = ?`)
176
+ .run(status, error, now, runId);
177
+ }
178
+
179
+ getRunInput(runId: string): RunCreateInput | null {
180
+ const row = this.db
181
+ .query(`SELECT params_json FROM runs WHERE id = ? LIMIT 1`)
182
+ .get(runId) as any;
183
+ if (!row) return null;
184
+ return parseJson<RunCreateInput>(row.params_json);
185
+ }
186
+
187
+ createNodes(runId: string, nodes: PlannedNode[]): void {
188
+ const insert = this.db.query(
189
+ `INSERT INTO nodes
190
+ (id, run_id, kind, wave, depends_on_json, task, model, prompt, meta_json, status, retry_count, max_retries, error, started_at, finished_at, tokens_prompt, tokens_completion, cost_usd)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, NULL, NULL, NULL, 0, 0, 0)`
192
+ );
193
+ const tx = this.db.transaction(() => {
194
+ const idMap = new Map<string, string>();
195
+ for (const node of nodes) {
196
+ idMap.set(node.id, `${runId}:${node.id}`);
197
+ }
198
+
199
+ for (const node of nodes) {
200
+ const nodeId = idMap.get(node.id)!;
201
+ const mappedDependsOn = node.dependsOn.map((dep) => idMap.get(dep) ?? `${runId}:${dep}`);
202
+ insert.run(
203
+ nodeId,
204
+ runId,
205
+ node.kind,
206
+ node.wave,
207
+ JSON.stringify(mappedDependsOn),
208
+ node.task,
209
+ node.model,
210
+ node.prompt,
211
+ JSON.stringify(node.meta ?? {}),
212
+ node.maxRetries ?? 2
213
+ );
214
+ }
215
+ });
216
+ tx();
217
+ }
218
+
219
+ listNodes(runId: string): NodeRecord[] {
220
+ const rows = this.db
221
+ .query(
222
+ `SELECT id, run_id, kind, wave, depends_on_json, task, model, prompt, meta_json, status, retry_count, max_retries, error, started_at, finished_at, tokens_prompt, tokens_completion, cost_usd
223
+ FROM nodes WHERE run_id = ? ORDER BY wave ASC, id ASC`
224
+ )
225
+ .all(runId) as any[];
226
+ return rows.map((r) => this.mapNode(r));
227
+ }
228
+
229
+ getNode(nodeId: string): NodeRecord | null {
230
+ const row = this.db
231
+ .query(
232
+ `SELECT id, run_id, kind, wave, depends_on_json, task, model, prompt, meta_json, status, retry_count, max_retries, error, started_at, finished_at, tokens_prompt, tokens_completion, cost_usd
233
+ FROM nodes WHERE id = ? LIMIT 1`
234
+ )
235
+ .get(nodeId) as any;
236
+ if (!row) return null;
237
+ return this.mapNode(row);
238
+ }
239
+
240
+ startNode(nodeId: string): void {
241
+ const now = nowIso();
242
+ this.db
243
+ .query(
244
+ `UPDATE nodes
245
+ SET status = 'running', started_at = COALESCE(started_at, ?)
246
+ WHERE id = ?`
247
+ )
248
+ .run(now, nodeId);
249
+ }
250
+
251
+ markNodeCompleted(nodeId: string, tokensPrompt: number, tokensCompletion: number, costUsd: number): void {
252
+ const now = nowIso();
253
+ this.db
254
+ .query(
255
+ `UPDATE nodes
256
+ SET status = 'completed', finished_at = ?, tokens_prompt = ?, tokens_completion = ?, cost_usd = ?, error = NULL
257
+ WHERE id = ?`
258
+ )
259
+ .run(now, tokensPrompt, tokensCompletion, costUsd, nodeId);
260
+ }
261
+
262
+ markNodeSkipped(nodeId: string, reason: string): void {
263
+ const now = nowIso();
264
+ this.db
265
+ .query(
266
+ `UPDATE nodes
267
+ SET status = 'skipped', finished_at = ?, error = ?
268
+ WHERE id = ?`
269
+ )
270
+ .run(now, reason, nodeId);
271
+ }
272
+
273
+ markNodeFailed(nodeId: string, error: string): void {
274
+ const now = nowIso();
275
+ this.db
276
+ .query(
277
+ `UPDATE nodes
278
+ SET status = 'failed', finished_at = ?, error = ?
279
+ WHERE id = ?`
280
+ )
281
+ .run(now, error.slice(0, 5000), nodeId);
282
+ }
283
+
284
+ incrementNodeRetry(nodeId: string, error: string): void {
285
+ this.db
286
+ .query(
287
+ `UPDATE nodes
288
+ SET status = 'queued', retry_count = retry_count + 1, error = ?
289
+ WHERE id = ?`
290
+ )
291
+ .run(error.slice(0, 5000), nodeId);
292
+ }
293
+
294
+ addEvent(runId: string, level: "info" | "warn" | "error", message: string, nodeId?: string): void {
295
+ this.db
296
+ .query(
297
+ `INSERT INTO events (id, run_id, node_id, level, message, ts)
298
+ VALUES (?, ?, ?, ?, ?, ?)`
299
+ )
300
+ .run(randomUUID(), runId, nodeId ?? null, level, message.slice(0, 5000), nowIso());
301
+ }
302
+
303
+ listRecentEvents(runId: string, limit = 20): Array<{ level: string; message: string; ts: string; nodeId: string | null }> {
304
+ const safeLimit = Math.max(1, Math.min(limit, 200));
305
+ const rows = this.db
306
+ .query(
307
+ `SELECT level, message, ts, node_id
308
+ FROM events WHERE run_id = ? ORDER BY ts DESC LIMIT ?`
309
+ )
310
+ .all(runId, safeLimit) as Array<{ level: string; message: string; ts: string; node_id: string | null }>;
311
+ return rows.map((row) => ({
312
+ level: row.level,
313
+ message: row.message,
314
+ ts: row.ts,
315
+ nodeId: row.node_id,
316
+ }));
317
+ }
318
+
319
+ createArtifact(input: Omit<ArtifactRecord, "id">): ArtifactRecord {
320
+ const id = randomUUID();
321
+ this.db
322
+ .query(
323
+ `INSERT INTO artifacts (id, run_id, node_id, type, path, bytes, sha256, created_at)
324
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
325
+ )
326
+ .run(id, input.runId, input.nodeId, input.type, input.path, input.bytes, input.sha256, input.createdAt);
327
+
328
+ return {
329
+ id,
330
+ ...input,
331
+ };
332
+ }
333
+
334
+ listArtifacts(runId: string): ArtifactRecord[] {
335
+ const rows = this.db
336
+ .query(
337
+ `SELECT id, run_id, node_id, type, path, bytes, sha256, created_at
338
+ FROM artifacts WHERE run_id = ? ORDER BY created_at ASC`
339
+ )
340
+ .all(runId) as any[];
341
+
342
+ return rows.map((r) => ({
343
+ id: r.id,
344
+ runId: r.run_id,
345
+ nodeId: r.node_id,
346
+ type: r.type,
347
+ path: r.path,
348
+ bytes: r.bytes,
349
+ sha256: r.sha256,
350
+ createdAt: r.created_at,
351
+ }));
352
+ }
353
+
354
+ getArtifact(runId: string, nodeId: string): ArtifactRecord | null {
355
+ const row = this.db
356
+ .query(
357
+ `SELECT id, run_id, node_id, type, path, bytes, sha256, created_at
358
+ FROM artifacts WHERE run_id = ? AND node_id = ? ORDER BY created_at DESC LIMIT 1`
359
+ )
360
+ .get(runId, nodeId) as any;
361
+ if (!row) return null;
362
+ return {
363
+ id: row.id,
364
+ runId: row.run_id,
365
+ nodeId: row.node_id,
366
+ type: row.type,
367
+ path: row.path,
368
+ bytes: row.bytes,
369
+ sha256: row.sha256,
370
+ createdAt: row.created_at,
371
+ };
372
+ }
373
+
374
+ getRunCost(runId: string): { promptTokens: number; completionTokens: number; usdEstimate: number } {
375
+ const row = this.db
376
+ .query(
377
+ `SELECT
378
+ COALESCE(SUM(tokens_prompt), 0) AS prompt_tokens,
379
+ COALESCE(SUM(tokens_completion), 0) AS completion_tokens,
380
+ COALESCE(SUM(cost_usd), 0) AS usd_estimate
381
+ FROM nodes WHERE run_id = ?`
382
+ )
383
+ .get(runId) as any;
384
+
385
+ return {
386
+ promptTokens: Number(row.prompt_tokens ?? 0),
387
+ completionTokens: Number(row.completion_tokens ?? 0),
388
+ usdEstimate: Number(row.usd_estimate ?? 0),
389
+ };
390
+ }
391
+
392
+ pingWritable(): boolean {
393
+ try {
394
+ this.db.exec(`
395
+ CREATE TABLE IF NOT EXISTS _health_probe (id INTEGER PRIMARY KEY, ts TEXT NOT NULL);
396
+ INSERT INTO _health_probe (ts) VALUES (datetime('now'));
397
+ DELETE FROM _health_probe WHERE id = (SELECT MAX(id) FROM _health_probe);
398
+ `);
399
+ return true;
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+
405
+ close(): void {
406
+ this.db.close();
407
+ }
408
+
409
+ private mapRun(row: any): RunRecord {
410
+ return {
411
+ id: row.id,
412
+ task: row.task,
413
+ mode: row.mode,
414
+ budget: row.budget,
415
+ context: row.context,
416
+ paramsJson: row.params_json,
417
+ status: row.status,
418
+ error: row.error,
419
+ createdAt: row.created_at,
420
+ updatedAt: row.updated_at,
421
+ startedAt: row.started_at,
422
+ finishedAt: row.finished_at,
423
+ };
424
+ }
425
+
426
+ private mapNode(row: any): NodeRecord {
427
+ return {
428
+ id: row.id,
429
+ runId: row.run_id,
430
+ kind: row.kind,
431
+ wave: row.wave,
432
+ dependsOnJson: row.depends_on_json,
433
+ task: row.task,
434
+ model: row.model,
435
+ prompt: row.prompt,
436
+ metaJson: row.meta_json,
437
+ status: row.status,
438
+ retryCount: row.retry_count,
439
+ maxRetries: row.max_retries,
440
+ error: row.error,
441
+ startedAt: row.started_at,
442
+ finishedAt: row.finished_at,
443
+ tokensPrompt: row.tokens_prompt,
444
+ tokensCompletion: row.tokens_completion,
445
+ costUsd: Number(row.cost_usd ?? 0),
446
+ };
447
+ }
448
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync } from "node:fs";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { loadConfig } from "./config.ts";
6
+ import { McpStore } from "./db.ts";
7
+ import { OpenRouterClient } from "./openrouter-client.ts";
8
+ import { RuntimeQueue } from "./runtime/queue.ts";
9
+ import { SmartSpawnClient } from "./smart-spawn-client.ts";
10
+ import { ArtifactStorage } from "./storage.ts";
11
+ import { registerToolHandlers } from "./tools.ts";
12
+
13
+ async function main(): Promise<void> {
14
+ const config = loadConfig();
15
+
16
+ mkdirSync(config.homeDir, { recursive: true });
17
+ const store = new McpStore(config.dbPath);
18
+ const storage = new ArtifactStorage(config.homeDir, config.artifactsDir);
19
+ const smartSpawn = new SmartSpawnClient(config.smartSpawnApiUrl);
20
+ const openRouter = new OpenRouterClient(config.openRouterApiKey);
21
+ const runtime = new RuntimeQueue(config, store, storage, smartSpawn, openRouter);
22
+ await runtime.start();
23
+
24
+ const server = new Server(
25
+ {
26
+ name: "smart-spawn-mcp",
27
+ version: "0.1.0",
28
+ },
29
+ {
30
+ capabilities: {
31
+ tools: {},
32
+ },
33
+ }
34
+ );
35
+
36
+ registerToolHandlers(server, runtime);
37
+
38
+ const transport = new StdioServerTransport();
39
+ await server.connect(transport);
40
+ }
41
+
42
+ main().catch((error) => {
43
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
44
+ console.error(message);
45
+ process.exit(1);
46
+ });