@ontrails/mcp 1.0.0-beta.1 → 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.
@@ -3,36 +3,39 @@ import { describe, expect, test } from 'bun:test';
3
3
  import { deriveAnnotations } from '../annotations.js';
4
4
 
5
5
  describe('deriveAnnotations', () => {
6
- test('readOnly trail produces readOnlyHint', () => {
7
- const annotations = deriveAnnotations({ readOnly: true });
6
+ test('read intent produces readOnlyHint', () => {
7
+ const annotations = deriveAnnotations({ intent: 'read' });
8
8
  expect(annotations.readOnlyHint).toBe(true);
9
9
  expect(annotations.destructiveHint).toBeUndefined();
10
10
  expect(annotations.idempotentHint).toBeUndefined();
11
11
  });
12
12
 
13
- test('destructive trail produces destructiveHint', () => {
14
- const annotations = deriveAnnotations({ destructive: true });
13
+ test('destroy intent produces destructiveHint', () => {
14
+ const annotations = deriveAnnotations({ intent: 'destroy' });
15
15
  expect(annotations.destructiveHint).toBe(true);
16
16
  expect(annotations.readOnlyHint).toBeUndefined();
17
17
  });
18
18
 
19
19
  test('idempotent trail produces idempotentHint', () => {
20
- const annotations = deriveAnnotations({ idempotent: true });
20
+ const annotations = deriveAnnotations({
21
+ idempotent: true,
22
+ intent: 'write',
23
+ });
21
24
  expect(annotations.idempotentHint).toBe(true);
22
25
  });
23
26
 
24
- test('multiple markers combine correctly', () => {
27
+ test('read intent with idempotent combines correctly', () => {
25
28
  const annotations = deriveAnnotations({
26
29
  idempotent: true,
27
- readOnly: true,
30
+ intent: 'read',
28
31
  });
29
32
  expect(annotations.readOnlyHint).toBe(true);
30
33
  expect(annotations.idempotentHint).toBe(true);
31
34
  expect(annotations.destructiveHint).toBeUndefined();
32
35
  });
33
36
 
34
- test('no markers produces empty annotations', () => {
35
- const annotations = deriveAnnotations({});
37
+ test('write intent produces empty annotations', () => {
38
+ const annotations = deriveAnnotations({ intent: 'write' });
36
39
  expect(annotations.readOnlyHint).toBeUndefined();
37
40
  expect(annotations.destructiveHint).toBeUndefined();
38
41
  expect(annotations.idempotentHint).toBeUndefined();
@@ -42,29 +45,19 @@ describe('deriveAnnotations', () => {
42
45
  test('description maps to title', () => {
43
46
  const annotations = deriveAnnotations({
44
47
  description: 'Show entity details',
48
+ intent: 'write',
45
49
  });
46
50
  expect(annotations.title).toBe('Show entity details');
47
51
  });
48
52
 
49
- test('all markers plus description', () => {
53
+ test('all hints plus description', () => {
50
54
  const annotations = deriveAnnotations({
51
55
  description: 'A trail',
52
- destructive: true,
53
56
  idempotent: true,
54
- readOnly: true,
57
+ intent: 'destroy',
55
58
  });
56
- expect(annotations.readOnlyHint).toBe(true);
57
59
  expect(annotations.destructiveHint).toBe(true);
58
60
  expect(annotations.idempotentHint).toBe(true);
59
61
  expect(annotations.title).toBe('A trail');
60
62
  });
61
-
62
- test('false values are not included', () => {
63
- const annotations = deriveAnnotations({
64
- destructive: false,
65
- readOnly: false,
66
- });
67
- expect(annotations.readOnlyHint).toBeUndefined();
68
- expect(annotations.destructiveHint).toBeUndefined();
69
- });
70
63
  });
@@ -3,14 +3,15 @@ import { describe, expect, test } from 'bun:test';
3
3
  import { Result, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
- import { createMcpServer } from '../blaze.js';
6
+ import { blaze, createMcpServer } from '../blaze.js';
7
7
  import { buildMcpTools } from '../build.js';
8
+ import type { McpToolDefinition } from '../build.js';
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Tests
11
12
  // ---------------------------------------------------------------------------
12
13
 
13
- const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
14
+ const requireTool = (tools: McpToolDefinition[], name: string) => {
14
15
  const tool = tools.find((entry) => entry.name === name);
15
16
  expect(tool).toBeDefined();
16
17
  if (!tool) {
@@ -19,35 +20,79 @@ const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
19
20
  return tool;
20
21
  };
21
22
 
23
+ /**
24
+ * Unwrap buildMcpTools result, throwing on error so test failures surface clearly.
25
+ */
26
+ const buildTools = (
27
+ ...args: Parameters<typeof buildMcpTools>
28
+ ): McpToolDefinition[] => {
29
+ const result = buildMcpTools(...args);
30
+ if (result.isErr()) {
31
+ throw result.error;
32
+ }
33
+ return result.value;
34
+ };
35
+
22
36
  const createIntegrationTools = () => {
23
37
  const greetTrail = trail('greet', {
24
38
  description: 'Greet someone',
25
- implementation: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
26
39
  input: z.object({ name: z.string() }),
27
- readOnly: true,
40
+ intent: 'read',
41
+ run: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
28
42
  });
29
43
 
30
44
  const deleteTrail = trail('item.delete', {
31
45
  description: 'Delete an item',
32
- destructive: true,
33
- implementation: (_input) => Result.ok({ deleted: true }),
34
46
  input: z.object({ id: z.string() }),
47
+ intent: 'destroy',
48
+ run: (_input) => Result.ok({ deleted: true }),
35
49
  });
36
50
 
37
- return buildMcpTools(topo('myapp', { deleteTrail, greetTrail }));
51
+ return buildTools(topo('myapp', { deleteTrail, greetTrail }));
38
52
  };
39
53
 
40
54
  describe('blaze', () => {
55
+ test('blaze throws on invalid topo', async () => {
56
+ const t = trail('broken', {
57
+ follow: ['nonexistent.trail'],
58
+ input: z.object({}),
59
+ output: z.object({}),
60
+ run: () => Result.ok({}),
61
+ });
62
+ const app = topo('test', { t });
63
+ await expect(blaze(app)).rejects.toThrow(/validation/i);
64
+ });
65
+
66
+ test('blaze skips validation when validate: false', async () => {
67
+ const t = trail('broken', {
68
+ follow: ['nonexistent.trail'],
69
+ input: z.object({}),
70
+ output: z.object({}),
71
+ run: () => Result.ok({}),
72
+ });
73
+ const app = topo('test', { t });
74
+ const result = await Promise.race([
75
+ blaze(app, { validate: false }).then(() => 'resolved' as const),
76
+ // oxlint-disable-next-line avoid-new -- Promise constructor needed for setTimeout-based timeout
77
+ new Promise<'timeout'>((resolve) => {
78
+ setTimeout(() => {
79
+ resolve('timeout');
80
+ }, 50);
81
+ }),
82
+ ]);
83
+ expect(['resolved', 'timeout']).toContain(result);
84
+ });
85
+
41
86
  test('createMcpServer registers tools that can be listed', () => {
42
87
  const echoTrail = trail('echo', {
43
88
  description: 'Echo',
44
- implementation: (input) => Result.ok({ reply: input.message }),
45
89
  input: z.object({ message: z.string() }),
46
- readOnly: true,
90
+ intent: 'read',
91
+ run: (input) => Result.ok({ reply: input.message }),
47
92
  });
48
93
 
49
94
  const app = topo('testapp', { echoTrail });
50
- const tools = buildMcpTools(app);
95
+ const tools = buildTools(app);
51
96
  const server = createMcpServer(tools, {
52
97
  name: 'testapp',
53
98
  version: '0.1.0',
@@ -60,19 +105,19 @@ describe('blaze', () => {
60
105
  test('createMcpServer handles multiple tools', () => {
61
106
  const echoTrail = trail('echo', {
62
107
  description: 'Echo',
63
- implementation: (input) => Result.ok({ reply: input.message }),
64
108
  input: z.object({ message: z.string() }),
109
+ run: (input) => Result.ok({ reply: input.message }),
65
110
  });
66
111
 
67
112
  const searchTrail = trail('search', {
68
113
  description: 'Search',
69
- implementation: (input) => Result.ok({ results: [input.query] }),
70
114
  input: z.object({ query: z.string() }),
71
- readOnly: true,
115
+ intent: 'read',
116
+ run: (input) => Result.ok({ results: [input.query] }),
72
117
  });
73
118
 
74
119
  const app = topo('testapp', { echoTrail, searchTrail });
75
- const tools = buildMcpTools(app);
120
+ const tools = buildTools(app);
76
121
 
77
122
  expect(tools).toHaveLength(2);
78
123
 
@@ -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, 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,13 @@ 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
46
  });
47
47
 
48
48
  const noExtra: McpExtra = {};
49
49
 
50
- const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
50
+ const requireTool = (tools: McpToolDefinition[], name: string) => {
51
51
  const tool = tools.find((entry) => entry.name === name);
52
52
  expect(tool).toBeDefined();
53
53
  if (!tool) {
@@ -56,7 +56,7 @@ const requireTool = (tools: ReturnType<typeof buildMcpTools>, name: string) => {
56
56
  return tool;
57
57
  };
58
58
 
59
- const requireOnlyTool = (tools: ReturnType<typeof buildMcpTools>) => {
59
+ const requireOnlyTool = (tools: McpToolDefinition[]) => {
60
60
  expect(tools).toHaveLength(1);
61
61
  const [tool] = tools;
62
62
  expect(tool).toBeDefined();
@@ -66,6 +66,20 @@ const requireOnlyTool = (tools: ReturnType<typeof buildMcpTools>) => {
66
66
  return tool;
67
67
  };
68
68
 
69
+ /**
70
+ * Unwrap buildMcpTools result for success-path tests.
71
+ * Throws if the result is an error so test failures surface clearly.
72
+ */
73
+ const buildTools = (
74
+ ...args: Parameters<typeof buildMcpTools>
75
+ ): McpToolDefinition[] => {
76
+ const result = buildMcpTools(...args);
77
+ if (result.isErr()) {
78
+ throw result.error;
79
+ }
80
+ return result.value;
81
+ };
82
+
69
83
  const parseJsonContent = (
70
84
  content: { readonly text?: string | undefined } | undefined
71
85
  ): unknown => {
@@ -81,7 +95,7 @@ describe('buildMcpTools', () => {
81
95
  describe('discovery', () => {
82
96
  test('builds tools from a single-trail app', () => {
83
97
  const app = topo('myapp', { echoTrail });
84
- const tools = buildMcpTools(app);
98
+ const tools = buildTools(app);
85
99
 
86
100
  expect(tools).toHaveLength(1);
87
101
  expect(requireOnlyTool(tools).name).toBe('myapp_echo');
@@ -89,7 +103,7 @@ describe('buildMcpTools', () => {
89
103
 
90
104
  test('builds tools from a multi-trail app', () => {
91
105
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
92
- const tools = buildMcpTools(app);
106
+ const tools = buildTools(app);
93
107
 
94
108
  expect(tools).toHaveLength(3);
95
109
  const names = tools.map((t) => t.name);
@@ -100,14 +114,14 @@ describe('buildMcpTools', () => {
100
114
 
101
115
  test('tool names follow derivation rules', () => {
102
116
  const app = topo('myapp', { deleteTrail });
103
- const tools = buildMcpTools(app);
117
+ const tools = buildTools(app);
104
118
 
105
119
  expect(requireOnlyTool(tools).name).toBe('myapp_item_delete');
106
120
  });
107
121
 
108
122
  test('input schema is valid JSON Schema', () => {
109
123
  const app = topo('myapp', { echoTrail });
110
- const schema = requireOnlyTool(buildMcpTools(app)).inputSchema;
124
+ const schema = requireOnlyTool(buildTools(app)).inputSchema;
111
125
 
112
126
  expect(schema['type']).toBe('object');
113
127
  expect(schema['properties']).toBeDefined();
@@ -117,7 +131,7 @@ describe('buildMcpTools', () => {
117
131
 
118
132
  test('annotations are correctly derived', () => {
119
133
  const app = topo('myapp', { deleteTrail, echoTrail });
120
- const tools = buildMcpTools(app);
134
+ const tools = buildTools(app);
121
135
 
122
136
  expect(requireTool(tools, 'myapp_echo').annotations?.readOnlyHint).toBe(
123
137
  true
@@ -129,12 +143,22 @@ describe('buildMcpTools', () => {
129
143
  requireTool(tools, 'myapp_item_delete').annotations?.destructiveHint
130
144
  ).toBe(true);
131
145
  });
146
+
147
+ test('trailId identifies the source trail', () => {
148
+ const app = topo('myapp', { deleteTrail, echoTrail });
149
+ const tools = buildTools(app);
150
+
151
+ expect(requireTool(tools, 'myapp_echo').trailId).toBe('echo');
152
+ expect(requireTool(tools, 'myapp_item_delete').trailId).toBe(
153
+ 'item.delete'
154
+ );
155
+ });
132
156
  });
133
157
 
134
158
  describe('handler execution', () => {
135
159
  test('handler validates input and returns isError on invalid', async () => {
136
160
  const app = topo('myapp', { echoTrail });
137
- const tool = requireOnlyTool(buildMcpTools(app));
161
+ const tool = requireOnlyTool(buildTools(app));
138
162
 
139
163
  const result = await tool.handler({ notMessage: 123 }, noExtra);
140
164
  expect(result?.isError).toBe(true);
@@ -144,7 +168,7 @@ describe('buildMcpTools', () => {
144
168
 
145
169
  test('handler calls implementation and returns result as text content', async () => {
146
170
  const app = topo('myapp', { echoTrail });
147
- const tool = requireOnlyTool(buildMcpTools(app));
171
+ const tool = requireOnlyTool(buildTools(app));
148
172
 
149
173
  const result = await tool.handler({ message: 'hello' }, noExtra);
150
174
  expect(result?.isError).toBeUndefined();
@@ -156,7 +180,7 @@ describe('buildMcpTools', () => {
156
180
 
157
181
  test('handler maps errors to isError content', async () => {
158
182
  const app = topo('myapp', { failTrail });
159
- const tool = requireOnlyTool(buildMcpTools(app));
183
+ const tool = requireOnlyTool(buildTools(app));
160
184
 
161
185
  const result = await tool.handler({ reason: 'broken' }, noExtra);
162
186
  expect(result?.isError).toBe(true);
@@ -165,15 +189,13 @@ describe('buildMcpTools', () => {
165
189
 
166
190
  test('handler catches thrown exceptions', async () => {
167
191
  const throwTrail = trail('throw', {
168
- implementation: () => {
192
+ input: z.object({}),
193
+ run: () => {
169
194
  throw new Error('unexpected crash');
170
195
  },
171
- input: z.object({}),
172
196
  });
173
197
 
174
- const tool = requireOnlyTool(
175
- buildMcpTools(topo('myapp', { throwTrail }))
176
- );
198
+ const tool = requireOnlyTool(buildTools(topo('myapp', { throwTrail })));
177
199
  const result = await tool.handler({}, noExtra);
178
200
 
179
201
  expect(result?.isError).toBe(true);
@@ -184,7 +206,7 @@ describe('buildMcpTools', () => {
184
206
  describe('filters', () => {
185
207
  test('include filter limits which trails become tools', () => {
186
208
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
187
- const tools = buildMcpTools(app, {
209
+ const tools = buildTools(app, {
188
210
  includeTrails: ['echo'],
189
211
  });
190
212
 
@@ -194,7 +216,7 @@ describe('buildMcpTools', () => {
194
216
 
195
217
  test('exclude filter removes specific trails', () => {
196
218
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
197
- const tools = buildMcpTools(app, {
219
+ const tools = buildTools(app, {
198
220
  excludeTrails: ['fail'],
199
221
  });
200
222
 
@@ -205,7 +227,7 @@ describe('buildMcpTools', () => {
205
227
 
206
228
  test('include takes precedence over exclude', () => {
207
229
  const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
208
- const tools = buildMcpTools(app, {
230
+ const tools = buildTools(app, {
209
231
  excludeTrails: ['fail'],
210
232
  includeTrails: ['echo', 'fail'],
211
233
  });
@@ -234,7 +256,7 @@ describe('buildMcpTools', () => {
234
256
  };
235
257
 
236
258
  const app = topo('myapp', { echoTrail });
237
- const tool = requireOnlyTool(buildMcpTools(app, { layers: [testLayer] }));
259
+ const tool = requireOnlyTool(buildTools(app, { layers: [testLayer] }));
238
260
 
239
261
  await tool.handler({ message: 'hi' }, noExtra);
240
262
  expect(calls).toEqual(['before', 'after']);
@@ -244,17 +266,15 @@ describe('buildMcpTools', () => {
244
266
  let capturedSignal: AbortSignal | undefined;
245
267
 
246
268
  const signalTrail = trail('signal.check', {
247
- implementation: (_input, ctx) => {
269
+ input: z.object({}),
270
+ run: (_input, ctx) => {
248
271
  capturedSignal = ctx.signal;
249
272
  return Result.ok({ ok: true });
250
273
  },
251
- input: z.object({}),
252
274
  });
253
275
 
254
276
  const controller = new AbortController();
255
- const tool = requireOnlyTool(
256
- buildMcpTools(topo('myapp', { signalTrail }))
257
- );
277
+ const tool = requireOnlyTool(buildTools(topo('myapp', { signalTrail })));
258
278
 
259
279
  await tool.handler({}, { signal: controller.signal });
260
280
  expect(capturedSignal).toBe(controller.signal);
@@ -262,7 +282,7 @@ describe('buildMcpTools', () => {
262
282
 
263
283
  test('description includes first example input when present', () => {
264
284
  const app = topo('myapp', { exampleTrail });
265
- const tool = requireOnlyTool(buildMcpTools(app));
285
+ const tool = requireOnlyTool(buildTools(app));
266
286
 
267
287
  expect(tool.description).toContain('A trail with examples');
268
288
  expect(tool.description).toContain('"name":"world"');
@@ -272,19 +292,18 @@ describe('buildMcpTools', () => {
272
292
  let contextUsed = false;
273
293
 
274
294
  const ctxTrail = trail('ctx.check', {
275
- implementation: (_input, ctx) => {
276
- const ctxRecord = ctx as Record<string, unknown>;
277
- contextUsed = ctxRecord['custom'] === true;
295
+ input: z.object({}),
296
+ run: (_input, ctx) => {
297
+ contextUsed = ctx.extensions?.['custom'] === true;
278
298
  return Result.ok({ ok: true });
279
299
  },
280
- input: z.object({}),
281
300
  });
282
301
 
283
302
  const app = topo('myapp', { ctxTrail });
284
303
  const tool = requireOnlyTool(
285
- buildMcpTools(app, {
304
+ buildTools(app, {
286
305
  createContext: () => ({
287
- custom: true,
306
+ extensions: { custom: true },
288
307
  requestId: 'test-id',
289
308
  signal: new AbortController().signal,
290
309
  }),
@@ -299,17 +318,19 @@ describe('buildMcpTools', () => {
299
318
  describe('blob outputs', () => {
300
319
  test('BlobRef output converts to image content', async () => {
301
320
  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
321
  input: z.object({}),
322
+ run: () =>
323
+ Result.ok(
324
+ createBlobRef({
325
+ data: new Uint8Array([1, 2, 3]),
326
+ mimeType: 'image/png',
327
+ name: 'test.png',
328
+ size: 3,
329
+ })
330
+ ),
310
331
  });
311
332
 
312
- const tool = requireOnlyTool(buildMcpTools(topo('myapp', { blobTrail })));
333
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
313
334
  const result = await tool.handler({}, noExtra);
314
335
 
315
336
  expect(result?.content[0]?.type).toBe('image');
@@ -319,23 +340,103 @@ describe('buildMcpTools', () => {
319
340
 
320
341
  test('BlobRef output converts to resource content for non-images', async () => {
321
342
  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
343
  input: z.object({}),
344
+ run: () =>
345
+ Result.ok(
346
+ createBlobRef({
347
+ data: new Uint8Array([1, 2, 3]),
348
+ mimeType: 'application/pdf',
349
+ name: 'doc.pdf',
350
+ size: 3,
351
+ })
352
+ ),
330
353
  });
331
354
 
332
- const tool = requireOnlyTool(buildMcpTools(topo('myapp', { blobTrail })));
355
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
333
356
  const result = await tool.handler({}, noExtra);
334
357
 
335
358
  expect(result?.content[0]?.type).toBe('resource');
336
359
  expect(result?.content[0]?.uri).toBe('blob://doc.pdf');
337
360
  expect(result?.content[0]?.mimeType).toBe('application/pdf');
338
361
  });
362
+
363
+ test('BlobRef with ReadableStream data is collected and serialized', async () => {
364
+ const bytes = new Uint8Array([10, 20, 30]);
365
+ const stream = new ReadableStream<Uint8Array>({
366
+ start(controller) {
367
+ controller.enqueue(bytes);
368
+ controller.close();
369
+ },
370
+ });
371
+ const blobTrail = trail('blob.stream', {
372
+ input: z.object({}),
373
+ run: () =>
374
+ Result.ok(
375
+ createBlobRef({
376
+ data: stream,
377
+ mimeType: 'image/gif',
378
+ name: 'anim.gif',
379
+ size: 3,
380
+ })
381
+ ),
382
+ });
383
+
384
+ const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
385
+ const result = await tool.handler({}, noExtra);
386
+
387
+ expect(result?.content[0]?.type).toBe('image');
388
+ expect(result?.content[0]?.mimeType).toBe('image/gif');
389
+ expect(result?.content[0]?.data).toBeDefined();
390
+ });
391
+ });
392
+
393
+ describe('tool-name collision detection', () => {
394
+ test('returns Err on trails that produce the same derived tool name', () => {
395
+ const dotTrail = trail('foo.bar', {
396
+ input: z.object({}),
397
+ run: () => Result.ok({ ok: true }),
398
+ });
399
+ const underscoreTrail = trail('foo_bar', {
400
+ input: z.object({}),
401
+ run: () => Result.ok({ ok: true }),
402
+ });
403
+
404
+ const app = topo('myapp', { dotTrail, underscoreTrail });
405
+ const result = buildMcpTools(app);
406
+ expect(result.isErr()).toBe(true);
407
+ expect(result.error?.message).toMatch(/tool-name collision/i);
408
+ });
409
+
410
+ test('returns Err on trails where hyphen and underscore collide', () => {
411
+ const hyphenTrail = trail('foo-bar', {
412
+ input: z.object({}),
413
+ run: () => Result.ok({ ok: true }),
414
+ });
415
+ const underscoreTrail = trail('foo_bar', {
416
+ input: z.object({}),
417
+ run: () => Result.ok({ ok: true }),
418
+ });
419
+
420
+ const app = topo('myapp', { hyphenTrail, underscoreTrail });
421
+ const result = buildMcpTools(app);
422
+ expect(result.isErr()).toBe(true);
423
+ expect(result.error?.message).toMatch(/tool-name collision/i);
424
+ });
425
+
426
+ test('returns Ok when trail names are distinct after normalization', () => {
427
+ const fooTrail = trail('foo', {
428
+ input: z.object({}),
429
+ run: () => Result.ok({ ok: true }),
430
+ });
431
+ const barTrail = trail('bar', {
432
+ input: z.object({}),
433
+ run: () => Result.ok({ ok: true }),
434
+ });
435
+
436
+ const app = topo('myapp', { barTrail, fooTrail });
437
+ const result = buildMcpTools(app);
438
+ expect(result.isOk()).toBe(true);
439
+ });
339
440
  });
340
441
 
341
442
  describe('end-to-end', () => {
@@ -343,16 +444,13 @@ describe('buildMcpTools', () => {
343
444
  const greetTrail = trail('greet', {
344
445
  description: 'Greet someone',
345
446
  idempotent: true,
346
- implementation: (input) =>
347
- Result.ok({ greeting: `Hello, ${input.name}!` }),
348
447
  input: z.object({ name: z.string() }),
448
+ intent: 'read',
349
449
  output: z.object({ greeting: z.string() }),
350
- readOnly: true,
450
+ run: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
351
451
  });
352
452
 
353
- const tool = requireOnlyTool(
354
- buildMcpTools(topo('testapp', { greetTrail }))
355
- );
453
+ const tool = requireOnlyTool(buildTools(topo('testapp', { greetTrail })));
356
454
 
357
455
  expect(tool).toMatchObject({
358
456
  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;