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