@anchrd/intel-api 0.13.0 → 0.14.0
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/adapters/cloudflare/cloudflare.js +1 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.js +136 -44
- package/dist/adapters/db/db-indexing.js +79 -0
- package/dist/adapters/db/db.js +66 -27
- package/dist/adapters/semantic-index/semantic-index.js +97 -17
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
- package/dist/bundle/bundle.js +23 -2
- package/dist/http/http.js +4 -0
- package/dist/indexing/indexing.js +165 -16
- package/dist/indexing/indexing.types.d.ts +1 -0
- package/dist/mcp/mcp.js +45 -31
- package/dist/nodes/board/board.d.ts +3 -1
- package/dist/nodes/board/board.js +307 -9
- package/dist/nodes/board/board.types.d.ts +7 -0
- package/dist/nodes/nodes.js +146 -14
- package/dist/nodes/nodes.types.d.ts +61 -3
- package/migrations/0009_no_context_policy.sql +15 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +90 -0
- package/package.json +2 -2
package/dist/bundle/bundle.js
CHANGED
|
@@ -926,6 +926,20 @@ export function createBundle(deps) {
|
|
|
926
926
|
if (header.length === 0 || header.every((column) => column.trim().length === 0)) {
|
|
927
927
|
throw new IntelError(400, "import_invalid_table", `Bundle entry is not a table with a header row: ${entry.path}`);
|
|
928
928
|
}
|
|
929
|
+
// ⚠️ Distinct column names, read the same way `DefineTableInput` and
|
|
930
|
+
// `RedefineTableInput` read them — case-insensitively (#322). Both refuse a duplicate,
|
|
931
|
+
// and the import used to walk past both: a table could only enter the tree this way
|
|
932
|
+
// with two columns of one name.
|
|
933
|
+
//
|
|
934
|
+
// The consumer assumes the opposite and says so. `packages/ui/src/node-table` keys its
|
|
935
|
+
// header cells and every row cell with the column name and carries the comment
|
|
936
|
+
// "Column names are distinct by contract (`DefineTableInput`)" — true for the two write
|
|
937
|
+
// paths, and false for this one. Two equal names are two React keys with one value, in
|
|
938
|
+
// the header AND in every row.
|
|
939
|
+
const seen = new Set(header.map((column) => column.trim().toLowerCase()));
|
|
940
|
+
if (seen.size !== header.length) {
|
|
941
|
+
throw new IntelError(400, "import_invalid_table", `Bundle entry has two columns of the same name: ${entry.path}`);
|
|
942
|
+
}
|
|
929
943
|
// The whole file as one snapshot: the same shape a definition or a redefine writes
|
|
930
944
|
// (#135), so reading starts here and nothing older is expected to exist.
|
|
931
945
|
version = versionRowFor(entry, text, TableMediaType, await deps.hash(text), "snapshot");
|
|
@@ -957,12 +971,19 @@ export function createBundle(deps) {
|
|
|
957
971
|
* Outside the catch above on purpose: swallowed into `import_invalid_board` this would
|
|
958
972
|
* read as "that file is not a board", and whoever is holding the file would look for
|
|
959
973
|
* the wrong thing. `repeatedBoardId` names the id, which is the line they have to fix.
|
|
974
|
+
*
|
|
975
|
+
* ⚠️ And it names the way out, not only the fault (anchrd/intel#341). The usual holder
|
|
976
|
+
* of such a file is not its author but somebody moving a tree between installations,
|
|
977
|
+
* and the pair is in the BOARD the export came from — where `board_repair_task_ids`
|
|
978
|
+
* and `board_configure` each remove one kind of it. Told only what is wrong, they were
|
|
979
|
+
* left editing JSON by hand or leaving the board behind, and an import is all or
|
|
980
|
+
* nothing: one such board stops the whole move.
|
|
960
981
|
*/
|
|
961
982
|
const repeated = repeatedBoardId(document);
|
|
962
983
|
if (repeated !== null) {
|
|
963
984
|
throw new IntelError(400, "import_duplicate_board_id", repeated.list === "tasks"
|
|
964
|
-
? `Bundle entry names the task “${repeated.id}” twice: ${entry.path}
|
|
965
|
-
: `Bundle entry names the status “${repeated.id}” twice: ${entry.path}
|
|
985
|
+
? `Bundle entry names the task “${repeated.id}” twice: ${entry.path}. Repair that board with board_repair_task_ids where it came from, then export it again.`
|
|
986
|
+
: `Bundle entry names the status “${repeated.id}” twice: ${entry.path}. Repair that board with board_configure — the status list written whole, with distinct ids — where it came from, then export it again.`);
|
|
966
987
|
}
|
|
967
988
|
// ⚠️ `references` are remapped the way a document's inline links are, and `dependsOn`
|
|
968
989
|
// and `parentId` are NOT (#285). A task's references point at NODES, which the import
|
package/dist/http/http.js
CHANGED
|
@@ -486,6 +486,10 @@ export function createHttp(deps) {
|
|
|
486
486
|
}
|
|
487
487
|
return context.json(await deps.nodes.deleteBoardTask(asActor(auth), input), 201);
|
|
488
488
|
});
|
|
489
|
+
// ⚠️ There is deliberately no route for the sixth board operation. `board_repair_task_ids`
|
|
490
|
+
// (anchrd/intel#341) is MCP-only until a screen calls it: the routes here are what the browser
|
|
491
|
+
// reaches, nothing in `intel-data-provider` asks for it, and a route with no caller is the thing
|
|
492
|
+
// YAGNI is about. The screen is anchrd/intel#358, and it brings this route with it.
|
|
489
493
|
app.get("/nodes/:nodeId/links", async (context) => {
|
|
490
494
|
const auth = requireCapability(context, "knowledge", "read");
|
|
491
495
|
return context.json(await deps.nodes.listLinks(asActor(auth), context.req.param("nodeId")));
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
|
|
1
|
+
import { ArchivedBoardStatusId, BlockNoteDocument, BlockNoteMediaType, BoardDocument, } from "@anchrd/intel-contract";
|
|
2
2
|
import { upgradeStoredBoard } from "../nodes/board/board.js";
|
|
3
3
|
export class PermanentIndexingError extends Error {
|
|
4
4
|
}
|
|
5
|
+
// The key under which a node that has exactly one vector is filed — every kind but `board`. It is
|
|
6
|
+
// the bare node id in the index itself, which is why nothing written before anchrd/intel#301 has to
|
|
7
|
+
// be renamed or embedded again.
|
|
8
|
+
const wholeNodeChunkKey = "";
|
|
9
|
+
// What the pass hands to `purgeVectors` at its end: the names it has just written, in case the node
|
|
10
|
+
// was archived while it worked and the record of them was refused (anchrd/intel#348).
|
|
11
|
+
const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
|
|
5
12
|
function indexText(mediaType, content) {
|
|
6
13
|
if (mediaType !== BlockNoteMediaType)
|
|
7
14
|
return content;
|
|
@@ -15,7 +22,8 @@ function indexText(mediaType, content) {
|
|
|
15
22
|
}
|
|
16
23
|
}
|
|
17
24
|
/**
|
|
18
|
-
* One passage per task, never one per board (#285)
|
|
25
|
+
* One passage per task, never one per board (#285) — and since anchrd/intel#301 one VECTOR per task
|
|
26
|
+
* too, which is what `key` carries.
|
|
19
27
|
*
|
|
20
28
|
* ⚠️ This is the whole reason a board is chunked at all. "Where do I stand with X" has to land on a
|
|
21
29
|
* CARD: indexed as one blob, a board of three hundred tasks matches on any of them and answers with
|
|
@@ -23,8 +31,17 @@ function indexText(mediaType, content) {
|
|
|
23
31
|
* have nothing to do with the question. Each chunk carries the task's own title, which is the
|
|
24
32
|
* column the FTS table weights highest.
|
|
25
33
|
*
|
|
26
|
-
* ⚠️ The status list is not indexed. "Backlog" and "Done" appear
|
|
27
|
-
* installation, so they are the words most likely to match and the least
|
|
34
|
+
* ⚠️ The status list is not indexed, and neither is a task's own status. "Backlog" and "Done" appear
|
|
35
|
+
* on every board in the installation, so they are the words most likely to match and the least
|
|
36
|
+
* likely to mean anything — and in the semantic half they would drag every card of every board
|
|
37
|
+
* towards every question phrased as a state.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ A task on the `archived` shelf is left out of BOTH halves (anchrd/intel#301). That shelf is
|
|
40
|
+
* what a board has instead of deleting a card (`ArchivedBoardStatusId`), so it is the same statement
|
|
41
|
+
* `archived_at` makes about a node one level up — and an archived node has never been searchable.
|
|
42
|
+
* Answering "where do I stand with X" out of a card somebody swept away is the failure the ticket
|
|
43
|
+
* names. A card in a `terminal` status is NOT swept: "we finished it" is an answer to where the work
|
|
44
|
+
* stands, and the only thing that says a card no longer counts is the shelf.
|
|
28
45
|
*/
|
|
29
46
|
function boardChunks(content) {
|
|
30
47
|
let parsed;
|
|
@@ -48,11 +65,125 @@ function boardChunks(content) {
|
|
|
48
65
|
if (!board.success) {
|
|
49
66
|
throw new PermanentIndexingError("Node version content is not a valid board document");
|
|
50
67
|
}
|
|
51
|
-
return board.data.tasks
|
|
68
|
+
return board.data.tasks
|
|
69
|
+
.filter((task) => task.status !== ArchivedBoardStatusId)
|
|
70
|
+
.map((task) => ({
|
|
71
|
+
key: task.id,
|
|
52
72
|
title: task.title,
|
|
53
73
|
text: [task.title, ...task.labels, task.description].filter(Boolean).join("\n\n"),
|
|
54
74
|
}));
|
|
55
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* What one chunk is embedded as (anchrd/intel#301).
|
|
78
|
+
*
|
|
79
|
+
* The node's title in front of a whole-node chunk, which is the exact string the semantic adapter
|
|
80
|
+
* used to compose on its own side. It moved here because the fingerprint has to cover what is
|
|
81
|
+
* embedded and nothing else: a prefix added downstream would sit outside the comparison and change
|
|
82
|
+
* nothing when it changed. The vector it produces therefore keeps the same name AND the same
|
|
83
|
+
* content it had before this — an upgrading installation loses no answer, though its first pass
|
|
84
|
+
* over any node does embed once more, because `node_vectors` starts empty and has no fingerprint to
|
|
85
|
+
* compare against. That cost is one embedding per node, paid when that node is next saved or when
|
|
86
|
+
* an administrator runs `reindex`, and never per card.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ A card is NOT prefixed with the board's title. It brings its own title as the first line of its
|
|
89
|
+
* text already, and the board's would put a value into all three hundred fingerprints that moves
|
|
90
|
+
* when somebody renames the board — one rename, three hundred embeddings.
|
|
91
|
+
*/
|
|
92
|
+
function embeddedText(target, chunk) {
|
|
93
|
+
return chunk.key === wholeNodeChunkKey ? `${target.title}\n\n${chunk.text}` : chunk.text;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The vectors this node needs, minus the ones it already has (anchrd/intel#301).
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ This is the answer to "a changed card must not reindex the board". A save writes the whole
|
|
99
|
+
* document — that is what a board version IS — so without this every keystroke-save of a board with
|
|
100
|
+
* three hundred cards would cost three hundred embeddings. The fingerprint of the exact text that
|
|
101
|
+
* was embedded is what tells an untouched card from a changed one.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ The comparison is against D1 and deliberately not against Vectorize. Vectorize writes are
|
|
104
|
+
* asynchronous — a vector upserted a moment ago is not readable yet — so a check made there would
|
|
105
|
+
* answer about the save before last and re-embed a board that had just been saved twice.
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ The record is written only after the upsert returned, in the caller. A pass that dies in
|
|
108
|
+
* between leaves the record short, so the next one embeds again; the opposite order would leave a
|
|
109
|
+
* record claiming a vector nobody ever wrote, and nothing would ever notice.
|
|
110
|
+
*/
|
|
111
|
+
async function replaceVectors(deps, target, chunks) {
|
|
112
|
+
const semantic = deps.semantic;
|
|
113
|
+
if (!semantic)
|
|
114
|
+
return;
|
|
115
|
+
const stored = await deps.repository.listVectors(target.nodeId);
|
|
116
|
+
const known = new Map(stored.map((record) => [record.chunkKey, record.fingerprint]));
|
|
117
|
+
const records = [];
|
|
118
|
+
const changed = [];
|
|
119
|
+
for (const chunk of chunks) {
|
|
120
|
+
const text = embeddedText(target, chunk);
|
|
121
|
+
const fingerprint = await deps.hash(text);
|
|
122
|
+
if (known.get(chunk.key) !== fingerprint)
|
|
123
|
+
changed.push({ key: chunk.key, text });
|
|
124
|
+
records.push({ chunkKey: chunk.key, fingerprint, passage: chunk.text });
|
|
125
|
+
}
|
|
126
|
+
const wanted = new Set(chunks.map((chunk) => chunk.key));
|
|
127
|
+
const stale = stored.map((record) => record.chunkKey).filter((key) => !wanted.has(key));
|
|
128
|
+
// ⚠️ The one-time sweep of the vector this node had BEFORE it was chunked. An empty record with a
|
|
129
|
+
// chunked node means this is the first pass since anchrd/intel#301 (or since `reindex` emptied
|
|
130
|
+
// the record), and the whole-board vector written under the bare node id is still sitting there —
|
|
131
|
+
// matching questions and answering with the wrong card's passage. Once a record exists this
|
|
132
|
+
// branch is never taken again, so it costs one call per board rather than one per save.
|
|
133
|
+
if (stored.length === 0 && !wanted.has(wholeNodeChunkKey))
|
|
134
|
+
stale.push(wholeNodeChunkKey);
|
|
135
|
+
await semantic.upsert(target, changed);
|
|
136
|
+
if (stale.length > 0)
|
|
137
|
+
await semantic.remove(target.nodeId, stale);
|
|
138
|
+
await deps.repository.replaceVectors(target, records);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Everything an archived node had in the vector index, taken out of it (anchrd/intel#348).
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ The ORDER is the whole safety argument, and it is the exact mirror of the one above. There the
|
|
144
|
+
* record is written last, because a row claiming a vector nobody wrote would make the next pass skip
|
|
145
|
+
* that card forever. Here the record is DELETED last, because the row is the only thing that knows
|
|
146
|
+
* what a vector is called: drop it first and the vectors stay in the index under names nothing left
|
|
147
|
+
* anywhere can produce. A pass that dies in between leaves vectors already gone and a record that
|
|
148
|
+
* still names them — which costs the next pass one embedding per card and nothing else, because the
|
|
149
|
+
* queue redelivers this message and the second run asks Vectorize to forget names it has already
|
|
150
|
+
* forgotten. Both failures are repaired by repeating; neither ends in a wrong answer.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ The bare node id goes with them whether or not it is in the record. That name is the ONE vector
|
|
153
|
+
* a node can hold without any record of it — everything written before anchrd/intel#301 is filed
|
|
154
|
+
* under it — and an archived node has no later pass in which the one-time sweep in `replaceVectors`
|
|
155
|
+
* could find it. The standing cost of that is one `deleteByIds` of a single name on a redelivered
|
|
156
|
+
* message, which Vectorize answers without complaint for an id it no longer holds.
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ `alsoNamed` is what makes this run after a NORMAL pass too, and it closes the one hole a purge
|
|
159
|
+
* driven from `archive` alone cannot. A pass that has already upserted its vectors when somebody
|
|
160
|
+
* archives the node writes no record at all — `replaceVectors` refuses on `archived_at IS NULL` —
|
|
161
|
+
* so those vectors would sit in the index with nothing anywhere able to name them, and no message
|
|
162
|
+
* left to repair it. The pass knows the names it just wrote, so it hands them over.
|
|
163
|
+
*
|
|
164
|
+
* ⚠️ Without a semantic index configured nothing happens at all, `node_vectors` included. The rows
|
|
165
|
+
* can only have been written while one WAS configured, so a deployment that has temporarily lost the
|
|
166
|
+
* binding must not take the record away — that would leave the vectors behind it unnameable, which
|
|
167
|
+
* is the failure this whole function exists to avoid.
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ One race is left and it is the harmless direction: a restore that lands between the read below
|
|
170
|
+
* and the deletion loses the vectors that restore's own pass had just written. The node keeps
|
|
171
|
+
* answering out of the lexical half — `hydrateVisibleCitations` falls back on the full-text passage
|
|
172
|
+
* when there is no `node_vectors` row — and the next save or `reindex` puts the vectors back. The
|
|
173
|
+
* record is gone with them, so nothing claims otherwise in the meantime.
|
|
174
|
+
*/
|
|
175
|
+
async function purgeVectors(deps, versionId, alsoNamed = []) {
|
|
176
|
+
const semantic = deps.semantic;
|
|
177
|
+
if (!semantic)
|
|
178
|
+
return;
|
|
179
|
+
const nodeId = await deps.repository.archivedNodeId(versionId);
|
|
180
|
+
if (nodeId === null)
|
|
181
|
+
return;
|
|
182
|
+
const recorded = (await deps.repository.listVectors(nodeId)).map((record) => record.chunkKey);
|
|
183
|
+
const keys = [...new Set([wholeNodeChunkKey, ...recorded, ...alsoNamed])];
|
|
184
|
+
await semantic.remove(nodeId, keys);
|
|
185
|
+
await deps.repository.deleteVectors(nodeId);
|
|
186
|
+
}
|
|
56
187
|
async function readCanonical(deps, target) {
|
|
57
188
|
if (target.kind === "attachment") {
|
|
58
189
|
const key = target.contentKeys[0];
|
|
@@ -67,8 +198,15 @@ export function createIndexing(deps) {
|
|
|
67
198
|
return {
|
|
68
199
|
async index(versionId) {
|
|
69
200
|
const target = await deps.repository.getTarget(versionId);
|
|
70
|
-
|
|
201
|
+
// ⚠️ No target has meant "nothing to do" since the pass existed, and for a superseded version
|
|
202
|
+
// it still does. For an ARCHIVED node it never did: its vectors stayed in the index, invisible
|
|
203
|
+
// because every citation is hydrated through a join on `nodes` — and costing places in a
|
|
204
|
+
// candidate list that Vectorize caps at 100 for everybody (anchrd/intel#348). `purgeVectors`
|
|
205
|
+
// is the one that tells the two apart; this call cannot.
|
|
206
|
+
if (!target) {
|
|
207
|
+
await purgeVectors(deps, versionId);
|
|
71
208
|
return;
|
|
209
|
+
}
|
|
72
210
|
// ⚠️ An agent's body is a definition, not something to read: node IDs, a cron line and a model
|
|
73
211
|
// name (ADR-0005 §4). Indexing it verbatim would fill the index with identifiers and, worse,
|
|
74
212
|
// put them into the passage a searcher is shown — the definition names nodes the searcher may
|
|
@@ -76,10 +214,12 @@ export function createIndexing(deps) {
|
|
|
76
214
|
// the tree being asked. What makes an agent findable is what a person wrote about it, so the
|
|
77
215
|
// text is its description, and the FTS table indexes the title on its own (#139).
|
|
78
216
|
if (target.kind === "agent") {
|
|
79
|
-
|
|
80
|
-
{ title: target.title, text: target.description ?? "" },
|
|
81
|
-
]
|
|
82
|
-
await deps.
|
|
217
|
+
const chunks = [
|
|
218
|
+
{ key: wholeNodeChunkKey, title: target.title, text: target.description ?? "" },
|
|
219
|
+
];
|
|
220
|
+
await deps.repository.replace(target, chunks);
|
|
221
|
+
await replaceVectors(deps, target, chunks);
|
|
222
|
+
await purgeVectors(deps, versionId, writtenKeys(chunks));
|
|
83
223
|
await deps.repository.markIndexed(versionId, deps.now().toISOString());
|
|
84
224
|
return;
|
|
85
225
|
}
|
|
@@ -100,12 +240,21 @@ export function createIndexing(deps) {
|
|
|
100
240
|
if (text === undefined) {
|
|
101
241
|
throw new PermanentIndexingError(`No document converter is configured for ${target.mediaType}`);
|
|
102
242
|
}
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
243
|
+
// Both halves are chunked the same way now (anchrd/intel#301): a board is one passage and
|
|
244
|
+
// one vector per card, everything else is one of each for the node. The two indexes read the
|
|
245
|
+
// same list, so they cannot come to disagree about what a board is made of.
|
|
246
|
+
const chunks = target.kind === "board"
|
|
247
|
+
? boardChunks(text)
|
|
248
|
+
: [{ key: wholeNodeChunkKey, title: target.title, text }];
|
|
249
|
+
await deps.repository.replace(target, chunks);
|
|
250
|
+
await replaceVectors(deps, target, chunks);
|
|
251
|
+
// ⚠️ Asked again, at the END of a pass that started on a live node (anchrd/intel#348). The
|
|
252
|
+
// node may have been archived while this pass was embedding, and then `replaceVectors` above
|
|
253
|
+
// wrote no record at all — its statements refuse on `archived_at IS NULL`. The vectors are in
|
|
254
|
+
// the index either way, so without this they would stay there with nothing anywhere able to
|
|
255
|
+
// name them and no message left to try again: the archive's own pass has an empty record to
|
|
256
|
+
// read from. One extra `SELECT` per pass buys that, and on a live node it answers null.
|
|
257
|
+
await purgeVectors(deps, versionId, writtenKeys(chunks));
|
|
109
258
|
await deps.repository.markIndexed(versionId, deps.now().toISOString());
|
|
110
259
|
}
|
|
111
260
|
catch (error) {
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AddBoardTaskInput, AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, ConfigureBoardInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetBoardInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, MoveBoardTaskInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateBoardTaskInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AddBoardTaskInput, AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, ConfigureBoardInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetBoardInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, MoveBoardTaskInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, RepairBoardTaskIdsInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateBoardTaskInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -8,18 +8,18 @@ function text(value) {
|
|
|
8
8
|
return { content: [{ type: "text", text: JSON.stringify(value) }], isError: false };
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* ⚠️ One name per tool, and the second one is gone for good (#149).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Every tool here carried a `knowledge_*` alias beside its name from #125 until today. The alias
|
|
14
|
+
* was never politeness: a tool name lives in somebody else's configuration — a portal endpoint, an
|
|
15
|
+
* agent definition, a saved prompt — so renaming it here alone breaks them at the moment we deploy
|
|
16
|
+
* rather than at a moment they chose. ADR-0005 §8 therefore kept both, **for a transition**.
|
|
17
|
+
*
|
|
18
|
+
* A transition without an end date is not a transition, it is a second permanent name — and two
|
|
19
|
+
* names for one tool is exactly the friction #125 removed. It ends here. What follows for anybody
|
|
20
|
+
* writing a tool: there is one name, and a rename is a breaking change that needs its own ticket
|
|
21
|
+
* and its own notice, not a quiet second entry.
|
|
18
22
|
*/
|
|
19
|
-
function registerWithAlias(server, names, config, handler) {
|
|
20
|
-
server.registerTool(names.name, config, handler);
|
|
21
|
-
server.registerTool(names.deprecated, { ...config, description: `Deprecated: use ${names.name}. ${config.description}` }, handler);
|
|
22
|
-
}
|
|
23
23
|
// MCP returns resource payloads inline as base64, so an attachment cannot be streamed to the client
|
|
24
24
|
// and must fit in Worker memory twice over. Refuse oversized attachments instead of losing the
|
|
25
25
|
// isolate; HTTP still serves them as a stream.
|
|
@@ -112,7 +112,7 @@ export async function handleMcp(request, deps) {
|
|
|
112
112
|
name: deps.authorization.identity.name ?? null,
|
|
113
113
|
}));
|
|
114
114
|
if (permits(deps.authorization, "knowledge", "read")) {
|
|
115
|
-
|
|
115
|
+
server.registerTool("node_list", {
|
|
116
116
|
title: "List nodes",
|
|
117
117
|
description: "List authorized folders, documents, attachments, and tables under one parent.",
|
|
118
118
|
inputSchema: ListNodesInput,
|
|
@@ -124,7 +124,7 @@ export async function handleMcp(request, deps) {
|
|
|
124
124
|
openWorldHint: false,
|
|
125
125
|
},
|
|
126
126
|
}, async (input) => text(await deps.nodes.list(actor, input)));
|
|
127
|
-
|
|
127
|
+
server.registerTool("node_get", {
|
|
128
128
|
title: "Get node",
|
|
129
129
|
description: "Get one authorized node and its current immutable content version.",
|
|
130
130
|
inputSchema: GetNodeInput,
|
|
@@ -136,7 +136,7 @@ export async function handleMcp(request, deps) {
|
|
|
136
136
|
openWorldHint: false,
|
|
137
137
|
},
|
|
138
138
|
}, async (input) => text(await deps.nodes.get(actor, input.nodeId)));
|
|
139
|
-
|
|
139
|
+
server.registerTool("node_versions_list", {
|
|
140
140
|
title: "List node versions",
|
|
141
141
|
description: "List immutable versions of one authorized node.",
|
|
142
142
|
inputSchema: GetNodeInput,
|
|
@@ -163,7 +163,7 @@ export async function handleMcp(request, deps) {
|
|
|
163
163
|
openWorldHint: false,
|
|
164
164
|
},
|
|
165
165
|
}, async (input) => text(await deps.nodes.getVersion(actor, input.nodeId, input.versionId)));
|
|
166
|
-
|
|
166
|
+
server.registerTool("node_attachment_get", {
|
|
167
167
|
title: "Get node attachment",
|
|
168
168
|
description: "Get authorized immutable attachment metadata and its explicit MCP resource URI.",
|
|
169
169
|
inputSchema: GetNodeInput,
|
|
@@ -199,7 +199,7 @@ export async function handleMcp(request, deps) {
|
|
|
199
199
|
],
|
|
200
200
|
};
|
|
201
201
|
});
|
|
202
|
-
|
|
202
|
+
server.registerTool("search", {
|
|
203
203
|
title: "Search nodes",
|
|
204
204
|
description: "Search only authorized indexed nodes and return version-pinned citations. Pass scopeId " +
|
|
205
205
|
"with a folder node id to search that folder and everything beneath it instead of " +
|
|
@@ -240,7 +240,7 @@ export async function handleMcp(request, deps) {
|
|
|
240
240
|
openWorldHint: false,
|
|
241
241
|
},
|
|
242
242
|
}, async (input) => text(await deps.nodes.getAgent(actor, input)));
|
|
243
|
-
|
|
243
|
+
server.registerTool("node_table_get", {
|
|
244
244
|
title: "Get node table",
|
|
245
245
|
description: "Read one authorized table as column names and rows.",
|
|
246
246
|
inputSchema: GetTableInput,
|
|
@@ -267,7 +267,7 @@ export async function handleMcp(request, deps) {
|
|
|
267
267
|
openWorldHint: false,
|
|
268
268
|
},
|
|
269
269
|
}, async (input) => text(await deps.nodes.getBoard(actor, input)));
|
|
270
|
-
|
|
270
|
+
server.registerTool("node_links_list", {
|
|
271
271
|
title: "List node links",
|
|
272
272
|
description: "List authorized outgoing links and backlinks for one node.",
|
|
273
273
|
inputSchema: GetNodeInput,
|
|
@@ -286,7 +286,7 @@ export async function handleMcp(request, deps) {
|
|
|
286
286
|
// ⚠️ A target this caller may not reach, or one that is gone, is simply absent from the answer.
|
|
287
287
|
// The two are indistinguishable on purpose: telling them apart would confirm that a document
|
|
288
288
|
// exists somewhere they cannot look.
|
|
289
|
-
|
|
289
|
+
server.registerTool("node_links_resolve", {
|
|
290
290
|
title: "Resolve node links",
|
|
291
291
|
description: "Resolve document link targets to the titles this caller is authorized to see. Targets that are unreachable or deleted are absent from the result.",
|
|
292
292
|
inputSchema: ResolveNodeLinksInput,
|
|
@@ -298,7 +298,7 @@ export async function handleMcp(request, deps) {
|
|
|
298
298
|
openWorldHint: false,
|
|
299
299
|
},
|
|
300
300
|
}, async (input) => text(await deps.nodes.resolveLinks(actor, input)));
|
|
301
|
-
|
|
301
|
+
server.registerTool("node_graph", {
|
|
302
302
|
title: "Get node graph",
|
|
303
303
|
description: "Get the authorized nodes and explicit relationships for discovery.",
|
|
304
304
|
inputSchema: NodeGraphInput,
|
|
@@ -330,7 +330,7 @@ export async function handleMcp(request, deps) {
|
|
|
330
330
|
}, async (input) => text(await deps.bundle.manifest(bundleActor, input.nodeId)));
|
|
331
331
|
}
|
|
332
332
|
if (deps.authorization.can("intel", "admin")) {
|
|
333
|
-
|
|
333
|
+
server.registerTool("node_reindex", {
|
|
334
334
|
title: "Reindex nodes",
|
|
335
335
|
description: "Queue every current canonical node version for derived index rebuild.",
|
|
336
336
|
inputSchema: EmptyInput,
|
|
@@ -344,7 +344,7 @@ export async function handleMcp(request, deps) {
|
|
|
344
344
|
}, async () => text(await deps.nodes.reindex(actor)));
|
|
345
345
|
}
|
|
346
346
|
if (permits(deps.authorization, "knowledge", "create")) {
|
|
347
|
-
|
|
347
|
+
server.registerTool("node_create", {
|
|
348
348
|
title: "Create node",
|
|
349
349
|
description: "Create a governed folder, document, attachment, or table node.",
|
|
350
350
|
inputSchema: CreateNodeInput,
|
|
@@ -415,7 +415,7 @@ export async function handleMcp(request, deps) {
|
|
|
415
415
|
openWorldHint: true,
|
|
416
416
|
},
|
|
417
417
|
}, async (input) => text(await deps.nodes.rotateAgentKey(actor, input, { token: deps.bearer })));
|
|
418
|
-
|
|
418
|
+
server.registerTool("node_save", {
|
|
419
419
|
title: "Save node version",
|
|
420
420
|
description: "Append an immutable content version using an optimistic base version.",
|
|
421
421
|
inputSchema: SaveNodeVersionInput,
|
|
@@ -427,7 +427,7 @@ export async function handleMcp(request, deps) {
|
|
|
427
427
|
openWorldHint: false,
|
|
428
428
|
},
|
|
429
429
|
}, async (input) => text(await deps.nodes.save(actor, input)));
|
|
430
|
-
|
|
430
|
+
server.registerTool("node_attachment_save", {
|
|
431
431
|
title: "Save node attachment",
|
|
432
432
|
description: "Append immutable base64 file bytes to an attachment node.",
|
|
433
433
|
inputSchema: SaveAttachmentInput,
|
|
@@ -459,7 +459,7 @@ export async function handleMcp(request, deps) {
|
|
|
459
459
|
zip: decodeZipBase64(input.zipBase64),
|
|
460
460
|
idempotencyKey: input.idempotencyKey,
|
|
461
461
|
})));
|
|
462
|
-
|
|
462
|
+
server.registerTool("node_table_define", {
|
|
463
463
|
title: "Define node table columns",
|
|
464
464
|
description: "Write the column names of an empty table. The header is the contract every append is checked against and cannot be rewritten.",
|
|
465
465
|
inputSchema: DefineTableInput,
|
|
@@ -473,7 +473,7 @@ export async function handleMcp(request, deps) {
|
|
|
473
473
|
}, async (input) => text(await deps.nodes.defineTable(actor, input)));
|
|
474
474
|
// The tool #40 exists for: an agent collecting findings on a schedule appends them without
|
|
475
475
|
// reading or resending what is already there, and two agents appending at once lose nothing.
|
|
476
|
-
|
|
476
|
+
server.registerTool("node_table_append", {
|
|
477
477
|
title: "Append node table rows",
|
|
478
478
|
description: "Append rows to a table without reading or resending its existing content. Each row must have exactly as many cells as the table has columns; a row that does not is rejected and nothing is written.",
|
|
479
479
|
inputSchema: AppendTableRowsInput,
|
|
@@ -601,7 +601,21 @@ export async function handleMcp(request, deps) {
|
|
|
601
601
|
openWorldHint: false,
|
|
602
602
|
},
|
|
603
603
|
}, async (input) => text(await deps.nodes.deleteBoardTask(actor, input)));
|
|
604
|
-
|
|
604
|
+
server.registerTool("board_repair_task_ids", {
|
|
605
|
+
title: "Repair repeated board task ids",
|
|
606
|
+
description: "Give a fresh id to every task entry on a board that repeats an id another entry already used. Only a board imported from a hand-written or foreign bundle can hold such a pair, and it cannot be repaired with the other calls: they address a task by id, so they would read, write or remove both entries at once. The first entry under an id keeps it — that is the one every parentId and dependsOn on the board already means — and each later one becomes its own task with everything else it carries, so nothing is lost and no edge is invented. The answer names each pair. A board that names every task once is refused rather than rewritten. Repeated STATUS ids are repaired with board_configure instead, by writing the status list with distinct ids.",
|
|
607
|
+
inputSchema: RepairBoardTaskIdsInput,
|
|
608
|
+
annotations: {
|
|
609
|
+
title: "Repair repeated board task ids",
|
|
610
|
+
readOnlyHint: false,
|
|
611
|
+
// Nothing is removed and nothing is overwritten: one entry gains an id of its own and
|
|
612
|
+
// every other field on every task stays exactly as it stood.
|
|
613
|
+
destructiveHint: false,
|
|
614
|
+
idempotentHint: true,
|
|
615
|
+
openWorldHint: false,
|
|
616
|
+
},
|
|
617
|
+
}, async (input) => text(await deps.nodes.repairBoardTaskIds(actor, input)));
|
|
618
|
+
server.registerTool("node_update", {
|
|
605
619
|
title: "Update node",
|
|
606
620
|
description: "Rename, move, or describe a node.",
|
|
607
621
|
inputSchema: UpdateNodeInput,
|
|
@@ -613,7 +627,7 @@ export async function handleMcp(request, deps) {
|
|
|
613
627
|
openWorldHint: false,
|
|
614
628
|
},
|
|
615
629
|
}, async (input) => text(await deps.nodes.update(actor, input)));
|
|
616
|
-
|
|
630
|
+
server.registerTool("node_archive", {
|
|
617
631
|
title: "Archive node",
|
|
618
632
|
description: "Archive or restore one node using optimistic concurrency. Archiving an agent also switches off its Gate application; restoring switches it back on.",
|
|
619
633
|
inputSchema: ArchiveNodeInput,
|
|
@@ -632,7 +646,7 @@ export async function handleMcp(request, deps) {
|
|
|
632
646
|
async (input) => text(await deps.nodes.archive(actor, input, { token: deps.bearer })));
|
|
633
647
|
}
|
|
634
648
|
if (permits(deps.authorization, "knowledge", "share")) {
|
|
635
|
-
|
|
649
|
+
server.registerTool("node_shares_list", {
|
|
636
650
|
title: "List node shares",
|
|
637
651
|
description: "List direct grants for nodes the caller is allowed to manage.",
|
|
638
652
|
inputSchema: ListGrantsInput,
|
|
@@ -644,7 +658,7 @@ export async function handleMcp(request, deps) {
|
|
|
644
658
|
openWorldHint: false,
|
|
645
659
|
},
|
|
646
660
|
}, async (input) => text(await deps.nodes.listGrants(actor, input.resourceId)));
|
|
647
|
-
|
|
661
|
+
server.registerTool("node_share", {
|
|
648
662
|
title: "Share node",
|
|
649
663
|
description: "Grant inherited node access to a Gate user, verified email, or the organization.",
|
|
650
664
|
inputSchema: ShareInput,
|
|
@@ -656,7 +670,7 @@ export async function handleMcp(request, deps) {
|
|
|
656
670
|
openWorldHint: false,
|
|
657
671
|
},
|
|
658
672
|
}, async (input) => text(await deps.nodes.share(actor, input)));
|
|
659
|
-
|
|
673
|
+
server.registerTool("node_revoke_share", {
|
|
660
674
|
title: "Revoke node share",
|
|
661
675
|
description: "Revoke one direct node grant by ID.",
|
|
662
676
|
inputSchema: RevokeGrantInput,
|
|
@@ -32,7 +32,9 @@ export declare function upgradeStoredBoard(parsed: unknown): unknown;
|
|
|
32
32
|
* first `board_task_update` against such a pair writes the SAME task into both entries. The stored
|
|
33
33
|
* board is then two identical tasks rather than two different ones. That is a consequence of
|
|
34
34
|
* addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
|
|
35
|
-
* import is to stop the pair from existing, not to make it survivable.
|
|
35
|
+
* import is to stop the pair from existing, not to make it survivable. A board that already holds
|
|
36
|
+
* one is repaired by `repairTaskIds` below — asked for, never done on the quiet — and the refusal
|
|
37
|
+
* here names it, because "your file is wrong" without a way out is where anchrd/intel#341 started.
|
|
36
38
|
*
|
|
37
39
|
* So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
|
|
38
40
|
* document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
|