@segmentstream-cloud/ontology 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +84 -17
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +6 -3
  7. package/dist/index.js +5 -2
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/apply.js +2 -1
  13. package/dist/migrate/plan.d.ts +1 -1
  14. package/dist/migrate/plan.js +59 -22
  15. package/dist/migrate/storage.d.ts +15 -2
  16. package/dist/migrate/storage.js +63 -5
  17. package/dist/names.d.ts +4 -2
  18. package/dist/names.js +5 -1
  19. package/dist/query.d.ts +5 -2
  20. package/dist/query.js +6 -2
  21. package/dist/relationship.d.ts +36 -5
  22. package/dist/relationship.js +5 -3
  23. package/dist/render.d.ts +47 -3
  24. package/dist/render.js +117 -11
  25. package/dist/replication/model.d.ts +1 -1
  26. package/dist/replication/model.js +11 -1
  27. package/dist/runtime/agents.d.ts +9 -0
  28. package/dist/runtime/agents.js +145 -0
  29. package/dist/runtime/service-binding.js +2 -0
  30. package/dist/service/agent/protocol.d.ts +47 -0
  31. package/dist/service/agent/protocol.js +59 -0
  32. package/dist/service/agent/server.d.ts +27 -0
  33. package/dist/service/agent/server.js +235 -0
  34. package/dist/service/aggregate-platform.d.ts +60 -0
  35. package/dist/service/aggregate-platform.js +189 -0
  36. package/dist/service/aggregate.d.ts +42 -0
  37. package/dist/service/aggregate.js +145 -0
  38. package/dist/service/api.d.ts +5 -3
  39. package/dist/service/api.js +52 -6
  40. package/dist/service/authentication.d.ts +16 -5
  41. package/dist/service/authentication.js +51 -6
  42. package/dist/service/catalog.d.ts +10 -1
  43. package/dist/service/catalog.js +12 -3
  44. package/dist/service/config.d.ts +30 -1
  45. package/dist/service/config.js +53 -1
  46. package/dist/service/errors.d.ts +38 -0
  47. package/dist/service/errors.js +118 -0
  48. package/dist/service/handlers.d.ts +2 -1
  49. package/dist/service/handlers.js +66 -28
  50. package/dist/service/mcp/server.d.ts +6 -0
  51. package/dist/service/mcp/server.js +74 -17
  52. package/dist/service/mcp/tools.d.ts +13 -2
  53. package/dist/service/mcp/tools.js +165 -6
  54. package/dist/service/plan.js +7 -1
  55. package/dist/service/runtime-contract.d.ts +68 -0
  56. package/dist/service/runtime-identity.d.ts +59 -0
  57. package/dist/service/runtime-identity.js +102 -0
  58. package/dist/service/schemas.d.ts +99 -1
  59. package/dist/service/schemas.js +150 -2
  60. package/dist/service/service.d.ts +2 -2
  61. package/dist/service/service.js +24 -6
  62. package/dist/user.d.ts +1 -1
  63. package/dist/warehouse-backed.js +222 -121
  64. package/package.json +1 -1
@@ -3,14 +3,16 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
3
3
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
4
  import { encodeManifest } from "../../manifest.js";
5
5
  import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
6
- import { renderCatalog } from "../../render.js";
6
+ import { SCOPE_MISMATCH } from "../aggregate-platform.js";
7
7
  import { CurrentPrincipal, withPrincipal } from "../authentication.js";
8
8
  import { catalogFor } from "../catalog.js";
9
+ import { describeCatalog, measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
9
10
  import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
10
11
  import { publicOrigin } from "../oauth/protocol.js";
11
12
  import { mcpChallenge, mcpResource } from "../oauth/server.js";
12
13
  import { identityToken, makeOperations, toQuery } from "../operations.js";
13
14
  import { OntologyBackend } from "../runtime-contract.js";
15
+ import { runtimeCredential } from "../runtime-identity.js";
14
16
  import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
15
17
  import { mcpTools, visibleTools } from "./tools.js";
16
18
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
@@ -79,6 +81,41 @@ function errorText(body) {
79
81
  : [];
80
82
  return `${body.detail} (${body.error}, request ${body.requestId})${issues.join("")}`;
81
83
  }
84
+ /** SegmentStream's refusal when its deployed manifest is not the one the service checked against. */
85
+ const VERSION_MISMATCH = "ontology_version_mismatch";
86
+ /** What the agent reads instead of SegmentStream's detail, for refusals it must act on itself. */
87
+ const AGENT_DETAILS = {
88
+ [VERSION_MISMATCH]: "The ontology changed; reload and try again: call describe for the current measures and " +
89
+ "names, then call aggregate with them.",
90
+ [SCOPE_MISMATCH]: "SegmentStream would read entities this app's ontology service did not check, so it refused. " +
91
+ "Retrying will not help: ask the app's operator to update @segmentstream-cloud/ontology and " +
92
+ "redeploy.",
93
+ };
94
+ /**
95
+ * SegmentStream's answer to an aggregate as the model reads it: the body as structured content, its
96
+ * warnings first in the text so they are not missed, and an error status as a tool error.
97
+ */
98
+ function aggregateOutput(answer) {
99
+ const { body } = answer;
100
+ if (answer.status >= 400) {
101
+ const code = typeof body.error === "string" ? body.error : `HTTP ${answer.status}`;
102
+ const reference = typeof body.requestId === "string" ? `, request ${body.requestId}` : "";
103
+ const detail = AGENT_DETAILS[code] ??
104
+ (typeof body.detail === "string" ? body.detail : "The aggregate failed.");
105
+ return { text: `${detail} (${code}${reference})`, structured: body, isError: true };
106
+ }
107
+ const execution = body.execution;
108
+ const warnings = Array.isArray(execution?.warnings)
109
+ ? execution.warnings.filter((warning) => typeof warning === "string")
110
+ : [];
111
+ const json = JSON.stringify(body);
112
+ return {
113
+ text: warnings.length === 0
114
+ ? json
115
+ : `Warnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}\n\n${json}`,
116
+ structured: body,
117
+ };
118
+ }
82
119
  /**
83
120
  * A failed tool call as the model reads it: `isError`, the REST route's error body as structured
84
121
  * content, and the same in a sentence. Unexpected failures are logged with their cause and answered
@@ -108,16 +145,21 @@ function toolFailure(cause, requestId) {
108
145
  isError: true,
109
146
  }));
110
147
  }
111
- function runTool({ built, operations }, tool, args, request) {
148
+ function runTool({ built, operations, aggregate }, tool, args, request) {
112
149
  return Effect.gen(function* () {
113
150
  const { target } = tool;
114
151
  switch (target.kind) {
115
152
  case "describe": {
116
153
  const principal = yield* CurrentPrincipal;
117
154
  return {
118
- text: renderCatalog(built.plan.ontology.manifest, { as: principal.permissions }),
155
+ text: describeCatalog(built.plan.ontology.manifest, principal.permissions),
119
156
  };
120
157
  }
158
+ case "aggregate": {
159
+ // The route's facade, as the caller: the arguments are the request body.
160
+ const answer = yield* aggregate(args).pipe(Effect.catchTag("InvalidRequestBody", (error) => Effect.fail(new InvalidArguments({ issues: error.issues }))));
161
+ return aggregateOutput(answer);
162
+ }
121
163
  case "me":
122
164
  return structured(yield* encoded(Me, yield* operations.caller));
123
165
  case "find": {
@@ -157,9 +199,10 @@ function callTool(server, params, request) {
157
199
  if (!isObject(args)) {
158
200
  return { kind: "invalid", message: "The tool's arguments must be an object." };
159
201
  }
160
- const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content }) => ({
202
+ const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content, isError }) => ({
161
203
  content: [{ type: "text", text }],
162
204
  ...(content === undefined ? {} : { structuredContent: content }),
205
+ ...(isError === true ? { isError } : {}),
163
206
  })), Effect.catchCause((cause) => toolFailure(cause, requestId)));
164
207
  return { kind: "called", result };
165
208
  });
@@ -201,7 +244,7 @@ function readResource({ built }, uri) {
201
244
  {
202
245
  uri,
203
246
  mimeType: "text/plain",
204
- text: renderCatalog(manifest, { as: principal.permissions }),
247
+ text: describeCatalog(manifest, principal.permissions),
205
248
  },
206
249
  ];
207
250
  default:
@@ -210,16 +253,18 @@ function readResource({ built }, uri) {
210
253
  });
211
254
  }
212
255
  /**
213
- * What an agent learns when it connects: what the server is, the entities the caller may read and
214
- * where to find the rest.
256
+ * What an agent learns when it connects: what the server is, the entities the caller may read, the
257
+ * measures of every entity they may read, stored or warehouse-backed, and where to find the rest.
215
258
  */
216
259
  function instructions({ built }, permissions) {
260
+ const { manifest } = built.plan.ontology;
217
261
  const readable = built.plan.entities
218
262
  .filter(({ name, resolved }) => {
219
263
  const definition = resolved.entity.definition;
220
264
  return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
221
265
  })
222
266
  .map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
267
+ const measures = measuresFor(manifest, permissions).map(({ name, measure }) => `- ${name}: ${measure.description}`);
223
268
  return [
224
269
  "This server is the app's ontology: the records the company works with and the actions " +
225
270
  "that change them. Every change goes through an action, is checked against your " +
@@ -227,7 +272,14 @@ function instructions({ built }, permissions) {
227
272
  readable.length > 0
228
273
  ? `Entities you may read, each with <entity>_find, <entity>_get and <entity>_history:\n${readable.join("\n")}`
229
274
  : "You may read no entities.",
230
- "Call describe for the full catalog: properties, relationships and the actions you may run.",
275
+ ...(measures.length > 0
276
+ ? [
277
+ `Measures you may aggregate with the aggregate tool; ${RECORDS_OR_AGGREGATES}:\n` +
278
+ measures.join("\n"),
279
+ ]
280
+ : []),
281
+ "Call describe for the full catalog: properties, relationships, measures and the actions " +
282
+ "you may run.",
231
283
  ].join("\n\n");
232
284
  }
233
285
  function negotiate(requested) {
@@ -295,7 +347,7 @@ function crossOrigin(request) {
295
347
  return !URL.canParse(origin) || new URL(origin).host !== new URL(publicOrigin(request)).host;
296
348
  }
297
349
  /** `POST /mcp`: one JSON-RPC message in, one JSON response out, nothing kept between requests. */
298
- function handlePost(server, verifyToken, backend) {
350
+ function handlePost(server, verifyToken, verifyRuntime, backend) {
299
351
  const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
300
352
  return (request) => Effect.gen(function* () {
301
353
  const requestId = yield* RequestId;
@@ -338,14 +390,18 @@ function handlePost(server, verifyToken, backend) {
338
390
  }
339
391
  });
340
392
  const origin = publicOrigin(request);
393
+ const runtime = runtimeCredential(verifyRuntime, request);
341
394
  // With the OAuth tables, members' access tokens work here, and a 401 tells the client where
342
- // the member authorizes it.
343
- return yield* withPrincipal(yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request), verifyToken, backend, answer, backend.oauth === undefined
344
- ? {}
345
- : {
346
- oauthResource: mcpResource(origin),
347
- challenge: (missing) => mcpChallenge(origin, missing),
348
- });
395
+ // the member authorizes it. The app's agent calls as the member it names.
396
+ return yield* withPrincipal(yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request), verifyToken, backend, answer, {
397
+ ...(runtime === undefined ? {} : { runtime }),
398
+ ...(backend.oauth === undefined
399
+ ? {}
400
+ : {
401
+ oauthResource: mcpResource(origin),
402
+ challenge: (missing) => mcpChallenge(origin, missing),
403
+ }),
404
+ });
349
405
  });
350
406
  }
351
407
  /**
@@ -362,8 +418,9 @@ export function mcpLayer(options) {
362
418
  built: options.built,
363
419
  tools,
364
420
  operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
421
+ aggregate: options.aggregates.model,
365
422
  };
366
- yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
423
+ yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
367
424
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
368
425
  yield* router.add("DELETE", MCP_PATH, METHOD_NOT_ALLOWED);
369
426
  }));
@@ -22,6 +22,8 @@ export type McpToolTarget = {
22
22
  readonly kind: "describe";
23
23
  } | {
24
24
  readonly kind: "me";
25
+ } | {
26
+ readonly kind: "aggregate";
25
27
  };
26
28
  /** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
27
29
  export interface McpTool {
@@ -34,14 +36,23 @@ export interface McpTool {
34
36
  readonly annotations: McpToolAnnotations;
35
37
  /** Every permission a caller needs to see and run the tool; none for the global tools. */
36
38
  readonly permissions: ReadonlyArray<string>;
39
+ /**
40
+ * For a tool whose input depends on what the caller may read: the caller's input schema, or
41
+ * `undefined` when the tool can do nothing for them and is not listed. Calls are checked anyway.
42
+ */
43
+ readonly inputSchemaFor?: (caller: PermissionHolder) => JsonSchemaObject | undefined;
37
44
  readonly target: McpToolTarget;
38
45
  }
39
46
  /**
40
- * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
47
+ * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
48
+ * then for every stored entity `find`, `get`,
41
49
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
42
50
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
43
51
  * `check` already refuses.
44
52
  */
45
53
  export declare function mcpTools(built: BuiltApi): ReadonlyArray<McpTool>;
46
- /** The tools a caller may see: those whose every permission the caller's roles grant. */
54
+ /**
55
+ * The tools a caller may see: those whose every permission the caller's roles grant, each with the
56
+ * caller's input schema.
57
+ */
47
58
  export declare function visibleTools(tools: ReadonlyArray<McpTool>, caller: PermissionHolder): ReadonlyArray<McpTool>;
@@ -1,11 +1,14 @@
1
1
  import { Schema } from "effect";
2
+ import { AGGREGATE_NAME_PATTERN } from "../../aggregate-routes.js";
2
3
  import { InvalidOntology } from "../../diagnostics.js";
4
+ import { isValueMatched } from "../../manifest.js";
3
5
  import { GENERATED_UPSERT_PREFIX, humanize, mcpToolName } from "../../names.js";
4
6
  import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
5
7
  import { queryJsonSchema } from "../../query.js";
8
+ import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
6
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
7
10
  import { actionDescription, requires } from "../descriptions.js";
8
- import { HistoryPage, Me } from "../schemas.js";
11
+ import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
9
12
  const DEFS = "#/$defs/";
10
13
  /**
11
14
  * A JSON Schema whose root is an object schema, as MCP requires of tool inputs and outputs: a root
@@ -94,12 +97,30 @@ function relatedHint(planned, traversal) {
94
97
  `{"${traversal.opposite}": {"some": {"id": {"eq": "${id}"}}}}.`);
95
98
  }
96
99
  }
100
+ /**
101
+ * How an agent reads the target of a value-matched relationship, which the service cannot traverse:
102
+ * the reference is an ordinary property, and the target lives in the warehouse.
103
+ */
104
+ function matchedHints(planned) {
105
+ return planned.resolved.relationships.flatMap(({ name, relationship, target }) => {
106
+ if (relationship.cardinality !== "one" || !isValueMatched(relationship)) {
107
+ return [];
108
+ }
109
+ const property = Object.keys(relationship.on ?? {})[0] ?? "";
110
+ return [
111
+ `${name} is matched on ${property}; read ${target ?? "it"} through the workspace aggregates.`,
112
+ ];
113
+ });
114
+ }
97
115
  function readTools(planned, schemas, built) {
98
116
  const { manifest } = built.plan.ontology;
99
117
  const { definition } = planned.resolved.entity;
100
118
  const label = definition.label.toLowerCase();
101
119
  const read = permissionOf(planned.name, READ_VERB);
102
- const related = planned.traversals.map((traversal) => `- ${relatedHint(planned, traversal)}`);
120
+ const related = [
121
+ ...planned.traversals.map((traversal) => relatedHint(planned, traversal)),
122
+ ...matchedHints(planned),
123
+ ].map((hint) => `- ${hint}`);
103
124
  return [
104
125
  {
105
126
  name: mcpToolName(planned.name, "find"),
@@ -181,14 +202,140 @@ const GLOBAL_TOOLS = [
181
202
  target: { kind: "me" },
182
203
  },
183
204
  ];
205
+ const AGGREGATE_NAME = {
206
+ type: "string",
207
+ pattern: AGGREGATE_NAME_PATTERN,
208
+ description: "<entity>.<property>, or a route from a measure's entity when it reaches the property's " +
209
+ "entity in several ways: <entity>.<relationship>.<property>, at most two relationships.",
210
+ };
211
+ const AGGREGATE_VALUE = {
212
+ anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
213
+ };
214
+ /** One `anyOf` branch per measure: a JSON Schema `enum` cannot describe its values. */
215
+ function measureChoice({ name, entity, measure }) {
216
+ return {
217
+ const: name,
218
+ description: `${entity.label} · ${measure.label} (${measure.unit}): ${measure.description}`,
219
+ };
220
+ }
221
+ /** The aggregate tool's input for a caller: the request of `POST /api/ontology/aggregate`. */
222
+ function aggregateInput(measures) {
223
+ return {
224
+ type: "object",
225
+ properties: {
226
+ measures: {
227
+ type: "array",
228
+ minItems: 1,
229
+ maxItems: AGGREGATE_LIMITS.measures,
230
+ uniqueItems: true,
231
+ items: { anyOf: measures.map(measureChoice) },
232
+ description: "What to compute, as <entity>.<measure>. Measures of several entities are each " +
233
+ "computed over their own entity's rows, then combined on the dimensions.",
234
+ },
235
+ dimensions: {
236
+ type: "array",
237
+ maxItems: AGGREGATE_LIMITS.dimensions,
238
+ uniqueItems: true,
239
+ items: AGGREGATE_NAME,
240
+ description: "What to group by. Every dimension must be reachable from every measure's entity.",
241
+ },
242
+ filters: {
243
+ type: "array",
244
+ maxItems: AGGREGATE_LIMITS.filters,
245
+ description: "All must hold. A filter applies to each measure's entity that reaches it; the answer " +
246
+ "warns about the others.",
247
+ items: {
248
+ type: "object",
249
+ properties: {
250
+ dimension: AGGREGATE_NAME,
251
+ operator: { enum: [...AGGREGATE_FILTER_OPERATORS] },
252
+ value: { ...AGGREGATE_VALUE, description: "The value of equals." },
253
+ values: {
254
+ type: "array",
255
+ minItems: 1,
256
+ maxItems: AGGREGATE_LIMITS.values,
257
+ items: AGGREGATE_VALUE,
258
+ description: "The values of in, or the two inclusive bounds of between, for example " +
259
+ '["2026-09-01", "2026-09-30"].',
260
+ },
261
+ },
262
+ required: ["dimension", "operator"],
263
+ additionalProperties: false,
264
+ },
265
+ },
266
+ granularity: {
267
+ enum: [...AGGREGATE_GRANULARITIES],
268
+ description: "Buckets the date or timestamp dimension granularityOf names.",
269
+ },
270
+ granularityOf: { ...AGGREGATE_NAME, description: "A requested date or timestamp dimension." },
271
+ orderBy: {
272
+ type: "array",
273
+ maxItems: AGGREGATE_LIMITS.orderBy,
274
+ items: {
275
+ type: "object",
276
+ properties: {
277
+ measure: { type: "string", description: "A requested measure." },
278
+ dimension: { type: "string", description: "A requested dimension." },
279
+ direction: { enum: ["asc", "desc"] },
280
+ },
281
+ required: ["direction"],
282
+ additionalProperties: false,
283
+ },
284
+ },
285
+ limit: {
286
+ type: "integer",
287
+ minimum: 1,
288
+ maximum: AGGREGATE_LIMITS.rows,
289
+ description: "At most 1000 rows; 100 by default.",
290
+ },
291
+ },
292
+ required: ["measures"],
293
+ additionalProperties: false,
294
+ };
295
+ }
296
+ const AGGREGATE_DESCRIPTION = "Numbers about the model: measures from one or several entities, grouped and filtered by " +
297
+ `properties of related entities. The rule: ${RECORDS_OR_AGGREGATES}.\n\n` +
298
+ "Examples, with entities sales, stock and product:\n" +
299
+ '- September revenue by brand: {"measures": ["sales.revenue"], "dimensions": ["product.brand"], ' +
300
+ '"filters": [{"dimension": "sales.order_date", "operator": "between", ' +
301
+ '"values": ["2026-09-01", "2026-09-30"]}]}\n' +
302
+ '- September units sold beside units in stock, by status: {"measures": ["sales.units", ' +
303
+ '"stock.on_hand"], "dimensions": ["product.status"], "filters": [{"dimension": ' +
304
+ '"sales.order_date", "operator": "between", "values": ["2026-09-01", "2026-09-30"]}]}\n\n' +
305
+ "Partitioned warehouse entities require a between filter on their date property; the error " +
306
+ "names it. Currency measures need a single-currency filter. " +
307
+ "Answers come from the warehouse, and stored records from its replica: execution.sources says " +
308
+ "how fresh each is, and execution.warnings says when a filter did not apply to a measure. " +
309
+ "Requires reading every entity the request reads, path intermediates included. Call describe " +
310
+ "for the model's measures and relationships.";
311
+ function aggregateTool(manifest) {
312
+ return {
313
+ name: "aggregate",
314
+ title: "Aggregate measures",
315
+ description: AGGREGATE_DESCRIPTION,
316
+ inputSchema: aggregateInput(measuresFor(manifest)),
317
+ inputSchemaFor: (caller) => {
318
+ const measures = measuresFor(manifest, caller.permissions);
319
+ return measures.length === 0 ? undefined : aggregateInput(measures);
320
+ },
321
+ annotations: READ,
322
+ permissions: [],
323
+ target: { kind: "aggregate" },
324
+ };
325
+ }
184
326
  /**
185
- * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
327
+ * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
328
+ * then for every stored entity `find`, `get`,
186
329
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
187
330
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
188
331
  * `check` already refuses.
189
332
  */
190
333
  export function mcpTools(built) {
191
- const tools = [...GLOBAL_TOOLS];
334
+ const { manifest } = built.plan.ontology;
335
+ const tools = [
336
+ ...GLOBAL_TOOLS,
337
+ ...(measuresFor(manifest).length > 0 ? [aggregateTool(manifest)] : []),
338
+ ];
192
339
  for (const planned of built.plan.entities) {
193
340
  if (planned.resolved.entity.definition.source !== undefined) {
194
341
  continue;
@@ -212,7 +359,19 @@ export function mcpTools(built) {
212
359
  }
213
360
  return tools;
214
361
  }
215
- /** The tools a caller may see: those whose every permission the caller's roles grant. */
362
+ /**
363
+ * The tools a caller may see: those whose every permission the caller's roles grant, each with the
364
+ * caller's input schema.
365
+ */
216
366
  export function visibleTools(tools, caller) {
217
- return tools.filter((tool) => tool.permissions.every((required) => isAllowed(required, caller)));
367
+ return tools.flatMap((tool) => {
368
+ if (!tool.permissions.every((required) => isAllowed(required, caller))) {
369
+ return [];
370
+ }
371
+ if (tool.inputSchemaFor === undefined) {
372
+ return [tool];
373
+ }
374
+ const inputSchema = tool.inputSchemaFor(caller);
375
+ return inputSchema === undefined ? [] : [{ ...tool, inputSchema }];
376
+ });
218
377
  }
@@ -63,13 +63,19 @@ export function planApi(ontology, generatedActions) {
63
63
  const report = (path, message) => {
64
64
  diagnostics.push({ severity: "error", code: "reserved-name", path, message });
65
65
  };
66
+ // The service cannot read warehouse records, so a relationship to one has no route.
67
+ const warehouseBacked = new Set(ontology.entities
68
+ .filter((candidate) => candidate.entity.definition.source !== undefined)
69
+ .map((candidate) => candidate.name));
66
70
  const entities = ontology.entities.map((resolved) => ({
67
71
  name: resolved.name,
68
72
  resolved,
69
73
  traversals: resolved.entity.definition.source !== undefined
70
74
  ? []
71
75
  : [
72
- ...resolved.relationships.map(declaredTraversal),
76
+ ...resolved.relationships
77
+ .filter(({ target }) => target === undefined || !warehouseBacked.has(target))
78
+ .map(declaredTraversal),
73
79
  ...inverseTraversals(resolved.name, ontology.entities),
74
80
  ],
75
81
  actions: plannedActions(resolved, (entity) => generatedActions(entity, ontology), report),
@@ -153,6 +153,72 @@ export interface OAuthStore {
153
153
  /** The member a grant acts for, with the roles they hold now; undefined once they may not sign in. */
154
154
  readonly memberPrincipal: (userId: string) => Effect.Effect<Principal | undefined>;
155
155
  }
156
+ /** A channel account waiting, for ten minutes, for a member to claim it with its pairing code. */
157
+ export interface AgentPairing {
158
+ readonly id: string;
159
+ /** The channel, such as `telegram`. */
160
+ readonly provider: string;
161
+ /** The account's id on the channel. */
162
+ readonly externalId: string;
163
+ /** How the channel names the account, shown on the connect page. */
164
+ readonly display: string | null;
165
+ readonly expiresAt: Date;
166
+ }
167
+ /** A channel account paired with a member: the app's agent acts as the member for its messages. */
168
+ export interface AgentIdentity {
169
+ readonly id: string;
170
+ readonly provider: string;
171
+ readonly externalId: string;
172
+ readonly userId: string;
173
+ readonly display: string | null;
174
+ readonly createdAt: Date;
175
+ /** When the identity stopped working; null while it is live. */
176
+ readonly revokedAt: Date | null;
177
+ }
178
+ /** A member as the agent names them: their user's name and the roles they hold now. */
179
+ export interface AgentMember {
180
+ readonly name: string;
181
+ readonly principal: Principal;
182
+ }
183
+ /**
184
+ * Where the app's agent keeps pairing codes and the identities they create: the runtime's agent
185
+ * tables in the workspace database. Codes are stored only as SHA-256 hashes. Expiry is checked
186
+ * against the database's clock; failures are defects.
187
+ */
188
+ export interface AgentStore {
189
+ /** Stores a pairing under its code's hash and forgets expired ones; false when the hash is taken. */
190
+ readonly savePairing: (pairing: AgentPairing & {
191
+ readonly codeHash: string;
192
+ }) => Effect.Effect<boolean>;
193
+ /** A pairing that has neither expired nor been approved. */
194
+ readonly findPairing: (codeHash: string) => Effect.Effect<AgentPairing | undefined>;
195
+ /**
196
+ * Consumes a live pairing, once, and pairs its account with the member. The account's previous
197
+ * identity and the member's previous identity on the same provider are revoked: each has one live
198
+ * identity per provider. `undefined` when the pairing expired or was already approved.
199
+ */
200
+ readonly approvePairing: (input: {
201
+ readonly codeHash: string;
202
+ readonly userId: string;
203
+ readonly identityId: string;
204
+ }) => Effect.Effect<AgentIdentity | undefined>;
205
+ /** The account's live identity, else its latest revoked one, else `undefined`. */
206
+ readonly findIdentity: (input: {
207
+ readonly provider: string;
208
+ readonly externalId: string;
209
+ }) => Effect.Effect<AgentIdentity | undefined>;
210
+ /** The member's live identities, oldest first. */
211
+ readonly listIdentities: (userId: string) => Effect.Effect<ReadonlyArray<AgentIdentity>>;
212
+ /** Revokes one of the member's live identities; false when they have no such identity. */
213
+ readonly revokeIdentity: (input: {
214
+ readonly id: string;
215
+ readonly userId: string;
216
+ }) => Effect.Effect<boolean>;
217
+ /** An active member by user id; `undefined` for an unknown user, a service or an inactive member. */
218
+ readonly member: (userId: string) => Effect.Effect<AgentMember | undefined>;
219
+ /** The member a runtime caller acts for: active, and with a live identity. */
220
+ readonly actingMember: (userId: string) => Effect.Effect<Principal | undefined>;
221
+ }
156
222
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
157
223
  readonly blobs?: BlobRuntime;
158
224
  readonly maintainBlobs?: Effect.Effect<{
@@ -192,6 +258,8 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
192
258
  readonly replicationEnabled?: Effect.Effect<boolean>;
193
259
  /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
194
260
  readonly oauth?: OAuthStore;
261
+ /** The agent tables, which pair channel accounts with members for the app's own agent. */
262
+ readonly agents?: AgentStore;
195
263
  }>;
196
264
  /**
197
265
  * The runtime as the HTTP service sees it, with its database and ontology already bound. Failures
@@ -0,0 +1,59 @@
1
+ import { Effect } from "effect";
2
+ import type { HttpServerRequest } from "effect/unstable/http";
3
+ import { type JWTVerifyGetKey } from "jose";
4
+ /**
5
+ * The header in which a runtime caller names the member it acts for: the member's user id. The
6
+ * service honours it only with a verified runtime token, so nobody else can claim a member with it.
7
+ */
8
+ export declare const ACT_AS_HEADER = "x-segmentstream-act-as";
9
+ /**
10
+ * Whom the service trusts as its runtime caller: Vercel OIDC tokens of the functions of this
11
+ * deployment's own project. The app's agent is a service of the same Vercel project as the ontology
12
+ * service, so `VERCEL_PROJECT_ID` pins exactly the deployment's own functions.
13
+ */
14
+ export interface RuntimeIdentityConfig {
15
+ /** The team's issuer: `https://oidc.vercel.com/<team slug>` in team issuer mode. */
16
+ readonly issuer: string;
17
+ /** The audience Vercel issues function tokens for: `https://vercel.com/<team slug>`. */
18
+ readonly audience: string;
19
+ /** The project the token must name, from `VERCEL_PROJECT_ID`. */
20
+ readonly projectId: string;
21
+ }
22
+ export type RuntimeVerification = {
23
+ readonly status: "valid";
24
+ }
25
+ /** The token claims the runtime issuer but fails verification; `reason` names what failed. */
26
+ | {
27
+ readonly status: "invalid";
28
+ readonly reason: string;
29
+ } | {
30
+ readonly status: "unavailable";
31
+ }
32
+ /** The token does not claim the runtime issuer: it is some other credential. */
33
+ | {
34
+ readonly status: "other";
35
+ };
36
+ /** Verifies a bearer token as the deployment's own runtime token. */
37
+ export type RuntimeTokenVerifier = (token: string) => Promise<RuntimeVerification>;
38
+ /** The issuer's key set, at `<issuer>/.well-known/jwks` as Vercel publishes it. */
39
+ export declare function runtimeKeySet(issuer: string): JWTVerifyGetKey;
40
+ /**
41
+ * Verifies the Vercel OIDC token of a function of this deployment's project, as backend-next
42
+ * verifies the ontology service's own: RS256 from the team's issuer, for the team's audience,
43
+ * unexpired, with `project_id` equal to the configured project and `environment` `production`.
44
+ * The issuer and the audience name the team, so no further owner check is needed. A token that
45
+ * claims another issuer is `other`, without a key-set fetch.
46
+ */
47
+ export declare function makeRuntimeTokenVerifier(config: RuntimeIdentityConfig, keySet: JWTVerifyGetKey): RuntimeTokenVerifier;
48
+ /** The runtime verifier on the issuer's remote key set, which jose caches between requests. */
49
+ export declare function runtimeTokenVerifier(config: RuntimeIdentityConfig): RuntimeTokenVerifier;
50
+ /** A runtime token that failed verification: the reason is logged, the token never. */
51
+ export declare function logRuntimeRejection(reason: string): Effect.Effect<void>;
52
+ /** How a request may authenticate as the runtime: the verifier and the member it names, if any. */
53
+ export interface RuntimeCredential {
54
+ readonly verify: RuntimeTokenVerifier;
55
+ /** The user id in `X-SegmentStream-Act-As`; `undefined` without the header. */
56
+ readonly actAs: string | undefined;
57
+ }
58
+ /** The request's runtime credential, or `undefined` while runtime authentication is disabled. */
59
+ export declare function runtimeCredential(verify: RuntimeTokenVerifier | undefined, request: HttpServerRequest.HttpServerRequest): RuntimeCredential | undefined;