@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.
- package/README.md +46 -16
- package/dist/aggregate-routes.d.ts +95 -0
- package/dist/aggregate-routes.js +332 -0
- package/dist/analyze.js +83 -16
- package/dist/entity.d.ts +31 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +57 -23
- package/dist/manifest.js +77 -30
- package/dist/measure.d.ts +11 -2
- package/dist/measure.js +14 -1
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +38 -13
- package/dist/migrate/storage.d.ts +3 -2
- package/dist/migrate/storage.js +15 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +4 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.js +6 -2
- package/dist/relationship.d.ts +36 -5
- package/dist/relationship.js +5 -3
- package/dist/render.d.ts +47 -3
- package/dist/render.js +117 -11
- package/dist/replication/model.d.ts +1 -1
- package/dist/replication/model.js +11 -1
- package/dist/service/aggregate-platform.d.ts +60 -0
- package/dist/service/aggregate-platform.js +189 -0
- package/dist/service/aggregate.d.ts +42 -0
- package/dist/service/aggregate.js +145 -0
- package/dist/service/api.d.ts +5 -3
- package/dist/service/api.js +52 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +13 -1
- package/dist/service/config.js +11 -1
- package/dist/service/errors.d.ts +32 -0
- package/dist/service/errors.js +102 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +66 -28
- package/dist/service/mcp/server.d.ts +3 -0
- package/dist/service/mcp/server.js +60 -8
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.js +8 -2
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- package/package.json +1 -1
package/dist/service/handlers.js
CHANGED
|
@@ -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 {
|
|
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 `
|
|
28
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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) =>
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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`,
|
|
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
|
-
/**
|
|
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 =
|
|
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`,
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
}
|
package/dist/service/plan.js
CHANGED
|
@@ -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
|
|
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),
|