@ontrails/mcp 1.0.0-beta.0 → 1.0.0-beta.10

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