@heuresis/mcp 1.0.0-rc.16 → 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
  // ---------------------------------------------------------------------------
@@ -188,11 +188,25 @@ function shapeProject(p) {
188
188
  // ---------------------------------------------------------------------------
189
189
  export const getSubtreeInput = z
190
190
  .object({
191
- rootId: z.string(),
191
+ rootId: z.string().min(1).optional(),
192
+ id: z
193
+ .string()
194
+ .min(1)
195
+ .optional()
196
+ .describe('Alias for rootId — the concept to root the subtree at (use either).'),
192
197
  depth: z.number().int().min(0).max(6).default(3),
193
198
  detail: detailArg,
194
199
  })
195
- .strict();
200
+ .strict()
201
+ .refine((a) => Boolean(a.rootId ?? a.id), {
202
+ message: 'Provide `id` (or `rootId`) — the concept to get the subtree for.',
203
+ path: ['id'],
204
+ })
205
+ .transform((a) => ({
206
+ rootId: (a.rootId ?? a.id),
207
+ depth: a.depth,
208
+ detail: a.detail,
209
+ }));
196
210
  export async function getSubtree(client, args) {
197
211
  const rootRes = await client.from('nodes').select('*').eq('id', args.rootId).maybeSingle();
198
212
  if (rootRes.error)
@@ -280,7 +294,7 @@ export async function getConcept(client, args) {
280
294
  let cur = node.parent_id;
281
295
  while (cur && !seen.has(cur)) {
282
296
  seen.add(cur);
283
- 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());
284
298
  if (!p)
285
299
  break;
286
300
  chain.unshift({ id: p.id, label: p.label });
@@ -417,7 +431,7 @@ export async function addConcept(client, args) {
417
431
  // Figure out parent + project.
418
432
  let parentNode = null;
419
433
  if (typeof args.parentId === 'string') {
420
- 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());
421
435
  if (!parentNode)
422
436
  return { error: `Parent ${args.parentId} not found.` };
423
437
  }
@@ -768,6 +782,12 @@ export const listConceptsInput = z
768
782
  includeArchived: z.boolean().default(false),
769
783
  detail: detailArg,
770
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.'),
771
791
  })
772
792
  .strict();
773
793
  export async function listConcepts(client, args) {
@@ -778,7 +798,7 @@ export async function listConcepts(client, args) {
778
798
  if (!args.projectId) {
779
799
  return { error: 'scope=project requires projectId.' };
780
800
  }
781
- const proj = unwrap(await client
801
+ const proj = unwrapMaybe(await client
782
802
  .from('projects')
783
803
  .select('id, name')
784
804
  .eq('id', args.projectId)
@@ -803,20 +823,26 @@ export async function listConcepts(client, args) {
803
823
  }
804
824
  let qb = client
805
825
  .from('nodes')
806
- .select('*')
826
+ .select('*', { count: 'exact' })
807
827
  .eq('workspace_id', wsId)
808
828
  .order('updated_at', { ascending: false })
809
- .limit(args.limit);
829
+ .range(args.offset, args.offset + args.limit - 1);
810
830
  if (memberIds)
811
831
  qb = qb.in('id', memberIds);
812
832
  if (!args.includeArchived)
813
833
  qb = qb.neq('status', 'archived');
814
- 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;
815
839
  return {
816
840
  scope: args.scope,
817
841
  projectName,
818
- total: rows.length,
819
- truncated: rows.length === args.limit,
842
+ total,
843
+ offset: args.offset,
844
+ returned: rows.length,
845
+ hasMore: args.offset + rows.length < total,
820
846
  concepts: rows.map((n) => nodeView(n, args.detail)),
821
847
  };
822
848
  }
@@ -912,13 +938,13 @@ export const validateConceptInput = z
912
938
  })
913
939
  .strict();
914
940
  export async function validateConcept(client, args) {
915
- 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());
916
942
  if (!node)
917
943
  return { error: `No concept with id ${args.id}` };
918
944
  const patch = { status: 'validated' };
919
945
  if (args.rationale !== undefined)
920
946
  patch.rationale = args.rationale;
921
- const row = unwrap(await client
947
+ const row = unwrapMaybe(await client
922
948
  .from('nodes')
923
949
  .update(patch)
924
950
  .eq('id', args.id)
@@ -944,7 +970,7 @@ export const setStandingInput = z
944
970
  })
945
971
  .strict();
946
972
  export async function setStanding(client, args) {
947
- const node = unwrap(await client
973
+ const node = unwrapMaybe(await client
948
974
  .from('nodes')
949
975
  .select('id, workspace_id, label, standing_rationale')
950
976
  .eq('id', args.id)
@@ -956,7 +982,7 @@ export async function setStanding(client, args) {
956
982
  standing_rationale: args.rationale,
957
983
  standing_assessed_at: new Date().toISOString(),
958
984
  };
959
- const row = unwrap(await client
985
+ const row = unwrapMaybe(await client
960
986
  .from('nodes')
961
987
  .update(patch)
962
988
  .eq('id', args.id)
@@ -981,7 +1007,7 @@ export async function setStanding(client, args) {
981
1007
  // ---------------------------------------------------------------------------
982
1008
  export const archiveConceptInput = z.object({ id: z.string().min(1) }).strict();
983
1009
  export async function archiveConcept(client, args) {
984
- const node = unwrap(await client
1010
+ const node = unwrapMaybe(await client
985
1011
  .from('nodes')
986
1012
  .select('id, workspace_id, label, status')
987
1013
  .eq('id', args.id)
@@ -991,7 +1017,7 @@ export async function archiveConcept(client, args) {
991
1017
  if (node.status === 'archived') {
992
1018
  return { id: node.id, label: node.label, status: node.status, noop: true };
993
1019
  }
994
- const row = unwrap(await client
1020
+ const row = unwrapMaybe(await client
995
1021
  .from('nodes')
996
1022
  .update({ status: 'archived' })
997
1023
  .eq('id', args.id)
@@ -1008,7 +1034,7 @@ export async function archiveConcept(client, args) {
1008
1034
  }
1009
1035
  export const unarchiveConceptInput = z.object({ id: z.string().min(1) }).strict();
1010
1036
  export async function unarchiveConcept(client, args) {
1011
- const node = unwrap(await client
1037
+ const node = unwrapMaybe(await client
1012
1038
  .from('nodes')
1013
1039
  .select('id, workspace_id, label, status')
1014
1040
  .eq('id', args.id)
@@ -1018,7 +1044,7 @@ export async function unarchiveConcept(client, args) {
1018
1044
  if (node.status !== 'archived') {
1019
1045
  return { id: node.id, label: node.label, status: node.status, noop: true };
1020
1046
  }
1021
- const row = unwrap(await client
1047
+ const row = unwrapMaybe(await client
1022
1048
  .from('nodes')
1023
1049
  .update({ status: 'open' })
1024
1050
  .eq('id', args.id)
@@ -1035,7 +1061,7 @@ export async function unarchiveConcept(client, args) {
1035
1061
  }
1036
1062
  export const starConceptInput = z.object({ id: z.string().min(1) }).strict();
1037
1063
  export async function starConcept(client, args) {
1038
- const node = unwrap(await client
1064
+ const node = unwrapMaybe(await client
1039
1065
  .from('nodes')
1040
1066
  .select('id, workspace_id, label, starred')
1041
1067
  .eq('id', args.id)
@@ -1043,7 +1069,7 @@ export async function starConcept(client, args) {
1043
1069
  if (!node)
1044
1070
  return { error: `No concept with id ${args.id}` };
1045
1071
  const next = !node.starred;
1046
- const row = unwrap(await client
1072
+ const row = unwrapMaybe(await client
1047
1073
  .from('nodes')
1048
1074
  .update({ starred: next })
1049
1075
  .eq('id', args.id)
@@ -1070,7 +1096,7 @@ export async function starConcept(client, args) {
1070
1096
  // in code, collect every descendant, and delete the bottom-up set.
1071
1097
  export const removeConceptInput = z.object({ id: z.string().min(1) }).strict();
1072
1098
  export async function removeConcept(client, args) {
1073
- const node = unwrap(await client
1099
+ const node = unwrapMaybe(await client
1074
1100
  .from('nodes')
1075
1101
  .select('id, workspace_id, label')
1076
1102
  .eq('id', args.id)
@@ -1086,26 +1112,40 @@ export async function removeConcept(client, args) {
1086
1112
  if (isRoot.length > 0) {
1087
1113
  return { error: 'Cannot delete a project root node.' };
1088
1114
  }
1089
- // Walk the parent_id subtree in memory (cheaper than depth round-trips).
1090
- const allNodes = unwrap(await client
1091
- .from('nodes')
1092
- .select('id, parent_id')
1093
- .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'));
1094
1126
  const childrenByParent = new Map();
1095
- for (const n of allNodes) {
1096
- if (n.parent_id) {
1097
- const arr = childrenByParent.get(n.parent_id) ?? [];
1098
- arr.push(n.id);
1099
- childrenByParent.set(n.parent_id, arr);
1100
- }
1101
- }
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);
1102
1137
  const toDelete = [];
1138
+ const seen = new Set();
1103
1139
  const stack = [args.id];
1104
1140
  while (stack.length > 0) {
1105
1141
  const cur = stack.pop();
1142
+ if (seen.has(cur))
1143
+ continue;
1144
+ seen.add(cur);
1106
1145
  toDelete.push(cur);
1107
- const kids = childrenByParent.get(cur) ?? [];
1108
- stack.push(...kids);
1146
+ for (const k of childrenByParent.get(cur) ?? [])
1147
+ if (!seen.has(k))
1148
+ stack.push(k);
1109
1149
  }
1110
1150
  // Delete in one shot — edges/junctions cascade via FK; provenance
1111
1151
  // cascades via FK in 0015's table definition.
@@ -1119,6 +1159,40 @@ export async function removeConcept(client, args) {
1119
1159
  };
1120
1160
  }
1121
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
+ // ---------------------------------------------------------------------------
1122
1196
  // bulk_add_concepts (atomic via fn_bulk_add_concepts RPC)
1123
1197
  // ---------------------------------------------------------------------------
1124
1198
  // Each child resolves its project_id either explicitly (via the item's
@@ -1210,7 +1284,7 @@ export const setParentInput = z
1210
1284
  })
1211
1285
  .strict();
1212
1286
  export async function setParent(client, args) {
1213
- const node = unwrap(await client
1287
+ const node = unwrapMaybe(await client
1214
1288
  .from('nodes')
1215
1289
  .select('id, workspace_id, parent_id, label')
1216
1290
  .eq('id', args.nodeId)
@@ -1222,7 +1296,7 @@ export async function setParent(client, args) {
1222
1296
  }
1223
1297
  let resolvedParentId = args.newParentId;
1224
1298
  if (resolvedParentId) {
1225
- const parent = unwrap(await client
1299
+ const parent = unwrapMaybe(await client
1226
1300
  .from('nodes')
1227
1301
  .select('id, workspace_id')
1228
1302
  .eq('id', resolvedParentId)
@@ -1232,6 +1306,22 @@ export async function setParent(client, args) {
1232
1306
  if (parent.workspace_id !== node.workspace_id) {
1233
1307
  return { error: 'Cannot re-parent across workspaces.' };
1234
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
+ }
1235
1325
  }
1236
1326
  // 1) Update parent_id.
1237
1327
  const updRes = await client
@@ -1531,13 +1621,101 @@ export async function updateProject(client, args) {
1531
1621
  lifecycle: row.lifecycle,
1532
1622
  };
1533
1623
  }
1534
- 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();
1535
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
+ }
1536
1652
  const del = await client.from('projects').delete().eq('id', args.id);
1537
1653
  if (del.error)
1538
1654
  return { error: del.error.message };
1539
1655
  return { id: args.id, deleted: true };
1540
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
+ }
1541
1719
  export const CLOUD_TOOLS = [
1542
1720
  // ── Reads ──────────────────────────────────────────────────────────────
1543
1721
  {
@@ -1554,7 +1732,9 @@ export const CLOUD_TOOLS = [
1554
1732
  },
1555
1733
  {
1556
1734
  name: 'get_subtree',
1557
- description: 'A node and its descendants up to a given depth. Use when the user asks "tell me about everything under X".',
1735
+ description: 'A node and its descendants up to a given depth. Pass the concept as `id` (or `rootId`). Use when the user asks "tell me about everything under X".',
1736
+ // ZodEffects (refine+transform for the id/rootId alias) — cast like the
1737
+ // operator tools; zodToJsonSchema unwraps it for the served schema.
1558
1738
  inputSchema: getSubtreeInput,
1559
1739
  handler: async (client, args) => getSubtree(client, getSubtreeInput.parse(args)),
1560
1740
  },
@@ -1657,10 +1837,22 @@ export const CLOUD_TOOLS = [
1657
1837
  },
1658
1838
  {
1659
1839
  name: 'remove_concept',
1660
- 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.',
1661
1841
  inputSchema: removeConceptInput,
1662
1842
  handler: async (client, args) => removeConcept(client, removeConceptInput.parse(args)),
1663
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
+ },
1664
1856
  {
1665
1857
  name: 'bulk_add_concepts',
1666
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.',
@@ -1725,7 +1917,7 @@ export const CLOUD_TOOLS = [
1725
1917
  },
1726
1918
  {
1727
1919
  name: 'delete_project',
1728
- 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.',
1729
1921
  inputSchema: deleteProjectInput,
1730
1922
  handler: async (client, args) => deleteProject(client, deleteProjectInput.parse(args)),
1731
1923
  },
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // Subcommands (one-shot, never start the MCP server):
16
16
  // login | logout | whoami | --help
17
- import { existsSync } from 'node:fs';
17
+ import { existsSync, readFileSync } from 'node:fs';
18
18
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
20
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -27,8 +27,87 @@ import { CloudAuthError, getCloudClient, getCloudClientFromPassword } from './cl
27
27
  import { ensureProxyAgent } from './proxy.js';
28
28
  import { DEFAULT_SUPABASE_URL, helpCommand, loginCommand, logoutCommand, whoamiCommand, } from './cli.js';
29
29
  import { readRealtimeFlag, resolveSubscriptionWorkspaceId, startRealtimeSubscription, stripRealtimeFlags, } from './realtime.js';
30
- const VERSION = '0.2.0-alpha';
30
+ // Report the real published version (dist/index.js → ../package.json) so
31
+ // `heuresis-mcp --version` and the startup banner never lie about what's loaded.
32
+ const VERSION = (() => {
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
35
+ return pkg.version ?? '0.0.0';
36
+ }
37
+ catch {
38
+ return '0.0.0';
39
+ }
40
+ })();
31
41
  const MAX_RESULT_CHARS = 50_000;
42
+ // Fields worth preserving when a node/edge-bearing result is degraded to fit
43
+ // the size cap (everything else — descriptions, rationale, tags — is dropped).
44
+ const SKELETON_KEYS = [
45
+ 'id',
46
+ 'label',
47
+ 'name',
48
+ 'parentId',
49
+ 'parent_id',
50
+ 'kind',
51
+ 'from',
52
+ 'to',
53
+ 'status',
54
+ 'standing',
55
+ 'starred',
56
+ 'projectId',
57
+ 'rootNodeId',
58
+ ];
59
+ function skeletonize(item) {
60
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
61
+ const o = item;
62
+ const keep = {};
63
+ for (const k of SKELETON_KEYS)
64
+ if (k in o)
65
+ keep[k] = o[k];
66
+ return Object.keys(keep).length > 0 ? keep : item;
67
+ }
68
+ return item;
69
+ }
70
+ // Never hard-fail a read on size. If a result blows the cap, first drop verbose
71
+ // fields from its array elements (keep id/label/parent/kind…), then, if still
72
+ // too big, slice the largest array — always tagging `_truncated` + `_note` so
73
+ // the agent knows to narrow (limit/depth/scope) or fetch detail via get_concept.
74
+ function fitResultToCap(result, cap) {
75
+ if (JSON.stringify(result, null, 2).length <= cap)
76
+ return result;
77
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
78
+ const s = JSON.stringify(result, null, 2);
79
+ return {
80
+ _truncated: true,
81
+ _note: `Result exceeded ${cap} chars and could not be structurally reduced; showing a prefix. Narrow the query.`,
82
+ preview: s.slice(0, Math.max(0, cap - 200)),
83
+ };
84
+ }
85
+ const obj = { ...result };
86
+ const arrayKeys = Object.keys(obj).filter((k) => Array.isArray(obj[k]) && obj[k].some((x) => x && typeof x === 'object'));
87
+ for (const k of arrayKeys)
88
+ obj[k] = obj[k].map(skeletonize);
89
+ obj._truncated = true;
90
+ obj._note =
91
+ 'Result exceeded the size cap; verbose fields were dropped (id/label/parent/kind kept). Use get_concept(id) for full detail, or narrow with limit/depth/scope.';
92
+ let guard = 0;
93
+ while (JSON.stringify(obj, null, 2).length > cap && guard++ < 100) {
94
+ let biggestKey;
95
+ let biggestLen = 0;
96
+ for (const k of arrayKeys) {
97
+ const len = obj[k].length;
98
+ if (len > biggestLen) {
99
+ biggestLen = len;
100
+ biggestKey = k;
101
+ }
102
+ }
103
+ if (!biggestKey || biggestLen <= 1)
104
+ break;
105
+ const arr = obj[biggestKey];
106
+ obj[biggestKey] = arr.slice(0, Math.max(1, Math.floor(arr.length * 0.8)));
107
+ obj._note = `Result exceeded the size cap; showing a truncated, skeletonized view (some "${biggestKey}" omitted). Narrow with limit/depth/scope, or fetch specifics with get_concept.`;
108
+ }
109
+ return obj;
110
+ }
32
111
  function makeCloudTools(getClient, operatorTools) {
33
112
  // Lazy: defer the actual auth handshake until the first tool call so the
34
113
  // MCP server boots fast.
@@ -252,20 +331,10 @@ async function runServer() {
252
331
  // background with their own concurrency control (cloudOperators.ts), so no
253
332
  // per-call serialization is needed here.
254
333
  const result = await tool.handler(req.params.arguments ?? {});
255
- const text = JSON.stringify(result, null, 2);
256
- if (text.length > MAX_RESULT_CHARS) {
257
- return {
258
- isError: true,
259
- content: [
260
- {
261
- type: 'text',
262
- text: `Result too large (${text.length} chars, limit ${MAX_RESULT_CHARS}). ` +
263
- `Narrow the query: lower 'limit'/'depth', keep detail='compact', ` +
264
- `or fetch individual nodes with get_concept.`,
265
- },
266
- ],
267
- };
268
- }
334
+ // Never hard-fail on size: auto-degrade node/edge-bearing results
335
+ // (skeletonize slice) so the agent always gets actionable structure
336
+ // back instead of an error it has to recover from.
337
+ const text = JSON.stringify(fitResultToCap(result, MAX_RESULT_CHARS), null, 2);
269
338
  return {
270
339
  content: [{ type: 'text', text }],
271
340
  };
@@ -358,6 +427,11 @@ async function main() {
358
427
  case 'whoami':
359
428
  await whoamiCommand();
360
429
  return;
430
+ case '-v':
431
+ case '--version':
432
+ case 'version':
433
+ console.log(VERSION);
434
+ return;
361
435
  case '-h':
362
436
  case '--help':
363
437
  case 'help':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heuresis/mcp",
3
- "version": "1.0.0-rc.16",
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",