@anchrd/intel-api 0.37.0 → 0.38.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.
@@ -76,6 +76,17 @@ function parseMetadata(raw) {
76
76
  * exist. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
77
77
  */
78
78
  const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
79
+ // ⚠️ A card whose title is NULL names nothing, and `FeedEvent.resourceTitle` refuses it — the reader
80
+ // gets a validation error instead of a feed. It is a real state and not a theoretical one (#763,
81
+ // found against the running installation): an ADMIN passes `flowInSubtree` on its first branch
82
+ // alone, so events about a flow whose row is gone reach this far, and only `flows.purge` carries a
83
+ // title in its metadata. `flows.create` and `flows.archive` carry none, and there is nothing left
84
+ // to read one from.
85
+ //
86
+ // ⚠️ The node half cannot reach this state, and that asymmetry is the whole reason it was missed:
87
+ // there the check is `COALESCE(n.id, …) IN (SELECT id FROM allowed)`, a join against real rows with
88
+ // no admin short-circuit in front of it, so a deleted node's events drop out on their own.
89
+ const HAS_TITLE = "COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
79
90
  const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
80
91
  export const feedPageQuery = `${subtreeCte}
81
92
  SELECT e.id, e.actor_id, e.action, e.resource_id,
@@ -98,6 +109,7 @@ export const feedPageQuery = `${subtreeCte}
98
109
  AND ${flowInSubtreeOver(FLOW_PARENT)}
99
110
  )
100
111
  )
112
+ AND ${HAS_TITLE}
101
113
  AND (? IS NULL OR e.actor_id = ?)
102
114
  AND (
103
115
  ? IS NULL
@@ -1,4 +1,4 @@
1
- import { descendantsCte, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
1
+ import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
2
  const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
3
3
  size, segment, created_by, created_at`;
4
4
  const grantColumns = grantColumnsFor("node_id");
@@ -70,6 +70,18 @@ export function createNodeRepository(deps) {
70
70
  // same question of the same table.
71
71
  const visibleCte = subtreeCte;
72
72
  const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
73
+ /**
74
+ * What a LEVEL query binds: the walk over `nodes`, then the flow half of `has_children`, in the
75
+ * order the two appear in the statement.
76
+ *
77
+ * ⚠️ One clock reading for both halves. Two calls to `deps.now()` inside one statement would
78
+ * measure the same `expires_at` against two different moments, and a grant that expires between
79
+ * them would be honoured on one side of the OR and refused on the other.
80
+ */
81
+ const levelBindings = (actor) => {
82
+ const now = deps.now().toISOString();
83
+ return [...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)];
84
+ };
73
85
  /**
74
86
  * The optional folder cut a search carries (#126), in the three places one statement needs it:
75
87
  * the extra CTE, the extra join, and the one binding between the actor's and the query's. All
@@ -126,8 +138,14 @@ export function createNodeRepository(deps) {
126
138
  * everywhere would make a link to a task a dead end.
127
139
  */
128
140
  const notInTree = (alias) => `${alias}.kind <> 'task'`;
129
- const visibleChildren = (bounded) => `${visibleCte}
130
- SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""},
141
+ /**
142
+ * The chevron column, and it is drawn ONLY for the tree (#777). The bounded read the relation
143
+ * graph makes discards it, and since #777 it is no longer free: computing it costs a second
144
+ * correlated `EXISTS` per row and six bindings that statement has no other use for. Leaving it
145
+ * out there is not a divergence in the visibility predicate, which is what the two forms share;
146
+ * it is one column the caller never reads.
147
+ */
148
+ const chevronColumn = `
131
149
  -- Whether this row has children THIS actor may see (#59), asked of the same allowed set one
132
150
  -- level down. A second rule written here would drift from the one above it, and the drift
133
151
  -- would show as a chevron that opens onto nothing.
@@ -137,11 +155,29 @@ export function createNodeRepository(deps) {
137
155
  -- actor can see, and the chevron falls away by itself (D66, #376). Leaving it out here would
138
156
  -- draw a chevron that opens onto an empty level — exactly the drift the comment above warns
139
157
  -- about, arriving through the door it was written for.
140
- EXISTS (
158
+ --
159
+ -- ⚠️ And the SECOND half of the level, because the tree is shared: a folder holds nodes and
160
+ -- flows side by side (ADR-0004 §1), and a flow is not a node. Asking only the nodes table
161
+ -- left a folder whose only children are flows answering "empty": no chevron, no way down,
162
+ -- while clicking the folder listed both kinds perfectly well (#777). It is the same root as
163
+ -- #457, where a count over nodes alone held such a folder for empty on the delete path; that
164
+ -- one was fixed, this one was not.
165
+ --
166
+ -- ⚠️ flowInSubtree rather than a hand-written condition, for the reason stated above it: it
167
+ -- is the very predicate db-flows.ts draws the level with, so the chevron and the level it
168
+ -- opens onto cannot answer differently. Its folder clause is already satisfied here (the
169
+ -- flow's parent is n, and n is joined to allowed), so today it can only widen the answer,
170
+ -- never narrow it, which is the only direction a chevron may drift in at all.
171
+ (EXISTS (
141
172
  SELECT 1 FROM nodes child
142
173
  JOIN allowed AS allowed_child ON allowed_child.id = child.id
143
174
  WHERE child.parent_id = n.id AND child.archived_at IS NULL AND ${notInTree("child")}
144
- ) AS has_children
175
+ ) OR EXISTS (
176
+ SELECT 1 FROM flows flow
177
+ WHERE flow.parent_id = n.id AND flow.archived_at IS NULL AND ${flowInSubtree}
178
+ )) AS has_children`;
179
+ const visibleChildren = (bounded) => `${visibleCte}
180
+ SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : `,${chevronColumn}`}
145
181
  FROM nodes n
146
182
  JOIN allowed ON allowed.id = n.id
147
183
  WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL) AND ${notInTree("n")}
@@ -166,9 +202,17 @@ export function createNodeRepository(deps) {
166
202
  async listVisible(actor, input) {
167
203
  const result = await deps.db
168
204
  .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
169
- .bind(...readBindings(actor), ...(input.archivedOnly
170
- ? []
171
- : [input.parentId, input.parentId, input.includeArchived ? 1 : 0]))
205
+ .bind(
206
+ // ⚠️ The archive statement answers `has_children` as a literal 0, so it carries no flow
207
+ // bindings; the level statement carries both halves. Same branch, two bind lists.
208
+ ...(input.archivedOnly
209
+ ? readBindings(actor)
210
+ : [
211
+ ...levelBindings(actor),
212
+ input.parentId,
213
+ input.parentId,
214
+ input.includeArchived ? 1 : 0,
215
+ ]))
172
216
  .all();
173
217
  const rows = result.results ?? [];
174
218
  return {
@@ -179,6 +223,9 @@ export function createNodeRepository(deps) {
179
223
  async listVisibleBounded(actor, input) {
180
224
  const result = await deps.db
181
225
  .prepare(visibleChildren(true))
226
+ // ⚠️ `readBindings`, not `levelBindings`: this form carries no chevron column, so the
227
+ // flow half's six bindings have no placeholder to land in. D1 answers a miscount with
228
+ // `Wrong number of parameter bindings`, so the two cannot silently go out of step.
182
229
  .bind(...readBindings(actor), input.parentId, input.parentId, 0, input.limit)
183
230
  .all();
184
231
  const rows = result.results ?? [];
@@ -1340,6 +1387,31 @@ export function createNodeRepository(deps) {
1340
1387
  .all();
1341
1388
  return { nodes, links: (linkResult.results ?? []).map(mapLink) };
1342
1389
  },
1390
+ /**
1391
+ * The same `allowed` set every other read joins against, narrowed to attachments (#773).
1392
+ *
1393
+ * ⚠️ Ordered and paged by `n.id` rather than by title: the id is the cursor, and a cursor has to
1394
+ * be unique or a page boundary falling between two identical titles silently drops one of them.
1395
+ * `graphVisible` above orders by title because it draws a picture and takes the whole set.
1396
+ */
1397
+ async listVisibleAttachments(actor, input) {
1398
+ const result = await deps.db
1399
+ .prepare(`${visibleCte}
1400
+ SELECT ${nodeColumns}
1401
+ FROM nodes n
1402
+ JOIN allowed ON allowed.id = n.id
1403
+ WHERE n.archived_at IS NULL
1404
+ AND n.kind = 'attachment'
1405
+ AND (? IS NULL OR n.id > ?)
1406
+ ORDER BY n.id
1407
+ LIMIT ?`)
1408
+ // ⚠️ Positional throughout, and the cursor is bound TWICE. Numbered parameters (?1, ?2)
1409
+ // would count from the first placeholder of the whole statement — and `visibleCte` above
1410
+ // has already spent several of them.
1411
+ .bind(...readBindings(actor), input.after, input.after, input.limit)
1412
+ .all();
1413
+ return (result.results ?? []).map(mapNode);
1414
+ },
1343
1415
  async setGrant(input) {
1344
1416
  const grant = input.grant;
1345
1417
  const { type: principalType, id: principalId } = principalColumns(grant.principal);
package/dist/mcp/mcp.js CHANGED
@@ -4,7 +4,7 @@ import { BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, Boa
4
4
  import { FeedListRequest } from "@anchrd/intel-contract/feed";
5
5
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
6
6
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
7
- import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
7
+ import { ArchiveNodeInput, CreateNodeInput, GetAttachmentInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
8
8
  import { ListEffectiveAccessInput, ListFlowEffectiveAccessInput, ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
9
9
  import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
10
10
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
@@ -329,8 +329,11 @@ export async function handleMcp(request, deps) {
329
329
  }, async (input) => text(await deps.nodes.getVersion(actor, input.nodeId, input.versionId)));
330
330
  server.registerTool("node_attachment_get", {
331
331
  title: "Get node attachment",
332
- description: "Get authorized immutable attachment metadata and its explicit MCP resource URI.",
333
- inputSchema: GetNodeInput,
332
+ description: "Get authorized immutable attachment metadata, and with includeContent the file itself. " +
333
+ "The resourceUri in the answer is this server's own address; a portal that aggregates " +
334
+ "several servers prefixes it, so read the file through includeContent unless the URI " +
335
+ "came from resources/list.",
336
+ inputSchema: GetAttachmentInput,
334
337
  annotations: {
335
338
  title: "Get node attachment",
336
339
  readOnlyHint: true,
@@ -338,8 +341,76 @@ export async function handleMcp(request, deps) {
338
341
  idempotentHint: true,
339
342
  openWorldHint: false,
340
343
  },
341
- }, async (input) => text(await deps.nodes.getAttachment(actor, input.nodeId)));
342
- server.registerResource("node-attachment", new ResourceTemplate("intel://nodes/{nodeId}/attachment", { list: undefined }), { title: "Intel node attachment" }, async (uri, variables) => {
344
+ },
345
+ /**
346
+ * ⚠️ The size is checked BEFORE the stream is opened, which the resource handler below cannot
347
+ * do — it is handed the body by the SDK and has to cancel it. Here the metadata read is its
348
+ * own call, so an oversized attachment costs no R2 read at all.
349
+ *
350
+ * ⚠️ The bytes go out as an embedded RESOURCE block, never as text. A base64 string in a text
351
+ * block is something a model will try to read; a resource block is a file to a client that
352
+ * understands one, and the metadata stays in the first block either way (#773).
353
+ */
354
+ async (input) => {
355
+ const metadata = await deps.nodes.getAttachment(actor, input.nodeId);
356
+ if (!input.includeContent)
357
+ return text(metadata);
358
+ if (metadata.version.size > AttachmentInlineLimit) {
359
+ throw new IntelError(413, "attachment_too_large", "Attachment is too large to inline; read it over HTTP instead");
360
+ }
361
+ const { attachment, body } = await deps.nodes.readAttachment(actor, input.nodeId);
362
+ return {
363
+ content: [
364
+ { type: "text", text: JSON.stringify(attachment) },
365
+ {
366
+ type: "resource",
367
+ resource: {
368
+ uri: `intel://nodes/${attachment.node.id}/attachment`,
369
+ mimeType: attachment.version.mediaType,
370
+ blob: await base64(body),
371
+ },
372
+ },
373
+ ],
374
+ isError: false,
375
+ };
376
+ });
377
+ server.registerResource("node-attachment",
378
+ /**
379
+ * ⚠️ `list` is what makes the attachments findable at all, and it was `undefined` until #773.
380
+ * A client that cannot enumerate them has to GUESS the URI — and the one place it could copy
381
+ * one from, `node_attachment_get`, answers this server's own address, which a portal that
382
+ * aggregates several servers prefixes. Measured on 2026-08-24: the URI out of that tool was
383
+ * refused as `Resource not found` while the prefixed spelling delivered the file.
384
+ *
385
+ * ⚠️ The listing is COMPLETE, and the loop below is why it has to be. `ListResourcesCallback`
386
+ * takes `RequestHandlerExtra` and nothing else — the SDK hands a template's list callback no
387
+ * cursor (checked against the 1.30.0 type: `(extra) => ListResourcesResult`), so a
388
+ * `nextCursor` in the answer is one nothing would ever send back. That leaves two honest
389
+ * options and one dishonest one: answer everything, refuse, or cut the list at some limit and
390
+ * let it read as complete. The cut is the one that is out — a reader would never learn which
391
+ * of their files are missing.
392
+ *
393
+ * The paging is therefore INTERNAL: D1 is asked in pages so one statement never has to carry
394
+ * the whole set, and the loop ends when the service stops handing back a cursor.
395
+ */
396
+ new ResourceTemplate("intel://nodes/{nodeId}/attachment", {
397
+ list: async () => {
398
+ const resources = [];
399
+ let after = null;
400
+ do {
401
+ const page = await deps.nodes.listAttachments(actor, { limit: 100, after });
402
+ resources.push(...page.items.map((node) => ({
403
+ uri: `intel://nodes/${node.id}/attachment`,
404
+ name: node.title,
405
+ // ⚠️ No `mimeType`: it lives on the VERSION, so answering one would cost a version
406
+ // read per row. The read itself carries the real one.
407
+ description: node.description ?? undefined,
408
+ })));
409
+ after = page.nextCursor;
410
+ } while (after !== null);
411
+ return { resources };
412
+ },
413
+ }), { title: "Intel node attachment" }, async (uri, variables) => {
343
414
  const { attachment, body } = await deps.nodes.readAttachment(actor, String(variables.nodeId));
344
415
  if (attachment.version.size > AttachmentInlineLimit) {
345
416
  await body.cancel();
@@ -782,6 +782,23 @@ export function createNodes(deps) {
782
782
  throw new IntelError(500, "content_missing", "Attachment is missing");
783
783
  return { attachment: metadata, body };
784
784
  },
785
+ /**
786
+ * One page of readable attachments for the MCP resource listing (#773).
787
+ *
788
+ * ⚠️ It asks the repository for one row MORE than it hands back. Without that, a page that
789
+ * happens to be exactly full is indistinguishable from a page with more behind it, and the
790
+ * choice is between always sending a cursor (one wasted round trip per listing) or never
791
+ * sending one (a silently truncated list, which reads as complete).
792
+ */
793
+ async listAttachments(actor, input) {
794
+ const rows = await deps.repository.listVisibleAttachments(actor, {
795
+ limit: input.limit + 1,
796
+ after: input.after,
797
+ });
798
+ const items = rows.slice(0, input.limit);
799
+ const last = items.at(-1);
800
+ return { items, nextCursor: rows.length > input.limit && last ? last.id : null };
801
+ },
785
802
  async getTable(actor, nodeId) {
786
803
  const node = await requireVisible(actor, nodeId);
787
804
  if (node.kind !== "table") {
@@ -56,6 +56,10 @@ export interface NodeRepository {
56
56
  }): Promise<BoundedChildren>;
57
57
  getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
58
58
  listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
59
+ listVisibleAttachments(actor: Actor, input: {
60
+ limit: number;
61
+ after: string | null;
62
+ }): Promise<Node[]>;
59
63
  can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
60
64
  findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
61
65
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
@@ -325,6 +329,13 @@ export interface NodeService {
325
329
  redefineTable(actor: Actor, input: RedefineTableInput): Promise<NodeTable>;
326
330
  getAttachment(actor: Actor, nodeId: string): Promise<NodeAttachment>;
327
331
  readAttachment(actor: Actor, nodeId: string): Promise<NodeAttachmentBody>;
332
+ listAttachments(actor: Actor, input: {
333
+ limit: number;
334
+ after: string | null;
335
+ }): Promise<{
336
+ items: Node[];
337
+ nextCursor: string | null;
338
+ }>;
328
339
  listVersions(actor: Actor, nodeId: string): Promise<{
329
340
  items: NodeVersion[];
330
341
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -42,8 +42,8 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "dependencies": {
45
- "@anchrd/gate-sdk": "^0.25.0",
46
- "@anchrd/intel-contract": "^0.29.0",
45
+ "@anchrd/gate-sdk": "^0.26.0",
46
+ "@anchrd/intel-contract": "^0.30.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",