@ontrails/mcp 1.0.0-beta.2 → 1.0.0-beta.21
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/CHANGELOG.md +300 -5
- package/README.md +70 -21
- package/package.json +11 -3
- package/src/annotations.ts +32 -9
- package/src/build.ts +1236 -137
- package/src/index.ts +31 -4
- package/src/progress.ts +22 -0
- package/src/resources.ts +228 -0
- package/src/stdio.ts +9 -1
- package/src/surface.ts +279 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/annotations.d.ts +0 -19
- package/dist/annotations.d.ts.map +0 -1
- package/dist/annotations.js +0 -29
- package/dist/annotations.js.map +0 -1
- package/dist/blaze.d.ts +0 -36
- package/dist/blaze.d.ts.map +0 -1
- package/dist/blaze.js +0 -96
- package/dist/blaze.js.map +0 -1
- package/dist/build.d.ts +0 -40
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js +0 -190
- package/dist/build.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -13
- package/dist/index.js.map +0 -1
- package/dist/progress.d.ts +0 -13
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js +0 -51
- package/dist/progress.js.map +0 -1
- package/dist/stdio.d.ts +0 -12
- package/dist/stdio.d.ts.map +0 -1
- package/dist/stdio.js +0 -15
- package/dist/stdio.js.map +0 -1
- package/dist/tool-name.d.ts +0 -15
- package/dist/tool-name.d.ts.map +0 -1
- package/dist/tool-name.js +0 -19
- package/dist/tool-name.js.map +0 -1
- package/src/__tests__/annotations.test.ts +0 -70
- package/src/__tests__/blaze.test.ts +0 -105
- package/src/__tests__/build.test.ts +0 -377
- package/src/__tests__/progress.test.ts +0 -136
- package/src/__tests__/tool-name.test.ts +0 -46
- package/src/blaze.ts +0 -146
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/build.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build MCP tool definitions from a Trails
|
|
2
|
+
* Build MCP tool definitions from a Trails graph.
|
|
3
3
|
*
|
|
4
4
|
* Iterates the topo, generates McpToolDefinition[] with handlers that
|
|
5
5
|
* validate input, compose layers, execute the implementation, and map
|
|
@@ -7,55 +7,179 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
AuthError,
|
|
11
|
+
Result,
|
|
12
|
+
ValidationError,
|
|
13
|
+
collectAttachedTypedLayers,
|
|
14
|
+
deriveSurfaceTrailVersionProjections,
|
|
15
|
+
deriveStructuredTrailExamples,
|
|
16
|
+
executeTrail,
|
|
17
|
+
filterSurfaceTrails,
|
|
18
|
+
isBlobRef,
|
|
19
|
+
isTrailsError,
|
|
20
|
+
LAYER_FIELD_RESERVED_NAMES,
|
|
21
|
+
matchesTrailPattern,
|
|
22
|
+
projectLayerFieldName,
|
|
23
|
+
projectPublicSurfaceError,
|
|
24
|
+
toBlobRefDescriptor,
|
|
25
|
+
validateSurfaceTopo,
|
|
26
|
+
withSurfaceLayerNames,
|
|
13
27
|
zodToJsonSchema,
|
|
14
28
|
} from '@ontrails/core';
|
|
15
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
AttachedTypedLayer,
|
|
31
|
+
BasePermit,
|
|
32
|
+
BaseSurfaceOptions,
|
|
33
|
+
BlobRef,
|
|
34
|
+
Layer,
|
|
35
|
+
ResourceOverrideMap,
|
|
36
|
+
SurfaceErrorProjection,
|
|
37
|
+
SurfaceTrailVersionProjection,
|
|
38
|
+
Topo,
|
|
39
|
+
Trail,
|
|
40
|
+
TrailContextInit,
|
|
41
|
+
TrailVersionReference,
|
|
42
|
+
} from '@ontrails/core';
|
|
16
43
|
|
|
17
44
|
import type { McpAnnotations } from './annotations.js';
|
|
18
45
|
import { deriveAnnotations } from './annotations.js';
|
|
19
46
|
import { createMcpProgressCallback } from './progress.js';
|
|
20
47
|
import { deriveToolName } from './tool-name.js';
|
|
21
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Metadata key used for structured trail examples on derived MCP tools.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import { MCP_TOOL_EXAMPLES_META_KEY } from '@ontrails/mcp';
|
|
55
|
+
*
|
|
56
|
+
* const examples = tool._meta?.[MCP_TOOL_EXAMPLES_META_KEY];
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Metadata key used for public Trails error projections on MCP tool errors.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { MCP_TOOL_ERROR_META_KEY } from '@ontrails/mcp';
|
|
67
|
+
*
|
|
68
|
+
* const error = result._meta?.[MCP_TOOL_ERROR_META_KEY];
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Metadata key used to identify MCP tools derived from surface facets.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { MCP_TOOL_FACET_META_KEY } from '@ontrails/mcp';
|
|
79
|
+
*
|
|
80
|
+
* const facet = tool._meta?.[MCP_TOOL_FACET_META_KEY];
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export const MCP_TOOL_FACET_META_KEY = 'ontrails/facet';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Metadata key used as a compatibility hint for clients that support
|
|
87
|
+
* deferred MCP tool loading.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { MCP_TOOL_DEFERRED_META_KEY } from '@ontrails/mcp';
|
|
92
|
+
*
|
|
93
|
+
* const isDeferred = tool._meta?.[MCP_TOOL_DEFERRED_META_KEY] === true;
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export const MCP_TOOL_DEFERRED_META_KEY = 'ontrails/deferred';
|
|
97
|
+
|
|
22
98
|
// ---------------------------------------------------------------------------
|
|
23
99
|
// Public types
|
|
24
100
|
// ---------------------------------------------------------------------------
|
|
25
101
|
|
|
26
|
-
export interface
|
|
102
|
+
export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
|
|
27
103
|
readonly createContext?:
|
|
28
|
-
| (() =>
|
|
104
|
+
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
29
105
|
| undefined;
|
|
30
|
-
readonly
|
|
31
|
-
readonly includeTrails?: readonly string[] | undefined;
|
|
106
|
+
readonly facets?: McpSurfaceFacetMap | undefined;
|
|
32
107
|
readonly layers?: readonly Layer[] | undefined;
|
|
108
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
109
|
+
readonly resolvePermit?: ResolveMcpPermit | undefined;
|
|
33
110
|
}
|
|
34
111
|
|
|
112
|
+
export type McpSurfaceFacetTrailSelector = string | readonly string[];
|
|
113
|
+
|
|
114
|
+
export interface McpSurfaceFacetDefinition {
|
|
115
|
+
readonly trails: McpSurfaceFacetTrailSelector;
|
|
116
|
+
readonly description: string;
|
|
117
|
+
readonly visibility?: 'public' | 'internal' | undefined;
|
|
118
|
+
readonly descriptionStableThrough?: string | undefined;
|
|
119
|
+
readonly visibilityWideningAccepted?: true | undefined;
|
|
120
|
+
readonly mcp?:
|
|
121
|
+
| {
|
|
122
|
+
readonly loading?: 'deferred' | undefined;
|
|
123
|
+
}
|
|
124
|
+
| undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type McpSurfaceFacetMap = Readonly<
|
|
128
|
+
Record<string, McpSurfaceFacetDefinition>
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
export interface ResolveMcpPermitInput {
|
|
132
|
+
readonly authorization?: string | undefined;
|
|
133
|
+
readonly bearerToken?: string | undefined;
|
|
134
|
+
readonly sessionId?: string | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type ResolveMcpPermit = (
|
|
138
|
+
input: ResolveMcpPermitInput
|
|
139
|
+
) =>
|
|
140
|
+
| Promise<Result<BasePermit | null | undefined, Error>>
|
|
141
|
+
| Result<BasePermit | null | undefined, Error>;
|
|
142
|
+
|
|
35
143
|
export interface McpToolDefinition {
|
|
144
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
36
145
|
readonly annotations: McpAnnotations | undefined;
|
|
37
146
|
readonly description: string | undefined;
|
|
147
|
+
readonly facetId?: string | undefined;
|
|
38
148
|
readonly handler: (
|
|
39
149
|
args: Record<string, unknown>,
|
|
40
150
|
extra: McpExtra
|
|
41
151
|
) => Promise<McpToolResult>;
|
|
42
152
|
readonly inputSchema: Record<string, unknown>;
|
|
153
|
+
readonly memberTrailIds?: readonly string[] | undefined;
|
|
43
154
|
readonly name: string;
|
|
155
|
+
readonly outputSchema?: Record<string, unknown> | undefined;
|
|
156
|
+
/** The trail ID this tool was derived from. */
|
|
157
|
+
readonly trailId?: string | undefined;
|
|
158
|
+
readonly versions?: readonly SurfaceTrailVersionProjection[] | undefined;
|
|
44
159
|
}
|
|
45
160
|
|
|
46
161
|
export interface McpExtra {
|
|
162
|
+
readonly authorization?: string | undefined;
|
|
47
163
|
readonly progressToken?: string | number | undefined;
|
|
48
164
|
readonly sendProgress?:
|
|
49
165
|
| ((current: number, total: number) => Promise<void>)
|
|
50
166
|
| undefined;
|
|
51
|
-
readonly
|
|
167
|
+
readonly abortSignal?: AbortSignal | undefined;
|
|
168
|
+
readonly permit?: BasePermit | undefined;
|
|
169
|
+
readonly sessionId?: string | undefined;
|
|
52
170
|
}
|
|
53
171
|
|
|
54
172
|
export interface McpToolResult {
|
|
173
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
55
174
|
readonly content: readonly McpContent[];
|
|
56
175
|
readonly isError?: boolean | undefined;
|
|
176
|
+
readonly structuredContent?: Record<string, unknown> | undefined;
|
|
57
177
|
}
|
|
58
178
|
|
|
179
|
+
export type McpToolErrorMeta = Omit<SurfaceErrorProjection, 'surface'> & {
|
|
180
|
+
readonly surface: 'mcp';
|
|
181
|
+
};
|
|
182
|
+
|
|
59
183
|
export interface McpContent {
|
|
60
184
|
readonly data?: string | undefined;
|
|
61
185
|
readonly mimeType?: string | undefined;
|
|
@@ -68,25 +192,52 @@ export interface McpContent {
|
|
|
68
192
|
// Internal helpers (defined before use)
|
|
69
193
|
// ---------------------------------------------------------------------------
|
|
70
194
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
195
|
+
/** Concatenate an array of Uint8Array chunks into a single Uint8Array. */
|
|
196
|
+
const concatChunks = (
|
|
197
|
+
chunks: Uint8Array[],
|
|
198
|
+
totalLength: number
|
|
199
|
+
): Uint8Array => {
|
|
200
|
+
const result = new Uint8Array(totalLength);
|
|
201
|
+
let offset = 0;
|
|
202
|
+
for (const chunk of chunks) {
|
|
203
|
+
result.set(chunk, offset);
|
|
204
|
+
offset += chunk.length;
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
};
|
|
77
208
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
209
|
+
/** Collect a ReadableStream into a single Uint8Array. */
|
|
210
|
+
const collectStream = async (
|
|
211
|
+
stream: ReadableStream<Uint8Array>
|
|
212
|
+
): Promise<Uint8Array> => {
|
|
213
|
+
const reader = stream.getReader();
|
|
214
|
+
const chunks: Uint8Array[] = [];
|
|
215
|
+
let totalLength = 0;
|
|
216
|
+
try {
|
|
217
|
+
for (;;) {
|
|
218
|
+
const { done, value } = await reader.read();
|
|
219
|
+
if (done) {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
chunks.push(value);
|
|
223
|
+
totalLength += value.length;
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
reader.releaseLock();
|
|
81
227
|
}
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
obj['kind'] === 'blob' &&
|
|
85
|
-
obj['data'] instanceof Uint8Array &&
|
|
86
|
-
typeof obj['mimeType'] === 'string'
|
|
87
|
-
);
|
|
228
|
+
return concatChunks(chunks, totalLength);
|
|
88
229
|
};
|
|
89
230
|
|
|
231
|
+
/** Resolve BlobRef data to Uint8Array (handles ReadableStream). */
|
|
232
|
+
const resolveBlobData = (blob: BlobRef): Promise<Uint8Array> | Uint8Array => {
|
|
233
|
+
if (blob.data instanceof ReadableStream) {
|
|
234
|
+
return collectStream(blob.data);
|
|
235
|
+
}
|
|
236
|
+
return blob.data;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
type BlobDataResolver = (blob: BlobRef) => Promise<Uint8Array> | Uint8Array;
|
|
240
|
+
|
|
90
241
|
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
91
242
|
// Use btoa with manual conversion for runtime-agnostic base64
|
|
92
243
|
let binary = '';
|
|
@@ -96,37 +247,168 @@ const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
|
96
247
|
return btoa(binary);
|
|
97
248
|
};
|
|
98
249
|
|
|
99
|
-
const blobToContent = (
|
|
100
|
-
|
|
250
|
+
const blobToContent = async (
|
|
251
|
+
blob: BlobRef,
|
|
252
|
+
resolveData: BlobDataResolver = resolveBlobData
|
|
253
|
+
): Promise<McpContent> => {
|
|
254
|
+
if (!blob.mimeType.startsWith('image/')) {
|
|
101
255
|
return {
|
|
102
|
-
data: uint8ArrayToBase64(blob.data),
|
|
103
256
|
mimeType: blob.mimeType,
|
|
104
|
-
type: '
|
|
257
|
+
type: 'resource',
|
|
258
|
+
uri: `blob://${blob.name}`,
|
|
105
259
|
};
|
|
106
260
|
}
|
|
107
261
|
|
|
262
|
+
const bytes = await resolveData(blob);
|
|
108
263
|
return {
|
|
264
|
+
data: uint8ArrayToBase64(bytes),
|
|
109
265
|
mimeType: blob.mimeType,
|
|
110
|
-
type: '
|
|
111
|
-
uri: `blob://${blob.name ?? 'unnamed'}`,
|
|
266
|
+
type: 'image',
|
|
112
267
|
};
|
|
113
268
|
};
|
|
114
269
|
|
|
270
|
+
type BlobContentResolver = (blob: BlobRef) => Promise<McpContent>;
|
|
271
|
+
|
|
272
|
+
const createBlobContentResolver = (): BlobContentResolver => {
|
|
273
|
+
const contentByBlob = new WeakMap<BlobRef, Promise<McpContent>>();
|
|
274
|
+
const dataByStream = new WeakMap<
|
|
275
|
+
ReadableStream<Uint8Array>,
|
|
276
|
+
Promise<Uint8Array>
|
|
277
|
+
>();
|
|
278
|
+
|
|
279
|
+
const resolveData: BlobDataResolver = (blob) => {
|
|
280
|
+
if (!(blob.data instanceof ReadableStream)) {
|
|
281
|
+
return blob.data;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let data = dataByStream.get(blob.data);
|
|
285
|
+
if (data === undefined) {
|
|
286
|
+
data = collectStream(blob.data);
|
|
287
|
+
dataByStream.set(blob.data, data);
|
|
288
|
+
}
|
|
289
|
+
return data;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return (blob) => {
|
|
293
|
+
let content = contentByBlob.get(blob);
|
|
294
|
+
if (content === undefined) {
|
|
295
|
+
content = blobToContent(blob, resolveData);
|
|
296
|
+
contentByBlob.set(blob, content);
|
|
297
|
+
}
|
|
298
|
+
return content;
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const containsBlobRef = (
|
|
303
|
+
value: unknown,
|
|
304
|
+
path = new WeakSet<object>()
|
|
305
|
+
): boolean => {
|
|
306
|
+
if (isBlobRef(value)) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
if (value === null || typeof value !== 'object') {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (path.has(value)) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
path.add(value);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
if (Array.isArray(value)) {
|
|
319
|
+
return value.some((item) => containsBlobRef(item, path));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return Object.values(value as Record<string, unknown>).some((item) =>
|
|
323
|
+
containsBlobRef(item, path)
|
|
324
|
+
);
|
|
325
|
+
} finally {
|
|
326
|
+
path.delete(value);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const toStructuredValue = (
|
|
331
|
+
value: unknown,
|
|
332
|
+
path = new WeakSet<object>()
|
|
333
|
+
): unknown => {
|
|
334
|
+
if (isBlobRef(value)) {
|
|
335
|
+
return toBlobRefDescriptor(value);
|
|
336
|
+
}
|
|
337
|
+
if (value === null || typeof value !== 'object') {
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
if (path.has(value)) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
path.add(value);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
if (Array.isArray(value)) {
|
|
347
|
+
return value.map((item) => toStructuredValue(item, path));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return Object.fromEntries(
|
|
351
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
352
|
+
key,
|
|
353
|
+
toStructuredValue(item, path),
|
|
354
|
+
])
|
|
355
|
+
);
|
|
356
|
+
} finally {
|
|
357
|
+
path.delete(value);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const collectBlobRefs = (
|
|
362
|
+
value: unknown,
|
|
363
|
+
path = new WeakSet<object>()
|
|
364
|
+
): BlobRef[] => {
|
|
365
|
+
if (isBlobRef(value)) {
|
|
366
|
+
return [value];
|
|
367
|
+
}
|
|
368
|
+
if (value === null || typeof value !== 'object') {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
if (path.has(value)) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
path.add(value);
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const items = Array.isArray(value)
|
|
378
|
+
? value
|
|
379
|
+
: Object.values(value as Record<string, unknown>);
|
|
380
|
+
return items.flatMap((item) => collectBlobRefs(item, path));
|
|
381
|
+
} finally {
|
|
382
|
+
path.delete(value);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const collectBlobContents = async (
|
|
387
|
+
value: unknown,
|
|
388
|
+
resolveContent: BlobContentResolver
|
|
389
|
+
): Promise<McpContent[]> =>
|
|
390
|
+
Promise.all(collectBlobRefs(value).map(resolveContent));
|
|
391
|
+
|
|
115
392
|
/** Separate blob fields from non-blob fields in an object. */
|
|
116
|
-
const separateBlobFields = (
|
|
393
|
+
const separateBlobFields = async (
|
|
117
394
|
obj: Record<string, unknown>
|
|
118
|
-
): {
|
|
395
|
+
): Promise<{
|
|
119
396
|
blobContents: McpContent[];
|
|
120
397
|
hasBlobFields: boolean;
|
|
121
398
|
textFields: Record<string, unknown>;
|
|
122
|
-
} => {
|
|
399
|
+
}> => {
|
|
400
|
+
const resolveContent = createBlobContentResolver();
|
|
123
401
|
const blobContents: McpContent[] = [];
|
|
124
402
|
const textFields: Record<string, unknown> = {};
|
|
125
403
|
let hasBlobFields = false;
|
|
126
404
|
for (const [key, val] of Object.entries(obj)) {
|
|
127
405
|
if (isBlobRef(val)) {
|
|
128
406
|
hasBlobFields = true;
|
|
129
|
-
blobContents.push(
|
|
407
|
+
blobContents.push(await resolveContent(val));
|
|
408
|
+
} else if (containsBlobRef(val)) {
|
|
409
|
+
hasBlobFields = true;
|
|
410
|
+
blobContents.push(...(await collectBlobContents(val, resolveContent)));
|
|
411
|
+
textFields[key] = toStructuredValue(val);
|
|
130
412
|
} else {
|
|
131
413
|
textFields[key] = val;
|
|
132
414
|
}
|
|
@@ -135,10 +417,11 @@ const separateBlobFields = (
|
|
|
135
417
|
};
|
|
136
418
|
|
|
137
419
|
/** Serialize a mixed blob/text object to MCP content. */
|
|
138
|
-
const serializeMixedObject = (
|
|
420
|
+
const serializeMixedObject = async (
|
|
139
421
|
obj: Record<string, unknown>
|
|
140
|
-
): readonly McpContent[] | undefined => {
|
|
141
|
-
const { blobContents, hasBlobFields, textFields } =
|
|
422
|
+
): Promise<readonly McpContent[] | undefined> => {
|
|
423
|
+
const { blobContents, hasBlobFields, textFields } =
|
|
424
|
+
await separateBlobFields(obj);
|
|
142
425
|
if (!hasBlobFields) {
|
|
143
426
|
return undefined;
|
|
144
427
|
}
|
|
@@ -148,12 +431,36 @@ const serializeMixedObject = (
|
|
|
148
431
|
return blobContents;
|
|
149
432
|
};
|
|
150
433
|
|
|
151
|
-
const
|
|
434
|
+
const serializeBlobArray = async (
|
|
435
|
+
value: readonly unknown[]
|
|
436
|
+
): Promise<readonly McpContent[] | undefined> => {
|
|
437
|
+
if (!containsBlobRef(value)) {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const blobContents = await collectBlobContents(
|
|
441
|
+
value,
|
|
442
|
+
createBlobContentResolver()
|
|
443
|
+
);
|
|
444
|
+
return [
|
|
445
|
+
{ text: JSON.stringify(toStructuredValue(value)), type: 'text' },
|
|
446
|
+
...blobContents,
|
|
447
|
+
];
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const serializeOutput = async (
|
|
451
|
+
value: unknown
|
|
452
|
+
): Promise<readonly McpContent[]> => {
|
|
152
453
|
if (isBlobRef(value)) {
|
|
153
|
-
return [blobToContent(value)];
|
|
454
|
+
return [await blobToContent(value)];
|
|
455
|
+
}
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
const mixed = await serializeBlobArray(value);
|
|
458
|
+
if (mixed) {
|
|
459
|
+
return mixed;
|
|
460
|
+
}
|
|
154
461
|
}
|
|
155
462
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
156
|
-
const mixed = serializeMixedObject(value as Record<string, unknown>);
|
|
463
|
+
const mixed = await serializeMixedObject(value as Record<string, unknown>);
|
|
157
464
|
if (mixed) {
|
|
158
465
|
return mixed;
|
|
159
466
|
}
|
|
@@ -161,71 +468,440 @@ const serializeOutput = (value: unknown): readonly McpContent[] => {
|
|
|
161
468
|
return [{ text: JSON.stringify(value), type: 'text' }];
|
|
162
469
|
};
|
|
163
470
|
|
|
471
|
+
// `wrapAsData` is decided at build time from the schema shape (see
|
|
472
|
+
// `buildOutputSchemaProjection`). It must be threaded through to the runtime
|
|
473
|
+
// because the schema's wrap decision and the runtime value's wrap decision
|
|
474
|
+
// can diverge — e.g. for `z.union([z.object(...), z.string()])` or
|
|
475
|
+
// `z.any()`, the schema declares a `{ data: ... }` envelope but a runtime
|
|
476
|
+
// object value would otherwise be returned unwrapped, breaking the
|
|
477
|
+
// outputSchema/structuredContent contract.
|
|
478
|
+
const toStructuredContent = (
|
|
479
|
+
value: unknown,
|
|
480
|
+
wrapAsData: boolean
|
|
481
|
+
): Record<string, unknown> | undefined => {
|
|
482
|
+
const structuredValue = containsBlobRef(value)
|
|
483
|
+
? toStructuredValue(value)
|
|
484
|
+
: value;
|
|
485
|
+
if (wrapAsData) {
|
|
486
|
+
return { data: structuredValue };
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
structuredValue !== null &&
|
|
490
|
+
typeof structuredValue === 'object' &&
|
|
491
|
+
!Array.isArray(structuredValue)
|
|
492
|
+
) {
|
|
493
|
+
return structuredValue as Record<string, unknown>;
|
|
494
|
+
}
|
|
495
|
+
// When wrapAsData is false the schema's top-level type is `'object'`, and
|
|
496
|
+
// output validation has already constrained the runtime value to that
|
|
497
|
+
// shape — a primitive or array reaching this branch indicates the
|
|
498
|
+
// validation contract was bypassed. Return `undefined` so any future
|
|
499
|
+
// bypass surfaces as a missing `structuredContent` rather than a silently
|
|
500
|
+
// wrapped envelope that contradicts the published `outputSchema`.
|
|
501
|
+
return undefined;
|
|
502
|
+
};
|
|
503
|
+
|
|
164
504
|
// ---------------------------------------------------------------------------
|
|
165
|
-
//
|
|
505
|
+
// Layer input projection (TRL-474)
|
|
166
506
|
// ---------------------------------------------------------------------------
|
|
167
507
|
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Per-layer projection onto an MCP tool's input schema.
|
|
510
|
+
*
|
|
511
|
+
* `routing` maps the parameter name a consumer sees on the tool to the
|
|
512
|
+
* authored field name on the layer's input schema. When no rename was
|
|
513
|
+
* required the two are the same; on collision the parameter name carries
|
|
514
|
+
* the layer prefix while the routing target preserves the original field.
|
|
515
|
+
*/
|
|
516
|
+
interface McpLayerInputProjection {
|
|
517
|
+
readonly layerName: string;
|
|
518
|
+
/** parameterName → originalFieldName for this layer. */
|
|
519
|
+
readonly routing: ReadonlyMap<string, string>;
|
|
520
|
+
/** Fragment merged into the top-level input schema's `properties`. */
|
|
521
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
522
|
+
/** Field names appended to the top-level `required` list. */
|
|
523
|
+
readonly required: readonly string[];
|
|
524
|
+
}
|
|
173
525
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
526
|
+
/**
|
|
527
|
+
* Build the camelCase rename target for a layer field collision.
|
|
528
|
+
*
|
|
529
|
+
* The CLI projection uses `kebab-case` (`<layerName>-<field>`); MCP exposes
|
|
530
|
+
* fields as JSON properties so the corresponding shape is camelCase
|
|
531
|
+
* (`<layerName><FieldCapitalized>`). The shared collision policy lives in
|
|
532
|
+
* `projectLayerFieldName`; this helper just supplies the surface-specific
|
|
533
|
+
* fallback name.
|
|
534
|
+
*/
|
|
535
|
+
const buildMcpRenameTarget = (
|
|
536
|
+
layerName: string,
|
|
537
|
+
originalName: string
|
|
538
|
+
): string => {
|
|
539
|
+
if (originalName.length === 0) {
|
|
540
|
+
return layerName;
|
|
541
|
+
}
|
|
542
|
+
const [head, ...rest] = originalName;
|
|
543
|
+
if (head === undefined) {
|
|
544
|
+
return layerName;
|
|
545
|
+
}
|
|
546
|
+
return `${layerName}${head.toUpperCase()}${rest.join('')}`;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const isJsonObjectSchema = (
|
|
550
|
+
value: unknown
|
|
551
|
+
): value is { properties?: Record<string, unknown>; required?: string[] } =>
|
|
552
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Project a single layer's input schema into MCP-shaped property and
|
|
556
|
+
* required fragments, applying the deterministic collision rename rule.
|
|
557
|
+
*/
|
|
558
|
+
const projectMcpLayerInput = (
|
|
559
|
+
layer: Layer,
|
|
560
|
+
claimedNames: Set<string>
|
|
561
|
+
): McpLayerInputProjection => {
|
|
562
|
+
if (layer.input === undefined) {
|
|
563
|
+
return {
|
|
564
|
+
layerName: layer.name,
|
|
565
|
+
properties: {},
|
|
566
|
+
required: [],
|
|
567
|
+
routing: new Map(),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const layerSchema = zodToJsonSchema(layer.input);
|
|
572
|
+
const properties: Record<string, unknown> = {};
|
|
573
|
+
const required: string[] = [];
|
|
574
|
+
const routing = new Map<string, string>();
|
|
575
|
+
|
|
576
|
+
if (
|
|
577
|
+
!isJsonObjectSchema(layerSchema) ||
|
|
578
|
+
layerSchema.properties === undefined
|
|
579
|
+
) {
|
|
580
|
+
return {
|
|
581
|
+
layerName: layer.name,
|
|
582
|
+
properties,
|
|
583
|
+
required,
|
|
584
|
+
routing,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const requiredSet = new Set<string>(layerSchema.required);
|
|
589
|
+
for (const [fieldName, fieldSchema] of Object.entries(
|
|
590
|
+
layerSchema.properties
|
|
591
|
+
)) {
|
|
592
|
+
const renamed = buildMcpRenameTarget(layer.name, fieldName);
|
|
593
|
+
const projection = projectLayerFieldName(
|
|
594
|
+
layer.name,
|
|
595
|
+
fieldName,
|
|
596
|
+
fieldName,
|
|
597
|
+
renamed,
|
|
598
|
+
claimedNames,
|
|
599
|
+
LAYER_FIELD_RESERVED_NAMES
|
|
600
|
+
);
|
|
601
|
+
properties[projection.claimedName] = fieldSchema;
|
|
602
|
+
if (requiredSet.has(fieldName)) {
|
|
603
|
+
required.push(projection.claimedName);
|
|
604
|
+
}
|
|
605
|
+
routing.set(projection.claimedName, projection.routingTarget);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { layerName: layer.name, properties, required, routing };
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
interface McpInputProjection {
|
|
612
|
+
readonly schema: Record<string, unknown>;
|
|
613
|
+
readonly projections: readonly McpLayerInputProjection[];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Merge typed layer input schemas into the trail's input schema.
|
|
618
|
+
*
|
|
619
|
+
* Returns the merged input schema published on the MCP tool plus the
|
|
620
|
+
* per-layer routing tables consumed by the handler when partitioning
|
|
621
|
+
* incoming parameters.
|
|
622
|
+
*/
|
|
623
|
+
const projectMcpInputSchema = (
|
|
624
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
625
|
+
attachedLayers: readonly AttachedTypedLayer[]
|
|
626
|
+
): McpInputProjection => {
|
|
627
|
+
const baseSchema = zodToJsonSchema(trail.input);
|
|
628
|
+
if (attachedLayers.length === 0) {
|
|
629
|
+
return { projections: [], schema: baseSchema };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const baseProperties =
|
|
633
|
+
isJsonObjectSchema(baseSchema) && baseSchema.properties !== undefined
|
|
634
|
+
? baseSchema.properties
|
|
635
|
+
: undefined;
|
|
636
|
+
const baseRequired =
|
|
637
|
+
isJsonObjectSchema(baseSchema) && Array.isArray(baseSchema.required)
|
|
638
|
+
? baseSchema.required
|
|
639
|
+
: [];
|
|
640
|
+
|
|
641
|
+
const claimedNames = new Set<string>(
|
|
642
|
+
baseProperties === undefined ? [] : Object.keys(baseProperties)
|
|
643
|
+
);
|
|
183
644
|
|
|
184
|
-
const
|
|
185
|
-
|
|
645
|
+
const mergedProperties: Record<string, unknown> = {
|
|
646
|
+
...baseProperties,
|
|
647
|
+
};
|
|
648
|
+
const mergedRequired = [...baseRequired];
|
|
649
|
+
const projections: McpLayerInputProjection[] = [];
|
|
650
|
+
|
|
651
|
+
for (const { layer } of attachedLayers) {
|
|
652
|
+
const projection = projectMcpLayerInput(layer, claimedNames);
|
|
653
|
+
if (projection.routing.size === 0) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
Object.assign(mergedProperties, projection.properties);
|
|
657
|
+
mergedRequired.push(...projection.required);
|
|
658
|
+
projections.push(projection);
|
|
659
|
+
}
|
|
186
660
|
|
|
661
|
+
if (projections.length === 0) {
|
|
662
|
+
return { projections: [], schema: baseSchema };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const mergedSchema: Record<string, unknown> = isJsonObjectSchema(baseSchema)
|
|
666
|
+
? { ...baseSchema, properties: mergedProperties, type: 'object' }
|
|
667
|
+
: { properties: mergedProperties, type: 'object' };
|
|
668
|
+
if (mergedRequired.length > 0) {
|
|
669
|
+
mergedSchema['required'] = mergedRequired;
|
|
670
|
+
} else if ('required' in mergedSchema) {
|
|
671
|
+
delete mergedSchema['required'];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return { projections, schema: mergedSchema };
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const TRAIL_VERSION_PARAM = 'trailVersion';
|
|
678
|
+
|
|
679
|
+
const addMcpVersionInputSchema = (
|
|
680
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
681
|
+
schema: Record<string, unknown>
|
|
682
|
+
): Record<string, unknown> => {
|
|
683
|
+
if (trail.version === undefined) {
|
|
684
|
+
return schema;
|
|
685
|
+
}
|
|
686
|
+
const properties =
|
|
687
|
+
isJsonObjectSchema(schema) && schema.properties !== undefined
|
|
688
|
+
? schema.properties
|
|
689
|
+
: undefined;
|
|
187
690
|
return {
|
|
188
|
-
...
|
|
189
|
-
|
|
190
|
-
|
|
691
|
+
...schema,
|
|
692
|
+
properties: {
|
|
693
|
+
...properties,
|
|
694
|
+
[TRAIL_VERSION_PARAM]: {
|
|
695
|
+
description: 'Live trail version number or marker prefix',
|
|
696
|
+
type: 'string',
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
type: 'object',
|
|
191
700
|
};
|
|
192
701
|
};
|
|
193
702
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
703
|
+
const splitMcpSurfaceVersion = (
|
|
704
|
+
args: Record<string, unknown>
|
|
705
|
+
): {
|
|
706
|
+
readonly args: Record<string, unknown>;
|
|
707
|
+
readonly version: TrailVersionReference | undefined;
|
|
708
|
+
} => {
|
|
709
|
+
const { [TRAIL_VERSION_PARAM]: rawVersion, ...rest } = args;
|
|
710
|
+
return {
|
|
711
|
+
args: rest,
|
|
712
|
+
version:
|
|
713
|
+
typeof rawVersion === 'string' || typeof rawVersion === 'number'
|
|
714
|
+
? rawVersion
|
|
715
|
+
: undefined,
|
|
716
|
+
};
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Partition a parsed MCP `args` record into the trail input plus per-layer
|
|
721
|
+
* inputs, using each layer's routing table.
|
|
722
|
+
*
|
|
723
|
+
* Layer-projected parameter names are stripped from the trail input so the
|
|
724
|
+
* trail's schema validation only ever sees its own fields. A layer that
|
|
725
|
+
* received no parameters is omitted from `layerInputs` so consumers can
|
|
726
|
+
* cleanly assert which layers were activated by the request.
|
|
727
|
+
*/
|
|
728
|
+
const partitionMcpArgs = (
|
|
729
|
+
args: Record<string, unknown>,
|
|
730
|
+
projections: readonly McpLayerInputProjection[]
|
|
731
|
+
): {
|
|
732
|
+
readonly trailInput: Record<string, unknown>;
|
|
733
|
+
readonly layerInputs: Record<string, unknown>;
|
|
734
|
+
} => {
|
|
735
|
+
if (projections.length === 0) {
|
|
736
|
+
return { layerInputs: {}, trailInput: { ...args } };
|
|
737
|
+
}
|
|
738
|
+
const claimedKeys = new Set<string>();
|
|
739
|
+
const layerInputs: Record<string, unknown> = {};
|
|
740
|
+
for (const projection of projections) {
|
|
741
|
+
const layerInput: Record<string, unknown> = {};
|
|
742
|
+
let received = false;
|
|
743
|
+
for (const [paramName, fieldName] of projection.routing) {
|
|
744
|
+
claimedKeys.add(paramName);
|
|
745
|
+
const value = args[paramName];
|
|
746
|
+
if (value === undefined) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
layerInput[fieldName] = value;
|
|
750
|
+
received = true;
|
|
751
|
+
}
|
|
752
|
+
if (received) {
|
|
753
|
+
layerInputs[projection.layerName] = layerInput;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const trailInput: Record<string, unknown> = {};
|
|
757
|
+
for (const [key, value] of Object.entries(args)) {
|
|
758
|
+
if (claimedKeys.has(key)) {
|
|
759
|
+
continue;
|
|
206
760
|
}
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
761
|
+
trailInput[key] = value;
|
|
762
|
+
}
|
|
763
|
+
return { layerInputs, trailInput };
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// Handler factory
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
const buildMcpErrorMeta = (
|
|
771
|
+
error: Error,
|
|
772
|
+
projection: SurfaceErrorProjection
|
|
773
|
+
): Record<string, McpToolErrorMeta> | undefined => {
|
|
774
|
+
if (!isTrailsError(error)) {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
[MCP_TOOL_ERROR_META_KEY]: {
|
|
779
|
+
...projection,
|
|
780
|
+
surface: 'mcp',
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/** Create an error result for MCP responses. */
|
|
786
|
+
const mcpError = (error: Error): McpToolResult => {
|
|
787
|
+
const projection = projectPublicSurfaceError('mcp', error);
|
|
788
|
+
const meta = buildMcpErrorMeta(error, projection);
|
|
789
|
+
return {
|
|
790
|
+
...(meta === undefined ? {} : { _meta: meta }),
|
|
791
|
+
content: [{ text: projection.message, type: 'text' }],
|
|
792
|
+
isError: true,
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/** Add the MCP surface marker while preserving any existing context extras. */
|
|
797
|
+
const withMcpSurface = (
|
|
798
|
+
progressCb: TrailContextInit['progress'],
|
|
799
|
+
layers: readonly Layer[]
|
|
800
|
+
): Partial<TrailContextInit> =>
|
|
801
|
+
withSurfaceLayerNames(
|
|
802
|
+
'mcp',
|
|
803
|
+
layers,
|
|
804
|
+
progressCb === undefined ? {} : { progress: progressCb }
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const parseBearerAuthorization = (
|
|
808
|
+
authorization: string | undefined
|
|
809
|
+
): Result<string | undefined, Error> => {
|
|
810
|
+
if (authorization === undefined || authorization.length === 0) {
|
|
811
|
+
return Result.ok();
|
|
812
|
+
}
|
|
813
|
+
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
|
814
|
+
const token = match?.[1]?.trim();
|
|
815
|
+
if (token === undefined || token.length === 0) {
|
|
816
|
+
return Result.err(
|
|
817
|
+
new AuthError('Malformed MCP authorization; expected Bearer token', {
|
|
818
|
+
context: { code: 'invalid_authorization_header' },
|
|
819
|
+
})
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
return Result.ok(token);
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const resolveMcpPermit = async (
|
|
826
|
+
options: DeriveMcpToolsOptions,
|
|
827
|
+
extra: McpExtra
|
|
828
|
+
): Promise<Result<BasePermit | undefined, Error>> => {
|
|
829
|
+
if (extra.permit !== undefined) {
|
|
830
|
+
return Result.ok(extra.permit);
|
|
831
|
+
}
|
|
832
|
+
const token = parseBearerAuthorization(extra.authorization);
|
|
833
|
+
if (token.isErr()) {
|
|
834
|
+
return token;
|
|
835
|
+
}
|
|
836
|
+
if (token.value === undefined) {
|
|
837
|
+
return Result.ok();
|
|
838
|
+
}
|
|
839
|
+
if (options.resolvePermit === undefined) {
|
|
840
|
+
return Result.ok();
|
|
841
|
+
}
|
|
842
|
+
const resolved = await options.resolvePermit({
|
|
843
|
+
authorization: extra.authorization,
|
|
844
|
+
bearerToken: token.value,
|
|
845
|
+
sessionId: extra.sessionId,
|
|
846
|
+
});
|
|
847
|
+
if (resolved.isErr()) {
|
|
848
|
+
return resolved;
|
|
210
849
|
}
|
|
850
|
+
return Result.ok(resolved.value ?? undefined);
|
|
211
851
|
};
|
|
212
852
|
|
|
213
853
|
const createHandler =
|
|
214
854
|
(
|
|
215
|
-
|
|
855
|
+
graph: Topo,
|
|
856
|
+
t: Trail<unknown, unknown, unknown>,
|
|
216
857
|
layers: readonly Layer[],
|
|
217
|
-
options:
|
|
858
|
+
options: DeriveMcpToolsOptions,
|
|
859
|
+
wrapAsData: boolean,
|
|
860
|
+
layerProjections: readonly McpLayerInputProjection[]
|
|
218
861
|
): ((
|
|
219
862
|
args: Record<string, unknown>,
|
|
220
863
|
extra: McpExtra
|
|
221
864
|
) => Promise<McpToolResult>) =>
|
|
222
865
|
async (args, extra): Promise<McpToolResult> => {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
866
|
+
const progressCb = createMcpProgressCallback(extra);
|
|
867
|
+
const versionedArgs =
|
|
868
|
+
t.version === undefined
|
|
869
|
+
? { args, version: undefined }
|
|
870
|
+
: splitMcpSurfaceVersion(args);
|
|
871
|
+
const { trailInput, layerInputs } = partitionMcpArgs(
|
|
872
|
+
versionedArgs.args,
|
|
873
|
+
layerProjections
|
|
874
|
+
);
|
|
875
|
+
const permitResolution = await resolveMcpPermit(options, extra);
|
|
876
|
+
if (permitResolution.isErr()) {
|
|
877
|
+
return mcpError(permitResolution.error);
|
|
878
|
+
}
|
|
879
|
+
const permit = permitResolution.value;
|
|
880
|
+
const result = await executeTrail(t, trailInput, {
|
|
881
|
+
abortSignal: extra.abortSignal,
|
|
882
|
+
configValues: options.configValues,
|
|
883
|
+
createContext: options.createContext,
|
|
884
|
+
ctx: withMcpSurface(progressCb, layers),
|
|
885
|
+
...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
|
|
886
|
+
...(permit === undefined ? {} : { permit }),
|
|
887
|
+
resources: options.resources,
|
|
888
|
+
surfaceLayers: layers,
|
|
889
|
+
topo: graph,
|
|
890
|
+
topoLayers: graph.layers,
|
|
891
|
+
...(versionedArgs.version === undefined
|
|
892
|
+
? {}
|
|
893
|
+
: { version: versionedArgs.version }),
|
|
894
|
+
});
|
|
895
|
+
if (result.isOk()) {
|
|
896
|
+
return {
|
|
897
|
+
content: await serializeOutput(result.value),
|
|
898
|
+
structuredContent:
|
|
899
|
+
t.output === undefined
|
|
900
|
+
? undefined
|
|
901
|
+
: toStructuredContent(result.value, wrapAsData),
|
|
902
|
+
};
|
|
226
903
|
}
|
|
227
|
-
|
|
228
|
-
return executeAndMap(trail, validated.value, ctx, layers);
|
|
904
|
+
return mcpError(result.error);
|
|
229
905
|
};
|
|
230
906
|
|
|
231
907
|
// ---------------------------------------------------------------------------
|
|
@@ -233,89 +909,512 @@ const createHandler =
|
|
|
233
909
|
// ---------------------------------------------------------------------------
|
|
234
910
|
|
|
235
911
|
/**
|
|
236
|
-
* Build MCP tool definitions from
|
|
912
|
+
* Build MCP tool definitions from a graph's topology.
|
|
237
913
|
*
|
|
238
914
|
* Each trail in the topo becomes an McpToolDefinition with:
|
|
239
|
-
* - A derived tool name (
|
|
915
|
+
* - A derived tool name (topo-name-prefixed, underscore-delimited)
|
|
240
916
|
* - JSON Schema input from zodToJsonSchema
|
|
241
|
-
* - MCP annotations from trail
|
|
917
|
+
* - MCP annotations from trail meta
|
|
242
918
|
* - A handler that validates, composes layers, executes, and maps results
|
|
243
919
|
*/
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
trail: Trail<unknown, unknown
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
920
|
+
|
|
921
|
+
const buildDescription = (
|
|
922
|
+
trail: Trail<unknown, unknown, unknown>
|
|
923
|
+
): string | undefined => trail.description;
|
|
924
|
+
|
|
925
|
+
// MCP requires `outputSchema` to have literal `type: "object"` at the root
|
|
926
|
+
// (see `@modelcontextprotocol/sdk` Tool schema — `outputSchema: z.object({
|
|
927
|
+
// type: z.literal('object'), ... })`). Object-shaped unions like
|
|
928
|
+
// `z.discriminatedUnion(...)` emit as `{ anyOf: [...] }` from
|
|
929
|
+
// `zodToJsonSchema` with no top-level `type`, so we publish them under the
|
|
930
|
+
// data envelope. The shape of `structuredContent` then flows from the
|
|
931
|
+
// `wrapAsData` flag, keeping the runtime aligned with what the schema
|
|
932
|
+
// declares.
|
|
933
|
+
const isMcpStructuredObjectSchema = (
|
|
934
|
+
schema: Record<string, unknown>
|
|
935
|
+
): boolean => schema['type'] === 'object';
|
|
936
|
+
|
|
937
|
+
interface OutputSchemaProjection {
|
|
938
|
+
readonly schema: Record<string, unknown>;
|
|
939
|
+
readonly wrapAsData: boolean;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const projectMcpOutputSchema = (
|
|
943
|
+
schema: Parameters<typeof zodToJsonSchema>[0]
|
|
944
|
+
): OutputSchemaProjection => {
|
|
945
|
+
const raw = zodToJsonSchema(schema);
|
|
946
|
+
if (isMcpStructuredObjectSchema(raw)) {
|
|
947
|
+
return { schema: raw, wrapAsData: false };
|
|
260
948
|
}
|
|
261
|
-
return
|
|
949
|
+
return {
|
|
950
|
+
schema: {
|
|
951
|
+
properties: { data: raw },
|
|
952
|
+
required: ['data'],
|
|
953
|
+
type: 'object',
|
|
954
|
+
},
|
|
955
|
+
wrapAsData: true,
|
|
956
|
+
};
|
|
262
957
|
};
|
|
263
958
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
) {
|
|
274
|
-
|
|
275
|
-
if (firstExample !== undefined) {
|
|
276
|
-
description = `${description}\n\nExample input: ${JSON.stringify(firstExample.input)}`;
|
|
277
|
-
}
|
|
959
|
+
const buildOutputSchemaProjection = (
|
|
960
|
+
trail: Trail<unknown, unknown, unknown>
|
|
961
|
+
): OutputSchemaProjection | undefined =>
|
|
962
|
+
trail.output === undefined ? undefined : projectMcpOutputSchema(trail.output);
|
|
963
|
+
|
|
964
|
+
const buildMeta = (
|
|
965
|
+
trail: Trail<unknown, unknown, unknown>
|
|
966
|
+
): Record<string, unknown> | undefined => {
|
|
967
|
+
const examples = deriveStructuredTrailExamples(trail.examples);
|
|
968
|
+
if (examples === undefined) {
|
|
969
|
+
return undefined;
|
|
278
970
|
}
|
|
279
|
-
return
|
|
971
|
+
return { [MCP_TOOL_EXAMPLES_META_KEY]: examples };
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const mergeMeta = (
|
|
975
|
+
...entries: readonly (Record<string, unknown> | undefined)[]
|
|
976
|
+
): Record<string, unknown> | undefined => {
|
|
977
|
+
const merged = Object.assign(
|
|
978
|
+
{},
|
|
979
|
+
...(entries.filter(Boolean) as Record<string, unknown>[])
|
|
980
|
+
);
|
|
981
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
280
982
|
};
|
|
281
983
|
|
|
282
984
|
/** Build a single MCP tool definition from a trail. */
|
|
283
985
|
const buildToolDefinition = (
|
|
284
|
-
|
|
285
|
-
trail: Trail<unknown, unknown>,
|
|
986
|
+
graph: Topo,
|
|
987
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
286
988
|
layers: readonly Layer[],
|
|
287
|
-
options:
|
|
989
|
+
options: DeriveMcpToolsOptions
|
|
288
990
|
): McpToolDefinition => {
|
|
289
991
|
const rawAnnotations = deriveAnnotations(trail);
|
|
290
992
|
const annotations =
|
|
291
993
|
Object.keys(rawAnnotations).length > 0 ? rawAnnotations : undefined;
|
|
994
|
+
const projection = buildOutputSchemaProjection(trail);
|
|
995
|
+
const attachedLayers = collectAttachedTypedLayers(
|
|
996
|
+
graph,
|
|
997
|
+
trail,
|
|
998
|
+
options.layers
|
|
999
|
+
);
|
|
1000
|
+
const inputProjection = projectMcpInputSchema(trail, attachedLayers);
|
|
1001
|
+
const inputSchema = addMcpVersionInputSchema(trail, inputProjection.schema);
|
|
1002
|
+
const versions = deriveSurfaceTrailVersionProjections(trail);
|
|
292
1003
|
return {
|
|
1004
|
+
_meta: buildMeta(trail),
|
|
293
1005
|
annotations,
|
|
294
1006
|
description: buildDescription(trail),
|
|
295
|
-
handler: createHandler(
|
|
296
|
-
|
|
297
|
-
|
|
1007
|
+
handler: createHandler(
|
|
1008
|
+
graph,
|
|
1009
|
+
trail,
|
|
1010
|
+
layers,
|
|
1011
|
+
options,
|
|
1012
|
+
projection?.wrapAsData ?? false,
|
|
1013
|
+
inputProjection.projections
|
|
1014
|
+
),
|
|
1015
|
+
inputSchema,
|
|
1016
|
+
name: deriveToolName(graph.name, trail.id),
|
|
1017
|
+
outputSchema: projection?.schema,
|
|
1018
|
+
trailId: trail.id,
|
|
1019
|
+
...(versions === undefined ? {} : { versions }),
|
|
298
1020
|
};
|
|
299
1021
|
};
|
|
300
1022
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
): McpToolDefinition[] => {
|
|
305
|
-
const layers = options.layers ?? [];
|
|
306
|
-
const tools: McpToolDefinition[] = [];
|
|
1023
|
+
const facetSelectors = (
|
|
1024
|
+
selector: McpSurfaceFacetTrailSelector
|
|
1025
|
+
): readonly string[] => (typeof selector === 'string' ? [selector] : selector);
|
|
307
1026
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1027
|
+
const matchesFacetSelector = (
|
|
1028
|
+
trailId: string,
|
|
1029
|
+
selector: McpSurfaceFacetTrailSelector
|
|
1030
|
+
): boolean =>
|
|
1031
|
+
facetSelectors(selector).some((pattern) =>
|
|
1032
|
+
matchesTrailPattern(trailId, pattern)
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
interface FacetMemberTool {
|
|
1036
|
+
readonly tool: McpToolDefinition;
|
|
1037
|
+
readonly trail: Trail<unknown, unknown, unknown>;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
1041
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1042
|
+
|
|
1043
|
+
const buildFacetInputSchema = (
|
|
1044
|
+
members: readonly FacetMemberTool[]
|
|
1045
|
+
): Record<string, unknown> => ({
|
|
1046
|
+
anyOf: members.map(({ tool, trail }) => ({
|
|
1047
|
+
properties: {
|
|
1048
|
+
input: tool.inputSchema,
|
|
1049
|
+
trail: { const: trail.id },
|
|
1050
|
+
},
|
|
1051
|
+
required: ['trail', 'input'],
|
|
1052
|
+
type: 'object',
|
|
1053
|
+
})),
|
|
1054
|
+
properties: {
|
|
1055
|
+
input: { type: 'object' },
|
|
1056
|
+
trail: {
|
|
1057
|
+
enum: members.map(({ trail }) => trail.id),
|
|
1058
|
+
type: 'string',
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
required: ['trail', 'input'],
|
|
1062
|
+
type: 'object',
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
const buildFacetOutputSchema = (
|
|
1066
|
+
members: readonly FacetMemberTool[]
|
|
1067
|
+
): Record<string, unknown> => {
|
|
1068
|
+
const outputSchemas = members.map(({ tool }) => tool.outputSchema ?? {});
|
|
1069
|
+
return {
|
|
1070
|
+
properties: {
|
|
1071
|
+
output:
|
|
1072
|
+
outputSchemas.length === 1
|
|
1073
|
+
? (outputSchemas[0] ?? {})
|
|
1074
|
+
: { anyOf: outputSchemas },
|
|
1075
|
+
trail: {
|
|
1076
|
+
enum: members.map(({ trail }) => trail.id),
|
|
1077
|
+
type: 'string',
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
required: ['trail', 'output'],
|
|
1081
|
+
type: 'object',
|
|
1082
|
+
};
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
const parseJsonTextContent = (
|
|
1086
|
+
content: readonly McpContent[]
|
|
1087
|
+
): unknown | undefined => {
|
|
1088
|
+
const text = content.find((item) => item.type === 'text')?.text;
|
|
1089
|
+
if (text === undefined) {
|
|
1090
|
+
return undefined;
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
return JSON.parse(text) as unknown;
|
|
1094
|
+
} catch {
|
|
1095
|
+
return undefined;
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const wrapFacetResult = (
|
|
1100
|
+
trailId: string,
|
|
1101
|
+
result: McpToolResult
|
|
1102
|
+
): McpToolResult => {
|
|
1103
|
+
if (result.isError === true) {
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
const output =
|
|
1107
|
+
result.structuredContent ?? parseJsonTextContent(result.content) ?? null;
|
|
1108
|
+
const envelope = { output, trail: trailId };
|
|
1109
|
+
return {
|
|
1110
|
+
...(result._meta === undefined ? {} : { _meta: result._meta }),
|
|
1111
|
+
content: [
|
|
1112
|
+
{ text: JSON.stringify(envelope), type: 'text' },
|
|
1113
|
+
...result.content.filter((item) => item.type !== 'text'),
|
|
1114
|
+
],
|
|
1115
|
+
structuredContent: envelope,
|
|
1116
|
+
};
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
const createFacetHandler = (
|
|
1120
|
+
facetId: string,
|
|
1121
|
+
members: readonly FacetMemberTool[]
|
|
1122
|
+
): McpToolDefinition['handler'] => {
|
|
1123
|
+
const byTrailId = new Map(
|
|
1124
|
+
members.map((member) => [member.trail.id, member.tool])
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
return async (args, extra): Promise<McpToolResult> => {
|
|
1128
|
+
const trailId = typeof args['trail'] === 'string' ? args['trail'] : '';
|
|
1129
|
+
const tool = byTrailId.get(trailId);
|
|
1130
|
+
if (tool === undefined) {
|
|
1131
|
+
return mcpError(
|
|
1132
|
+
new ValidationError(
|
|
1133
|
+
`MCP facet "${facetId}" received unknown trail selector "${trailId || '(missing)'}"`
|
|
1134
|
+
)
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const { input } = args;
|
|
1139
|
+
if (!isRecord(input)) {
|
|
1140
|
+
return mcpError(
|
|
1141
|
+
new ValidationError(
|
|
1142
|
+
`MCP facet "${facetId}" expects an object input for trail "${trailId}"`
|
|
1143
|
+
)
|
|
1144
|
+
);
|
|
311
1145
|
}
|
|
312
|
-
|
|
1146
|
+
|
|
1147
|
+
return wrapFacetResult(trailId, await tool.handler(input, extra));
|
|
1148
|
+
};
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const deriveFacetIntent = (
|
|
1152
|
+
members: readonly FacetMemberTool[]
|
|
1153
|
+
): Pick<Trail<unknown, unknown, unknown>, 'intent'>['intent'] => {
|
|
1154
|
+
if (members.every(({ trail }) => trail.intent === 'read')) {
|
|
1155
|
+
return 'read';
|
|
1156
|
+
}
|
|
1157
|
+
if (members.some(({ trail }) => trail.intent === 'destroy')) {
|
|
1158
|
+
return 'destroy';
|
|
1159
|
+
}
|
|
1160
|
+
return 'write';
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
const deriveFacetAnnotations = (
|
|
1164
|
+
definition: McpSurfaceFacetDefinition,
|
|
1165
|
+
members: readonly FacetMemberTool[]
|
|
1166
|
+
): McpAnnotations | undefined => {
|
|
1167
|
+
const annotations = deriveAnnotations({
|
|
1168
|
+
description: definition.description,
|
|
1169
|
+
idempotent: false,
|
|
1170
|
+
intent: deriveFacetIntent(members),
|
|
1171
|
+
} as Pick<
|
|
1172
|
+
Trail<unknown, unknown, unknown>,
|
|
1173
|
+
'description' | 'idempotent' | 'intent'
|
|
1174
|
+
>);
|
|
1175
|
+
return Object.keys(annotations).length > 0 ? annotations : undefined;
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
const buildFacetMeta = (
|
|
1179
|
+
facetId: string,
|
|
1180
|
+
definition: McpSurfaceFacetDefinition,
|
|
1181
|
+
memberTrailIds: readonly string[]
|
|
1182
|
+
): Record<string, unknown> | undefined =>
|
|
1183
|
+
mergeMeta(
|
|
1184
|
+
{
|
|
1185
|
+
[MCP_TOOL_FACET_META_KEY]: {
|
|
1186
|
+
id: facetId,
|
|
1187
|
+
memberTrailIds,
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
definition.mcp?.loading === 'deferred'
|
|
1191
|
+
? { [MCP_TOOL_DEFERRED_META_KEY]: true }
|
|
1192
|
+
: undefined
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
const buildFacetToolDefinition = (
|
|
1196
|
+
graph: Topo,
|
|
1197
|
+
facetId: string,
|
|
1198
|
+
definition: McpSurfaceFacetDefinition,
|
|
1199
|
+
members: readonly FacetMemberTool[]
|
|
1200
|
+
): McpToolDefinition => {
|
|
1201
|
+
const memberTrailIds = members.map(({ trail }) => trail.id);
|
|
1202
|
+
return {
|
|
1203
|
+
_meta: buildFacetMeta(facetId, definition, memberTrailIds),
|
|
1204
|
+
annotations: deriveFacetAnnotations(definition, members),
|
|
1205
|
+
description: definition.description,
|
|
1206
|
+
facetId,
|
|
1207
|
+
handler: createFacetHandler(facetId, members),
|
|
1208
|
+
inputSchema: buildFacetInputSchema(members),
|
|
1209
|
+
memberTrailIds,
|
|
1210
|
+
name: deriveToolName(graph.name, facetId),
|
|
1211
|
+
outputSchema: buildFacetOutputSchema(members),
|
|
1212
|
+
};
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
/** Register a trail as an MCP tool, checking for name collisions. */
|
|
1216
|
+
const registerTool = (
|
|
1217
|
+
graph: Topo,
|
|
1218
|
+
trailItem: Trail<unknown, unknown, unknown>,
|
|
1219
|
+
layers: readonly Layer[],
|
|
1220
|
+
options: DeriveMcpToolsOptions,
|
|
1221
|
+
nameToSourceId: Map<string, string>,
|
|
1222
|
+
tools: McpToolDefinition[]
|
|
1223
|
+
): Result<void, Error> => {
|
|
1224
|
+
const toolName = deriveToolName(graph.name, trailItem.id);
|
|
1225
|
+
const existingId = nameToSourceId.get(toolName);
|
|
1226
|
+
if (existingId !== undefined) {
|
|
1227
|
+
return Result.err(
|
|
1228
|
+
new ValidationError(
|
|
1229
|
+
`MCP tool-name collision: "${existingId}" and "trail:${trailItem.id}" both derive the tool name "${toolName}"`
|
|
1230
|
+
)
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
nameToSourceId.set(toolName, `trail:${trailItem.id}`);
|
|
1234
|
+
tools.push(buildToolDefinition(graph, trailItem, layers, options));
|
|
1235
|
+
return Result.ok();
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
/** Filter topo items to eligible trails. */
|
|
1239
|
+
const eligibleTrails = (
|
|
1240
|
+
graph: Topo,
|
|
1241
|
+
options: DeriveMcpToolsOptions
|
|
1242
|
+
): Trail<unknown, unknown, unknown>[] =>
|
|
1243
|
+
filterSurfaceTrails(graph.list(), {
|
|
1244
|
+
exclude: options.exclude,
|
|
1245
|
+
include: options.include,
|
|
1246
|
+
intent: options.intent,
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
const validateToolBuild = (
|
|
1250
|
+
graph: Topo,
|
|
1251
|
+
options: DeriveMcpToolsOptions
|
|
1252
|
+
): Result<void, Error> => validateSurfaceTopo(graph, options);
|
|
1253
|
+
|
|
1254
|
+
const collectFacetMembers = (
|
|
1255
|
+
graph: Topo,
|
|
1256
|
+
definition: McpSurfaceFacetDefinition,
|
|
1257
|
+
availableTrails: readonly Trail<unknown, unknown, unknown>[],
|
|
1258
|
+
layers: readonly Layer[],
|
|
1259
|
+
options: DeriveMcpToolsOptions
|
|
1260
|
+
): readonly FacetMemberTool[] =>
|
|
1261
|
+
availableTrails
|
|
1262
|
+
.filter((trailItem) =>
|
|
1263
|
+
matchesFacetSelector(trailItem.id, definition.trails)
|
|
1264
|
+
)
|
|
1265
|
+
.map((trailItem) => ({
|
|
1266
|
+
tool: buildToolDefinition(graph, trailItem, layers, options),
|
|
1267
|
+
trail: trailItem,
|
|
1268
|
+
}));
|
|
1269
|
+
|
|
1270
|
+
const registerFacet = (
|
|
1271
|
+
graph: Topo,
|
|
1272
|
+
facetId: string,
|
|
1273
|
+
definition: McpSurfaceFacetDefinition,
|
|
1274
|
+
members: readonly FacetMemberTool[],
|
|
1275
|
+
nameToSourceId: Map<string, string>,
|
|
1276
|
+
tools: McpToolDefinition[]
|
|
1277
|
+
): Result<void, Error> => {
|
|
1278
|
+
if (members.length === 0) {
|
|
1279
|
+
return Result.err(
|
|
1280
|
+
new ValidationError(
|
|
1281
|
+
`MCP facet "${facetId}" did not match any surface-eligible trails`
|
|
1282
|
+
)
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const toolName = deriveToolName(graph.name, facetId);
|
|
1287
|
+
const existingId = nameToSourceId.get(toolName);
|
|
1288
|
+
if (existingId !== undefined) {
|
|
1289
|
+
return Result.err(
|
|
1290
|
+
new ValidationError(
|
|
1291
|
+
`MCP tool-name collision: "${existingId}" and "facet:${facetId}" both derive the tool name "${toolName}"`
|
|
1292
|
+
)
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
nameToSourceId.set(toolName, `facet:${facetId}`);
|
|
1297
|
+
tools.push(buildFacetToolDefinition(graph, facetId, definition, members));
|
|
1298
|
+
return Result.ok();
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
const registerFacets = (
|
|
1302
|
+
graph: Topo,
|
|
1303
|
+
options: DeriveMcpToolsOptions,
|
|
1304
|
+
layers: readonly Layer[],
|
|
1305
|
+
availableTrails: readonly Trail<unknown, unknown, unknown>[],
|
|
1306
|
+
nameToSourceId: Map<string, string>,
|
|
1307
|
+
tools: McpToolDefinition[]
|
|
1308
|
+
): Result<ReadonlySet<string>, Error> => {
|
|
1309
|
+
const { facets } = options;
|
|
1310
|
+
const consumedTrailIds = new Set<string>();
|
|
1311
|
+
const ownerByTrailId = new Map<string, string>();
|
|
1312
|
+
|
|
1313
|
+
if (facets === undefined || Object.keys(facets).length === 0) {
|
|
1314
|
+
return Result.ok(consumedTrailIds);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
for (const [facetId, definition] of Object.entries(facets).toSorted()) {
|
|
1318
|
+
const members = collectFacetMembers(
|
|
1319
|
+
graph,
|
|
1320
|
+
definition,
|
|
1321
|
+
availableTrails,
|
|
1322
|
+
layers,
|
|
1323
|
+
options
|
|
1324
|
+
);
|
|
1325
|
+
for (const { trail: memberTrail } of members) {
|
|
1326
|
+
const previous = ownerByTrailId.get(memberTrail.id);
|
|
1327
|
+
if (previous !== undefined) {
|
|
1328
|
+
return Result.err(
|
|
1329
|
+
new ValidationError(
|
|
1330
|
+
`MCP facet overlap: trail "${memberTrail.id}" is selected by facets "${previous}" and "${facetId}"`
|
|
1331
|
+
)
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
ownerByTrailId.set(memberTrail.id, facetId);
|
|
1335
|
+
consumedTrailIds.add(memberTrail.id);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const registered = registerFacet(
|
|
1339
|
+
graph,
|
|
1340
|
+
facetId,
|
|
1341
|
+
definition,
|
|
1342
|
+
members,
|
|
1343
|
+
nameToSourceId,
|
|
1344
|
+
tools
|
|
1345
|
+
);
|
|
1346
|
+
if (registered.isErr()) {
|
|
1347
|
+
return registered;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return Result.ok(consumedTrailIds);
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
const registerTools = (
|
|
1355
|
+
graph: Topo,
|
|
1356
|
+
options: DeriveMcpToolsOptions,
|
|
1357
|
+
layers: readonly Layer[]
|
|
1358
|
+
): Result<McpToolDefinition[], Error> => {
|
|
1359
|
+
const tools: McpToolDefinition[] = [];
|
|
1360
|
+
const nameToSourceId = new Map<string, string>();
|
|
1361
|
+
const availableTrails = eligibleTrails(graph, options);
|
|
1362
|
+
const registeredFacets = registerFacets(
|
|
1363
|
+
graph,
|
|
1364
|
+
options,
|
|
1365
|
+
layers,
|
|
1366
|
+
availableTrails,
|
|
1367
|
+
nameToSourceId,
|
|
1368
|
+
tools
|
|
1369
|
+
);
|
|
1370
|
+
if (registeredFacets.isErr()) {
|
|
1371
|
+
return registeredFacets;
|
|
1372
|
+
}
|
|
1373
|
+
const consumedTrailIds = registeredFacets.value;
|
|
1374
|
+
|
|
1375
|
+
for (const trailItem of availableTrails) {
|
|
1376
|
+
if (consumedTrailIds.has(trailItem.id)) {
|
|
313
1377
|
continue;
|
|
314
1378
|
}
|
|
315
|
-
|
|
316
|
-
|
|
1379
|
+
const registered = registerTool(
|
|
1380
|
+
graph,
|
|
1381
|
+
trailItem,
|
|
1382
|
+
layers,
|
|
1383
|
+
options,
|
|
1384
|
+
nameToSourceId,
|
|
1385
|
+
tools
|
|
317
1386
|
);
|
|
1387
|
+
if (registered.isErr()) {
|
|
1388
|
+
return registered;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return Result.ok(tools);
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Build MCP tool definitions from a topo without opening a transport.
|
|
1397
|
+
*
|
|
1398
|
+
* @example
|
|
1399
|
+
* ```ts
|
|
1400
|
+
* import { deriveMcpTools } from '@ontrails/mcp';
|
|
1401
|
+
*
|
|
1402
|
+
* const tools = deriveMcpTools(graph, { include: ['entity.**'] });
|
|
1403
|
+
* if (tools.isErr()) throw tools.error;
|
|
1404
|
+
*
|
|
1405
|
+
* for (const tool of tools.value) {
|
|
1406
|
+
* console.log(tool.name);
|
|
1407
|
+
* }
|
|
1408
|
+
* ```
|
|
1409
|
+
*/
|
|
1410
|
+
export const deriveMcpTools = (
|
|
1411
|
+
graph: Topo,
|
|
1412
|
+
options: DeriveMcpToolsOptions = {}
|
|
1413
|
+
): Result<McpToolDefinition[], Error> => {
|
|
1414
|
+
const validation = validateToolBuild(graph, options);
|
|
1415
|
+
if (validation.isErr()) {
|
|
1416
|
+
return validation;
|
|
318
1417
|
}
|
|
319
1418
|
|
|
320
|
-
return
|
|
1419
|
+
return registerTools(graph, options, options.layers ?? []);
|
|
321
1420
|
};
|