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