@ontrails/mcp 1.0.0-beta.2 → 1.0.0-beta.21

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