@heuresis/mcp 1.0.0-rc.17 → 1.0.0-rc.18

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.
@@ -155,3 +155,15 @@ export function unwrap(res) {
155
155
  throw new Error('Empty result from cloud.');
156
156
  return res.data;
157
157
  }
158
+ /**
159
+ * Like unwrap(), but a `null` data is RETURNED rather than thrown. Use for
160
+ * `.maybeSingle()` lookups where "no row" is a meaningful outcome the caller
161
+ * handles itself (e.g. `if (!node) return { error: 'No concept with id …' }`).
162
+ * Wrapping those in unwrap() turned a legitimate not-found into the opaque
163
+ * "Empty result from cloud" — and made get_concept throw on dangling ancestry.
164
+ */
165
+ export function unwrapMaybe(res) {
166
+ if (res.error)
167
+ throw new Error(res.error.message);
168
+ return res.data;
169
+ }
@@ -41,7 +41,7 @@
41
41
  // the change. The stamp is best-effort: a failed provenance insert never
42
42
  // fails the underlying write.
43
43
  import { z } from 'zod';
44
- import { unwrap } from './cloudClient.js';
44
+ import { unwrap, unwrapMaybe } from './cloudClient.js';
45
45
  // ---------------------------------------------------------------------------
46
46
  // Shared helpers
47
47
  // ---------------------------------------------------------------------------
@@ -294,7 +294,7 @@ export async function getConcept(client, args) {
294
294
  let cur = node.parent_id;
295
295
  while (cur && !seen.has(cur)) {
296
296
  seen.add(cur);
297
- const p = unwrap(await client.from('nodes').select('id, label, parent_id').eq('id', cur).maybeSingle());
297
+ const p = unwrapMaybe(await client.from('nodes').select('id, label, parent_id').eq('id', cur).maybeSingle());
298
298
  if (!p)
299
299
  break;
300
300
  chain.unshift({ id: p.id, label: p.label });
@@ -431,7 +431,7 @@ export async function addConcept(client, args) {
431
431
  // Figure out parent + project.
432
432
  let parentNode = null;
433
433
  if (typeof args.parentId === 'string') {
434
- parentNode = unwrap(await client.from('nodes').select('*').eq('id', args.parentId).maybeSingle());
434
+ parentNode = unwrapMaybe(await client.from('nodes').select('*').eq('id', args.parentId).maybeSingle());
435
435
  if (!parentNode)
436
436
  return { error: `Parent ${args.parentId} not found.` };
437
437
  }
@@ -782,6 +782,12 @@ export const listConceptsInput = z
782
782
  includeArchived: z.boolean().default(false),
783
783
  detail: detailArg,
784
784
  limit: z.number().int().min(1).max(500).default(500),
785
+ offset: z
786
+ .number()
787
+ .int()
788
+ .min(0)
789
+ .default(0)
790
+ .describe('Skip this many results — page through a large workspace with limit + offset.'),
785
791
  })
786
792
  .strict();
787
793
  export async function listConcepts(client, args) {
@@ -792,7 +798,7 @@ export async function listConcepts(client, args) {
792
798
  if (!args.projectId) {
793
799
  return { error: 'scope=project requires projectId.' };
794
800
  }
795
- const proj = unwrap(await client
801
+ const proj = unwrapMaybe(await client
796
802
  .from('projects')
797
803
  .select('id, name')
798
804
  .eq('id', args.projectId)
@@ -817,20 +823,26 @@ export async function listConcepts(client, args) {
817
823
  }
818
824
  let qb = client
819
825
  .from('nodes')
820
- .select('*')
826
+ .select('*', { count: 'exact' })
821
827
  .eq('workspace_id', wsId)
822
828
  .order('updated_at', { ascending: false })
823
- .limit(args.limit);
829
+ .range(args.offset, args.offset + args.limit - 1);
824
830
  if (memberIds)
825
831
  qb = qb.in('id', memberIds);
826
832
  if (!args.includeArchived)
827
833
  qb = qb.neq('status', 'archived');
828
- const rows = unwrap(await qb);
834
+ const res = await qb;
835
+ if (res.error)
836
+ throw new Error(res.error.message);
837
+ const rows = (res.data ?? []);
838
+ const total = res.count ?? rows.length;
829
839
  return {
830
840
  scope: args.scope,
831
841
  projectName,
832
- total: rows.length,
833
- truncated: rows.length === args.limit,
842
+ total,
843
+ offset: args.offset,
844
+ returned: rows.length,
845
+ hasMore: args.offset + rows.length < total,
834
846
  concepts: rows.map((n) => nodeView(n, args.detail)),
835
847
  };
836
848
  }
@@ -926,13 +938,13 @@ export const validateConceptInput = z
926
938
  })
927
939
  .strict();
928
940
  export async function validateConcept(client, args) {
929
- const node = unwrap(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
941
+ const node = unwrapMaybe(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
930
942
  if (!node)
931
943
  return { error: `No concept with id ${args.id}` };
932
944
  const patch = { status: 'validated' };
933
945
  if (args.rationale !== undefined)
934
946
  patch.rationale = args.rationale;
935
- const row = unwrap(await client
947
+ const row = unwrapMaybe(await client
936
948
  .from('nodes')
937
949
  .update(patch)
938
950
  .eq('id', args.id)
@@ -958,7 +970,7 @@ export const setStandingInput = z
958
970
  })
959
971
  .strict();
960
972
  export async function setStanding(client, args) {
961
- const node = unwrap(await client
973
+ const node = unwrapMaybe(await client
962
974
  .from('nodes')
963
975
  .select('id, workspace_id, label, standing_rationale')
964
976
  .eq('id', args.id)
@@ -970,7 +982,7 @@ export async function setStanding(client, args) {
970
982
  standing_rationale: args.rationale,
971
983
  standing_assessed_at: new Date().toISOString(),
972
984
  };
973
- const row = unwrap(await client
985
+ const row = unwrapMaybe(await client
974
986
  .from('nodes')
975
987
  .update(patch)
976
988
  .eq('id', args.id)
@@ -995,7 +1007,7 @@ export async function setStanding(client, args) {
995
1007
  // ---------------------------------------------------------------------------
996
1008
  export const archiveConceptInput = z.object({ id: z.string().min(1) }).strict();
997
1009
  export async function archiveConcept(client, args) {
998
- const node = unwrap(await client
1010
+ const node = unwrapMaybe(await client
999
1011
  .from('nodes')
1000
1012
  .select('id, workspace_id, label, status')
1001
1013
  .eq('id', args.id)
@@ -1005,7 +1017,7 @@ export async function archiveConcept(client, args) {
1005
1017
  if (node.status === 'archived') {
1006
1018
  return { id: node.id, label: node.label, status: node.status, noop: true };
1007
1019
  }
1008
- const row = unwrap(await client
1020
+ const row = unwrapMaybe(await client
1009
1021
  .from('nodes')
1010
1022
  .update({ status: 'archived' })
1011
1023
  .eq('id', args.id)
@@ -1022,7 +1034,7 @@ export async function archiveConcept(client, args) {
1022
1034
  }
1023
1035
  export const unarchiveConceptInput = z.object({ id: z.string().min(1) }).strict();
1024
1036
  export async function unarchiveConcept(client, args) {
1025
- const node = unwrap(await client
1037
+ const node = unwrapMaybe(await client
1026
1038
  .from('nodes')
1027
1039
  .select('id, workspace_id, label, status')
1028
1040
  .eq('id', args.id)
@@ -1032,7 +1044,7 @@ export async function unarchiveConcept(client, args) {
1032
1044
  if (node.status !== 'archived') {
1033
1045
  return { id: node.id, label: node.label, status: node.status, noop: true };
1034
1046
  }
1035
- const row = unwrap(await client
1047
+ const row = unwrapMaybe(await client
1036
1048
  .from('nodes')
1037
1049
  .update({ status: 'open' })
1038
1050
  .eq('id', args.id)
@@ -1049,7 +1061,7 @@ export async function unarchiveConcept(client, args) {
1049
1061
  }
1050
1062
  export const starConceptInput = z.object({ id: z.string().min(1) }).strict();
1051
1063
  export async function starConcept(client, args) {
1052
- const node = unwrap(await client
1064
+ const node = unwrapMaybe(await client
1053
1065
  .from('nodes')
1054
1066
  .select('id, workspace_id, label, starred')
1055
1067
  .eq('id', args.id)
@@ -1057,7 +1069,7 @@ export async function starConcept(client, args) {
1057
1069
  if (!node)
1058
1070
  return { error: `No concept with id ${args.id}` };
1059
1071
  const next = !node.starred;
1060
- const row = unwrap(await client
1072
+ const row = unwrapMaybe(await client
1061
1073
  .from('nodes')
1062
1074
  .update({ starred: next })
1063
1075
  .eq('id', args.id)
@@ -1084,7 +1096,7 @@ export async function starConcept(client, args) {
1084
1096
  // in code, collect every descendant, and delete the bottom-up set.
1085
1097
  export const removeConceptInput = z.object({ id: z.string().min(1) }).strict();
1086
1098
  export async function removeConcept(client, args) {
1087
- const node = unwrap(await client
1099
+ const node = unwrapMaybe(await client
1088
1100
  .from('nodes')
1089
1101
  .select('id, workspace_id, label')
1090
1102
  .eq('id', args.id)
@@ -1100,26 +1112,40 @@ export async function removeConcept(client, args) {
1100
1112
  if (isRoot.length > 0) {
1101
1113
  return { error: 'Cannot delete a project root node.' };
1102
1114
  }
1103
- // Walk the parent_id subtree in memory (cheaper than depth round-trips).
1104
- const allNodes = unwrap(await client
1105
- .from('nodes')
1106
- .select('id, parent_id')
1107
- .eq('workspace_id', node.workspace_id));
1115
+ // Walk the subtree via BOTH parent_id AND partition edges. A child can be
1116
+ // attached either way; walking parent_id alone would strand partition-only
1117
+ // children as dangling orphans (the inconsistency surfaced in the rc.17
1118
+ // review — get_subtree counts partition-edge children, so the cascade must
1119
+ // too).
1120
+ const allNodes = unwrap(await client.from('nodes').select('id, parent_id').eq('workspace_id', node.workspace_id));
1121
+ const partEdges = unwrap(await client
1122
+ .from('edges')
1123
+ .select('from_id, to_id')
1124
+ .eq('workspace_id', node.workspace_id)
1125
+ .eq('kind', 'partition'));
1108
1126
  const childrenByParent = new Map();
1109
- for (const n of allNodes) {
1110
- if (n.parent_id) {
1111
- const arr = childrenByParent.get(n.parent_id) ?? [];
1112
- arr.push(n.id);
1113
- childrenByParent.set(n.parent_id, arr);
1114
- }
1115
- }
1127
+ const addChild = (parent, child) => {
1128
+ const arr = childrenByParent.get(parent) ?? [];
1129
+ arr.push(child);
1130
+ childrenByParent.set(parent, arr);
1131
+ };
1132
+ for (const n of allNodes)
1133
+ if (n.parent_id)
1134
+ addChild(n.parent_id, n.id);
1135
+ for (const e of partEdges)
1136
+ addChild(e.from_id, e.to_id);
1116
1137
  const toDelete = [];
1138
+ const seen = new Set();
1117
1139
  const stack = [args.id];
1118
1140
  while (stack.length > 0) {
1119
1141
  const cur = stack.pop();
1142
+ if (seen.has(cur))
1143
+ continue;
1144
+ seen.add(cur);
1120
1145
  toDelete.push(cur);
1121
- const kids = childrenByParent.get(cur) ?? [];
1122
- stack.push(...kids);
1146
+ for (const k of childrenByParent.get(cur) ?? [])
1147
+ if (!seen.has(k))
1148
+ stack.push(k);
1123
1149
  }
1124
1150
  // Delete in one shot — edges/junctions cascade via FK; provenance
1125
1151
  // cascades via FK in 0015's table definition.
@@ -1133,6 +1159,40 @@ export async function removeConcept(client, args) {
1133
1159
  };
1134
1160
  }
1135
1161
  // ---------------------------------------------------------------------------
1162
+ // remove_concepts (bulk) — delete many concepts in one call.
1163
+ // ---------------------------------------------------------------------------
1164
+ // Sequential (not parallel) so a big cleanup can't hammer the backend; each id
1165
+ // reuses removeConcept (so each cascades its own subtree). Per-id results are
1166
+ // returned so the caller sees exactly what landed — ids already gone (e.g.
1167
+ // swept up in another id's cascade) come back ok:false with a not-found error,
1168
+ // which is expected, not fatal.
1169
+ export const removeConceptsInput = z
1170
+ .object({
1171
+ ids: z
1172
+ .array(z.string().min(1))
1173
+ .min(1)
1174
+ .max(500)
1175
+ .describe('Concept ids to hard-delete. Each cascades its own subtree.'),
1176
+ })
1177
+ .strict();
1178
+ export async function removeConcepts(client, args) {
1179
+ const results = [];
1180
+ for (const id of args.ids) {
1181
+ const r = (await removeConcept(client, { id }));
1182
+ if (r.error)
1183
+ results.push({ id, ok: false, error: r.error });
1184
+ else
1185
+ results.push({ id, ok: true, cascadeCount: r.cascadeCount });
1186
+ }
1187
+ const ok = results.filter((r) => r.ok).length;
1188
+ return {
1189
+ requested: args.ids.length,
1190
+ deleted: ok,
1191
+ failed: args.ids.length - ok,
1192
+ results,
1193
+ };
1194
+ }
1195
+ // ---------------------------------------------------------------------------
1136
1196
  // bulk_add_concepts (atomic via fn_bulk_add_concepts RPC)
1137
1197
  // ---------------------------------------------------------------------------
1138
1198
  // Each child resolves its project_id either explicitly (via the item's
@@ -1224,7 +1284,7 @@ export const setParentInput = z
1224
1284
  })
1225
1285
  .strict();
1226
1286
  export async function setParent(client, args) {
1227
- const node = unwrap(await client
1287
+ const node = unwrapMaybe(await client
1228
1288
  .from('nodes')
1229
1289
  .select('id, workspace_id, parent_id, label')
1230
1290
  .eq('id', args.nodeId)
@@ -1236,7 +1296,7 @@ export async function setParent(client, args) {
1236
1296
  }
1237
1297
  let resolvedParentId = args.newParentId;
1238
1298
  if (resolvedParentId) {
1239
- const parent = unwrap(await client
1299
+ const parent = unwrapMaybe(await client
1240
1300
  .from('nodes')
1241
1301
  .select('id, workspace_id')
1242
1302
  .eq('id', resolvedParentId)
@@ -1246,6 +1306,22 @@ export async function setParent(client, args) {
1246
1306
  if (parent.workspace_id !== node.workspace_id) {
1247
1307
  return { error: 'Cannot re-parent across workspaces.' };
1248
1308
  }
1309
+ // Cycle guard: the new parent must not sit inside this node's own subtree —
1310
+ // that would detach the subtree from any root and break parent-chain
1311
+ // traversal (get_subtree / get_concept ancestry / remove_concept cascade).
1312
+ const wsNodes = unwrap(await client.from('nodes').select('id, parent_id').eq('workspace_id', node.workspace_id));
1313
+ const parentOf = new Map(wsNodes.map((n) => [n.id, n.parent_id]));
1314
+ let walk = resolvedParentId;
1315
+ const visited = new Set();
1316
+ while (walk && !visited.has(walk)) {
1317
+ if (walk === args.nodeId) {
1318
+ return {
1319
+ error: "Cannot set parent: the target parent is inside this node's own subtree (would create a cycle).",
1320
+ };
1321
+ }
1322
+ visited.add(walk);
1323
+ walk = parentOf.get(walk) ?? null;
1324
+ }
1249
1325
  }
1250
1326
  // 1) Update parent_id.
1251
1327
  const updRes = await client
@@ -1545,13 +1621,101 @@ export async function updateProject(client, args) {
1545
1621
  lifecycle: row.lifecycle,
1546
1622
  };
1547
1623
  }
1548
- export const deleteProjectInput = z.object({ id: z.string().min(1) }).strict();
1624
+ export const deleteProjectInput = z
1625
+ .object({
1626
+ id: z.string().min(1),
1627
+ deleteNodes: z
1628
+ .boolean()
1629
+ .default(false)
1630
+ .describe("Also hard-delete every concept in this project (each cascades its subtree). Default false leaves the nodes behind as orphans — matching prior behavior. Use true to avoid creating orphans."),
1631
+ })
1632
+ .strict();
1549
1633
  export async function deleteProject(client, args) {
1634
+ if (args.deleteNodes) {
1635
+ // Capture members BEFORE deleting the project (project_nodes cascades away
1636
+ // with it). Delete the project first so removeConcept's "can't delete a
1637
+ // project root" guard no longer blocks the root node.
1638
+ const members = unwrap(await client.from('project_nodes').select('node_id').eq('project_id', args.id));
1639
+ const del = await client.from('projects').delete().eq('id', args.id);
1640
+ if (del.error)
1641
+ return { error: del.error.message };
1642
+ let nodesDeleted = 0;
1643
+ for (const m of members) {
1644
+ const r = (await removeConcept(client, { id: m.node_id }));
1645
+ // Many members are already gone (swept up in the root's cascade); that
1646
+ // returns a not-found error we simply skip.
1647
+ if (!r.error && typeof r.cascadeCount === 'number')
1648
+ nodesDeleted += r.cascadeCount;
1649
+ }
1650
+ return { id: args.id, deleted: true, nodesDeleted };
1651
+ }
1550
1652
  const del = await client.from('projects').delete().eq('id', args.id);
1551
1653
  if (del.error)
1552
1654
  return { error: del.error.message };
1553
1655
  return { id: args.id, deleted: true };
1554
1656
  }
1657
+ // ---------------------------------------------------------------------------
1658
+ // find_orphans — surface concepts that no normal traversal reaches.
1659
+ // ---------------------------------------------------------------------------
1660
+ // Two integrity classes, both confined to the caller's own workspace (RLS):
1661
+ // • projectless — node in no project (the classic residue of delete_project,
1662
+ // which leaves nodes behind).
1663
+ // • danglingParent — node whose parent_id points at a row that no longer
1664
+ // exists (invisible to root traversal, yet still present).
1665
+ // Read-only; pair with remove_concepts (to delete) or set_parent (to re-home).
1666
+ export const findOrphansInput = z
1667
+ .object({
1668
+ kind: z
1669
+ .enum(['projectless', 'dangling', 'all'])
1670
+ .default('all')
1671
+ .describe("Which orphan class to surface: 'projectless', 'dangling', or 'all'."),
1672
+ limit: z
1673
+ .number()
1674
+ .int()
1675
+ .min(1)
1676
+ .max(1000)
1677
+ .default(200)
1678
+ .describe('Max nodes to list per class (counts are always exact).'),
1679
+ })
1680
+ .strict();
1681
+ export async function findOrphans(client, args) {
1682
+ const wsId = await resolveWorkspaceId(client);
1683
+ const allNodes = unwrap(await client.from('nodes').select('id, label, parent_id, status').eq('workspace_id', wsId));
1684
+ const liveIds = new Set(allNodes.map((n) => n.id));
1685
+ const projects = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
1686
+ let memberIds = new Set();
1687
+ if (projects.length > 0) {
1688
+ const pn = unwrap(await client
1689
+ .from('project_nodes')
1690
+ .select('node_id')
1691
+ .in('project_id', projects.map((p) => p.id)));
1692
+ memberIds = new Set(pn.map((r) => r.node_id));
1693
+ }
1694
+ const view = (n) => ({
1695
+ id: n.id,
1696
+ label: n.label,
1697
+ parentId: n.parent_id,
1698
+ status: n.status,
1699
+ });
1700
+ const result = { workspaceId: wsId, totalNodes: allNodes.length };
1701
+ if (args.kind === 'projectless' || args.kind === 'all') {
1702
+ const projectless = allNodes.filter((n) => !memberIds.has(n.id));
1703
+ result.projectless = {
1704
+ count: projectless.length,
1705
+ nodes: projectless.slice(0, args.limit).map(view),
1706
+ };
1707
+ }
1708
+ if (args.kind === 'dangling' || args.kind === 'all') {
1709
+ const dangling = allNodes.filter((n) => n.parent_id && !liveIds.has(n.parent_id));
1710
+ result.danglingParent = {
1711
+ count: dangling.length,
1712
+ nodes: dangling.slice(0, args.limit).map(view),
1713
+ };
1714
+ }
1715
+ result.note =
1716
+ 'projectless = in no project (often left by delete_project). danglingParent = parent_id points at a deleted node. To clean up: remove_concepts(ids) to delete, or set_parent to re-home.';
1717
+ return result;
1718
+ }
1555
1719
  export const CLOUD_TOOLS = [
1556
1720
  // ── Reads ──────────────────────────────────────────────────────────────
1557
1721
  {
@@ -1673,10 +1837,22 @@ export const CLOUD_TOOLS = [
1673
1837
  },
1674
1838
  {
1675
1839
  name: 'remove_concept',
1676
- 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.',
1840
+ description: 'Hard-delete a concept and its descendants (walks the subtree via parent_id AND partition edges). Edges and project/idea memberships cascade via FK. Refuses to delete a project root node. For many at once, use remove_concepts.',
1677
1841
  inputSchema: removeConceptInput,
1678
1842
  handler: async (client, args) => removeConcept(client, removeConceptInput.parse(args)),
1679
1843
  },
1844
+ {
1845
+ name: 'remove_concepts',
1846
+ description: "Bulk hard-delete: delete many concepts in one call (each cascades its own subtree). Returns per-id results. Ids already swept up in another id's cascade come back ok:false (not-found) — expected, not fatal. Pair with find_orphans for cleanup.",
1847
+ inputSchema: removeConceptsInput,
1848
+ handler: async (client, args) => removeConcepts(client, removeConceptsInput.parse(args)),
1849
+ },
1850
+ {
1851
+ name: 'find_orphans',
1852
+ description: "Surface concepts no normal traversal reaches, scoped to your workspace: 'projectless' (in no project — typically left behind by delete_project) and 'danglingParent' (parent_id points at a deleted node). Read-only. Clean up with remove_concepts (delete) or set_parent (re-home).",
1853
+ inputSchema: findOrphansInput,
1854
+ handler: async (client, args) => findOrphans(client, findOrphansInput.parse(args)),
1855
+ },
1680
1856
  {
1681
1857
  name: 'bulk_add_concepts',
1682
1858
  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.',
@@ -1741,7 +1917,7 @@ export const CLOUD_TOOLS = [
1741
1917
  },
1742
1918
  {
1743
1919
  name: 'delete_project',
1744
- 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.",
1920
+ description: 'Delete a project. By default its concepts are LEFT behind as orphans (find them later with find_orphans). Pass deleteNodes:true to also hard-delete every concept in the project (each cascades its subtree) so no orphans are created.',
1745
1921
  inputSchema: deleteProjectInput,
1746
1922
  handler: async (client, args) => deleteProject(client, deleteProjectInput.parse(args)),
1747
1923
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heuresis/mcp",
3
- "version": "1.0.0-rc.17",
3
+ "version": "1.0.0-rc.18",
4
4
  "mcpName": "io.github.ToremLabs/heuresis",
5
5
  "description": "Cloud-authenticated Model Context Protocol server for a Heuresis workspace. Logs into the user's Heuresis account and lets any MCP client (Claude Desktop, Claude Code, Cursor, custom agents) read and write the same workspace the webapp uses. 31 data tools, 3 operator tools (Branch/Matrix/C-K/ASIT/TRIZ/Free/Combine/Explore), and live Realtime change subscriptions.",
6
6
  "type": "module",