@segmentstream-cloud/ontology 0.12.0 → 0.13.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 (50) hide show
  1. package/README.md +46 -16
  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 +1 -1
  7. package/dist/index.js +1 -1
  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/plan.d.ts +1 -1
  13. package/dist/migrate/plan.js +38 -13
  14. package/dist/migrate/storage.d.ts +3 -2
  15. package/dist/migrate/storage.js +15 -5
  16. package/dist/names.d.ts +4 -2
  17. package/dist/names.js +4 -1
  18. package/dist/query.d.ts +5 -2
  19. package/dist/query.js +6 -2
  20. package/dist/relationship.d.ts +36 -5
  21. package/dist/relationship.js +5 -3
  22. package/dist/render.d.ts +47 -3
  23. package/dist/render.js +117 -11
  24. package/dist/replication/model.d.ts +1 -1
  25. package/dist/replication/model.js +11 -1
  26. package/dist/service/aggregate-platform.d.ts +60 -0
  27. package/dist/service/aggregate-platform.js +189 -0
  28. package/dist/service/aggregate.d.ts +42 -0
  29. package/dist/service/aggregate.js +145 -0
  30. package/dist/service/api.d.ts +5 -3
  31. package/dist/service/api.js +52 -6
  32. package/dist/service/catalog.d.ts +10 -1
  33. package/dist/service/catalog.js +12 -3
  34. package/dist/service/config.d.ts +13 -1
  35. package/dist/service/config.js +11 -1
  36. package/dist/service/errors.d.ts +32 -0
  37. package/dist/service/errors.js +102 -0
  38. package/dist/service/handlers.d.ts +2 -1
  39. package/dist/service/handlers.js +66 -28
  40. package/dist/service/mcp/server.d.ts +3 -0
  41. package/dist/service/mcp/server.js +60 -8
  42. package/dist/service/mcp/tools.d.ts +13 -2
  43. package/dist/service/mcp/tools.js +165 -6
  44. package/dist/service/plan.js +7 -1
  45. package/dist/service/schemas.d.ts +99 -1
  46. package/dist/service/schemas.js +150 -2
  47. package/dist/service/service.js +8 -2
  48. package/dist/user.d.ts +1 -1
  49. package/dist/warehouse-backed.js +222 -121
  50. package/package.json +1 -1
@@ -3,11 +3,12 @@ import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
4
  import { STORAGE_GROUP } from "../storage/api.js";
5
5
  import { Conflict } from "../errors.js";
6
+ import { isValueMatched } from "../manifest.js";
6
7
  import { EMAIL_PATTERN } from "../property.js";
7
- import { renderCatalog } from "../render.js";
8
- import { SERVICE_GROUP } from "./api.js";
8
+ import { SERVICE_GROUP, hasMeasures } from "./api.js";
9
9
  import { CurrentPrincipal } from "./authentication.js";
10
10
  import { catalogFor, graphOf } from "./catalog.js";
11
+ import { describeCatalog } from "../render.js";
11
12
  import { InvalidRequestBody, SignInCheckLimited, schemaIssues } from "./errors.js";
12
13
  import { identityToken, makeOperations, requireRead, toQuery, } from "./operations.js";
13
14
  import { clientAddress, fixedWindowLimiter, } from "./rate-limit.js";
@@ -24,34 +25,43 @@ function isJsonMediaType(contentType) {
24
25
  const mediaType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
25
26
  return mediaType === "application/json" || mediaType.endsWith("+json");
26
27
  }
27
- /** Reads `{ input, options? }`. The input itself is validated by `runAction`. */
28
- function readEnvelope(request) {
28
+ /** Reads a JSON body: `application/json` or a `+json` type, or no content type at all. */
29
+ function readJson(request) {
29
30
  return Effect.gen(function* () {
30
31
  if (!isJsonMediaType(request.headers["content-type"])) {
31
32
  return yield* new InvalidRequestBody({ reason: "unsupported_media_type", issues: [] });
32
33
  }
33
34
  const text = yield* Effect.orDie(request.text);
34
- let body;
35
35
  try {
36
- body = JSON.parse(text);
36
+ return JSON.parse(text);
37
37
  }
38
38
  catch {
39
39
  return yield* new InvalidRequestBody({ reason: "invalid_json", issues: [] });
40
40
  }
41
- return yield* Schema.decodeUnknownEffect(ActionEnvelope)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error.issue) })));
42
41
  });
43
42
  }
44
- function entityHandlers(built, planned, operationsOf) {
43
+ /** Reads `{ input, options? }`. The input itself is validated by `runAction`. */
44
+ function readEnvelope(request) {
45
+ return readJson(request).pipe(Effect.flatMap((body) => Schema.decodeUnknownEffect(ActionEnvelope)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({
46
+ reason: "invalid_request",
47
+ issues: schemaIssues(error.issue),
48
+ })))));
49
+ }
50
+ function entityHandlers(built, planned, operationsOf, aggregates) {
51
+ // Registered beside the record routes; `aggregate` is a static segment, record ids are UUIDs.
52
+ const withAggregate = (handlers) => hasMeasures(planned)
53
+ ? handlers.handleRaw("aggregate", aggregateHandler(aggregates.entity(planned.name)))
54
+ : handlers;
45
55
  return dynamicGroup(built.api, planned.name, (handlers) => Effect.gen(function* () {
46
56
  if (planned.resolved.entity.definition.source !== undefined) {
47
- return handlers.handleRaw("sourceCollection", () => requireRead([planned.name]).pipe(Effect.andThen(() => HttpServerResponse.json({
57
+ return withAggregate(handlers).handleRaw("sourceCollection", () => requireRead([planned.name]).pipe(Effect.andThen(() => HttpServerResponse.json({
48
58
  error: "not_found",
49
59
  detail: `${planned.name} uses a warehouse source; query it through the workspace API.`,
50
60
  }, { status: 404 }))));
51
61
  }
52
62
  const backend = yield* OntologyBackend;
53
63
  const operations = operationsOf(backend);
54
- let registered = handlers
64
+ let registered = withAggregate(handlers)
55
65
  .handle("find", (request) => operations.find(planned, toQuery(request.query)))
56
66
  .handle("query", (request) => operations.find(planned, toQuery(request.payload)))
57
67
  .handle("get", (request) => operations.get(planned, request.params.id))
@@ -108,7 +118,21 @@ function checkSignIn(backend, limiter) {
108
118
  return yield* backend.checkSignIn(email);
109
119
  });
110
120
  }
111
- function serviceHandlers(built, operationsOf) {
121
+ /**
122
+ * `POST /api/ontology/aggregate` and `POST /api/ontology/<entity>/aggregate`: the facade's answer is
123
+ * SegmentStream's, so its status and JSON body pass through as they came, with its `Retry-After` on
124
+ * a 429 or a 503.
125
+ */
126
+ function aggregateHandler(aggregate) {
127
+ return ({ request }) => readJson(request).pipe(Effect.flatMap(aggregate), Effect.map((answer) => HttpServerResponse.text(answer.text, {
128
+ status: answer.status,
129
+ contentType: "application/json",
130
+ ...(answer.retryAfter === undefined
131
+ ? {}
132
+ : { headers: { "retry-after": answer.retryAfter } }),
133
+ })));
134
+ }
135
+ function serviceHandlers(built, operationsOf, aggregate) {
112
136
  const { manifest } = built.plan.ontology;
113
137
  const graph = graphOf(manifest);
114
138
  const signInLimiter = fixedWindowLimiter(SIGN_IN_CHECK_LIMIT);
@@ -130,29 +154,43 @@ function serviceHandlers(built, operationsOf) {
130
154
  .handle("openapi", () => Effect.sync(() => {
131
155
  if (openApi === undefined) {
132
156
  openApi = OpenApi.fromApi(built.api);
133
- for (const entity of manifest.entities.filter((entity) => entity.source !== undefined)) {
134
- const document = Schema.toJsonSchemaDocument(built.schemas.get(entity.name).record);
135
- openApi.components.schemas ??= {};
136
- openApi.components.schemas[entity.name] = {
137
- ...(document.definitions[`${entity.name
138
- .split("_")
139
- .map((part) => part[0].toUpperCase() + part.slice(1))
140
- .join("")}Record`] ?? document.schema),
141
- "x-ontology-source": entity.source,
142
- "x-ontology-measures": entity.measures,
143
- "x-ontology-relationships": entity.relationships,
144
- };
157
+ openApi.components.schemas ??= {};
158
+ for (const entity of manifest.entities) {
159
+ const recordName = `${entity.name
160
+ .split("_")
161
+ .map((part) => part[0].toUpperCase() + part.slice(1))
162
+ .join("")}Record`;
163
+ if (entity.source !== undefined) {
164
+ const document = Schema.toJsonSchemaDocument(built.schemas.get(entity.name).record);
165
+ openApi.components.schemas[entity.name] = {
166
+ ...(document.definitions[recordName] ?? document.schema),
167
+ "x-ontology-source": entity.source,
168
+ "x-ontology-measures": entity.measures,
169
+ "x-ontology-relationships": entity.relationships,
170
+ };
171
+ continue;
172
+ }
173
+ // A value-matched relationship has no key field or route; only this names it.
174
+ const matched = entity.relationships.filter(isValueMatched);
175
+ const record = openApi.components.schemas[recordName];
176
+ if (matched.length > 0 && record !== undefined) {
177
+ openApi.components.schemas[recordName] = {
178
+ ...record,
179
+ "x-ontology-relationships": matched,
180
+ };
181
+ }
145
182
  }
146
183
  }
147
184
  return openApi;
148
185
  }))
149
- .handle("describe", (request) => Effect.map(permissionsFor(request.query.as), (permissions) => renderCatalog(manifest, permissions === undefined ? {} : { as: permissions })))
186
+ .handle("describe", (request) => Effect.map(permissionsFor(request.query.as), (permissions) => describeCatalog(manifest, permissions)))
150
187
  .handle("status", () => Effect.map(backend.appliedHash, (appliedHash) => ({
151
188
  hash: manifest.hash,
152
189
  appliedHash: appliedHash ?? null,
153
190
  upToDate: appliedHash === manifest.hash,
154
191
  })))
155
- .handle("signInCheck", checkSignIn(backend, signInLimiter));
192
+ .handle("signInCheck", checkSignIn(backend, signInLimiter))
193
+ .handleRaw("aggregate", aggregateHandler(aggregate));
156
194
  }));
157
195
  }
158
196
  function storageHandlers(built) {
@@ -178,14 +216,14 @@ function storageHandlers(built) {
178
216
  }));
179
217
  }
180
218
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
181
- export function handlersLayer(built, hooks, connections, replicate) {
219
+ export function handlersLayer(built, hooks, connections, replicate, aggregates) {
182
220
  const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
183
221
  const layers = [
184
- serviceHandlers(built, operationsOf),
222
+ serviceHandlers(built, operationsOf, aggregates.model),
185
223
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
186
224
  ? [storageHandlers(built)]
187
225
  : []),
188
- ...built.plan.entities.map((planned) => entityHandlers(built, planned, operationsOf)),
226
+ ...built.plan.entities.map((planned) => entityHandlers(built, planned, operationsOf, aggregates)),
189
227
  ];
190
228
  const [first, ...rest] = layers;
191
229
  return Layer.mergeAll(first, ...rest);
@@ -1,5 +1,6 @@
1
1
  import { Layer } from "effect";
2
2
  import { HttpRouter } from "effect/unstable/http";
3
+ import type { Aggregates } from "../aggregate.js";
3
4
  import type { BuiltApi } from "../api.js";
4
5
  import { type TokenVerifier } from "../authentication.js";
5
6
  import type { AfterCommitHook } from "../config.js";
@@ -17,6 +18,8 @@ export interface McpServerOptions {
17
18
  readonly connections: ConnectionTransportFactory;
18
19
  readonly replicate: ReplicationKick;
19
20
  readonly verifyToken: TokenVerifier;
21
+ /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
22
+ readonly aggregates: Aggregates;
20
23
  }
21
24
  /**
22
25
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
@@ -3,9 +3,10 @@ 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";
@@ -79,6 +80,41 @@ function errorText(body) {
79
80
  : [];
80
81
  return `${body.detail} (${body.error}, request ${body.requestId})${issues.join("")}`;
81
82
  }
83
+ /** SegmentStream's refusal when its deployed manifest is not the one the service checked against. */
84
+ const VERSION_MISMATCH = "ontology_version_mismatch";
85
+ /** What the agent reads instead of SegmentStream's detail, for refusals it must act on itself. */
86
+ const AGENT_DETAILS = {
87
+ [VERSION_MISMATCH]: "The ontology changed; reload and try again: call describe for the current measures and " +
88
+ "names, then call aggregate with them.",
89
+ [SCOPE_MISMATCH]: "SegmentStream would read entities this app's ontology service did not check, so it refused. " +
90
+ "Retrying will not help: ask the app's operator to update @segmentstream-cloud/ontology and " +
91
+ "redeploy.",
92
+ };
93
+ /**
94
+ * SegmentStream's answer to an aggregate as the model reads it: the body as structured content, its
95
+ * warnings first in the text so they are not missed, and an error status as a tool error.
96
+ */
97
+ function aggregateOutput(answer) {
98
+ const { body } = answer;
99
+ if (answer.status >= 400) {
100
+ const code = typeof body.error === "string" ? body.error : `HTTP ${answer.status}`;
101
+ const reference = typeof body.requestId === "string" ? `, request ${body.requestId}` : "";
102
+ const detail = AGENT_DETAILS[code] ??
103
+ (typeof body.detail === "string" ? body.detail : "The aggregate failed.");
104
+ return { text: `${detail} (${code}${reference})`, structured: body, isError: true };
105
+ }
106
+ const execution = body.execution;
107
+ const warnings = Array.isArray(execution?.warnings)
108
+ ? execution.warnings.filter((warning) => typeof warning === "string")
109
+ : [];
110
+ const json = JSON.stringify(body);
111
+ return {
112
+ text: warnings.length === 0
113
+ ? json
114
+ : `Warnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}\n\n${json}`,
115
+ structured: body,
116
+ };
117
+ }
82
118
  /**
83
119
  * A failed tool call as the model reads it: `isError`, the REST route's error body as structured
84
120
  * content, and the same in a sentence. Unexpected failures are logged with their cause and answered
@@ -108,16 +144,21 @@ function toolFailure(cause, requestId) {
108
144
  isError: true,
109
145
  }));
110
146
  }
111
- function runTool({ built, operations }, tool, args, request) {
147
+ function runTool({ built, operations, aggregate }, tool, args, request) {
112
148
  return Effect.gen(function* () {
113
149
  const { target } = tool;
114
150
  switch (target.kind) {
115
151
  case "describe": {
116
152
  const principal = yield* CurrentPrincipal;
117
153
  return {
118
- text: renderCatalog(built.plan.ontology.manifest, { as: principal.permissions }),
154
+ text: describeCatalog(built.plan.ontology.manifest, principal.permissions),
119
155
  };
120
156
  }
157
+ case "aggregate": {
158
+ // The route's facade, as the caller: the arguments are the request body.
159
+ const answer = yield* aggregate(args).pipe(Effect.catchTag("InvalidRequestBody", (error) => Effect.fail(new InvalidArguments({ issues: error.issues }))));
160
+ return aggregateOutput(answer);
161
+ }
121
162
  case "me":
122
163
  return structured(yield* encoded(Me, yield* operations.caller));
123
164
  case "find": {
@@ -157,9 +198,10 @@ function callTool(server, params, request) {
157
198
  if (!isObject(args)) {
158
199
  return { kind: "invalid", message: "The tool's arguments must be an object." };
159
200
  }
160
- const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content }) => ({
201
+ const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content, isError }) => ({
161
202
  content: [{ type: "text", text }],
162
203
  ...(content === undefined ? {} : { structuredContent: content }),
204
+ ...(isError === true ? { isError } : {}),
163
205
  })), Effect.catchCause((cause) => toolFailure(cause, requestId)));
164
206
  return { kind: "called", result };
165
207
  });
@@ -201,7 +243,7 @@ function readResource({ built }, uri) {
201
243
  {
202
244
  uri,
203
245
  mimeType: "text/plain",
204
- text: renderCatalog(manifest, { as: principal.permissions }),
246
+ text: describeCatalog(manifest, principal.permissions),
205
247
  },
206
248
  ];
207
249
  default:
@@ -210,16 +252,18 @@ function readResource({ built }, uri) {
210
252
  });
211
253
  }
212
254
  /**
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.
255
+ * What an agent learns when it connects: what the server is, the entities the caller may read, the
256
+ * measures of every entity they may read, stored or warehouse-backed, and where to find the rest.
215
257
  */
216
258
  function instructions({ built }, permissions) {
259
+ const { manifest } = built.plan.ontology;
217
260
  const readable = built.plan.entities
218
261
  .filter(({ name, resolved }) => {
219
262
  const definition = resolved.entity.definition;
220
263
  return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
221
264
  })
222
265
  .map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
266
+ const measures = measuresFor(manifest, permissions).map(({ name, measure }) => `- ${name}: ${measure.description}`);
223
267
  return [
224
268
  "This server is the app's ontology: the records the company works with and the actions " +
225
269
  "that change them. Every change goes through an action, is checked against your " +
@@ -227,7 +271,14 @@ function instructions({ built }, permissions) {
227
271
  readable.length > 0
228
272
  ? `Entities you may read, each with <entity>_find, <entity>_get and <entity>_history:\n${readable.join("\n")}`
229
273
  : "You may read no entities.",
230
- "Call describe for the full catalog: properties, relationships and the actions you may run.",
274
+ ...(measures.length > 0
275
+ ? [
276
+ `Measures you may aggregate with the aggregate tool; ${RECORDS_OR_AGGREGATES}:\n` +
277
+ measures.join("\n"),
278
+ ]
279
+ : []),
280
+ "Call describe for the full catalog: properties, relationships, measures and the actions " +
281
+ "you may run.",
231
282
  ].join("\n\n");
232
283
  }
233
284
  function negotiate(requested) {
@@ -362,6 +413,7 @@ export function mcpLayer(options) {
362
413
  built: options.built,
363
414
  tools,
364
415
  operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
416
+ aggregate: options.aggregates.model,
365
417
  };
366
418
  yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
367
419
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
@@ -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),