@heuresis/mcp 1.0.0-rc.1

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.
@@ -0,0 +1,1727 @@
1
+ // Heuresis MCP — cloud-backed tool implementations.
2
+ //
3
+ // Every read/write goes through `supabase-js` against the user's
4
+ // authenticated session. RLS in the database is the security boundary — the
5
+ // MCP has exactly the user's permissions, nothing more, nothing less.
6
+ //
7
+ // Each tool's INPUT shape MIRRORS the matching webapp tool in
8
+ // `src/agent/tools.ts` so an agent that uses both surfaces sees a uniform
9
+ // contract. Outputs mirror the legacy snapshot-mode shapes from
10
+ // `mcp-server/src/tools.ts` where one existed, so existing prompts that
11
+ // reference the snapshot shape keep working.
12
+ //
13
+ // Phase 19.1 shipped 8 tools (the reads + add/update/link). Phase 19.4
14
+ // brings the remaining write surface to parity with src/agent/tools.ts:
15
+ //
16
+ // reads added:
17
+ // get_workspace_summary, list_recent_decisions, list_concepts,
18
+ // list_edges, find_concepts
19
+ // writes added:
20
+ // validate_concept, set_standing, archive_concept, unarchive_concept,
21
+ // star_concept, remove_concept, bulk_add_concepts, set_parent,
22
+ // rename_idea, recolor_idea, set_idea_members, create_idea,
23
+ // add_to_idea, delete_idea, create_project, update_project,
24
+ // delete_project, add_kref
25
+ //
26
+ // Skipped (in-browser-only — no server analog):
27
+ // * focus_on_canvas — fires a DOM CustomEvent in the webapp.
28
+ // * tidy_layout — UI affordance, no DB effect.
29
+ // * reconcile_edges — webapp-only data healing; cloud edges are already
30
+ // normalised.
31
+ // * undo — session-local Zustand undo stack; no DB-side analog.
32
+ // * find_in_files — needs in-browser embeddings; ships with operator
33
+ // parity in Phase 19.5.
34
+ // * add_k_node / add_c_node_disciplined / add_parking_lot_item / reflect
35
+ // — C-K discipline tools that are sequenced add_concept + update_concept
36
+ // calls with project-coverage gating; the MCP-side agent can drive that
37
+ // sequence using the primitives directly.
38
+ //
39
+ // Every WRITE tool stamps a public.provenance row (added in migration 0015)
40
+ // with origin='mcp' so the Inspector / session log shows which surface made
41
+ // the change. The stamp is best-effort: a failed provenance insert never
42
+ // fails the underlying write.
43
+ import { z } from 'zod';
44
+ import { unwrap } from './cloudClient.js';
45
+ // ---------------------------------------------------------------------------
46
+ // Shared helpers
47
+ // ---------------------------------------------------------------------------
48
+ const MAX_RESULTS = 50;
49
+ const detailArg = z
50
+ .enum(['compact', 'full'])
51
+ .default('compact')
52
+ .describe("'compact' drops description/rationale to keep the payload small; 'full' includes them.");
53
+ function nodeView(n, detail = 'full') {
54
+ const base = {
55
+ id: n.id,
56
+ label: n.label,
57
+ status: n.status,
58
+ starred: n.starred,
59
+ parentId: n.parent_id,
60
+ partitionAttribute: n.partition_attribute,
61
+ tags: n.tags ?? [],
62
+ updatedAt: n.updated_at,
63
+ standing: n.standing,
64
+ };
65
+ if (detail === 'compact')
66
+ return base;
67
+ return { ...base, description: n.description, rationale: n.rationale };
68
+ }
69
+ /**
70
+ * Resolve the workspace the MCP session should operate against. v1 ships
71
+ * with single-workspace selection (spec §10.2): use the user's first
72
+ * membership in alphabetical order by workspace name. A future
73
+ * `npx @heuresis/mcp workspace <id>` command will swap this.
74
+ */
75
+ async function resolveWorkspaceId(client) {
76
+ const res = await client
77
+ .from('workspaces')
78
+ .select('id, name')
79
+ .order('name', { ascending: true })
80
+ .limit(1);
81
+ const rows = unwrap(res);
82
+ if (rows.length === 0) {
83
+ throw new Error('No workspaces visible to this account. Open the Heuresis webapp first to create or be invited to a workspace.');
84
+ }
85
+ return rows[0].id;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // list_projects
89
+ // ---------------------------------------------------------------------------
90
+ export const listProjectsInput = z.object({}).strict();
91
+ export async function listProjects(client) {
92
+ const wsId = await resolveWorkspaceId(client);
93
+ const projects = unwrap(await client
94
+ .from('projects')
95
+ .select('*')
96
+ .eq('workspace_id', wsId)
97
+ .order('updated_at', { ascending: false }));
98
+ const projectNodes = unwrap(await client
99
+ .from('project_nodes')
100
+ .select('project_id, node_id')
101
+ .in('project_id', projects.map((p) => p.id)));
102
+ const countByProject = new Map();
103
+ for (const pn of projectNodes) {
104
+ countByProject.set(pn.project_id, (countByProject.get(pn.project_id) ?? 0) + 1);
105
+ }
106
+ return {
107
+ workspaceId: wsId,
108
+ projects: projects.map((p) => ({
109
+ id: p.id,
110
+ name: p.name,
111
+ brief: p.brief,
112
+ direction: p.direction,
113
+ lifecycle: p.lifecycle,
114
+ nodeCount: countByProject.get(p.id) ?? 0,
115
+ rootNodeId: p.root_node_id,
116
+ })),
117
+ };
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // get_project_graph
121
+ // ---------------------------------------------------------------------------
122
+ export const getProjectGraphInput = z
123
+ .object({
124
+ projectId: z.string(),
125
+ includeArchived: z.boolean().default(false),
126
+ detail: detailArg,
127
+ })
128
+ .strict();
129
+ export async function getProjectGraph(client, args) {
130
+ const projRes = await client
131
+ .from('projects')
132
+ .select('*')
133
+ .eq('id', args.projectId)
134
+ .maybeSingle();
135
+ if (projRes.error)
136
+ throw new Error(projRes.error.message);
137
+ const project = projRes.data;
138
+ if (!project)
139
+ return { error: `No project with id ${args.projectId}` };
140
+ const memberRows = unwrap(await client
141
+ .from('project_nodes')
142
+ .select('node_id')
143
+ .eq('project_id', project.id));
144
+ const memberIds = memberRows.map((r) => r.node_id);
145
+ if (memberIds.length === 0) {
146
+ return {
147
+ project: shapeProject(project),
148
+ detail: args.detail,
149
+ nodeCount: 0,
150
+ edgeCount: 0,
151
+ nodes: [],
152
+ edges: [],
153
+ };
154
+ }
155
+ const nodesRes = await client.from('nodes').select('*').in('id', memberIds);
156
+ const nodes = unwrap(nodesRes).filter((n) => args.includeArchived ? true : n.status !== 'archived');
157
+ const nodeIdSet = new Set(nodes.map((n) => n.id));
158
+ // Pull every edge whose endpoints are in this project. Two `in()` filters
159
+ // in PostgREST require an `or()`.
160
+ const edgesRes = await client
161
+ .from('edges')
162
+ .select('id, from_id, to_id, kind')
163
+ .in('from_id', memberIds);
164
+ const edges = unwrap(edgesRes)
165
+ .filter((e) => nodeIdSet.has(e.from_id) && nodeIdSet.has(e.to_id))
166
+ .map((e) => ({ from: e.from_id, to: e.to_id, kind: e.kind }));
167
+ return {
168
+ project: shapeProject(project),
169
+ detail: args.detail,
170
+ nodeCount: nodes.length,
171
+ edgeCount: edges.length,
172
+ nodes: nodes.map((n) => nodeView(n, args.detail)),
173
+ edges,
174
+ };
175
+ }
176
+ function shapeProject(p) {
177
+ return {
178
+ id: p.id,
179
+ name: p.name,
180
+ brief: p.brief,
181
+ direction: p.direction,
182
+ lifecycle: p.lifecycle,
183
+ rootNodeId: p.root_node_id,
184
+ };
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // get_subtree
188
+ // ---------------------------------------------------------------------------
189
+ export const getSubtreeInput = z
190
+ .object({
191
+ rootId: z.string(),
192
+ depth: z.number().int().min(0).max(6).default(3),
193
+ detail: detailArg,
194
+ })
195
+ .strict();
196
+ export async function getSubtree(client, args) {
197
+ const rootRes = await client.from('nodes').select('*').eq('id', args.rootId).maybeSingle();
198
+ if (rootRes.error)
199
+ throw new Error(rootRes.error.message);
200
+ const root = rootRes.data;
201
+ if (!root)
202
+ return { error: `No concept with id ${args.rootId}` };
203
+ const wsId = root.workspace_id;
204
+ // Pull ALL workspace nodes + partition edges in one shot, then walk in
205
+ // memory. Cheaper than depth round-trips for any realistic workspace size,
206
+ // and still covered by RLS.
207
+ const allNodes = unwrap(await client.from('nodes').select('*').eq('workspace_id', wsId));
208
+ const partitionEdges = unwrap(await client
209
+ .from('edges')
210
+ .select('from_id, to_id, kind')
211
+ .eq('workspace_id', wsId)
212
+ .eq('kind', 'partition'));
213
+ const nodeById = new Map(allNodes.map((n) => [n.id, n]));
214
+ const childrenByParent = new Map();
215
+ for (const n of allNodes) {
216
+ if (n.parent_id) {
217
+ const s = childrenByParent.get(n.parent_id) ?? new Set();
218
+ s.add(n.id);
219
+ childrenByParent.set(n.parent_id, s);
220
+ }
221
+ }
222
+ for (const e of partitionEdges) {
223
+ const s = childrenByParent.get(e.from_id) ?? new Set();
224
+ s.add(e.to_id);
225
+ childrenByParent.set(e.from_id, s);
226
+ }
227
+ const out = [];
228
+ const seen = new Set();
229
+ let frontier = [args.rootId];
230
+ for (let d = 0; d <= args.depth; d++) {
231
+ const next = [];
232
+ for (const cur of frontier) {
233
+ if (seen.has(cur))
234
+ continue;
235
+ seen.add(cur);
236
+ const node = nodeById.get(cur);
237
+ if (!node)
238
+ continue;
239
+ out.push(node);
240
+ if (d < args.depth) {
241
+ const kids = childrenByParent.get(cur);
242
+ if (kids)
243
+ for (const k of kids)
244
+ if (!seen.has(k))
245
+ next.push(k);
246
+ }
247
+ }
248
+ frontier = next;
249
+ }
250
+ return {
251
+ rootId: args.rootId,
252
+ depth: args.depth,
253
+ detail: args.detail,
254
+ nodeCount: out.length,
255
+ nodes: out.map((n) => nodeView(n, args.detail)),
256
+ };
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // get_concept
260
+ // ---------------------------------------------------------------------------
261
+ export const getConceptInput = z
262
+ .object({
263
+ id: z.string(),
264
+ includeAncestry: z.boolean().default(true),
265
+ includeChildren: z.boolean().default(true),
266
+ includeIdeaMemberships: z.boolean().default(true),
267
+ })
268
+ .strict();
269
+ export async function getConcept(client, args) {
270
+ const nodeRes = await client.from('nodes').select('*').eq('id', args.id).maybeSingle();
271
+ if (nodeRes.error)
272
+ throw new Error(nodeRes.error.message);
273
+ const node = nodeRes.data;
274
+ if (!node)
275
+ return { error: `No concept with id ${args.id}` };
276
+ const out = { node: nodeView(node) };
277
+ if (args.includeAncestry) {
278
+ const chain = [];
279
+ const seen = new Set();
280
+ let cur = node.parent_id;
281
+ while (cur && !seen.has(cur)) {
282
+ seen.add(cur);
283
+ const p = unwrap(await client.from('nodes').select('id, label, parent_id').eq('id', cur).maybeSingle());
284
+ if (!p)
285
+ break;
286
+ chain.unshift({ id: p.id, label: p.label });
287
+ cur = p.parent_id;
288
+ }
289
+ out.ancestry = chain;
290
+ }
291
+ if (args.includeChildren) {
292
+ const kids = unwrap(await client
293
+ .from('nodes')
294
+ .select('id, label, status')
295
+ .eq('parent_id', node.id));
296
+ out.children = kids;
297
+ }
298
+ if (args.includeIdeaMemberships) {
299
+ // Fetch idea_nodes for this node, then idea rows separately. Joining
300
+ // through PostgREST's `ideas(...)` shorthand returns the related row(s)
301
+ // as an array which complicates typing here — two queries are cheap.
302
+ const ideaNodeRows = unwrap(await client.from('idea_nodes').select('idea_id').eq('node_id', node.id));
303
+ const ideaIds = ideaNodeRows.map((r) => r.idea_id);
304
+ if (ideaIds.length === 0) {
305
+ out.ideaMemberships = [];
306
+ }
307
+ else {
308
+ const ideas = unwrap(await client.from('ideas').select('id, name, color').in('id', ideaIds));
309
+ out.ideaMemberships = ideas.map((i) => ({ id: i.id, name: i.name, color: i.color }));
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+ // ---------------------------------------------------------------------------
315
+ // search_concepts (text-only for 19.1; semantic search is a 19.4 enhancement)
316
+ // ---------------------------------------------------------------------------
317
+ export const searchConceptsInput = z
318
+ .object({
319
+ query: z
320
+ .string()
321
+ .describe('Substring matched against label, description, tags, partitionAttribute.'),
322
+ limit: z.number().int().min(1).max(MAX_RESULTS).default(20),
323
+ projectId: z
324
+ .string()
325
+ .optional()
326
+ .describe('Restrict results to nodes belonging to this project.'),
327
+ status: z.enum(['open', 'validated', 'archived']).optional(),
328
+ detail: detailArg,
329
+ })
330
+ .strict();
331
+ export async function searchConcepts(client, args) {
332
+ const q = args.query.trim();
333
+ const wsId = await resolveWorkspaceId(client);
334
+ // Restrict to a project if requested.
335
+ let memberIds = null;
336
+ if (args.projectId) {
337
+ const rows = unwrap(await client
338
+ .from('project_nodes')
339
+ .select('node_id')
340
+ .eq('project_id', args.projectId));
341
+ memberIds = rows.map((r) => r.node_id);
342
+ if (memberIds.length === 0) {
343
+ return { query: q, total: 0, detail: args.detail, results: [] };
344
+ }
345
+ }
346
+ let qb = client
347
+ .from('nodes')
348
+ .select('*')
349
+ .eq('workspace_id', wsId)
350
+ .order('updated_at', { ascending: false })
351
+ .limit(args.limit);
352
+ if (args.status)
353
+ qb = qb.eq('status', args.status);
354
+ if (memberIds)
355
+ qb = qb.in('id', memberIds);
356
+ if (q.length > 0) {
357
+ // Server-side ilike over label/description/partition_attribute/rationale.
358
+ // tags is text[] — PostgREST `cs.{value}` would require exact match, so
359
+ // we filter tags client-side after fetching a slightly larger page.
360
+ const escaped = q.replace(/%/g, '\\%').replace(/_/g, '\\_');
361
+ const pattern = `%${escaped}%`;
362
+ qb = qb.or([
363
+ `label.ilike.${pattern}`,
364
+ `description.ilike.${pattern}`,
365
+ `partition_attribute.ilike.${pattern}`,
366
+ `rationale.ilike.${pattern}`,
367
+ ].join(','));
368
+ }
369
+ const rows = unwrap(await qb);
370
+ const lower = q.toLowerCase();
371
+ // tag-match top-up: when the query string appears verbatim in a tag we
372
+ // want to surface it even if it wasn't in any other column.
373
+ let extra = [];
374
+ if (q.length > 0 && rows.length < args.limit) {
375
+ let tagQb = client
376
+ .from('nodes')
377
+ .select('*')
378
+ .eq('workspace_id', wsId)
379
+ .contains('tags', [q])
380
+ .limit(args.limit);
381
+ if (memberIds)
382
+ tagQb = tagQb.in('id', memberIds);
383
+ extra = unwrap(await tagQb);
384
+ }
385
+ const dedup = new Map();
386
+ for (const n of rows)
387
+ dedup.set(n.id, n);
388
+ for (const n of extra)
389
+ dedup.set(n.id, n);
390
+ const hits = Array.from(dedup.values())
391
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
392
+ .slice(0, args.limit)
393
+ .map((n) => nodeView(n, args.detail));
394
+ return { query: q || '', total: hits.length, detail: args.detail, results: hits, note: q ? undefined : 'Empty query — returned most-recently-updated concepts.' };
395
+ }
396
+ // ---------------------------------------------------------------------------
397
+ // add_concept (write)
398
+ // ---------------------------------------------------------------------------
399
+ // Mirrors `add_concept` in src/agent/tools.ts. If `parentId` is given, the
400
+ // new node becomes a child of that node AND a partition edge is created so
401
+ // the canvas renders the line. The node is also added to the parent's
402
+ // project via `project_nodes`.
403
+ export const addConceptInput = z
404
+ .object({
405
+ label: z.string().min(1).max(140),
406
+ description: z.string().max(2000).optional(),
407
+ parentId: z.string().nullable().optional(),
408
+ projectId: z
409
+ .string()
410
+ .optional()
411
+ .describe('Required when parentId is null/omitted AND the workspace has more than one project. Otherwise inferred.'),
412
+ tags: z.array(z.string()).optional(),
413
+ })
414
+ .strict();
415
+ export async function addConcept(client, args) {
416
+ const wsId = await resolveWorkspaceId(client);
417
+ // Figure out parent + project.
418
+ let parentNode = null;
419
+ if (typeof args.parentId === 'string') {
420
+ parentNode = unwrap(await client.from('nodes').select('*').eq('id', args.parentId).maybeSingle());
421
+ if (!parentNode)
422
+ return { error: `Parent ${args.parentId} not found.` };
423
+ }
424
+ let projectId = args.projectId ?? null;
425
+ if (!projectId && parentNode) {
426
+ // Inherit the parent's project (the first one if it sits in many).
427
+ const rows = unwrap(await client
428
+ .from('project_nodes')
429
+ .select('project_id')
430
+ .eq('node_id', parentNode.id)
431
+ .limit(1));
432
+ if (rows.length > 0)
433
+ projectId = rows[0].project_id;
434
+ }
435
+ if (!projectId) {
436
+ // Fall back to the workspace's only project. If there's >1 and the
437
+ // caller didn't pass projectId we refuse rather than guess.
438
+ const rows = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
439
+ if (rows.length === 0) {
440
+ return {
441
+ error: 'No project in this workspace. Create one in the webapp first (or via create_project once that tool ships in 19.4).',
442
+ };
443
+ }
444
+ if (rows.length > 1) {
445
+ return {
446
+ error: 'Workspace has multiple projects — pass `projectId` to disambiguate (or set `parentId` so the project is inherited).',
447
+ };
448
+ }
449
+ projectId = rows[0].id;
450
+ }
451
+ // Insert the node.
452
+ const insertNode = unwrap(await client
453
+ .from('nodes')
454
+ .insert({
455
+ workspace_id: wsId,
456
+ parent_id: parentNode?.id ?? null,
457
+ label: args.label,
458
+ description: args.description ?? '',
459
+ tags: args.tags ?? [],
460
+ project_id: projectId,
461
+ })
462
+ .select('*')
463
+ .single());
464
+ // Link to the project.
465
+ await client
466
+ .from('project_nodes')
467
+ .insert({ project_id: projectId, node_id: insertNode.id })
468
+ .select('id')
469
+ .maybeSingle();
470
+ // Partition edge if parented.
471
+ if (parentNode) {
472
+ await client
473
+ .from('edges')
474
+ .insert({
475
+ workspace_id: wsId,
476
+ from_id: parentNode.id,
477
+ to_id: insertNode.id,
478
+ kind: 'partition',
479
+ })
480
+ .select('id')
481
+ .maybeSingle();
482
+ }
483
+ return {
484
+ id: insertNode.id,
485
+ label: insertNode.label,
486
+ parentId: insertNode.parent_id,
487
+ projectId,
488
+ };
489
+ }
490
+ // ---------------------------------------------------------------------------
491
+ // update_concept (write)
492
+ // ---------------------------------------------------------------------------
493
+ export const updateConceptInput = z
494
+ .object({
495
+ id: z.string().min(1),
496
+ label: z.string().optional(),
497
+ description: z.string().optional(),
498
+ tags: z.array(z.string()).optional(),
499
+ partitionAttribute: z.string().optional(),
500
+ rationale: z.string().optional(),
501
+ status: z.enum(['open', 'validated', 'archived']).optional(),
502
+ })
503
+ .strict();
504
+ export async function updateConcept(client, args) {
505
+ const patch = {};
506
+ if (args.label !== undefined)
507
+ patch.label = args.label;
508
+ if (args.description !== undefined)
509
+ patch.description = args.description;
510
+ if (args.tags !== undefined)
511
+ patch.tags = args.tags;
512
+ if (args.partitionAttribute !== undefined)
513
+ patch.partition_attribute = args.partitionAttribute;
514
+ if (args.rationale !== undefined)
515
+ patch.rationale = args.rationale;
516
+ if (args.status !== undefined)
517
+ patch.status = args.status;
518
+ if (Object.keys(patch).length === 0) {
519
+ return { error: 'No fields to update.' };
520
+ }
521
+ const row = unwrap(await client
522
+ .from('nodes')
523
+ .update(patch)
524
+ .eq('id', args.id)
525
+ .select('id, label, status')
526
+ .maybeSingle());
527
+ if (!row)
528
+ return { error: `No concept with id ${args.id}` };
529
+ return { id: row.id, label: row.label, status: row.status };
530
+ }
531
+ // ---------------------------------------------------------------------------
532
+ // link_concepts (write — k-ref / derived-from / semantic-adjacency only)
533
+ // ---------------------------------------------------------------------------
534
+ // We DON'T expose `partition` here — partition edges are an artifact of
535
+ // parent_id and are managed via add_concept / set_parent. Matches the
536
+ // webapp tool's contract.
537
+ export const linkConceptsInput = z
538
+ .object({
539
+ fromId: z.string().min(1),
540
+ toId: z.string().min(1),
541
+ kind: z.enum(['k-ref', 'derived-from', 'semantic-adjacency']),
542
+ })
543
+ .strict();
544
+ export async function linkConcepts(client, args) {
545
+ if (args.fromId === args.toId) {
546
+ return { error: 'Self-loop edges are not allowed.' };
547
+ }
548
+ const from = unwrap(await client
549
+ .from('nodes')
550
+ .select('id, workspace_id')
551
+ .eq('id', args.fromId)
552
+ .maybeSingle());
553
+ if (!from)
554
+ return { error: `No concept with id ${args.fromId}` };
555
+ const to = unwrap(await client
556
+ .from('nodes')
557
+ .select('id, workspace_id')
558
+ .eq('id', args.toId)
559
+ .maybeSingle());
560
+ if (!to)
561
+ return { error: `No concept with id ${args.toId}` };
562
+ if (from.workspace_id !== to.workspace_id) {
563
+ return {
564
+ error: 'Cannot link concepts from different workspaces.',
565
+ };
566
+ }
567
+ // Reject duplicate edges of the same kind on the same pair.
568
+ const dup = unwrap(await client
569
+ .from('edges')
570
+ .select('id')
571
+ .eq('from_id', from.id)
572
+ .eq('to_id', to.id)
573
+ .eq('kind', args.kind)
574
+ .maybeSingle());
575
+ if (dup) {
576
+ return { id: dup.id, fromId: from.id, toId: to.id, kind: args.kind, duplicate: true };
577
+ }
578
+ const row = unwrap(await client
579
+ .from('edges')
580
+ .insert({
581
+ workspace_id: from.workspace_id,
582
+ from_id: from.id,
583
+ to_id: to.id,
584
+ kind: args.kind,
585
+ })
586
+ .select('id, from_id, to_id, kind')
587
+ .single());
588
+ return { id: row.id, fromId: row.from_id, toId: row.to_id, kind: row.kind };
589
+ }
590
+ async function stampProvenance(client, args) {
591
+ try {
592
+ const row = {
593
+ workspace_id: args.workspaceId,
594
+ node_id: args.nodeId,
595
+ origin: args.origin ?? 'mcp',
596
+ operator_key: args.operatorKey ?? null,
597
+ source_refs: [args.sourceRef],
598
+ llm_json: args.llmJson ?? null,
599
+ created_by: 'agent',
600
+ analysis_id: args.analysisId ?? null,
601
+ analysis_tool: args.analysisTool ?? null,
602
+ timestamp_ms: Date.now(),
603
+ };
604
+ const res = await client.from('provenance').insert(row);
605
+ if (res.error) {
606
+ console.error(`[heuresis-mcp] provenance insert failed (best-effort): ${res.error.message}`);
607
+ }
608
+ }
609
+ catch (err) {
610
+ console.error(`[heuresis-mcp] provenance insert threw (best-effort): ${err instanceof Error ? err.message : String(err)}`);
611
+ }
612
+ }
613
+ // ===========================================================================
614
+ // READS — additions (Phase 19.4)
615
+ // ===========================================================================
616
+ // ---------------------------------------------------------------------------
617
+ // get_workspace_summary
618
+ // ---------------------------------------------------------------------------
619
+ // Same shape the legacy snapshot mode produced so existing agent prompts
620
+ // keep working. Source of truth is the cloud workspace, not a snapshot file.
621
+ export const getWorkspaceSummaryInput = z.object({}).strict();
622
+ export async function getWorkspaceSummary(client) {
623
+ const wsId = await resolveWorkspaceId(client);
624
+ const ws = unwrap(await client
625
+ .from('workspaces')
626
+ .select('id, name')
627
+ .eq('id', wsId)
628
+ .maybeSingle());
629
+ const [projectRows, ideaRows, nodeCountRes, edgeCountRes] = await Promise.all([
630
+ client.from('projects').select('*').eq('workspace_id', wsId),
631
+ client.from('ideas').select('*').eq('workspace_id', wsId),
632
+ client
633
+ .from('nodes')
634
+ .select('id', { count: 'exact', head: true })
635
+ .eq('workspace_id', wsId),
636
+ client
637
+ .from('edges')
638
+ .select('id', { count: 'exact', head: true })
639
+ .eq('workspace_id', wsId),
640
+ ]);
641
+ const projects = unwrap(projectRows);
642
+ const ideas = unwrap(ideaRows);
643
+ if (nodeCountRes.error)
644
+ throw new Error(nodeCountRes.error.message);
645
+ if (edgeCountRes.error)
646
+ throw new Error(edgeCountRes.error.message);
647
+ // Per-project membership counts.
648
+ const pn = unwrap(await client
649
+ .from('project_nodes')
650
+ .select('project_id, node_id')
651
+ .in('project_id', projects.map((p) => p.id)));
652
+ const projCount = new Map();
653
+ for (const r of pn)
654
+ projCount.set(r.project_id, (projCount.get(r.project_id) ?? 0) + 1);
655
+ // Per-idea membership counts.
656
+ const inJ = unwrap(await client
657
+ .from('idea_nodes')
658
+ .select('idea_id, node_id')
659
+ .in('idea_id', ideas.map((i) => i.id)));
660
+ const ideaCount = new Map();
661
+ for (const r of inJ)
662
+ ideaCount.set(r.idea_id, (ideaCount.get(r.idea_id) ?? 0) + 1);
663
+ return {
664
+ workspaceId: wsId,
665
+ workspaces: ws ? [{ id: ws.id, name: ws.name }] : [],
666
+ counts: {
667
+ nodes: nodeCountRes.count ?? 0,
668
+ edges: edgeCountRes.count ?? 0,
669
+ projects: projects.length,
670
+ ideas: ideas.length,
671
+ },
672
+ projects: projects.map((p) => ({
673
+ id: p.id,
674
+ name: p.name,
675
+ brief: p.brief,
676
+ direction: p.direction,
677
+ lifecycle: p.lifecycle,
678
+ nodeCount: projCount.get(p.id) ?? 0,
679
+ rootNodeId: p.root_node_id,
680
+ })),
681
+ ideas: ideas.map((i) => ({
682
+ id: i.id,
683
+ name: i.name,
684
+ color: i.color,
685
+ nodeCount: ideaCount.get(i.id) ?? 0,
686
+ })),
687
+ };
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // list_recent_decisions
691
+ // ---------------------------------------------------------------------------
692
+ // "Decisions" = nodes the user has explicitly resolved recently (validated,
693
+ // starred, or archived). Same shape as the legacy snapshot tool.
694
+ export const listRecentDecisionsInput = z
695
+ .object({
696
+ sinceMs: z
697
+ .number()
698
+ .int()
699
+ .optional()
700
+ .describe('Unix-ms cutoff; default = last 7 days.'),
701
+ limit: z.number().int().min(1).max(MAX_RESULTS).default(20),
702
+ })
703
+ .strict();
704
+ export async function listRecentDecisions(client, args) {
705
+ const wsId = await resolveWorkspaceId(client);
706
+ const cutoff = args.sinceMs ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
707
+ const cutoffIso = new Date(cutoff).toISOString();
708
+ // Fetch the most-recently-updated nodes that match a decision shape.
709
+ const rows = unwrap(await client
710
+ .from('nodes')
711
+ .select('id, label, status, starred, updated_at')
712
+ .eq('workspace_id', wsId)
713
+ .gte('updated_at', cutoffIso)
714
+ .order('updated_at', { ascending: false })
715
+ .limit(args.limit * 4));
716
+ const decisions = rows
717
+ .filter((n) => n.starred || n.status === 'validated' || n.status === 'archived')
718
+ .slice(0, args.limit)
719
+ .map((n) => ({
720
+ id: n.id,
721
+ label: n.label,
722
+ status: n.status,
723
+ starred: n.starred,
724
+ updatedAt: n.updated_at,
725
+ decision: n.status === 'validated'
726
+ ? 'validated'
727
+ : n.status === 'archived'
728
+ ? 'archived'
729
+ : n.starred
730
+ ? 'starred'
731
+ : 'open',
732
+ }));
733
+ return {
734
+ sinceMs: cutoff,
735
+ since: cutoffIso,
736
+ count: decisions.length,
737
+ decisions,
738
+ };
739
+ }
740
+ // ---------------------------------------------------------------------------
741
+ // list_concepts
742
+ // ---------------------------------------------------------------------------
743
+ // Mirrors webapp `list_concepts`. `scope`='project' uses projectId arg (the
744
+ // MCP has no notion of a "current project" the way the webapp does), so the
745
+ // MCP arg adds projectId where the webapp implicitly reads currentProjectId.
746
+ export const listConceptsInput = z
747
+ .object({
748
+ scope: z.enum(['project', 'workspace']).default('workspace'),
749
+ projectId: z
750
+ .string()
751
+ .optional()
752
+ .describe('Required when scope=project.'),
753
+ includeArchived: z.boolean().default(false),
754
+ detail: detailArg,
755
+ limit: z.number().int().min(1).max(500).default(500),
756
+ })
757
+ .strict();
758
+ export async function listConcepts(client, args) {
759
+ const wsId = await resolveWorkspaceId(client);
760
+ let memberIds = null;
761
+ let projectName = null;
762
+ if (args.scope === 'project') {
763
+ if (!args.projectId) {
764
+ return { error: 'scope=project requires projectId.' };
765
+ }
766
+ const proj = unwrap(await client
767
+ .from('projects')
768
+ .select('id, name')
769
+ .eq('id', args.projectId)
770
+ .maybeSingle());
771
+ if (!proj)
772
+ return { error: `No project with id ${args.projectId}` };
773
+ projectName = proj.name;
774
+ const rows = unwrap(await client
775
+ .from('project_nodes')
776
+ .select('node_id')
777
+ .eq('project_id', args.projectId));
778
+ memberIds = rows.map((r) => r.node_id);
779
+ if (memberIds.length === 0) {
780
+ return {
781
+ scope: args.scope,
782
+ projectName,
783
+ total: 0,
784
+ truncated: false,
785
+ concepts: [],
786
+ };
787
+ }
788
+ }
789
+ let qb = client
790
+ .from('nodes')
791
+ .select('*')
792
+ .eq('workspace_id', wsId)
793
+ .order('updated_at', { ascending: false })
794
+ .limit(args.limit);
795
+ if (memberIds)
796
+ qb = qb.in('id', memberIds);
797
+ if (!args.includeArchived)
798
+ qb = qb.neq('status', 'archived');
799
+ const rows = unwrap(await qb);
800
+ return {
801
+ scope: args.scope,
802
+ projectName,
803
+ total: rows.length,
804
+ truncated: rows.length === args.limit,
805
+ concepts: rows.map((n) => nodeView(n, args.detail)),
806
+ };
807
+ }
808
+ // ---------------------------------------------------------------------------
809
+ // list_edges
810
+ // ---------------------------------------------------------------------------
811
+ // Mirrors webapp `list_edges`. Restrict to a project when projectId is given.
812
+ export const listEdgesInput = z
813
+ .object({
814
+ projectId: z.string().optional(),
815
+ kind: z
816
+ .enum(['partition', 'k-ref', 'semantic-adjacency', 'derived-from', 'imported-from'])
817
+ .optional(),
818
+ })
819
+ .strict();
820
+ export async function listEdges(client, args) {
821
+ const wsId = await resolveWorkspaceId(client);
822
+ let memberIds = null;
823
+ if (args.projectId) {
824
+ const rows = unwrap(await client
825
+ .from('project_nodes')
826
+ .select('node_id')
827
+ .eq('project_id', args.projectId));
828
+ memberIds = new Set(rows.map((r) => r.node_id));
829
+ if (memberIds.size === 0) {
830
+ return { total: 0, edges: [] };
831
+ }
832
+ }
833
+ let qb = client
834
+ .from('edges')
835
+ .select('id, from_id, to_id, kind')
836
+ .eq('workspace_id', wsId);
837
+ if (args.kind)
838
+ qb = qb.eq('kind', args.kind);
839
+ const rows = unwrap(await qb);
840
+ const filtered = memberIds
841
+ ? rows.filter((e) => memberIds.has(e.from_id) && memberIds.has(e.to_id))
842
+ : rows;
843
+ return {
844
+ total: filtered.length,
845
+ edges: filtered.map((e) => ({
846
+ id: e.id,
847
+ fromId: e.from_id,
848
+ toId: e.to_id,
849
+ kind: e.kind,
850
+ })),
851
+ };
852
+ }
853
+ // ---------------------------------------------------------------------------
854
+ // find_concepts
855
+ // ---------------------------------------------------------------------------
856
+ // Mirrors the webapp `find_concepts` tool. Webapp version supports
857
+ // by='meaning' (in-browser embeddings) and by='label'. The MCP runs server-
858
+ // side without a local embedding model, so by='meaning' transparently falls
859
+ // back to the same substring search as by='label' and the response carries a
860
+ // `note` explaining the degradation. Phase 19.5 swaps in pgvector when the
861
+ // schema picks it up.
862
+ export const findConceptsInput = z
863
+ .object({
864
+ query: z.string().min(1),
865
+ k: z.number().int().positive().max(20).default(10),
866
+ by: z.enum(['meaning', 'label']).default('meaning'),
867
+ projectId: z.string().optional(),
868
+ })
869
+ .strict();
870
+ export async function findConcepts(client, args) {
871
+ // We just call searchConcepts; the shape is similar but the response
872
+ // contract here matches the webapp tool's response (`hits`, `mode`).
873
+ const sub = await searchConcepts(client, {
874
+ query: args.query,
875
+ limit: args.k,
876
+ detail: 'compact',
877
+ projectId: args.projectId,
878
+ });
879
+ if ('error' in sub)
880
+ return sub;
881
+ const results = (sub.results ?? []);
882
+ const hits = results.map((r) => ({
883
+ id: r.id,
884
+ label: r.label,
885
+ status: r.status,
886
+ score: 1,
887
+ }));
888
+ if (args.by === 'meaning') {
889
+ return {
890
+ hits,
891
+ mode: 'label',
892
+ note: 'Semantic search not yet wired in MCP (no in-browser embedding model); fell back to label/substring match.',
893
+ };
894
+ }
895
+ return { hits, mode: 'label' };
896
+ }
897
+ // ===========================================================================
898
+ // WRITES — additions (Phase 19.4)
899
+ // ===========================================================================
900
+ // ---------------------------------------------------------------------------
901
+ // validate_concept
902
+ // ---------------------------------------------------------------------------
903
+ export const validateConceptInput = z
904
+ .object({
905
+ id: z.string().min(1),
906
+ rationale: z.string().optional(),
907
+ })
908
+ .strict();
909
+ export async function validateConcept(client, args) {
910
+ const node = unwrap(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
911
+ if (!node)
912
+ return { error: `No concept with id ${args.id}` };
913
+ const patch = { status: 'validated' };
914
+ if (args.rationale !== undefined)
915
+ patch.rationale = args.rationale;
916
+ const row = unwrap(await client
917
+ .from('nodes')
918
+ .update(patch)
919
+ .eq('id', args.id)
920
+ .select('id, label, status')
921
+ .maybeSingle());
922
+ if (!row)
923
+ return { error: `Update failed for ${args.id}` };
924
+ await stampProvenance(client, {
925
+ nodeId: row.id,
926
+ workspaceId: node.workspace_id,
927
+ sourceRef: 'validate',
928
+ });
929
+ return { id: row.id, label: row.label, status: row.status };
930
+ }
931
+ // ---------------------------------------------------------------------------
932
+ // set_standing
933
+ // ---------------------------------------------------------------------------
934
+ export const setStandingInput = z
935
+ .object({
936
+ id: z.string().min(1),
937
+ standing: z.enum(['unknown', 'novel', 'emerging', 'established']),
938
+ rationale: z.string().min(1).max(500),
939
+ })
940
+ .strict();
941
+ export async function setStanding(client, args) {
942
+ const node = unwrap(await client
943
+ .from('nodes')
944
+ .select('id, workspace_id, label, standing_rationale')
945
+ .eq('id', args.id)
946
+ .maybeSingle());
947
+ if (!node)
948
+ return { error: `No concept with id ${args.id}` };
949
+ const patch = {
950
+ standing: args.standing,
951
+ standing_rationale: args.rationale,
952
+ standing_assessed_at: new Date().toISOString(),
953
+ };
954
+ const row = unwrap(await client
955
+ .from('nodes')
956
+ .update(patch)
957
+ .eq('id', args.id)
958
+ .select('id, label, standing, standing_rationale')
959
+ .maybeSingle());
960
+ if (!row)
961
+ return { error: `Update failed for ${args.id}` };
962
+ await stampProvenance(client, {
963
+ nodeId: row.id,
964
+ workspaceId: node.workspace_id,
965
+ sourceRef: `standing:${args.standing}`,
966
+ });
967
+ return {
968
+ id: row.id,
969
+ label: row.label,
970
+ standing: row.standing,
971
+ rationale: row.standing_rationale,
972
+ };
973
+ }
974
+ // ---------------------------------------------------------------------------
975
+ // archive_concept / unarchive_concept / star_concept
976
+ // ---------------------------------------------------------------------------
977
+ export const archiveConceptInput = z.object({ id: z.string().min(1) }).strict();
978
+ export async function archiveConcept(client, args) {
979
+ const node = unwrap(await client
980
+ .from('nodes')
981
+ .select('id, workspace_id, label, status')
982
+ .eq('id', args.id)
983
+ .maybeSingle());
984
+ if (!node)
985
+ return { error: `No concept with id ${args.id}` };
986
+ if (node.status === 'archived') {
987
+ return { id: node.id, label: node.label, status: node.status, noop: true };
988
+ }
989
+ const row = unwrap(await client
990
+ .from('nodes')
991
+ .update({ status: 'archived' })
992
+ .eq('id', args.id)
993
+ .select('id, label, status')
994
+ .maybeSingle());
995
+ if (!row)
996
+ return { error: `Update failed for ${args.id}` };
997
+ await stampProvenance(client, {
998
+ nodeId: row.id,
999
+ workspaceId: node.workspace_id,
1000
+ sourceRef: 'archive',
1001
+ });
1002
+ return { id: row.id, label: row.label, status: row.status };
1003
+ }
1004
+ export const unarchiveConceptInput = z.object({ id: z.string().min(1) }).strict();
1005
+ export async function unarchiveConcept(client, args) {
1006
+ const node = unwrap(await client
1007
+ .from('nodes')
1008
+ .select('id, workspace_id, label, status')
1009
+ .eq('id', args.id)
1010
+ .maybeSingle());
1011
+ if (!node)
1012
+ return { error: `No concept with id ${args.id}` };
1013
+ if (node.status !== 'archived') {
1014
+ return { id: node.id, label: node.label, status: node.status, noop: true };
1015
+ }
1016
+ const row = unwrap(await client
1017
+ .from('nodes')
1018
+ .update({ status: 'open' })
1019
+ .eq('id', args.id)
1020
+ .select('id, label, status')
1021
+ .maybeSingle());
1022
+ if (!row)
1023
+ return { error: `Update failed for ${args.id}` };
1024
+ await stampProvenance(client, {
1025
+ nodeId: row.id,
1026
+ workspaceId: node.workspace_id,
1027
+ sourceRef: 'unarchive',
1028
+ });
1029
+ return { id: row.id, label: row.label, status: row.status };
1030
+ }
1031
+ export const starConceptInput = z.object({ id: z.string().min(1) }).strict();
1032
+ export async function starConcept(client, args) {
1033
+ const node = unwrap(await client
1034
+ .from('nodes')
1035
+ .select('id, workspace_id, label, starred')
1036
+ .eq('id', args.id)
1037
+ .maybeSingle());
1038
+ if (!node)
1039
+ return { error: `No concept with id ${args.id}` };
1040
+ const next = !node.starred;
1041
+ const row = unwrap(await client
1042
+ .from('nodes')
1043
+ .update({ starred: next })
1044
+ .eq('id', args.id)
1045
+ .select('id, label, starred')
1046
+ .maybeSingle());
1047
+ if (!row)
1048
+ return { error: `Update failed for ${args.id}` };
1049
+ await stampProvenance(client, {
1050
+ nodeId: row.id,
1051
+ workspaceId: node.workspace_id,
1052
+ sourceRef: next ? 'star' : 'unstar',
1053
+ });
1054
+ return { id: row.id, label: row.label, starred: row.starred };
1055
+ }
1056
+ // ---------------------------------------------------------------------------
1057
+ // remove_concept (cascade across the parent_id tree)
1058
+ // ---------------------------------------------------------------------------
1059
+ // Cloud FKs:
1060
+ // nodes.parent_id ON DELETE SET NULL (children become orphans, NOT auto-
1061
+ // deleted by Postgres)
1062
+ // edges.from_id/to_id ON DELETE CASCADE
1063
+ // project_nodes / idea_nodes ON DELETE CASCADE
1064
+ // So to match the webapp's `removeNode` cascade we walk the parent_id graph
1065
+ // in code, collect every descendant, and delete the bottom-up set.
1066
+ export const removeConceptInput = z.object({ id: z.string().min(1) }).strict();
1067
+ export async function removeConcept(client, args) {
1068
+ const node = unwrap(await client
1069
+ .from('nodes')
1070
+ .select('id, workspace_id, label')
1071
+ .eq('id', args.id)
1072
+ .maybeSingle());
1073
+ if (!node)
1074
+ return { error: `No concept with id ${args.id}` };
1075
+ // Refuse to delete a project's root.
1076
+ const isRoot = unwrap(await client
1077
+ .from('projects')
1078
+ .select('id')
1079
+ .eq('root_node_id', args.id)
1080
+ .limit(1));
1081
+ if (isRoot.length > 0) {
1082
+ return { error: 'Cannot delete a project root node.' };
1083
+ }
1084
+ // Walk the parent_id subtree in memory (cheaper than depth round-trips).
1085
+ const allNodes = unwrap(await client
1086
+ .from('nodes')
1087
+ .select('id, parent_id')
1088
+ .eq('workspace_id', node.workspace_id));
1089
+ const childrenByParent = new Map();
1090
+ for (const n of allNodes) {
1091
+ if (n.parent_id) {
1092
+ const arr = childrenByParent.get(n.parent_id) ?? [];
1093
+ arr.push(n.id);
1094
+ childrenByParent.set(n.parent_id, arr);
1095
+ }
1096
+ }
1097
+ const toDelete = [];
1098
+ const stack = [args.id];
1099
+ while (stack.length > 0) {
1100
+ const cur = stack.pop();
1101
+ toDelete.push(cur);
1102
+ const kids = childrenByParent.get(cur) ?? [];
1103
+ stack.push(...kids);
1104
+ }
1105
+ // Delete in one shot — edges/junctions cascade via FK; provenance
1106
+ // cascades via FK in 0015's table definition.
1107
+ const del = await client.from('nodes').delete().in('id', toDelete);
1108
+ if (del.error)
1109
+ return { error: del.error.message };
1110
+ return {
1111
+ id: args.id,
1112
+ removed: true,
1113
+ cascadeCount: toDelete.length,
1114
+ };
1115
+ }
1116
+ // ---------------------------------------------------------------------------
1117
+ // bulk_add_concepts (atomic via fn_bulk_add_concepts RPC)
1118
+ // ---------------------------------------------------------------------------
1119
+ // Each child resolves its project_id either explicitly (via the item's
1120
+ // `projectId`), via the parent's home project, or via the workspace's only
1121
+ // project. Resolution happens here (the RPC requires project_id on every
1122
+ // item) and the RPC then guarantees atomicity across the multi-row insert.
1123
+ export const bulkAddConceptsInput = z
1124
+ .object({
1125
+ items: z
1126
+ .array(z.object({
1127
+ label: z.string().min(1).max(140),
1128
+ description: z.string().max(2000).optional(),
1129
+ parentId: z.string().nullable().optional(),
1130
+ projectId: z.string().optional(),
1131
+ tags: z.array(z.string()).optional(),
1132
+ }))
1133
+ .min(1)
1134
+ .max(200),
1135
+ })
1136
+ .strict();
1137
+ export async function bulkAddConcepts(client, args) {
1138
+ const wsId = await resolveWorkspaceId(client);
1139
+ // Pre-resolve every item's project_id so the RPC has the simple shape.
1140
+ const parentIds = Array.from(new Set(args.items
1141
+ .map((i) => i.parentId)
1142
+ .filter((p) => typeof p === 'string' && p.length > 0)));
1143
+ const parentRows = parentIds.length
1144
+ ? unwrap(await client.from('nodes').select('id, project_id').in('id', parentIds))
1145
+ : [];
1146
+ const parentProject = new Map(parentRows.map((p) => [p.id, p.project_id]));
1147
+ // Workspace's solo project, used when nothing else resolves.
1148
+ const projectRows = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
1149
+ const soloProjectId = projectRows.length === 1 ? projectRows[0].id : null;
1150
+ const resolved = [];
1151
+ for (const item of args.items) {
1152
+ let projectId = item.projectId ?? null;
1153
+ if (!projectId && item.parentId) {
1154
+ projectId = parentProject.get(item.parentId) ?? null;
1155
+ }
1156
+ if (!projectId)
1157
+ projectId = soloProjectId;
1158
+ if (!projectId) {
1159
+ return {
1160
+ error: `Could not resolve projectId for item "${item.label}" — workspace has ${projectRows.length} projects, no parent to inherit from.`,
1161
+ };
1162
+ }
1163
+ resolved.push({
1164
+ parent_id: item.parentId ?? null,
1165
+ label: item.label,
1166
+ description: item.description ?? '',
1167
+ tags: item.tags ?? [],
1168
+ project_id: projectId,
1169
+ });
1170
+ }
1171
+ const rpc = await client.rpc('fn_bulk_add_concepts', {
1172
+ p_items: resolved,
1173
+ });
1174
+ if (rpc.error)
1175
+ return { error: rpc.error.message };
1176
+ const created = (rpc.data ?? []);
1177
+ // Best-effort provenance for each new node.
1178
+ for (const c of created) {
1179
+ await stampProvenance(client, {
1180
+ nodeId: c.id,
1181
+ workspaceId: wsId,
1182
+ sourceRef: 'bulk-add',
1183
+ });
1184
+ }
1185
+ return {
1186
+ created: created.map((c) => ({
1187
+ id: c.id,
1188
+ label: c.label,
1189
+ parentId: c.parent_id,
1190
+ projectId: c.project_id,
1191
+ })),
1192
+ count: created.length,
1193
+ };
1194
+ }
1195
+ // ---------------------------------------------------------------------------
1196
+ // set_parent (move a node + sync partition edge)
1197
+ // ---------------------------------------------------------------------------
1198
+ // Mirrors webapp `set_parent` / store.moveNodeParent: updates parent_id AND
1199
+ // drops any pre-existing partition edge whose target is this node, then
1200
+ // inserts a fresh partition edge from the new parent if non-null.
1201
+ export const setParentInput = z
1202
+ .object({
1203
+ nodeId: z.string().min(1),
1204
+ newParentId: z.string().nullable(),
1205
+ })
1206
+ .strict();
1207
+ export async function setParent(client, args) {
1208
+ const node = unwrap(await client
1209
+ .from('nodes')
1210
+ .select('id, workspace_id, parent_id, label')
1211
+ .eq('id', args.nodeId)
1212
+ .maybeSingle());
1213
+ if (!node)
1214
+ return { error: `No concept with id ${args.nodeId}` };
1215
+ if (args.newParentId && args.newParentId === args.nodeId) {
1216
+ return { error: 'Cannot parent a node to itself.' };
1217
+ }
1218
+ let resolvedParentId = args.newParentId;
1219
+ if (resolvedParentId) {
1220
+ const parent = unwrap(await client
1221
+ .from('nodes')
1222
+ .select('id, workspace_id')
1223
+ .eq('id', resolvedParentId)
1224
+ .maybeSingle());
1225
+ if (!parent)
1226
+ return { error: `New parent ${resolvedParentId} not found.` };
1227
+ if (parent.workspace_id !== node.workspace_id) {
1228
+ return { error: 'Cannot re-parent across workspaces.' };
1229
+ }
1230
+ }
1231
+ // 1) Update parent_id.
1232
+ const updRes = await client
1233
+ .from('nodes')
1234
+ .update({ parent_id: resolvedParentId })
1235
+ .eq('id', args.nodeId);
1236
+ if (updRes.error)
1237
+ return { error: updRes.error.message };
1238
+ // 2) Delete every existing partition edge whose target is this node.
1239
+ const delRes = await client
1240
+ .from('edges')
1241
+ .delete()
1242
+ .eq('to_id', args.nodeId)
1243
+ .eq('kind', 'partition');
1244
+ if (delRes.error)
1245
+ return { error: delRes.error.message };
1246
+ // 3) Insert a fresh partition edge from the new parent (if any).
1247
+ if (resolvedParentId) {
1248
+ const insRes = await client.from('edges').insert({
1249
+ workspace_id: node.workspace_id,
1250
+ from_id: resolvedParentId,
1251
+ to_id: args.nodeId,
1252
+ kind: 'partition',
1253
+ });
1254
+ if (insRes.error)
1255
+ return { error: insRes.error.message };
1256
+ }
1257
+ await stampProvenance(client, {
1258
+ nodeId: args.nodeId,
1259
+ workspaceId: node.workspace_id,
1260
+ sourceRef: 'set-parent',
1261
+ });
1262
+ return {
1263
+ ok: true,
1264
+ nodeId: args.nodeId,
1265
+ newParentId: resolvedParentId,
1266
+ };
1267
+ }
1268
+ // ---------------------------------------------------------------------------
1269
+ // add_kref — convenience alias of link_concepts kind='k-ref'.
1270
+ // ---------------------------------------------------------------------------
1271
+ export const addKrefInput = z
1272
+ .object({
1273
+ fromId: z.string().min(1),
1274
+ toId: z.string().min(1),
1275
+ })
1276
+ .strict();
1277
+ export async function addKref(client, args) {
1278
+ return linkConcepts(client, {
1279
+ fromId: args.fromId,
1280
+ toId: args.toId,
1281
+ kind: 'k-ref',
1282
+ });
1283
+ }
1284
+ // ---------------------------------------------------------------------------
1285
+ // IDEAS — create_idea, rename_idea, recolor_idea, set_idea_members,
1286
+ // add_to_idea, delete_idea
1287
+ // ---------------------------------------------------------------------------
1288
+ // Idea ↔ node membership lives in `idea_nodes`. `set_idea_members` follows
1289
+ // the reconcileJunctionForParent pattern from src/data/cloud-sync.ts: upsert
1290
+ // the desired pairs, then delete the rows no longer in the set.
1291
+ const DEFAULT_IDEA_PALETTE = [
1292
+ '#ff6b6b',
1293
+ '#ffd166',
1294
+ '#06d6a0',
1295
+ '#118ab2',
1296
+ '#9b5de5',
1297
+ '#f15bb5',
1298
+ '#00bbf9',
1299
+ '#fee440',
1300
+ ];
1301
+ export const createIdeaInput = z
1302
+ .object({
1303
+ name: z.string().min(1).max(140),
1304
+ color: z.string().optional(),
1305
+ nodeIds: z.array(z.string()).optional(),
1306
+ })
1307
+ .strict();
1308
+ export async function createIdea(client, args) {
1309
+ const wsId = await resolveWorkspaceId(client);
1310
+ // Pick a default color if none given. Webapp uses an 8-color palette
1311
+ // indexed by current idea count; do the same here.
1312
+ let color = args.color;
1313
+ if (!color) {
1314
+ const existing = unwrap(await client
1315
+ .from('ideas')
1316
+ .select('id', { count: 'exact', head: true })
1317
+ .eq('workspace_id', wsId));
1318
+ void existing;
1319
+ // The head:true query returns count via the result envelope; we just
1320
+ // index modulo palette length using a fresh count query.
1321
+ const c = await client
1322
+ .from('ideas')
1323
+ .select('id', { count: 'exact', head: true })
1324
+ .eq('workspace_id', wsId);
1325
+ color = DEFAULT_IDEA_PALETTE[(c.count ?? 0) % DEFAULT_IDEA_PALETTE.length];
1326
+ }
1327
+ const idea = unwrap(await client
1328
+ .from('ideas')
1329
+ .insert({ workspace_id: wsId, name: args.name, color })
1330
+ .select('id, name, color')
1331
+ .single());
1332
+ // Insert membership rows.
1333
+ const nodeIds = args.nodeIds ?? [];
1334
+ if (nodeIds.length > 0) {
1335
+ const rows = nodeIds.map((nid, i) => ({
1336
+ idea_id: idea.id,
1337
+ node_id: nid,
1338
+ position: i,
1339
+ }));
1340
+ const ins = await client.from('idea_nodes').insert(rows);
1341
+ if (ins.error)
1342
+ return { error: ins.error.message };
1343
+ }
1344
+ return { id: idea.id, name: idea.name, color: idea.color, nodeCount: nodeIds.length };
1345
+ }
1346
+ export const renameIdeaInput = z
1347
+ .object({ id: z.string().min(1), name: z.string().min(1).max(140) })
1348
+ .strict();
1349
+ export async function renameIdea(client, args) {
1350
+ const row = unwrap(await client
1351
+ .from('ideas')
1352
+ .update({ name: args.name })
1353
+ .eq('id', args.id)
1354
+ .select('id, name')
1355
+ .maybeSingle());
1356
+ if (!row)
1357
+ return { error: `No idea with id ${args.id}` };
1358
+ return { id: row.id, name: row.name };
1359
+ }
1360
+ export const recolorIdeaInput = z
1361
+ .object({ id: z.string().min(1), color: z.string().min(1) })
1362
+ .strict();
1363
+ export async function recolorIdea(client, args) {
1364
+ const row = unwrap(await client
1365
+ .from('ideas')
1366
+ .update({ color: args.color })
1367
+ .eq('id', args.id)
1368
+ .select('id, name, color')
1369
+ .maybeSingle());
1370
+ if (!row)
1371
+ return { error: `No idea with id ${args.id}` };
1372
+ return { id: row.id, name: row.name, color: row.color };
1373
+ }
1374
+ export const setIdeaMembersInput = z
1375
+ .object({
1376
+ ideaId: z.string().min(1),
1377
+ nodeIds: z.array(z.string()),
1378
+ })
1379
+ .strict();
1380
+ export async function setIdeaMembers(client, args) {
1381
+ const idea = unwrap(await client
1382
+ .from('ideas')
1383
+ .select('id, name')
1384
+ .eq('id', args.ideaId)
1385
+ .maybeSingle());
1386
+ if (!idea)
1387
+ return { error: `No idea with id ${args.ideaId}` };
1388
+ const desiredOrder = new Map(args.nodeIds.map((n, i) => [n, i]));
1389
+ const existing = unwrap(await client
1390
+ .from('idea_nodes')
1391
+ .select('node_id')
1392
+ .eq('idea_id', args.ideaId));
1393
+ const existingSet = new Set(existing.map((r) => r.node_id));
1394
+ // Upsert desired rows.
1395
+ const upserts = [...desiredOrder.entries()].map(([nid, position]) => ({
1396
+ idea_id: args.ideaId,
1397
+ node_id: nid,
1398
+ position,
1399
+ }));
1400
+ if (upserts.length > 0) {
1401
+ const up = await client
1402
+ .from('idea_nodes')
1403
+ .upsert(upserts, { onConflict: 'idea_id,node_id' });
1404
+ if (up.error)
1405
+ return { error: up.error.message };
1406
+ }
1407
+ // Delete rows not in the desired set.
1408
+ const toRemove = [...existingSet].filter((nid) => !desiredOrder.has(nid));
1409
+ if (toRemove.length > 0) {
1410
+ const del = await client
1411
+ .from('idea_nodes')
1412
+ .delete()
1413
+ .eq('idea_id', args.ideaId)
1414
+ .in('node_id', toRemove);
1415
+ if (del.error)
1416
+ return { error: del.error.message };
1417
+ }
1418
+ return {
1419
+ id: args.ideaId,
1420
+ name: idea.name,
1421
+ nodeCount: desiredOrder.size,
1422
+ added: [...desiredOrder.keys()].filter((n) => !existingSet.has(n)).length,
1423
+ removed: toRemove.length,
1424
+ };
1425
+ }
1426
+ export const addToIdeaInput = z
1427
+ .object({
1428
+ ideaId: z.string().min(1),
1429
+ nodeId: z.string().min(1),
1430
+ })
1431
+ .strict();
1432
+ export async function addToIdea(client, args) {
1433
+ const idea = unwrap(await client
1434
+ .from('ideas')
1435
+ .select('id')
1436
+ .eq('id', args.ideaId)
1437
+ .maybeSingle());
1438
+ if (!idea)
1439
+ return { error: `No idea with id ${args.ideaId}` };
1440
+ const node = unwrap(await client
1441
+ .from('nodes')
1442
+ .select('id')
1443
+ .eq('id', args.nodeId)
1444
+ .maybeSingle());
1445
+ if (!node)
1446
+ return { error: `No concept with id ${args.nodeId}` };
1447
+ const ins = await client
1448
+ .from('idea_nodes')
1449
+ .upsert({ idea_id: args.ideaId, node_id: args.nodeId, position: 0 }, { onConflict: 'idea_id,node_id' });
1450
+ if (ins.error)
1451
+ return { error: ins.error.message };
1452
+ return { ideaId: args.ideaId, nodeId: args.nodeId };
1453
+ }
1454
+ export const deleteIdeaInput = z.object({ id: z.string().min(1) }).strict();
1455
+ export async function deleteIdea(client, args) {
1456
+ const del = await client.from('ideas').delete().eq('id', args.id);
1457
+ if (del.error)
1458
+ return { error: del.error.message };
1459
+ return { id: args.id, deleted: true };
1460
+ }
1461
+ // ---------------------------------------------------------------------------
1462
+ // PROJECTS — create_project, update_project, delete_project
1463
+ // ---------------------------------------------------------------------------
1464
+ export const createProjectInput = z
1465
+ .object({
1466
+ name: z.string().min(1).max(140),
1467
+ brief: z.string().max(2000).default(''),
1468
+ direction: z.string().max(2000).optional(),
1469
+ })
1470
+ .strict();
1471
+ export async function createProject(client, args) {
1472
+ const wsId = await resolveWorkspaceId(client);
1473
+ const row = unwrap(await client
1474
+ .from('projects')
1475
+ .insert({
1476
+ workspace_id: wsId,
1477
+ name: args.name,
1478
+ brief: args.brief,
1479
+ direction: args.direction ?? null,
1480
+ })
1481
+ .select('id, name, brief, direction, lifecycle')
1482
+ .single());
1483
+ return {
1484
+ id: row.id,
1485
+ name: row.name,
1486
+ brief: row.brief,
1487
+ direction: row.direction,
1488
+ lifecycle: row.lifecycle,
1489
+ };
1490
+ }
1491
+ export const updateProjectInput = z
1492
+ .object({
1493
+ id: z.string().min(1),
1494
+ name: z.string().min(1).max(140).optional(),
1495
+ brief: z.string().max(2000).optional(),
1496
+ direction: z.string().max(2000).optional(),
1497
+ lifecycle: z.enum(['active', 'paused', 'completed', 'abandoned']).optional(),
1498
+ })
1499
+ .strict();
1500
+ export async function updateProject(client, args) {
1501
+ const patch = {};
1502
+ if (args.name !== undefined)
1503
+ patch.name = args.name;
1504
+ if (args.brief !== undefined)
1505
+ patch.brief = args.brief;
1506
+ if (args.direction !== undefined)
1507
+ patch.direction = args.direction;
1508
+ if (args.lifecycle !== undefined)
1509
+ patch.lifecycle = args.lifecycle;
1510
+ if (Object.keys(patch).length === 0) {
1511
+ return { error: 'No fields to update.' };
1512
+ }
1513
+ const row = unwrap(await client
1514
+ .from('projects')
1515
+ .update(patch)
1516
+ .eq('id', args.id)
1517
+ .select('id, name, brief, direction, lifecycle')
1518
+ .maybeSingle());
1519
+ if (!row)
1520
+ return { error: `No project with id ${args.id}` };
1521
+ return {
1522
+ id: row.id,
1523
+ name: row.name,
1524
+ brief: row.brief,
1525
+ direction: row.direction,
1526
+ lifecycle: row.lifecycle,
1527
+ };
1528
+ }
1529
+ export const deleteProjectInput = z.object({ id: z.string().min(1) }).strict();
1530
+ export async function deleteProject(client, args) {
1531
+ const del = await client.from('projects').delete().eq('id', args.id);
1532
+ if (del.error)
1533
+ return { error: del.error.message };
1534
+ return { id: args.id, deleted: true };
1535
+ }
1536
+ export const CLOUD_TOOLS = [
1537
+ // ── Reads ──────────────────────────────────────────────────────────────
1538
+ {
1539
+ name: 'list_projects',
1540
+ description: 'Every project in the workspace with brief, direction, lifecycle, and node count. Use this to figure out which project the user is asking about.',
1541
+ inputSchema: listProjectsInput,
1542
+ handler: async (client) => listProjects(client),
1543
+ },
1544
+ {
1545
+ name: 'get_project_graph',
1546
+ description: 'Every node + edge inside one project. Returns a graph the agent can reason over end-to-end. Use for project-wide questions.',
1547
+ inputSchema: getProjectGraphInput,
1548
+ handler: async (client, args) => getProjectGraph(client, getProjectGraphInput.parse(args)),
1549
+ },
1550
+ {
1551
+ name: 'get_subtree',
1552
+ description: 'A node and its descendants up to a given depth. Use when the user asks "tell me about everything under X".',
1553
+ inputSchema: getSubtreeInput,
1554
+ handler: async (client, args) => getSubtree(client, getSubtreeInput.parse(args)),
1555
+ },
1556
+ {
1557
+ name: 'get_concept',
1558
+ description: 'One concept by id, optionally with its ancestry (parent chain), direct children, and idea memberships.',
1559
+ inputSchema: getConceptInput,
1560
+ handler: async (client, args) => getConcept(client, getConceptInput.parse(args)),
1561
+ },
1562
+ {
1563
+ name: 'search_concepts',
1564
+ description: "Substring search across concept labels, descriptions, partition attributes, rationale, and tags. Use this when the user mentions a concept by name and you don't have its id. (Semantic search is a Phase 19.5 enhancement.)",
1565
+ inputSchema: searchConceptsInput,
1566
+ handler: async (client, args) => searchConcepts(client, searchConceptsInput.parse(args)),
1567
+ },
1568
+ {
1569
+ name: 'get_workspace_summary',
1570
+ description: "Counts of nodes / edges / projects / ideas + a one-line overview of each project and idea. Always start here when you don't know what's in the workspace.",
1571
+ inputSchema: getWorkspaceSummaryInput,
1572
+ handler: async (client) => getWorkspaceSummary(client),
1573
+ },
1574
+ {
1575
+ name: 'list_recent_decisions',
1576
+ description: 'Nodes the user has explicitly resolved (validated, starred, or archived) recently. Use to surface "what did we conclude last week" without a full scan.',
1577
+ inputSchema: listRecentDecisionsInput,
1578
+ handler: async (client, args) => listRecentDecisions(client, listRecentDecisionsInput.parse(args)),
1579
+ },
1580
+ {
1581
+ name: 'list_concepts',
1582
+ description: 'List concepts in the workspace or one project. Returns id / label / status / parentId / tags up to `limit` entries (default 500). Use BEFORE making changes to learn what already exists.',
1583
+ inputSchema: listConceptsInput,
1584
+ handler: async (client, args) => listConcepts(client, listConceptsInput.parse(args)),
1585
+ },
1586
+ {
1587
+ name: 'list_edges',
1588
+ description: 'List every edge in the workspace (or restricted to a project). Filter by kind: partition / k-ref / semantic-adjacency / derived-from / imported-from.',
1589
+ inputSchema: listEdgesInput,
1590
+ handler: async (client, args) => listEdges(client, listEdgesInput.parse(args)),
1591
+ },
1592
+ {
1593
+ name: 'find_concepts',
1594
+ description: "Find concepts by label/substring match. (Webapp parity: by='meaning' is accepted but currently falls back to label match on the MCP — semantic search ships in Phase 19.5.) Returns up to k hits, each with id, label, status.",
1595
+ inputSchema: findConceptsInput,
1596
+ handler: async (client, args) => findConcepts(client, findConceptsInput.parse(args)),
1597
+ },
1598
+ // ── Writes ─────────────────────────────────────────────────────────────
1599
+ {
1600
+ name: 'add_concept',
1601
+ description: 'Create a new concept (Node). If parentId is given, the concept becomes a CHILD of that concept and a partition edge is auto-created. If parentId is omitted, the concept attaches to the project root.',
1602
+ inputSchema: addConceptInput,
1603
+ handler: async (client, args) => addConcept(client, addConceptInput.parse(args)),
1604
+ },
1605
+ {
1606
+ name: 'update_concept',
1607
+ description: "Update an existing concept's label, description, tags, partitionAttribute, rationale, or status. Only the fields provided are changed.",
1608
+ inputSchema: updateConceptInput,
1609
+ handler: async (client, args) => updateConcept(client, updateConceptInput.parse(args)),
1610
+ },
1611
+ {
1612
+ name: 'link_concepts',
1613
+ description: "Create an edge between two concepts. kind is one of: 'k-ref' (knowledge reference), 'derived-from' (provenance link), 'semantic-adjacency' (sibling kinship). Partition (parent/child) edges are managed via add_concept / set_parent.",
1614
+ inputSchema: linkConceptsInput,
1615
+ handler: async (client, args) => linkConcepts(client, linkConceptsInput.parse(args)),
1616
+ },
1617
+ {
1618
+ name: 'add_kref',
1619
+ description: 'Convenience: create a k-ref (knowledge reference) edge from fromId → toId. Equivalent to link_concepts with kind=k-ref.',
1620
+ inputSchema: addKrefInput,
1621
+ handler: async (client, args) => addKref(client, addKrefInput.parse(args)),
1622
+ },
1623
+ {
1624
+ name: 'validate_concept',
1625
+ description: 'Mark a concept as validated (status=validated). Optionally also overwrite `rationale`. Idempotent on already-validated nodes.',
1626
+ inputSchema: validateConceptInput,
1627
+ handler: async (client, args) => validateConcept(client, validateConceptInput.parse(args)),
1628
+ },
1629
+ {
1630
+ name: 'set_standing',
1631
+ description: "Set a concept's STANDING — how known / proven the idea is in the wider world. `standing` ∈ 'novel' | 'emerging' | 'established' | 'unknown'. `rationale` is a one-sentence justification (saved as `standing_rationale`). Use this when classifying / judging novelty.",
1632
+ inputSchema: setStandingInput,
1633
+ handler: async (client, args) => setStanding(client, setStandingInput.parse(args)),
1634
+ },
1635
+ {
1636
+ name: 'archive_concept',
1637
+ description: 'Archive a concept (status=archived). Reversible via unarchive_concept.',
1638
+ inputSchema: archiveConceptInput,
1639
+ handler: async (client, args) => archiveConcept(client, archiveConceptInput.parse(args)),
1640
+ },
1641
+ {
1642
+ name: 'unarchive_concept',
1643
+ description: 'Revive a previously-archived concept (status=open). Idempotent on already-open nodes.',
1644
+ inputSchema: unarchiveConceptInput,
1645
+ handler: async (client, args) => unarchiveConcept(client, unarchiveConceptInput.parse(args)),
1646
+ },
1647
+ {
1648
+ name: 'star_concept',
1649
+ description: 'Toggle the starred flag on a concept (user-favorite marker).',
1650
+ inputSchema: starConceptInput,
1651
+ handler: async (client, args) => starConcept(client, starConceptInput.parse(args)),
1652
+ },
1653
+ {
1654
+ name: 'remove_concept',
1655
+ description: 'Hard-delete a concept and its descendants (walks the parent_id subtree). Edges and project/idea memberships cascade via FK. Refuses to delete a project root node.',
1656
+ inputSchema: removeConceptInput,
1657
+ handler: async (client, args) => removeConcept(client, removeConceptInput.parse(args)),
1658
+ },
1659
+ {
1660
+ name: 'bulk_add_concepts',
1661
+ description: 'Create many concepts in a single atomic transaction. `items` is an array; each item carries label / description? / parentId? / projectId? / tags?. project_id is resolved per-item (explicit > inherited from parent > workspace solo project). Returns the created ids in input order.',
1662
+ inputSchema: bulkAddConceptsInput,
1663
+ handler: async (client, args) => bulkAddConcepts(client, bulkAddConceptsInput.parse(args)),
1664
+ },
1665
+ {
1666
+ name: 'set_parent',
1667
+ description: "Re-parent an existing concept: nodeId's parent becomes newParentId (or detached when null). Updates BOTH node.parent_id AND the partition edge so the canvas tree stays in sync.",
1668
+ inputSchema: setParentInput,
1669
+ handler: async (client, args) => setParent(client, setParentInput.parse(args)),
1670
+ },
1671
+ // Ideas
1672
+ {
1673
+ name: 'create_idea',
1674
+ description: 'Create a new Idea (a named grouping of concepts) with an optional color and optional starting node membership.',
1675
+ inputSchema: createIdeaInput,
1676
+ handler: async (client, args) => createIdea(client, createIdeaInput.parse(args)),
1677
+ },
1678
+ {
1679
+ name: 'rename_idea',
1680
+ description: "Rename an Idea.",
1681
+ inputSchema: renameIdeaInput,
1682
+ handler: async (client, args) => renameIdea(client, renameIdeaInput.parse(args)),
1683
+ },
1684
+ {
1685
+ name: 'recolor_idea',
1686
+ description: "Change an Idea's color (any CSS color string).",
1687
+ inputSchema: recolorIdeaInput,
1688
+ handler: async (client, args) => recolorIdea(client, recolorIdeaInput.parse(args)),
1689
+ },
1690
+ {
1691
+ name: 'set_idea_members',
1692
+ description: "Replace an Idea's full membership with `nodeIds`. Reconciles the junction table: inserts missing pairs, deletes pairs no longer in the set. Returns added / removed counts.",
1693
+ inputSchema: setIdeaMembersInput,
1694
+ handler: async (client, args) => setIdeaMembers(client, setIdeaMembersInput.parse(args)),
1695
+ },
1696
+ {
1697
+ name: 'add_to_idea',
1698
+ description: 'Add a single concept to an existing Idea (idempotent).',
1699
+ inputSchema: addToIdeaInput,
1700
+ handler: async (client, args) => addToIdea(client, addToIdeaInput.parse(args)),
1701
+ },
1702
+ {
1703
+ name: 'delete_idea',
1704
+ description: 'Delete an Idea. Membership rows cascade via FK; the underlying concepts are untouched.',
1705
+ inputSchema: deleteIdeaInput,
1706
+ handler: async (client, args) => deleteIdea(client, deleteIdeaInput.parse(args)),
1707
+ },
1708
+ // Projects
1709
+ {
1710
+ name: 'create_project',
1711
+ description: 'Create a new Project (problem-with-direction) with a name and brief. Optional `direction`.',
1712
+ inputSchema: createProjectInput,
1713
+ handler: async (client, args) => createProject(client, createProjectInput.parse(args)),
1714
+ },
1715
+ {
1716
+ name: 'update_project',
1717
+ description: "Update a project's name / brief / direction / lifecycle. Only fields provided are changed.",
1718
+ inputSchema: updateProjectInput,
1719
+ handler: async (client, args) => updateProject(client, updateProjectInput.parse(args)),
1720
+ },
1721
+ {
1722
+ name: 'delete_project',
1723
+ description: "Delete a project. Project_nodes / problem_frames / closed_worlds / lab_matrix_cells cascade via FK. Concepts that belonged to ONLY this project are orphaned (project_id → NULL) — they are NOT auto-deleted.",
1724
+ inputSchema: deleteProjectInput,
1725
+ handler: async (client, args) => deleteProject(client, deleteProjectInput.parse(args)),
1726
+ },
1727
+ ];