@ontrails/mcp 1.0.0-beta.1 → 1.0.0-beta.11

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.
package/src/build.ts CHANGED
@@ -7,12 +7,20 @@
7
7
  */
8
8
 
9
9
  import {
10
- composeLayers,
11
- createTrailContext,
12
- validateInput,
10
+ Result,
11
+ ValidationError,
12
+ executeTrail,
13
+ isBlobRef,
13
14
  zodToJsonSchema,
14
15
  } from '@ontrails/core';
15
- import type { Layer, Topo, Trail, TrailContext } from '@ontrails/core';
16
+ import type {
17
+ BlobRef,
18
+ Layer,
19
+ ServiceOverrideMap,
20
+ Topo,
21
+ Trail,
22
+ TrailContextInit,
23
+ } from '@ontrails/core';
16
24
 
17
25
  import type { McpAnnotations } from './annotations.js';
18
26
  import { deriveAnnotations } from './annotations.js';
@@ -25,11 +33,12 @@ import { deriveToolName } from './tool-name.js';
25
33
 
26
34
  export interface BuildMcpToolsOptions {
27
35
  readonly createContext?:
28
- | (() => TrailContext | Promise<TrailContext>)
36
+ | (() => TrailContextInit | Promise<TrailContextInit>)
29
37
  | undefined;
30
38
  readonly excludeTrails?: readonly string[] | undefined;
31
39
  readonly includeTrails?: readonly string[] | undefined;
32
40
  readonly layers?: readonly Layer[] | undefined;
41
+ readonly services?: ServiceOverrideMap | undefined;
33
42
  }
34
43
 
35
44
  export interface McpToolDefinition {
@@ -41,6 +50,8 @@ export interface McpToolDefinition {
41
50
  ) => Promise<McpToolResult>;
42
51
  readonly inputSchema: Record<string, unknown>;
43
52
  readonly name: string;
53
+ /** The trail ID this tool was derived from. */
54
+ readonly trailId: string;
44
55
  }
45
56
 
46
57
  export interface McpExtra {
@@ -68,23 +79,44 @@ export interface McpContent {
68
79
  // Internal helpers (defined before use)
69
80
  // ---------------------------------------------------------------------------
70
81
 
71
- interface BlobRef {
72
- readonly data: Uint8Array;
73
- readonly kind: 'blob';
74
- readonly mimeType: string;
75
- readonly name?: string | undefined;
76
- }
82
+ /** Concatenate an array of Uint8Array chunks into a single Uint8Array. */
83
+ const concatChunks = (
84
+ chunks: Uint8Array[],
85
+ totalLength: number
86
+ ): Uint8Array => {
87
+ const result = new Uint8Array(totalLength);
88
+ let offset = 0;
89
+ for (const chunk of chunks) {
90
+ result.set(chunk, offset);
91
+ offset += chunk.length;
92
+ }
93
+ return result;
94
+ };
77
95
 
78
- const isBlobRef = (value: unknown): value is BlobRef => {
79
- if (typeof value !== 'object' || value === null) {
80
- return false;
96
+ /** Collect a ReadableStream into a single Uint8Array. */
97
+ const collectStream = async (
98
+ stream: ReadableStream<Uint8Array>
99
+ ): Promise<Uint8Array> => {
100
+ const reader = stream.getReader();
101
+ const chunks: Uint8Array[] = [];
102
+ let totalLength = 0;
103
+ for (;;) {
104
+ const { done, value } = await reader.read();
105
+ if (done) {
106
+ break;
107
+ }
108
+ chunks.push(value);
109
+ totalLength += value.length;
81
110
  }
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
- );
111
+ return concatChunks(chunks, totalLength);
112
+ };
113
+
114
+ /** Resolve BlobRef data to Uint8Array (handles ReadableStream). */
115
+ const resolveBlobData = (blob: BlobRef): Promise<Uint8Array> | Uint8Array => {
116
+ if (blob.data instanceof ReadableStream) {
117
+ return collectStream(blob.data);
118
+ }
119
+ return blob.data;
88
120
  };
89
121
 
90
122
  const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
@@ -96,10 +128,11 @@ const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
96
128
  return btoa(binary);
97
129
  };
98
130
 
99
- const blobToContent = (blob: BlobRef): McpContent => {
131
+ const blobToContent = async (blob: BlobRef): Promise<McpContent> => {
132
+ const bytes = await resolveBlobData(blob);
100
133
  if (blob.mimeType.startsWith('image/')) {
101
134
  return {
102
- data: uint8ArrayToBase64(blob.data),
135
+ data: uint8ArrayToBase64(bytes),
103
136
  mimeType: blob.mimeType,
104
137
  type: 'image',
105
138
  };
@@ -108,25 +141,25 @@ const blobToContent = (blob: BlobRef): McpContent => {
108
141
  return {
109
142
  mimeType: blob.mimeType,
110
143
  type: 'resource',
111
- uri: `blob://${blob.name ?? 'unnamed'}`,
144
+ uri: `blob://${blob.name}`,
112
145
  };
113
146
  };
114
147
 
115
148
  /** Separate blob fields from non-blob fields in an object. */
116
- const separateBlobFields = (
149
+ const separateBlobFields = async (
117
150
  obj: Record<string, unknown>
118
- ): {
151
+ ): Promise<{
119
152
  blobContents: McpContent[];
120
153
  hasBlobFields: boolean;
121
154
  textFields: Record<string, unknown>;
122
- } => {
155
+ }> => {
123
156
  const blobContents: McpContent[] = [];
124
157
  const textFields: Record<string, unknown> = {};
125
158
  let hasBlobFields = false;
126
159
  for (const [key, val] of Object.entries(obj)) {
127
160
  if (isBlobRef(val)) {
128
161
  hasBlobFields = true;
129
- blobContents.push(blobToContent(val));
162
+ blobContents.push(await blobToContent(val));
130
163
  } else {
131
164
  textFields[key] = val;
132
165
  }
@@ -135,10 +168,11 @@ const separateBlobFields = (
135
168
  };
136
169
 
137
170
  /** Serialize a mixed blob/text object to MCP content. */
138
- const serializeMixedObject = (
171
+ const serializeMixedObject = async (
139
172
  obj: Record<string, unknown>
140
- ): readonly McpContent[] | undefined => {
141
- const { blobContents, hasBlobFields, textFields } = separateBlobFields(obj);
173
+ ): Promise<readonly McpContent[] | undefined> => {
174
+ const { blobContents, hasBlobFields, textFields } =
175
+ await separateBlobFields(obj);
142
176
  if (!hasBlobFields) {
143
177
  return undefined;
144
178
  }
@@ -148,12 +182,14 @@ const serializeMixedObject = (
148
182
  return blobContents;
149
183
  };
150
184
 
151
- const serializeOutput = (value: unknown): readonly McpContent[] => {
185
+ const serializeOutput = async (
186
+ value: unknown
187
+ ): Promise<readonly McpContent[]> => {
152
188
  if (isBlobRef(value)) {
153
- return [blobToContent(value)];
189
+ return [await blobToContent(value)];
154
190
  }
155
191
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
156
- const mixed = serializeMixedObject(value as Record<string, unknown>);
192
+ const mixed = await serializeMixedObject(value as Record<string, unknown>);
157
193
  if (mixed) {
158
194
  return mixed;
159
195
  }
@@ -171,48 +207,9 @@ const mcpError = (message: string): McpToolResult => ({
171
207
  isError: true,
172
208
  });
173
209
 
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();
183
-
184
- const signal = extra.signal ?? baseContext.signal;
185
- const progressCb = createMcpProgressCallback(extra);
186
-
187
- return {
188
- ...baseContext,
189
- signal,
190
- ...(progressCb === undefined ? {} : { progress: progressCb }),
191
- };
192
- };
193
-
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) };
206
- }
207
- return mcpError(result.error.message);
208
- } catch (error: unknown) {
209
- return mcpError(error instanceof Error ? error.message : String(error));
210
- }
211
- };
212
-
213
210
  const createHandler =
214
211
  (
215
- trail: Trail<unknown, unknown>,
212
+ t: Trail<unknown, unknown>,
216
213
  layers: readonly Layer[],
217
214
  options: BuildMcpToolsOptions
218
215
  ): ((
@@ -220,12 +217,18 @@ const createHandler =
220
217
  extra: McpExtra
221
218
  ) => Promise<McpToolResult>) =>
222
219
  async (args, extra): Promise<McpToolResult> => {
223
- const validated = validateInput(trail.input, args);
224
- if (validated.isErr()) {
225
- return mcpError(validated.error.message);
220
+ const progressCb = createMcpProgressCallback(extra);
221
+ const result = await executeTrail(t, args, {
222
+ createContext: options.createContext,
223
+ ctx: progressCb === undefined ? undefined : { progress: progressCb },
224
+ layers,
225
+ services: options.services,
226
+ signal: extra.signal,
227
+ });
228
+ if (result.isOk()) {
229
+ return { content: await serializeOutput(result.value) };
226
230
  }
227
- const ctx = await buildTrailContext(options, extra);
228
- return executeAndMap(trail, validated.value, ctx, layers);
231
+ return mcpError(result.error.message);
229
232
  };
230
233
 
231
234
  // ---------------------------------------------------------------------------
@@ -238,15 +241,15 @@ const createHandler =
238
241
  * Each trail in the topo becomes an McpToolDefinition with:
239
242
  * - A derived tool name (app-prefixed, underscore-delimited)
240
243
  * - JSON Schema input from zodToJsonSchema
241
- * - MCP annotations from trail markers
244
+ * - MCP annotations from trail metadata
242
245
  * - A handler that validates, composes layers, executes, and maps results
243
246
  */
244
- /** Check if a trail should be included based on markers and filters. */
247
+ /** Check if a trail should be included based on metadata and filters. */
245
248
  const shouldInclude = (
246
249
  trail: Trail<unknown, unknown>,
247
250
  options: BuildMcpToolsOptions
248
251
  ): boolean => {
249
- if (trail.markers?.['internal'] === true) {
252
+ if (trail.metadata?.['internal'] === true) {
250
253
  return false;
251
254
  }
252
255
  if (options.includeTrails !== undefined && options.includeTrails.length > 0) {
@@ -295,27 +298,61 @@ const buildToolDefinition = (
295
298
  handler: createHandler(trail, layers, options),
296
299
  inputSchema: zodToJsonSchema(trail.input),
297
300
  name: deriveToolName(app.name, trail.id),
301
+ trailId: trail.id,
298
302
  };
299
303
  };
300
304
 
305
+ /** Register a trail as an MCP tool, checking for name collisions. */
306
+ const registerTool = (
307
+ app: Topo,
308
+ trailItem: Trail<unknown, unknown>,
309
+ layers: readonly Layer[],
310
+ options: BuildMcpToolsOptions,
311
+ nameToTrailId: Map<string, string>,
312
+ tools: McpToolDefinition[]
313
+ ): Result<void, Error> => {
314
+ const toolName = deriveToolName(app.name, trailItem.id);
315
+ const existingId = nameToTrailId.get(toolName);
316
+ if (existingId !== undefined) {
317
+ return Result.err(
318
+ new ValidationError(
319
+ `MCP tool-name collision: trails "${existingId}" and "${trailItem.id}" both derive the tool name "${toolName}"`
320
+ )
321
+ );
322
+ }
323
+ nameToTrailId.set(toolName, trailItem.id);
324
+ tools.push(buildToolDefinition(app, trailItem, layers, options));
325
+ return Result.ok();
326
+ };
327
+
328
+ /** Filter topo items to eligible trails. */
329
+ const eligibleTrails = (
330
+ app: Topo,
331
+ options: BuildMcpToolsOptions
332
+ ): Trail<unknown, unknown>[] =>
333
+ app.list().filter((trail) => shouldInclude(trail, options));
334
+
301
335
  export const buildMcpTools = (
302
336
  app: Topo,
303
337
  options: BuildMcpToolsOptions = {}
304
- ): McpToolDefinition[] => {
338
+ ): Result<McpToolDefinition[], Error> => {
305
339
  const layers = options.layers ?? [];
306
340
  const tools: McpToolDefinition[] = [];
341
+ const nameToTrailId = new Map<string, string>();
307
342
 
308
- for (const item of app.list()) {
309
- if (item.kind !== 'trail' && item.kind !== 'hike') {
310
- continue;
311
- }
312
- if (!shouldInclude(item as Trail<unknown, unknown>, options)) {
313
- continue;
314
- }
315
- tools.push(
316
- buildToolDefinition(app, item as Trail<unknown, unknown>, layers, options)
343
+ for (const trailItem of eligibleTrails(app, options)) {
344
+ const registered = registerTool(
345
+ app,
346
+ trailItem,
347
+ layers,
348
+ options,
349
+ nameToTrailId,
350
+ tools
317
351
  );
352
+ if (registered.isErr()) {
353
+ return registered;
354
+ }
318
355
  }
319
356
 
320
- return tools;
357
+ return Result.ok(tools);
321
358
  };