@anchrd/intel-api 0.3.1 → 0.3.2

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.
@@ -1,9 +1,9 @@
1
- import type { ArchiveKnowledgeNodeInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, KnowledgeAttachment, KnowledgeCitation, KnowledgeDocument, KnowledgeGraph, KnowledgeGraphInput, KnowledgeLink, KnowledgeNode, KnowledgeVersion, ListKnowledgeNodesInput, ResourceGrant, RevokeKnowledgeGrantInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, UpdateKnowledgeNodeInput } from "@anchrd/intel-contract";
1
+ import type { AppendKnowledgeTableRowsInput, AppendKnowledgeTableRowsResult, ArchiveKnowledgeNodeInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, KnowledgeAttachment, KnowledgeCitation, KnowledgeDocument, KnowledgeGraph, KnowledgeGraphInput, KnowledgeLink, KnowledgeNode, KnowledgeTable, KnowledgeVersion, ListKnowledgeNodesInput, ResolveKnowledgeLinksInput, ResolveKnowledgeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeKnowledgeGrantInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, ShareKnowledgeResult, UpdateKnowledgeNodeInput } from "@anchrd/intel-contract";
2
2
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
3
  export interface Actor {
4
4
  id: string;
5
5
  email: string;
6
- canReindex?: boolean;
6
+ isAdmin?: boolean;
7
7
  }
8
8
  export interface NewKnowledgeNode {
9
9
  node: KnowledgeNode;
@@ -18,16 +18,28 @@ export interface NewKnowledgeVersion {
18
18
  idempotencyKey: string;
19
19
  auditId: string;
20
20
  }
21
+ export interface NewKnowledgeTableVersion {
22
+ version: Omit<KnowledgeVersion, "sequence">;
23
+ actorId: string;
24
+ idempotencyKey: string;
25
+ auditId: string;
26
+ }
21
27
  export interface KnowledgeRepository {
22
28
  listVisible(actor: Actor, input: ListKnowledgeNodesInput): Promise<KnowledgeNode[]>;
29
+ listVisibleBounded(actor: Actor, input: {
30
+ parentId: string | null;
31
+ limit: number;
32
+ }): Promise<BoundedChildren>;
23
33
  getVisible(actor: Actor, nodeId: string): Promise<KnowledgeNode | null>;
24
- canEdit(actor: Actor, nodeId: string): Promise<boolean>;
25
- canManage(actor: Actor, nodeId: string): Promise<boolean>;
26
- findIdempotentNode(actorId: string, operation: "knowledge.create" | "knowledge.save" | "knowledge.update" | "knowledge.archive" | "knowledge.link" | "knowledge.unlink" | "knowledge.share" | "knowledge.revoke", idempotencyKey: string): Promise<string | null>;
34
+ can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
35
+ findIdempotentNode(actorId: string, operation: "knowledge.create" | "knowledge.save" | "knowledge.append" | "knowledge.update" | "knowledge.archive" | "knowledge.share" | "knowledge.revoke", idempotencyKey: string): Promise<string | null>;
27
36
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
28
37
  insertNode(input: NewKnowledgeNode): Promise<KnowledgeNode>;
29
38
  getVersion(versionId: string): Promise<KnowledgeVersion | null>;
30
39
  appendVersion(input: NewKnowledgeVersion): Promise<"saved" | "conflict">;
40
+ appendTableVersion(input: NewKnowledgeTableVersion): Promise<KnowledgeVersion>;
41
+ listVersionContentKeys(nodeId: string): Promise<string[]>;
42
+ firstVersionContentKey(nodeId: string): Promise<string | null>;
31
43
  listVersions(nodeId: string): Promise<KnowledgeVersion[]>;
32
44
  updateNode(input: {
33
45
  node: KnowledgeNode;
@@ -46,23 +58,22 @@ export interface KnowledgeRepository {
46
58
  auditId: string;
47
59
  }): Promise<"conflict" | KnowledgeNode>;
48
60
  listGrants(resourceId: string): Promise<ResourceGrant[]>;
61
+ organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
49
62
  listLinksVisible(actor: Actor, nodeId: string): Promise<KnowledgeLink[]>;
50
- getLinkVisible(actor: Actor, linkId: string): Promise<KnowledgeLink | null>;
51
- findLinkVisible(actor: Actor, input: Pick<CreateKnowledgeLinkInput, "sourceNodeId" | "targetNodeId" | "relation">): Promise<KnowledgeLink | null>;
52
- insertLink(input: {
53
- link: KnowledgeLink;
54
- actorId: string;
55
- idempotencyKey: string;
56
- auditId: string;
57
- }): Promise<KnowledgeLink>;
58
- deleteLink(input: {
63
+ resolveVisibleTitles(actor: Actor, nodeIds: string[]): Promise<Array<{
64
+ nodeId: string;
65
+ title: string;
66
+ }>>;
67
+ replaceTextLinks(input: {
59
68
  sourceNodeId: string;
60
- linkId: string;
69
+ links: Array<{
70
+ id: string;
71
+ targetNodeId: string;
72
+ }>;
61
73
  actorId: string;
62
- idempotencyKey: string;
63
74
  auditId: string;
64
75
  occurredAt: string;
65
- }): Promise<boolean>;
76
+ }): Promise<void>;
66
77
  graphVisible(actor: Actor, input: KnowledgeGraphInput): Promise<KnowledgeGraph>;
67
78
  setGrant(input: {
68
79
  grant: ResourceGrant;
@@ -102,6 +113,11 @@ export interface KnowledgeDeps {
102
113
  content: ContentStore;
103
114
  id(): string;
104
115
  now(): Date;
116
+ externalFlowCallers(actor: Actor, folderId: string): Promise<{
117
+ visible: string[];
118
+ hidden: number;
119
+ }>;
120
+ flowKnowledgeReferences(actor: Actor, folderId: string): Promise<string[]>;
105
121
  hash(content: string | Uint8Array): Promise<string>;
106
122
  indexing: {
107
123
  enqueue(versionId: string): Promise<void>;
@@ -109,15 +125,34 @@ export interface KnowledgeDeps {
109
125
  semantic?: SemanticIndex | undefined;
110
126
  }
111
127
  export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
128
+ export interface BoundedChildren {
129
+ items: KnowledgeNode[];
130
+ total: number;
131
+ }
112
132
  export interface KnowledgeService {
113
133
  list(actor: Actor, input: ListKnowledgeNodesInput): Promise<{
114
134
  items: KnowledgeNode[];
115
135
  }>;
136
+ /**
137
+ * One level of the tree as this actor may see it, bounded: at most `limit` current children, and
138
+ * `total` for how many there are. Its caller is the relation graph, which draws a bounded picture
139
+ * and must not read a folder of a thousand documents to do it (#30).
140
+ *
141
+ * ⚠️ `total` counts what passed the visibility predicate and nothing else, or the size a picture
142
+ * reports would become a way of learning that something is filed here.
143
+ */
144
+ childrenBounded(actor: Actor, input: {
145
+ parentId: string | null;
146
+ limit: number;
147
+ }): Promise<BoundedChildren>;
116
148
  get(actor: Actor, nodeId: string): Promise<KnowledgeDocument>;
117
149
  folderAccess(actor: Actor, folderId: string): Promise<FolderAccess>;
118
150
  create(actor: Actor, input: CreateKnowledgeNodeInput): Promise<KnowledgeNode>;
119
151
  save(actor: Actor, input: SaveKnowledgeVersionInput): Promise<KnowledgeDocument>;
120
152
  saveAttachment(actor: Actor, input: SaveKnowledgeAttachmentInput): Promise<KnowledgeDocument>;
153
+ getTable(actor: Actor, nodeId: string): Promise<KnowledgeTable>;
154
+ defineTable(actor: Actor, input: DefineKnowledgeTableInput): Promise<KnowledgeTable>;
155
+ appendTableRows(actor: Actor, input: AppendKnowledgeTableRowsInput): Promise<AppendKnowledgeTableRowsResult>;
121
156
  getAttachment(actor: Actor, nodeId: string): Promise<KnowledgeAttachment>;
122
157
  readAttachment(actor: Actor, nodeId: string): Promise<KnowledgeAttachmentBody>;
123
158
  listVersions(actor: Actor, nodeId: string): Promise<{
@@ -125,18 +160,14 @@ export interface KnowledgeService {
125
160
  }>;
126
161
  update(actor: Actor, input: UpdateKnowledgeNodeInput): Promise<KnowledgeNode>;
127
162
  archive(actor: Actor, input: ArchiveKnowledgeNodeInput): Promise<KnowledgeNode>;
128
- listGrants(actor: Actor, resourceId: string): Promise<{
129
- items: ResourceGrant[];
130
- }>;
163
+ listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
131
164
  listLinks(actor: Actor, nodeId: string): Promise<{
132
165
  items: KnowledgeLink[];
133
166
  }>;
134
- createLink(actor: Actor, input: CreateKnowledgeLinkInput): Promise<KnowledgeLink>;
135
- deleteLink(actor: Actor, input: DeleteKnowledgeLinkInput): Promise<{
136
- deleted: boolean;
137
- }>;
167
+ resolveLinks(actor: Actor, input: ResolveKnowledgeLinksInput): Promise<ResolveKnowledgeLinksResult>;
138
168
  graph(actor: Actor, input: KnowledgeGraphInput): Promise<KnowledgeGraph>;
139
- share(actor: Actor, input: ShareKnowledgeInput): Promise<ResourceGrant>;
169
+ visibleNode(actor: Actor, nodeId: string): Promise<KnowledgeNode | null>;
170
+ share(actor: Actor, input: ShareKnowledgeInput): Promise<ShareKnowledgeResult>;
140
171
  revokeGrant(actor: Actor, input: RevokeKnowledgeGrantInput): Promise<{
141
172
  revoked: boolean;
142
173
  }>;
@@ -151,9 +182,9 @@ export interface KnowledgeIndexTarget {
151
182
  nodeId: string;
152
183
  versionId: string;
153
184
  title: string;
154
- contentKey: string;
185
+ contentKeys: string[];
155
186
  mediaType: string;
156
- kind: "document" | "attachment";
187
+ kind: "document" | "attachment" | "table";
157
188
  updatedAt: string;
158
189
  }
159
190
  export interface KnowledgeIndexRepository {
package/dist/mcp/mcp.js CHANGED
@@ -1,7 +1,8 @@
1
- import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, KnowledgeGraphInput, ListFlowGrantsInput, ListFlowsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
1
+ import { AppendKnowledgeTableRowsInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, GetKnowledgeTableInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } 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";
5
+ import { permits } from "../shared/gate-authorization/gate-authorization.js";
5
6
  import { IntelError } from "../shared/intel-error/intel-error.js";
6
7
  function text(value) {
7
8
  return { content: [{ type: "text", text: JSON.stringify(value) }], isError: false };
@@ -27,25 +28,26 @@ export async function handleMcp(request, deps) {
27
28
  const actor = {
28
29
  id: deps.authorization.identity.id,
29
30
  email: deps.authorization.identity.email,
30
- canReindex: deps.authorization.can("intel", "admin"),
31
+ isAdmin: deps.authorization.can("intel", "admin"),
31
32
  };
32
33
  const toolActor = {
33
34
  id: deps.authorization.identity.id,
34
35
  email: deps.authorization.identity.email,
35
- canExecute: deps.authorization.can("tools", "execute"),
36
+ canExecute: permits(deps.authorization, "tools", "execute"),
36
37
  };
37
38
  const flowActor = {
38
39
  id: deps.authorization.identity.id,
39
40
  email: deps.authorization.identity.email,
40
- canRun: deps.authorization.can("flows", "run"),
41
- canApprove: deps.authorization.can("flows", "approve"),
41
+ canRun: permits(deps.authorization, "flows", "run"),
42
+ canApprove: permits(deps.authorization, "flows", "approve"),
43
+ isAdmin: deps.authorization.can("intel", "admin"),
42
44
  };
43
45
  const server = new McpServer({ name: "intel", version: "0.1.0" });
44
46
  const EmptyInput = z.strictObject({});
45
- if (deps.authorization.can("knowledge", "read")) {
47
+ if (permits(deps.authorization, "knowledge", "read")) {
46
48
  server.registerTool("knowledge_list", {
47
49
  title: "List knowledge",
48
- description: "List authorized folders, documents, and attachments under one parent.",
50
+ description: "List authorized folders, documents, attachments, and tables under one parent.",
49
51
  inputSchema: ListKnowledgeNodesInput,
50
52
  annotations: {
51
53
  title: "List knowledge",
@@ -127,6 +129,18 @@ export async function handleMcp(request, deps) {
127
129
  openWorldHint: false,
128
130
  },
129
131
  }, async (input) => text(await deps.knowledge.search(actor, input)));
132
+ server.registerTool("knowledge_table_get", {
133
+ title: "Get knowledge table",
134
+ description: "Read one authorized table as column names and rows.",
135
+ inputSchema: GetKnowledgeTableInput,
136
+ annotations: {
137
+ title: "Get knowledge table",
138
+ readOnlyHint: true,
139
+ destructiveHint: false,
140
+ idempotentHint: true,
141
+ openWorldHint: false,
142
+ },
143
+ }, async (input) => text(await deps.knowledge.getTable(actor, input.nodeId)));
130
144
  server.registerTool("knowledge_links_list", {
131
145
  title: "List knowledge links",
132
146
  description: "List authorized outgoing links and backlinks for one Knowledge node.",
@@ -139,6 +153,25 @@ export async function handleMcp(request, deps) {
139
153
  openWorldHint: false,
140
154
  },
141
155
  }, async (input) => text(await deps.knowledge.listLinks(actor, input.nodeId)));
156
+ // The reader's half of a document link (#41): the titles of the linked documents this caller
157
+ // may see. There is no tool to create or delete a link — a relationship is written in the text
158
+ // and `knowledge_save` is what records it, so there is only one way to make one.
159
+ //
160
+ // ⚠️ A target this caller may not reach, or one that is gone, is simply absent from the answer.
161
+ // The two are indistinguishable on purpose: telling them apart would confirm that a document
162
+ // exists somewhere they cannot look.
163
+ server.registerTool("knowledge_links_resolve", {
164
+ title: "Resolve knowledge links",
165
+ 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.",
166
+ inputSchema: ResolveKnowledgeLinksInput,
167
+ annotations: {
168
+ title: "Resolve knowledge links",
169
+ readOnlyHint: true,
170
+ destructiveHint: false,
171
+ idempotentHint: true,
172
+ openWorldHint: false,
173
+ },
174
+ }, async (input) => text(await deps.knowledge.resolveLinks(actor, input)));
142
175
  server.registerTool("knowledge_graph", {
143
176
  title: "Get knowledge graph",
144
177
  description: "Get the authorized Knowledge nodes and explicit relationships for discovery.",
@@ -166,10 +199,10 @@ export async function handleMcp(request, deps) {
166
199
  },
167
200
  }, async () => text(await deps.knowledge.reindex(actor)));
168
201
  }
169
- if (deps.authorization.can("knowledge", "create")) {
202
+ if (permits(deps.authorization, "knowledge", "create")) {
170
203
  server.registerTool("knowledge_create", {
171
204
  title: "Create knowledge",
172
- description: "Create a governed folder, document, or attachment metadata node.",
205
+ description: "Create a governed folder, document, attachment, or table node.",
173
206
  inputSchema: CreateKnowledgeNodeInput,
174
207
  annotations: {
175
208
  title: "Create knowledge",
@@ -180,7 +213,7 @@ export async function handleMcp(request, deps) {
180
213
  },
181
214
  }, async (input) => text(await deps.knowledge.create(actor, input)));
182
215
  }
183
- if (deps.authorization.can("knowledge", "write")) {
216
+ if (permits(deps.authorization, "knowledge", "write")) {
184
217
  server.registerTool("knowledge_save", {
185
218
  title: "Save knowledge version",
186
219
  description: "Append an immutable content version using an optimistic base version.",
@@ -205,6 +238,34 @@ export async function handleMcp(request, deps) {
205
238
  openWorldHint: false,
206
239
  },
207
240
  }, async (input) => text(await deps.knowledge.saveAttachment(actor, input)));
241
+ server.registerTool("knowledge_table_define", {
242
+ title: "Define knowledge table columns",
243
+ description: "Write the column names of an empty table. The header is the contract every append is checked against and cannot be rewritten.",
244
+ inputSchema: DefineKnowledgeTableInput,
245
+ annotations: {
246
+ title: "Define knowledge table columns",
247
+ readOnlyHint: false,
248
+ destructiveHint: false,
249
+ idempotentHint: true,
250
+ openWorldHint: false,
251
+ },
252
+ }, async (input) => text(await deps.knowledge.defineTable(actor, input)));
253
+ // The tool #40 exists for: an agent collecting findings on a schedule appends them without
254
+ // reading or resending what is already there, and two agents appending at once lose nothing.
255
+ server.registerTool("knowledge_table_append", {
256
+ title: "Append knowledge table rows",
257
+ 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.",
258
+ inputSchema: AppendKnowledgeTableRowsInput,
259
+ annotations: {
260
+ title: "Append knowledge table rows",
261
+ readOnlyHint: false,
262
+ destructiveHint: false,
263
+ // The idempotency key makes a repeat of the same call a no-op; without one, appending
264
+ // twice appends twice, which is what appending means.
265
+ idempotentHint: true,
266
+ openWorldHint: false,
267
+ },
268
+ }, async (input) => text(await deps.knowledge.appendTableRows(actor, input)));
208
269
  server.registerTool("knowledge_update", {
209
270
  title: "Update knowledge",
210
271
  description: "Rename, move, describe, or change the context policy of Knowledge.",
@@ -229,32 +290,8 @@ export async function handleMcp(request, deps) {
229
290
  openWorldHint: false,
230
291
  },
231
292
  }, async (input) => text(await deps.knowledge.archive(actor, input)));
232
- server.registerTool("knowledge_link_create", {
233
- title: "Link knowledge",
234
- description: "Create a governed relationship between two authorized Knowledge nodes.",
235
- inputSchema: CreateKnowledgeLinkInput,
236
- annotations: {
237
- title: "Link knowledge",
238
- readOnlyHint: false,
239
- destructiveHint: false,
240
- idempotentHint: true,
241
- openWorldHint: false,
242
- },
243
- }, async (input) => text(await deps.knowledge.createLink(actor, input)));
244
- server.registerTool("knowledge_link_delete", {
245
- title: "Delete knowledge link",
246
- description: "Delete one governed Knowledge relationship by ID.",
247
- inputSchema: DeleteKnowledgeLinkInput,
248
- annotations: {
249
- title: "Delete knowledge link",
250
- readOnlyHint: false,
251
- destructiveHint: true,
252
- idempotentHint: true,
253
- openWorldHint: false,
254
- },
255
- }, async (input) => text(await deps.knowledge.deleteLink(actor, input)));
256
293
  }
257
- if (deps.authorization.can("knowledge", "share")) {
294
+ if (permits(deps.authorization, "knowledge", "share")) {
258
295
  server.registerTool("knowledge_shares_list", {
259
296
  title: "List knowledge shares",
260
297
  description: "List direct grants for Knowledge the caller is allowed to manage.",
@@ -292,7 +329,7 @@ export async function handleMcp(request, deps) {
292
329
  },
293
330
  }, async (input) => text(await deps.knowledge.revokeGrant(actor, input)));
294
331
  }
295
- if (deps.authorization.can("flows", "read")) {
332
+ if (permits(deps.authorization, "flows", "read")) {
296
333
  server.registerTool("flows_list", {
297
334
  title: "List flows",
298
335
  description: "List authorized flows, either all of them or the ones filed in one folder of the shared Knowledge tree.",
@@ -317,6 +354,45 @@ export async function handleMcp(request, deps) {
317
354
  openWorldHint: false,
318
355
  },
319
356
  }, async (input) => text(await deps.flows.get(flowActor, input.flowId)));
357
+ server.registerTool("flow_calls_list", {
358
+ title: "List called flows",
359
+ description: "List the flows one flow calls, read out of its graph rather than out of where it is filed.",
360
+ inputSchema: GetFlowInput,
361
+ annotations: {
362
+ title: "List called flows",
363
+ readOnlyHint: true,
364
+ destructiveHint: false,
365
+ idempotentHint: true,
366
+ openWorldHint: false,
367
+ },
368
+ }, async (input) => text(await deps.flows.listCalls(flowActor, input.flowId)));
369
+ // ⚠️ Only nodes the requesting user may see are in the answer, placeholders included: an edge to
370
+ // a grey box would already say that something is there. The same rule the screen follows,
371
+ // because it is the same service (#19).
372
+ server.registerTool("flow_relation_graph", {
373
+ title: "Read the relation graph",
374
+ description: "Read what accesses what for one folder or one flow: which flow reads which document and which flow calls which flow.",
375
+ inputSchema: RelationGraphInput,
376
+ annotations: {
377
+ title: "Read the relation graph",
378
+ readOnlyHint: true,
379
+ destructiveHint: false,
380
+ idempotentHint: true,
381
+ openWorldHint: false,
382
+ },
383
+ }, async (input) => text(await deps.flows.relationGraph(flowActor, input)));
384
+ server.registerTool("flow_requirements_list", {
385
+ title: "List what a flow needs",
386
+ description: "List the Knowledge documents and MCP tools one flow's graph names. Documents the calling user cannot see are counted rather than named, and no claim is made about whether anyone may reach them: for tools that cannot be known in advance, because the catalog is a live query with each user's own portal token.",
387
+ inputSchema: GetFlowInput,
388
+ annotations: {
389
+ title: "List what a flow needs",
390
+ readOnlyHint: true,
391
+ destructiveHint: false,
392
+ idempotentHint: true,
393
+ openWorldHint: false,
394
+ },
395
+ }, async (input) => text(await deps.flows.listRequirements(flowActor, input.flowId)));
320
396
  server.registerTool("flow_run_get", {
321
397
  title: "Get flow run",
322
398
  description: "Get durable run state and the next node an AI or person must handle.",
@@ -329,46 +405,37 @@ export async function handleMcp(request, deps) {
329
405
  openWorldHint: false,
330
406
  },
331
407
  }, async (input) => text(await deps.flows.getRun(flowActor, input.runId)));
332
- }
333
- if (deps.authorization.can("flows", "share")) {
334
- server.registerTool("flow_shares_list", {
335
- title: "List flow shares",
336
- description: "List direct grants for a Flow the caller is allowed to manage.",
337
- inputSchema: ListFlowGrantsInput,
338
- annotations: {
339
- title: "List flow shares",
408
+ // ⚠️ Model context like every other tool result: what a run did, never what it produced. The run
409
+ // input and output are absent because a run reaches Knowledge and tools with the rights of
410
+ // whoever started it, and only their own runs are the calling user's to read out that way.
411
+ server.registerTool("flow_runs_list", {
412
+ title: "List flow runs",
413
+ description: "List the runs of one flow, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.",
414
+ inputSchema: ListFlowRunsInput,
415
+ annotations: {
416
+ title: "List flow runs",
340
417
  readOnlyHint: true,
341
418
  destructiveHint: false,
342
419
  idempotentHint: true,
343
420
  openWorldHint: false,
344
421
  },
345
- }, async (input) => text(await deps.flows.listGrants(flowActor, input.resourceId)));
346
- server.registerTool("flow_share", {
347
- title: "Share flow",
348
- description: "Grant Flow access to a Gate user, verified email, or the organization.",
349
- inputSchema: ShareFlowInput,
422
+ }, async (input) => text(await deps.flows.listRuns(flowActor, input)));
423
+ server.registerTool("flow_run_steps_list", {
424
+ title: "List flow run steps",
425
+ description: "List every completed step of one run, oldest first, with its outcome, branch, and the reason a failed step gives, plus the call chain the run belongs to.",
426
+ inputSchema: GetFlowRunInput,
350
427
  annotations: {
351
- title: "Share flow",
352
- readOnlyHint: false,
428
+ title: "List flow run steps",
429
+ readOnlyHint: true,
353
430
  destructiveHint: false,
354
431
  idempotentHint: true,
355
432
  openWorldHint: false,
356
433
  },
357
- }, async (input) => text(await deps.flows.share(flowActor, input)));
358
- server.registerTool("flow_revoke_share", {
359
- title: "Revoke flow share",
360
- description: "Revoke one direct Flow grant by ID.",
361
- inputSchema: RevokeFlowGrantInput,
362
- annotations: {
363
- title: "Revoke flow share",
364
- readOnlyHint: false,
365
- destructiveHint: true,
366
- idempotentHint: true,
367
- openWorldHint: false,
368
- },
369
- }, async (input) => text(await deps.flows.revokeGrant(flowActor, input)));
434
+ }, async (input) => text(await deps.flows.listRunSteps(flowActor, input.runId)));
370
435
  }
371
- if (deps.authorization.can("flows", "create")) {
436
+ // A flow has no share tool of its own. Sharing happens on the folder a flow is filed in, through
437
+ // knowledge_share, so a narrower grant cannot sit beside the folder grant (ADR-0004 §2).
438
+ if (permits(deps.authorization, "flows", "create")) {
372
439
  server.registerTool("flow_create", {
373
440
  title: "Create flow",
374
441
  description: "Create governed flow metadata before adding an immutable graph version.",
@@ -382,7 +449,7 @@ export async function handleMcp(request, deps) {
382
449
  },
383
450
  }, async (input) => text(await deps.flows.create(flowActor, input)));
384
451
  }
385
- if (deps.authorization.can("flows", "write")) {
452
+ if (permits(deps.authorization, "flows", "write")) {
386
453
  server.registerTool("flow_update", {
387
454
  title: "Rename or move flow",
388
455
  description: "Rename a flow, change its description, or move it to another folder of the shared Knowledge tree. Never changes what the flow does.",
@@ -408,7 +475,22 @@ export async function handleMcp(request, deps) {
408
475
  },
409
476
  }, async (input) => text(await deps.flows.save(flowActor, input)));
410
477
  }
411
- if (deps.authorization.can("flows", "publish")) {
478
+ if (permits(deps.authorization, "flows", "publish")) {
479
+ // The same answer the screen shows the author before publishing. Every user-visible capability
480
+ // has its MCP equivalent, and a freeze nobody could inspect from here would be one behavior
481
+ // through two doors.
482
+ server.registerTool("flow_publish_preview", {
483
+ title: "Preview flow publication",
484
+ description: "Show which version each sub-flow call will take and which of them publishing freezes.",
485
+ inputSchema: PreviewFlowPublishInput,
486
+ annotations: {
487
+ title: "Preview flow publication",
488
+ readOnlyHint: true,
489
+ destructiveHint: false,
490
+ idempotentHint: true,
491
+ openWorldHint: false,
492
+ },
493
+ }, async (input) => text(await deps.flows.previewPublish(flowActor, input)));
412
494
  server.registerTool("flow_publish", {
413
495
  title: "Publish flow",
414
496
  description: "Publish a version after resolving its authorized Knowledge and tool references.",
@@ -422,7 +504,7 @@ export async function handleMcp(request, deps) {
422
504
  },
423
505
  }, async (input) => text(await deps.flows.publish(flowActor, input)));
424
506
  }
425
- if (deps.authorization.can("flows", "run")) {
507
+ if (permits(deps.authorization, "flows", "run")) {
426
508
  server.registerTool("flow_run_start", {
427
509
  title: "Start flow run",
428
510
  description: "Start a durable run of the published graph and return its first actionable node.",
@@ -448,7 +530,7 @@ export async function handleMcp(request, deps) {
448
530
  },
449
531
  }, async (input) => text(await deps.flows.completeStep(flowActor, input)));
450
532
  }
451
- if (deps.authorization.can("tools", "read")) {
533
+ if (permits(deps.authorization, "tools", "read")) {
452
534
  server.registerTool("tools_catalog_list", {
453
535
  title: "List tools",
454
536
  description: "List the MCP tools the calling user can reach through the portal, live rather than cached.",
@@ -462,7 +544,7 @@ export async function handleMcp(request, deps) {
462
544
  },
463
545
  }, async () => text(await deps.tools.catalog(toolActor)));
464
546
  }
465
- if (deps.authorization.can("tools", "test")) {
547
+ if (permits(deps.authorization, "tools", "test")) {
466
548
  server.registerTool("tools_test", {
467
549
  title: "Test tool",
468
550
  description: "Validate input and execute one explicitly read-only cached MCP tool with the caller's Gate connection.",
@@ -476,7 +558,7 @@ export async function handleMcp(request, deps) {
476
558
  },
477
559
  }, async (input) => text(await deps.tools.test(toolActor, input)));
478
560
  }
479
- if (deps.authorization.can("tools", "execute")) {
561
+ if (permits(deps.authorization, "tools", "execute")) {
480
562
  server.registerTool("tools_execute", {
481
563
  title: "Execute tool",
482
564
  description: "Validate and execute a discovered MCP tool with the caller's just-in-time Gate connection.",
@@ -0,0 +1,13 @@
1
+ export declare function encodeCsvRow(cells: readonly string[]): string;
2
+ export declare function encodeCsv(rows: readonly (readonly string[])[]): string;
3
+ /**
4
+ * Every row of a CSV body, quoted fields included.
5
+ *
6
+ * ⚠️ A quoted field may contain a line break, so this cannot split on `\n` first. A row is closed
7
+ * by a line break that falls outside quotes and by nothing else — the reason a naive `split` reads
8
+ * a table with one multi-line cell as two broken rows.
9
+ *
10
+ * A trailing newline does not produce an empty final row; an empty line in the middle does produce
11
+ * a row with one empty cell, because that is what the file says.
12
+ */
13
+ export declare function parseCsv(text: string): string[][];
@@ -0,0 +1,85 @@
1
+ // The one CSV reader and writer in Intel. A table's canonical body is CSV (#40), and every surface
2
+ // that has to look inside one — the grid, the column check, the search index — goes through here so
3
+ // none of them can grow a private opinion about quoting.
4
+ //
5
+ // RFC 4180 with two deliberate narrowings: rows are separated by `\n` when written (a reader
6
+ // accepts `\r\n` too), and a field is quoted only when it has to be. Nothing else is escaped,
7
+ // because CSV has no escapes — a quote inside a quoted field is written twice.
8
+ function needsQuoting(cell) {
9
+ return (cell.includes(",") ||
10
+ cell.includes('"') ||
11
+ cell.includes("\n") ||
12
+ cell.includes("\r") ||
13
+ cell !== cell.trim());
14
+ }
15
+ export function encodeCsvRow(cells) {
16
+ return cells
17
+ .map((cell) => (needsQuoting(cell) ? `"${cell.replaceAll('"', '""')}"` : cell))
18
+ .join(",");
19
+ }
20
+ export function encodeCsv(rows) {
21
+ return rows.length === 0 ? "" : `${rows.map(encodeCsvRow).join("\n")}\n`;
22
+ }
23
+ /**
24
+ * Every row of a CSV body, quoted fields included.
25
+ *
26
+ * ⚠️ A quoted field may contain a line break, so this cannot split on `\n` first. A row is closed
27
+ * by a line break that falls outside quotes and by nothing else — the reason a naive `split` reads
28
+ * a table with one multi-line cell as two broken rows.
29
+ *
30
+ * A trailing newline does not produce an empty final row; an empty line in the middle does produce
31
+ * a row with one empty cell, because that is what the file says.
32
+ */
33
+ export function parseCsv(text) {
34
+ const rows = [];
35
+ let row = [];
36
+ let cell = "";
37
+ let quoted = false;
38
+ let started = false;
39
+ for (let index = 0; index < text.length; index += 1) {
40
+ const character = text[index];
41
+ if (quoted) {
42
+ if (character === '"') {
43
+ if (text[index + 1] === '"') {
44
+ cell += '"';
45
+ index += 1;
46
+ }
47
+ else {
48
+ quoted = false;
49
+ }
50
+ }
51
+ else {
52
+ cell += character;
53
+ }
54
+ continue;
55
+ }
56
+ if (character === '"' && cell === "") {
57
+ quoted = true;
58
+ started = true;
59
+ continue;
60
+ }
61
+ if (character === ",") {
62
+ row.push(cell);
63
+ cell = "";
64
+ started = true;
65
+ continue;
66
+ }
67
+ if (character === "\n" || character === "\r") {
68
+ if (character === "\r" && text[index + 1] === "\n")
69
+ index += 1;
70
+ row.push(cell);
71
+ rows.push(row);
72
+ row = [];
73
+ cell = "";
74
+ started = false;
75
+ continue;
76
+ }
77
+ cell += character;
78
+ started = true;
79
+ }
80
+ if (started || cell !== "" || row.length > 0) {
81
+ row.push(cell);
82
+ rows.push(row);
83
+ }
84
+ return rows;
85
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Authorized, GateClient } from "@anchrd/gate-sdk";
2
2
  export declare function bearer(headers: Headers): string | null;
3
3
  export declare function authorize(gate: Pick<GateClient, "authorize">, headers: Headers, resource: string): Promise<Authorized | null>;
4
+ export declare function permits(authorization: Pick<Authorized, "can">, handle: string, fn: string): boolean;
4
5
  export declare function authorizeBearer(gate: Pick<GateClient, "authorize">, token: string, resource: string): Promise<Authorized | null>;
@@ -13,6 +13,13 @@ export async function authorize(gate, headers, resource) {
13
13
  return null;
14
14
  return await authorizeBearer(gate, token, resource);
15
15
  }
16
+ // `intel/admin` opens every door, and it has to: `share` inherits downwards only, so without a way
17
+ // in that needs no grant, a fresh instance has nobody who can set the first one (ADR-0004 §6). Gate
18
+ // learned the same lesson at three separate doors — a guard written per route is a list that goes
19
+ // quietly incomplete at the next one, so both surfaces ask this one function instead.
20
+ export function permits(authorization, handle, fn) {
21
+ return authorization.can(handle, fn) || authorization.can("intel", "admin");
22
+ }
16
23
  export async function authorizeBearer(gate, token, resource) {
17
24
  const result = await gate.authorize(token);
18
25
  return result.ok && result.resource === resource ? result : null;