@segmentstream-cloud/ontology 0.9.0 → 0.11.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 (90) hide show
  1. package/dist/action.d.ts +19 -0
  2. package/dist/action.js +1 -0
  3. package/dist/analyze.d.ts +3 -1
  4. package/dist/analyze.js +97 -2
  5. package/dist/bin/migrate.js +3 -2
  6. package/dist/blob.d.ts +39 -0
  7. package/dist/blob.js +49 -0
  8. package/dist/diagnostics.d.ts +3 -3
  9. package/dist/diagnostics.js +1 -0
  10. package/dist/entity.d.ts +5 -3
  11. package/dist/index.d.ts +9 -4
  12. package/dist/index.js +6 -1
  13. package/dist/manifest.d.ts +71 -7
  14. package/dist/manifest.js +18 -1
  15. package/dist/migrate/apply.d.ts +7 -0
  16. package/dist/migrate/apply.js +18 -1
  17. package/dist/migrate/index.d.ts +1 -1
  18. package/dist/migrate/index.js +1 -1
  19. package/dist/migrate/model.d.ts +15 -2
  20. package/dist/migrate/plan.d.ts +6 -0
  21. package/dist/migrate/plan.js +35 -3
  22. package/dist/migrate/storage.d.ts +14 -0
  23. package/dist/migrate/storage.js +95 -0
  24. package/dist/names.d.ts +9 -1
  25. package/dist/names.js +11 -0
  26. package/dist/nitro.d.ts +13 -0
  27. package/dist/nitro.js +18 -0
  28. package/dist/property.d.ts +10 -1
  29. package/dist/property.js +12 -1
  30. package/dist/query.js +6 -1
  31. package/dist/runtime/generated-actions.d.ts +3 -0
  32. package/dist/runtime/generated-actions.js +10 -5
  33. package/dist/runtime/history.d.ts +3 -0
  34. package/dist/runtime/oauth.d.ts +9 -0
  35. package/dist/runtime/oauth.js +165 -0
  36. package/dist/runtime/pipeline.d.ts +5 -1
  37. package/dist/runtime/pipeline.js +87 -3
  38. package/dist/runtime/principals.d.ts +5 -0
  39. package/dist/runtime/principals.js +15 -0
  40. package/dist/runtime/records.js +9 -0
  41. package/dist/runtime/service-binding.d.ts +2 -1
  42. package/dist/runtime/service-binding.js +15 -2
  43. package/dist/runtime/writes.js +17 -3
  44. package/dist/service/api.js +19 -10
  45. package/dist/service/authentication.d.ts +27 -1
  46. package/dist/service/authentication.js +48 -26
  47. package/dist/service/config.d.ts +8 -0
  48. package/dist/service/config.js +18 -0
  49. package/dist/service/descriptions.d.ts +8 -0
  50. package/dist/service/descriptions.js +15 -0
  51. package/dist/service/errors.d.ts +5 -1
  52. package/dist/service/errors.js +6 -2
  53. package/dist/service/handlers.d.ts +1 -4
  54. package/dist/service/handlers.js +59 -203
  55. package/dist/service/mcp/server.d.ts +24 -0
  56. package/dist/service/mcp/server.js +370 -0
  57. package/dist/service/mcp/tools.d.ts +47 -0
  58. package/dist/service/mcp/tools.js +218 -0
  59. package/dist/service/oauth/protocol.d.ts +52 -0
  60. package/dist/service/oauth/protocol.js +110 -0
  61. package/dist/service/oauth/server.d.ts +27 -0
  62. package/dist/service/oauth/server.js +576 -0
  63. package/dist/service/operations.d.ts +114 -0
  64. package/dist/service/operations.js +218 -0
  65. package/dist/service/runtime-contract.d.ts +98 -0
  66. package/dist/service/schemas.d.ts +18 -2
  67. package/dist/service/schemas.js +18 -2
  68. package/dist/service/service.d.ts +3 -3
  69. package/dist/service/service.js +27 -7
  70. package/dist/storage/api.d.ts +4 -0
  71. package/dist/storage/api.js +39 -0
  72. package/dist/storage/authorization.d.ts +8 -0
  73. package/dist/storage/authorization.js +30 -0
  74. package/dist/storage/maintenance-auth.d.ts +20 -0
  75. package/dist/storage/maintenance-auth.js +24 -0
  76. package/dist/storage/maintenance.d.ts +7 -0
  77. package/dist/storage/maintenance.js +50 -0
  78. package/dist/storage/neon.d.ts +3 -0
  79. package/dist/storage/neon.js +212 -0
  80. package/dist/storage/operator.d.ts +10 -0
  81. package/dist/storage/operator.js +30 -0
  82. package/dist/storage/provider.d.ts +80 -0
  83. package/dist/storage/provider.js +20 -0
  84. package/dist/storage/sessions.d.ts +87 -0
  85. package/dist/storage/sessions.js +341 -0
  86. package/dist/storage/tables.d.ts +2 -0
  87. package/dist/storage/tables.js +55 -0
  88. package/dist/storage/writes.d.ts +7 -0
  89. package/dist/storage/writes.js +88 -0
  90. package/package.json +8 -1
@@ -0,0 +1,370 @@
1
+ import { Cause, Data, Effect, Layer, Option, Schema } from "effect";
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { encodeManifest } from "../../manifest.js";
5
+ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
6
+ import { renderCatalog } from "../../render.js";
7
+ import { CurrentPrincipal, withPrincipal } from "../authentication.js";
8
+ import { catalogFor } from "../catalog.js";
9
+ import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
10
+ import { publicOrigin } from "../oauth/protocol.js";
11
+ import { mcpChallenge, mcpResource } from "../oauth/server.js";
12
+ import { identityToken, makeOperations, toQuery } from "../operations.js";
13
+ import { OntologyBackend } from "../runtime-contract.js";
14
+ import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
15
+ import { mcpTools, visibleTools } from "./tools.js";
16
+ /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
17
+ export const MCP_PATH = "/mcp";
18
+ /** The MCP revisions the server speaks, newest first. */
19
+ export const MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
20
+ const PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
21
+ /** JSON-RPC and MCP error codes. */
22
+ const PARSE_ERROR = -32700;
23
+ const INVALID_REQUEST = -32600;
24
+ const METHOD_NOT_FOUND = -32601;
25
+ const INVALID_PARAMS = -32602;
26
+ const RESOURCE_NOT_FOUND = -32002;
27
+ function isObject(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function rpcResult(id, result) {
31
+ return HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id, result });
32
+ }
33
+ function rpcError(id, code, message, status = 200) {
34
+ return HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id, error: { code, message } }, { status });
35
+ }
36
+ /** A message that is not a request: a notification, or a response to a request of the server's. */
37
+ const ACCEPTED = HttpServerResponse.empty({ status: 202 });
38
+ const METHOD_NOT_ALLOWED = HttpServerResponse.empty({ status: 405, headers: { allow: "POST" } });
39
+ function classify(body) {
40
+ if (!isObject(body) || body.jsonrpc !== "2.0") {
41
+ return { kind: "invalid", id: null };
42
+ }
43
+ const { id, method, params } = body;
44
+ const validId = typeof id === "string" || typeof id === "number";
45
+ if (typeof method !== "string") {
46
+ // A response to a request the server sent; this server sends none, so there is nothing to do.
47
+ return "result" in body || "error" in body ? { kind: "other" } : { kind: "invalid", id: null };
48
+ }
49
+ if (id === undefined) {
50
+ return { kind: "other" };
51
+ }
52
+ if (!validId || (params !== undefined && !isObject(params))) {
53
+ return { kind: "invalid", id: validId ? id : null };
54
+ }
55
+ return { kind: "request", id, method, params: params ?? {} };
56
+ }
57
+ /** Arguments a tool could not decode. Unlike a protocol error, the model reads it and corrects itself. */
58
+ class InvalidArguments extends Data.TaggedError("InvalidArguments") {
59
+ }
60
+ /** Decodes a tool's arguments; the service's schemas need no services. */
61
+ function decodeArguments(schema, args) {
62
+ return Schema.decodeUnknownEffect(schema)(args, {
63
+ errors: "all",
64
+ onExcessProperty: "error",
65
+ }).pipe(Effect.mapError((error) => new InvalidArguments({ issues: schemaIssues(error.issue) })));
66
+ }
67
+ /** What the service sends: a value encoded with the schema of the matching HTTP route. */
68
+ function encoded(schema, value) {
69
+ return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
70
+ }
71
+ const GetArguments = Schema.Struct({ id: IdParam });
72
+ const HistoryArguments = Schema.Struct({ id: IdParam, ...HistoryParams });
73
+ function structured(value) {
74
+ return { text: JSON.stringify(value), structured: value };
75
+ }
76
+ function errorText(body) {
77
+ const issues = Array.isArray(body.issues)
78
+ ? body.issues.map(({ path, message }) => `\n- ${path.length > 0 ? `${path.join(".")}: ` : ""}${message}`)
79
+ : [];
80
+ return `${body.detail} (${body.error}, request ${body.requestId})${issues.join("")}`;
81
+ }
82
+ /**
83
+ * A failed tool call as the model reads it: `isError`, the REST route's error body as structured
84
+ * content, and the same in a sentence. Unexpected failures are logged with their cause and answered
85
+ * with the request id only.
86
+ */
87
+ function toolFailure(cause, requestId) {
88
+ if (Cause.hasInterruptsOnly(cause)) {
89
+ return Effect.failCause(cause);
90
+ }
91
+ const error = Option.getOrUndefined(Cause.findErrorOption(cause));
92
+ const expected = error instanceof InvalidArguments
93
+ ? {
94
+ error: "invalid_request",
95
+ detail: "The tool's arguments are invalid.",
96
+ requestId,
97
+ issues: error.issues,
98
+ }
99
+ : error === undefined
100
+ ? undefined
101
+ : expectedFailure(error, requestId)?.body;
102
+ const failure = expected === undefined
103
+ ? Effect.logError("ontology.mcp.tool_failed", cause).pipe(Effect.annotateLogs({ requestId }), Effect.as(internalError(requestId).body))
104
+ : Effect.succeed(expected);
105
+ return Effect.map(failure, (body) => ({
106
+ content: [{ type: "text", text: errorText(body) }],
107
+ structuredContent: errorBodyForWire(body),
108
+ isError: true,
109
+ }));
110
+ }
111
+ function runTool({ built, operations }, tool, args, request) {
112
+ return Effect.gen(function* () {
113
+ const { target } = tool;
114
+ switch (target.kind) {
115
+ case "describe": {
116
+ const principal = yield* CurrentPrincipal;
117
+ return {
118
+ text: renderCatalog(built.plan.ontology.manifest, { as: principal.permissions }),
119
+ };
120
+ }
121
+ case "me":
122
+ return structured(yield* encoded(Me, yield* operations.caller));
123
+ case "find": {
124
+ const schemas = built.schemas.get(target.planned.name);
125
+ const query = (yield* decodeArguments(schemas.query, args));
126
+ const page = yield* operations.find(target.planned, toQuery(query));
127
+ return structured(yield* encoded(schemas.findResult, page));
128
+ }
129
+ case "get": {
130
+ const schemas = built.schemas.get(target.planned.name);
131
+ const { id } = yield* decodeArguments(GetArguments, args);
132
+ return structured(yield* encoded(schemas.record, yield* operations.get(target.planned, id)));
133
+ }
134
+ case "history": {
135
+ const { id, ...params } = yield* decodeArguments(HistoryArguments, args);
136
+ return structured(yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params)));
137
+ }
138
+ case "action": {
139
+ const answer = yield* operations.runAction(target.planned, target.action, {
140
+ input: args,
141
+ validateOnly: false,
142
+ identityToken: identityToken(request),
143
+ });
144
+ return structured(yield* encoded(ActionResponse, answer));
145
+ }
146
+ }
147
+ });
148
+ }
149
+ function callTool(server, params, request) {
150
+ return Effect.gen(function* () {
151
+ const requestId = yield* RequestId;
152
+ const tool = server.tools.find((candidate) => candidate.name === params.name);
153
+ if (tool === undefined) {
154
+ return { kind: "invalid", message: `Unknown tool: ${String(params.name)}.` };
155
+ }
156
+ const args = params.arguments ?? {};
157
+ if (!isObject(args)) {
158
+ return { kind: "invalid", message: "The tool's arguments must be an object." };
159
+ }
160
+ const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content }) => ({
161
+ content: [{ type: "text", text }],
162
+ ...(content === undefined ? {} : { structuredContent: content }),
163
+ })), Effect.catchCause((cause) => toolFailure(cause, requestId)));
164
+ return { kind: "called", result };
165
+ });
166
+ }
167
+ const CATALOG_URI = "ontology://catalog";
168
+ const DESCRIBE_URI = "ontology://describe";
169
+ const RESOURCES = [
170
+ {
171
+ uri: CATALOG_URI,
172
+ name: "catalog",
173
+ title: "The ontology's catalog",
174
+ description: "The manifest: entities, properties, relationships, actions and roles with their " +
175
+ "permissions, with only the actions you may run.",
176
+ mimeType: "application/json",
177
+ },
178
+ {
179
+ uri: DESCRIBE_URI,
180
+ name: "describe",
181
+ title: "The ontology as prose",
182
+ description: "What the describe tool returns: the entities you may read and the actions you may run.",
183
+ mimeType: "text/plain",
184
+ },
185
+ ];
186
+ function readResource({ built }, uri) {
187
+ return Effect.gen(function* () {
188
+ const principal = yield* CurrentPrincipal;
189
+ const { manifest } = built.plan.ontology;
190
+ switch (uri) {
191
+ case CATALOG_URI:
192
+ return [
193
+ {
194
+ uri,
195
+ mimeType: "application/json",
196
+ text: JSON.stringify(encodeManifest(catalogFor(manifest, principal.permissions))),
197
+ },
198
+ ];
199
+ case DESCRIBE_URI:
200
+ return [
201
+ {
202
+ uri,
203
+ mimeType: "text/plain",
204
+ text: renderCatalog(manifest, { as: principal.permissions }),
205
+ },
206
+ ];
207
+ default:
208
+ return undefined;
209
+ }
210
+ });
211
+ }
212
+ /**
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.
215
+ */
216
+ function instructions({ built }, permissions) {
217
+ const readable = built.plan.entities
218
+ .filter(({ name, resolved }) => {
219
+ const definition = resolved.entity.definition;
220
+ return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
221
+ })
222
+ .map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
223
+ return [
224
+ "This server is the app's ontology: the records the company works with and the actions " +
225
+ "that change them. Every change goes through an action, is checked against your " +
226
+ "permissions and is kept in history; nothing else writes.",
227
+ readable.length > 0
228
+ ? `Entities you may read, each with <entity>_find, <entity>_get and <entity>_history:\n${readable.join("\n")}`
229
+ : "You may read no entities.",
230
+ "Call describe for the full catalog: properties, relationships and the actions you may run.",
231
+ ].join("\n\n");
232
+ }
233
+ function negotiate(requested) {
234
+ return MCP_PROTOCOL_VERSIONS.includes(requested)
235
+ ? requested
236
+ : MCP_PROTOCOL_VERSIONS[0];
237
+ }
238
+ /** Answers one JSON-RPC request as the authenticated caller. */
239
+ function dispatch(server, message, request) {
240
+ return Effect.gen(function* () {
241
+ const { id, method, params } = message;
242
+ const principal = yield* CurrentPrincipal;
243
+ switch (method) {
244
+ case "initialize":
245
+ return rpcResult(id, {
246
+ protocolVersion: negotiate(params.protocolVersion),
247
+ capabilities: { tools: {}, resources: {} },
248
+ serverInfo: {
249
+ name: "segmentstream-ontology",
250
+ title: "Ontology",
251
+ version: server.built.plan.ontology.manifest.hash,
252
+ },
253
+ instructions: instructions(server, principal.permissions),
254
+ });
255
+ case "ping":
256
+ return rpcResult(id, {});
257
+ case "tools/list":
258
+ return rpcResult(id, {
259
+ tools: visibleTools(server.tools, principal).map((tool) => ({
260
+ name: tool.name,
261
+ title: tool.title,
262
+ description: tool.description,
263
+ inputSchema: tool.inputSchema,
264
+ ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
265
+ annotations: tool.annotations,
266
+ })),
267
+ });
268
+ case "tools/call": {
269
+ const called = yield* callTool(server, params, request);
270
+ return called.kind === "invalid"
271
+ ? rpcError(id, INVALID_PARAMS, called.message)
272
+ : rpcResult(id, called.result);
273
+ }
274
+ case "resources/list":
275
+ return rpcResult(id, { resources: RESOURCES });
276
+ case "resources/templates/list":
277
+ return rpcResult(id, { resourceTemplates: [] });
278
+ case "resources/read": {
279
+ const contents = yield* readResource(server, params.uri);
280
+ return contents === undefined
281
+ ? rpcError(id, RESOURCE_NOT_FOUND, `Unknown resource: ${String(params.uri)}.`)
282
+ : rpcResult(id, { contents });
283
+ }
284
+ default:
285
+ return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}.`);
286
+ }
287
+ });
288
+ }
289
+ /** A request from a page on another origin, which a browser sent: refused, as MCP asks. */
290
+ function crossOrigin(request) {
291
+ const origin = request.headers.origin;
292
+ if (origin === undefined) {
293
+ return false;
294
+ }
295
+ return !URL.canParse(origin) || new URL(origin).host !== new URL(publicOrigin(request)).host;
296
+ }
297
+ /** `POST /mcp`: one JSON-RPC message in, one JSON response out, nothing kept between requests. */
298
+ function handlePost(server, verifyToken, backend) {
299
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
300
+ return (request) => Effect.gen(function* () {
301
+ const requestId = yield* RequestId;
302
+ if (crossOrigin(request)) {
303
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire({
304
+ error: "forbidden",
305
+ detail: "Requests from another origin are refused.",
306
+ requestId,
307
+ }), { status: 403 });
308
+ }
309
+ const answer = Effect.gen(function* () {
310
+ const text = yield* Effect.orDie(request.text);
311
+ let body;
312
+ try {
313
+ body = JSON.parse(text);
314
+ }
315
+ catch {
316
+ return rpcError(null, PARSE_ERROR, "The body is not valid JSON.", 400);
317
+ }
318
+ if (Array.isArray(body)) {
319
+ return rpcError(null, INVALID_REQUEST, "Batches are not supported.", 400);
320
+ }
321
+ const message = classify(body);
322
+ const version = request.headers[PROTOCOL_VERSION_HEADER];
323
+ // `initialize` negotiates the revision, so its header is not checked: a 400 there would make
324
+ // a client fall back to the legacy HTTP+SSE transport.
325
+ if (version !== undefined &&
326
+ !MCP_PROTOCOL_VERSIONS.includes(version) &&
327
+ !(message.kind === "request" && message.method === "initialize")) {
328
+ return rpcError(null, INVALID_REQUEST, `Unsupported MCP-Protocol-Version ${version}; this server speaks ` +
329
+ `${MCP_PROTOCOL_VERSIONS.join(" and ")}.`, 400);
330
+ }
331
+ switch (message.kind) {
332
+ case "invalid":
333
+ return rpcError(message.id, INVALID_REQUEST, "Invalid JSON-RPC request.", 400);
334
+ case "other":
335
+ return ACCEPTED;
336
+ case "request":
337
+ return yield* dispatch(server, message, request);
338
+ }
339
+ });
340
+ const origin = publicOrigin(request);
341
+ // 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
+ });
349
+ });
350
+ }
351
+ /**
352
+ * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
353
+ * answer any request. Its tools run the same operations as the HTTP routes, behind the same
354
+ * authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
355
+ */
356
+ export function mcpLayer(options) {
357
+ const tools = mcpTools(options.built);
358
+ return Layer.effectDiscard(Effect.gen(function* () {
359
+ const router = yield* HttpRouter.HttpRouter;
360
+ const backend = yield* OntologyBackend;
361
+ const server = {
362
+ built: options.built,
363
+ tools,
364
+ operations: makeOperations(backend, options.built.plan, options.hooks, options.connections),
365
+ };
366
+ yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
367
+ yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
368
+ yield* router.add("DELETE", MCP_PATH, METHOD_NOT_ALLOWED);
369
+ }));
370
+ }
@@ -0,0 +1,47 @@
1
+ import type { JsonSchemaObject } from "../../analyze.js";
2
+ import { type PermissionHolder } from "../../permissions.js";
3
+ import type { BuiltApi } from "../api.js";
4
+ import type { PlannedAction, PlannedEntity } from "../plan.js";
5
+ /** How a tool behaves, as MCP's tool annotations say it. */
6
+ export interface McpToolAnnotations {
7
+ readonly readOnlyHint: boolean;
8
+ readonly destructiveHint?: boolean;
9
+ readonly idempotentHint?: boolean;
10
+ /** Whether the tool reaches beyond the ontology: true for actions that call connections. */
11
+ readonly openWorldHint: boolean;
12
+ }
13
+ /** What a tool runs: a read or an action of an entity, or one of the global tools. */
14
+ export type McpToolTarget = {
15
+ readonly kind: "find" | "get" | "history";
16
+ readonly planned: PlannedEntity;
17
+ } | {
18
+ readonly kind: "action";
19
+ readonly planned: PlannedEntity;
20
+ readonly action: PlannedAction;
21
+ } | {
22
+ readonly kind: "describe";
23
+ } | {
24
+ readonly kind: "me";
25
+ };
26
+ /** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
27
+ export interface McpTool {
28
+ readonly name: string;
29
+ readonly title: string;
30
+ readonly description: string;
31
+ readonly inputSchema: JsonSchemaObject;
32
+ /** The JSON Schema of the tool's `structuredContent`, for the tools that return a known shape. */
33
+ readonly outputSchema?: JsonSchemaObject;
34
+ readonly annotations: McpToolAnnotations;
35
+ /** Every permission a caller needs to see and run the tool; none for the global tools. */
36
+ readonly permissions: ReadonlyArray<string>;
37
+ readonly target: McpToolTarget;
38
+ }
39
+ /**
40
+ * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
41
+ * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
42
+ * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
43
+ * `check` already refuses.
44
+ */
45
+ export declare function mcpTools(built: BuiltApi): ReadonlyArray<McpTool>;
46
+ /** The tools a caller may see: those whose every permission the caller's roles grant. */
47
+ export declare function visibleTools(tools: ReadonlyArray<McpTool>, caller: PermissionHolder): ReadonlyArray<McpTool>;
@@ -0,0 +1,218 @@
1
+ import { Schema } from "effect";
2
+ import { InvalidOntology } from "../../diagnostics.js";
3
+ import { GENERATED_UPSERT_PREFIX, humanize, mcpToolName } from "../../names.js";
4
+ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
5
+ import { queryJsonSchema } from "../../query.js";
6
+ import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
7
+ import { actionDescription, requires } from "../descriptions.js";
8
+ import { HistoryPage, Me } from "../schemas.js";
9
+ const DEFS = "#/$defs/";
10
+ /**
11
+ * A JSON Schema whose root is an object schema, as MCP requires of tool inputs and outputs: a root
12
+ * `$ref` into `$defs` is replaced by the definition it names.
13
+ */
14
+ function rootObject(schema) {
15
+ const reference = schema.$ref;
16
+ const definitions = schema.$defs;
17
+ if (typeof reference !== "string" || !reference.startsWith(DEFS) || definitions === undefined) {
18
+ return schema;
19
+ }
20
+ const referenced = definitions[reference.slice(DEFS.length)];
21
+ if (referenced === undefined) {
22
+ return schema;
23
+ }
24
+ const rest = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "$ref"));
25
+ return { ...rest, ...referenced };
26
+ }
27
+ /** The JSON Schema of what a schema encodes to: the shape the service sends. */
28
+ function encodedJsonSchema(schema) {
29
+ const document = Schema.toJsonSchemaDocument(schema);
30
+ const root = document.schema;
31
+ return rootObject(Object.keys(document.definitions).length === 0
32
+ ? root
33
+ : { ...root, $defs: document.definitions });
34
+ }
35
+ const NO_INPUT = { type: "object", properties: {}, additionalProperties: false };
36
+ function idInput(label) {
37
+ return {
38
+ type: "object",
39
+ properties: { id: { type: "string", format: "uuid", description: `The ${label}'s id.` } },
40
+ required: ["id"],
41
+ additionalProperties: false,
42
+ };
43
+ }
44
+ function historyInput(label) {
45
+ return {
46
+ type: "object",
47
+ properties: {
48
+ id: { type: "string", format: "uuid", description: `The ${label}'s id.` },
49
+ after: { type: "string", description: "The next_cursor of the previous page." },
50
+ limit: {
51
+ type: "integer",
52
+ minimum: 1,
53
+ maximum: 200,
54
+ description: "The page size, at most 200.",
55
+ },
56
+ },
57
+ required: ["id"],
58
+ additionalProperties: false,
59
+ };
60
+ }
61
+ const READ = { readOnlyHint: true, openWorldHint: false };
62
+ /** The hints of generated actions, which only their name tells apart. */
63
+ const GENERATED_HINTS = {
64
+ create: { destructive: false, idempotent: false },
65
+ update: { destructive: true, idempotent: true },
66
+ delete: { destructive: true, idempotent: true },
67
+ link: { destructive: false, idempotent: true },
68
+ unlink: { destructive: true, idempotent: true },
69
+ };
70
+ /** MCP's defaults for a tool that writes: it may destroy, and repeating it may change more. */
71
+ const AUTHORED_DEFAULT = { destructive: true, idempotent: false };
72
+ function hintsOf(planned, { name, generated }) {
73
+ if (generated) {
74
+ return name.startsWith(GENERATED_UPSERT_PREFIX)
75
+ ? { destructive: true, idempotent: true }
76
+ : (GENERATED_HINTS[name] ?? AUTHORED_DEFAULT);
77
+ }
78
+ const declared = planned.resolved.actions.find((candidate) => candidate.name === name)?.hints;
79
+ return { ...AUTHORED_DEFAULT, ...declared };
80
+ }
81
+ /** How an agent reaches the records a relationship leads to, with the tools it has. */
82
+ function relatedHint(planned, traversal) {
83
+ const id = `<${planned.resolved.entity.definition.label.toLowerCase()} id>`;
84
+ const find = mcpToolName(traversal.target, "find");
85
+ switch (traversal.kind) {
86
+ case "one":
87
+ return (`${traversal.name}: include {"${traversal.name}": true}, or ` +
88
+ `${mcpToolName(traversal.target, "get")} with ${traversal.name}_id.`);
89
+ case "inverseOfOne":
90
+ return `${traversal.name}: ${find} with where {"${traversal.opposite}": {"id": {"eq": "${id}"}}}.`;
91
+ case "many":
92
+ case "inverseOfMany":
93
+ return (`${traversal.name}: ${find} with where ` +
94
+ `{"${traversal.opposite}": {"some": {"id": {"eq": "${id}"}}}}.`);
95
+ }
96
+ }
97
+ function readTools(planned, schemas, built) {
98
+ const { manifest } = built.plan.ontology;
99
+ const { definition } = planned.resolved.entity;
100
+ const label = definition.label.toLowerCase();
101
+ const read = permissionOf(planned.name, READ_VERB);
102
+ const related = planned.traversals.map((traversal) => `- ${relatedHint(planned, traversal)}`);
103
+ return [
104
+ {
105
+ name: mcpToolName(planned.name, "find"),
106
+ title: `Find ${definition.plural.toLowerCase()}`,
107
+ description: `Filter, sort and page through ${definition.plural.toLowerCase()}, at most 200 at a ` +
108
+ `time; pass next_cursor as after to read the next page. ${definition.description}\n\n` +
109
+ `${requires([read])} Including a relationship, or filtering through one, requires ` +
110
+ "reading its entity too." +
111
+ (related.length > 0 ? `\n\nRelated records:\n${related.join("\n")}` : ""),
112
+ // The query of POST /<entity>/query, closed: an unknown field or operator is an error.
113
+ inputSchema: queryJsonSchema(manifest, planned.name),
114
+ outputSchema: encodedJsonSchema(schemas.findResult),
115
+ annotations: READ,
116
+ permissions: [read],
117
+ target: { kind: "find", planned },
118
+ },
119
+ {
120
+ name: mcpToolName(planned.name, "get"),
121
+ title: `Get a ${label}`,
122
+ description: `One ${label} by its id. ${definition.description}\n\n${requires([read])}`,
123
+ inputSchema: idInput(label),
124
+ outputSchema: encodedJsonSchema(schemas.record),
125
+ annotations: READ,
126
+ permissions: [read],
127
+ target: { kind: "get", planned },
128
+ },
129
+ {
130
+ name: mcpToolName(planned.name, "history"),
131
+ title: `History of a ${label}`,
132
+ description: `What each action changed on a ${label}, newest first, with who ran it and the values ` +
133
+ `before and after. ${requires([read])}`,
134
+ inputSchema: historyInput(label),
135
+ outputSchema: encodedJsonSchema(HistoryPage),
136
+ annotations: READ,
137
+ permissions: [read],
138
+ target: { kind: "history", planned },
139
+ },
140
+ ];
141
+ }
142
+ function actionTool(planned, plannedAction) {
143
+ const { name, action, permissions } = plannedAction;
144
+ const hints = hintsOf(planned, plannedAction);
145
+ return {
146
+ name: mcpToolName(planned.name, name),
147
+ title: `${planned.resolved.entity.definition.label}: ${humanize(name).toLowerCase()}`,
148
+ description: actionDescription(plannedAction),
149
+ inputSchema: rootObject(actionInputJsonSchema(action.input)),
150
+ annotations: {
151
+ readOnlyHint: false,
152
+ destructiveHint: hints.destructive,
153
+ idempotentHint: hints.idempotent,
154
+ openWorldHint: action.connections.length > 0,
155
+ },
156
+ permissions,
157
+ target: { kind: "action", planned, action: plannedAction },
158
+ };
159
+ }
160
+ const GLOBAL_TOOLS = [
161
+ {
162
+ name: "describe",
163
+ title: "Describe the ontology",
164
+ description: "The ontology as prose: the entities you may read, their properties and relationships, " +
165
+ "and the actions you may run, each with what it requires. Read it before working with " +
166
+ "records.",
167
+ inputSchema: NO_INPUT,
168
+ annotations: READ,
169
+ permissions: [],
170
+ target: { kind: "describe" },
171
+ },
172
+ {
173
+ name: "me",
174
+ title: "Who am I",
175
+ description: "Who you are in this app: your user, your roles and the permissions they grant. History " +
176
+ "names you by the user id.",
177
+ inputSchema: NO_INPUT,
178
+ outputSchema: encodedJsonSchema(Me),
179
+ annotations: READ,
180
+ permissions: [],
181
+ target: { kind: "me" },
182
+ },
183
+ ];
184
+ /**
185
+ * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
186
+ * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
187
+ * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
188
+ * `check` already refuses.
189
+ */
190
+ export function mcpTools(built) {
191
+ const tools = [...GLOBAL_TOOLS];
192
+ for (const planned of built.plan.entities) {
193
+ if (planned.resolved.entity.definition.source !== undefined) {
194
+ continue;
195
+ }
196
+ const schemas = built.schemas.get(planned.name);
197
+ tools.push(...readTools(planned, schemas, built));
198
+ tools.push(...planned.actions.map((action) => actionTool(planned, action)));
199
+ }
200
+ const seen = new Set();
201
+ const duplicates = tools.filter(({ name }) => seen.size === seen.add(name).size);
202
+ if (duplicates.length > 0) {
203
+ throw new InvalidOntology({
204
+ message: `The ontology's MCP tools collide: ${duplicates.map(({ name }) => name).join(", ")}.`,
205
+ diagnostics: duplicates.map(({ name }) => ({
206
+ severity: "error",
207
+ code: "invalid-tool-name",
208
+ path: name,
209
+ message: `${name} is the name of two MCP tools.`,
210
+ })),
211
+ });
212
+ }
213
+ return tools;
214
+ }
215
+ /** The tools a caller may see: those whose every permission the caller's roles grant. */
216
+ export function visibleTools(tools, caller) {
217
+ return tools.filter((tool) => tool.permissions.every((required) => isAllowed(required, caller)));
218
+ }