@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 +18 -0
- package/dist/action.d.ts +3 -1
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -1
- package/dist/service/discovery-schemas.d.ts +143 -0
- package/dist/service/discovery-schemas.js +80 -0
- package/dist/service/discovery-search.d.ts +27 -0
- package/dist/service/discovery-search.js +95 -0
- package/dist/service/discovery.d.ts +25 -0
- package/dist/service/discovery.js +457 -0
- package/dist/service/mcp/server.js +60 -53
- package/dist/service/mcp/tools.d.ts +3 -1
- package/dist/service/mcp/tools.js +30 -31
- package/package.json +1 -1
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
|
|
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 {
|
|
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;
|
|
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
|
|
155
|
+
const expected = error instanceof DiscoveryError
|
|
155
156
|
? {
|
|
156
|
-
error:
|
|
157
|
-
detail:
|
|
157
|
+
error: error.error,
|
|
158
|
+
detail: error.detail,
|
|
158
159
|
requestId,
|
|
159
|
-
|
|
160
|
+
...(error.hash === undefined ? {} : { hash: error.hash }),
|
|
160
161
|
}
|
|
161
|
-
: error
|
|
162
|
-
?
|
|
163
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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: "
|
|
271
|
-
"
|
|
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
|
|
278
|
-
description: "
|
|
279
|
-
|
|
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: "
|
|
300
|
-
text:
|
|
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
|
-
|
|
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
|
|
326
|
-
"
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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: "
|
|
230
|
-
description: "
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
|
|
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(
|
|
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: {
|
|
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.
|
|
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(
|
|
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.
|
|
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",
|