@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.
- package/dist/cloudClient.js +12 -0
- package/dist/cloudTools.js +234 -42
- package/dist/index.js +90 -16
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
.
|
|
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
|
|
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
|
|
819
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
|
1108
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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.
|
|
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",
|