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

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.
@@ -27,6 +27,14 @@ import { makeRetryingFetch } from './httpRetry.js';
27
27
  // one-shot-fail a data call (see httpRetry.ts).
28
28
  const retryingFetch = makeRetryingFetch();
29
29
  let cached = null;
30
+ // The device name from credentials.json, captured at bootstrap so provenance
31
+ // stamps can record WHICH device an MCP write came from (surfaced in the webapp
32
+ // timeline once it syncs cloud provenance). null in headless/password mode,
33
+ // which has no device pairing.
34
+ let activeDeviceName = null;
35
+ export function getActiveDeviceName() {
36
+ return activeDeviceName;
37
+ }
30
38
  export class CloudAuthError extends Error {
31
39
  constructor(msg) {
32
40
  super(msg);
@@ -96,6 +104,7 @@ async function persistRotatedToken(creds, newToken) {
96
104
  * been revoked/rotated away.
97
105
  */
98
106
  export async function getCloudClient(creds) {
107
+ activeDeviceName = creds.device_name ?? activeDeviceName;
99
108
  if (cached)
100
109
  return cached;
101
110
  try {
@@ -155,3 +164,15 @@ export function unwrap(res) {
155
164
  throw new Error('Empty result from cloud.');
156
165
  return res.data;
157
166
  }
167
+ /**
168
+ * Like unwrap(), but a `null` data is RETURNED rather than thrown. Use for
169
+ * `.maybeSingle()` lookups where "no row" is a meaningful outcome the caller
170
+ * handles itself (e.g. `if (!node) return { error: 'No concept with id …' }`).
171
+ * Wrapping those in unwrap() turned a legitimate not-found into the opaque
172
+ * "Empty result from cloud" — and made get_concept throw on dangling ancestry.
173
+ */
174
+ export function unwrapMaybe(res) {
175
+ if (res.error)
176
+ throw new Error(res.error.message);
177
+ return res.data;
178
+ }
@@ -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, getActiveDeviceName } 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
  }
@@ -618,12 +618,16 @@ export async function linkConcepts(client, args) {
618
618
  }
619
619
  async function stampProvenance(client, args) {
620
620
  try {
621
+ // Tag the originating device so the webapp timeline can show WHICH MCP
622
+ // device made the change. Rides in source_refs as `device:<name>` — no
623
+ // schema migration, and source_refs already syncs/exports.
624
+ const device = getActiveDeviceName();
621
625
  const row = {
622
626
  workspace_id: args.workspaceId,
623
627
  node_id: args.nodeId,
624
628
  origin: args.origin ?? 'mcp',
625
629
  operator_key: args.operatorKey ?? null,
626
- source_refs: [args.sourceRef],
630
+ source_refs: device ? [args.sourceRef, `device:${device}`] : [args.sourceRef],
627
631
  llm_json: args.llmJson ?? null,
628
632
  created_by: 'agent',
629
633
  analysis_id: args.analysisId ?? null,
@@ -782,6 +786,12 @@ export const listConceptsInput = z
782
786
  includeArchived: z.boolean().default(false),
783
787
  detail: detailArg,
784
788
  limit: z.number().int().min(1).max(500).default(500),
789
+ offset: z
790
+ .number()
791
+ .int()
792
+ .min(0)
793
+ .default(0)
794
+ .describe('Skip this many results — page through a large workspace with limit + offset.'),
785
795
  })
786
796
  .strict();
787
797
  export async function listConcepts(client, args) {
@@ -792,7 +802,7 @@ export async function listConcepts(client, args) {
792
802
  if (!args.projectId) {
793
803
  return { error: 'scope=project requires projectId.' };
794
804
  }
795
- const proj = unwrap(await client
805
+ const proj = unwrapMaybe(await client
796
806
  .from('projects')
797
807
  .select('id, name')
798
808
  .eq('id', args.projectId)
@@ -817,20 +827,26 @@ export async function listConcepts(client, args) {
817
827
  }
818
828
  let qb = client
819
829
  .from('nodes')
820
- .select('*')
830
+ .select('*', { count: 'exact' })
821
831
  .eq('workspace_id', wsId)
822
832
  .order('updated_at', { ascending: false })
823
- .limit(args.limit);
833
+ .range(args.offset, args.offset + args.limit - 1);
824
834
  if (memberIds)
825
835
  qb = qb.in('id', memberIds);
826
836
  if (!args.includeArchived)
827
837
  qb = qb.neq('status', 'archived');
828
- const rows = unwrap(await qb);
838
+ const res = await qb;
839
+ if (res.error)
840
+ throw new Error(res.error.message);
841
+ const rows = (res.data ?? []);
842
+ const total = res.count ?? rows.length;
829
843
  return {
830
844
  scope: args.scope,
831
845
  projectName,
832
- total: rows.length,
833
- truncated: rows.length === args.limit,
846
+ total,
847
+ offset: args.offset,
848
+ returned: rows.length,
849
+ hasMore: args.offset + rows.length < total,
834
850
  concepts: rows.map((n) => nodeView(n, args.detail)),
835
851
  };
836
852
  }
@@ -926,13 +942,13 @@ export const validateConceptInput = z
926
942
  })
927
943
  .strict();
928
944
  export async function validateConcept(client, args) {
929
- const node = unwrap(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
945
+ const node = unwrapMaybe(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
930
946
  if (!node)
931
947
  return { error: `No concept with id ${args.id}` };
932
948
  const patch = { status: 'validated' };
933
949
  if (args.rationale !== undefined)
934
950
  patch.rationale = args.rationale;
935
- const row = unwrap(await client
951
+ const row = unwrapMaybe(await client
936
952
  .from('nodes')
937
953
  .update(patch)
938
954
  .eq('id', args.id)
@@ -958,7 +974,7 @@ export const setStandingInput = z
958
974
  })
959
975
  .strict();
960
976
  export async function setStanding(client, args) {
961
- const node = unwrap(await client
977
+ const node = unwrapMaybe(await client
962
978
  .from('nodes')
963
979
  .select('id, workspace_id, label, standing_rationale')
964
980
  .eq('id', args.id)
@@ -970,7 +986,7 @@ export async function setStanding(client, args) {
970
986
  standing_rationale: args.rationale,
971
987
  standing_assessed_at: new Date().toISOString(),
972
988
  };
973
- const row = unwrap(await client
989
+ const row = unwrapMaybe(await client
974
990
  .from('nodes')
975
991
  .update(patch)
976
992
  .eq('id', args.id)
@@ -995,7 +1011,7 @@ export async function setStanding(client, args) {
995
1011
  // ---------------------------------------------------------------------------
996
1012
  export const archiveConceptInput = z.object({ id: z.string().min(1) }).strict();
997
1013
  export async function archiveConcept(client, args) {
998
- const node = unwrap(await client
1014
+ const node = unwrapMaybe(await client
999
1015
  .from('nodes')
1000
1016
  .select('id, workspace_id, label, status')
1001
1017
  .eq('id', args.id)
@@ -1005,7 +1021,7 @@ export async function archiveConcept(client, args) {
1005
1021
  if (node.status === 'archived') {
1006
1022
  return { id: node.id, label: node.label, status: node.status, noop: true };
1007
1023
  }
1008
- const row = unwrap(await client
1024
+ const row = unwrapMaybe(await client
1009
1025
  .from('nodes')
1010
1026
  .update({ status: 'archived' })
1011
1027
  .eq('id', args.id)
@@ -1022,7 +1038,7 @@ export async function archiveConcept(client, args) {
1022
1038
  }
1023
1039
  export const unarchiveConceptInput = z.object({ id: z.string().min(1) }).strict();
1024
1040
  export async function unarchiveConcept(client, args) {
1025
- const node = unwrap(await client
1041
+ const node = unwrapMaybe(await client
1026
1042
  .from('nodes')
1027
1043
  .select('id, workspace_id, label, status')
1028
1044
  .eq('id', args.id)
@@ -1032,7 +1048,7 @@ export async function unarchiveConcept(client, args) {
1032
1048
  if (node.status !== 'archived') {
1033
1049
  return { id: node.id, label: node.label, status: node.status, noop: true };
1034
1050
  }
1035
- const row = unwrap(await client
1051
+ const row = unwrapMaybe(await client
1036
1052
  .from('nodes')
1037
1053
  .update({ status: 'open' })
1038
1054
  .eq('id', args.id)
@@ -1049,7 +1065,7 @@ export async function unarchiveConcept(client, args) {
1049
1065
  }
1050
1066
  export const starConceptInput = z.object({ id: z.string().min(1) }).strict();
1051
1067
  export async function starConcept(client, args) {
1052
- const node = unwrap(await client
1068
+ const node = unwrapMaybe(await client
1053
1069
  .from('nodes')
1054
1070
  .select('id, workspace_id, label, starred')
1055
1071
  .eq('id', args.id)
@@ -1057,7 +1073,7 @@ export async function starConcept(client, args) {
1057
1073
  if (!node)
1058
1074
  return { error: `No concept with id ${args.id}` };
1059
1075
  const next = !node.starred;
1060
- const row = unwrap(await client
1076
+ const row = unwrapMaybe(await client
1061
1077
  .from('nodes')
1062
1078
  .update({ starred: next })
1063
1079
  .eq('id', args.id)
@@ -1084,7 +1100,7 @@ export async function starConcept(client, args) {
1084
1100
  // in code, collect every descendant, and delete the bottom-up set.
1085
1101
  export const removeConceptInput = z.object({ id: z.string().min(1) }).strict();
1086
1102
  export async function removeConcept(client, args) {
1087
- const node = unwrap(await client
1103
+ const node = unwrapMaybe(await client
1088
1104
  .from('nodes')
1089
1105
  .select('id, workspace_id, label')
1090
1106
  .eq('id', args.id)
@@ -1100,26 +1116,40 @@ export async function removeConcept(client, args) {
1100
1116
  if (isRoot.length > 0) {
1101
1117
  return { error: 'Cannot delete a project root node.' };
1102
1118
  }
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));
1119
+ // Walk the subtree via BOTH parent_id AND partition edges. A child can be
1120
+ // attached either way; walking parent_id alone would strand partition-only
1121
+ // children as dangling orphans (the inconsistency surfaced in the rc.17
1122
+ // review — get_subtree counts partition-edge children, so the cascade must
1123
+ // too).
1124
+ const allNodes = unwrap(await client.from('nodes').select('id, parent_id').eq('workspace_id', node.workspace_id));
1125
+ const partEdges = unwrap(await client
1126
+ .from('edges')
1127
+ .select('from_id, to_id')
1128
+ .eq('workspace_id', node.workspace_id)
1129
+ .eq('kind', 'partition'));
1108
1130
  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
- }
1131
+ const addChild = (parent, child) => {
1132
+ const arr = childrenByParent.get(parent) ?? [];
1133
+ arr.push(child);
1134
+ childrenByParent.set(parent, arr);
1135
+ };
1136
+ for (const n of allNodes)
1137
+ if (n.parent_id)
1138
+ addChild(n.parent_id, n.id);
1139
+ for (const e of partEdges)
1140
+ addChild(e.from_id, e.to_id);
1116
1141
  const toDelete = [];
1142
+ const seen = new Set();
1117
1143
  const stack = [args.id];
1118
1144
  while (stack.length > 0) {
1119
1145
  const cur = stack.pop();
1146
+ if (seen.has(cur))
1147
+ continue;
1148
+ seen.add(cur);
1120
1149
  toDelete.push(cur);
1121
- const kids = childrenByParent.get(cur) ?? [];
1122
- stack.push(...kids);
1150
+ for (const k of childrenByParent.get(cur) ?? [])
1151
+ if (!seen.has(k))
1152
+ stack.push(k);
1123
1153
  }
1124
1154
  // Delete in one shot — edges/junctions cascade via FK; provenance
1125
1155
  // cascades via FK in 0015's table definition.
@@ -1133,6 +1163,40 @@ export async function removeConcept(client, args) {
1133
1163
  };
1134
1164
  }
1135
1165
  // ---------------------------------------------------------------------------
1166
+ // remove_concepts (bulk) — delete many concepts in one call.
1167
+ // ---------------------------------------------------------------------------
1168
+ // Sequential (not parallel) so a big cleanup can't hammer the backend; each id
1169
+ // reuses removeConcept (so each cascades its own subtree). Per-id results are
1170
+ // returned so the caller sees exactly what landed — ids already gone (e.g.
1171
+ // swept up in another id's cascade) come back ok:false with a not-found error,
1172
+ // which is expected, not fatal.
1173
+ export const removeConceptsInput = z
1174
+ .object({
1175
+ ids: z
1176
+ .array(z.string().min(1))
1177
+ .min(1)
1178
+ .max(500)
1179
+ .describe('Concept ids to hard-delete. Each cascades its own subtree.'),
1180
+ })
1181
+ .strict();
1182
+ export async function removeConcepts(client, args) {
1183
+ const results = [];
1184
+ for (const id of args.ids) {
1185
+ const r = (await removeConcept(client, { id }));
1186
+ if (r.error)
1187
+ results.push({ id, ok: false, error: r.error });
1188
+ else
1189
+ results.push({ id, ok: true, cascadeCount: r.cascadeCount });
1190
+ }
1191
+ const ok = results.filter((r) => r.ok).length;
1192
+ return {
1193
+ requested: args.ids.length,
1194
+ deleted: ok,
1195
+ failed: args.ids.length - ok,
1196
+ results,
1197
+ };
1198
+ }
1199
+ // ---------------------------------------------------------------------------
1136
1200
  // bulk_add_concepts (atomic via fn_bulk_add_concepts RPC)
1137
1201
  // ---------------------------------------------------------------------------
1138
1202
  // Each child resolves its project_id either explicitly (via the item's
@@ -1224,7 +1288,7 @@ export const setParentInput = z
1224
1288
  })
1225
1289
  .strict();
1226
1290
  export async function setParent(client, args) {
1227
- const node = unwrap(await client
1291
+ const node = unwrapMaybe(await client
1228
1292
  .from('nodes')
1229
1293
  .select('id, workspace_id, parent_id, label')
1230
1294
  .eq('id', args.nodeId)
@@ -1236,7 +1300,7 @@ export async function setParent(client, args) {
1236
1300
  }
1237
1301
  let resolvedParentId = args.newParentId;
1238
1302
  if (resolvedParentId) {
1239
- const parent = unwrap(await client
1303
+ const parent = unwrapMaybe(await client
1240
1304
  .from('nodes')
1241
1305
  .select('id, workspace_id')
1242
1306
  .eq('id', resolvedParentId)
@@ -1246,6 +1310,22 @@ export async function setParent(client, args) {
1246
1310
  if (parent.workspace_id !== node.workspace_id) {
1247
1311
  return { error: 'Cannot re-parent across workspaces.' };
1248
1312
  }
1313
+ // Cycle guard: the new parent must not sit inside this node's own subtree —
1314
+ // that would detach the subtree from any root and break parent-chain
1315
+ // traversal (get_subtree / get_concept ancestry / remove_concept cascade).
1316
+ const wsNodes = unwrap(await client.from('nodes').select('id, parent_id').eq('workspace_id', node.workspace_id));
1317
+ const parentOf = new Map(wsNodes.map((n) => [n.id, n.parent_id]));
1318
+ let walk = resolvedParentId;
1319
+ const visited = new Set();
1320
+ while (walk && !visited.has(walk)) {
1321
+ if (walk === args.nodeId) {
1322
+ return {
1323
+ error: "Cannot set parent: the target parent is inside this node's own subtree (would create a cycle).",
1324
+ };
1325
+ }
1326
+ visited.add(walk);
1327
+ walk = parentOf.get(walk) ?? null;
1328
+ }
1249
1329
  }
1250
1330
  // 1) Update parent_id.
1251
1331
  const updRes = await client
@@ -1545,13 +1625,101 @@ export async function updateProject(client, args) {
1545
1625
  lifecycle: row.lifecycle,
1546
1626
  };
1547
1627
  }
1548
- export const deleteProjectInput = z.object({ id: z.string().min(1) }).strict();
1628
+ export const deleteProjectInput = z
1629
+ .object({
1630
+ id: z.string().min(1),
1631
+ deleteNodes: z
1632
+ .boolean()
1633
+ .default(false)
1634
+ .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."),
1635
+ })
1636
+ .strict();
1549
1637
  export async function deleteProject(client, args) {
1638
+ if (args.deleteNodes) {
1639
+ // Capture members BEFORE deleting the project (project_nodes cascades away
1640
+ // with it). Delete the project first so removeConcept's "can't delete a
1641
+ // project root" guard no longer blocks the root node.
1642
+ const members = unwrap(await client.from('project_nodes').select('node_id').eq('project_id', args.id));
1643
+ const del = await client.from('projects').delete().eq('id', args.id);
1644
+ if (del.error)
1645
+ return { error: del.error.message };
1646
+ let nodesDeleted = 0;
1647
+ for (const m of members) {
1648
+ const r = (await removeConcept(client, { id: m.node_id }));
1649
+ // Many members are already gone (swept up in the root's cascade); that
1650
+ // returns a not-found error we simply skip.
1651
+ if (!r.error && typeof r.cascadeCount === 'number')
1652
+ nodesDeleted += r.cascadeCount;
1653
+ }
1654
+ return { id: args.id, deleted: true, nodesDeleted };
1655
+ }
1550
1656
  const del = await client.from('projects').delete().eq('id', args.id);
1551
1657
  if (del.error)
1552
1658
  return { error: del.error.message };
1553
1659
  return { id: args.id, deleted: true };
1554
1660
  }
1661
+ // ---------------------------------------------------------------------------
1662
+ // find_orphans — surface concepts that no normal traversal reaches.
1663
+ // ---------------------------------------------------------------------------
1664
+ // Two integrity classes, both confined to the caller's own workspace (RLS):
1665
+ // • projectless — node in no project (the classic residue of delete_project,
1666
+ // which leaves nodes behind).
1667
+ // • danglingParent — node whose parent_id points at a row that no longer
1668
+ // exists (invisible to root traversal, yet still present).
1669
+ // Read-only; pair with remove_concepts (to delete) or set_parent (to re-home).
1670
+ export const findOrphansInput = z
1671
+ .object({
1672
+ kind: z
1673
+ .enum(['projectless', 'dangling', 'all'])
1674
+ .default('all')
1675
+ .describe("Which orphan class to surface: 'projectless', 'dangling', or 'all'."),
1676
+ limit: z
1677
+ .number()
1678
+ .int()
1679
+ .min(1)
1680
+ .max(1000)
1681
+ .default(200)
1682
+ .describe('Max nodes to list per class (counts are always exact).'),
1683
+ })
1684
+ .strict();
1685
+ export async function findOrphans(client, args) {
1686
+ const wsId = await resolveWorkspaceId(client);
1687
+ const allNodes = unwrap(await client.from('nodes').select('id, label, parent_id, status').eq('workspace_id', wsId));
1688
+ const liveIds = new Set(allNodes.map((n) => n.id));
1689
+ const projects = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
1690
+ let memberIds = new Set();
1691
+ if (projects.length > 0) {
1692
+ const pn = unwrap(await client
1693
+ .from('project_nodes')
1694
+ .select('node_id')
1695
+ .in('project_id', projects.map((p) => p.id)));
1696
+ memberIds = new Set(pn.map((r) => r.node_id));
1697
+ }
1698
+ const view = (n) => ({
1699
+ id: n.id,
1700
+ label: n.label,
1701
+ parentId: n.parent_id,
1702
+ status: n.status,
1703
+ });
1704
+ const result = { workspaceId: wsId, totalNodes: allNodes.length };
1705
+ if (args.kind === 'projectless' || args.kind === 'all') {
1706
+ const projectless = allNodes.filter((n) => !memberIds.has(n.id));
1707
+ result.projectless = {
1708
+ count: projectless.length,
1709
+ nodes: projectless.slice(0, args.limit).map(view),
1710
+ };
1711
+ }
1712
+ if (args.kind === 'dangling' || args.kind === 'all') {
1713
+ const dangling = allNodes.filter((n) => n.parent_id && !liveIds.has(n.parent_id));
1714
+ result.danglingParent = {
1715
+ count: dangling.length,
1716
+ nodes: dangling.slice(0, args.limit).map(view),
1717
+ };
1718
+ }
1719
+ result.note =
1720
+ '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.';
1721
+ return result;
1722
+ }
1555
1723
  export const CLOUD_TOOLS = [
1556
1724
  // ── Reads ──────────────────────────────────────────────────────────────
1557
1725
  {
@@ -1673,10 +1841,22 @@ export const CLOUD_TOOLS = [
1673
1841
  },
1674
1842
  {
1675
1843
  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.',
1844
+ 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
1845
  inputSchema: removeConceptInput,
1678
1846
  handler: async (client, args) => removeConcept(client, removeConceptInput.parse(args)),
1679
1847
  },
1848
+ {
1849
+ name: 'remove_concepts',
1850
+ 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.",
1851
+ inputSchema: removeConceptsInput,
1852
+ handler: async (client, args) => removeConcepts(client, removeConceptsInput.parse(args)),
1853
+ },
1854
+ {
1855
+ name: 'find_orphans',
1856
+ 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).",
1857
+ inputSchema: findOrphansInput,
1858
+ handler: async (client, args) => findOrphans(client, findOrphansInput.parse(args)),
1859
+ },
1680
1860
  {
1681
1861
  name: 'bulk_add_concepts',
1682
1862
  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 +1921,7 @@ export const CLOUD_TOOLS = [
1741
1921
  },
1742
1922
  {
1743
1923
  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.",
1924
+ 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
1925
  inputSchema: deleteProjectInput,
1746
1926
  handler: async (client, args) => deleteProject(client, deleteProjectInput.parse(args)),
1747
1927
  },
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.19",
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",