@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +391 -12
  2. package/README.md +69 -17
  3. package/package.json +11 -3
  4. package/src/annotations.ts +24 -1
  5. package/src/build.ts +1335 -136
  6. package/src/index.ts +32 -4
  7. package/src/progress.ts +22 -0
  8. package/src/resources.ts +336 -0
  9. package/src/stdio.ts +9 -1
  10. package/src/surface.ts +295 -0
  11. package/src/tool-name.ts +6 -7
  12. package/.turbo/turbo-build.log +0 -1
  13. package/.turbo/turbo-lint.log +0 -3
  14. package/.turbo/turbo-typecheck.log +0 -1
  15. package/dist/annotations.d.ts +0 -19
  16. package/dist/annotations.d.ts.map +0 -1
  17. package/dist/annotations.js +0 -31
  18. package/dist/annotations.js.map +0 -1
  19. package/dist/blaze.d.ts +0 -36
  20. package/dist/blaze.d.ts.map +0 -1
  21. package/dist/blaze.js +0 -96
  22. package/dist/blaze.js.map +0 -1
  23. package/dist/build.d.ts +0 -40
  24. package/dist/build.d.ts.map +0 -1
  25. package/dist/build.js +0 -227
  26. package/dist/build.js.map +0 -1
  27. package/dist/index.d.ts +0 -7
  28. package/dist/index.d.ts.map +0 -1
  29. package/dist/index.js +0 -13
  30. package/dist/index.js.map +0 -1
  31. package/dist/progress.d.ts +0 -13
  32. package/dist/progress.d.ts.map +0 -1
  33. package/dist/progress.js +0 -51
  34. package/dist/progress.js.map +0 -1
  35. package/dist/stdio.d.ts +0 -12
  36. package/dist/stdio.d.ts.map +0 -1
  37. package/dist/stdio.js +0 -15
  38. package/dist/stdio.js.map +0 -1
  39. package/dist/tool-name.d.ts +0 -15
  40. package/dist/tool-name.d.ts.map +0 -1
  41. package/dist/tool-name.js +0 -19
  42. package/dist/tool-name.js.map +0 -1
  43. package/src/__tests__/annotations.test.ts +0 -63
  44. package/src/__tests__/blaze.test.ts +0 -105
  45. package/src/__tests__/build.test.ts +0 -453
  46. package/src/__tests__/progress.test.ts +0 -136
  47. package/src/__tests__/tool-name.test.ts +0 -46
  48. package/src/blaze.ts +0 -146
  49. package/tsconfig.json +0 -9
  50. package/tsconfig.tsbuildinfo +0 -1
package/src/build.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Build MCP tool definitions from a Trails App.
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
- composeLayers,
11
- createTrailContext,
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
- validateInput,
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 { BlobRef, Layer, Topo, Trail, TrailContext } from '@ontrails/core';
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 BuildMcpToolsOptions {
112
+ export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
28
113
  readonly createContext?:
29
- | (() => TrailContext | Promise<TrailContext>)
114
+ | (() => TrailContextInit | Promise<TrailContextInit>)
30
115
  | undefined;
31
- readonly excludeTrails?: readonly string[] | undefined;
32
- readonly includeTrails?: readonly string[] | undefined;
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 signal?: AbortSignal | undefined;
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
- for (;;) {
94
- const { done, value } = await reader.read();
95
- if (done) {
96
- break;
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
- chunks.push(value);
99
- totalLength += value.length;
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 (blob: BlobRef): Promise<McpContent> => {
122
- const bytes = await resolveBlobData(blob);
123
- if (blob.mimeType.startsWith('image/')) {
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: 'image',
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: 'resource',
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 blobToContent(val));
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
- // Handler factory
528
+ // Layer input projection (TRL-474)
192
529
  // ---------------------------------------------------------------------------
193
530
 
194
- /** Create an error result for MCP responses. */
195
- const mcpError = (message: string): McpToolResult => ({
196
- content: [{ text: message, type: 'text' }],
197
- isError: true,
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
- /** Build a TrailContext from options and MCP extra. */
201
- const buildTrailContext = async (
202
- options: BuildMcpToolsOptions,
203
- extra: McpExtra
204
- ): Promise<TrailContext> => {
205
- const baseContext =
206
- options.createContext !== undefined && options.createContext !== null
207
- ? await options.createContext()
208
- : createTrailContext();
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
- const signal = extra.signal ?? baseContext.signal;
211
- const progressCb = createMcpProgressCallback(extra);
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
- ...baseContext,
215
- signal,
216
- ...(progressCb === undefined ? {} : { progress: progressCb }),
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
- /** Execute a trail and map the result to an MCP response. */
221
- const executeAndMap = async (
222
- trail: Trail<unknown, unknown>,
223
- validatedInput: unknown,
224
- ctx: TrailContext,
225
- layers: readonly Layer[]
226
- ): Promise<McpToolResult> => {
227
- const impl = composeLayers([...layers], trail, trail.run);
228
- try {
229
- const result = await impl(validatedInput, ctx);
230
- if (result.isOk()) {
231
- return { content: await serializeOutput(result.value) };
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
- return mcpError(result.error.message);
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
- trail: Trail<unknown, unknown>,
878
+ graph: Topo,
879
+ t: Trail<unknown, unknown, unknown>,
242
880
  layers: readonly Layer[],
243
- options: BuildMcpToolsOptions
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 validated = validateInput(trail.input, args);
250
- if (validated.isErr()) {
251
- return mcpError(validated.error.message);
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
- const ctx = await buildTrailContext(options, extra);
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 an App's topology.
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 (app-prefixed, underscore-delimited)
938
+ * - A derived tool name (topo-name-prefixed, underscore-delimited)
266
939
  * - JSON Schema input from zodToJsonSchema
267
- * - MCP annotations from trail metadata
940
+ * - MCP annotations from trail meta
268
941
  * - A handler that validates, composes layers, executes, and maps results
269
942
  */
270
- /** Check if a trail should be included based on metadata and filters. */
271
- const shouldInclude = (
272
- trail: Trail<unknown, unknown>,
273
- options: BuildMcpToolsOptions
274
- ): boolean => {
275
- if (trail.metadata?.['internal'] === true) {
276
- return false;
277
- }
278
- if (options.includeTrails !== undefined && options.includeTrails.length > 0) {
279
- return options.includeTrails.includes(trail.id);
280
- }
281
- if (
282
- options.excludeTrails !== undefined &&
283
- options.excludeTrails.includes(trail.id)
284
- ) {
285
- return false;
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 true;
972
+ return {
973
+ schema: {
974
+ properties: { data: raw },
975
+ required: ['data'],
976
+ type: 'object',
977
+ },
978
+ wrapAsData: true,
979
+ };
288
980
  };
289
981
 
290
- /** Build a description with optional example input appended. */
291
- const buildDescription = (
292
- trail: Trail<unknown, unknown>
293
- ): string | undefined => {
294
- let { description } = trail;
295
- if (
296
- description !== undefined &&
297
- trail.examples !== undefined &&
298
- trail.examples.length > 0
299
- ) {
300
- const [firstExample] = trail.examples;
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 description;
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
- app: Topo,
311
- trail: Trail<unknown, unknown>,
1009
+ graph: Topo,
1010
+ trail: Trail<unknown, unknown, unknown>,
312
1011
  layers: readonly Layer[],
313
- options: BuildMcpToolsOptions
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(trail, layers, options),
322
- inputSchema: zodToJsonSchema(trail.input),
323
- name: deriveToolName(app.name, trail.id),
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
- app: Topo,
330
- trailItem: Trail<unknown, unknown>,
1240
+ graph: Topo,
1241
+ trailItem: Trail<unknown, unknown, unknown>,
331
1242
  layers: readonly Layer[],
332
- options: BuildMcpToolsOptions,
333
- nameToTrailId: Map<string, string>,
1243
+ options: DeriveMcpToolsOptions,
1244
+ nameToSourceId: Map<string, string>,
334
1245
  tools: McpToolDefinition[]
335
- ): void => {
336
- const toolName = deriveToolName(app.name, trailItem.id);
337
- const existingId = nameToTrailId.get(toolName);
1246
+ ): Result<void, Error> => {
1247
+ const toolName = deriveToolName(graph.name, trailItem.id);
1248
+ const existingId = nameToSourceId.get(toolName);
338
1249
  if (existingId !== undefined) {
339
- throw new Error(
340
- `MCP tool-name collision: trails "${existingId}" and "${trailItem.id}" both derive the tool name "${toolName}"`
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
- nameToTrailId.set(toolName, trailItem.id);
344
- tools.push(buildToolDefinition(app, trailItem, layers, options));
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
- app: Topo,
350
- options: BuildMcpToolsOptions
351
- ): Trail<unknown, unknown>[] =>
352
- app
353
- .list()
354
- .filter(
355
- (item): item is Trail<unknown, unknown> =>
356
- item.kind === 'trail' &&
357
- shouldInclude(item as Trail<unknown, unknown>, options)
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
- export const buildMcpTools = (
361
- app: Topo,
362
- options: BuildMcpToolsOptions = {}
363
- ): McpToolDefinition[] => {
364
- const layers = options.layers ?? [];
1484
+ const registerTools = (
1485
+ graph: Topo,
1486
+ options: DeriveMcpToolsOptions,
1487
+ layers: readonly Layer[]
1488
+ ): Result<McpToolDefinition[], Error> => {
365
1489
  const tools: McpToolDefinition[] = [];
366
- const nameToTrailId = new Map<string, string>();
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 eligibleTrails(app, options)) {
369
- registerTool(app, trailItem, layers, options, nameToTrailId, tools);
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 tools;
1571
+ return registerTools(graph, options, options.layers ?? []);
373
1572
  };