@segmentstream-cloud/ontology 0.10.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 (56) 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 +81 -1
  5. package/dist/bin/migrate.js +3 -2
  6. package/dist/diagnostics.d.ts +3 -3
  7. package/dist/diagnostics.js +1 -0
  8. package/dist/index.d.ts +5 -3
  9. package/dist/index.js +4 -1
  10. package/dist/manifest.d.ts +48 -0
  11. package/dist/manifest.js +9 -0
  12. package/dist/migrate/apply.d.ts +7 -0
  13. package/dist/migrate/apply.js +18 -1
  14. package/dist/migrate/index.d.ts +1 -1
  15. package/dist/migrate/index.js +1 -1
  16. package/dist/migrate/model.d.ts +8 -0
  17. package/dist/migrate/plan.d.ts +6 -0
  18. package/dist/migrate/plan.js +10 -2
  19. package/dist/migrate/storage.d.ts +12 -0
  20. package/dist/migrate/storage.js +82 -0
  21. package/dist/names.d.ts +9 -1
  22. package/dist/names.js +11 -0
  23. package/dist/nitro.d.ts +13 -0
  24. package/dist/nitro.js +18 -0
  25. package/dist/runtime/generated-actions.d.ts +3 -0
  26. package/dist/runtime/generated-actions.js +3 -2
  27. package/dist/runtime/oauth.d.ts +9 -0
  28. package/dist/runtime/oauth.js +165 -0
  29. package/dist/runtime/principals.d.ts +5 -0
  30. package/dist/runtime/principals.js +15 -0
  31. package/dist/runtime/service-binding.js +2 -0
  32. package/dist/service/api.js +4 -10
  33. package/dist/service/authentication.d.ts +27 -1
  34. package/dist/service/authentication.js +48 -26
  35. package/dist/service/config.d.ts +5 -0
  36. package/dist/service/descriptions.d.ts +8 -0
  37. package/dist/service/descriptions.js +15 -0
  38. package/dist/service/errors.d.ts +5 -1
  39. package/dist/service/errors.js +6 -2
  40. package/dist/service/handlers.d.ts +1 -4
  41. package/dist/service/handlers.js +20 -203
  42. package/dist/service/mcp/server.d.ts +24 -0
  43. package/dist/service/mcp/server.js +370 -0
  44. package/dist/service/mcp/tools.d.ts +47 -0
  45. package/dist/service/mcp/tools.js +218 -0
  46. package/dist/service/oauth/protocol.d.ts +52 -0
  47. package/dist/service/oauth/protocol.js +110 -0
  48. package/dist/service/oauth/server.d.ts +27 -0
  49. package/dist/service/oauth/server.js +576 -0
  50. package/dist/service/operations.d.ts +114 -0
  51. package/dist/service/operations.js +218 -0
  52. package/dist/service/runtime-contract.d.ts +89 -0
  53. package/dist/service/schemas.d.ts +8 -0
  54. package/dist/service/service.d.ts +3 -3
  55. package/dist/service/service.js +10 -5
  56. package/package.json +6 -1
@@ -0,0 +1,114 @@
1
+ import { Effect } from "effect";
2
+ import type { HttpServerRequest } from "effect/unstable/http";
3
+ import { PermissionDenied } from "../errors.js";
4
+ import type { Query, QueryResult } from "../query.js";
5
+ import { CurrentPrincipal } from "./authentication.js";
6
+ import type { AfterCommitHook } from "./config.js";
7
+ import { type ConnectionTransportFactory } from "./connections.js";
8
+ import { InvalidActionInput, RequestId } from "./errors.js";
9
+ import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
10
+ import type { OntologyBackend } from "./runtime-contract.js";
11
+ /**
12
+ * What the service does for a caller, whatever the transport: the reads and actions of every entity
13
+ * and the caller's own account. The HTTP routes and the MCP tools both call these, so a permission
14
+ * check, a validation or a history row never depends on how the request arrived. Each requires the
15
+ * caller, as {@link CurrentPrincipal}, and the request id.
16
+ */
17
+ /** The query the runtime receives: every key present only when set, and no empty `include`. */
18
+ export declare function toQuery(decoded: Query): Query;
19
+ /**
20
+ * Requires `<entity>:read` on every entity a request reads. A missing one fails the whole request,
21
+ * before anything is read: nothing is silently left out.
22
+ */
23
+ export declare function requireRead(entities: ReadonlyArray<string>): Effect.Effect<undefined, PermissionDenied, CurrentPrincipal>;
24
+ /** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
25
+ export declare function identityToken(request: HttpServerRequest.HttpServerRequest): string | undefined;
26
+ /** An action call, as a transport decoded it. */
27
+ export interface ActionCall {
28
+ /** The input as the caller sent it; the runtime validates it against the action's input schema. */
29
+ readonly input: unknown;
30
+ readonly validateOnly: boolean;
31
+ /** The request's other action options: the idempotency key and create intent of an upload. */
32
+ readonly options?: {
33
+ readonly idempotencyKey?: string;
34
+ readonly createIntent?: string;
35
+ };
36
+ /** The deployment's OIDC token, which authorizes the action's connection calls. */
37
+ readonly identityToken: string | undefined;
38
+ }
39
+ /** What an action answers: `{ result, validation, edits }`. */
40
+ export interface ActionAnswer {
41
+ readonly result: unknown;
42
+ readonly validation: {
43
+ readonly ok: boolean;
44
+ readonly issues: ReadonlyArray<unknown>;
45
+ };
46
+ readonly edits: ReadonlyArray<unknown>;
47
+ }
48
+ /** The caller, as `GET /api/ontology/me` answers. */
49
+ export interface Caller {
50
+ readonly user: {
51
+ readonly id: string;
52
+ readonly name: string;
53
+ readonly email: string | null;
54
+ readonly kind: string;
55
+ readonly active: boolean;
56
+ };
57
+ readonly roles: ReadonlyArray<string>;
58
+ readonly permissions: ReadonlyArray<string>;
59
+ }
60
+ type Backend = OntologyBackend["Service"];
61
+ /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
62
+ export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): {
63
+ /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
64
+ find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
65
+ /** One record. Requires `read` on the entity. */
66
+ get: (planned: PlannedEntity, id: string) => Effect.Effect<Readonly<Record<string, unknown>>, PermissionDenied | import("../errors.js").NotFound, CurrentPrincipal>;
67
+ /** A page of a record's history, newest first. Requires `read` on the entity. */
68
+ history: (planned: PlannedEntity, id: string, params: {
69
+ readonly after?: string;
70
+ readonly limit?: number;
71
+ }) => Effect.Effect<QueryResult<{
72
+ readonly id: string;
73
+ readonly recordId: string;
74
+ readonly action: string;
75
+ readonly actionId: string;
76
+ readonly kind: "create" | "update" | "delete" | "link" | "unlink";
77
+ readonly principalId: string | null;
78
+ readonly at: string;
79
+ readonly changes: {
80
+ readonly [x: string]: {
81
+ readonly before: import("effect/Schema").Json;
82
+ readonly after: import("effect/Schema").Json;
83
+ };
84
+ };
85
+ }>, PermissionDenied | import("../errors.js").NotFound | import("../index.js").InvalidQuery, CurrentPrincipal>;
86
+ /** The records a relationship reaches from one record, as a page of the target entity. */
87
+ traverse: (planned: PlannedEntity, traversal: Traversal, id: string, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../errors.js").NotFound | import("../index.js").InvalidQuery, CurrentPrincipal>;
88
+ /** Runs an action as the caller, with the action's after-commit hook and connections. */
89
+ runAction: (planned: PlannedEntity, { name, generated }: PlannedAction, call: ActionCall) => Effect.Effect<{
90
+ result: {} | null;
91
+ validation: {
92
+ ok: boolean;
93
+ issues: readonly {
94
+ readonly path: readonly (string | number)[];
95
+ readonly message: string;
96
+ }[];
97
+ };
98
+ edits: readonly {
99
+ readonly entity: string;
100
+ readonly id: string;
101
+ readonly kind: "create" | "update" | "delete" | "link" | "unlink";
102
+ readonly changes: {
103
+ readonly [x: string]: {
104
+ readonly before: import("effect/Schema").Json;
105
+ readonly after: import("effect/Schema").Json;
106
+ };
107
+ };
108
+ }[];
109
+ }, import("../connection.js").ConnectionError | import("../errors.js").ActionRejected | PermissionDenied | import("../errors.js").Conflict | import("../errors.js").NotFound | InvalidActionInput, RequestId | CurrentPrincipal>;
110
+ /** The caller's user, roles and permissions. It needs no permission. */
111
+ caller: Effect.Effect<Caller, never, CurrentPrincipal>;
112
+ };
113
+ export type Operations = ReturnType<typeof makeOperations>;
114
+ export {};
@@ -0,0 +1,218 @@
1
+ import { Effect } from "effect";
2
+ import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
3
+ import { nameToWire } from "../authoring-names.js";
4
+ import { principalFromWire } from "../authoring.js";
5
+ import { PermissionDenied } from "../errors.js";
6
+ import { toCamelCase } from "../names.js";
7
+ import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
8
+ import { CurrentPrincipal } from "./authentication.js";
9
+ import { OIDC_TOKEN_HEADER } from "./connections.js";
10
+ import { InvalidActionInput, RequestId } from "./errors.js";
11
+ /**
12
+ * What the service does for a caller, whatever the transport: the reads and actions of every entity
13
+ * and the caller's own account. The HTTP routes and the MCP tools both call these, so a permission
14
+ * check, a validation or a history row never depends on how the request arrived. Each requires the
15
+ * caller, as {@link CurrentPrincipal}, and the request id.
16
+ */
17
+ /** The query the runtime receives: every key present only when set, and no empty `include`. */
18
+ export function toQuery(decoded) {
19
+ const { include, ...rest } = decoded;
20
+ return include !== undefined && Object.keys(include).length > 0 ? { ...rest, include } : rest;
21
+ }
22
+ function page(result) {
23
+ return { items: result.items, nextCursor: result.nextCursor ?? null };
24
+ }
25
+ function relationshipTargets(plan) {
26
+ return new Map(plan.entities.map((planned) => [
27
+ planned.name,
28
+ new Map(planned.traversals.map((traversal) => [traversal.name, traversal.target])),
29
+ ]));
30
+ }
31
+ /** Adds the entities a `where` looks into through relationship filters, at any combinator depth. */
32
+ function filterReads(where, targets, reads) {
33
+ for (const [key, condition] of Object.entries(where ?? {})) {
34
+ if (key === "and" || key === "or") {
35
+ for (const part of condition) {
36
+ filterReads(part, targets, reads);
37
+ }
38
+ }
39
+ else if (key === "not") {
40
+ filterReads(condition, targets, reads);
41
+ }
42
+ else {
43
+ const target = targets.get(key);
44
+ if (target !== undefined) {
45
+ reads.add(target);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * The entities a query on `entity` reads: the entity, the entity of every relationship it includes,
52
+ * and the entity of every relationship its filter looks into.
53
+ */
54
+ function queryReads(entity, query, targets) {
55
+ const own = targets.get(entity) ?? new Map();
56
+ const reads = new Set([entity]);
57
+ for (const name of Object.keys(query.include ?? {})) {
58
+ const target = own.get(name);
59
+ if (target !== undefined) {
60
+ reads.add(target);
61
+ }
62
+ }
63
+ filterReads(query.where, own, reads);
64
+ return [...reads];
65
+ }
66
+ /**
67
+ * Requires `<entity>:read` on every entity a request reads. A missing one fails the whole request,
68
+ * before anything is read: nothing is silently left out.
69
+ */
70
+ export function requireRead(entities) {
71
+ return Effect.gen(function* () {
72
+ const principal = yield* CurrentPrincipal;
73
+ const missing = missingPermission(entities.map((entity) => permissionOf(entity, READ_VERB)), principal);
74
+ if (missing !== undefined) {
75
+ return yield* new PermissionDenied({
76
+ permission: missing,
77
+ message: `Reading requires ${missing}, which the principal's roles do not grant.`,
78
+ });
79
+ }
80
+ });
81
+ }
82
+ /**
83
+ * The configured hook as `runAction` runs it after the commit. A failure is logged with the request
84
+ * id here, so the action's caller still gets the committed result.
85
+ */
86
+ function afterCommitOf(hook, request, ontology) {
87
+ return (committed) => {
88
+ const definition = ontology.config.entities.find((entity) => nameToWire(entity.name) === committed.entity)?.definition;
89
+ return Effect.tryPromise(async () => hook({
90
+ entity: toCamelCase(committed.entity),
91
+ action: toCamelCase(committed.action),
92
+ principal: principalFromWire(committed.principal),
93
+ input: request.generated ? recordFromWire(committed.input, definition) : committed.input,
94
+ result: request.generated
95
+ ? generatedResultFromWire(committed.result, committed.action, definition)
96
+ : committed.result,
97
+ edits: editsFromWire(committed.edits, ontology.config.entities),
98
+ requestId: request.requestId,
99
+ })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
100
+ requestId: request.requestId,
101
+ action: `${committed.entity}.${committed.action}`,
102
+ }))));
103
+ };
104
+ }
105
+ /** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
106
+ export function identityToken(request) {
107
+ const header = request.headers[OIDC_TOKEN_HEADER];
108
+ return typeof header === "string" && header !== "" ? header : process.env.VERCEL_OIDC_TOKEN;
109
+ }
110
+ function findOf(backend, targets, entity, query) {
111
+ // Every read checks the caller's permissions first; the backend is called only afterwards.
112
+ return requireRead(queryReads(entity, query, targets)).pipe(Effect.andThen(() => backend.find({ entity, query })), Effect.map(page));
113
+ }
114
+ function getOf(backend, entity, id) {
115
+ return requireRead([entity]).pipe(Effect.andThen(() => backend.get({ entity, id })));
116
+ }
117
+ function historyOf(backend, entity, id, params) {
118
+ return requireRead([entity]).pipe(Effect.andThen(() => backend.history({ entity, id, ...params })), Effect.map(page));
119
+ }
120
+ /**
121
+ * Follows a relationship from one record with the query language: the record's own
122
+ * `<relationship>_id` for a `rel.one`, and for everything to-many a relationship path from the
123
+ * target back to the record (`lead.notes` is notes whose `lead` is the lead, `lead.tags` is tags
124
+ * some of whose `leads` is the lead), combined with the caller's query and paged like any find.
125
+ */
126
+ function traverseOf(backend, targets, planned, traversal, id, query) {
127
+ return Effect.gen(function* () {
128
+ yield* requireRead([planned.name, ...queryReads(traversal.target, query, targets)]);
129
+ const source = yield* backend.get({ entity: planned.name, id });
130
+ if (traversal.kind === "one") {
131
+ const targetId = source[`${traversal.name}_id`];
132
+ if (typeof targetId !== "string") {
133
+ return { items: [], nextCursor: null };
134
+ }
135
+ return page(yield* backend.find({
136
+ entity: traversal.target,
137
+ query: { ...query, where: { id: { eq: targetId } }, limit: 1 },
138
+ }));
139
+ }
140
+ const match = { id: { eq: id } };
141
+ const related = traversal.kind === "inverseOfOne"
142
+ ? { [traversal.opposite]: match }
143
+ : { [traversal.opposite]: { some: match } };
144
+ const where = query.where === undefined ? related : { and: [related, query.where] };
145
+ return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
146
+ });
147
+ }
148
+ function runActionOf(backend, ontology, hooks, connections) {
149
+ return (planned, { name, generated }, call) => Effect.gen(function* () {
150
+ const principal = yield* CurrentPrincipal;
151
+ const requestId = yield* RequestId;
152
+ const hook = hooks.get(`${planned.name}.${name}`);
153
+ const outcome = yield* backend.runAction({
154
+ entity: planned.name,
155
+ action: name,
156
+ input: call.input,
157
+ principal,
158
+ options: { ...call.options, validateOnly: call.validateOnly },
159
+ requestId,
160
+ connections: connections({
161
+ identityToken: call.identityToken,
162
+ audit: { action: `${planned.name}.${name}`, principalId: principal.id, requestId },
163
+ }),
164
+ ...(hook === undefined
165
+ ? {}
166
+ : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
167
+ });
168
+ const { issues } = outcome.validation;
169
+ if (!outcome.validation.ok && !call.validateOnly) {
170
+ return yield* new InvalidActionInput({ issues });
171
+ }
172
+ return {
173
+ result: outcome.result ?? null,
174
+ validation: { ok: outcome.validation.ok, issues },
175
+ edits: outcome.edits,
176
+ };
177
+ });
178
+ }
179
+ /**
180
+ * The caller: their user record and the roles and permissions authentication resolved. Anyone may
181
+ * learn who they are and what they may do.
182
+ */
183
+ function callerOf(backend) {
184
+ return Effect.gen(function* () {
185
+ const principal = yield* CurrentPrincipal;
186
+ // Authentication resolved the principal from this user a moment ago; its absence is a defect.
187
+ const user = yield* backend.get({ entity: "user", id: principal.id }).pipe(Effect.orDie);
188
+ return {
189
+ user: {
190
+ id: principal.id,
191
+ name: String(user.name),
192
+ email: typeof user.email === "string" ? user.email : null,
193
+ kind: principal.kind,
194
+ active: user.active === true,
195
+ },
196
+ roles: principal.roles,
197
+ permissions: principal.permissions,
198
+ };
199
+ });
200
+ }
201
+ /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
202
+ export function makeOperations(backend, plan, hooks, connections) {
203
+ const targets = relationshipTargets(plan);
204
+ return {
205
+ /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
206
+ find: (planned, query) => findOf(backend, targets, planned.name, query),
207
+ /** One record. Requires `read` on the entity. */
208
+ get: (planned, id) => getOf(backend, planned.name, id),
209
+ /** A page of a record's history, newest first. Requires `read` on the entity. */
210
+ history: (planned, id, params) => historyOf(backend, planned.name, id, params),
211
+ /** The records a relationship reaches from one record, as a page of the target entity. */
212
+ traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
213
+ /** Runs an action as the caller, with the action's after-commit hook and connections. */
214
+ runAction: runActionOf(backend, plan.ontology, hooks, connections),
215
+ /** The caller's user, roles and permissions. It needs no permission. */
216
+ caller: callerOf(backend),
217
+ };
218
+ }
@@ -66,6 +66,93 @@ declare const NotInvited_base: Schema.Class<NotInvited, Schema.TaggedStruct<"Not
66
66
  */
67
67
  export declare class NotInvited extends NotInvited_base {
68
68
  }
69
+ /** How a client proves itself at the token endpoint; `none` for a public client, with PKCE only. */
70
+ export type OAuthClientAuthMethod = "none" | "client_secret_post" | "client_secret_basic";
71
+ /** A client that registered itself to connect an agent (RFC 7591 dynamic registration). */
72
+ export interface OAuthClient {
73
+ readonly id: string;
74
+ readonly name: string;
75
+ readonly redirectUris: ReadonlyArray<string>;
76
+ readonly authMethod: OAuthClientAuthMethod;
77
+ /** SHA-256 of a confidential client's secret; null for a public client. */
78
+ readonly secretHash: string | null;
79
+ readonly clientUri: string | null;
80
+ }
81
+ /** An authorization request waiting, for a few minutes, for a member's consent. */
82
+ export interface OAuthRequest {
83
+ readonly id: string;
84
+ readonly clientId: string;
85
+ readonly redirectUri: string;
86
+ readonly state: string | null;
87
+ /** The PKCE S256 challenge the token request must answer. */
88
+ readonly codeChallenge: string;
89
+ readonly scope: string;
90
+ /** The MCP server the tokens are for (RFC 8707). */
91
+ readonly resource: string;
92
+ readonly expiresAt: Date;
93
+ }
94
+ /** What a member allowed a client to do. Its tokens stop working once it is revoked. */
95
+ export interface OAuthGrant {
96
+ readonly id: string;
97
+ readonly clientId: string;
98
+ readonly userId: string;
99
+ readonly scope: string;
100
+ readonly resource: string;
101
+ }
102
+ export type OAuthTokenKind = "code" | "access" | "refresh";
103
+ /** A code or token of a grant, stored only as the SHA-256 of its value. */
104
+ export interface OAuthToken {
105
+ readonly hash: string;
106
+ readonly grantId: string;
107
+ readonly kind: OAuthTokenKind;
108
+ readonly expiresAt: Date;
109
+ /** For a code: the redirect URI it was sent to, which the token request must repeat. */
110
+ readonly redirectUri?: string;
111
+ /** For a code: the PKCE challenge the token request's verifier must answer. */
112
+ readonly codeChallenge?: string;
113
+ }
114
+ /**
115
+ * A code or refresh token presented once more: `reused` when it was used before, which means it
116
+ * leaked, and the caller revokes its grant.
117
+ */
118
+ export type UsedOAuthToken = {
119
+ readonly status: "valid";
120
+ readonly token: OAuthToken;
121
+ readonly grant: OAuthGrant;
122
+ } | {
123
+ readonly status: "reused";
124
+ readonly grant: OAuthGrant;
125
+ } | {
126
+ readonly status: "invalid";
127
+ };
128
+ /**
129
+ * Where the MCP server's authorization server keeps clients, pending requests, grants and tokens:
130
+ * the runtime's OAuth tables in the workspace database. Expiry is checked against the database's
131
+ * clock; failures are defects.
132
+ */
133
+ export interface OAuthStore {
134
+ readonly saveClient: (client: OAuthClient) => Effect.Effect<void>;
135
+ readonly findClient: (id: string) => Effect.Effect<OAuthClient | undefined>;
136
+ /** Stores a request, and forgets the expired ones. */
137
+ readonly saveRequest: (request: OAuthRequest) => Effect.Effect<void>;
138
+ /** A request that has not expired. */
139
+ readonly findRequest: (id: string) => Effect.Effect<OAuthRequest | undefined>;
140
+ /** Removes a request that has not expired and returns it: a request is decided once. */
141
+ readonly takeRequest: (id: string) => Effect.Effect<OAuthRequest | undefined>;
142
+ /** Records a grant with its authorization code. */
143
+ readonly saveGrant: (grant: OAuthGrant, code: OAuthToken) => Effect.Effect<void>;
144
+ /** Uses a code or refresh token, once, before it expires and while its grant stands. */
145
+ readonly useToken: (hash: string, kind: "code" | "refresh") => Effect.Effect<UsedOAuthToken>;
146
+ /** Stores new tokens, and forgets the expired ones. */
147
+ readonly saveTokens: (tokens: ReadonlyArray<OAuthToken>) => Effect.Effect<void>;
148
+ /** The grant of an access token that has not expired, unless the grant was revoked. */
149
+ readonly findAccess: (hash: string) => Effect.Effect<OAuthGrant | undefined>;
150
+ /** The grant of a token of any kind, revoked or not. */
151
+ readonly findGrantOfToken: (hash: string) => Effect.Effect<OAuthGrant | undefined>;
152
+ readonly revokeGrant: (id: string) => Effect.Effect<void>;
153
+ /** The member a grant acts for, with the roles they hold now; undefined once they may not sign in. */
154
+ readonly memberPrincipal: (userId: string) => Effect.Effect<Principal | undefined>;
155
+ }
69
156
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
70
157
  readonly blobs?: BlobRuntime;
71
158
  readonly maintainBlobs?: Effect.Effect<{
@@ -102,6 +189,8 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
102
189
  readonly resolveApiKey: (key: Redacted.Redacted<string>) => Effect.Effect<Principal | undefined>;
103
190
  /** The manifest hash recorded in the `_ontology` catalog table, or `undefined` before the first. */
104
191
  readonly appliedHash: Effect.Effect<string | undefined>;
192
+ /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
193
+ readonly oauth?: OAuthStore;
105
194
  }>;
106
195
  /**
107
196
  * The runtime as the HTTP service sees it, with its database and ontology already bound. Failures
@@ -228,6 +228,10 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
228
228
  readonly permission: Schema.optionalKey<Schema.String>;
229
229
  readonly input: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
230
230
  readonly connections: Schema.optionalKey<Schema.$Array<Schema.String>>;
231
+ readonly hints: Schema.optionalKey<Schema.Struct<{
232
+ readonly destructive: Schema.optionalKey<Schema.Boolean>;
233
+ readonly idempotent: Schema.optionalKey<Schema.Boolean>;
234
+ }>>;
231
235
  }>>;
232
236
  readonly permissions: Schema.optionalKey<Schema.Struct<{
233
237
  readonly read: Schema.String;
@@ -339,6 +343,10 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
339
343
  readonly permission: Schema.optionalKey<Schema.String>;
340
344
  readonly input: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
341
345
  readonly connections: Schema.optionalKey<Schema.$Array<Schema.String>>;
346
+ readonly hints: Schema.optionalKey<Schema.Struct<{
347
+ readonly destructive: Schema.optionalKey<Schema.Boolean>;
348
+ readonly idempotent: Schema.optionalKey<Schema.Boolean>;
349
+ }>>;
342
350
  }>>;
343
351
  readonly permissions: Schema.optionalKey<Schema.Struct<{
344
352
  readonly read: Schema.String;
@@ -13,8 +13,8 @@ export interface OntologyService {
13
13
  readonly dispose: () => Promise<void>;
14
14
  }
15
15
  /**
16
- * The ontology service: the derived HttpApi, wired to the runtime, behind Neon Auth, app-token and
17
- * API-key authentication. Every response carries `x-ontology-hash` and `x-request-id`; failures
18
- * answer with a JSON error and never a stack or SQL.
16
+ * The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
17
+ * behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
18
+ * and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
19
19
  */
20
20
  export declare function createOntologyService(ontology: Ontology, config: OntologyServiceConfig): OntologyService;
@@ -11,6 +11,8 @@ import { RequestId, failureResponse, internalError, errorBodyForWire, routeNotFo
11
11
  import { deployedConnections, localConnections } from "./connections.js";
12
12
  import { DocsRoute } from "./docs.js";
13
13
  import { handlersLayer } from "./handlers.js";
14
+ import { mcpLayer } from "./mcp/server.js";
15
+ import { oauthLayer } from "./oauth/server.js";
14
16
  import { planApi } from "./plan.js";
15
17
  /** The header every response carries: the hash of the manifest the service serves. */
16
18
  export const ONTOLOGY_HASH_HEADER = "x-ontology-hash";
@@ -54,9 +56,9 @@ function decorate(response, requestId, hash) {
54
56
  });
55
57
  }
56
58
  /**
57
- * The ontology service: the derived HttpApi, wired to the runtime, behind Neon Auth, app-token and
58
- * API-key authentication. Every response carries `x-ontology-hash` and `x-request-id`; failures
59
- * answer with a JSON error and never a stack or SQL.
59
+ * The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
60
+ * behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
61
+ * and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
60
62
  */
61
63
  export function createOntologyService(ontology, config) {
62
64
  if (config.blobStorage &&
@@ -84,8 +86,11 @@ export function createOntologyService(ontology, config) {
84
86
  authenticationLayer(verifyToken, exchangeSession),
85
87
  ErrorResponsesLive,
86
88
  storageMaintenanceAuthenticationLayer(config.storageMaintenanceKey),
87
- ]), Layer.provide(backend));
88
- const application = Layer.mergeAll(routes, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(HttpServer.layerServices));
89
+ ]));
90
+ const mcp = config.mcp === false
91
+ ? Layer.empty
92
+ : Layer.mergeAll(mcpLayer({ built, hooks, connections, verifyToken }), oauthLayer({ verifyToken }));
93
+ const application = Layer.mergeAll(routes, mcp, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
89
94
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
90
95
  const hash = ontology.manifest.hash;
91
96
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",
@@ -20,6 +20,10 @@
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
22
  "import": "./dist/index.js"
23
+ },
24
+ "./nitro": {
25
+ "types": "./dist/nitro.d.ts",
26
+ "import": "./dist/nitro.js"
23
27
  }
24
28
  },
25
29
  "bin": {
@@ -54,6 +58,7 @@
54
58
  },
55
59
  "devDependencies": {
56
60
  "@electric-sql/pglite": "0.4.6",
61
+ "@modelcontextprotocol/sdk": "1.32.1",
57
62
  "@types/node": "^24",
58
63
  "effect": "4.0.0-rc.113",
59
64
  "typescript": "^5.9",