@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.
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
- import { Result, trail, topo } from '@ontrails/core';
3
+ import { Result, createBlobRef, service, trail, topo } from '@ontrails/core';
4
4
  import type { Layer } from '@ontrails/core';
5
5
  import { z } from 'zod';
6
6
 
7
7
  import { buildMcpTools } from '../build.js';
8
- import type { McpExtra } from '../build.js';
8
+ import type { McpExtra, McpToolDefinition } from '../build.js';
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
11
  // Helpers
@@ -13,23 +13,23 @@ import type { McpExtra } from '../build.js';
13
13
 
14
14
  const echoTrail = trail('echo', {
15
15
  description: 'Echo a message back',
16
- implementation: (input) => Result.ok({ reply: input.message }),
17
16
  input: z.object({ message: z.string() }),
17
+ intent: 'read',
18
18
  output: z.object({ reply: z.string() }),
19
- readOnly: true,
19
+ run: (input) => Result.ok({ reply: input.message }),
20
20
  });
21
21
 
22
22
  const deleteTrail = trail('item.delete', {
23
23
  description: 'Delete an item',
24
- destructive: true,
25
- implementation: (_input) => Result.ok({ deleted: true }),
26
24
  input: z.object({ id: z.string() }),
25
+ intent: 'destroy',
26
+ run: (_input) => Result.ok({ deleted: true }),
27
27
  });
28
28
 
29
29
  const failTrail = trail('fail', {
30
30
  description: 'Always fails',
31
- implementation: (input) => Result.err(new Error(input.reason)),
32
31
  input: z.object({ reason: z.string() }),
32
+ run: (input) => Result.err(new Error(input.reason)),
33
33
  });
34
34
 
35
35
  const exampleTrail = trail('with.examples', {
@@ -41,13 +41,20 @@ const exampleTrail = trail('with.examples', {
41
41
  name: 'basic',
42
42
  },
43
43
  ],
44
- implementation: (input) => Result.ok({ greeting: `hello ${input.name}` }),
45
44
  input: z.object({ name: z.string() }),
45
+ run: (input) => Result.ok({ greeting: `hello ${input.name}` }),
46
+ });
47
+
48
+ const dbService = service('db.main', {
49
+ create: () =>
50
+ Result.ok({
51
+ source: 'factory',
52
+ }),
46
53
  });
47
54
 
48
55
  const noExtra: McpExtra = {};
49
56
 
50
- const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
57
+ const requireTool = (tools: McpToolDefinition[], name: string) => {
51
58
  const tool = tools.find((entry) => entry.name === name);
52
59
  expect(tool).toBeDefined();
53
60
  if (!tool) {
@@ -56,7 +63,7 @@ const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
56
63
  return tool;
57
64
  };
58
65
 
59
- const requireOnlyTool = (tools: ReturnType<typeof buildMcpTools>) => {
66
+ const requireOnlyTool = (tools: McpToolDefinition[]) => {
60
67
  expect(tools).toHaveLength(1);
61
68
  const [tool] = tools;
62
69
  expect(tool).toBeDefined();
@@ -66,6 +73,20 @@ const requireOnlyTool = (tools: ReturnType<typeof buildMcpTools>) => {
66
73
  return tool;
67
74
  };
68
75
 
76
+ /**
77
+ * Unwrap buildMcpTools result for success-path tests.
78
+ * Throws if the result is an error so test failures surface clearly.
79
+ */
80
+ const buildTools = (
81
+ ...args: Parameters<typeof buildMcpTools>
82
+ ): McpToolDefinition[] => {
83
+ const result = buildMcpTools(...args);
84
+ if (result.isErr()) {
85
+ throw result.error;
86
+ }
87
+ return result.value;
88
+ };
89
+
69
90
  const parseJsonContent = (
70
91
  content: { readonly text?: string | undefined } | undefined
71
92
  ): unknown => {
@@ -81,7 +102,7 @@ describe('buildMcpTools', () => {
81
102
  describe('discovery', () => {
82
103
  test('builds tools from a single-trail app', () => {
83
104
  const app = topo('myapp', { echoTrail });
84
- const tools = buildMcpTools(app);
105
+ const tools = buildTools(app);
85
106
 
86
107
  expect(tools).toHaveLength(1);
87
108
  expect(requireOnlyTool(tools).name).toBe('myapp_echo');
@@ -89,7 +110,7 @@ describe('buildMcpTools', () => {
89
110
 
90
111
  test('builds tools from a multi-trail app', () => {
91
112
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
92
- const tools = buildMcpTools(app);
113
+ const tools = buildTools(app);
93
114
 
94
115
  expect(tools).toHaveLength(3);
95
116
  const names = tools.map((t) => t.name);
@@ -100,14 +121,14 @@ describe('buildMcpTools', () => {
100
121
 
101
122
  test('tool names follow derivation rules', () => {
102
123
  const app = topo('myapp', { deleteTrail });
103
- const tools = buildMcpTools(app);
124
+ const tools = buildTools(app);
104
125
 
105
126
  expect(requireOnlyTool(tools).name).toBe('myapp_item_delete');
106
127
  });
107
128
 
108
129
  test('input schema is valid JSON Schema', () => {
109
130
  const app = topo('myapp', { echoTrail });
110
- const schema = requireOnlyTool(buildMcpTools(app)).inputSchema;
131
+ const schema = requireOnlyTool(buildTools(app)).inputSchema;
111
132
 
112
133
  expect(schema['type']).toBe('object');
113
134
  expect(schema['properties']).toBeDefined();
@@ -117,7 +138,7 @@ describe('buildMcpTools', () => {
117
138
 
118
139
  test('annotations are correctly derived', () => {
119
140
  const app = topo('myapp', { deleteTrail, echoTrail });
120
- const tools = buildMcpTools(app);
141
+ const tools = buildTools(app);
121
142
 
122
143
  expect(requireTool(tools, 'myapp_echo').annotations?.readOnlyHint).toBe(
123
144
  true
@@ -129,12 +150,22 @@ describe('buildMcpTools', () => {
129
150
  requireTool(tools, 'myapp_item_delete').annotations?.destructiveHint
130
151
  ).toBe(true);
131
152
  });
153
+
154
+ test('trailId identifies the source trail', () => {
155
+ const app = topo('myapp', { deleteTrail, echoTrail });
156
+ const tools = buildTools(app);
157
+
158
+ expect(requireTool(tools, 'myapp_echo').trailId).toBe('echo');
159
+ expect(requireTool(tools, 'myapp_item_delete').trailId).toBe(
160
+ 'item.delete'
161
+ );
162
+ });
132
163
  });
133
164
 
134
165
  describe('handler execution', () => {
135
166
  test('handler validates input and returns isError on invalid', async () => {
136
167
  const app = topo('myapp', { echoTrail });
137
- const tool = requireOnlyTool(buildMcpTools(app));
168
+ const tool = requireOnlyTool(buildTools(app));
138
169
 
139
170
  const result = await tool.handler({ notMessage: 123 }, noExtra);
140
171
  expect(result?.isError).toBe(true);
@@ -144,7 +175,7 @@ describe('buildMcpTools', () => {
144
175
 
145
176
  test('handler calls implementation and returns result as text content', async () => {
146
177
  const app = topo('myapp', { echoTrail });
147
- const tool = requireOnlyTool(buildMcpTools(app));
178
+ const tool = requireOnlyTool(buildTools(app));
148
179
 
149
180
  const result = await tool.handler({ message: 'hello' }, noExtra);
150
181
  expect(result?.isError).toBeUndefined();
@@ -156,7 +187,7 @@ describe('buildMcpTools', () => {
156
187
 
157
188
  test('handler maps errors to isError content', async () => {
158
189
  const app = topo('myapp', { failTrail });
159
- const tool = requireOnlyTool(buildMcpTools(app));
190
+ const tool = requireOnlyTool(buildTools(app));
160
191
 
161
192
  const result = await tool.handler({ reason: 'broken' }, noExtra);
162
193
  expect(result?.isError).toBe(true);
@@ -165,15 +196,13 @@ describe('buildMcpTools', () => {
165
196
 
166
197
  test('handler catches thrown exceptions', async () => {
167
198
  const throwTrail = trail('throw', {
168
- implementation: () => {
199
+ input: z.object({}),
200
+ run: () => {
169
201
  throw new Error('unexpected crash');
170
202
  },
171
- input: z.object({}),
172
203
  });
173
204
 
174
- const tool = requireOnlyTool(
175
- buildMcpTools(topo('myapp', { throwTrail }))
176
- );
205
+ const tool = requireOnlyTool(buildTools(topo('myapp', { throwTrail })));
177
206
  const result = await tool.handler({}, noExtra);
178
207
 
179
208
  expect(result?.isError).toBe(true);
@@ -184,7 +213,7 @@ describe('buildMcpTools', () => {
184
213
  describe('filters', () => {
185
214
  test('include filter limits which trails become tools', () => {
186
215
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
187
- const tools = buildMcpTools(app, {
216
+ const tools = buildTools(app, {
188
217
  includeTrails: ['echo'],
189
218
  });
190
219
 
@@ -194,7 +223,7 @@ describe('buildMcpTools', () => {
194
223
 
195
224
  test('exclude filter removes specific trails', () => {
196
225
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
197
- const tools = buildMcpTools(app, {
226
+ const tools = buildTools(app, {
198
227
  excludeTrails: ['fail'],
199
228
  });
200
229
 
@@ -205,7 +234,7 @@ describe('buildMcpTools', () => {
205
234
 
206
235
  test('include takes precedence over exclude', () => {
207
236
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
208
- const tools = buildMcpTools(app, {
237
+ const tools = buildTools(app, {
209
238
  excludeTrails: ['fail'],
210
239
  includeTrails: ['echo', 'fail'],
211
240
  });
@@ -234,7 +263,7 @@ describe('buildMcpTools', () => {
234
263
  };
235
264
 
236
265
  const app = topo('myapp', { echoTrail });
237
- const tool = requireOnlyTool(buildMcpTools(app, { layers: [testLayer] }));
266
+ const tool = requireOnlyTool(buildTools(app, { layers: [testLayer] }));
238
267
 
239
268
  await tool.handler({ message: 'hi' }, noExtra);
240
269
  expect(calls).toEqual(['before', 'after']);
@@ -244,17 +273,15 @@ describe('buildMcpTools', () => {
244
273
  let capturedSignal: AbortSignal | undefined;
245
274
 
246
275
  const signalTrail = trail('signal.check', {
247
- implementation: (_input, ctx) => {
276
+ input: z.object({}),
277
+ run: (_input, ctx) => {
248
278
  capturedSignal = ctx.signal;
249
279
  return Result.ok({ ok: true });
250
280
  },
251
- input: z.object({}),
252
281
  });
253
282
 
254
283
  const controller = new AbortController();
255
- const tool = requireOnlyTool(
256
- buildMcpTools(topo('myapp', { signalTrail }))
257
- );
284
+ const tool = requireOnlyTool(buildTools(topo('myapp', { signalTrail })));
258
285
 
259
286
  await tool.handler({}, { signal: controller.signal });
260
287
  expect(capturedSignal).toBe(controller.signal);
@@ -262,7 +289,7 @@ describe('buildMcpTools', () => {
262
289
 
263
290
  test('description includes first example input when present', () => {
264
291
  const app = topo('myapp', { exampleTrail });
265
- const tool = requireOnlyTool(buildMcpTools(app));
292
+ const tool = requireOnlyTool(buildTools(app));
266
293
 
267
294
  expect(tool.description).toContain('A trail with examples');
268
295
  expect(tool.description).toContain('"name":"world"');
@@ -272,19 +299,18 @@ describe('buildMcpTools', () => {
272
299
  let contextUsed = false;
273
300
 
274
301
  const ctxTrail = trail('ctx.check', {
275
- implementation: (_input, ctx) => {
276
- const ctxRecord = ctx as Record<string, unknown>;
277
- contextUsed = ctxRecord['custom'] === true;
302
+ input: z.object({}),
303
+ run: (_input, ctx) => {
304
+ contextUsed = ctx.extensions?.['custom'] === true;
278
305
  return Result.ok({ ok: true });
279
306
  },
280
- input: z.object({}),
281
307
  });
282
308
 
283
309
  const app = topo('myapp', { ctxTrail });
284
310
  const tool = requireOnlyTool(
285
- buildMcpTools(app, {
311
+ buildTools(app, {
286
312
  createContext: () => ({
287
- custom: true,
313
+ extensions: { custom: true },
288
314
  requestId: 'test-id',
289
315
  signal: new AbortController().signal,
290
316
  }),
@@ -294,22 +320,46 @@ describe('buildMcpTools', () => {
294
320
  await tool.handler({}, noExtra);
295
321
  expect(contextUsed).toBe(true);
296
322
  });
323
+
324
+ test('service overrides are forwarded to executeTrail', async () => {
325
+ const serviceTrail = trail('service.check', {
326
+ input: z.object({}),
327
+ output: z.object({ source: z.string() }),
328
+ run: (_input, ctx) =>
329
+ Result.ok({ source: dbService.from(ctx).source as string }),
330
+ services: [dbService],
331
+ });
332
+
333
+ const tool = requireOnlyTool(
334
+ buildTools(topo('myapp', { serviceTrail }), {
335
+ services: { 'db.main': { source: 'override' } },
336
+ })
337
+ );
338
+
339
+ const result = await tool.handler({}, noExtra);
340
+ expect(result?.isError).toBeUndefined();
341
+ expect(parseJsonContent(result?.content[0])).toEqual({
342
+ source: 'override',
343
+ });
344
+ });
297
345
  });
298
346
 
299
347
  describe('blob outputs', () => {
300
348
  test('BlobRef output converts to image content', async () => {
301
349
  const blobTrail = trail('blob.image', {
302
- implementation: () =>
303
- Result.ok({
304
- data: new Uint8Array([1, 2, 3]),
305
- kind: 'blob' as const,
306
- mimeType: 'image/png',
307
- name: 'test.png',
308
- }),
309
350
  input: z.object({}),
351
+ run: () =>
352
+ Result.ok(
353
+ createBlobRef({
354
+ data: new Uint8Array([1, 2, 3]),
355
+ mimeType: 'image/png',
356
+ name: 'test.png',
357
+ size: 3,
358
+ })
359
+ ),
310
360
  });
311
361
 
312
- const tool = requireOnlyTool(buildMcpTools(topo('myapp', { blobTrail })));
362
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
313
363
  const result = await tool.handler({}, noExtra);
314
364
 
315
365
  expect(result?.content[0]?.type).toBe('image');
@@ -319,23 +369,103 @@ describe('buildMcpTools', () => {
319
369
 
320
370
  test('BlobRef output converts to resource content for non-images', async () => {
321
371
  const blobTrail = trail('blob.file', {
322
- implementation: () =>
323
- Result.ok({
324
- data: new Uint8Array([1, 2, 3]),
325
- kind: 'blob' as const,
326
- mimeType: 'application/pdf',
327
- name: 'doc.pdf',
328
- }),
329
372
  input: z.object({}),
373
+ run: () =>
374
+ Result.ok(
375
+ createBlobRef({
376
+ data: new Uint8Array([1, 2, 3]),
377
+ mimeType: 'application/pdf',
378
+ name: 'doc.pdf',
379
+ size: 3,
380
+ })
381
+ ),
330
382
  });
331
383
 
332
- const tool = requireOnlyTool(buildMcpTools(topo('myapp', { blobTrail })));
384
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
333
385
  const result = await tool.handler({}, noExtra);
334
386
 
335
387
  expect(result?.content[0]?.type).toBe('resource');
336
388
  expect(result?.content[0]?.uri).toBe('blob://doc.pdf');
337
389
  expect(result?.content[0]?.mimeType).toBe('application/pdf');
338
390
  });
391
+
392
+ test('BlobRef with ReadableStream data is collected and serialized', async () => {
393
+ const bytes = new Uint8Array([10, 20, 30]);
394
+ const stream = new ReadableStream<Uint8Array>({
395
+ start(controller) {
396
+ controller.enqueue(bytes);
397
+ controller.close();
398
+ },
399
+ });
400
+ const blobTrail = trail('blob.stream', {
401
+ input: z.object({}),
402
+ run: () =>
403
+ Result.ok(
404
+ createBlobRef({
405
+ data: stream,
406
+ mimeType: 'image/gif',
407
+ name: 'anim.gif',
408
+ size: 3,
409
+ })
410
+ ),
411
+ });
412
+
413
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
414
+ const result = await tool.handler({}, noExtra);
415
+
416
+ expect(result?.content[0]?.type).toBe('image');
417
+ expect(result?.content[0]?.mimeType).toBe('image/gif');
418
+ expect(result?.content[0]?.data).toBeDefined();
419
+ });
420
+ });
421
+
422
+ describe('tool-name collision detection', () => {
423
+ test('returns Err on trails that produce the same derived tool name', () => {
424
+ const dotTrail = trail('foo.bar', {
425
+ input: z.object({}),
426
+ run: () => Result.ok({ ok: true }),
427
+ });
428
+ const underscoreTrail = trail('foo_bar', {
429
+ input: z.object({}),
430
+ run: () => Result.ok({ ok: true }),
431
+ });
432
+
433
+ const app = topo('myapp', { dotTrail, underscoreTrail });
434
+ const result = buildMcpTools(app);
435
+ expect(result.isErr()).toBe(true);
436
+ expect(result.error?.message).toMatch(/tool-name collision/i);
437
+ });
438
+
439
+ test('returns Err on trails where hyphen and underscore collide', () => {
440
+ const hyphenTrail = trail('foo-bar', {
441
+ input: z.object({}),
442
+ run: () => Result.ok({ ok: true }),
443
+ });
444
+ const underscoreTrail = trail('foo_bar', {
445
+ input: z.object({}),
446
+ run: () => Result.ok({ ok: true }),
447
+ });
448
+
449
+ const app = topo('myapp', { hyphenTrail, underscoreTrail });
450
+ const result = buildMcpTools(app);
451
+ expect(result.isErr()).toBe(true);
452
+ expect(result.error?.message).toMatch(/tool-name collision/i);
453
+ });
454
+
455
+ test('returns Ok when trail names are distinct after normalization', () => {
456
+ const fooTrail = trail('foo', {
457
+ input: z.object({}),
458
+ run: () => Result.ok({ ok: true }),
459
+ });
460
+ const barTrail = trail('bar', {
461
+ input: z.object({}),
462
+ run: () => Result.ok({ ok: true }),
463
+ });
464
+
465
+ const app = topo('myapp', { barTrail, fooTrail });
466
+ const result = buildMcpTools(app);
467
+ expect(result.isOk()).toBe(true);
468
+ });
339
469
  });
340
470
 
341
471
  describe('end-to-end', () => {
@@ -343,16 +473,13 @@ describe('buildMcpTools', () => {
343
473
  const greetTrail = trail('greet', {
344
474
  description: 'Greet someone',
345
475
  idempotent: true,
346
- implementation: (input) =>
347
- Result.ok({ greeting: `Hello, ${input.name}!` }),
348
476
  input: z.object({ name: z.string() }),
477
+ intent: 'read',
349
478
  output: z.object({ greeting: z.string() }),
350
- readOnly: true,
479
+ run: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
351
480
  });
352
481
 
353
- const tool = requireOnlyTool(
354
- buildMcpTools(topo('testapp', { greetTrail }))
355
- );
482
+ const tool = requireOnlyTool(buildTools(topo('testapp', { greetTrail })));
356
483
 
357
484
  expect(tool).toMatchObject({
358
485
  annotations: {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Derive MCP tool annotations from trail spec markers.
2
+ * Derive MCP tool annotations from trail spec fields.
3
3
  */
4
4
 
5
- import type { Trail } from '@ontrails/core';
5
+ import type { Intent, Trail } from '@ontrails/core';
6
6
 
7
7
  // ---------------------------------------------------------------------------
8
8
  // Types
@@ -27,18 +27,18 @@ export interface McpAnnotations {
27
27
  * Omitted hints let the MCP SDK use its defaults.
28
28
  */
29
29
  export const deriveAnnotations = (
30
- trail: Pick<
31
- Trail<unknown, unknown>,
32
- 'readOnly' | 'destructive' | 'idempotent' | 'description'
33
- >
30
+ trail: Pick<Trail<unknown, unknown>, 'intent' | 'idempotent' | 'description'>
34
31
  ): McpAnnotations => {
35
32
  const annotations: Record<string, unknown> = {};
36
33
 
37
- if (trail.readOnly === true) {
38
- annotations['readOnlyHint'] = true;
39
- }
40
- if (trail.destructive === true) {
41
- annotations['destructiveHint'] = true;
34
+ const intentToHint: Partial<Record<Intent, string>> = {
35
+ destroy: 'destructiveHint',
36
+ read: 'readOnlyHint',
37
+ };
38
+
39
+ const hint = intentToHint[trail.intent];
40
+ if (hint) {
41
+ annotations[hint] = true;
42
42
  }
43
43
  if (trail.idempotent === true) {
44
44
  annotations['idempotentHint'] = true;
package/src/blaze.ts CHANGED
@@ -14,7 +14,13 @@ import {
14
14
  CallToolRequestSchema,
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js';
17
- import type { Layer, Topo, TrailContext } from '@ontrails/core';
17
+ import type {
18
+ Layer,
19
+ ServiceOverrideMap,
20
+ Topo,
21
+ TrailContextInit,
22
+ } from '@ontrails/core';
23
+ import { validateTopo } from '@ontrails/core';
18
24
 
19
25
  import type { McpToolDefinition } from './build.js';
20
26
  import { buildMcpTools } from './build.js';
@@ -26,7 +32,7 @@ import { connectStdio } from './stdio.js';
26
32
 
27
33
  export interface BlazeMcpOptions {
28
34
  readonly createContext?:
29
- | (() => TrailContext | Promise<TrailContext>)
35
+ | (() => TrailContextInit | Promise<TrailContextInit>)
30
36
  | undefined;
31
37
  readonly excludeTrails?: readonly string[] | undefined;
32
38
  readonly includeTrails?: readonly string[] | undefined;
@@ -38,6 +44,9 @@ export interface BlazeMcpOptions {
38
44
  }
39
45
  | undefined;
40
46
  readonly transport?: 'stdio' | undefined;
47
+ readonly services?: ServiceOverrideMap | undefined;
48
+ /** Set to `false` to skip topo validation at startup. Defaults to `true`. */
49
+ readonly validate?: boolean | undefined;
41
50
  }
42
51
 
43
52
  // ---------------------------------------------------------------------------
@@ -130,14 +139,26 @@ export const blaze = async (
130
139
  app: Topo,
131
140
  options: BlazeMcpOptions = {}
132
141
  ): Promise<void> => {
133
- const tools = buildMcpTools(app, {
142
+ if (options.validate !== false) {
143
+ const validated = validateTopo(app);
144
+ if (validated.isErr()) {
145
+ throw validated.error;
146
+ }
147
+ }
148
+
149
+ const toolsResult = buildMcpTools(app, {
134
150
  createContext: options.createContext,
135
151
  excludeTrails: options.excludeTrails,
136
152
  includeTrails: options.includeTrails,
137
153
  layers: options.layers,
154
+ services: options.services,
138
155
  });
139
156
 
140
- const server = createMcpServer(tools, {
157
+ if (toolsResult.isErr()) {
158
+ throw toolsResult.error;
159
+ }
160
+
161
+ const server = createMcpServer(toolsResult.value, {
141
162
  name: options.serverInfo?.name ?? app.name,
142
163
  version: options.serverInfo?.version ?? '0.1.0',
143
164
  });