@ontrails/mcp 1.0.0-beta.14 → 1.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +36 -17
  3. package/package.json +11 -3
  4. package/src/annotations.ts +4 -1
  5. package/src/build.ts +689 -127
  6. package/src/index.ts +14 -4
  7. package/src/stdio.ts +1 -1
  8. package/src/surface.ts +215 -0
  9. package/.turbo/turbo-build.log +0 -1
  10. package/.turbo/turbo-lint.log +0 -3
  11. package/.turbo/turbo-typecheck.log +0 -1
  12. package/dist/annotations.d.ts +0 -19
  13. package/dist/annotations.d.ts.map +0 -1
  14. package/dist/annotations.js +0 -31
  15. package/dist/annotations.js.map +0 -1
  16. package/dist/blaze.d.ts +0 -41
  17. package/dist/blaze.d.ts.map +0 -1
  18. package/dist/blaze.js +0 -108
  19. package/dist/blaze.js.map +0 -1
  20. package/dist/build.d.ts +0 -48
  21. package/dist/build.d.ts.map +0 -1
  22. package/dist/build.js +0 -229
  23. package/dist/build.js.map +0 -1
  24. package/dist/index.d.ts +0 -7
  25. package/dist/index.d.ts.map +0 -1
  26. package/dist/index.js +0 -13
  27. package/dist/index.js.map +0 -1
  28. package/dist/progress.d.ts +0 -13
  29. package/dist/progress.d.ts.map +0 -1
  30. package/dist/progress.js +0 -51
  31. package/dist/progress.js.map +0 -1
  32. package/dist/stdio.d.ts +0 -12
  33. package/dist/stdio.d.ts.map +0 -1
  34. package/dist/stdio.js +0 -15
  35. package/dist/stdio.js.map +0 -1
  36. package/dist/tool-name.d.ts +0 -15
  37. package/dist/tool-name.d.ts.map +0 -1
  38. package/dist/tool-name.js +0 -19
  39. package/dist/tool-name.js.map +0 -1
  40. package/dist/trailhead.d.ts +0 -41
  41. package/dist/trailhead.d.ts.map +0 -1
  42. package/dist/trailhead.js +0 -109
  43. package/dist/trailhead.js.map +0 -1
  44. package/src/__tests__/annotations.test.ts +0 -63
  45. package/src/__tests__/build.test.ts +0 -529
  46. package/src/__tests__/progress.test.ts +0 -136
  47. package/src/__tests__/tool-name.test.ts +0 -46
  48. package/src/__tests__/trailhead.test.ts +0 -158
  49. package/src/trailhead.ts +0 -173
  50. package/tsconfig.json +0 -9
  51. package/tsconfig.tsbuildinfo +0 -1
package/src/build.ts CHANGED
@@ -1,24 +1,37 @@
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
- * validate input, compose gates, execute the implementation, and map
5
+ * validate input, compose layers, execute the implementation, and map
6
6
  * Results to MCP responses.
7
7
  */
8
8
 
9
9
  import {
10
+ AuthError,
10
11
  Result,
11
- TRAILHEAD_KEY,
12
12
  ValidationError,
13
+ collectAttachedTypedLayers,
14
+ deriveStructuredTrailExamples,
13
15
  executeTrail,
16
+ filterSurfaceTrails,
14
17
  isBlobRef,
15
- validateEstablishedTopo,
18
+ isTrailsError,
19
+ LAYER_FIELD_RESERVED_NAMES,
20
+ projectLayerFieldName,
21
+ projectPublicSurfaceError,
22
+ toBlobRefDescriptor,
23
+ validateSurfaceTopo,
24
+ withSurfaceLayerNames,
16
25
  zodToJsonSchema,
17
26
  } from '@ontrails/core';
18
27
  import type {
28
+ AttachedTypedLayer,
29
+ BasePermit,
30
+ BaseSurfaceOptions,
19
31
  BlobRef,
20
- Gate,
21
- ProvisionOverrideMap,
32
+ Layer,
33
+ ResourceOverrideMap,
34
+ SurfaceErrorProjection,
22
35
  Topo,
23
36
  Trail,
24
37
  TrailContextInit,
@@ -29,27 +42,37 @@ import { deriveAnnotations } from './annotations.js';
29
42
  import { createMcpProgressCallback } from './progress.js';
30
43
  import { deriveToolName } from './tool-name.js';
31
44
 
45
+ export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
46
+
47
+ export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
48
+
32
49
  // ---------------------------------------------------------------------------
33
50
  // Public types
34
51
  // ---------------------------------------------------------------------------
35
52
 
36
- export interface BuildMcpToolsOptions {
37
- /** Config values for provisions that declare a `config` schema, keyed by provision ID. */
38
- readonly configValues?:
39
- | Readonly<Record<string, Record<string, unknown>>>
40
- | undefined;
53
+ export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
41
54
  readonly createContext?:
42
55
  | (() => TrailContextInit | Promise<TrailContextInit>)
43
56
  | undefined;
44
- readonly excludeTrails?: readonly string[] | undefined;
45
- readonly includeTrails?: readonly string[] | undefined;
46
- readonly gates?: readonly Gate[] | undefined;
47
- readonly provisions?: ProvisionOverrideMap | undefined;
48
- /** Set to `false` to skip topo validation while building tools. */
49
- readonly validate?: boolean | undefined;
57
+ readonly layers?: readonly Layer[] | undefined;
58
+ readonly resources?: ResourceOverrideMap | undefined;
59
+ readonly resolvePermit?: ResolveMcpPermit | undefined;
50
60
  }
51
61
 
62
+ export interface ResolveMcpPermitInput {
63
+ readonly authorization?: string | undefined;
64
+ readonly bearerToken?: string | undefined;
65
+ readonly sessionId?: string | undefined;
66
+ }
67
+
68
+ export type ResolveMcpPermit = (
69
+ input: ResolveMcpPermitInput
70
+ ) =>
71
+ | Promise<Result<BasePermit | null | undefined, Error>>
72
+ | Result<BasePermit | null | undefined, Error>;
73
+
52
74
  export interface McpToolDefinition {
75
+ readonly _meta?: Record<string, unknown> | undefined;
53
76
  readonly annotations: McpAnnotations | undefined;
54
77
  readonly description: string | undefined;
55
78
  readonly handler: (
@@ -58,23 +81,33 @@ export interface McpToolDefinition {
58
81
  ) => Promise<McpToolResult>;
59
82
  readonly inputSchema: Record<string, unknown>;
60
83
  readonly name: string;
84
+ readonly outputSchema?: Record<string, unknown> | undefined;
61
85
  /** The trail ID this tool was derived from. */
62
86
  readonly trailId: string;
63
87
  }
64
88
 
65
89
  export interface McpExtra {
90
+ readonly authorization?: string | undefined;
66
91
  readonly progressToken?: string | number | undefined;
67
92
  readonly sendProgress?:
68
93
  | ((current: number, total: number) => Promise<void>)
69
94
  | undefined;
70
95
  readonly abortSignal?: AbortSignal | undefined;
96
+ readonly permit?: BasePermit | undefined;
97
+ readonly sessionId?: string | undefined;
71
98
  }
72
99
 
73
100
  export interface McpToolResult {
101
+ readonly _meta?: Record<string, unknown> | undefined;
74
102
  readonly content: readonly McpContent[];
75
103
  readonly isError?: boolean | undefined;
104
+ readonly structuredContent?: Record<string, unknown> | undefined;
76
105
  }
77
106
 
107
+ export type McpToolErrorMeta = Omit<SurfaceErrorProjection, 'surface'> & {
108
+ readonly surface: 'mcp';
109
+ };
110
+
78
111
  export interface McpContent {
79
112
  readonly data?: string | undefined;
80
113
  readonly mimeType?: string | undefined;
@@ -108,13 +141,17 @@ const collectStream = async (
108
141
  const reader = stream.getReader();
109
142
  const chunks: Uint8Array[] = [];
110
143
  let totalLength = 0;
111
- for (;;) {
112
- const { done, value } = await reader.read();
113
- if (done) {
114
- break;
144
+ try {
145
+ for (;;) {
146
+ const { done, value } = await reader.read();
147
+ if (done) {
148
+ break;
149
+ }
150
+ chunks.push(value);
151
+ totalLength += value.length;
115
152
  }
116
- chunks.push(value);
117
- totalLength += value.length;
153
+ } finally {
154
+ reader.releaseLock();
118
155
  }
119
156
  return concatChunks(chunks, totalLength);
120
157
  };
@@ -127,6 +164,8 @@ const resolveBlobData = (blob: BlobRef): Promise<Uint8Array> | Uint8Array => {
127
164
  return blob.data;
128
165
  };
129
166
 
167
+ type BlobDataResolver = (blob: BlobRef) => Promise<Uint8Array> | Uint8Array;
168
+
130
169
  const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
131
170
  // Use btoa with manual conversion for runtime-agnostic base64
132
171
  let binary = '';
@@ -136,23 +175,148 @@ const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
136
175
  return btoa(binary);
137
176
  };
138
177
 
139
- const blobToContent = async (blob: BlobRef): Promise<McpContent> => {
140
- const bytes = await resolveBlobData(blob);
141
- if (blob.mimeType.startsWith('image/')) {
178
+ const blobToContent = async (
179
+ blob: BlobRef,
180
+ resolveData: BlobDataResolver = resolveBlobData
181
+ ): Promise<McpContent> => {
182
+ if (!blob.mimeType.startsWith('image/')) {
142
183
  return {
143
- data: uint8ArrayToBase64(bytes),
144
184
  mimeType: blob.mimeType,
145
- type: 'image',
185
+ type: 'resource',
186
+ uri: `blob://${blob.name}`,
146
187
  };
147
188
  }
148
189
 
190
+ const bytes = await resolveData(blob);
149
191
  return {
192
+ data: uint8ArrayToBase64(bytes),
150
193
  mimeType: blob.mimeType,
151
- type: 'resource',
152
- uri: `blob://${blob.name}`,
194
+ type: 'image',
195
+ };
196
+ };
197
+
198
+ type BlobContentResolver = (blob: BlobRef) => Promise<McpContent>;
199
+
200
+ const createBlobContentResolver = (): BlobContentResolver => {
201
+ const contentByBlob = new WeakMap<BlobRef, Promise<McpContent>>();
202
+ const dataByStream = new WeakMap<
203
+ ReadableStream<Uint8Array>,
204
+ Promise<Uint8Array>
205
+ >();
206
+
207
+ const resolveData: BlobDataResolver = (blob) => {
208
+ if (!(blob.data instanceof ReadableStream)) {
209
+ return blob.data;
210
+ }
211
+
212
+ let data = dataByStream.get(blob.data);
213
+ if (data === undefined) {
214
+ data = collectStream(blob.data);
215
+ dataByStream.set(blob.data, data);
216
+ }
217
+ return data;
218
+ };
219
+
220
+ return (blob) => {
221
+ let content = contentByBlob.get(blob);
222
+ if (content === undefined) {
223
+ content = blobToContent(blob, resolveData);
224
+ contentByBlob.set(blob, content);
225
+ }
226
+ return content;
153
227
  };
154
228
  };
155
229
 
230
+ const containsBlobRef = (
231
+ value: unknown,
232
+ path = new WeakSet<object>()
233
+ ): boolean => {
234
+ if (isBlobRef(value)) {
235
+ return true;
236
+ }
237
+ if (value === null || typeof value !== 'object') {
238
+ return false;
239
+ }
240
+ if (path.has(value)) {
241
+ return false;
242
+ }
243
+ path.add(value);
244
+
245
+ try {
246
+ if (Array.isArray(value)) {
247
+ return value.some((item) => containsBlobRef(item, path));
248
+ }
249
+
250
+ return Object.values(value as Record<string, unknown>).some((item) =>
251
+ containsBlobRef(item, path)
252
+ );
253
+ } finally {
254
+ path.delete(value);
255
+ }
256
+ };
257
+
258
+ const toStructuredValue = (
259
+ value: unknown,
260
+ path = new WeakSet<object>()
261
+ ): unknown => {
262
+ if (isBlobRef(value)) {
263
+ return toBlobRefDescriptor(value);
264
+ }
265
+ if (value === null || typeof value !== 'object') {
266
+ return value;
267
+ }
268
+ if (path.has(value)) {
269
+ return undefined;
270
+ }
271
+ path.add(value);
272
+
273
+ try {
274
+ if (Array.isArray(value)) {
275
+ return value.map((item) => toStructuredValue(item, path));
276
+ }
277
+
278
+ return Object.fromEntries(
279
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [
280
+ key,
281
+ toStructuredValue(item, path),
282
+ ])
283
+ );
284
+ } finally {
285
+ path.delete(value);
286
+ }
287
+ };
288
+
289
+ const collectBlobRefs = (
290
+ value: unknown,
291
+ path = new WeakSet<object>()
292
+ ): BlobRef[] => {
293
+ if (isBlobRef(value)) {
294
+ return [value];
295
+ }
296
+ if (value === null || typeof value !== 'object') {
297
+ return [];
298
+ }
299
+ if (path.has(value)) {
300
+ return [];
301
+ }
302
+ path.add(value);
303
+
304
+ try {
305
+ const items = Array.isArray(value)
306
+ ? value
307
+ : Object.values(value as Record<string, unknown>);
308
+ return items.flatMap((item) => collectBlobRefs(item, path));
309
+ } finally {
310
+ path.delete(value);
311
+ }
312
+ };
313
+
314
+ const collectBlobContents = async (
315
+ value: unknown,
316
+ resolveContent: BlobContentResolver
317
+ ): Promise<McpContent[]> =>
318
+ Promise.all(collectBlobRefs(value).map(resolveContent));
319
+
156
320
  /** Separate blob fields from non-blob fields in an object. */
157
321
  const separateBlobFields = async (
158
322
  obj: Record<string, unknown>
@@ -161,13 +325,18 @@ const separateBlobFields = async (
161
325
  hasBlobFields: boolean;
162
326
  textFields: Record<string, unknown>;
163
327
  }> => {
328
+ const resolveContent = createBlobContentResolver();
164
329
  const blobContents: McpContent[] = [];
165
330
  const textFields: Record<string, unknown> = {};
166
331
  let hasBlobFields = false;
167
332
  for (const [key, val] of Object.entries(obj)) {
168
333
  if (isBlobRef(val)) {
169
334
  hasBlobFields = true;
170
- blobContents.push(await blobToContent(val));
335
+ blobContents.push(await resolveContent(val));
336
+ } else if (containsBlobRef(val)) {
337
+ hasBlobFields = true;
338
+ blobContents.push(...(await collectBlobContents(val, resolveContent)));
339
+ textFields[key] = toStructuredValue(val);
171
340
  } else {
172
341
  textFields[key] = val;
173
342
  }
@@ -190,12 +359,34 @@ const serializeMixedObject = async (
190
359
  return blobContents;
191
360
  };
192
361
 
362
+ const serializeBlobArray = async (
363
+ value: readonly unknown[]
364
+ ): Promise<readonly McpContent[] | undefined> => {
365
+ if (!containsBlobRef(value)) {
366
+ return undefined;
367
+ }
368
+ const blobContents = await collectBlobContents(
369
+ value,
370
+ createBlobContentResolver()
371
+ );
372
+ return [
373
+ { text: JSON.stringify(toStructuredValue(value)), type: 'text' },
374
+ ...blobContents,
375
+ ];
376
+ };
377
+
193
378
  const serializeOutput = async (
194
379
  value: unknown
195
380
  ): Promise<readonly McpContent[]> => {
196
381
  if (isBlobRef(value)) {
197
382
  return [await blobToContent(value)];
198
383
  }
384
+ if (Array.isArray(value)) {
385
+ const mixed = await serializeBlobArray(value);
386
+ if (mixed) {
387
+ return mixed;
388
+ }
389
+ }
199
390
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
200
391
  const mixed = await serializeMixedObject(value as Record<string, unknown>);
201
392
  if (mixed) {
@@ -205,49 +396,391 @@ const serializeOutput = async (
205
396
  return [{ text: JSON.stringify(value), type: 'text' }];
206
397
  };
207
398
 
399
+ // `wrapAsData` is decided at build time from the schema shape (see
400
+ // `buildOutputSchemaProjection`). It must be threaded through to the runtime
401
+ // because the schema's wrap decision and the runtime value's wrap decision
402
+ // can diverge — e.g. for `z.union([z.object(...), z.string()])` or
403
+ // `z.any()`, the schema declares a `{ data: ... }` envelope but a runtime
404
+ // object value would otherwise be returned unwrapped, breaking the
405
+ // outputSchema/structuredContent contract.
406
+ const toStructuredContent = (
407
+ value: unknown,
408
+ wrapAsData: boolean
409
+ ): Record<string, unknown> | undefined => {
410
+ const structuredValue = containsBlobRef(value)
411
+ ? toStructuredValue(value)
412
+ : value;
413
+ if (wrapAsData) {
414
+ return { data: structuredValue };
415
+ }
416
+ if (
417
+ structuredValue !== null &&
418
+ typeof structuredValue === 'object' &&
419
+ !Array.isArray(structuredValue)
420
+ ) {
421
+ return structuredValue as Record<string, unknown>;
422
+ }
423
+ // When wrapAsData is false the schema's top-level type is `'object'`, and
424
+ // output validation has already constrained the runtime value to that
425
+ // shape — a primitive or array reaching this branch indicates the
426
+ // validation contract was bypassed. Return `undefined` so any future
427
+ // bypass surfaces as a missing `structuredContent` rather than a silently
428
+ // wrapped envelope that contradicts the published `outputSchema`.
429
+ return undefined;
430
+ };
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Layer input projection (TRL-474)
434
+ // ---------------------------------------------------------------------------
435
+
436
+ /**
437
+ * Per-layer projection onto an MCP tool's input schema.
438
+ *
439
+ * `routing` maps the parameter name a consumer sees on the tool to the
440
+ * authored field name on the layer's input schema. When no rename was
441
+ * required the two are the same; on collision the parameter name carries
442
+ * the layer prefix while the routing target preserves the original field.
443
+ */
444
+ interface McpLayerInputProjection {
445
+ readonly layerName: string;
446
+ /** parameterName → originalFieldName for this layer. */
447
+ readonly routing: ReadonlyMap<string, string>;
448
+ /** Fragment merged into the top-level input schema's `properties`. */
449
+ readonly properties: Readonly<Record<string, unknown>>;
450
+ /** Field names appended to the top-level `required` list. */
451
+ readonly required: readonly string[];
452
+ }
453
+
454
+ /**
455
+ * Build the camelCase rename target for a layer field collision.
456
+ *
457
+ * The CLI projection uses `kebab-case` (`<layerName>-<field>`); MCP exposes
458
+ * fields as JSON properties so the corresponding shape is camelCase
459
+ * (`<layerName><FieldCapitalized>`). The shared collision policy lives in
460
+ * `projectLayerFieldName`; this helper just supplies the surface-specific
461
+ * fallback name.
462
+ */
463
+ const buildMcpRenameTarget = (
464
+ layerName: string,
465
+ originalName: string
466
+ ): string => {
467
+ if (originalName.length === 0) {
468
+ return layerName;
469
+ }
470
+ const [head, ...rest] = originalName;
471
+ if (head === undefined) {
472
+ return layerName;
473
+ }
474
+ return `${layerName}${head.toUpperCase()}${rest.join('')}`;
475
+ };
476
+
477
+ const isJsonObjectSchema = (
478
+ value: unknown
479
+ ): value is { properties?: Record<string, unknown>; required?: string[] } =>
480
+ typeof value === 'object' && value !== null && !Array.isArray(value);
481
+
482
+ /**
483
+ * Project a single layer's input schema into MCP-shaped property and
484
+ * required fragments, applying the deterministic collision rename rule.
485
+ */
486
+ const projectMcpLayerInput = (
487
+ layer: Layer,
488
+ claimedNames: Set<string>
489
+ ): McpLayerInputProjection => {
490
+ if (layer.input === undefined) {
491
+ return {
492
+ layerName: layer.name,
493
+ properties: {},
494
+ required: [],
495
+ routing: new Map(),
496
+ };
497
+ }
498
+
499
+ const layerSchema = zodToJsonSchema(layer.input);
500
+ const properties: Record<string, unknown> = {};
501
+ const required: string[] = [];
502
+ const routing = new Map<string, string>();
503
+
504
+ if (
505
+ !isJsonObjectSchema(layerSchema) ||
506
+ layerSchema.properties === undefined
507
+ ) {
508
+ return {
509
+ layerName: layer.name,
510
+ properties,
511
+ required,
512
+ routing,
513
+ };
514
+ }
515
+
516
+ const requiredSet = new Set<string>(layerSchema.required);
517
+ for (const [fieldName, fieldSchema] of Object.entries(
518
+ layerSchema.properties
519
+ )) {
520
+ const renamed = buildMcpRenameTarget(layer.name, fieldName);
521
+ const projection = projectLayerFieldName(
522
+ layer.name,
523
+ fieldName,
524
+ fieldName,
525
+ renamed,
526
+ claimedNames,
527
+ LAYER_FIELD_RESERVED_NAMES
528
+ );
529
+ properties[projection.claimedName] = fieldSchema;
530
+ if (requiredSet.has(fieldName)) {
531
+ required.push(projection.claimedName);
532
+ }
533
+ routing.set(projection.claimedName, projection.routingTarget);
534
+ }
535
+
536
+ return { layerName: layer.name, properties, required, routing };
537
+ };
538
+
539
+ interface McpInputProjection {
540
+ readonly schema: Record<string, unknown>;
541
+ readonly projections: readonly McpLayerInputProjection[];
542
+ }
543
+
544
+ /**
545
+ * Merge typed layer input schemas into the trail's input schema.
546
+ *
547
+ * Returns the merged input schema published on the MCP tool plus the
548
+ * per-layer routing tables consumed by the handler when partitioning
549
+ * incoming parameters.
550
+ */
551
+ const projectMcpInputSchema = (
552
+ trail: Trail<unknown, unknown, unknown>,
553
+ attachedLayers: readonly AttachedTypedLayer[]
554
+ ): McpInputProjection => {
555
+ const baseSchema = zodToJsonSchema(trail.input);
556
+ if (attachedLayers.length === 0) {
557
+ return { projections: [], schema: baseSchema };
558
+ }
559
+
560
+ const baseProperties =
561
+ isJsonObjectSchema(baseSchema) && baseSchema.properties !== undefined
562
+ ? baseSchema.properties
563
+ : undefined;
564
+ const baseRequired =
565
+ isJsonObjectSchema(baseSchema) && Array.isArray(baseSchema.required)
566
+ ? baseSchema.required
567
+ : [];
568
+
569
+ const claimedNames = new Set<string>(
570
+ baseProperties === undefined ? [] : Object.keys(baseProperties)
571
+ );
572
+
573
+ const mergedProperties: Record<string, unknown> = {
574
+ ...baseProperties,
575
+ };
576
+ const mergedRequired = [...baseRequired];
577
+ const projections: McpLayerInputProjection[] = [];
578
+
579
+ for (const { layer } of attachedLayers) {
580
+ const projection = projectMcpLayerInput(layer, claimedNames);
581
+ if (projection.routing.size === 0) {
582
+ continue;
583
+ }
584
+ Object.assign(mergedProperties, projection.properties);
585
+ mergedRequired.push(...projection.required);
586
+ projections.push(projection);
587
+ }
588
+
589
+ if (projections.length === 0) {
590
+ return { projections: [], schema: baseSchema };
591
+ }
592
+
593
+ const mergedSchema: Record<string, unknown> = isJsonObjectSchema(baseSchema)
594
+ ? { ...baseSchema, properties: mergedProperties, type: 'object' }
595
+ : { properties: mergedProperties, type: 'object' };
596
+ if (mergedRequired.length > 0) {
597
+ mergedSchema['required'] = mergedRequired;
598
+ } else if ('required' in mergedSchema) {
599
+ delete mergedSchema['required'];
600
+ }
601
+
602
+ return { projections, schema: mergedSchema };
603
+ };
604
+
605
+ /**
606
+ * Partition a parsed MCP `args` record into the trail input plus per-layer
607
+ * inputs, using each layer's routing table.
608
+ *
609
+ * Layer-projected parameter names are stripped from the trail input so the
610
+ * trail's schema validation only ever sees its own fields. A layer that
611
+ * received no parameters is omitted from `layerInputs` so consumers can
612
+ * cleanly assert which layers were activated by the request.
613
+ */
614
+ const partitionMcpArgs = (
615
+ args: Record<string, unknown>,
616
+ projections: readonly McpLayerInputProjection[]
617
+ ): {
618
+ readonly trailInput: Record<string, unknown>;
619
+ readonly layerInputs: Record<string, unknown>;
620
+ } => {
621
+ if (projections.length === 0) {
622
+ return { layerInputs: {}, trailInput: { ...args } };
623
+ }
624
+ const claimedKeys = new Set<string>();
625
+ const layerInputs: Record<string, unknown> = {};
626
+ for (const projection of projections) {
627
+ const layerInput: Record<string, unknown> = {};
628
+ let received = false;
629
+ for (const [paramName, fieldName] of projection.routing) {
630
+ claimedKeys.add(paramName);
631
+ const value = args[paramName];
632
+ if (value === undefined) {
633
+ continue;
634
+ }
635
+ layerInput[fieldName] = value;
636
+ received = true;
637
+ }
638
+ if (received) {
639
+ layerInputs[projection.layerName] = layerInput;
640
+ }
641
+ }
642
+ const trailInput: Record<string, unknown> = {};
643
+ for (const [key, value] of Object.entries(args)) {
644
+ if (claimedKeys.has(key)) {
645
+ continue;
646
+ }
647
+ trailInput[key] = value;
648
+ }
649
+ return { layerInputs, trailInput };
650
+ };
651
+
208
652
  // ---------------------------------------------------------------------------
209
653
  // Handler factory
210
654
  // ---------------------------------------------------------------------------
211
655
 
656
+ const buildMcpErrorMeta = (
657
+ error: Error,
658
+ projection: SurfaceErrorProjection
659
+ ): Record<string, McpToolErrorMeta> | undefined => {
660
+ if (!isTrailsError(error)) {
661
+ return undefined;
662
+ }
663
+ return {
664
+ [MCP_TOOL_ERROR_META_KEY]: {
665
+ ...projection,
666
+ surface: 'mcp',
667
+ },
668
+ };
669
+ };
670
+
212
671
  /** Create an error result for MCP responses. */
213
- const mcpError = (message: string): McpToolResult => ({
214
- content: [{ text: message, type: 'text' }],
215
- isError: true,
216
- });
217
-
218
- /** Add the MCP trailhead marker while preserving any existing context extras. */
219
- const withMcpTrailhead = (
220
- progressCb: TrailContextInit['progress']
221
- ): Partial<TrailContextInit> => ({
222
- ...(progressCb === undefined ? {} : { progress: progressCb }),
223
- extensions: {
224
- [TRAILHEAD_KEY]: 'mcp' as const,
225
- },
226
- });
672
+ const mcpError = (error: Error): McpToolResult => {
673
+ const projection = projectPublicSurfaceError('mcp', error);
674
+ const meta = buildMcpErrorMeta(error, projection);
675
+ return {
676
+ ...(meta === undefined ? {} : { _meta: meta }),
677
+ content: [{ text: projection.message, type: 'text' }],
678
+ isError: true,
679
+ };
680
+ };
681
+
682
+ /** Add the MCP surface marker while preserving any existing context extras. */
683
+ const withMcpSurface = (
684
+ progressCb: TrailContextInit['progress'],
685
+ layers: readonly Layer[]
686
+ ): Partial<TrailContextInit> =>
687
+ withSurfaceLayerNames(
688
+ 'mcp',
689
+ layers,
690
+ progressCb === undefined ? {} : { progress: progressCb }
691
+ );
692
+
693
+ const parseBearerAuthorization = (
694
+ authorization: string | undefined
695
+ ): Result<string | undefined, Error> => {
696
+ if (authorization === undefined || authorization.length === 0) {
697
+ return Result.ok();
698
+ }
699
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
700
+ const token = match?.[1]?.trim();
701
+ if (token === undefined || token.length === 0) {
702
+ return Result.err(
703
+ new AuthError('Malformed MCP authorization; expected Bearer token', {
704
+ context: { code: 'invalid_authorization_header' },
705
+ })
706
+ );
707
+ }
708
+ return Result.ok(token);
709
+ };
710
+
711
+ const resolveMcpPermit = async (
712
+ options: DeriveMcpToolsOptions,
713
+ extra: McpExtra
714
+ ): Promise<Result<BasePermit | undefined, Error>> => {
715
+ if (extra.permit !== undefined) {
716
+ return Result.ok(extra.permit);
717
+ }
718
+ const token = parseBearerAuthorization(extra.authorization);
719
+ if (token.isErr()) {
720
+ return token;
721
+ }
722
+ if (token.value === undefined) {
723
+ return Result.ok();
724
+ }
725
+ if (options.resolvePermit === undefined) {
726
+ return Result.ok();
727
+ }
728
+ const resolved = await options.resolvePermit({
729
+ authorization: extra.authorization,
730
+ bearerToken: token.value,
731
+ sessionId: extra.sessionId,
732
+ });
733
+ if (resolved.isErr()) {
734
+ return resolved;
735
+ }
736
+ return Result.ok(resolved.value ?? undefined);
737
+ };
227
738
 
228
739
  const createHandler =
229
740
  (
230
- t: Trail<unknown, unknown>,
231
- gates: readonly Gate[],
232
- options: BuildMcpToolsOptions
741
+ graph: Topo,
742
+ t: Trail<unknown, unknown, unknown>,
743
+ layers: readonly Layer[],
744
+ options: DeriveMcpToolsOptions,
745
+ wrapAsData: boolean,
746
+ layerProjections: readonly McpLayerInputProjection[]
233
747
  ): ((
234
748
  args: Record<string, unknown>,
235
749
  extra: McpExtra
236
750
  ) => Promise<McpToolResult>) =>
237
751
  async (args, extra): Promise<McpToolResult> => {
238
752
  const progressCb = createMcpProgressCallback(extra);
239
- const result = await executeTrail(t, args, {
753
+ const { trailInput, layerInputs } = partitionMcpArgs(
754
+ args,
755
+ layerProjections
756
+ );
757
+ const permitResolution = await resolveMcpPermit(options, extra);
758
+ if (permitResolution.isErr()) {
759
+ return mcpError(permitResolution.error);
760
+ }
761
+ const permit = permitResolution.value;
762
+ const result = await executeTrail(t, trailInput, {
240
763
  abortSignal: extra.abortSignal,
241
764
  configValues: options.configValues,
242
765
  createContext: options.createContext,
243
- ctx: withMcpTrailhead(progressCb),
244
- gates,
245
- provisions: options.provisions,
766
+ ctx: withMcpSurface(progressCb, layers),
767
+ ...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
768
+ ...(permit === undefined ? {} : { permit }),
769
+ resources: options.resources,
770
+ surfaceLayers: layers,
771
+ topo: graph,
772
+ topoLayers: graph.layers,
246
773
  });
247
774
  if (result.isOk()) {
248
- return { content: await serializeOutput(result.value) };
775
+ return {
776
+ content: await serializeOutput(result.value),
777
+ structuredContent:
778
+ t.output === undefined
779
+ ? undefined
780
+ : toStructuredContent(result.value, wrapAsData),
781
+ };
249
782
  }
250
- return mcpError(result.error.message);
783
+ return mcpError(result.error);
251
784
  };
252
785
 
253
786
  // ---------------------------------------------------------------------------
@@ -255,82 +788,114 @@ const createHandler =
255
788
  // ---------------------------------------------------------------------------
256
789
 
257
790
  /**
258
- * Build MCP tool definitions from an App's topology.
791
+ * Build MCP tool definitions from a graph's topology.
259
792
  *
260
793
  * Each trail in the topo becomes an McpToolDefinition with:
261
- * - A derived tool name (app-prefixed, underscore-delimited)
794
+ * - A derived tool name (topo-name-prefixed, underscore-delimited)
262
795
  * - JSON Schema input from zodToJsonSchema
263
796
  * - MCP annotations from trail meta
264
- * - A handler that validates, composes gates, executes, and maps results
797
+ * - A handler that validates, composes layers, executes, and maps results
265
798
  */
266
- /** Check if a trail should be included based on meta and filters. */
267
- const shouldInclude = (
268
- trail: Trail<unknown, unknown>,
269
- options: BuildMcpToolsOptions
270
- ): boolean => {
271
- if (trail.meta?.['internal'] === true) {
272
- return false;
273
- }
274
- if (options.includeTrails !== undefined && options.includeTrails.length > 0) {
275
- return options.includeTrails.includes(trail.id);
276
- }
277
- if (
278
- options.excludeTrails !== undefined &&
279
- options.excludeTrails.includes(trail.id)
280
- ) {
281
- return false;
799
+
800
+ const buildDescription = (
801
+ trail: Trail<unknown, unknown, unknown>
802
+ ): string | undefined => trail.description;
803
+
804
+ // MCP requires `outputSchema` to have literal `type: "object"` at the root
805
+ // (see `@modelcontextprotocol/sdk` Tool schema — `outputSchema: z.object({
806
+ // type: z.literal('object'), ... })`). Object-shaped unions like
807
+ // `z.discriminatedUnion(...)` emit as `{ anyOf: [...] }` from
808
+ // `zodToJsonSchema` with no top-level `type`, so we publish them under the
809
+ // data envelope. The shape of `structuredContent` then flows from the
810
+ // `wrapAsData` flag, keeping the runtime aligned with what the schema
811
+ // declares.
812
+ const isMcpStructuredObjectSchema = (
813
+ schema: Record<string, unknown>
814
+ ): boolean => schema['type'] === 'object';
815
+
816
+ interface OutputSchemaProjection {
817
+ readonly schema: Record<string, unknown>;
818
+ readonly wrapAsData: boolean;
819
+ }
820
+
821
+ const projectMcpOutputSchema = (
822
+ schema: Parameters<typeof zodToJsonSchema>[0]
823
+ ): OutputSchemaProjection => {
824
+ const raw = zodToJsonSchema(schema);
825
+ if (isMcpStructuredObjectSchema(raw)) {
826
+ return { schema: raw, wrapAsData: false };
282
827
  }
283
- return true;
828
+ return {
829
+ schema: {
830
+ properties: { data: raw },
831
+ required: ['data'],
832
+ type: 'object',
833
+ },
834
+ wrapAsData: true,
835
+ };
284
836
  };
285
837
 
286
- /** Build a description with optional example input appended. */
287
- const buildDescription = (
288
- trail: Trail<unknown, unknown>
289
- ): string | undefined => {
290
- let { description } = trail;
291
- if (
292
- description !== undefined &&
293
- trail.examples !== undefined &&
294
- trail.examples.length > 0
295
- ) {
296
- const [firstExample] = trail.examples;
297
- if (firstExample !== undefined) {
298
- description = `${description}\n\nExample input: ${JSON.stringify(firstExample.input)}`;
299
- }
838
+ const buildOutputSchemaProjection = (
839
+ trail: Trail<unknown, unknown, unknown>
840
+ ): OutputSchemaProjection | undefined =>
841
+ trail.output === undefined ? undefined : projectMcpOutputSchema(trail.output);
842
+
843
+ const buildMeta = (
844
+ trail: Trail<unknown, unknown, unknown>
845
+ ): Record<string, unknown> | undefined => {
846
+ const examples = deriveStructuredTrailExamples(trail.examples);
847
+ if (examples === undefined) {
848
+ return undefined;
300
849
  }
301
- return description;
850
+ return { [MCP_TOOL_EXAMPLES_META_KEY]: examples };
302
851
  };
303
852
 
304
853
  /** Build a single MCP tool definition from a trail. */
305
854
  const buildToolDefinition = (
306
- app: Topo,
307
- trail: Trail<unknown, unknown>,
308
- gates: readonly Gate[],
309
- options: BuildMcpToolsOptions
855
+ graph: Topo,
856
+ trail: Trail<unknown, unknown, unknown>,
857
+ layers: readonly Layer[],
858
+ options: DeriveMcpToolsOptions
310
859
  ): McpToolDefinition => {
311
860
  const rawAnnotations = deriveAnnotations(trail);
312
861
  const annotations =
313
862
  Object.keys(rawAnnotations).length > 0 ? rawAnnotations : undefined;
863
+ const projection = buildOutputSchemaProjection(trail);
864
+ const attachedLayers = collectAttachedTypedLayers(
865
+ graph,
866
+ trail,
867
+ options.layers
868
+ );
869
+ const inputProjection = projectMcpInputSchema(trail, attachedLayers);
314
870
  return {
871
+ _meta: buildMeta(trail),
315
872
  annotations,
316
873
  description: buildDescription(trail),
317
- handler: createHandler(trail, gates, options),
318
- inputSchema: zodToJsonSchema(trail.input),
319
- name: deriveToolName(app.name, trail.id),
874
+ handler: createHandler(
875
+ graph,
876
+ trail,
877
+ layers,
878
+ options,
879
+ projection?.wrapAsData ?? false,
880
+ inputProjection.projections
881
+ ),
882
+ inputSchema: inputProjection.schema,
883
+ name: deriveToolName(graph.name, trail.id),
884
+ outputSchema: projection?.schema,
320
885
  trailId: trail.id,
321
886
  };
322
887
  };
323
888
 
324
889
  /** Register a trail as an MCP tool, checking for name collisions. */
325
890
  const registerTool = (
326
- app: Topo,
327
- trailItem: Trail<unknown, unknown>,
328
- gates: readonly Gate[],
329
- options: BuildMcpToolsOptions,
891
+ graph: Topo,
892
+ trailItem: Trail<unknown, unknown, unknown>,
893
+ layers: readonly Layer[],
894
+ options: DeriveMcpToolsOptions,
330
895
  nameToTrailId: Map<string, string>,
331
896
  tools: McpToolDefinition[]
332
897
  ): Result<void, Error> => {
333
- const toolName = deriveToolName(app.name, trailItem.id);
898
+ const toolName = deriveToolName(graph.name, trailItem.id);
334
899
  const existingId = nameToTrailId.get(toolName);
335
900
  if (existingId !== undefined) {
336
901
  return Result.err(
@@ -340,42 +905,39 @@ const registerTool = (
340
905
  );
341
906
  }
342
907
  nameToTrailId.set(toolName, trailItem.id);
343
- tools.push(buildToolDefinition(app, trailItem, gates, options));
908
+ tools.push(buildToolDefinition(graph, trailItem, layers, options));
344
909
  return Result.ok();
345
910
  };
346
911
 
347
912
  /** Filter topo items to eligible trails. */
348
913
  const eligibleTrails = (
349
- app: Topo,
350
- options: BuildMcpToolsOptions
351
- ): Trail<unknown, unknown>[] =>
352
- app.list().filter((trail) => shouldInclude(trail, options));
914
+ graph: Topo,
915
+ options: DeriveMcpToolsOptions
916
+ ): Trail<unknown, unknown, unknown>[] =>
917
+ filterSurfaceTrails(graph.list(), {
918
+ exclude: options.exclude,
919
+ include: options.include,
920
+ intent: options.intent,
921
+ });
353
922
 
354
923
  const validateToolBuild = (
355
- app: Topo,
356
- options: BuildMcpToolsOptions
357
- ): Result<void, Error> => {
358
- if (options.validate === false) {
359
- return Result.ok();
360
- }
361
-
362
- const validated = validateEstablishedTopo(app);
363
- return validated.isErr() ? Result.err(validated.error) : Result.ok();
364
- };
924
+ graph: Topo,
925
+ options: DeriveMcpToolsOptions
926
+ ): Result<void, Error> => validateSurfaceTopo(graph, options);
365
927
 
366
928
  const registerTools = (
367
- app: Topo,
368
- options: BuildMcpToolsOptions,
369
- gates: readonly Gate[]
929
+ graph: Topo,
930
+ options: DeriveMcpToolsOptions,
931
+ layers: readonly Layer[]
370
932
  ): Result<McpToolDefinition[], Error> => {
371
933
  const tools: McpToolDefinition[] = [];
372
934
  const nameToTrailId = new Map<string, string>();
373
935
 
374
- for (const trailItem of eligibleTrails(app, options)) {
936
+ for (const trailItem of eligibleTrails(graph, options)) {
375
937
  const registered = registerTool(
376
- app,
938
+ graph,
377
939
  trailItem,
378
- gates,
940
+ layers,
379
941
  options,
380
942
  nameToTrailId,
381
943
  tools
@@ -388,14 +950,14 @@ const registerTools = (
388
950
  return Result.ok(tools);
389
951
  };
390
952
 
391
- export const buildMcpTools = (
392
- app: Topo,
393
- options: BuildMcpToolsOptions = {}
953
+ export const deriveMcpTools = (
954
+ graph: Topo,
955
+ options: DeriveMcpToolsOptions = {}
394
956
  ): Result<McpToolDefinition[], Error> => {
395
- const validation = validateToolBuild(app, options);
957
+ const validation = validateToolBuild(graph, options);
396
958
  if (validation.isErr()) {
397
959
  return validation;
398
960
  }
399
961
 
400
- return registerTools(app, options, options.gates ?? []);
962
+ return registerTools(graph, options, options.layers ?? []);
401
963
  };