@anchrd/intel-api 0.15.0 → 0.16.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.
@@ -1,4 +1,5 @@
1
- import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
1
+ import { Flow, FlowGraph, FlowVersion, } from "@anchrd/intel-contract/flow";
2
+ import { FlowRun } from "@anchrd/intel-contract/flow-run";
2
3
  import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
3
4
  import { flowCallable, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
4
5
  const flowColumnNames = [
@@ -1,4 +1,4 @@
1
- import type { ResourceVerb } from "@anchrd/intel-contract";
1
+ import type { ResourceVerb } from "@anchrd/intel-contract/share";
2
2
  export interface GrantActor {
3
3
  id: string;
4
4
  email: string;
@@ -1,4 +1,4 @@
1
- import { ToolSourceUrl } from "@anchrd/intel-contract";
1
+ import { ToolSourceUrl } from "@anchrd/intel-contract/tool";
2
2
  import * as client from "openid-client";
3
3
  import { z } from "zod";
4
4
  const ProtectedResource = z.looseObject({
@@ -162,9 +162,20 @@ export function createOpenId(deps) {
162
162
  // one with a 404, which surfaced as a bare 500 on /auth/connect (#93). The fallback repeats the
163
163
  // operation once with the OAuth document; when both fail, the second error is thrown because it
164
164
  // belongs to the attempt that got further for the issuer that needed the fallback at all.
165
- async function withAlgorithmFallback(run) {
165
+ async function withAlgorithmFallback(run, usable = () => true) {
166
166
  try {
167
- return await run();
167
+ const first = await run();
168
+ // ⚠️ A THROW is not the only way the OIDC document can be the wrong one (#425). Cloudflare
169
+ // Access answers `/.well-known/openid-configuration` with **200** and a two-field stub —
170
+ // `issuer` and `jwks_uri`, nothing else — while the endpoints live only under RFC 8414's
171
+ // `oauth-authorization-server`. Discovery therefore SUCCEEDS, the fallback never runs, and
172
+ // the missing endpoint surfaces minutes later inside `buildAuthorizationUrl` as
173
+ // `authorization server metadata does not contain a valid "as.authorization_endpoint"` — a
174
+ // 500 on `/auth/connect` with nothing in it pointing at discovery.
175
+ //
176
+ // So the question is not "did it fail" but "is what came back usable". An answer that parses
177
+ // and carries nothing is the same situation as a 404, and it gets the same second attempt.
178
+ return usable(first) ? first : await run("oauth2");
168
179
  }
169
180
  catch {
170
181
  return await run("oauth2");
@@ -174,7 +185,14 @@ export function createOpenId(deps) {
174
185
  const key = `${issuer}#${clientId}`;
175
186
  let configuration = configurations.get(key);
176
187
  if (!configuration) {
177
- configuration = withAlgorithmFallback((algorithm) => client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), { ...options(issuer), ...(algorithm ? { algorithm } : {}) }));
188
+ configuration = withAlgorithmFallback((algorithm) => client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), { ...options(issuer), ...(algorithm ? { algorithm } : {}) }),
189
+ // The two endpoints every flow here needs. Asking for exactly them rather than for a long
190
+ // list keeps the check honest: a server that publishes these two is usable, and one that
191
+ // publishes neither is the stub case above, whatever else it carries.
192
+ (resolved) => {
193
+ const metadata = resolved.serverMetadata();
194
+ return Boolean(metadata.authorization_endpoint && metadata.token_endpoint);
195
+ });
178
196
  configurations.set(key, configuration);
179
197
  void configuration.catch(() => configurations.delete(key));
180
198
  }
@@ -1,4 +1,4 @@
1
- import { ToolTestResult } from "@anchrd/intel-contract";
1
+ import { ToolTestResult } from "@anchrd/intel-contract/tool";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
4
  import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
@@ -1,4 +1,7 @@
1
- import { BlockNoteMediaType, BundleImportResult, BundleManifest, BundleManifestFilename, DocumentLinkInlineType, FlowGraph, TableMediaType, } from "@anchrd/intel-contract";
1
+ import { BundleImportResult, BundleManifest, BundleManifestFilename, } from "@anchrd/intel-contract/bundle";
2
+ import { FlowGraph } from "@anchrd/intel-contract/flow";
3
+ import { BlockNoteMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract/node";
4
+ import { TableMediaType } from "@anchrd/intel-contract/table";
2
5
  import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
3
6
  import { documentLinkTargets } from "../nodes/document-links/document-links.js";
4
7
  import { parseCsv } from "../shared/csv/csv.js";
@@ -1,4 +1,4 @@
1
- import type { BundleImportResult, BundleManifest } from "@anchrd/intel-contract";
1
+ import type { BundleImportResult, BundleManifest } from "@anchrd/intel-contract/bundle";
2
2
  import type { FlowRepository } from "../flows/flows.types.js";
3
3
  import type { Actor, ContentStore, NodeRepository } from "../nodes/nodes.types.js";
4
4
  export interface BundleActor extends Actor {
package/dist/cli/cli.js CHANGED
@@ -2,7 +2,20 @@ import { createBuild } from "../build/build.js";
2
2
  import { createPrepare } from "../prepare/prepare.js";
3
3
  const interfaces = [
4
4
  { handle: "intel", functions: ["use", "admin"] },
5
- { handle: "knowledge", functions: ["read", "create", "write", "share"] },
5
+ // ⚠️ `nodes`, not `knowledge` and the rename cost something, which is why it needed its own
6
+ // ticket (#152) five months after #125 renamed everything else. This handle is only half Intel's:
7
+ // `bootstrap` DECLARES it in Gate and deliberately changes no grant, so to Gate a renamed handle
8
+ // is a NEW one. Every grant on the old name keeps pointing at the old name, and everybody loses
9
+ // access at once, silently, until an operator hands the permissions out again.
10
+ //
11
+ // It was done in the one window where that is free: the installation on anchrd.sh was being
12
+ // rebuilt anyway (#385, anchrd/core#93) and there is no other. Doing it later would mean doing it
13
+ // to somebody.
14
+ //
15
+ // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares, it never revokes.
16
+ // It is a harmless leftover on installations that have it, and nothing in Intel asks about it any
17
+ // more; removing it is an act in Gate, by hand, when somebody is sure nothing else uses it.
18
+ { handle: "nodes", functions: ["read", "create", "write", "share"] },
6
19
  // ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
7
20
  // a function nobody asks about is a permission an operator has to decide on for no reason. An
8
21
  // installation that already granted it keeps a harmless leftover — bootstrap declares, it does
@@ -118,7 +131,7 @@ export function createCli(deps) {
118
131
  if (!intelUrl || !token) {
119
132
  return fail("INTEL_URL and a short-lived INTEL_OPERATOR_TOKEN must be set.");
120
133
  }
121
- const response = await deps.fetch(`${intelUrl}/api/v1/search/reindex`, {
134
+ const response = await deps.fetch(`${intelUrl}/api/v1/nodes/reindex`, {
122
135
  method: "POST",
123
136
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
124
137
  body: "{}",
@@ -1,4 +1,4 @@
1
- import type { FlowGraph, FlowNode } from "@anchrd/intel-contract";
1
+ import type { FlowGraph, FlowNode } from "@anchrd/intel-contract/flow";
2
2
  import type { CompiledFlow, FlowDeps, FlowService } from "./flows.types.js";
3
3
  type SubflowNode = Extract<FlowNode, {
4
4
  kind: "subflow";
@@ -1,4 +1,4 @@
1
- import { flowNodeLayer } from "@anchrd/intel-contract";
1
+ import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
3
  import { plainTitle } from "../shared/plain-title/plain-title.js";
4
4
  function invalid(detail) {
@@ -1,4 +1,7 @@
1
- import type { ArchiveFlowInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowValidation, FlowVersion, FlowVersionList, FlowVersionSummary, GetFlowVersionInput, ListFlowRunsInput, ListFlowsInput, Node, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UnpublishFlowInput, UpdateFlowInput } from "@anchrd/intel-contract";
1
+ import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowValidation, FlowVersion, FlowVersionList, FlowVersionSummary, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput } from "@anchrd/intel-contract/flow";
2
+ import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
3
+ import type { Node } from "@anchrd/intel-contract/node";
4
+ import type { ResourceVerb } from "@anchrd/intel-contract/share";
2
5
  export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
3
6
  export type FlowCallReach = "subtree" | "library" | "out-of-reach";
4
7
  export interface FlowRunChainEntry {
package/dist/http/http.js CHANGED
@@ -1,4 +1,9 @@
1
- import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, ListFlowRunsInput, ListFlowsInput, ListNodesInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
1
+ import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
2
+ import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
3
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
4
+ import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
5
+ import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
6
+ import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
2
7
  import { Hono } from "hono";
3
8
  import { z } from "zod";
4
9
  import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
@@ -119,15 +124,20 @@ export function createHttp(deps) {
119
124
  // The caller's own identity, so the shell can name who is signed in. It needs no capability —
120
125
  // the answer is the token's own subject — and it returns nothing but id, email, and name.
121
126
  app.get("/session", (context) => {
122
- const { identity } = context.get("authorization");
127
+ const authorization = context.get("authorization");
128
+ const { identity } = authorization;
123
129
  return context.json({
124
130
  id: identity.id,
125
131
  email: identity.email,
126
132
  name: identity.name ?? null,
133
+ // ⚠️ What the shell may DRAW, never what it may do (#416). Every admin route below still asks
134
+ // `requireCapability` for itself, and a browser that lied here would gain nothing but a
135
+ // button that answers 403.
136
+ isAdmin: authorization.can("intel", "admin"),
127
137
  });
128
138
  });
129
139
  app.get("/nodes", async (context) => {
130
- const auth = requireCapability(context, "knowledge", "read");
140
+ const auth = requireCapability(context, "nodes", "read");
131
141
  const url = new URL(context.req.url);
132
142
  const unknownQuery = [];
133
143
  url.searchParams.forEach((_value, key) => {
@@ -146,7 +156,7 @@ export function createHttp(deps) {
146
156
  return context.json(await deps.nodes.list(asActor(auth), input));
147
157
  });
148
158
  app.get("/nodes/graph", async (context) => {
149
- const auth = requireCapability(context, "knowledge", "read");
159
+ const auth = requireCapability(context, "nodes", "read");
150
160
  const url = new URL(context.req.url);
151
161
  const input = NodeGraphInput.parse({
152
162
  limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
@@ -156,14 +166,14 @@ export function createHttp(deps) {
156
166
  // ⚠️ Before `/nodes/:nodeId`, or the router would read "export" as a node ID. The root has no
157
167
  // node to address, so the whole installation — as this caller may read it — exports here (#136).
158
168
  app.get("/nodes/export", async (context) => {
159
- const auth = requireCapability(context, "knowledge", "read");
169
+ const auth = requireCapability(context, "nodes", "read");
160
170
  return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), null));
161
171
  });
162
172
  // The reverse door (#137): a zip bundle — or a naked zipped folder — lands as a new subtree.
163
173
  // The body IS the zip, streamed to the service rather than read whole here; `knowledge/create`
164
174
  // like every other way of making nodes, and the resource-level write check is the service's.
165
175
  async function importBundle(context, targetNodeId) {
166
- const auth = requireCapability(context, "knowledge", "create");
176
+ const auth = requireCapability(context, "nodes", "create");
167
177
  const idempotencyKey = ImportIdempotencyKey.parse(context.req.header("idempotency-key"));
168
178
  const body = context.req.raw.body;
169
179
  if (body === null) {
@@ -181,7 +191,7 @@ export function createHttp(deps) {
181
191
  // ⚠️ Before `/nodes/:nodeId`, or the router would read "import" as a node ID.
182
192
  app.post("/nodes/import", async (context) => await importBundle(context, null));
183
193
  app.get("/nodes/:nodeId", async (context) => {
184
- const auth = requireCapability(context, "knowledge", "read");
194
+ const auth = requireCapability(context, "nodes", "read");
185
195
  return context.json(await deps.nodes.get(asActor(auth), context.req.param("nodeId")));
186
196
  });
187
197
  app.post("/nodes/:nodeId/import", async (context) => {
@@ -190,19 +200,19 @@ export function createHttp(deps) {
190
200
  });
191
201
  // One subtree — a folder with everything beneath it, or a single node — as a zip bundle (#136).
192
202
  app.get("/nodes/:nodeId/export", async (context) => {
193
- const auth = requireCapability(context, "knowledge", "read");
203
+ const auth = requireCapability(context, "nodes", "read");
194
204
  const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
195
205
  return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
196
206
  });
197
207
  app.get("/nodes/:nodeId/versions", async (context) => {
198
- const auth = requireCapability(context, "knowledge", "read");
208
+ const auth = requireCapability(context, "nodes", "read");
199
209
  return context.json(await deps.nodes.listVersions(asActor(auth), context.req.param("nodeId")));
200
210
  });
201
211
  // The content behind one row of the list above (#147): what a version-pinned citation points at.
202
212
  // Read capability like every other node read; the resource ACL and the node/version pairing are
203
213
  // the service's to refuse.
204
214
  app.get("/nodes/:nodeId/versions/:versionId/content", async (context) => {
205
- const auth = requireCapability(context, "knowledge", "read");
215
+ const auth = requireCapability(context, "nodes", "read");
206
216
  const input = GetNodeVersionInput.parse({
207
217
  nodeId: context.req.param("nodeId"),
208
218
  versionId: context.req.param("versionId"),
@@ -214,13 +224,13 @@ export function createHttp(deps) {
214
224
  // resource address says nothing to an HTTP client, whose address for the bytes is the sibling
215
225
  // route below.
216
226
  app.get("/nodes/:nodeId/attachment/meta", async (context) => {
217
- const auth = requireCapability(context, "knowledge", "read");
227
+ const auth = requireCapability(context, "nodes", "read");
218
228
  const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
219
229
  const { node, version } = await deps.nodes.getAttachment(asActor(auth), input.nodeId);
220
230
  return context.json({ node, version });
221
231
  });
222
232
  app.get("/nodes/:nodeId/attachment", async (context) => {
223
- const auth = requireCapability(context, "knowledge", "read");
233
+ const auth = requireCapability(context, "nodes", "read");
224
234
  const { attachment, body } = await deps.nodes.readAttachment(asActor(auth), context.req.param("nodeId"));
225
235
  return new Response(body, {
226
236
  headers: {
@@ -232,13 +242,13 @@ export function createHttp(deps) {
232
242
  });
233
243
  });
234
244
  app.get("/nodes/:nodeId/table", async (context) => {
235
- const auth = requireCapability(context, "knowledge", "read");
245
+ const auth = requireCapability(context, "nodes", "read");
236
246
  return context.json(await deps.nodes.getTable(asActor(auth), context.req.param("nodeId")));
237
247
  });
238
248
  // Defining the header and appending rows are two routes because they are two decisions: the
239
249
  // header is written once and is the contract, an append is the everyday write (#40).
240
250
  app.post("/nodes/:nodeId/table", async (context) => {
241
- const auth = requireCapability(context, "knowledge", "write");
251
+ const auth = requireCapability(context, "nodes", "write");
242
252
  const input = DefineTableInput.parse(await context.req.json().catch(() => null));
243
253
  if (input.nodeId !== context.req.param("nodeId")) {
244
254
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -246,7 +256,7 @@ export function createHttp(deps) {
246
256
  return context.json(await deps.nodes.defineTable(asActor(auth), input), 201);
247
257
  });
248
258
  app.post("/nodes/:nodeId/table/rows", async (context) => {
249
- const auth = requireCapability(context, "knowledge", "write");
259
+ const auth = requireCapability(context, "nodes", "write");
250
260
  const input = AppendTableRowsInput.parse(await context.req.json().catch(() => null));
251
261
  if (input.nodeId !== context.req.param("nodeId")) {
252
262
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -258,7 +268,7 @@ export function createHttp(deps) {
258
268
  // the `baseVersionId` that says which state they mean. Each writes one snapshot version, so the
259
269
  // 201 is the same statement the append's 201 makes.
260
270
  app.post("/nodes/:nodeId/table/rows/update", async (context) => {
261
- const auth = requireCapability(context, "knowledge", "write");
271
+ const auth = requireCapability(context, "nodes", "write");
262
272
  const input = UpdateTableRowsInput.parse(await context.req.json().catch(() => null));
263
273
  if (input.nodeId !== context.req.param("nodeId")) {
264
274
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -266,15 +276,15 @@ export function createHttp(deps) {
266
276
  return context.json(await deps.nodes.updateTableRows(asActor(auth), input), 201);
267
277
  });
268
278
  app.post("/nodes/:nodeId/table/rows/delete", async (context) => {
269
- const auth = requireCapability(context, "knowledge", "write");
279
+ const auth = requireCapability(context, "nodes", "write");
270
280
  const input = DeleteTableRowsInput.parse(await context.req.json().catch(() => null));
271
281
  if (input.nodeId !== context.req.param("nodeId")) {
272
282
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
273
283
  }
274
284
  return context.json(await deps.nodes.deleteTableRows(asActor(auth), input), 201);
275
285
  });
276
- app.post("/nodes/:nodeId/table/redefine", async (context) => {
277
- const auth = requireCapability(context, "knowledge", "write");
286
+ app.patch("/nodes/:nodeId/table", async (context) => {
287
+ const auth = requireCapability(context, "nodes", "write");
278
288
  const input = RedefineTableInput.parse(await context.req.json().catch(() => null));
279
289
  if (input.nodeId !== context.req.param("nodeId")) {
280
290
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -286,7 +296,7 @@ export function createHttp(deps) {
286
296
  // reaches, nothing in `intel-data-provider` asks for it, and a route with no caller is the thing
287
297
  // YAGNI is about. The screen is anchrd/intel#358, and it brings this route with it.
288
298
  app.get("/nodes/:nodeId/links", async (context) => {
289
- const auth = requireCapability(context, "knowledge", "read");
299
+ const auth = requireCapability(context, "nodes", "read");
290
300
  return context.json(await deps.nodes.listLinks(asActor(auth), context.req.param("nodeId")));
291
301
  });
292
302
  // A document link's name, for whoever is reading the text (#41). A POST because the list of IDs
@@ -297,27 +307,27 @@ export function createHttp(deps) {
297
307
  // There is no route to create or delete a link any more. A relationship is written where it is
298
308
  // meant, in the text, and `POST /nodes/:nodeId/versions` is what records it.
299
309
  app.post("/nodes/links/resolve", async (context) => {
300
- const auth = requireCapability(context, "knowledge", "read");
310
+ const auth = requireCapability(context, "nodes", "read");
301
311
  const input = ResolveNodeLinksInput.parse(await context.req.json().catch(() => null));
302
312
  return context.json(await deps.nodes.resolveLinks(asActor(auth), input));
303
313
  });
304
314
  app.post("/nodes/search", async (context) => {
305
- const auth = requireCapability(context, "knowledge", "read");
315
+ const auth = requireCapability(context, "nodes", "read");
306
316
  const input = SearchInput.parse(await context.req.json().catch(() => null));
307
317
  return context.json(await deps.nodes.search(asActor(auth), input));
308
318
  });
309
- app.post("/search/reindex", async (context) => {
319
+ app.post("/nodes/reindex", async (context) => {
310
320
  const auth = requireCapability(context, "intel", "admin");
311
321
  z.strictObject({}).parse(await context.req.json().catch(() => null));
312
322
  return context.json(await deps.nodes.reindex(asActor(auth)), 202);
313
323
  });
314
324
  app.post("/nodes", async (context) => {
315
- const auth = requireCapability(context, "knowledge", "create");
325
+ const auth = requireCapability(context, "nodes", "create");
316
326
  const input = CreateNodeInput.parse(await context.req.json().catch(() => null));
317
327
  return context.json(await deps.nodes.create(asActor(auth), input), 201);
318
328
  });
319
329
  app.post("/nodes/:nodeId/versions", async (context) => {
320
- const auth = requireCapability(context, "knowledge", "write");
330
+ const auth = requireCapability(context, "nodes", "write");
321
331
  const input = SaveNodeVersionInput.parse(await context.req.json().catch(() => null));
322
332
  if (input.nodeId !== context.req.param("nodeId")) {
323
333
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -325,7 +335,7 @@ export function createHttp(deps) {
325
335
  return context.json(await deps.nodes.save(asActor(auth), input), 201);
326
336
  });
327
337
  app.post("/nodes/:nodeId/attachment", async (context) => {
328
- const auth = requireCapability(context, "knowledge", "write");
338
+ const auth = requireCapability(context, "nodes", "write");
329
339
  const input = SaveAttachmentInput.parse(await context.req.json().catch(() => null));
330
340
  if (input.nodeId !== context.req.param("nodeId")) {
331
341
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -333,7 +343,7 @@ export function createHttp(deps) {
333
343
  return context.json(await deps.nodes.saveAttachment(asActor(auth), input), 201);
334
344
  });
335
345
  app.patch("/nodes/:nodeId", async (context) => {
336
- const auth = requireCapability(context, "knowledge", "write");
346
+ const auth = requireCapability(context, "nodes", "write");
337
347
  const input = UpdateNodeInput.parse(await context.req.json().catch(() => null));
338
348
  if (input.nodeId !== context.req.param("nodeId")) {
339
349
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
@@ -341,21 +351,21 @@ export function createHttp(deps) {
341
351
  return context.json(await deps.nodes.update(asActor(auth), input));
342
352
  });
343
353
  app.post("/nodes/:nodeId/archive", async (context) => {
344
- const auth = requireCapability(context, "knowledge", "write");
354
+ const auth = requireCapability(context, "nodes", "write");
345
355
  const input = ArchiveNodeInput.parse(await context.req.json().catch(() => null));
346
356
  if (input.nodeId !== context.req.param("nodeId")) {
347
357
  throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
348
358
  }
349
359
  // The caller's own bearer, for the one thing archiving may have to do in Gate: switch off the
350
360
  // Application behind an agent (#182). Every other kind never reaches it.
351
- return context.json(await deps.nodes.archive(asActor(auth), input, { token: context.get("token") }));
361
+ return context.json(await deps.nodes.archive(asActor(auth), input));
352
362
  });
353
363
  app.get("/nodes/:nodeId/grants", async (context) => {
354
- const auth = requireCapability(context, "knowledge", "share");
364
+ const auth = requireCapability(context, "nodes", "share");
355
365
  return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
356
366
  });
357
367
  app.post("/nodes/:nodeId/grants", async (context) => {
358
- const auth = requireCapability(context, "knowledge", "share");
368
+ const auth = requireCapability(context, "nodes", "share");
359
369
  const input = ShareInput.parse(await context.req.json().catch(() => null));
360
370
  if (input.resourceId !== context.req.param("nodeId")) {
361
371
  throw new IntelError(400, "node_id_mismatch", "Path and body resource IDs differ");
@@ -363,7 +373,7 @@ export function createHttp(deps) {
363
373
  return context.json(await deps.nodes.share(asActor(auth), input), 201);
364
374
  });
365
375
  app.post("/nodes/:nodeId/grants/:grantId/revoke", async (context) => {
366
- const auth = requireCapability(context, "knowledge", "share");
376
+ const auth = requireCapability(context, "nodes", "share");
367
377
  const input = RevokeGrantInput.parse(await context.req.json().catch(() => null));
368
378
  if (input.resourceId !== context.req.param("nodeId") ||
369
379
  input.grantId !== context.req.param("grantId")) {
@@ -552,7 +562,7 @@ export function createHttp(deps) {
552
562
  const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
553
563
  return context.json(await deps.flows.listRunSteps(asFlowActor(auth), input.runId));
554
564
  });
555
- app.post("/flow-runs/:runId/complete", async (context) => {
565
+ app.post("/flow-runs/:runId/steps/complete", async (context) => {
556
566
  const auth = requireCapability(context, "flows", "run");
557
567
  const input = CompleteFlowRunStepInput.parse(await context.req.json().catch(() => null));
558
568
  if (input.runId !== context.req.param("runId")) {
@@ -1,4 +1,4 @@
1
- import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract";
1
+ import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract/node";
2
2
  export class PermanentIndexingError extends Error {
3
3
  }
4
4
  // The key under which a node with exactly one vector is filed — since #390 that is every kind. It
@@ -60,7 +60,16 @@ export function createIntel(deps) {
60
60
  if (error instanceof IntelError) {
61
61
  return context.redirect(`/tools?connectError=${encodeURIComponent(error.code)}`);
62
62
  }
63
- throw error;
63
+ // ⚠️ Everything else used to be rethrown, and the outer handler turned it into a bare 500
64
+ // with a generic body (#425). For an API that is the right answer; for a route a BROWSER is
65
+ // sent to it is a dead end — the reader gets JSON instead of a screen, and the Tools page
66
+ // never learns that the attempt happened at all, so it says "no access" about a sign-in
67
+ // that crashed.
68
+ //
69
+ // The trace still goes to the log, which is the operator's channel; what changes is that
70
+ // the reader is returned to a screen that can say "this broke" instead of "you may not".
71
+ reportUnexpectedError(error);
72
+ return context.redirect("/tools?connectError=portal_sign_in_failed");
64
73
  }
65
74
  });
66
75
  app.get("/auth/callback", async (context) => await browserAuth.callback(new URL(context.req.url), context.req.raw.headers));
package/dist/mcp/mcp.js CHANGED
@@ -1,4 +1,10 @@
1
- import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
1
+ import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
+ import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
3
+ import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
4
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
+ import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
6
+ import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
7
+ import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
2
8
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
9
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4
10
  import { z } from "zod";
@@ -65,14 +71,20 @@ export async function handleMcp(request, deps) {
65
71
  const EmptyInput = z.strictObject({});
66
72
  // `null` is the root: the whole tree as this caller may read it. The field is required rather
67
73
  // than defaulted so "everything" is always said, never fallen into.
68
- const ExportManifestInput = z.strictObject({ nodeId: IntelId.nullable() });
74
+ const ExportManifestInput = z.strictObject({
75
+ nodeId: IntelId.nullable().describe('Folder or node to plan the export of, or `null` for the whole tree as this caller may read it. Required rather than defaulted, so "everything" is always said and never fallen into.'),
76
+ });
69
77
  // The zip travels inline as base64, which is MCP's one way of carrying bytes. ~14M characters is
70
78
  // ~10 MB of zip — the same order as the attachment inline limit, and for the same isolate-memory
71
79
  // reason. Bigger bundles take the HTTP door, which streams.
72
80
  const ImportBundleInput = z.strictObject({
73
- nodeId: IntelId.nullable(),
74
- zipBase64: z.string().min(1).max(14_000_000),
75
- idempotencyKey: z.string().min(8).max(200),
81
+ nodeId: IntelId.nullable().describe("Folder the bundle lands under, or `null` for the top level. The bundle's own structure is kept beneath it."),
82
+ zipBase64: z
83
+ .string()
84
+ .min(1)
85
+ .max(14_000_000)
86
+ .describe("The zip bundle, base64-encoded. Around 10 MB of zip; a bigger bundle takes the HTTP door, which streams instead of holding it in memory."),
87
+ idempotencyKey: IdempotencyKey,
76
88
  });
77
89
  function decodeZipBase64(value) {
78
90
  if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
@@ -105,7 +117,7 @@ export async function handleMcp(request, deps) {
105
117
  email: deps.authorization.identity.email,
106
118
  name: deps.authorization.identity.name ?? null,
107
119
  }));
108
- if (permits(deps.authorization, "knowledge", "read")) {
120
+ if (permits(deps.authorization, "nodes", "read")) {
109
121
  server.registerTool("node_list", {
110
122
  title: "List nodes",
111
123
  description: "List authorized folders, documents, attachments, and tables under one parent.",
@@ -130,7 +142,7 @@ export async function handleMcp(request, deps) {
130
142
  openWorldHint: false,
131
143
  },
132
144
  }, async (input) => text(await deps.nodes.get(actor, input.nodeId)));
133
- server.registerTool("node_versions_list", {
145
+ server.registerTool("node_version_list", {
134
146
  title: "List node versions",
135
147
  description: "List immutable versions of one authorized node.",
136
148
  inputSchema: GetNodeInput,
@@ -219,7 +231,7 @@ export async function handleMcp(request, deps) {
219
231
  openWorldHint: false,
220
232
  },
221
233
  }, async (input) => text(await deps.nodes.getTable(actor, input.nodeId)));
222
- server.registerTool("node_links_list", {
234
+ server.registerTool("node_link_list", {
223
235
  title: "List node links",
224
236
  description: "List authorized outgoing links and backlinks for one node.",
225
237
  inputSchema: GetNodeInput,
@@ -233,12 +245,12 @@ export async function handleMcp(request, deps) {
233
245
  }, async (input) => text(await deps.nodes.listLinks(actor, input.nodeId)));
234
246
  // The reader's half of a document link (#41): the titles of the linked documents this caller
235
247
  // may see. There is no tool to create or delete a link — a relationship is written in the text
236
- // and `node_save` is what records it, so there is only one way to make one.
248
+ // and `node_version_create` is what records it, so there is only one way to make one.
237
249
  //
238
250
  // ⚠️ A target this caller may not reach, or one that is gone, is simply absent from the answer.
239
251
  // The two are indistinguishable on purpose: telling them apart would confirm that a document
240
252
  // exists somewhere they cannot look.
241
- server.registerTool("node_links_resolve", {
253
+ server.registerTool("node_link_resolve", {
242
254
  title: "Resolve node links",
243
255
  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.",
244
256
  inputSchema: ResolveNodeLinksInput,
@@ -268,12 +280,12 @@ export async function handleMcp(request, deps) {
268
280
  // resource payload travels inline as base64 and would have to fit in Worker memory twice, the
269
281
  // same limit that caps `node_attachment_get` at 10 MB — a whole subtree is the case that limit
270
282
  // exists for. HTTP `GET /nodes/:nodeId/export` streams the real archive.
271
- server.registerTool("node_export_manifest", {
272
- title: "Get export manifest",
283
+ server.registerTool("node_export", {
284
+ title: "Export nodes",
273
285
  description: "Plan a bundle export: the manifest of one authorized subtree (or the whole tree when nodeId is null) with each entry's bundle path, kind, and media type. Content bytes are fetched per node; the binary zip is served over HTTP.",
274
286
  inputSchema: ExportManifestInput,
275
287
  annotations: {
276
- title: "Get export manifest",
288
+ title: "Export nodes",
277
289
  readOnlyHint: true,
278
290
  destructiveHint: false,
279
291
  idempotentHint: true,
@@ -295,7 +307,7 @@ export async function handleMcp(request, deps) {
295
307
  },
296
308
  }, async () => text(await deps.nodes.reindex(actor)));
297
309
  }
298
- if (permits(deps.authorization, "knowledge", "create")) {
310
+ if (permits(deps.authorization, "nodes", "create")) {
299
311
  server.registerTool("node_create", {
300
312
  title: "Create node",
301
313
  description: "Create a governed folder, document, attachment, or table node.",
@@ -309,25 +321,25 @@ export async function handleMcp(request, deps) {
309
321
  },
310
322
  }, async (input) => text(await deps.nodes.create(actor, input)));
311
323
  }
312
- if (permits(deps.authorization, "knowledge", "write")) {
313
- server.registerTool("node_save", {
314
- title: "Save node version",
324
+ if (permits(deps.authorization, "nodes", "write")) {
325
+ server.registerTool("node_version_create", {
326
+ title: "Create node version",
315
327
  description: "Append an immutable content version using an optimistic base version.",
316
328
  inputSchema: SaveNodeVersionInput,
317
329
  annotations: {
318
- title: "Save node version",
330
+ title: "Create node version",
319
331
  readOnlyHint: false,
320
332
  destructiveHint: false,
321
333
  idempotentHint: true,
322
334
  openWorldHint: false,
323
335
  },
324
336
  }, async (input) => text(await deps.nodes.save(actor, input)));
325
- server.registerTool("node_attachment_save", {
326
- title: "Save node attachment",
337
+ server.registerTool("node_attachment_create", {
338
+ title: "Create node attachment version",
327
339
  description: "Append immutable base64 file bytes to an attachment node.",
328
340
  inputSchema: SaveAttachmentInput,
329
341
  annotations: {
330
- title: "Save node attachment",
342
+ title: "Create node attachment version",
331
343
  readOnlyHint: false,
332
344
  destructiveHint: false,
333
345
  idempotentHint: true,
@@ -338,12 +350,12 @@ export async function handleMcp(request, deps) {
338
350
  // the export — or a naked zipped folder — lands as a new subtree under the target. Same
339
351
  // service as HTTP, so authorization, limits, remapping, and the all-or-nothing write cannot
340
352
  // differ by surface; only the transport differs (inline base64 here, a streamed body there).
341
- server.registerTool("node_import_bundle", {
342
- title: "Import bundle",
353
+ server.registerTool("node_import", {
354
+ title: "Import nodes",
343
355
  description: "Import a zip bundle (base64) as a new subtree under an authorized target folder — null files at the root. Kinds come from manifest.json when present, from file extensions otherwise. Always creates new nodes; links and flow references between bundled entries are rewritten to the new IDs. Bundles beyond ~10 MB take the streaming HTTP route POST /nodes/:nodeId/import.",
344
356
  inputSchema: ImportBundleInput,
345
357
  annotations: {
346
- title: "Import bundle",
358
+ title: "Import nodes",
347
359
  readOnlyHint: false,
348
360
  destructiveHint: false,
349
361
  idempotentHint: true,
@@ -354,12 +366,12 @@ export async function handleMcp(request, deps) {
354
366
  zip: decodeZipBase64(input.zipBase64),
355
367
  idempotencyKey: input.idempotencyKey,
356
368
  })));
357
- server.registerTool("node_table_define", {
358
- title: "Define node table columns",
369
+ server.registerTool("node_table_create", {
370
+ title: "Create node table columns",
359
371
  description: "Write the column names of an empty table. The header is the contract every append is checked against and cannot be rewritten.",
360
372
  inputSchema: DefineTableInput,
361
373
  annotations: {
362
- title: "Define node table columns",
374
+ title: "Create node table columns",
363
375
  readOnlyHint: false,
364
376
  destructiveHint: false,
365
377
  idempotentHint: true,
@@ -368,12 +380,12 @@ export async function handleMcp(request, deps) {
368
380
  }, async (input) => text(await deps.nodes.defineTable(actor, input)));
369
381
  // The tool #40 exists for: an agent collecting findings on a schedule appends them without
370
382
  // reading or resending what is already there, and two agents appending at once lose nothing.
371
- server.registerTool("node_table_append", {
372
- title: "Append node table rows",
383
+ server.registerTool("node_table_row_create", {
384
+ title: "Create node table rows",
373
385
  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.",
374
386
  inputSchema: AppendTableRowsInput,
375
387
  annotations: {
376
- title: "Append node table rows",
388
+ title: "Create node table rows",
377
389
  readOnlyHint: false,
378
390
  destructiveHint: false,
379
391
  // The idempotency key makes a repeat of the same call a no-op; without one, appending
@@ -386,7 +398,7 @@ export async function handleMcp(request, deps) {
386
398
  // rows — they carry no IDs on purpose — so every mutation names the version the positions were
387
399
  // read from and is refused with `version_conflict` when the table moved on. No `knowledge_*`
388
400
  // alias: these tools are new under the post-#125 naming and never carried the old prefix.
389
- server.registerTool("node_table_update_rows", {
401
+ server.registerTool("node_table_row_update", {
390
402
  title: "Update node table rows",
391
403
  description: "Replace rows at the given zero-based positions. Requires the baseVersionId the positions were read from (see node_table_get); answers version_conflict and writes nothing when the table has changed since. Each replacement row must match the column count.",
392
404
  inputSchema: UpdateTableRowsInput,
@@ -399,7 +411,7 @@ export async function handleMcp(request, deps) {
399
411
  openWorldHint: false,
400
412
  },
401
413
  }, async (input) => text(await deps.nodes.updateTableRows(actor, input)));
402
- server.registerTool("node_table_delete_rows", {
414
+ server.registerTool("node_table_row_delete", {
403
415
  title: "Delete node table rows",
404
416
  description: "Remove rows at the given zero-based positions. Requires the baseVersionId the positions were read from; answers version_conflict and writes nothing when the table has changed since.",
405
417
  inputSchema: DeleteTableRowsInput,
@@ -411,12 +423,12 @@ export async function handleMcp(request, deps) {
411
423
  openWorldHint: false,
412
424
  },
413
425
  }, async (input) => text(await deps.nodes.deleteTableRows(actor, input)));
414
- server.registerTool("node_table_redefine", {
415
- title: "Redefine node table columns",
426
+ server.registerTool("node_table_update", {
427
+ title: "Update node table columns",
416
428
  description: "Change the header of a defined table through an explicit column mapping: each new column names the current column that fills it (rename or keep), names none to start empty (add), and a current column no entry names is removed with its cells. Requires the baseVersionId the mapping was read from. Defining over an existing header without a mapping stays refused.",
417
429
  inputSchema: RedefineTableInput,
418
430
  annotations: {
419
- title: "Redefine node table columns",
431
+ title: "Update node table columns",
420
432
  readOnlyHint: false,
421
433
  destructiveHint: true,
422
434
  idempotentHint: true,
@@ -437,53 +449,53 @@ export async function handleMcp(request, deps) {
437
449
  }, async (input) => text(await deps.nodes.update(actor, input)));
438
450
  server.registerTool("node_archive", {
439
451
  title: "Archive node",
440
- description: "Archive or restore one node using optimistic concurrency. Archiving an agent also switches off its Gate application; restoring switches it back on.",
452
+ description: "Archive or restore one node. Archiving hides it from listings and search and takes its vectors out of the index; nothing is deleted and every version stays readable, so restoring is the same call with `archived: false`.",
441
453
  inputSchema: ArchiveNodeInput,
442
454
  annotations: {
443
455
  title: "Archive node",
444
456
  readOnlyHint: false,
445
457
  destructiveHint: true,
446
458
  idempotentHint: true,
447
- // The one node mutation that can reach a system outside Intel: archiving an agent
448
- // switches its Gate Application (#182). Nothing else here leaves the installation.
449
- openWorldHint: true,
459
+ // ⚠️ `true` until #418, and the reason is worth keeping: archiving an agent used to
460
+ // switch its Gate Application off (#182), which was the one node mutation that reached
461
+ // outside. With the agents parked nothing here leaves the installation — and an
462
+ // openWorldHint that is wrong in the cautious direction is still wrong, because it is
463
+ // the field a client decides on.
464
+ openWorldHint: false,
450
465
  },
451
- },
452
- // The caller's own bearer, exactly as the runtime proxy uses it and for the same reason:
453
- // switching a machine principal is an act in Gate, performed by the person asking.
454
- async (input) => text(await deps.nodes.archive(actor, input, { token: deps.bearer })));
466
+ }, async (input) => text(await deps.nodes.archive(actor, input)));
455
467
  }
456
- if (permits(deps.authorization, "knowledge", "share")) {
457
- server.registerTool("node_shares_list", {
458
- title: "List node shares",
468
+ if (permits(deps.authorization, "nodes", "share")) {
469
+ server.registerTool("node_grant_list", {
470
+ title: "List node grants",
459
471
  description: "List direct grants for nodes the caller is allowed to manage.",
460
472
  inputSchema: ListGrantsInput,
461
473
  annotations: {
462
- title: "List node shares",
474
+ title: "List node grants",
463
475
  readOnlyHint: true,
464
476
  destructiveHint: false,
465
477
  idempotentHint: true,
466
478
  openWorldHint: false,
467
479
  },
468
480
  }, async (input) => text(await deps.nodes.listGrants(actor, input.resourceId)));
469
- server.registerTool("node_share", {
470
- title: "Share node",
481
+ server.registerTool("node_grant_create", {
482
+ title: "Grant node access",
471
483
  description: "Grant inherited node access to a Gate user, verified email, or the organization.",
472
484
  inputSchema: ShareInput,
473
485
  annotations: {
474
- title: "Share node",
486
+ title: "Grant node access",
475
487
  readOnlyHint: false,
476
488
  destructiveHint: false,
477
489
  idempotentHint: true,
478
490
  openWorldHint: false,
479
491
  },
480
492
  }, async (input) => text(await deps.nodes.share(actor, input)));
481
- server.registerTool("node_revoke_share", {
482
- title: "Revoke node share",
493
+ server.registerTool("node_grant_revoke", {
494
+ title: "Revoke node grant",
483
495
  description: "Revoke one direct node grant by ID.",
484
496
  inputSchema: RevokeGrantInput,
485
497
  annotations: {
486
- title: "Revoke node share",
498
+ title: "Revoke node grant",
487
499
  readOnlyHint: false,
488
500
  destructiveHint: true,
489
501
  idempotentHint: true,
@@ -492,7 +504,7 @@ export async function handleMcp(request, deps) {
492
504
  }, async (input) => text(await deps.nodes.revokeGrant(actor, input)));
493
505
  }
494
506
  if (permits(deps.authorization, "flows", "read")) {
495
- server.registerTool("flows_list", {
507
+ server.registerTool("flow_list", {
496
508
  title: "List flows",
497
509
  description: "List authorized flows, either all of them or the ones filed in one folder of the shared tree.",
498
510
  inputSchema: ListFlowsInput,
@@ -519,7 +531,7 @@ export async function handleMcp(request, deps) {
519
531
  // Named like `knowledge_versions_list`, because it is the same question about the other kind of
520
532
  // thing in the tree. This pair is what turns the `versionId` that `flow_publish` and
521
533
  // `flow_publish_preview` demand into something an MCP client can actually obtain (#144).
522
- server.registerTool("flow_versions_list", {
534
+ server.registerTool("flow_version_list", {
523
535
  title: "List flow versions",
524
536
  description: "List the immutable versions of one authorized flow, oldest first, marking which of them is published. Metadata only; flow_version_get loads a version's graph.",
525
537
  inputSchema: GetFlowInput,
@@ -543,7 +555,7 @@ export async function handleMcp(request, deps) {
543
555
  openWorldHint: false,
544
556
  },
545
557
  }, async (input) => text(await deps.flows.getVersion(flowActor, input)));
546
- server.registerTool("flow_calls_list", {
558
+ server.registerTool("flow_call_list", {
547
559
  title: "List called flows",
548
560
  description: "List the flows one flow calls, read out of its graph rather than out of where it is filed.",
549
561
  inputSchema: GetFlowInput,
@@ -558,7 +570,7 @@ export async function handleMcp(request, deps) {
558
570
  // ⚠️ Only nodes the requesting user may see are in the answer, placeholders included: an edge to
559
571
  // a grey box would already say that something is there. The same rule the screen follows,
560
572
  // because it is the same service (#19).
561
- server.registerTool("flow_relation_graph", {
573
+ server.registerTool("flow_graph", {
562
574
  title: "Read the relation graph",
563
575
  description: "Read what accesses what for one folder or one flow: which flow reads which document and which flow calls which flow.",
564
576
  inputSchema: RelationGraphInput,
@@ -582,7 +594,7 @@ export async function handleMcp(request, deps) {
582
594
  openWorldHint: false,
583
595
  },
584
596
  }, async (input) => text(await deps.flows.validate(flowActor, input.flowId)));
585
- server.registerTool("flow_requirements_list", {
597
+ server.registerTool("flow_requirement_list", {
586
598
  title: "List what a flow needs",
587
599
  description: "List the 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.",
588
600
  inputSchema: GetFlowInput,
@@ -609,7 +621,7 @@ export async function handleMcp(request, deps) {
609
621
  // ⚠️ Model context like every other tool result: what a run did, never what it produced. The run
610
622
  // input and output are absent because a run reaches nodes and tools with the rights of
611
623
  // whoever started it, and only their own runs are the calling user's to read out that way.
612
- server.registerTool("flow_runs_list", {
624
+ server.registerTool("flow_run_list", {
613
625
  title: "List flow runs",
614
626
  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.",
615
627
  inputSchema: ListFlowRunsInput,
@@ -621,7 +633,7 @@ export async function handleMcp(request, deps) {
621
633
  openWorldHint: false,
622
634
  },
623
635
  }, async (input) => text(await deps.flows.listRuns(flowActor, input)));
624
- server.registerTool("flow_run_steps_list", {
636
+ server.registerTool("flow_run_step_list", {
625
637
  title: "List flow run steps",
626
638
  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.",
627
639
  inputSchema: GetFlowRunInput,
@@ -635,7 +647,7 @@ export async function handleMcp(request, deps) {
635
647
  }, async (input) => text(await deps.flows.listRunSteps(flowActor, input.runId)));
636
648
  }
637
649
  // A flow has no share tool of its own. Sharing happens on the folder a flow is filed in, through
638
- // node_share, so a narrower grant cannot sit beside the folder grant (ADR-0004 §2).
650
+ // node_grant_create, so a narrower grant cannot sit beside the folder grant (ADR-0004 §2).
639
651
  if (permits(deps.authorization, "flows", "create")) {
640
652
  server.registerTool("flow_create", {
641
653
  title: "Create flow",
@@ -677,12 +689,12 @@ export async function handleMcp(request, deps) {
677
689
  openWorldHint: false,
678
690
  },
679
691
  }, async (input) => text(await deps.flows.archive(flowActor, input)));
680
- server.registerTool("flow_save", {
681
- title: "Save flow",
692
+ server.registerTool("flow_version_create", {
693
+ title: "Create flow version",
682
694
  description: "Append a validated immutable flow graph version with optimistic concurrency.",
683
695
  inputSchema: SaveFlowVersionInput,
684
696
  annotations: {
685
- title: "Save flow",
697
+ title: "Create flow version",
686
698
  readOnlyHint: false,
687
699
  destructiveHint: false,
688
700
  idempotentHint: true,
@@ -746,7 +758,7 @@ export async function handleMcp(request, deps) {
746
758
  openWorldHint: false,
747
759
  },
748
760
  }, async (input) => text(await deps.flows.start(flowActor, input)));
749
- server.registerTool("flow_run_complete_step", {
761
+ server.registerTool("flow_run_step_complete", {
750
762
  title: "Complete flow step",
751
763
  description: "Submit a node result or semantic branch to advance a durable run without persisting credentials.",
752
764
  inputSchema: CompleteFlowRunStepInput,
@@ -774,7 +786,7 @@ export async function handleMcp(request, deps) {
774
786
  }, async (input) => text(await deps.flows.cancelRun(flowActor, input)));
775
787
  }
776
788
  if (permits(deps.authorization, "tools", "read")) {
777
- server.registerTool("tools_catalog_list", {
789
+ server.registerTool("tool_list", {
778
790
  title: "List tools",
779
791
  description: "List the MCP tools the calling user can reach through the portal, live rather than cached.",
780
792
  inputSchema: EmptyInput,
@@ -787,9 +799,9 @@ export async function handleMcp(request, deps) {
787
799
  },
788
800
  }, async () => text(await deps.tools.catalog(toolActor)));
789
801
  // The servers behind the same live list (D30), as the portal itself names them.
790
- server.registerTool("tools_servers_list", {
802
+ server.registerTool("tool_server_list", {
791
803
  title: "List tool servers",
792
- description: "List the MCP servers the calling user reaches through the portal, as the portal itself names them. These are the handles an agent definition may delegate.",
804
+ description: "List the MCP servers the calling user reaches through the portal, as the portal itself names them. The handle is the prefix on every tool name from that server, which is how tool_execute is routed.",
793
805
  inputSchema: EmptyInput,
794
806
  annotations: {
795
807
  title: "List tool servers",
@@ -801,7 +813,7 @@ export async function handleMcp(request, deps) {
801
813
  }, async () => text(await deps.tools.servers(toolActor)));
802
814
  }
803
815
  if (permits(deps.authorization, "tools", "test")) {
804
- server.registerTool("tools_test", {
816
+ server.registerTool("tool_test", {
805
817
  title: "Test tool",
806
818
  description: "Validate input and execute one explicitly read-only cached MCP tool with the caller's Gate connection.",
807
819
  inputSchema: TestToolInput,
@@ -815,7 +827,7 @@ export async function handleMcp(request, deps) {
815
827
  }, async (input) => text(await deps.tools.test(toolActor, input)));
816
828
  }
817
829
  if (permits(deps.authorization, "tools", "execute")) {
818
- server.registerTool("tools_execute", {
830
+ server.registerTool("tool_execute", {
819
831
  title: "Execute tool",
820
832
  description: "Validate and execute a discovered MCP tool with the caller's just-in-time Gate connection.",
821
833
  inputSchema: ExecuteToolInput,
@@ -1,4 +1,4 @@
1
- import { BlockNoteDocument, BlockNoteMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
1
+ import { BlockNoteDocument, BlockNoteMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract/node";
2
2
  function isRecord(value) {
3
3
  return typeof value === "object" && value !== null;
4
4
  }
@@ -1,4 +1,4 @@
1
- import { TableMediaType, } from "@anchrd/intel-contract";
1
+ import { TableMediaType } from "@anchrd/intel-contract/table";
2
2
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
4
  import { plainTitle } from "../shared/plain-title/plain-title.js";
@@ -318,8 +318,8 @@ export function createNodes(deps) {
318
318
  * there, and what was added is.
319
319
  *
320
320
  * ⚠️ Only targets this actor may see become links. The author can only insert what the picker
321
- * offers them, but `node_save` takes any content over MCP, and an unfiltered write would
322
- * turn the graph into a place where the existence of an unreachable document can be confirmed by
321
+ * offers them, but `node_version_create` takes any content over MCP, and an unfiltered write
322
+ * would turn the graph into a place where the existence of an unreachable document can be confirmed by
323
323
  * anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
324
324
  * side goes through — one rule, not two.
325
325
  *
@@ -801,17 +801,17 @@ export function createNodes(deps) {
801
801
  *
802
802
  * ⚠️ Until #390 this also switched an agent's Gate Application off with the node, and the ORDER
803
803
  * of the two writes was the whole safety argument. With the agents parked there is no second
804
- * system to keep in step, and `caller` survives only because `archive` is still the one node
805
- * mutation a surface hands its bearer to.
804
+ * system to keep in step, so #418 took the caller's bearer back out a parameter kept "in
805
+ * case" is a parameter every surface has to find a value for, and both of them were passing a
806
+ * token nothing read.
806
807
  */
807
- async archive(actor, input, _caller) {
808
+ async archive(actor, input) {
808
809
  const current = await requireVisible(actor, input.nodeId);
809
810
  if (!(await deps.repository.can(actor, current.id, "write"))) {
810
811
  throw new IntelError(403, "node_forbidden", "This node cannot be edited");
811
812
  }
812
813
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.archive", input.idempotencyKey);
813
- // A replay switches nothing a second time: the first run already did, and Gate must not learn
814
- // about a request that is not happening.
814
+ // A replay does nothing a second time and answers with what the first run made.
815
815
  if (replayedId)
816
816
  return await requireVisible(actor, replayedId);
817
817
  const updatedAt = deps.now().toISOString();
@@ -1,4 +1,7 @@
1
- import type { AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, ListNodesInput, Node, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { Flow, FlowVersion } from "@anchrd/intel-contract/flow";
2
+ import type { ArchiveNodeInput, CreateNodeInput, ListNodesInput, Node, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeKind, NodeLink, NodeTable, NodeVersion, ResolveNodeLinksInput, ResolveNodeLinksResult, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput } from "@anchrd/intel-contract/node";
3
+ import type { ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, ShareInput, ShareResult } from "@anchrd/intel-contract/share";
4
+ import type { AppendTableRowsInput, AppendTableRowsResult, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, RedefineTableInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract/table";
2
5
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
6
  export interface Actor {
4
7
  id: string;
@@ -176,9 +179,6 @@ export interface NodeAttachmentBody {
176
179
  attachment: NodeAttachment;
177
180
  body: ReadableStream<Uint8Array>;
178
181
  }
179
- export interface GateCaller {
180
- token: string;
181
- }
182
182
  export interface NodesDeps {
183
183
  repository: NodeRepository;
184
184
  content: ContentStore;
@@ -258,7 +258,7 @@ export interface NodeService {
258
258
  * by a write that half succeeded; the opposite order can only leave a live agent whose principal
259
259
  * is off, which stops working loudly and is repaired by repeating the call.
260
260
  */
261
- archive(actor: Actor, input: ArchiveNodeInput, caller: GateCaller): Promise<Node>;
261
+ archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
262
262
  listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
263
263
  listLinks(actor: Actor, nodeId: string): Promise<{
264
264
  items: NodeLink[];
@@ -284,7 +284,7 @@ export interface NodeIndexTarget {
284
284
  description: string | null;
285
285
  contentKeys: string[];
286
286
  mediaType: string;
287
- kind: "document" | "attachment" | "table";
287
+ kind: Exclude<NodeKind, "folder">;
288
288
  updatedAt: string;
289
289
  }
290
290
  /**
@@ -1,4 +1,4 @@
1
- import { type ToolServer } from "@anchrd/intel-contract";
1
+ import { type ToolServer } from "@anchrd/intel-contract/tool";
2
2
  /**
3
3
  * The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
4
4
  * same way every other tool is: `tools/call` with the asking user's portal token.
@@ -1,4 +1,4 @@
1
- import { serverOf } from "@anchrd/intel-contract";
1
+ import { serverOf } from "@anchrd/intel-contract/tool";
2
2
  import { z } from "zod";
3
3
  /**
4
4
  * The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
@@ -1,4 +1,4 @@
1
- import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolServerCatalog, ToolTestResult } from "@anchrd/intel-contract";
1
+ import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolServerCatalog, ToolTestResult } from "@anchrd/intel-contract/tool";
2
2
  export interface ToolActor {
3
3
  id: string;
4
4
  email: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.13.0",
46
+ "@anchrd/intel-contract": "^0.14.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",