@anchrd/intel-api 0.2.1 → 0.3.1

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.
@@ -143,6 +143,7 @@ export default {
143
143
  throw error;
144
144
  }
145
145
  },
146
+ folderAccess: async (actor, folderId) => await knowledge.folderAccess(actor, folderId),
146
147
  toolFingerprint: async (actor, toolName) => {
147
148
  const catalog = await tools
148
149
  .catalog({ id: actor.id, email: actor.email, canExecute: true })
@@ -1,5 +1,5 @@
1
1
  import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
2
- const flowColumns = `id, title, description, owner_id, current_version_id,
2
+ const flowColumns = `id, parent_id, title, description, owner_id, current_version_id,
3
3
  published_version_id, created_at, updated_at, archived_at`;
4
4
  const runColumnNames = [
5
5
  "id",
@@ -39,6 +39,7 @@ function accessBindings(actor, now, roles = []) {
39
39
  function mapFlow(row) {
40
40
  return Flow.parse({
41
41
  id: row.id,
42
+ parentId: row.parent_id,
42
43
  title: row.title,
43
44
  description: row.description,
44
45
  ownerId: row.owner_id,
@@ -103,12 +104,20 @@ function mapGrant(row) {
103
104
  export function createFlowRepository(deps) {
104
105
  const visible = accessPredicate();
105
106
  return {
106
- async listVisible(actor) {
107
+ async listVisible(actor, input = {}) {
108
+ // An absent `parentId` asks for every visible flow; `null` asks for the root of the shared
109
+ // tree. `IS ?` would collapse the two, so the two cases are separate SQL rather than one
110
+ // binding that silently means both.
111
+ const scope = input.parentId === undefined
112
+ ? { clause: "", bindings: [] }
113
+ : input.parentId === null
114
+ ? { clause: "AND flow.parent_id IS NULL", bindings: [] }
115
+ : { clause: "AND flow.parent_id = ?", bindings: [input.parentId] };
107
116
  const result = await deps.db
108
117
  .prepare(`SELECT ${flowColumns} FROM flows flow
109
- WHERE ${visible} AND flow.archived_at IS NULL
118
+ WHERE ${visible} AND flow.archived_at IS NULL ${scope.clause}
110
119
  ORDER BY lower(flow.title), flow.id`)
111
- .bind(...accessBindings(actor, deps.now().toISOString()))
120
+ .bind(...accessBindings(actor, deps.now().toISOString()), ...scope.bindings)
112
121
  .all();
113
122
  return (result.results ?? []).map(mapFlow);
114
123
  },
@@ -159,10 +168,10 @@ export function createFlowRepository(deps) {
159
168
  await deps.db.batch([
160
169
  deps.db
161
170
  .prepare(`INSERT INTO flows (
162
- id, title, description, owner_id, current_version_id, published_version_id,
171
+ id, parent_id, title, description, owner_id, current_version_id, published_version_id,
163
172
  created_at, updated_at, archived_at
164
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
165
- .bind(flow.id, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
173
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
174
+ .bind(flow.id, flow.parentId, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
166
175
  deps.db
167
176
  .prepare(`INSERT INTO idempotency_keys (
168
177
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -189,6 +198,52 @@ export function createFlowRepository(deps) {
189
198
  throw error;
190
199
  }
191
200
  },
201
+ // Renaming and moving touch the flow row and nothing else. `current_version_id` and
202
+ // `published_version_id` are deliberately absent from the SET list: organization must not be
203
+ // able to change what a flow does (ADR-0004).
204
+ async updateFlow(input) {
205
+ const flow = input.flow;
206
+ try {
207
+ await deps.db.batch([
208
+ deps.db
209
+ .prepare(`UPDATE flows SET parent_id = ?, title = ?, description = ?, updated_at = ?
210
+ WHERE id = ? AND updated_at = ?`)
211
+ .bind(flow.parentId, flow.title, flow.description, flow.updatedAt, flow.id, input.baseUpdatedAt),
212
+ deps.db
213
+ .prepare(`INSERT INTO idempotency_keys (
214
+ actor_id, operation, idempotency_key, resource_id, created_at
215
+ ) SELECT ?, 'flows.update', ?, ?, ?
216
+ WHERE EXISTS (
217
+ SELECT 1 FROM flows
218
+ WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ? AND updated_at = ?
219
+ )`)
220
+ .bind(input.actorId, input.idempotencyKey, flow.id, flow.updatedAt, flow.id, flow.parentId, flow.title, flow.description, flow.updatedAt),
221
+ deps.db
222
+ .prepare(`INSERT INTO audit_events (
223
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
224
+ ) SELECT ?, ?, 'flows.update', 'flow', ?, ?, ?
225
+ WHERE EXISTS (
226
+ SELECT 1 FROM idempotency_keys
227
+ WHERE actor_id = ? AND operation = 'flows.update'
228
+ AND idempotency_key = ? AND resource_id = ?
229
+ )`)
230
+ .bind(input.auditId, input.actorId, flow.id, JSON.stringify({ parentId: flow.parentId, title: flow.title }), flow.updatedAt, input.actorId, input.idempotencyKey, flow.id),
231
+ ]);
232
+ }
233
+ catch (error) {
234
+ if (!(await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey))) {
235
+ throw error;
236
+ }
237
+ }
238
+ const replayed = await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey);
239
+ if (!replayed)
240
+ return "conflict";
241
+ const row = await deps.db
242
+ .prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
243
+ .bind(replayed)
244
+ .first();
245
+ return row ? mapFlow(row) : "conflict";
246
+ },
192
247
  async getVersion(versionId) {
193
248
  const row = await deps.db
194
249
  .prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
package/dist/cli/cli.js CHANGED
@@ -8,7 +8,10 @@ const interfaces = [
8
8
  functions: ["read", "create", "write", "publish", "run", "approve", "share"],
9
9
  },
10
10
  { handle: "tools", functions: ["read", "test", "execute", "admin"] },
11
- { handle: "intel-tools", functions: ["connect"] },
11
+ // `mcp:connect` heisst bei jedem MCP-Dienst dieser Installation gleich. Ein eigener Name fuer
12
+ // dieselbe Sache zwingt den Betreiber, pro Dienst nachzusehen, welches Recht den Portal-Zugang
13
+ // traegt.
14
+ { handle: "mcp", functions: ["connect"] },
12
15
  ];
13
16
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
14
17
  intel prepare Copy versioned D1 migrations to .intel/migrations
@@ -103,6 +103,23 @@ export function createFlows(deps) {
103
103
  }
104
104
  return flow;
105
105
  }
106
+ // A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
107
+ // second permission model here. `null` is the root and needs no permission of its own — the same
108
+ // as creating a folder at the root does.
109
+ async function requireFolder(actor, parentId) {
110
+ if (parentId === null)
111
+ return;
112
+ const access = await deps.folderAccess(actor, parentId);
113
+ if (access === "missing") {
114
+ throw new IntelError(404, "flow_parent_not_found", "Parent folder was not found");
115
+ }
116
+ if (access === "not-a-folder") {
117
+ throw new IntelError(409, "parent_not_folder", "A flow's parent must be a folder");
118
+ }
119
+ if (access === "forbidden") {
120
+ throw new IntelError(403, "knowledge_forbidden", "Destination folder cannot be edited");
121
+ }
122
+ }
106
123
  async function requireVersion(versionId, flowId) {
107
124
  const version = await deps.repository.getVersion(versionId);
108
125
  if (!version || (flowId && version.flowId !== flowId)) {
@@ -116,8 +133,8 @@ export function createFlows(deps) {
116
133
  return { run, node: nodeFor(version, run.currentNodeId) };
117
134
  }
118
135
  return {
119
- async list(actor) {
120
- return { items: await deps.repository.listVisible(actor) };
136
+ async list(actor, input = {}) {
137
+ return { items: await deps.repository.listVisible(actor, input) };
121
138
  },
122
139
  async get(actor, flowId) {
123
140
  const flow = await requireFlow(actor, flowId);
@@ -132,10 +149,12 @@ export function createFlows(deps) {
132
149
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
133
150
  if (replayed)
134
151
  return await requireFlow(actor, replayed);
152
+ await requireFolder(actor, input.parentId);
135
153
  const occurredAt = deps.now().toISOString();
136
154
  return await deps.repository.insertFlow({
137
155
  flow: {
138
156
  id: deps.id(),
157
+ parentId: input.parentId,
139
158
  title: input.title,
140
159
  description: input.description,
141
160
  ownerId: actor.id,
@@ -150,6 +169,34 @@ export function createFlows(deps) {
150
169
  auditId: deps.id(),
151
170
  });
152
171
  },
172
+ // Rename and move. It reads and writes the flow row alone: no version is appended, no published
173
+ // version is touched and no run is signalled, so tidying up the tree cannot change what a flow
174
+ // does (ADR-0004).
175
+ async update(actor, input) {
176
+ const current = await requireEdit(actor, input.flowId);
177
+ const replayed = await deps.repository.findIdempotent(actor.id, "flows.update", input.idempotencyKey);
178
+ if (replayed)
179
+ return await requireFlow(actor, replayed);
180
+ if (input.parentId !== undefined)
181
+ await requireFolder(actor, input.parentId);
182
+ const updated = await deps.repository.updateFlow({
183
+ flow: {
184
+ ...current,
185
+ parentId: input.parentId === undefined ? current.parentId : input.parentId,
186
+ title: input.title ?? current.title,
187
+ description: input.description === undefined ? current.description : input.description,
188
+ updatedAt: deps.now().toISOString(),
189
+ },
190
+ baseUpdatedAt: input.baseUpdatedAt,
191
+ actorId: actor.id,
192
+ idempotencyKey: input.idempotencyKey,
193
+ auditId: deps.id(),
194
+ });
195
+ if (updated === "conflict") {
196
+ throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
197
+ }
198
+ return updated;
199
+ },
153
200
  async save(actor, input) {
154
201
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
155
202
  if (replayed) {
@@ -1,13 +1,13 @@
1
- import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, PublishFlowInput, ResourceGrant, RevokeFlowGrantInput, SaveFlowVersionInput, ShareFlowInput, StartFlowRunInput } from "@anchrd/intel-contract";
1
+ import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, ListFlowsInput, PublishFlowInput, ResourceGrant, RevokeFlowGrantInput, SaveFlowVersionInput, ShareFlowInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
2
2
  export interface FlowActor {
3
3
  id: string;
4
4
  email: string;
5
5
  canRun: boolean;
6
6
  canApprove: boolean;
7
7
  }
8
- export type FlowOperation = "flows.create" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
8
+ export type FlowOperation = "flows.create" | "flows.update" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
9
9
  export interface FlowRepository {
10
- listVisible(actor: FlowActor): Promise<Flow[]>;
10
+ listVisible(actor: FlowActor, input?: ListFlowsInput): Promise<Flow[]>;
11
11
  getVisible(actor: FlowActor, flowId: string): Promise<Flow | null>;
12
12
  canEdit(actor: FlowActor, flowId: string): Promise<boolean>;
13
13
  canManage(actor: FlowActor, flowId: string): Promise<boolean>;
@@ -19,6 +19,13 @@ export interface FlowRepository {
19
19
  idempotencyKey: string;
20
20
  auditId: string;
21
21
  }): Promise<Flow>;
22
+ updateFlow(input: {
23
+ flow: Flow;
24
+ baseUpdatedAt: string;
25
+ actorId: string;
26
+ idempotencyKey: string;
27
+ auditId: string;
28
+ }): Promise<"conflict" | Flow>;
22
29
  getVersion(versionId: string): Promise<FlowVersion | null>;
23
30
  insertVersion(input: {
24
31
  version: FlowVersion;
@@ -80,15 +87,18 @@ export interface FlowDeps {
80
87
  id(): string;
81
88
  now(): Date;
82
89
  knowledgeExists(actor: FlowActor, resourceId: string): Promise<boolean>;
90
+ folderAccess(actor: FlowActor, folderId: string): Promise<FolderAccess>;
83
91
  toolFingerprint(actor: FlowActor, toolName: string): Promise<string | null>;
84
92
  unavailableTools(actor: FlowActor, toolNames: string[]): Promise<string[]>;
85
93
  }
94
+ export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
86
95
  export interface FlowService {
87
- list(actor: FlowActor): Promise<{
96
+ list(actor: FlowActor, input?: ListFlowsInput): Promise<{
88
97
  items: Flow[];
89
98
  }>;
90
99
  get(actor: FlowActor, flowId: string): Promise<FlowDocument>;
91
100
  create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
101
+ update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
92
102
  save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
93
103
  publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
94
104
  start(actor: FlowActor, input: StartFlowRunInput): Promise<FlowRunStep>;
package/dist/http/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, KnowledgeGraphInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
1
+ import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, KnowledgeGraphInput, ListFlowsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { authorizeBearer, bearer } from "../shared/gate-authorization/gate-authorization.js";
@@ -64,6 +64,16 @@ export function createHttp(deps) {
64
64
  }
65
65
  return authorization;
66
66
  }
67
+ // The caller's own identity, so the shell can name who is signed in. It needs no capability —
68
+ // the answer is the token's own subject — and it returns nothing but id, email, and name.
69
+ app.get("/session", (context) => {
70
+ const { identity } = context.get("authorization");
71
+ return context.json({
72
+ id: identity.id,
73
+ email: identity.email,
74
+ name: identity.name ?? null,
75
+ });
76
+ });
67
77
  app.get("/knowledge", async (context) => {
68
78
  const auth = requireCapability(context, "knowledge", "read");
69
79
  const url = new URL(context.req.url);
@@ -200,7 +210,20 @@ export function createHttp(deps) {
200
210
  });
201
211
  app.get("/flows", async (context) => {
202
212
  const auth = requireCapability(context, "flows", "read");
203
- return context.json(await deps.flows.list(asFlowActor(auth)));
213
+ const url = new URL(context.req.url);
214
+ const unknownQuery = [];
215
+ url.searchParams.forEach((_value, key) => {
216
+ if (key !== "parentId")
217
+ unknownQuery.push(key);
218
+ });
219
+ if (unknownQuery.length) {
220
+ throw new IntelError(400, "invalid_request", `Unknown query parameters: ${unknownQuery.join(", ")}`);
221
+ }
222
+ // Absent means "every flow I may see"; present but empty means the root of the shared tree.
223
+ // Without that distinction a tree level and a full list would be the same request.
224
+ const parentId = url.searchParams.get("parentId");
225
+ const input = ListFlowsInput.parse(url.searchParams.has("parentId") ? { parentId: parentId === "" ? null : parentId } : {});
226
+ return context.json(await deps.flows.list(asFlowActor(auth), input));
204
227
  });
205
228
  app.get("/flows/:flowId", async (context) => {
206
229
  const auth = requireCapability(context, "flows", "read");
@@ -232,6 +255,14 @@ export function createHttp(deps) {
232
255
  const input = CreateFlowInput.parse(await context.req.json().catch(() => null));
233
256
  return context.json(await deps.flows.create(asFlowActor(auth), input), 201);
234
257
  });
258
+ app.patch("/flows/:flowId", async (context) => {
259
+ const auth = requireCapability(context, "flows", "write");
260
+ const input = UpdateFlowInput.parse(await context.req.json().catch(() => null));
261
+ if (input.flowId !== context.req.param("flowId")) {
262
+ throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
263
+ }
264
+ return context.json(await deps.flows.update(asFlowActor(auth), input));
265
+ });
235
266
  app.post("/flows/:flowId/versions", async (context) => {
236
267
  const auth = requireCapability(context, "flows", "write");
237
268
  const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
@@ -97,6 +97,17 @@ export function createKnowledge(deps) {
97
97
  async get(actor, nodeId) {
98
98
  return await getDocument(await requireVisible(actor, nodeId));
99
99
  },
100
+ // Knowledge and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
101
+ // it: may this actor file something in that folder. The answer stays here, with the tree and
102
+ // its ACLs, rather than being reimplemented on the flow side.
103
+ async folderAccess(actor, folderId) {
104
+ const folder = await deps.repository.getVisible(actor, folderId);
105
+ if (!folder || folder.archivedAt)
106
+ return "missing";
107
+ if (folder.kind !== "folder")
108
+ return "not-a-folder";
109
+ return (await deps.repository.canEdit(actor, folder.id)) ? "ok" : "forbidden";
110
+ },
100
111
  async create(actor, input) {
101
112
  const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.create", input.idempotencyKey);
102
113
  if (existingId)
@@ -108,11 +108,13 @@ export interface KnowledgeDeps {
108
108
  };
109
109
  semantic?: SemanticIndex | undefined;
110
110
  }
111
+ export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
111
112
  export interface KnowledgeService {
112
113
  list(actor: Actor, input: ListKnowledgeNodesInput): Promise<{
113
114
  items: KnowledgeNode[];
114
115
  }>;
115
116
  get(actor: Actor, nodeId: string): Promise<KnowledgeDocument>;
117
+ folderAccess(actor: Actor, folderId: string): Promise<FolderAccess>;
116
118
  create(actor: Actor, input: CreateKnowledgeNodeInput): Promise<KnowledgeNode>;
117
119
  save(actor: Actor, input: SaveKnowledgeVersionInput): Promise<KnowledgeDocument>;
118
120
  saveAttachment(actor: Actor, input: SaveKnowledgeAttachmentInput): Promise<KnowledgeDocument>;
package/dist/mcp/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, KnowledgeGraphInput, ListFlowGrantsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
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";
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";
@@ -295,8 +295,8 @@ export async function handleMcp(request, deps) {
295
295
  if (deps.authorization.can("flows", "read")) {
296
296
  server.registerTool("flows_list", {
297
297
  title: "List flows",
298
- description: "List authorized company flows by title and description.",
299
- inputSchema: EmptyInput,
298
+ description: "List authorized flows, either all of them or the ones filed in one folder of the shared Knowledge tree.",
299
+ inputSchema: ListFlowsInput,
300
300
  annotations: {
301
301
  title: "List flows",
302
302
  readOnlyHint: true,
@@ -304,7 +304,7 @@ export async function handleMcp(request, deps) {
304
304
  idempotentHint: true,
305
305
  openWorldHint: false,
306
306
  },
307
- }, async () => text(await deps.flows.list(flowActor)));
307
+ }, async (input) => text(await deps.flows.list(flowActor, input)));
308
308
  server.registerTool("flow_get", {
309
309
  title: "Get flow",
310
310
  description: "Load one authorized flow and its current immutable graph on demand.",
@@ -383,6 +383,18 @@ export async function handleMcp(request, deps) {
383
383
  }, async (input) => text(await deps.flows.create(flowActor, input)));
384
384
  }
385
385
  if (deps.authorization.can("flows", "write")) {
386
+ server.registerTool("flow_update", {
387
+ title: "Rename or move flow",
388
+ description: "Rename a flow, change its description, or move it to another folder of the shared Knowledge tree. Never changes what the flow does.",
389
+ inputSchema: UpdateFlowInput,
390
+ annotations: {
391
+ title: "Rename or move flow",
392
+ readOnlyHint: false,
393
+ destructiveHint: false,
394
+ idempotentHint: true,
395
+ openWorldHint: false,
396
+ },
397
+ }, async (input) => text(await deps.flows.update(flowActor, input)));
386
398
  server.registerTool("flow_save", {
387
399
  title: "Save flow",
388
400
  description: "Append a validated immutable flow graph version with optimistic concurrency.",
@@ -86,7 +86,20 @@ export function createTools(deps) {
86
86
  // An unconnected portal is a normal state, not an error: the UI shows a connect prompt.
87
87
  if (!stored)
88
88
  return { portalConnected: false, items: [] };
89
- return { portalConnected: true, items: await capabilities(actor) };
89
+ try {
90
+ return { portalConnected: true, items: await capabilities(actor) };
91
+ }
92
+ catch (error) {
93
+ // A token that is gone or beyond renewal is the same answer as never having connected, so
94
+ // reading the catalog reports it as a state. Only a portal that does not answer stays an
95
+ // error — the view has to tell "connect the portal" apart from "the portal failed", and a
96
+ // 401 here would otherwise look like an expired Intel session to the browser.
97
+ if (error instanceof IntelError &&
98
+ (error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
99
+ return { portalConnected: false, items: [] };
100
+ }
101
+ throw error;
102
+ }
90
103
  },
91
104
  execute: async (actor, input) => await call(actor, input),
92
105
  async test(actor, input) {
@@ -0,0 +1,34 @@
1
+ -- ADR-0004: Knowledge and Flows share one folder tree. A flow gains `parent_id` into the existing
2
+ -- `knowledge_nodes` tree; the contents stay two tables, because versions, the R2 body and the
3
+ -- Vectorize entry belong to the document while the graph, runs and approvals belong to the flow.
4
+
5
+ -- SQLite only accepts an added REFERENCES column when its default is NULL, which is exactly what an
6
+ -- unfiled flow needs: `NULL` is the root of the same tree Knowledge uses.
7
+ ALTER TABLE flows ADD COLUMN parent_id TEXT REFERENCES knowledge_nodes(id);
8
+
9
+ CREATE INDEX flows_parent_idx ON flows(parent_id, archived_at, title);
10
+
11
+ -- Existing flows land in a defined folder rather than scattered across the root. One folder per
12
+ -- owner, with a derived ID so a re-run finds the same row: the narrow reading of an ambiguous case,
13
+ -- as the ADR requires. A single shared folder would have to belong to someone, and its owner would
14
+ -- gain sight of everyone else's flows — a migration must never widen access.
15
+ INSERT INTO knowledge_nodes (
16
+ id, parent_id, kind, title, description, context_policy, owner_id,
17
+ current_version_id, created_at, updated_at, archived_at
18
+ )
19
+ SELECT
20
+ 'folder-flows-' || flow.owner_id,
21
+ NULL,
22
+ 'folder',
23
+ 'Flows',
24
+ 'Flows that existed before Knowledge and Flows shared one tree.',
25
+ 'explicit',
26
+ flow.owner_id,
27
+ NULL,
28
+ '2026-08-01T00:00:00.000Z',
29
+ '2026-08-01T00:00:00.000Z',
30
+ NULL
31
+ FROM flows flow
32
+ GROUP BY flow.owner_id;
33
+
34
+ UPDATE flows SET parent_id = 'folder-flows-' || owner_id WHERE parent_id IS NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {