@ontrails/mcp 1.0.0-beta.14 → 1.0.0-beta.16
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 +41 -0
- package/README.md +36 -17
- package/package.json +11 -3
- package/src/annotations.ts +4 -1
- package/src/build.ts +689 -127
- package/src/index.ts +14 -4
- package/src/stdio.ts +1 -1
- package/src/surface.ts +215 -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 -31
- package/dist/annotations.js.map +0 -1
- package/dist/blaze.d.ts +0 -41
- package/dist/blaze.d.ts.map +0 -1
- package/dist/blaze.js +0 -108
- package/dist/blaze.js.map +0 -1
- package/dist/build.d.ts +0 -48
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js +0 -229
- 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/dist/trailhead.d.ts +0 -41
- package/dist/trailhead.d.ts.map +0 -1
- package/dist/trailhead.js +0 -109
- package/dist/trailhead.js.map +0 -1
- package/src/__tests__/annotations.test.ts +0 -63
- package/src/__tests__/build.test.ts +0 -529
- package/src/__tests__/progress.test.ts +0 -136
- package/src/__tests__/tool-name.test.ts +0 -46
- package/src/__tests__/trailhead.test.ts +0 -158
- package/src/trailhead.ts +0 -173
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/build.ts
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
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
|
-
* validate input, compose
|
|
5
|
+
* validate input, compose layers, execute the implementation, and map
|
|
6
6
|
* Results to MCP responses.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
AuthError,
|
|
10
11
|
Result,
|
|
11
|
-
TRAILHEAD_KEY,
|
|
12
12
|
ValidationError,
|
|
13
|
+
collectAttachedTypedLayers,
|
|
14
|
+
deriveStructuredTrailExamples,
|
|
13
15
|
executeTrail,
|
|
16
|
+
filterSurfaceTrails,
|
|
14
17
|
isBlobRef,
|
|
15
|
-
|
|
18
|
+
isTrailsError,
|
|
19
|
+
LAYER_FIELD_RESERVED_NAMES,
|
|
20
|
+
projectLayerFieldName,
|
|
21
|
+
projectPublicSurfaceError,
|
|
22
|
+
toBlobRefDescriptor,
|
|
23
|
+
validateSurfaceTopo,
|
|
24
|
+
withSurfaceLayerNames,
|
|
16
25
|
zodToJsonSchema,
|
|
17
26
|
} from '@ontrails/core';
|
|
18
27
|
import type {
|
|
28
|
+
AttachedTypedLayer,
|
|
29
|
+
BasePermit,
|
|
30
|
+
BaseSurfaceOptions,
|
|
19
31
|
BlobRef,
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
Layer,
|
|
33
|
+
ResourceOverrideMap,
|
|
34
|
+
SurfaceErrorProjection,
|
|
22
35
|
Topo,
|
|
23
36
|
Trail,
|
|
24
37
|
TrailContextInit,
|
|
@@ -29,27 +42,37 @@ import { deriveAnnotations } from './annotations.js';
|
|
|
29
42
|
import { createMcpProgressCallback } from './progress.js';
|
|
30
43
|
import { deriveToolName } from './tool-name.js';
|
|
31
44
|
|
|
45
|
+
export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
|
|
46
|
+
|
|
47
|
+
export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
|
|
48
|
+
|
|
32
49
|
// ---------------------------------------------------------------------------
|
|
33
50
|
// Public types
|
|
34
51
|
// ---------------------------------------------------------------------------
|
|
35
52
|
|
|
36
|
-
export interface
|
|
37
|
-
/** Config values for provisions that declare a `config` schema, keyed by provision ID. */
|
|
38
|
-
readonly configValues?:
|
|
39
|
-
| Readonly<Record<string, Record<string, unknown>>>
|
|
40
|
-
| undefined;
|
|
53
|
+
export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
|
|
41
54
|
readonly createContext?:
|
|
42
55
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
43
56
|
| undefined;
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
46
|
-
readonly
|
|
47
|
-
readonly provisions?: ProvisionOverrideMap | undefined;
|
|
48
|
-
/** Set to `false` to skip topo validation while building tools. */
|
|
49
|
-
readonly validate?: boolean | undefined;
|
|
57
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
58
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
59
|
+
readonly resolvePermit?: ResolveMcpPermit | undefined;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
export interface ResolveMcpPermitInput {
|
|
63
|
+
readonly authorization?: string | undefined;
|
|
64
|
+
readonly bearerToken?: string | undefined;
|
|
65
|
+
readonly sessionId?: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ResolveMcpPermit = (
|
|
69
|
+
input: ResolveMcpPermitInput
|
|
70
|
+
) =>
|
|
71
|
+
| Promise<Result<BasePermit | null | undefined, Error>>
|
|
72
|
+
| Result<BasePermit | null | undefined, Error>;
|
|
73
|
+
|
|
52
74
|
export interface McpToolDefinition {
|
|
75
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
53
76
|
readonly annotations: McpAnnotations | undefined;
|
|
54
77
|
readonly description: string | undefined;
|
|
55
78
|
readonly handler: (
|
|
@@ -58,23 +81,33 @@ export interface McpToolDefinition {
|
|
|
58
81
|
) => Promise<McpToolResult>;
|
|
59
82
|
readonly inputSchema: Record<string, unknown>;
|
|
60
83
|
readonly name: string;
|
|
84
|
+
readonly outputSchema?: Record<string, unknown> | undefined;
|
|
61
85
|
/** The trail ID this tool was derived from. */
|
|
62
86
|
readonly trailId: string;
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
export interface McpExtra {
|
|
90
|
+
readonly authorization?: string | undefined;
|
|
66
91
|
readonly progressToken?: string | number | undefined;
|
|
67
92
|
readonly sendProgress?:
|
|
68
93
|
| ((current: number, total: number) => Promise<void>)
|
|
69
94
|
| undefined;
|
|
70
95
|
readonly abortSignal?: AbortSignal | undefined;
|
|
96
|
+
readonly permit?: BasePermit | undefined;
|
|
97
|
+
readonly sessionId?: string | undefined;
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
export interface McpToolResult {
|
|
101
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
74
102
|
readonly content: readonly McpContent[];
|
|
75
103
|
readonly isError?: boolean | undefined;
|
|
104
|
+
readonly structuredContent?: Record<string, unknown> | undefined;
|
|
76
105
|
}
|
|
77
106
|
|
|
107
|
+
export type McpToolErrorMeta = Omit<SurfaceErrorProjection, 'surface'> & {
|
|
108
|
+
readonly surface: 'mcp';
|
|
109
|
+
};
|
|
110
|
+
|
|
78
111
|
export interface McpContent {
|
|
79
112
|
readonly data?: string | undefined;
|
|
80
113
|
readonly mimeType?: string | undefined;
|
|
@@ -108,13 +141,17 @@ const collectStream = async (
|
|
|
108
141
|
const reader = stream.getReader();
|
|
109
142
|
const chunks: Uint8Array[] = [];
|
|
110
143
|
let totalLength = 0;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const { done, value } = await reader.read();
|
|
147
|
+
if (done) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
chunks.push(value);
|
|
151
|
+
totalLength += value.length;
|
|
115
152
|
}
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
} finally {
|
|
154
|
+
reader.releaseLock();
|
|
118
155
|
}
|
|
119
156
|
return concatChunks(chunks, totalLength);
|
|
120
157
|
};
|
|
@@ -127,6 +164,8 @@ const resolveBlobData = (blob: BlobRef): Promise<Uint8Array> | Uint8Array => {
|
|
|
127
164
|
return blob.data;
|
|
128
165
|
};
|
|
129
166
|
|
|
167
|
+
type BlobDataResolver = (blob: BlobRef) => Promise<Uint8Array> | Uint8Array;
|
|
168
|
+
|
|
130
169
|
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
131
170
|
// Use btoa with manual conversion for runtime-agnostic base64
|
|
132
171
|
let binary = '';
|
|
@@ -136,23 +175,148 @@ const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
|
136
175
|
return btoa(binary);
|
|
137
176
|
};
|
|
138
177
|
|
|
139
|
-
const blobToContent = async (
|
|
140
|
-
|
|
141
|
-
|
|
178
|
+
const blobToContent = async (
|
|
179
|
+
blob: BlobRef,
|
|
180
|
+
resolveData: BlobDataResolver = resolveBlobData
|
|
181
|
+
): Promise<McpContent> => {
|
|
182
|
+
if (!blob.mimeType.startsWith('image/')) {
|
|
142
183
|
return {
|
|
143
|
-
data: uint8ArrayToBase64(bytes),
|
|
144
184
|
mimeType: blob.mimeType,
|
|
145
|
-
type: '
|
|
185
|
+
type: 'resource',
|
|
186
|
+
uri: `blob://${blob.name}`,
|
|
146
187
|
};
|
|
147
188
|
}
|
|
148
189
|
|
|
190
|
+
const bytes = await resolveData(blob);
|
|
149
191
|
return {
|
|
192
|
+
data: uint8ArrayToBase64(bytes),
|
|
150
193
|
mimeType: blob.mimeType,
|
|
151
|
-
type: '
|
|
152
|
-
|
|
194
|
+
type: 'image',
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type BlobContentResolver = (blob: BlobRef) => Promise<McpContent>;
|
|
199
|
+
|
|
200
|
+
const createBlobContentResolver = (): BlobContentResolver => {
|
|
201
|
+
const contentByBlob = new WeakMap<BlobRef, Promise<McpContent>>();
|
|
202
|
+
const dataByStream = new WeakMap<
|
|
203
|
+
ReadableStream<Uint8Array>,
|
|
204
|
+
Promise<Uint8Array>
|
|
205
|
+
>();
|
|
206
|
+
|
|
207
|
+
const resolveData: BlobDataResolver = (blob) => {
|
|
208
|
+
if (!(blob.data instanceof ReadableStream)) {
|
|
209
|
+
return blob.data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let data = dataByStream.get(blob.data);
|
|
213
|
+
if (data === undefined) {
|
|
214
|
+
data = collectStream(blob.data);
|
|
215
|
+
dataByStream.set(blob.data, data);
|
|
216
|
+
}
|
|
217
|
+
return data;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (blob) => {
|
|
221
|
+
let content = contentByBlob.get(blob);
|
|
222
|
+
if (content === undefined) {
|
|
223
|
+
content = blobToContent(blob, resolveData);
|
|
224
|
+
contentByBlob.set(blob, content);
|
|
225
|
+
}
|
|
226
|
+
return content;
|
|
153
227
|
};
|
|
154
228
|
};
|
|
155
229
|
|
|
230
|
+
const containsBlobRef = (
|
|
231
|
+
value: unknown,
|
|
232
|
+
path = new WeakSet<object>()
|
|
233
|
+
): boolean => {
|
|
234
|
+
if (isBlobRef(value)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (value === null || typeof value !== 'object') {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
if (path.has(value)) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
path.add(value);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
if (Array.isArray(value)) {
|
|
247
|
+
return value.some((item) => containsBlobRef(item, path));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return Object.values(value as Record<string, unknown>).some((item) =>
|
|
251
|
+
containsBlobRef(item, path)
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
path.delete(value);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const toStructuredValue = (
|
|
259
|
+
value: unknown,
|
|
260
|
+
path = new WeakSet<object>()
|
|
261
|
+
): unknown => {
|
|
262
|
+
if (isBlobRef(value)) {
|
|
263
|
+
return toBlobRefDescriptor(value);
|
|
264
|
+
}
|
|
265
|
+
if (value === null || typeof value !== 'object') {
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
if (path.has(value)) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
path.add(value);
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
if (Array.isArray(value)) {
|
|
275
|
+
return value.map((item) => toStructuredValue(item, path));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return Object.fromEntries(
|
|
279
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
280
|
+
key,
|
|
281
|
+
toStructuredValue(item, path),
|
|
282
|
+
])
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
path.delete(value);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const collectBlobRefs = (
|
|
290
|
+
value: unknown,
|
|
291
|
+
path = new WeakSet<object>()
|
|
292
|
+
): BlobRef[] => {
|
|
293
|
+
if (isBlobRef(value)) {
|
|
294
|
+
return [value];
|
|
295
|
+
}
|
|
296
|
+
if (value === null || typeof value !== 'object') {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
if (path.has(value)) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
path.add(value);
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const items = Array.isArray(value)
|
|
306
|
+
? value
|
|
307
|
+
: Object.values(value as Record<string, unknown>);
|
|
308
|
+
return items.flatMap((item) => collectBlobRefs(item, path));
|
|
309
|
+
} finally {
|
|
310
|
+
path.delete(value);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const collectBlobContents = async (
|
|
315
|
+
value: unknown,
|
|
316
|
+
resolveContent: BlobContentResolver
|
|
317
|
+
): Promise<McpContent[]> =>
|
|
318
|
+
Promise.all(collectBlobRefs(value).map(resolveContent));
|
|
319
|
+
|
|
156
320
|
/** Separate blob fields from non-blob fields in an object. */
|
|
157
321
|
const separateBlobFields = async (
|
|
158
322
|
obj: Record<string, unknown>
|
|
@@ -161,13 +325,18 @@ const separateBlobFields = async (
|
|
|
161
325
|
hasBlobFields: boolean;
|
|
162
326
|
textFields: Record<string, unknown>;
|
|
163
327
|
}> => {
|
|
328
|
+
const resolveContent = createBlobContentResolver();
|
|
164
329
|
const blobContents: McpContent[] = [];
|
|
165
330
|
const textFields: Record<string, unknown> = {};
|
|
166
331
|
let hasBlobFields = false;
|
|
167
332
|
for (const [key, val] of Object.entries(obj)) {
|
|
168
333
|
if (isBlobRef(val)) {
|
|
169
334
|
hasBlobFields = true;
|
|
170
|
-
blobContents.push(await
|
|
335
|
+
blobContents.push(await resolveContent(val));
|
|
336
|
+
} else if (containsBlobRef(val)) {
|
|
337
|
+
hasBlobFields = true;
|
|
338
|
+
blobContents.push(...(await collectBlobContents(val, resolveContent)));
|
|
339
|
+
textFields[key] = toStructuredValue(val);
|
|
171
340
|
} else {
|
|
172
341
|
textFields[key] = val;
|
|
173
342
|
}
|
|
@@ -190,12 +359,34 @@ const serializeMixedObject = async (
|
|
|
190
359
|
return blobContents;
|
|
191
360
|
};
|
|
192
361
|
|
|
362
|
+
const serializeBlobArray = async (
|
|
363
|
+
value: readonly unknown[]
|
|
364
|
+
): Promise<readonly McpContent[] | undefined> => {
|
|
365
|
+
if (!containsBlobRef(value)) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const blobContents = await collectBlobContents(
|
|
369
|
+
value,
|
|
370
|
+
createBlobContentResolver()
|
|
371
|
+
);
|
|
372
|
+
return [
|
|
373
|
+
{ text: JSON.stringify(toStructuredValue(value)), type: 'text' },
|
|
374
|
+
...blobContents,
|
|
375
|
+
];
|
|
376
|
+
};
|
|
377
|
+
|
|
193
378
|
const serializeOutput = async (
|
|
194
379
|
value: unknown
|
|
195
380
|
): Promise<readonly McpContent[]> => {
|
|
196
381
|
if (isBlobRef(value)) {
|
|
197
382
|
return [await blobToContent(value)];
|
|
198
383
|
}
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
const mixed = await serializeBlobArray(value);
|
|
386
|
+
if (mixed) {
|
|
387
|
+
return mixed;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
199
390
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
200
391
|
const mixed = await serializeMixedObject(value as Record<string, unknown>);
|
|
201
392
|
if (mixed) {
|
|
@@ -205,49 +396,391 @@ const serializeOutput = async (
|
|
|
205
396
|
return [{ text: JSON.stringify(value), type: 'text' }];
|
|
206
397
|
};
|
|
207
398
|
|
|
399
|
+
// `wrapAsData` is decided at build time from the schema shape (see
|
|
400
|
+
// `buildOutputSchemaProjection`). It must be threaded through to the runtime
|
|
401
|
+
// because the schema's wrap decision and the runtime value's wrap decision
|
|
402
|
+
// can diverge — e.g. for `z.union([z.object(...), z.string()])` or
|
|
403
|
+
// `z.any()`, the schema declares a `{ data: ... }` envelope but a runtime
|
|
404
|
+
// object value would otherwise be returned unwrapped, breaking the
|
|
405
|
+
// outputSchema/structuredContent contract.
|
|
406
|
+
const toStructuredContent = (
|
|
407
|
+
value: unknown,
|
|
408
|
+
wrapAsData: boolean
|
|
409
|
+
): Record<string, unknown> | undefined => {
|
|
410
|
+
const structuredValue = containsBlobRef(value)
|
|
411
|
+
? toStructuredValue(value)
|
|
412
|
+
: value;
|
|
413
|
+
if (wrapAsData) {
|
|
414
|
+
return { data: structuredValue };
|
|
415
|
+
}
|
|
416
|
+
if (
|
|
417
|
+
structuredValue !== null &&
|
|
418
|
+
typeof structuredValue === 'object' &&
|
|
419
|
+
!Array.isArray(structuredValue)
|
|
420
|
+
) {
|
|
421
|
+
return structuredValue as Record<string, unknown>;
|
|
422
|
+
}
|
|
423
|
+
// When wrapAsData is false the schema's top-level type is `'object'`, and
|
|
424
|
+
// output validation has already constrained the runtime value to that
|
|
425
|
+
// shape — a primitive or array reaching this branch indicates the
|
|
426
|
+
// validation contract was bypassed. Return `undefined` so any future
|
|
427
|
+
// bypass surfaces as a missing `structuredContent` rather than a silently
|
|
428
|
+
// wrapped envelope that contradicts the published `outputSchema`.
|
|
429
|
+
return undefined;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Layer input projection (TRL-474)
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Per-layer projection onto an MCP tool's input schema.
|
|
438
|
+
*
|
|
439
|
+
* `routing` maps the parameter name a consumer sees on the tool to the
|
|
440
|
+
* authored field name on the layer's input schema. When no rename was
|
|
441
|
+
* required the two are the same; on collision the parameter name carries
|
|
442
|
+
* the layer prefix while the routing target preserves the original field.
|
|
443
|
+
*/
|
|
444
|
+
interface McpLayerInputProjection {
|
|
445
|
+
readonly layerName: string;
|
|
446
|
+
/** parameterName → originalFieldName for this layer. */
|
|
447
|
+
readonly routing: ReadonlyMap<string, string>;
|
|
448
|
+
/** Fragment merged into the top-level input schema's `properties`. */
|
|
449
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
450
|
+
/** Field names appended to the top-level `required` list. */
|
|
451
|
+
readonly required: readonly string[];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build the camelCase rename target for a layer field collision.
|
|
456
|
+
*
|
|
457
|
+
* The CLI projection uses `kebab-case` (`<layerName>-<field>`); MCP exposes
|
|
458
|
+
* fields as JSON properties so the corresponding shape is camelCase
|
|
459
|
+
* (`<layerName><FieldCapitalized>`). The shared collision policy lives in
|
|
460
|
+
* `projectLayerFieldName`; this helper just supplies the surface-specific
|
|
461
|
+
* fallback name.
|
|
462
|
+
*/
|
|
463
|
+
const buildMcpRenameTarget = (
|
|
464
|
+
layerName: string,
|
|
465
|
+
originalName: string
|
|
466
|
+
): string => {
|
|
467
|
+
if (originalName.length === 0) {
|
|
468
|
+
return layerName;
|
|
469
|
+
}
|
|
470
|
+
const [head, ...rest] = originalName;
|
|
471
|
+
if (head === undefined) {
|
|
472
|
+
return layerName;
|
|
473
|
+
}
|
|
474
|
+
return `${layerName}${head.toUpperCase()}${rest.join('')}`;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const isJsonObjectSchema = (
|
|
478
|
+
value: unknown
|
|
479
|
+
): value is { properties?: Record<string, unknown>; required?: string[] } =>
|
|
480
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Project a single layer's input schema into MCP-shaped property and
|
|
484
|
+
* required fragments, applying the deterministic collision rename rule.
|
|
485
|
+
*/
|
|
486
|
+
const projectMcpLayerInput = (
|
|
487
|
+
layer: Layer,
|
|
488
|
+
claimedNames: Set<string>
|
|
489
|
+
): McpLayerInputProjection => {
|
|
490
|
+
if (layer.input === undefined) {
|
|
491
|
+
return {
|
|
492
|
+
layerName: layer.name,
|
|
493
|
+
properties: {},
|
|
494
|
+
required: [],
|
|
495
|
+
routing: new Map(),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const layerSchema = zodToJsonSchema(layer.input);
|
|
500
|
+
const properties: Record<string, unknown> = {};
|
|
501
|
+
const required: string[] = [];
|
|
502
|
+
const routing = new Map<string, string>();
|
|
503
|
+
|
|
504
|
+
if (
|
|
505
|
+
!isJsonObjectSchema(layerSchema) ||
|
|
506
|
+
layerSchema.properties === undefined
|
|
507
|
+
) {
|
|
508
|
+
return {
|
|
509
|
+
layerName: layer.name,
|
|
510
|
+
properties,
|
|
511
|
+
required,
|
|
512
|
+
routing,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const requiredSet = new Set<string>(layerSchema.required);
|
|
517
|
+
for (const [fieldName, fieldSchema] of Object.entries(
|
|
518
|
+
layerSchema.properties
|
|
519
|
+
)) {
|
|
520
|
+
const renamed = buildMcpRenameTarget(layer.name, fieldName);
|
|
521
|
+
const projection = projectLayerFieldName(
|
|
522
|
+
layer.name,
|
|
523
|
+
fieldName,
|
|
524
|
+
fieldName,
|
|
525
|
+
renamed,
|
|
526
|
+
claimedNames,
|
|
527
|
+
LAYER_FIELD_RESERVED_NAMES
|
|
528
|
+
);
|
|
529
|
+
properties[projection.claimedName] = fieldSchema;
|
|
530
|
+
if (requiredSet.has(fieldName)) {
|
|
531
|
+
required.push(projection.claimedName);
|
|
532
|
+
}
|
|
533
|
+
routing.set(projection.claimedName, projection.routingTarget);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return { layerName: layer.name, properties, required, routing };
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
interface McpInputProjection {
|
|
540
|
+
readonly schema: Record<string, unknown>;
|
|
541
|
+
readonly projections: readonly McpLayerInputProjection[];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Merge typed layer input schemas into the trail's input schema.
|
|
546
|
+
*
|
|
547
|
+
* Returns the merged input schema published on the MCP tool plus the
|
|
548
|
+
* per-layer routing tables consumed by the handler when partitioning
|
|
549
|
+
* incoming parameters.
|
|
550
|
+
*/
|
|
551
|
+
const projectMcpInputSchema = (
|
|
552
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
553
|
+
attachedLayers: readonly AttachedTypedLayer[]
|
|
554
|
+
): McpInputProjection => {
|
|
555
|
+
const baseSchema = zodToJsonSchema(trail.input);
|
|
556
|
+
if (attachedLayers.length === 0) {
|
|
557
|
+
return { projections: [], schema: baseSchema };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const baseProperties =
|
|
561
|
+
isJsonObjectSchema(baseSchema) && baseSchema.properties !== undefined
|
|
562
|
+
? baseSchema.properties
|
|
563
|
+
: undefined;
|
|
564
|
+
const baseRequired =
|
|
565
|
+
isJsonObjectSchema(baseSchema) && Array.isArray(baseSchema.required)
|
|
566
|
+
? baseSchema.required
|
|
567
|
+
: [];
|
|
568
|
+
|
|
569
|
+
const claimedNames = new Set<string>(
|
|
570
|
+
baseProperties === undefined ? [] : Object.keys(baseProperties)
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const mergedProperties: Record<string, unknown> = {
|
|
574
|
+
...baseProperties,
|
|
575
|
+
};
|
|
576
|
+
const mergedRequired = [...baseRequired];
|
|
577
|
+
const projections: McpLayerInputProjection[] = [];
|
|
578
|
+
|
|
579
|
+
for (const { layer } of attachedLayers) {
|
|
580
|
+
const projection = projectMcpLayerInput(layer, claimedNames);
|
|
581
|
+
if (projection.routing.size === 0) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
Object.assign(mergedProperties, projection.properties);
|
|
585
|
+
mergedRequired.push(...projection.required);
|
|
586
|
+
projections.push(projection);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (projections.length === 0) {
|
|
590
|
+
return { projections: [], schema: baseSchema };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const mergedSchema: Record<string, unknown> = isJsonObjectSchema(baseSchema)
|
|
594
|
+
? { ...baseSchema, properties: mergedProperties, type: 'object' }
|
|
595
|
+
: { properties: mergedProperties, type: 'object' };
|
|
596
|
+
if (mergedRequired.length > 0) {
|
|
597
|
+
mergedSchema['required'] = mergedRequired;
|
|
598
|
+
} else if ('required' in mergedSchema) {
|
|
599
|
+
delete mergedSchema['required'];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { projections, schema: mergedSchema };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Partition a parsed MCP `args` record into the trail input plus per-layer
|
|
607
|
+
* inputs, using each layer's routing table.
|
|
608
|
+
*
|
|
609
|
+
* Layer-projected parameter names are stripped from the trail input so the
|
|
610
|
+
* trail's schema validation only ever sees its own fields. A layer that
|
|
611
|
+
* received no parameters is omitted from `layerInputs` so consumers can
|
|
612
|
+
* cleanly assert which layers were activated by the request.
|
|
613
|
+
*/
|
|
614
|
+
const partitionMcpArgs = (
|
|
615
|
+
args: Record<string, unknown>,
|
|
616
|
+
projections: readonly McpLayerInputProjection[]
|
|
617
|
+
): {
|
|
618
|
+
readonly trailInput: Record<string, unknown>;
|
|
619
|
+
readonly layerInputs: Record<string, unknown>;
|
|
620
|
+
} => {
|
|
621
|
+
if (projections.length === 0) {
|
|
622
|
+
return { layerInputs: {}, trailInput: { ...args } };
|
|
623
|
+
}
|
|
624
|
+
const claimedKeys = new Set<string>();
|
|
625
|
+
const layerInputs: Record<string, unknown> = {};
|
|
626
|
+
for (const projection of projections) {
|
|
627
|
+
const layerInput: Record<string, unknown> = {};
|
|
628
|
+
let received = false;
|
|
629
|
+
for (const [paramName, fieldName] of projection.routing) {
|
|
630
|
+
claimedKeys.add(paramName);
|
|
631
|
+
const value = args[paramName];
|
|
632
|
+
if (value === undefined) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
layerInput[fieldName] = value;
|
|
636
|
+
received = true;
|
|
637
|
+
}
|
|
638
|
+
if (received) {
|
|
639
|
+
layerInputs[projection.layerName] = layerInput;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const trailInput: Record<string, unknown> = {};
|
|
643
|
+
for (const [key, value] of Object.entries(args)) {
|
|
644
|
+
if (claimedKeys.has(key)) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
trailInput[key] = value;
|
|
648
|
+
}
|
|
649
|
+
return { layerInputs, trailInput };
|
|
650
|
+
};
|
|
651
|
+
|
|
208
652
|
// ---------------------------------------------------------------------------
|
|
209
653
|
// Handler factory
|
|
210
654
|
// ---------------------------------------------------------------------------
|
|
211
655
|
|
|
656
|
+
const buildMcpErrorMeta = (
|
|
657
|
+
error: Error,
|
|
658
|
+
projection: SurfaceErrorProjection
|
|
659
|
+
): Record<string, McpToolErrorMeta> | undefined => {
|
|
660
|
+
if (!isTrailsError(error)) {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
[MCP_TOOL_ERROR_META_KEY]: {
|
|
665
|
+
...projection,
|
|
666
|
+
surface: 'mcp',
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
};
|
|
670
|
+
|
|
212
671
|
/** Create an error result for MCP responses. */
|
|
213
|
-
const mcpError = (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
672
|
+
const mcpError = (error: Error): McpToolResult => {
|
|
673
|
+
const projection = projectPublicSurfaceError('mcp', error);
|
|
674
|
+
const meta = buildMcpErrorMeta(error, projection);
|
|
675
|
+
return {
|
|
676
|
+
...(meta === undefined ? {} : { _meta: meta }),
|
|
677
|
+
content: [{ text: projection.message, type: 'text' }],
|
|
678
|
+
isError: true,
|
|
679
|
+
};
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
/** Add the MCP surface marker while preserving any existing context extras. */
|
|
683
|
+
const withMcpSurface = (
|
|
684
|
+
progressCb: TrailContextInit['progress'],
|
|
685
|
+
layers: readonly Layer[]
|
|
686
|
+
): Partial<TrailContextInit> =>
|
|
687
|
+
withSurfaceLayerNames(
|
|
688
|
+
'mcp',
|
|
689
|
+
layers,
|
|
690
|
+
progressCb === undefined ? {} : { progress: progressCb }
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const parseBearerAuthorization = (
|
|
694
|
+
authorization: string | undefined
|
|
695
|
+
): Result<string | undefined, Error> => {
|
|
696
|
+
if (authorization === undefined || authorization.length === 0) {
|
|
697
|
+
return Result.ok();
|
|
698
|
+
}
|
|
699
|
+
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
|
700
|
+
const token = match?.[1]?.trim();
|
|
701
|
+
if (token === undefined || token.length === 0) {
|
|
702
|
+
return Result.err(
|
|
703
|
+
new AuthError('Malformed MCP authorization; expected Bearer token', {
|
|
704
|
+
context: { code: 'invalid_authorization_header' },
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return Result.ok(token);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const resolveMcpPermit = async (
|
|
712
|
+
options: DeriveMcpToolsOptions,
|
|
713
|
+
extra: McpExtra
|
|
714
|
+
): Promise<Result<BasePermit | undefined, Error>> => {
|
|
715
|
+
if (extra.permit !== undefined) {
|
|
716
|
+
return Result.ok(extra.permit);
|
|
717
|
+
}
|
|
718
|
+
const token = parseBearerAuthorization(extra.authorization);
|
|
719
|
+
if (token.isErr()) {
|
|
720
|
+
return token;
|
|
721
|
+
}
|
|
722
|
+
if (token.value === undefined) {
|
|
723
|
+
return Result.ok();
|
|
724
|
+
}
|
|
725
|
+
if (options.resolvePermit === undefined) {
|
|
726
|
+
return Result.ok();
|
|
727
|
+
}
|
|
728
|
+
const resolved = await options.resolvePermit({
|
|
729
|
+
authorization: extra.authorization,
|
|
730
|
+
bearerToken: token.value,
|
|
731
|
+
sessionId: extra.sessionId,
|
|
732
|
+
});
|
|
733
|
+
if (resolved.isErr()) {
|
|
734
|
+
return resolved;
|
|
735
|
+
}
|
|
736
|
+
return Result.ok(resolved.value ?? undefined);
|
|
737
|
+
};
|
|
227
738
|
|
|
228
739
|
const createHandler =
|
|
229
740
|
(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
741
|
+
graph: Topo,
|
|
742
|
+
t: Trail<unknown, unknown, unknown>,
|
|
743
|
+
layers: readonly Layer[],
|
|
744
|
+
options: DeriveMcpToolsOptions,
|
|
745
|
+
wrapAsData: boolean,
|
|
746
|
+
layerProjections: readonly McpLayerInputProjection[]
|
|
233
747
|
): ((
|
|
234
748
|
args: Record<string, unknown>,
|
|
235
749
|
extra: McpExtra
|
|
236
750
|
) => Promise<McpToolResult>) =>
|
|
237
751
|
async (args, extra): Promise<McpToolResult> => {
|
|
238
752
|
const progressCb = createMcpProgressCallback(extra);
|
|
239
|
-
const
|
|
753
|
+
const { trailInput, layerInputs } = partitionMcpArgs(
|
|
754
|
+
args,
|
|
755
|
+
layerProjections
|
|
756
|
+
);
|
|
757
|
+
const permitResolution = await resolveMcpPermit(options, extra);
|
|
758
|
+
if (permitResolution.isErr()) {
|
|
759
|
+
return mcpError(permitResolution.error);
|
|
760
|
+
}
|
|
761
|
+
const permit = permitResolution.value;
|
|
762
|
+
const result = await executeTrail(t, trailInput, {
|
|
240
763
|
abortSignal: extra.abortSignal,
|
|
241
764
|
configValues: options.configValues,
|
|
242
765
|
createContext: options.createContext,
|
|
243
|
-
ctx:
|
|
244
|
-
|
|
245
|
-
|
|
766
|
+
ctx: withMcpSurface(progressCb, layers),
|
|
767
|
+
...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
|
|
768
|
+
...(permit === undefined ? {} : { permit }),
|
|
769
|
+
resources: options.resources,
|
|
770
|
+
surfaceLayers: layers,
|
|
771
|
+
topo: graph,
|
|
772
|
+
topoLayers: graph.layers,
|
|
246
773
|
});
|
|
247
774
|
if (result.isOk()) {
|
|
248
|
-
return {
|
|
775
|
+
return {
|
|
776
|
+
content: await serializeOutput(result.value),
|
|
777
|
+
structuredContent:
|
|
778
|
+
t.output === undefined
|
|
779
|
+
? undefined
|
|
780
|
+
: toStructuredContent(result.value, wrapAsData),
|
|
781
|
+
};
|
|
249
782
|
}
|
|
250
|
-
return mcpError(result.error
|
|
783
|
+
return mcpError(result.error);
|
|
251
784
|
};
|
|
252
785
|
|
|
253
786
|
// ---------------------------------------------------------------------------
|
|
@@ -255,82 +788,114 @@ const createHandler =
|
|
|
255
788
|
// ---------------------------------------------------------------------------
|
|
256
789
|
|
|
257
790
|
/**
|
|
258
|
-
* Build MCP tool definitions from
|
|
791
|
+
* Build MCP tool definitions from a graph's topology.
|
|
259
792
|
*
|
|
260
793
|
* Each trail in the topo becomes an McpToolDefinition with:
|
|
261
|
-
* - A derived tool name (
|
|
794
|
+
* - A derived tool name (topo-name-prefixed, underscore-delimited)
|
|
262
795
|
* - JSON Schema input from zodToJsonSchema
|
|
263
796
|
* - MCP annotations from trail meta
|
|
264
|
-
* - A handler that validates, composes
|
|
797
|
+
* - A handler that validates, composes layers, executes, and maps results
|
|
265
798
|
*/
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
trail: Trail<unknown, unknown
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
799
|
+
|
|
800
|
+
const buildDescription = (
|
|
801
|
+
trail: Trail<unknown, unknown, unknown>
|
|
802
|
+
): string | undefined => trail.description;
|
|
803
|
+
|
|
804
|
+
// MCP requires `outputSchema` to have literal `type: "object"` at the root
|
|
805
|
+
// (see `@modelcontextprotocol/sdk` Tool schema — `outputSchema: z.object({
|
|
806
|
+
// type: z.literal('object'), ... })`). Object-shaped unions like
|
|
807
|
+
// `z.discriminatedUnion(...)` emit as `{ anyOf: [...] }` from
|
|
808
|
+
// `zodToJsonSchema` with no top-level `type`, so we publish them under the
|
|
809
|
+
// data envelope. The shape of `structuredContent` then flows from the
|
|
810
|
+
// `wrapAsData` flag, keeping the runtime aligned with what the schema
|
|
811
|
+
// declares.
|
|
812
|
+
const isMcpStructuredObjectSchema = (
|
|
813
|
+
schema: Record<string, unknown>
|
|
814
|
+
): boolean => schema['type'] === 'object';
|
|
815
|
+
|
|
816
|
+
interface OutputSchemaProjection {
|
|
817
|
+
readonly schema: Record<string, unknown>;
|
|
818
|
+
readonly wrapAsData: boolean;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const projectMcpOutputSchema = (
|
|
822
|
+
schema: Parameters<typeof zodToJsonSchema>[0]
|
|
823
|
+
): OutputSchemaProjection => {
|
|
824
|
+
const raw = zodToJsonSchema(schema);
|
|
825
|
+
if (isMcpStructuredObjectSchema(raw)) {
|
|
826
|
+
return { schema: raw, wrapAsData: false };
|
|
282
827
|
}
|
|
283
|
-
return
|
|
828
|
+
return {
|
|
829
|
+
schema: {
|
|
830
|
+
properties: { data: raw },
|
|
831
|
+
required: ['data'],
|
|
832
|
+
type: 'object',
|
|
833
|
+
},
|
|
834
|
+
wrapAsData: true,
|
|
835
|
+
};
|
|
284
836
|
};
|
|
285
837
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
) {
|
|
296
|
-
|
|
297
|
-
if (firstExample !== undefined) {
|
|
298
|
-
description = `${description}\n\nExample input: ${JSON.stringify(firstExample.input)}`;
|
|
299
|
-
}
|
|
838
|
+
const buildOutputSchemaProjection = (
|
|
839
|
+
trail: Trail<unknown, unknown, unknown>
|
|
840
|
+
): OutputSchemaProjection | undefined =>
|
|
841
|
+
trail.output === undefined ? undefined : projectMcpOutputSchema(trail.output);
|
|
842
|
+
|
|
843
|
+
const buildMeta = (
|
|
844
|
+
trail: Trail<unknown, unknown, unknown>
|
|
845
|
+
): Record<string, unknown> | undefined => {
|
|
846
|
+
const examples = deriveStructuredTrailExamples(trail.examples);
|
|
847
|
+
if (examples === undefined) {
|
|
848
|
+
return undefined;
|
|
300
849
|
}
|
|
301
|
-
return
|
|
850
|
+
return { [MCP_TOOL_EXAMPLES_META_KEY]: examples };
|
|
302
851
|
};
|
|
303
852
|
|
|
304
853
|
/** Build a single MCP tool definition from a trail. */
|
|
305
854
|
const buildToolDefinition = (
|
|
306
|
-
|
|
307
|
-
trail: Trail<unknown, unknown>,
|
|
308
|
-
|
|
309
|
-
options:
|
|
855
|
+
graph: Topo,
|
|
856
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
857
|
+
layers: readonly Layer[],
|
|
858
|
+
options: DeriveMcpToolsOptions
|
|
310
859
|
): McpToolDefinition => {
|
|
311
860
|
const rawAnnotations = deriveAnnotations(trail);
|
|
312
861
|
const annotations =
|
|
313
862
|
Object.keys(rawAnnotations).length > 0 ? rawAnnotations : undefined;
|
|
863
|
+
const projection = buildOutputSchemaProjection(trail);
|
|
864
|
+
const attachedLayers = collectAttachedTypedLayers(
|
|
865
|
+
graph,
|
|
866
|
+
trail,
|
|
867
|
+
options.layers
|
|
868
|
+
);
|
|
869
|
+
const inputProjection = projectMcpInputSchema(trail, attachedLayers);
|
|
314
870
|
return {
|
|
871
|
+
_meta: buildMeta(trail),
|
|
315
872
|
annotations,
|
|
316
873
|
description: buildDescription(trail),
|
|
317
|
-
handler: createHandler(
|
|
318
|
-
|
|
319
|
-
|
|
874
|
+
handler: createHandler(
|
|
875
|
+
graph,
|
|
876
|
+
trail,
|
|
877
|
+
layers,
|
|
878
|
+
options,
|
|
879
|
+
projection?.wrapAsData ?? false,
|
|
880
|
+
inputProjection.projections
|
|
881
|
+
),
|
|
882
|
+
inputSchema: inputProjection.schema,
|
|
883
|
+
name: deriveToolName(graph.name, trail.id),
|
|
884
|
+
outputSchema: projection?.schema,
|
|
320
885
|
trailId: trail.id,
|
|
321
886
|
};
|
|
322
887
|
};
|
|
323
888
|
|
|
324
889
|
/** Register a trail as an MCP tool, checking for name collisions. */
|
|
325
890
|
const registerTool = (
|
|
326
|
-
|
|
327
|
-
trailItem: Trail<unknown, unknown>,
|
|
328
|
-
|
|
329
|
-
options:
|
|
891
|
+
graph: Topo,
|
|
892
|
+
trailItem: Trail<unknown, unknown, unknown>,
|
|
893
|
+
layers: readonly Layer[],
|
|
894
|
+
options: DeriveMcpToolsOptions,
|
|
330
895
|
nameToTrailId: Map<string, string>,
|
|
331
896
|
tools: McpToolDefinition[]
|
|
332
897
|
): Result<void, Error> => {
|
|
333
|
-
const toolName = deriveToolName(
|
|
898
|
+
const toolName = deriveToolName(graph.name, trailItem.id);
|
|
334
899
|
const existingId = nameToTrailId.get(toolName);
|
|
335
900
|
if (existingId !== undefined) {
|
|
336
901
|
return Result.err(
|
|
@@ -340,42 +905,39 @@ const registerTool = (
|
|
|
340
905
|
);
|
|
341
906
|
}
|
|
342
907
|
nameToTrailId.set(toolName, trailItem.id);
|
|
343
|
-
tools.push(buildToolDefinition(
|
|
908
|
+
tools.push(buildToolDefinition(graph, trailItem, layers, options));
|
|
344
909
|
return Result.ok();
|
|
345
910
|
};
|
|
346
911
|
|
|
347
912
|
/** Filter topo items to eligible trails. */
|
|
348
913
|
const eligibleTrails = (
|
|
349
|
-
|
|
350
|
-
options:
|
|
351
|
-
): Trail<unknown, unknown>[] =>
|
|
352
|
-
|
|
914
|
+
graph: Topo,
|
|
915
|
+
options: DeriveMcpToolsOptions
|
|
916
|
+
): Trail<unknown, unknown, unknown>[] =>
|
|
917
|
+
filterSurfaceTrails(graph.list(), {
|
|
918
|
+
exclude: options.exclude,
|
|
919
|
+
include: options.include,
|
|
920
|
+
intent: options.intent,
|
|
921
|
+
});
|
|
353
922
|
|
|
354
923
|
const validateToolBuild = (
|
|
355
|
-
|
|
356
|
-
options:
|
|
357
|
-
): Result<void, Error> =>
|
|
358
|
-
if (options.validate === false) {
|
|
359
|
-
return Result.ok();
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const validated = validateEstablishedTopo(app);
|
|
363
|
-
return validated.isErr() ? Result.err(validated.error) : Result.ok();
|
|
364
|
-
};
|
|
924
|
+
graph: Topo,
|
|
925
|
+
options: DeriveMcpToolsOptions
|
|
926
|
+
): Result<void, Error> => validateSurfaceTopo(graph, options);
|
|
365
927
|
|
|
366
928
|
const registerTools = (
|
|
367
|
-
|
|
368
|
-
options:
|
|
369
|
-
|
|
929
|
+
graph: Topo,
|
|
930
|
+
options: DeriveMcpToolsOptions,
|
|
931
|
+
layers: readonly Layer[]
|
|
370
932
|
): Result<McpToolDefinition[], Error> => {
|
|
371
933
|
const tools: McpToolDefinition[] = [];
|
|
372
934
|
const nameToTrailId = new Map<string, string>();
|
|
373
935
|
|
|
374
|
-
for (const trailItem of eligibleTrails(
|
|
936
|
+
for (const trailItem of eligibleTrails(graph, options)) {
|
|
375
937
|
const registered = registerTool(
|
|
376
|
-
|
|
938
|
+
graph,
|
|
377
939
|
trailItem,
|
|
378
|
-
|
|
940
|
+
layers,
|
|
379
941
|
options,
|
|
380
942
|
nameToTrailId,
|
|
381
943
|
tools
|
|
@@ -388,14 +950,14 @@ const registerTools = (
|
|
|
388
950
|
return Result.ok(tools);
|
|
389
951
|
};
|
|
390
952
|
|
|
391
|
-
export const
|
|
392
|
-
|
|
393
|
-
options:
|
|
953
|
+
export const deriveMcpTools = (
|
|
954
|
+
graph: Topo,
|
|
955
|
+
options: DeriveMcpToolsOptions = {}
|
|
394
956
|
): Result<McpToolDefinition[], Error> => {
|
|
395
|
-
const validation = validateToolBuild(
|
|
957
|
+
const validation = validateToolBuild(graph, options);
|
|
396
958
|
if (validation.isErr()) {
|
|
397
959
|
return validation;
|
|
398
960
|
}
|
|
399
961
|
|
|
400
|
-
return registerTools(
|
|
962
|
+
return registerTools(graph, options, options.layers ?? []);
|
|
401
963
|
};
|