@segmentstream-cloud/ontology 0.26.1 → 0.27.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 CHANGED
@@ -353,6 +353,24 @@ built-in `agent_chat`: reading or changing it needs a permission that names it,
353
353
  exist, and on `allow`, which version 0.2 replaced. It warns on an action no role may run
354
354
  and on `*:*`.
355
355
 
356
+ ## Discover the ontology over MCP
357
+
358
+ Since 0.27.0, agents use `describe` without arguments for a compact entity index and `discover`
359
+ for keyword, literal substring, or fuzzy metadata search. Inspect `entity:lead`,
360
+ `action:lead.change_status`, or `measure:sales.revenue` refs with `describe` for complete definitions.
361
+ Entity inspection includes all property metadata, relationships, permitted generated and authored
362
+ actions with their input contracts, and computable measures. Related entities stay references.
363
+
364
+ Index and search results carry `hash` and `next_cursor`; pass the cursor as `after` only when more
365
+ results are needed. Complete inspection is not paginated. An optional `expected_hash` detects a
366
+ changed deployment. Stored schema visibility does not grant permission to read records or run
367
+ operations. `tools/list` continues to expose the standard permission-filtered operation tools.
368
+
369
+ The MCP `ontology://describe` resource is the first index page as JSON. `ontology://catalog`
370
+ remains an explicit full manifest export; REST descriptions and CLI catalog rendering retain
371
+ whole-catalog behavior. Custom prompts expecting the old full no-argument MCP description should
372
+ switch to progressive inspection or explicitly request the full export.
373
+
356
374
  ## Serve the ontology
357
375
 
358
376
  ```ts
package/dist/action.d.ts CHANGED
@@ -18,7 +18,9 @@ export interface ActionContext<Input, Prepared = undefined> extends PrepareConte
18
18
  /** What `prepare` may fail with: the action errors, and connection calls that failed. */
19
19
  export type PrepareError = ActionError | ConnectionError;
20
20
  /**
21
- * How an action behaves. Agents confirm destructive or irreversible actions before preparation.
21
+ * How an action behaves. Agents confirm destructive, irreversible or external actions before
22
+ * preparation. Actions with connections are external even when both safety hints are false.
23
+ * Permitted, routine reversible actions can run without confirmation.
22
24
  * Custom actions without safety labels require confirmation for backwards compatibility.
23
25
  * Idempotence does not decide whether confirmation is needed.
24
26
  */
package/dist/names.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "sta
27
27
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
28
28
  export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
29
29
  /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
30
- export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me", "aggregate"];
30
+ export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "discover", "me", "aggregate"];
31
31
  /** The longest tool name that Claude and OpenAI accept. */
32
32
  export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
33
33
  /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
package/dist/names.js CHANGED
@@ -66,7 +66,7 @@ export const RESERVED_ENTITY_NAMES = [
66
66
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
67
67
  export const MCP_READ_TOOLS = ["find", "get", "history"];
68
68
  /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
69
- export const MCP_GLOBAL_TOOLS = ["describe", "me", "aggregate"];
69
+ export const MCP_GLOBAL_TOOLS = ["describe", "discover", "me", "aggregate"];
70
70
  /** The longest tool name that Claude and OpenAI accept. */
71
71
  export const MCP_TOOL_NAME_MAX_LENGTH = 64;
72
72
  /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
@@ -0,0 +1,143 @@
1
+ import { Schema } from "effect";
2
+ export declare const DescribeInput: Schema.Struct<{
3
+ readonly ref: Schema.optionalKey<Schema.String>;
4
+ readonly after: Schema.optionalKey<Schema.String>;
5
+ readonly limit: Schema.optionalKey<Schema.Int>;
6
+ readonly expected_hash: Schema.optionalKey<Schema.String>;
7
+ }>;
8
+ export type DescribeInput = typeof DescribeInput.Type;
9
+ export declare const DiscoverInput: Schema.Struct<{
10
+ readonly query: Schema.String;
11
+ readonly kind: Schema.optionalKey<Schema.Literals<readonly ["entity", "action", "measure"]>>;
12
+ readonly entity: Schema.optionalKey<Schema.String>;
13
+ readonly mode: Schema.optionalKey<Schema.Literals<readonly ["auto", "literal", "fuzzy"]>>;
14
+ readonly after: Schema.optionalKey<Schema.String>;
15
+ readonly limit: Schema.optionalKey<Schema.Int>;
16
+ readonly expected_hash: Schema.optionalKey<Schema.String>;
17
+ }>;
18
+ export type DiscoverInput = typeof DiscoverInput.Type;
19
+ export declare const IndexEntry: Schema.Struct<{
20
+ readonly storage: Schema.Literals<readonly ["stored", "warehouse"]>;
21
+ readonly capabilities: Schema.Struct<{
22
+ readonly read_records: Schema.Boolean;
23
+ readonly run_actions: Schema.Boolean;
24
+ readonly aggregate: Schema.Boolean;
25
+ }>;
26
+ readonly ref: Schema.String;
27
+ readonly name: Schema.String;
28
+ readonly label: Schema.String;
29
+ readonly description: Schema.String;
30
+ readonly summary_truncated: Schema.Boolean;
31
+ }>;
32
+ export type IndexEntry = typeof IndexEntry.Type;
33
+ export declare const SearchMatch: Schema.Struct<{
34
+ readonly kind: Schema.Literals<readonly ["entity", "action", "measure"]>;
35
+ readonly entity: Schema.optionalKey<Schema.String>;
36
+ readonly matched_on: Schema.$Array<Schema.Struct<{
37
+ readonly field: Schema.String;
38
+ readonly excerpt: Schema.String;
39
+ }>>;
40
+ readonly ref: Schema.String;
41
+ readonly name: Schema.String;
42
+ readonly label: Schema.String;
43
+ readonly description: Schema.String;
44
+ readonly summary_truncated: Schema.Boolean;
45
+ }>;
46
+ export type SearchMatch = typeof SearchMatch.Type;
47
+ /** One object root for clients validating MCP structured content. Definitions are request data. */
48
+ export declare const DescribeOutput: Schema.Struct<{
49
+ readonly hash: Schema.String;
50
+ readonly kind: Schema.Literals<readonly ["index", "entity", "action", "measure"]>;
51
+ readonly ref: Schema.optionalKey<Schema.String>;
52
+ readonly total: Schema.optionalKey<Schema.Int>;
53
+ readonly entities: Schema.optionalKey<Schema.$Array<Schema.Struct<{
54
+ readonly storage: Schema.Literals<readonly ["stored", "warehouse"]>;
55
+ readonly capabilities: Schema.Struct<{
56
+ readonly read_records: Schema.Boolean;
57
+ readonly run_actions: Schema.Boolean;
58
+ readonly aggregate: Schema.Boolean;
59
+ }>;
60
+ readonly ref: Schema.String;
61
+ readonly name: Schema.String;
62
+ readonly label: Schema.String;
63
+ readonly description: Schema.String;
64
+ readonly summary_truncated: Schema.Boolean;
65
+ }>>>;
66
+ readonly next_cursor: Schema.optionalKey<Schema.NullOr<Schema.String>>;
67
+ readonly entity: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
68
+ readonly action: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
69
+ readonly measure: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
70
+ }>;
71
+ export declare const DiscoverOutput: Schema.Struct<{
72
+ readonly hash: Schema.String;
73
+ readonly kind: Schema.Literal<"search">;
74
+ readonly query: Schema.String;
75
+ readonly total: Schema.Int;
76
+ readonly matches: Schema.$Array<Schema.Struct<{
77
+ readonly kind: Schema.Literals<readonly ["entity", "action", "measure"]>;
78
+ readonly entity: Schema.optionalKey<Schema.String>;
79
+ readonly matched_on: Schema.$Array<Schema.Struct<{
80
+ readonly field: Schema.String;
81
+ readonly excerpt: Schema.String;
82
+ }>>;
83
+ readonly ref: Schema.String;
84
+ readonly name: Schema.String;
85
+ readonly label: Schema.String;
86
+ readonly description: Schema.String;
87
+ readonly summary_truncated: Schema.Boolean;
88
+ }>>;
89
+ readonly next_cursor: Schema.NullOr<Schema.String>;
90
+ }>;
91
+ export declare const DescribeToolOutput: Schema.Union<readonly [Schema.Struct<{
92
+ readonly hash: Schema.String;
93
+ readonly kind: Schema.Literals<readonly ["index", "entity", "action", "measure"]>;
94
+ readonly ref: Schema.optionalKey<Schema.String>;
95
+ readonly total: Schema.optionalKey<Schema.Int>;
96
+ readonly entities: Schema.optionalKey<Schema.$Array<Schema.Struct<{
97
+ readonly storage: Schema.Literals<readonly ["stored", "warehouse"]>;
98
+ readonly capabilities: Schema.Struct<{
99
+ readonly read_records: Schema.Boolean;
100
+ readonly run_actions: Schema.Boolean;
101
+ readonly aggregate: Schema.Boolean;
102
+ }>;
103
+ readonly ref: Schema.String;
104
+ readonly name: Schema.String;
105
+ readonly label: Schema.String;
106
+ readonly description: Schema.String;
107
+ readonly summary_truncated: Schema.Boolean;
108
+ }>>>;
109
+ readonly next_cursor: Schema.optionalKey<Schema.NullOr<Schema.String>>;
110
+ readonly entity: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
111
+ readonly action: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
112
+ readonly measure: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
113
+ }>, Schema.Top, Schema.Top, Schema.Top, Schema.Struct<{
114
+ readonly error: Schema.Literals<readonly ["invalid_cursor", "ontology_version_mismatch", "discovery_too_large"]>;
115
+ readonly detail: Schema.String;
116
+ readonly request_id: Schema.String;
117
+ readonly hash: Schema.optionalKey<Schema.String>;
118
+ }>]>;
119
+ export declare const DiscoverToolOutput: Schema.Union<readonly [Schema.Struct<{
120
+ readonly hash: Schema.String;
121
+ readonly kind: Schema.Literal<"search">;
122
+ readonly query: Schema.String;
123
+ readonly total: Schema.Int;
124
+ readonly matches: Schema.$Array<Schema.Struct<{
125
+ readonly kind: Schema.Literals<readonly ["entity", "action", "measure"]>;
126
+ readonly entity: Schema.optionalKey<Schema.String>;
127
+ readonly matched_on: Schema.$Array<Schema.Struct<{
128
+ readonly field: Schema.String;
129
+ readonly excerpt: Schema.String;
130
+ }>>;
131
+ readonly ref: Schema.String;
132
+ readonly name: Schema.String;
133
+ readonly label: Schema.String;
134
+ readonly description: Schema.String;
135
+ readonly summary_truncated: Schema.Boolean;
136
+ }>>;
137
+ readonly next_cursor: Schema.NullOr<Schema.String>;
138
+ }>, Schema.Top, Schema.Top, Schema.Top, Schema.Struct<{
139
+ readonly error: Schema.Literals<readonly ["invalid_cursor", "ontology_version_mismatch", "discovery_too_large"]>;
140
+ readonly detail: Schema.String;
141
+ readonly request_id: Schema.String;
142
+ readonly hash: Schema.optionalKey<Schema.String>;
143
+ }>]>;
@@ -0,0 +1,80 @@
1
+ import { Schema } from "effect";
2
+ import { ErrorSchemas } from "./schemas.js";
3
+ const Cursor = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(2048));
4
+ const Hash = Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/));
5
+ const limit = (maximum) => Schema.Int.check(Schema.isBetween({ minimum: 1, maximum }));
6
+ export const DescribeInput = Schema.Struct({
7
+ ref: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256))),
8
+ after: Schema.optionalKey(Cursor),
9
+ limit: Schema.optionalKey(limit(100)),
10
+ expected_hash: Schema.optionalKey(Hash),
11
+ });
12
+ export const DiscoverInput = Schema.Struct({
13
+ query: Schema.String.check(Schema.makeFilter((value) => value.trim().length > 0 && Array.from(value).length <= 512
14
+ ? true
15
+ : "Use a nonblank query of at most 512 Unicode code points.", { toJsonSchema: () => ({ minLength: 1, maxLength: 512, pattern: "\\S" }) })),
16
+ kind: Schema.optionalKey(Schema.Literals(["entity", "action", "measure"])),
17
+ entity: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_]*$/))),
18
+ mode: Schema.optionalKey(Schema.Literals(["auto", "literal", "fuzzy"])),
19
+ after: Schema.optionalKey(Cursor),
20
+ limit: Schema.optionalKey(limit(50)),
21
+ expected_hash: Schema.optionalKey(Hash),
22
+ });
23
+ const Capabilities = Schema.Struct({
24
+ read_records: Schema.Boolean,
25
+ run_actions: Schema.Boolean,
26
+ aggregate: Schema.Boolean,
27
+ });
28
+ const Summary = {
29
+ ref: Schema.String,
30
+ name: Schema.String,
31
+ label: Schema.String,
32
+ description: Schema.String,
33
+ summary_truncated: Schema.Boolean,
34
+ };
35
+ export const IndexEntry = Schema.Struct({
36
+ ...Summary,
37
+ storage: Schema.Literals(["stored", "warehouse"]),
38
+ capabilities: Capabilities,
39
+ });
40
+ export const SearchMatch = Schema.Struct({
41
+ ...Summary,
42
+ kind: Schema.Literals(["entity", "action", "measure"]),
43
+ entity: Schema.optionalKey(Schema.String),
44
+ matched_on: Schema.Array(Schema.Struct({ field: Schema.String, excerpt: Schema.String })),
45
+ });
46
+ /** One object root for clients validating MCP structured content. Definitions are request data. */
47
+ export const DescribeOutput = Schema.Struct({
48
+ hash: Schema.String,
49
+ kind: Schema.Literals(["index", "entity", "action", "measure"]),
50
+ ref: Schema.optionalKey(Schema.String),
51
+ total: Schema.optionalKey(Schema.Int),
52
+ entities: Schema.optionalKey(Schema.Array(IndexEntry)),
53
+ next_cursor: Schema.optionalKey(Schema.NullOr(Schema.String)),
54
+ entity: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
55
+ action: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
56
+ measure: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
57
+ });
58
+ export const DiscoverOutput = Schema.Struct({
59
+ hash: Schema.String,
60
+ kind: Schema.Literal("search"),
61
+ query: Schema.String,
62
+ total: Schema.Int,
63
+ matches: Schema.Array(SearchMatch),
64
+ next_cursor: Schema.NullOr(Schema.String),
65
+ });
66
+ const DiscoveryFailureOutput = Schema.Struct({
67
+ error: Schema.Literals(["invalid_cursor", "ontology_version_mismatch", "discovery_too_large"]),
68
+ detail: Schema.String,
69
+ request_id: Schema.String,
70
+ hash: Schema.optionalKey(Schema.String),
71
+ });
72
+ // Some MCP clients validate structured tool errors as well as successful results.
73
+ const failures = [
74
+ ErrorSchemas.invalidRequest,
75
+ ErrorSchemas.notFound,
76
+ ErrorSchemas.internal,
77
+ DiscoveryFailureOutput,
78
+ ];
79
+ export const DescribeToolOutput = Schema.Union([DescribeOutput, ...failures]);
80
+ export const DiscoverToolOutput = Schema.Union([DiscoverOutput, ...failures]);
@@ -0,0 +1,27 @@
1
+ import type { DiscoverInput, SearchMatch } from "./discovery-schemas.js";
2
+ export interface SearchField {
3
+ readonly path: string;
4
+ readonly text: string;
5
+ readonly weight: number;
6
+ }
7
+ export interface SearchDocument {
8
+ readonly summary: Omit<SearchMatch, "matched_on">;
9
+ readonly fields: ReadonlyArray<SearchField>;
10
+ }
11
+ export declare function normalizeSearch(text: string): string;
12
+ /** Rank only caller-projected documents; forbidden fields never affect ranking or explanations. */
13
+ export declare function searchDocuments(documents: ReadonlyArray<SearchDocument>, input: DiscoverInput): {
14
+ readonly name: string;
15
+ readonly description: string;
16
+ readonly label: string;
17
+ readonly kind: "entity" | "measure" | "action";
18
+ readonly ref: string;
19
+ readonly summary_truncated: boolean;
20
+ readonly matched_on: readonly {
21
+ readonly field: string;
22
+ readonly excerpt: string;
23
+ }[];
24
+ readonly entity?: string | undefined;
25
+ }[];
26
+ /** Bytewise canonical-name ordering is independent of a function instance's locale. */
27
+ export declare function compareRefs(left: string, right: string): number;
@@ -0,0 +1,95 @@
1
+ export function normalizeSearch(text) {
2
+ return text.normalize("NFKC").toLowerCase().replace(/ß/g, "ss").replace(/ς/g, "σ").trim();
3
+ }
4
+ function tokens(text) {
5
+ const words = text.replace(/([\p{Ll}\d])([\p{Lu}])/gu, "$1 $2");
6
+ return normalizeSearch(words).match(/[\p{L}\p{N}]+/gu) ?? [];
7
+ }
8
+ /** Bounded optimal-string-alignment distance, including adjacent transpositions. */
9
+ function closeToken(left, right) {
10
+ const a = Array.from(left);
11
+ const b = Array.from(right);
12
+ const maximum = a.length < 4 ? 0 : a.length < 8 ? 1 : 2;
13
+ if (maximum === 0 || Math.abs(a.length - b.length) > maximum) {
14
+ return false;
15
+ }
16
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
17
+ let beforePrevious = previous;
18
+ for (let row = 1; row <= a.length; row++) {
19
+ const current = [row];
20
+ for (let column = 1; column <= b.length; column++) {
21
+ current[column] = Math.min((previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, (previous[column - 1] ?? 0) + (a[row - 1] === b[column - 1] ? 0 : 1));
22
+ if (row > 1 && column > 1 && a[row - 1] === b[column - 2] && a[row - 2] === b[column - 1]) {
23
+ current[column] = Math.min(current[column] ?? 0, (beforePrevious[column - 2] ?? 0) + 1);
24
+ }
25
+ }
26
+ if (Math.min(...current) > maximum) {
27
+ return false;
28
+ }
29
+ beforePrevious = previous;
30
+ previous = current;
31
+ }
32
+ return (previous[b.length] ?? Infinity) <= maximum;
33
+ }
34
+ function excerpt(text, query) {
35
+ const position = normalizeSearch(text).indexOf(query);
36
+ const characterPosition = Array.from(text.slice(0, Math.max(0, position))).length;
37
+ const start = Math.max(0, characterPosition - 40);
38
+ return Array.from(text)
39
+ .slice(start, start + 160)
40
+ .join("");
41
+ }
42
+ /** Rank only caller-projected documents; forbidden fields never affect ranking or explanations. */
43
+ export function searchDocuments(documents, input) {
44
+ const query = normalizeSearch(input.query);
45
+ const terms = [...new Set(tokens(input.query))];
46
+ const ranked = [];
47
+ for (const document of documents) {
48
+ if ((input.kind !== undefined && document.summary.kind !== input.kind) ||
49
+ (input.entity !== undefined && document.summary.entity !== input.entity)) {
50
+ continue;
51
+ }
52
+ const explanations = [];
53
+ const covered = new Set();
54
+ let best = 0;
55
+ for (const field of document.fields) {
56
+ const normalized = normalizeSearch(field.text);
57
+ const phrase = normalized.includes(query);
58
+ let tier = phrase ? (normalized === query ? 5 : 4) : 0;
59
+ if (input.mode !== "literal") {
60
+ const words = tokens(field.text);
61
+ for (const term of terms) {
62
+ if (words.some((word) => word === term || word.startsWith(term))) {
63
+ covered.add(term);
64
+ tier = Math.max(tier, 3);
65
+ }
66
+ else if (words.some((word) => closeToken(term, word))) {
67
+ covered.add(term);
68
+ tier = Math.max(tier, 2);
69
+ }
70
+ }
71
+ }
72
+ if (tier > 0) {
73
+ const score = tier * 1_000_000 + field.weight;
74
+ best = Math.max(best, score);
75
+ explanations.push({ field: field.path, excerpt: excerpt(field.text, query), score });
76
+ }
77
+ }
78
+ if (best > 0) {
79
+ explanations.sort((a, b) => b.score - a.score || a.field.localeCompare(b.field, "en"));
80
+ ranked.push({
81
+ match: {
82
+ ...document.summary,
83
+ matched_on: explanations.slice(0, 3).map(({ field, excerpt }) => ({ field, excerpt })),
84
+ },
85
+ score: best + covered.size * 10_000,
86
+ });
87
+ }
88
+ }
89
+ ranked.sort((a, b) => b.score - a.score || compareRefs(a.match.ref, b.match.ref));
90
+ return ranked.map(({ match }) => match);
91
+ }
92
+ /** Bytewise canonical-name ordering is independent of a function instance's locale. */
93
+ export function compareRefs(left, right) {
94
+ return left < right ? -1 : left > right ? 1 : 0;
95
+ }
@@ -0,0 +1,25 @@
1
+ import { type PermissionHolder } from "../permissions.js";
2
+ import type { BuiltApi } from "./api.js";
3
+ import type { DescribeInput, DiscoverInput } from "./discovery-schemas.js";
4
+ import { type McpTool } from "./mcp/tools.js";
5
+ type JsonObject = Readonly<Record<string, unknown>>;
6
+ export declare const DISCOVERY_RESULT_BYTES: number;
7
+ declare const DiscoveryError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
8
+ readonly _tag: "DiscoveryError";
9
+ } & Readonly<A>;
10
+ /** Expected metadata errors retain their public reason without exposing excluded definitions. */
11
+ export declare class DiscoveryError extends DiscoveryError_base<{
12
+ readonly error: "invalid_request" | "invalid_cursor" | "ontology_version_mismatch" | "not_found" | "discovery_too_large";
13
+ readonly detail: string;
14
+ readonly hash?: string;
15
+ }> {
16
+ }
17
+ /** Exactly the successful MCP result envelope, including JSON's escaped text representation. */
18
+ export declare function discoveryResultBytes(result: JsonObject): number;
19
+ /** Immutable deployment metadata; authorization and projections are recomputed for every caller. */
20
+ export declare function createDiscovery(built: BuiltApi, tools: ReadonlyArray<McpTool>): {
21
+ describe(input: DescribeInput, principal: PermissionHolder): JsonObject;
22
+ discover(input: DiscoverInput, principal: PermissionHolder): JsonObject;
23
+ };
24
+ export type Discovery = ReturnType<typeof createDiscovery>;
25
+ export {};
@@ -0,0 +1,457 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Data } from "effect";
3
+ import { encodeManifest } from "../manifest.js";
4
+ import { effectivePermissions, isAllowed, permissionOf, READ_VERB, } from "../permissions.js";
5
+ import { measureDescription, measuresFor, modelMeasureDescription, modelMeasuresFor, reachesOf, } from "../render.js";
6
+ import { compareRefs, normalizeSearch, searchDocuments, } from "./discovery-search.js";
7
+ import { visibleTools } from "./mcp/tools.js";
8
+ export const DISCOVERY_RESULT_BYTES = 16 * 1024;
9
+ const WIRE_NAME = "[a-z][a-z0-9_]*";
10
+ const REFERENCE_PATTERN = new RegExp(`^(?:${[
11
+ `entity:${WIRE_NAME}`,
12
+ `action:${WIRE_NAME}\\.${WIRE_NAME}`,
13
+ `measure:${WIRE_NAME}(?:\\.${WIRE_NAME})?`,
14
+ ].join("|")})$`);
15
+ /** Expected metadata errors retain their public reason without exposing excluded definitions. */
16
+ export class DiscoveryError extends Data.TaggedError("DiscoveryError") {
17
+ }
18
+ function object(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function digest(value) {
22
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
23
+ }
24
+ function preview(text, maximum) {
25
+ return Array.from(text).slice(0, maximum).join("");
26
+ }
27
+ function summary(ref, name, label, description) {
28
+ const shortLabel = preview(label, 120);
29
+ const shortDescription = preview(description, 240);
30
+ return {
31
+ ref,
32
+ name,
33
+ label: shortLabel,
34
+ description: shortDescription,
35
+ summary_truncated: shortLabel !== label || shortDescription !== description,
36
+ };
37
+ }
38
+ /** Exactly the successful MCP result envelope, including JSON's escaped text representation. */
39
+ export function discoveryResultBytes(result) {
40
+ return Buffer.byteLength(JSON.stringify({
41
+ content: [{ type: "text", text: JSON.stringify(result) }],
42
+ structuredContent: result,
43
+ }));
44
+ }
45
+ function checkHash(hash, expected) {
46
+ if (expected !== undefined && expected !== hash) {
47
+ throw new DiscoveryError({
48
+ error: "ontology_version_mismatch",
49
+ detail: "The ontology changed. Repeat the relevant discovery or inspection with the current hash.",
50
+ hash,
51
+ });
52
+ }
53
+ }
54
+ function invalidCursor() {
55
+ throw new DiscoveryError({
56
+ error: "invalid_cursor",
57
+ detail: "Restart this index or search; its cursor is invalid.",
58
+ });
59
+ }
60
+ function page(input) {
61
+ const scope = digest([...new Set(input.permissions)].sort(compareRefs));
62
+ const request = digest(input.request);
63
+ let start = 0;
64
+ if (input.after !== undefined) {
65
+ let cursor;
66
+ try {
67
+ if (!/^[A-Za-z0-9_-]+$/.test(input.after)) {
68
+ return invalidCursor();
69
+ }
70
+ cursor = JSON.parse(Buffer.from(input.after, "base64url").toString("utf8"));
71
+ }
72
+ catch (error) {
73
+ if (error instanceof DiscoveryError) {
74
+ throw error;
75
+ }
76
+ return invalidCursor();
77
+ }
78
+ if (!object(cursor) || typeof cursor.hash !== "string") {
79
+ return invalidCursor();
80
+ }
81
+ checkHash(input.hash, cursor.hash);
82
+ if (cursor.scope !== scope || cursor.request !== request || typeof cursor.last !== "string") {
83
+ return invalidCursor();
84
+ }
85
+ const position = input.items.findIndex(({ ref }) => ref === cursor.last);
86
+ if (position < 0) {
87
+ return invalidCursor();
88
+ }
89
+ start = position + 1;
90
+ }
91
+ const cursorAfter = (last) => Buffer.from(JSON.stringify({ hash: input.hash, scope, request, last })).toString("base64url");
92
+ const selected = [];
93
+ let result = input.result(selected, null);
94
+ for (const item of input.items.slice(start, start + input.limit)) {
95
+ const candidate = [...selected, item];
96
+ const more = start + candidate.length < input.items.length;
97
+ const next = input.result(candidate, more ? cursorAfter(item.ref) : null);
98
+ if (discoveryResultBytes(next) > DISCOVERY_RESULT_BYTES) {
99
+ if (selected.length === 0) {
100
+ throw new DiscoveryError({
101
+ error: "discovery_too_large",
102
+ detail: "This discovery entry exceeds the response budget.",
103
+ });
104
+ }
105
+ break;
106
+ }
107
+ selected.push(item);
108
+ result = next;
109
+ }
110
+ return result;
111
+ }
112
+ function textFields(value, prefix = "", weight = 10) {
113
+ return ["name", "label", "plural", "description"].flatMap((key) => {
114
+ const text = value[key];
115
+ return typeof text === "string"
116
+ ? [{ path: prefix + key, text, weight: key === "description" ? weight : weight + 10 }]
117
+ : [];
118
+ });
119
+ }
120
+ /** JSON Schema field descriptions, including nested properties and local definitions. */
121
+ function inputFields(schema, prefix = "input") {
122
+ const fields = [];
123
+ for (const [key, value] of Object.entries(schema)) {
124
+ if (key === "description" && typeof value === "string") {
125
+ fields.push({ path: `${prefix}.description`, text: value, weight: 10 });
126
+ }
127
+ else if (key === "properties" && object(value)) {
128
+ for (const [name, property] of Object.entries(value)) {
129
+ fields.push({ path: `${prefix}.properties.${name}.name`, text: name, weight: 20 });
130
+ if (object(property)) {
131
+ fields.push(...inputFields(property, `${prefix}.properties.${name}`));
132
+ }
133
+ }
134
+ }
135
+ else if (object(value)) {
136
+ fields.push(...inputFields(value, `${prefix}.${key}`));
137
+ }
138
+ else if (Array.isArray(value)) {
139
+ value.forEach((branch, index) => {
140
+ if (object(branch)) {
141
+ fields.push(...inputFields(branch, `${prefix}.${key}.${index}`));
142
+ }
143
+ });
144
+ }
145
+ }
146
+ return fields;
147
+ }
148
+ /** Immutable deployment metadata; authorization and projections are recomputed for every caller. */
149
+ export function createDiscovery(built, tools) {
150
+ const { manifest } = built.plan.ontology;
151
+ const wire = encodeManifest(manifest);
152
+ const wireEntities = new Map(wire.entities.map((entity) => [entity.name, entity]));
153
+ const actionDefinitions = new Map();
154
+ const incoming = new Map();
155
+ for (const source of manifest.entities) {
156
+ source.relationships.forEach((relationship, index) => {
157
+ const edges = incoming.get(relationship.to) ?? [];
158
+ edges.push({ source, relationship, index });
159
+ incoming.set(relationship.to, edges);
160
+ });
161
+ }
162
+ for (const tool of tools) {
163
+ if (tool.target.kind !== "action") {
164
+ continue;
165
+ }
166
+ const { planned, action } = tool.target;
167
+ actionDefinitions.set(`action:${planned.name}.${action.name}`, {
168
+ name: action.name,
169
+ entity: { ref: `entity:${planned.name}` },
170
+ tool_name: tool.name,
171
+ generated: action.generated,
172
+ description: action.action.description,
173
+ tool_description: tool.description,
174
+ permissions: tool.permissions,
175
+ input_schema: tool.inputSchema,
176
+ annotations: tool.annotations,
177
+ connections: action.action.connections.map(({ key, description }) => ({ key, description })),
178
+ });
179
+ }
180
+ function projection(principal) {
181
+ const permissions = effectivePermissions(principal);
182
+ const holder = { permissions };
183
+ const visibleEntities = new Map(manifest.entities
184
+ .filter((entity) => entity.source === undefined ||
185
+ isAllowed(entity.permissions?.read ?? permissionOf(entity.name, READ_VERB), holder))
186
+ .map((entity) => [entity.name, entity]));
187
+ const actions = new Map();
188
+ for (const tool of visibleTools(tools, principal)) {
189
+ if (tool.target.kind === "action") {
190
+ const ref = `action:${tool.target.planned.name}.${tool.target.action.name}`;
191
+ actions.set(ref, actionDefinitions.get(ref));
192
+ }
193
+ }
194
+ const measures = new Map();
195
+ for (const { name, entity, measure } of measuresFor(manifest, permissions)) {
196
+ const encoded = wireEntities.get(entity.name)?.measures;
197
+ measures.set(`measure:${name}`, {
198
+ ...encoded.find((candidate) => candidate.name === measure.name),
199
+ name,
200
+ entity: { ref: `entity:${entity.name}` },
201
+ explanation: measureDescription(entity, measure),
202
+ });
203
+ }
204
+ const model = wire.measures;
205
+ for (const measure of modelMeasuresFor(manifest, permissions)) {
206
+ measures.set(`measure:${measure.name}`, {
207
+ ...model?.find((candidate) => candidate.name === measure.name),
208
+ explanation: modelMeasureDescription(measure),
209
+ input_refs: measure.inputs.map((name) => `measure:${name}`),
210
+ computation: "Computed on the combined results of its input entity measures; " +
211
+ "division by zero and missing inputs remain null.",
212
+ });
213
+ }
214
+ const actionOwners = new Set([...actions.keys()].map((ref) => ref.slice("action:".length).split(".")[0]));
215
+ const measureOwners = new Set([...measures.keys()].flatMap((ref) => ref.includes(".") ? [ref.slice("measure:".length).split(".")[0]] : []));
216
+ function capabilities(entity) {
217
+ return {
218
+ read_records: entity.source === undefined &&
219
+ isAllowed(entity.permissions?.read ?? `${entity.name}:read`, holder),
220
+ run_actions: actionOwners.has(entity.name),
221
+ aggregate: measureOwners.has(entity.name),
222
+ };
223
+ }
224
+ function relationships(entity) {
225
+ const declared = entity.relationships.map((relationship, index) => ({
226
+ ...(wireEntities.get(entity.name)?.relationships)[index],
227
+ direction: "declared",
228
+ target: {
229
+ ref: `entity:${relationship.to}`,
230
+ inspectable: visibleEntities.has(relationship.to),
231
+ },
232
+ }));
233
+ const inverse = (incoming.get(entity.name) ?? []).flatMap(({ source, relationship, index }) => {
234
+ const encoded = (wireEntities.get(source.name)?.relationships)[index];
235
+ return [
236
+ {
237
+ name: relationship.inverse,
238
+ inverse: relationship.name,
239
+ to: source.name,
240
+ cardinality: "many",
241
+ optional: true,
242
+ direction: "inverse",
243
+ description: relationship.description,
244
+ phrase: [relationship.phrase[1], relationship.phrase[0]],
245
+ declaration: { entity: { ref: `entity:${source.name}` }, relationship: encoded },
246
+ ...(relationship.on === undefined
247
+ ? {}
248
+ : {
249
+ on: {
250
+ property: relationship.on.targetProperty,
251
+ target_property: relationship.on.property,
252
+ },
253
+ }),
254
+ target: {
255
+ ref: `entity:${source.name}`,
256
+ inspectable: visibleEntities.has(source.name),
257
+ },
258
+ },
259
+ ];
260
+ });
261
+ return [...declared, ...inverse];
262
+ }
263
+ function entityDefinition(entity) {
264
+ return {
265
+ ...wireEntities.get(entity.name),
266
+ storage: entity.source === undefined ? "stored" : "warehouse",
267
+ capabilities: capabilities(entity),
268
+ relationships: relationships(entity),
269
+ actions: [...actions]
270
+ .filter(([ref]) => ref.startsWith(`action:${entity.name}.`))
271
+ .map(([ref, action]) => ({ ref, ...action })),
272
+ measures: [...measures]
273
+ .filter(([ref]) => ref.startsWith(`measure:${entity.name}.`))
274
+ .map(([ref, measure]) => ({ ref, ...measure })),
275
+ ...(measureOwners.has(entity.name)
276
+ ? { aggregate_routes: reachesOf(manifest, entity.name, permissions) }
277
+ : {}),
278
+ };
279
+ }
280
+ function measureDefinition(measure) {
281
+ if (!object(measure.entity)) {
282
+ return measure;
283
+ }
284
+ const owner = measure.entity.ref.slice("entity:".length);
285
+ return { ...measure, routes: reachesOf(manifest, owner, permissions) };
286
+ }
287
+ function documents() {
288
+ const documents = [];
289
+ for (const entity of visibleEntities.values()) {
290
+ const fields = textFields(entity, "", 50);
291
+ entity.properties.forEach((property) => {
292
+ fields.push(...textFields(property, `properties.${property.name}.`));
293
+ property.options?.forEach((option, index) => {
294
+ fields.push(...textFields(option, `properties.${property.name}.options.${index}.`));
295
+ fields.push({
296
+ path: `properties.${property.name}.options.${index}.value`,
297
+ text: option.value,
298
+ weight: 20,
299
+ });
300
+ });
301
+ });
302
+ for (const relationship of relationships(entity)) {
303
+ const prefix = `relationships.${relationship.name}.`;
304
+ fields.push(...textFields(relationship, prefix));
305
+ relationship.phrase.forEach((text, index) => {
306
+ fields.push({ path: `${prefix}phrase.${index}`, text, weight: 10 });
307
+ });
308
+ const target = visibleEntities.get(relationship.to);
309
+ if (target !== undefined) {
310
+ fields.push({ path: `${prefix}target.label`, text: target.label, weight: 10 });
311
+ }
312
+ }
313
+ documents.push({
314
+ summary: {
315
+ ...summary(`entity:${entity.name}`, entity.name, entity.label, entity.description),
316
+ kind: "entity",
317
+ entity: entity.name,
318
+ },
319
+ fields,
320
+ });
321
+ }
322
+ for (const [ref, action] of actions) {
323
+ const owner = action.entity.ref.slice("entity:".length);
324
+ const name = `${owner}.${action.name}`;
325
+ documents.push({
326
+ summary: {
327
+ ...summary(ref, name, name, action.description),
328
+ kind: "action",
329
+ entity: owner,
330
+ },
331
+ fields: [
332
+ ...textFields({ ...action, name }, "", 50),
333
+ ...inputFields(action.input_schema),
334
+ ],
335
+ });
336
+ }
337
+ for (const [ref, measure] of measures) {
338
+ const owner = object(measure.entity)
339
+ ? measure.entity.ref.slice("entity:".length)
340
+ : undefined;
341
+ documents.push({
342
+ summary: {
343
+ ...summary(ref, measure.name, measure.label, measure.description),
344
+ kind: "measure",
345
+ ...(owner === undefined ? {} : { entity: owner }),
346
+ },
347
+ fields: textFields(measure, "", 50),
348
+ });
349
+ }
350
+ return documents;
351
+ }
352
+ return {
353
+ permissions,
354
+ visibleEntities,
355
+ actions,
356
+ measures,
357
+ capabilities,
358
+ entityDefinition,
359
+ measureDefinition,
360
+ documents,
361
+ };
362
+ }
363
+ return {
364
+ describe(input, principal) {
365
+ checkHash(manifest.hash, input.expected_hash);
366
+ const view = projection(principal);
367
+ if (input.ref !== undefined) {
368
+ if (input.after !== undefined || input.limit !== undefined) {
369
+ throw new DiscoveryError({
370
+ error: "invalid_request",
371
+ detail: "Inspection is complete; omit after and limit when selecting a ref.",
372
+ });
373
+ }
374
+ if (!REFERENCE_PATTERN.test(input.ref)) {
375
+ throw new DiscoveryError({
376
+ error: "invalid_request",
377
+ detail: "Use an entity:, action:, or measure: reference with canonical wire names.",
378
+ });
379
+ }
380
+ const [kind, name] = input.ref.split(":");
381
+ const entity = view.visibleEntities.get(name);
382
+ const definition = kind === "entity"
383
+ ? entity === undefined
384
+ ? undefined
385
+ : view.entityDefinition(entity)
386
+ : kind === "action"
387
+ ? view.actions.get(input.ref)
388
+ : view.measures.get(input.ref);
389
+ if (definition === undefined) {
390
+ throw new DiscoveryError({
391
+ error: "not_found",
392
+ detail: "The ontology definition was not found.",
393
+ });
394
+ }
395
+ return {
396
+ hash: manifest.hash,
397
+ kind,
398
+ ref: input.ref,
399
+ [kind]: kind === "measure" ? view.measureDefinition(definition) : definition,
400
+ };
401
+ }
402
+ const entries = [...view.visibleEntities.values()]
403
+ .map((entity) => ({
404
+ ...summary(`entity:${entity.name}`, entity.name, entity.label, entity.description),
405
+ storage: entity.source === undefined ? "stored" : "warehouse",
406
+ capabilities: view.capabilities(entity),
407
+ }))
408
+ .sort((a, b) => compareRefs(a.name, b.name));
409
+ const limit = input.limit ?? 100;
410
+ return page({
411
+ hash: manifest.hash,
412
+ permissions: view.permissions,
413
+ request: { kind: "index", limit },
414
+ after: input.after,
415
+ limit,
416
+ items: entries,
417
+ result: (entities, next_cursor) => ({
418
+ hash: manifest.hash,
419
+ kind: "index",
420
+ total: entries.length,
421
+ entities,
422
+ next_cursor,
423
+ }),
424
+ });
425
+ },
426
+ discover(input, principal) {
427
+ checkHash(manifest.hash, input.expected_hash);
428
+ const view = projection(principal);
429
+ const matches = searchDocuments(view.documents(), input);
430
+ const total = matches.length;
431
+ const limit = input.limit ?? 10;
432
+ return page({
433
+ hash: manifest.hash,
434
+ permissions: view.permissions,
435
+ request: {
436
+ kind: "search",
437
+ query: normalizeSearch(input.query),
438
+ filter: input.kind,
439
+ entity: input.entity,
440
+ mode: input.mode ?? "auto",
441
+ limit,
442
+ },
443
+ after: input.after,
444
+ limit,
445
+ items: matches,
446
+ result: (matches, next_cursor) => ({
447
+ hash: manifest.hash,
448
+ kind: "search",
449
+ query: input.query,
450
+ total,
451
+ matches,
452
+ next_cursor,
453
+ }),
454
+ });
455
+ },
456
+ };
457
+ }
@@ -3,12 +3,13 @@ import { Cause, Data, Effect, Layer, Option, Schema, SchemaTransformation } from
3
3
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
4
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
5
5
  import { encodeManifest } from "../../manifest.js";
6
- import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
7
6
  import { SCOPE_MISMATCH } from "../aggregate-platform.js";
8
7
  import { CurrentPrincipal, withPrincipal } from "../authentication.js";
9
8
  import { catalogFor } from "../catalog.js";
9
+ import { createDiscovery, DiscoveryError } from "../discovery.js";
10
+ import { DescribeInput, DiscoverInput } from "../discovery-schemas.js";
10
11
  import { ConversationSearch } from "../agent/conversations.js";
11
- import { describeCatalog, measureDescription, measuresFor, modelMeasureDescription, modelMeasuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
12
+ import { RECORDS_OR_AGGREGATES } from "../../render.js";
12
13
  import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
13
14
  import { publicOrigin } from "../oauth/protocol.js";
14
15
  import { mcpChallenge, mcpResource } from "../oauth/server.js";
@@ -110,7 +111,7 @@ function errorText(body) {
110
111
  const VERSION_MISMATCH = "ontology_version_mismatch";
111
112
  /** What the agent reads instead of SegmentStream's detail, for refusals it must act on itself. */
112
113
  const AGENT_DETAILS = {
113
- [VERSION_MISMATCH]: "The ontology changed; reload and try again: call describe for the current measures and " +
114
+ [VERSION_MISMATCH]: "The ontology changed; repeat the relevant discovery and inspection for current measure " +
114
115
  "names, then call aggregate with them.",
115
116
  [SCOPE_MISMATCH]: "SegmentStream would read entities this app's ontology service did not check, so it refused. " +
116
117
  "Retrying will not help: ask the app's operator to update @segmentstream-cloud/ontology and " +
@@ -151,16 +152,23 @@ function toolFailure(cause, requestId) {
151
152
  return Effect.failCause(cause);
152
153
  }
153
154
  const error = Option.getOrUndefined(Cause.findErrorOption(cause));
154
- const expected = error instanceof InvalidArguments
155
+ const expected = error instanceof DiscoveryError
155
156
  ? {
156
- error: "invalid_request",
157
- detail: "The tool's arguments are invalid.",
157
+ error: error.error,
158
+ detail: error.detail,
158
159
  requestId,
159
- issues: error.issues,
160
+ ...(error.hash === undefined ? {} : { hash: error.hash }),
160
161
  }
161
- : error === undefined
162
- ? undefined
163
- : expectedFailure(error, requestId)?.body;
162
+ : error instanceof InvalidArguments
163
+ ? {
164
+ error: "invalid_request",
165
+ detail: "The tool's arguments are invalid.",
166
+ requestId,
167
+ issues: error.issues,
168
+ }
169
+ : error === undefined
170
+ ? undefined
171
+ : expectedFailure(error, requestId)?.body;
164
172
  const failure = expected === undefined
165
173
  ? Effect.logError("ontology.mcp.tool_failed", cause).pipe(Effect.annotateLogs({ requestId }), Effect.as(internalError(requestId).body))
166
174
  : Effect.succeed(expected);
@@ -170,15 +178,27 @@ function toolFailure(cause, requestId) {
170
178
  isError: true,
171
179
  }));
172
180
  }
173
- function runTool({ built, operations, aggregate, conversations, memberFiles }, tool, args, request) {
181
+ function runTool({ built, operations, aggregate, conversations, discovery, memberFiles }, tool, args, request) {
174
182
  return Effect.gen(function* () {
175
183
  const { target } = tool;
176
184
  switch (target.kind) {
177
185
  case "describe": {
178
186
  const principal = yield* CurrentPrincipal;
179
- return {
180
- text: describeCatalog(built.plan.ontology.manifest, effectivePermissions(principal)),
181
- };
187
+ const input = yield* decodeArguments(DescribeInput, args);
188
+ const result = yield* Effect.try({
189
+ try: () => discovery.describe(input, principal),
190
+ catch: (error) => error,
191
+ });
192
+ return structured(result);
193
+ }
194
+ case "discover": {
195
+ const principal = yield* CurrentPrincipal;
196
+ const input = yield* decodeArguments(DiscoverInput, args);
197
+ const result = yield* Effect.try({
198
+ try: () => discovery.discover(input, principal),
199
+ catch: (error) => error,
200
+ });
201
+ return structured(result);
182
202
  }
183
203
  case "aggregate": {
184
204
  // The route's facade, as the caller: the arguments are the request body.
@@ -267,19 +287,20 @@ const RESOURCES = [
267
287
  uri: CATALOG_URI,
268
288
  name: "catalog",
269
289
  title: "The ontology's catalog",
270
- description: "The manifest: entities, properties, relationships, actions and roles with their " +
271
- "permissions, with only the actions you may run.",
290
+ description: "Explicit full manifest export, with current permission filtering. Can be large; " +
291
+ "use describe and discover for progressive metadata inspection.",
272
292
  mimeType: "application/json",
273
293
  },
274
294
  {
275
295
  uri: DESCRIBE_URI,
276
296
  name: "describe",
277
- title: "The ontology as prose",
278
- description: "What the describe tool returns: the entities you may read and the actions you may run.",
279
- mimeType: "text/plain",
297
+ title: "The ontology entity index",
298
+ description: "The first compact entity-index page from describe. Continue with the describe tool using " +
299
+ "next_cursor as after, or discover relevant definitions.",
300
+ mimeType: "application/json",
280
301
  },
281
302
  ];
282
- function readResource({ built }, uri) {
303
+ function readResource({ built, discovery }, uri) {
283
304
  return Effect.gen(function* () {
284
305
  const principal = yield* CurrentPrincipal;
285
306
  const { manifest } = built.plan.ontology;
@@ -296,8 +317,8 @@ function readResource({ built }, uri) {
296
317
  return [
297
318
  {
298
319
  uri,
299
- mimeType: "text/plain",
300
- text: describeCatalog(manifest, effectivePermissions(principal)),
320
+ mimeType: "application/json",
321
+ text: JSON.stringify(discovery.describe({}, principal)),
301
322
  },
302
323
  ];
303
324
  default:
@@ -305,37 +326,22 @@ function readResource({ built }, uri) {
305
326
  }
306
327
  });
307
328
  }
308
- /**
309
- * What an agent learns when it connects: what the server is, the entities the caller may read, the
310
- * measures of every entity they may read, stored or warehouse-backed, and where to find the rest.
311
- */
312
- function instructions({ built }, permissions) {
313
- const { manifest } = built.plan.ontology;
314
- const readable = built.plan.entities
315
- .filter(({ name, resolved }) => {
316
- const definition = resolved.entity.definition;
317
- return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
318
- })
319
- .map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
320
- const measures = [
321
- ...measuresFor(manifest, permissions).map(({ name, entity, measure }) => `- ${name}: ${measureDescription(entity, measure)}`),
322
- ...modelMeasuresFor(manifest, permissions).map((measure) => `- ${measure.name}: ${modelMeasureDescription(measure)}`),
323
- ];
329
+ /** Fixed guidance keeps initialization independent of ontology size. */
330
+ function instructions() {
324
331
  return [
325
- "This server is the app's ontology: the records the company works with and the actions " +
326
- "that change them. Every change goes through an action, is checked against your " +
327
- "permissions and is kept in history; nothing else writes.",
328
- readable.length > 0
329
- ? `Entities you may read, each with <entity>_find, <entity>_get and <entity>_history:\n${readable.join("\n")}`
330
- : "You may read no entities.",
331
- ...(measures.length > 0
332
- ? [
333
- `Measures you may aggregate with the aggregate tool; ${RECORDS_OR_AGGREGATES}:\n` +
334
- measures.join("\n"),
335
- ]
336
- : []),
337
- "Call describe for the full catalog: properties, relationships, measures and the actions " +
338
- "you may run.",
332
+ "This server is the app's ontology. Every change goes through an action, is checked " +
333
+ "against your current permissions, and is kept in history.",
334
+ "Call describe without ref for a compact entity index, or discover to search definitions. " +
335
+ "Inspect relevant entity:, action:, or measure: refs with describe for their complete definitions. " +
336
+ "Follow related refs only when needed; do not exhaust pages unrelated to the task.",
337
+ "Call me for the caller and permissions. Metadata visibility does not grant record access. " +
338
+ "Stored entities use <entity>_find, <entity>_get, <entity>_history and permitted action tools.",
339
+ `For analytics, ${RECORDS_OR_AGGREGATES}. Warehouse entities are read through aggregates.`,
340
+ "Use the authenticated tool annotations for confirmation: permitted actions classified as " +
341
+ "non-destructive and reversible need no extra approval. Destructive, irreversible or " +
342
+ "external actions (openWorldHint) require confirmation before preparation; idempotence " +
343
+ "alone does not waive it. Present confirmations in the conversation's language using " +
344
+ "action, entity, field and option labels and readable record titles, never tool names, ids or JSON.",
339
345
  ].join("\n\n");
340
346
  }
341
347
  function negotiate(requested) {
@@ -358,7 +364,7 @@ function dispatch(server, message, request) {
358
364
  title: "Ontology",
359
365
  version: server.built.plan.ontology.manifest.hash,
360
366
  },
361
- instructions: instructions(server, effectivePermissions(principal)),
367
+ instructions: instructions(),
362
368
  });
363
369
  case "ping":
364
370
  return rpcResult(id, {});
@@ -477,6 +483,7 @@ export function mcpLayer(options) {
477
483
  const server = {
478
484
  built: options.built,
479
485
  tools,
486
+ discovery: createDiscovery(options.built, tools),
480
487
  operations,
481
488
  memberFiles: memberFileOperations(backend, options.built.plan, operations),
482
489
  aggregate: options.aggregates.model,
@@ -23,6 +23,8 @@ export type McpToolTarget = {
23
23
  readonly action: PlannedAction;
24
24
  } | {
25
25
  readonly kind: "describe";
26
+ } | {
27
+ readonly kind: "discover";
26
28
  } | {
27
29
  readonly kind: "me";
28
30
  } | {
@@ -58,7 +60,7 @@ export interface McpTool {
58
60
  */
59
61
  export declare const ACTION_OPTIONS_ARGUMENT = "_options";
60
62
  /**
61
- * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
63
+ * The MCP tools of an ontology: `describe`, `discover` and `me`, `aggregate` when the ontology has measures,
62
64
  * then for every stored entity `find`, `get`,
63
65
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
64
66
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
@@ -1,15 +1,16 @@
1
1
  import { Schema } from "effect";
2
2
  import { AGGREGATE_NAME_PATTERN } from "../../aggregate-routes.js";
3
3
  import { InvalidOntology } from "../../diagnostics.js";
4
- import { isValueMatched, } from "../../manifest.js";
4
+ import { isValueMatched } from "../../manifest.js";
5
5
  import { GENERATED_UPSERT_PREFIX, humanize, mcpToolName } from "../../names.js";
6
6
  import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
7
7
  import { queryJsonSchema } from "../../query.js";
8
- import { measureDescription, measuresFor, modelMeasureDescription, modelMeasuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
8
+ import { measuresFor, modelMeasuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
9
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
10
10
  import { uploadFields } from "../../storage/attachments.js";
11
11
  import { MEMBER_FILE_SCHEMAS } from "../member-files.js";
12
12
  import { actionDescription, requires } from "../descriptions.js";
13
+ import { DescribeInput, DescribeToolOutput, DiscoverInput, DiscoverToolOutput, } from "../discovery-schemas.js";
13
14
  import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, HistoryPage, MAXIMUM_TIMEZONE_LENGTH, Me, } from "../schemas.js";
14
15
  const DEFS = "#/$defs/";
15
16
  /**
@@ -226,15 +227,31 @@ function actionTool(planned, plannedAction) {
226
227
  const GLOBAL_TOOLS = [
227
228
  {
228
229
  name: "describe",
229
- title: "Describe the ontology",
230
- description: "The ontology as prose: the entities you may read, their properties and relationships, " +
231
- "and the actions you may run, each with what it requires. Read it before working with " +
232
- "records.",
233
- inputSchema: NO_INPUT,
230
+ title: "Index or inspect the ontology",
231
+ description: "Without ref, a compact entity index; use next_cursor as after for more. With an entity:, " +
232
+ "action:, or measure: ref, its complete definition, including permitted operations. " +
233
+ "Inspect relevant entities before using unfamiliar fields; related entities remain references. " +
234
+ "Omit after and limit when inspecting a ref. Definitions do not grant record access.",
235
+ inputSchema: encodedJsonSchema(DescribeInput),
236
+ outputSchema: { type: "object", ...encodedJsonSchema(DescribeToolOutput) },
234
237
  annotations: READ,
235
238
  permissions: [],
236
239
  target: { kind: "describe" },
237
240
  },
241
+ {
242
+ name: "discover",
243
+ title: "Search ontology definitions",
244
+ description: "Find entities, permitted actions, and computable measures by their names and descriptions. " +
245
+ "Entity matches also search properties, options, and relationships. Auto ranks literal and " +
246
+ "token matches before spelling approximations; literal is a substring search with no regex. " +
247
+ "Results are summaries: inspect their refs with describe. Search reads metadata, not records. " +
248
+ "Use next_cursor as after only when more matches are relevant to the task.",
249
+ inputSchema: encodedJsonSchema(DiscoverInput),
250
+ outputSchema: { type: "object", ...encodedJsonSchema(DiscoverToolOutput) },
251
+ annotations: READ,
252
+ permissions: [],
253
+ target: { kind: "discover" },
254
+ },
238
255
  {
239
256
  name: "me",
240
257
  title: "Who am I",
@@ -274,23 +291,8 @@ const FILTER_VALUE_DESCRIPTION = "equals, notEquals: the value. gt, gte, lt, lte
274
291
  "dimension; a date covers whole days on a timestamp. contains, startsWith, endsWith: a " +
275
292
  "non-empty string, matched case-sensitively, without wildcards. inPeriod: a date or timestamp " +
276
293
  `dimension's period, today being the date in the request's timezone: ${PERIODS}`;
277
- /** One `anyOf` branch per measure: a JSON Schema `enum` cannot describe its values. */
278
- function measureChoice({ name, entity, measure }) {
279
- return {
280
- const: name,
281
- description: `${entity.label} · ${measure.label} (${measure.unit}): ` +
282
- measureDescription(entity, measure),
283
- };
284
- }
285
- /** A measure of the model, by its bare name, read only by a caller who may read every input. */
286
- function modelMeasureChoice(measure) {
287
- return {
288
- const: measure.name,
289
- description: `Model · ${measure.label} (${measure.unit}): ` + modelMeasureDescription(measure),
290
- };
291
- }
292
294
  /** The aggregate tool's input for a caller: the request of `POST /api/ontology/aggregate`. */
293
- function aggregateInput({ measures, model }) {
295
+ function aggregateInput() {
294
296
  return {
295
297
  type: "object",
296
298
  properties: {
@@ -299,7 +301,7 @@ function aggregateInput({ measures, model }) {
299
301
  minItems: 1,
300
302
  maxItems: AGGREGATE_LIMITS.measures,
301
303
  uniqueItems: true,
302
- items: { anyOf: [...measures.map(measureChoice), ...model.map(modelMeasureChoice)] },
304
+ items: { type: "string" },
303
305
  description: "What to compute, as <entity>.<measure>, or a measure of the model by its name. " +
304
306
  "Measures of several entities are each computed over their own entity's rows, then " +
305
307
  "combined on the dimensions; a measure of the model is computed on the combined rows " +
@@ -398,20 +400,17 @@ const AGGREGATE_DESCRIPTION = "Numbers about the model: measures from one or sev
398
400
  "Requires reading every entity the request reads, path intermediates included, and every " +
399
401
  "entity a measure of the model reads. A measure of the model is named without an entity; a " +
400
402
  "filter must reach every entity it reads or none of them, unless its alignment gives an entity " +
401
- "no counterpart. Call describe for the model's measures and relationships.";
403
+ "no counterpart. Use discover to find measures, then describe their refs or entity owners.";
402
404
  function aggregateTool(manifest) {
403
405
  return {
404
406
  name: "aggregate",
405
407
  title: "Aggregate measures",
406
408
  description: AGGREGATE_DESCRIPTION,
407
- inputSchema: aggregateInput({
408
- measures: measuresFor(manifest),
409
- model: modelMeasuresFor(manifest),
410
- }),
409
+ inputSchema: aggregateInput(),
411
410
  inputSchemaFor: (caller) => {
412
411
  const measures = measuresFor(manifest, caller.permissions);
413
412
  const model = modelMeasuresFor(manifest, caller.permissions);
414
- return measures.length === 0 ? undefined : aggregateInput({ measures, model });
413
+ return measures.length === 0 && model.length === 0 ? undefined : aggregateInput();
415
414
  },
416
415
  annotations: READ,
417
416
  permissions: [],
@@ -419,7 +418,7 @@ function aggregateTool(manifest) {
419
418
  };
420
419
  }
421
420
  /**
422
- * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
421
+ * The MCP tools of an ontology: `describe`, `discover` and `me`, `aggregate` when the ontology has measures,
423
422
  * then for every stored entity `find`, `get`,
424
423
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
425
424
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.26.1",
3
+ "version": "0.27.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",