@ontrails/mcp 1.0.0-beta.15 → 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 +34 -0
- package/README.md +19 -3
- package/package.json +11 -3
- package/src/build.ts +642 -70
- package/src/index.ts +5 -0
- package/src/surface.ts +71 -53
- 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/build.d.ts +0 -49
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js +0 -220
- 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/surface.d.ts +0 -35
- package/dist/surface.d.ts.map +0 -1
- package/dist/surface.js +0 -123
- package/dist/surface.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 -57
- package/dist/trailhead.d.ts.map +0 -1
- package/dist/trailhead.js +0 -136
- package/dist/trailhead.js.map +0 -1
- package/src/__tests__/annotations.test.ts +0 -63
- package/src/__tests__/build.test.ts +0 -648
- package/src/__tests__/progress.test.ts +0 -136
- package/src/__tests__/surface.test.ts +0 -191
- package/src/__tests__/tool-name.test.ts +0 -46
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
package/src/build.ts
CHANGED
|
@@ -7,20 +7,31 @@
|
|
|
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,
|
|
14
16
|
filterSurfaceTrails,
|
|
15
17
|
isBlobRef,
|
|
16
|
-
|
|
18
|
+
isTrailsError,
|
|
19
|
+
LAYER_FIELD_RESERVED_NAMES,
|
|
20
|
+
projectLayerFieldName,
|
|
21
|
+
projectPublicSurfaceError,
|
|
22
|
+
toBlobRefDescriptor,
|
|
23
|
+
validateSurfaceTopo,
|
|
24
|
+
withSurfaceLayerNames,
|
|
17
25
|
zodToJsonSchema,
|
|
18
26
|
} from '@ontrails/core';
|
|
19
27
|
import type {
|
|
28
|
+
AttachedTypedLayer,
|
|
29
|
+
BasePermit,
|
|
30
|
+
BaseSurfaceOptions,
|
|
20
31
|
BlobRef,
|
|
21
|
-
Intent,
|
|
22
32
|
Layer,
|
|
23
33
|
ResourceOverrideMap,
|
|
34
|
+
SurfaceErrorProjection,
|
|
24
35
|
Topo,
|
|
25
36
|
Trail,
|
|
26
37
|
TrailContextInit,
|
|
@@ -31,28 +42,37 @@ import { deriveAnnotations } from './annotations.js';
|
|
|
31
42
|
import { createMcpProgressCallback } from './progress.js';
|
|
32
43
|
import { deriveToolName } from './tool-name.js';
|
|
33
44
|
|
|
45
|
+
export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
|
|
46
|
+
|
|
47
|
+
export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
|
|
48
|
+
|
|
34
49
|
// ---------------------------------------------------------------------------
|
|
35
50
|
// Public types
|
|
36
51
|
// ---------------------------------------------------------------------------
|
|
37
52
|
|
|
38
|
-
export interface DeriveMcpToolsOptions {
|
|
39
|
-
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
40
|
-
readonly configValues?:
|
|
41
|
-
| Readonly<Record<string, Record<string, unknown>>>
|
|
42
|
-
| undefined;
|
|
53
|
+
export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
|
|
43
54
|
readonly createContext?:
|
|
44
55
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
45
56
|
| undefined;
|
|
46
|
-
readonly exclude?: readonly string[] | undefined;
|
|
47
|
-
readonly include?: readonly string[] | undefined;
|
|
48
|
-
readonly intent?: readonly Intent[] | undefined;
|
|
49
57
|
readonly layers?: readonly Layer[] | undefined;
|
|
50
58
|
readonly resources?: ResourceOverrideMap | undefined;
|
|
51
|
-
|
|
52
|
-
readonly validate?: boolean | undefined;
|
|
59
|
+
readonly resolvePermit?: ResolveMcpPermit | undefined;
|
|
53
60
|
}
|
|
54
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
|
+
|
|
55
74
|
export interface McpToolDefinition {
|
|
75
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
56
76
|
readonly annotations: McpAnnotations | undefined;
|
|
57
77
|
readonly description: string | undefined;
|
|
58
78
|
readonly handler: (
|
|
@@ -61,23 +81,33 @@ export interface McpToolDefinition {
|
|
|
61
81
|
) => Promise<McpToolResult>;
|
|
62
82
|
readonly inputSchema: Record<string, unknown>;
|
|
63
83
|
readonly name: string;
|
|
84
|
+
readonly outputSchema?: Record<string, unknown> | undefined;
|
|
64
85
|
/** The trail ID this tool was derived from. */
|
|
65
86
|
readonly trailId: string;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
export interface McpExtra {
|
|
90
|
+
readonly authorization?: string | undefined;
|
|
69
91
|
readonly progressToken?: string | number | undefined;
|
|
70
92
|
readonly sendProgress?:
|
|
71
93
|
| ((current: number, total: number) => Promise<void>)
|
|
72
94
|
| undefined;
|
|
73
95
|
readonly abortSignal?: AbortSignal | undefined;
|
|
96
|
+
readonly permit?: BasePermit | undefined;
|
|
97
|
+
readonly sessionId?: string | undefined;
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
export interface McpToolResult {
|
|
101
|
+
readonly _meta?: Record<string, unknown> | undefined;
|
|
77
102
|
readonly content: readonly McpContent[];
|
|
78
103
|
readonly isError?: boolean | undefined;
|
|
104
|
+
readonly structuredContent?: Record<string, unknown> | undefined;
|
|
79
105
|
}
|
|
80
106
|
|
|
107
|
+
export type McpToolErrorMeta = Omit<SurfaceErrorProjection, 'surface'> & {
|
|
108
|
+
readonly surface: 'mcp';
|
|
109
|
+
};
|
|
110
|
+
|
|
81
111
|
export interface McpContent {
|
|
82
112
|
readonly data?: string | undefined;
|
|
83
113
|
readonly mimeType?: string | undefined;
|
|
@@ -111,13 +141,17 @@ const collectStream = async (
|
|
|
111
141
|
const reader = stream.getReader();
|
|
112
142
|
const chunks: Uint8Array[] = [];
|
|
113
143
|
let totalLength = 0;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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;
|
|
118
152
|
}
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
} finally {
|
|
154
|
+
reader.releaseLock();
|
|
121
155
|
}
|
|
122
156
|
return concatChunks(chunks, totalLength);
|
|
123
157
|
};
|
|
@@ -130,6 +164,8 @@ const resolveBlobData = (blob: BlobRef): Promise<Uint8Array> | Uint8Array => {
|
|
|
130
164
|
return blob.data;
|
|
131
165
|
};
|
|
132
166
|
|
|
167
|
+
type BlobDataResolver = (blob: BlobRef) => Promise<Uint8Array> | Uint8Array;
|
|
168
|
+
|
|
133
169
|
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
134
170
|
// Use btoa with manual conversion for runtime-agnostic base64
|
|
135
171
|
let binary = '';
|
|
@@ -139,23 +175,148 @@ const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
|
|
139
175
|
return btoa(binary);
|
|
140
176
|
};
|
|
141
177
|
|
|
142
|
-
const blobToContent = async (
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
const blobToContent = async (
|
|
179
|
+
blob: BlobRef,
|
|
180
|
+
resolveData: BlobDataResolver = resolveBlobData
|
|
181
|
+
): Promise<McpContent> => {
|
|
182
|
+
if (!blob.mimeType.startsWith('image/')) {
|
|
145
183
|
return {
|
|
146
|
-
data: uint8ArrayToBase64(bytes),
|
|
147
184
|
mimeType: blob.mimeType,
|
|
148
|
-
type: '
|
|
185
|
+
type: 'resource',
|
|
186
|
+
uri: `blob://${blob.name}`,
|
|
149
187
|
};
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
const bytes = await resolveData(blob);
|
|
152
191
|
return {
|
|
192
|
+
data: uint8ArrayToBase64(bytes),
|
|
153
193
|
mimeType: blob.mimeType,
|
|
154
|
-
type: '
|
|
155
|
-
|
|
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;
|
|
156
227
|
};
|
|
157
228
|
};
|
|
158
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
|
+
|
|
159
320
|
/** Separate blob fields from non-blob fields in an object. */
|
|
160
321
|
const separateBlobFields = async (
|
|
161
322
|
obj: Record<string, unknown>
|
|
@@ -164,13 +325,18 @@ const separateBlobFields = async (
|
|
|
164
325
|
hasBlobFields: boolean;
|
|
165
326
|
textFields: Record<string, unknown>;
|
|
166
327
|
}> => {
|
|
328
|
+
const resolveContent = createBlobContentResolver();
|
|
167
329
|
const blobContents: McpContent[] = [];
|
|
168
330
|
const textFields: Record<string, unknown> = {};
|
|
169
331
|
let hasBlobFields = false;
|
|
170
332
|
for (const [key, val] of Object.entries(obj)) {
|
|
171
333
|
if (isBlobRef(val)) {
|
|
172
334
|
hasBlobFields = true;
|
|
173
|
-
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);
|
|
174
340
|
} else {
|
|
175
341
|
textFields[key] = val;
|
|
176
342
|
}
|
|
@@ -193,12 +359,34 @@ const serializeMixedObject = async (
|
|
|
193
359
|
return blobContents;
|
|
194
360
|
};
|
|
195
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
|
+
|
|
196
378
|
const serializeOutput = async (
|
|
197
379
|
value: unknown
|
|
198
380
|
): Promise<readonly McpContent[]> => {
|
|
199
381
|
if (isBlobRef(value)) {
|
|
200
382
|
return [await blobToContent(value)];
|
|
201
383
|
}
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
const mixed = await serializeBlobArray(value);
|
|
386
|
+
if (mixed) {
|
|
387
|
+
return mixed;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
202
390
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
203
391
|
const mixed = await serializeMixedObject(value as Record<string, unknown>);
|
|
204
392
|
if (mixed) {
|
|
@@ -208,51 +396,391 @@ const serializeOutput = async (
|
|
|
208
396
|
return [{ text: JSON.stringify(value), type: 'text' }];
|
|
209
397
|
};
|
|
210
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
|
+
|
|
211
652
|
// ---------------------------------------------------------------------------
|
|
212
653
|
// Handler factory
|
|
213
654
|
// ---------------------------------------------------------------------------
|
|
214
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
|
+
|
|
215
671
|
/** Create an error result for MCP responses. */
|
|
216
|
-
const mcpError = (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
};
|
|
230
738
|
|
|
231
739
|
const createHandler =
|
|
232
740
|
(
|
|
233
741
|
graph: Topo,
|
|
234
742
|
t: Trail<unknown, unknown, unknown>,
|
|
235
743
|
layers: readonly Layer[],
|
|
236
|
-
options: DeriveMcpToolsOptions
|
|
744
|
+
options: DeriveMcpToolsOptions,
|
|
745
|
+
wrapAsData: boolean,
|
|
746
|
+
layerProjections: readonly McpLayerInputProjection[]
|
|
237
747
|
): ((
|
|
238
748
|
args: Record<string, unknown>,
|
|
239
749
|
extra: McpExtra
|
|
240
750
|
) => Promise<McpToolResult>) =>
|
|
241
751
|
async (args, extra): Promise<McpToolResult> => {
|
|
242
752
|
const progressCb = createMcpProgressCallback(extra);
|
|
243
|
-
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, {
|
|
244
763
|
abortSignal: extra.abortSignal,
|
|
245
764
|
configValues: options.configValues,
|
|
246
765
|
createContext: options.createContext,
|
|
247
|
-
ctx:
|
|
248
|
-
|
|
766
|
+
ctx: withMcpSurface(progressCb, layers),
|
|
767
|
+
...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
|
|
768
|
+
...(permit === undefined ? {} : { permit }),
|
|
249
769
|
resources: options.resources,
|
|
770
|
+
surfaceLayers: layers,
|
|
250
771
|
topo: graph,
|
|
772
|
+
topoLayers: graph.layers,
|
|
251
773
|
});
|
|
252
774
|
if (result.isOk()) {
|
|
253
|
-
return {
|
|
775
|
+
return {
|
|
776
|
+
content: await serializeOutput(result.value),
|
|
777
|
+
structuredContent:
|
|
778
|
+
t.output === undefined
|
|
779
|
+
? undefined
|
|
780
|
+
: toStructuredContent(result.value, wrapAsData),
|
|
781
|
+
};
|
|
254
782
|
}
|
|
255
|
-
return mcpError(result.error
|
|
783
|
+
return mcpError(result.error);
|
|
256
784
|
};
|
|
257
785
|
|
|
258
786
|
// ---------------------------------------------------------------------------
|
|
@@ -269,22 +797,57 @@ const createHandler =
|
|
|
269
797
|
* - A handler that validates, composes layers, executes, and maps results
|
|
270
798
|
*/
|
|
271
799
|
|
|
272
|
-
/** Build a description with optional example input appended. */
|
|
273
800
|
const buildDescription = (
|
|
274
801
|
trail: Trail<unknown, unknown, unknown>
|
|
275
|
-
): string | undefined =>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 };
|
|
286
827
|
}
|
|
287
|
-
return
|
|
828
|
+
return {
|
|
829
|
+
schema: {
|
|
830
|
+
properties: { data: raw },
|
|
831
|
+
required: ['data'],
|
|
832
|
+
type: 'object',
|
|
833
|
+
},
|
|
834
|
+
wrapAsData: true,
|
|
835
|
+
};
|
|
836
|
+
};
|
|
837
|
+
|
|
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;
|
|
849
|
+
}
|
|
850
|
+
return { [MCP_TOOL_EXAMPLES_META_KEY]: examples };
|
|
288
851
|
};
|
|
289
852
|
|
|
290
853
|
/** Build a single MCP tool definition from a trail. */
|
|
@@ -297,12 +860,28 @@ const buildToolDefinition = (
|
|
|
297
860
|
const rawAnnotations = deriveAnnotations(trail);
|
|
298
861
|
const annotations =
|
|
299
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);
|
|
300
870
|
return {
|
|
871
|
+
_meta: buildMeta(trail),
|
|
301
872
|
annotations,
|
|
302
873
|
description: buildDescription(trail),
|
|
303
|
-
handler: createHandler(
|
|
304
|
-
|
|
874
|
+
handler: createHandler(
|
|
875
|
+
graph,
|
|
876
|
+
trail,
|
|
877
|
+
layers,
|
|
878
|
+
options,
|
|
879
|
+
projection?.wrapAsData ?? false,
|
|
880
|
+
inputProjection.projections
|
|
881
|
+
),
|
|
882
|
+
inputSchema: inputProjection.schema,
|
|
305
883
|
name: deriveToolName(graph.name, trail.id),
|
|
884
|
+
outputSchema: projection?.schema,
|
|
306
885
|
trailId: trail.id,
|
|
307
886
|
};
|
|
308
887
|
};
|
|
@@ -344,14 +923,7 @@ const eligibleTrails = (
|
|
|
344
923
|
const validateToolBuild = (
|
|
345
924
|
graph: Topo,
|
|
346
925
|
options: DeriveMcpToolsOptions
|
|
347
|
-
): Result<void, Error> =>
|
|
348
|
-
if (options.validate === false) {
|
|
349
|
-
return Result.ok();
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const validated = validateEstablishedTopo(graph);
|
|
353
|
-
return validated.isErr() ? Result.err(validated.error) : Result.ok();
|
|
354
|
-
};
|
|
926
|
+
): Result<void, Error> => validateSurfaceTopo(graph, options);
|
|
355
927
|
|
|
356
928
|
const registerTools = (
|
|
357
929
|
graph: Topo,
|