@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.
- package/dist/cloudClient.js +12 -0
- package/dist/cloudTools.js +215 -39
- package/package.json +1 -1
package/dist/cloudClient.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cloudTools.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
.
|
|
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
|
|
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
|
|
833
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
1122
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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",
|