@ontrails/mcp 1.0.0-beta.3 → 1.0.0-beta.32

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