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

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