@ontrails/mcp 1.0.0-beta.14 → 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 (51) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +36 -17
  3. package/package.json +11 -3
  4. package/src/annotations.ts +4 -1
  5. package/src/build.ts +689 -127
  6. package/src/index.ts +14 -4
  7. package/src/stdio.ts +1 -1
  8. package/src/surface.ts +215 -0
  9. package/.turbo/turbo-build.log +0 -1
  10. package/.turbo/turbo-lint.log +0 -3
  11. package/.turbo/turbo-typecheck.log +0 -1
  12. package/dist/annotations.d.ts +0 -19
  13. package/dist/annotations.d.ts.map +0 -1
  14. package/dist/annotations.js +0 -31
  15. package/dist/annotations.js.map +0 -1
  16. package/dist/blaze.d.ts +0 -41
  17. package/dist/blaze.d.ts.map +0 -1
  18. package/dist/blaze.js +0 -108
  19. package/dist/blaze.js.map +0 -1
  20. package/dist/build.d.ts +0 -48
  21. package/dist/build.d.ts.map +0 -1
  22. package/dist/build.js +0 -229
  23. package/dist/build.js.map +0 -1
  24. package/dist/index.d.ts +0 -7
  25. package/dist/index.d.ts.map +0 -1
  26. package/dist/index.js +0 -13
  27. package/dist/index.js.map +0 -1
  28. package/dist/progress.d.ts +0 -13
  29. package/dist/progress.d.ts.map +0 -1
  30. package/dist/progress.js +0 -51
  31. package/dist/progress.js.map +0 -1
  32. package/dist/stdio.d.ts +0 -12
  33. package/dist/stdio.d.ts.map +0 -1
  34. package/dist/stdio.js +0 -15
  35. package/dist/stdio.js.map +0 -1
  36. package/dist/tool-name.d.ts +0 -15
  37. package/dist/tool-name.d.ts.map +0 -1
  38. package/dist/tool-name.js +0 -19
  39. package/dist/tool-name.js.map +0 -1
  40. package/dist/trailhead.d.ts +0 -41
  41. package/dist/trailhead.d.ts.map +0 -1
  42. package/dist/trailhead.js +0 -109
  43. package/dist/trailhead.js.map +0 -1
  44. package/src/__tests__/annotations.test.ts +0 -63
  45. package/src/__tests__/build.test.ts +0 -529
  46. package/src/__tests__/progress.test.ts +0 -136
  47. package/src/__tests__/tool-name.test.ts +0 -46
  48. package/src/__tests__/trailhead.test.ts +0 -158
  49. package/src/trailhead.ts +0 -173
  50. package/tsconfig.json +0 -9
  51. package/tsconfig.tsbuildinfo +0 -1
@@ -1,63 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import { deriveAnnotations } from '../annotations.js';
4
-
5
- describe('deriveAnnotations', () => {
6
- test('read intent produces readOnlyHint', () => {
7
- const annotations = deriveAnnotations({ intent: 'read' });
8
- expect(annotations.readOnlyHint).toBe(true);
9
- expect(annotations.destructiveHint).toBeUndefined();
10
- expect(annotations.idempotentHint).toBeUndefined();
11
- });
12
-
13
- test('destroy intent produces destructiveHint', () => {
14
- const annotations = deriveAnnotations({ intent: 'destroy' });
15
- expect(annotations.destructiveHint).toBe(true);
16
- expect(annotations.readOnlyHint).toBeUndefined();
17
- });
18
-
19
- test('idempotent trail produces idempotentHint', () => {
20
- const annotations = deriveAnnotations({
21
- idempotent: true,
22
- intent: 'write',
23
- });
24
- expect(annotations.idempotentHint).toBe(true);
25
- });
26
-
27
- test('read intent with idempotent combines correctly', () => {
28
- const annotations = deriveAnnotations({
29
- idempotent: true,
30
- intent: 'read',
31
- });
32
- expect(annotations.readOnlyHint).toBe(true);
33
- expect(annotations.idempotentHint).toBe(true);
34
- expect(annotations.destructiveHint).toBeUndefined();
35
- });
36
-
37
- test('write intent produces empty annotations', () => {
38
- const annotations = deriveAnnotations({ intent: 'write' });
39
- expect(annotations.readOnlyHint).toBeUndefined();
40
- expect(annotations.destructiveHint).toBeUndefined();
41
- expect(annotations.idempotentHint).toBeUndefined();
42
- expect(annotations.title).toBeUndefined();
43
- });
44
-
45
- test('description maps to title', () => {
46
- const annotations = deriveAnnotations({
47
- description: 'Show entity details',
48
- intent: 'write',
49
- });
50
- expect(annotations.title).toBe('Show entity details');
51
- });
52
-
53
- test('all hints plus description', () => {
54
- const annotations = deriveAnnotations({
55
- description: 'A trail',
56
- idempotent: true,
57
- intent: 'destroy',
58
- });
59
- expect(annotations.destructiveHint).toBe(true);
60
- expect(annotations.idempotentHint).toBe(true);
61
- expect(annotations.title).toBe('A trail');
62
- });
63
- });
@@ -1,529 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import {
4
- Result,
5
- TRAILHEAD_KEY,
6
- createBlobRef,
7
- provision,
8
- trail,
9
- topo,
10
- } from '@ontrails/core';
11
- import type { Gate } from '@ontrails/core';
12
- import { z } from 'zod';
13
-
14
- import { buildMcpTools } from '../build.js';
15
- import type { McpExtra, McpToolDefinition } from '../build.js';
16
-
17
- // ---------------------------------------------------------------------------
18
- // Helpers
19
- // ---------------------------------------------------------------------------
20
-
21
- const echoTrail = trail('echo', {
22
- blaze: (input) => Result.ok({ reply: input.message }),
23
- description: 'Echo a message back',
24
- input: z.object({ message: z.string() }),
25
- intent: 'read',
26
- output: z.object({ reply: z.string() }),
27
- });
28
-
29
- const deleteTrail = trail('item.delete', {
30
- blaze: (_input) => Result.ok({ deleted: true }),
31
- description: 'Delete an item',
32
- input: z.object({ id: z.string() }),
33
- intent: 'destroy',
34
- });
35
-
36
- const failTrail = trail('fail', {
37
- blaze: (input) => Result.err(new Error(input.reason)),
38
- description: 'Always fails',
39
- input: z.object({ reason: z.string() }),
40
- });
41
-
42
- const exampleTrail = trail('with.examples', {
43
- blaze: (input) => Result.ok({ greeting: `hello ${input.name}` }),
44
- description: 'A trail with examples',
45
- examples: [
46
- {
47
- expected: { greeting: 'hello world' },
48
- input: { name: 'world' },
49
- name: 'basic',
50
- },
51
- ],
52
- input: z.object({ name: z.string() }),
53
- });
54
-
55
- const dbProvision = provision('db.main', {
56
- create: () =>
57
- Result.ok({
58
- source: 'factory',
59
- }),
60
- });
61
-
62
- const noExtra: McpExtra = {};
63
-
64
- const requireTool = (tools: McpToolDefinition[], name: string) => {
65
- const tool = tools.find((entry) => entry.name === name);
66
- expect(tool).toBeDefined();
67
- if (!tool) {
68
- throw new Error(`Expected tool: ${name}`);
69
- }
70
- return tool;
71
- };
72
-
73
- const requireOnlyTool = (tools: McpToolDefinition[]) => {
74
- expect(tools).toHaveLength(1);
75
- const [tool] = tools;
76
- expect(tool).toBeDefined();
77
- if (!tool) {
78
- throw new Error('Expected one MCP tool');
79
- }
80
- return tool;
81
- };
82
-
83
- /**
84
- * Unwrap buildMcpTools result for success-path tests.
85
- * Throws if the result is an error so test failures show up clearly.
86
- */
87
- const buildTools = (
88
- ...args: Parameters<typeof buildMcpTools>
89
- ): McpToolDefinition[] => {
90
- const result = buildMcpTools(...args);
91
- if (result.isErr()) {
92
- throw result.error;
93
- }
94
- return result.value;
95
- };
96
-
97
- const parseJsonContent = (
98
- content: { readonly text?: string | undefined } | undefined
99
- ): unknown => {
100
- expect(content?.text).toBeDefined();
101
- return JSON.parse(content?.text ?? 'null');
102
- };
103
-
104
- // ---------------------------------------------------------------------------
105
- // Tests
106
- // ---------------------------------------------------------------------------
107
-
108
- describe('buildMcpTools', () => {
109
- describe('discovery', () => {
110
- test('builds tools from a single-trail app', () => {
111
- const app = topo('myapp', { echoTrail });
112
- const tools = buildTools(app);
113
-
114
- expect(tools).toHaveLength(1);
115
- expect(requireOnlyTool(tools).name).toBe('myapp_echo');
116
- });
117
-
118
- test('builds tools from a multi-trail app', () => {
119
- const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
120
- const tools = buildTools(app);
121
-
122
- expect(tools).toHaveLength(3);
123
- const names = tools.map((t) => t.name);
124
- expect(names).toContain('myapp_echo');
125
- expect(names).toContain('myapp_item_delete');
126
- expect(names).toContain('myapp_fail');
127
- });
128
-
129
- test('tool names follow derivation rules', () => {
130
- const app = topo('myapp', { deleteTrail });
131
- const tools = buildTools(app);
132
-
133
- expect(requireOnlyTool(tools).name).toBe('myapp_item_delete');
134
- });
135
-
136
- test('input schema is valid JSON Schema', () => {
137
- const app = topo('myapp', { echoTrail });
138
- const schema = requireOnlyTool(buildTools(app)).inputSchema;
139
-
140
- expect(schema['type']).toBe('object');
141
- expect(schema['properties']).toBeDefined();
142
- const props = schema['properties'] as Record<string, unknown>;
143
- expect(props['message']).toEqual({ type: 'string' });
144
- });
145
-
146
- test('annotations are correctly derived', () => {
147
- const app = topo('myapp', { deleteTrail, echoTrail });
148
- const tools = buildTools(app);
149
-
150
- expect(requireTool(tools, 'myapp_echo').annotations?.readOnlyHint).toBe(
151
- true
152
- );
153
- expect(requireTool(tools, 'myapp_echo').annotations?.title).toBe(
154
- 'Echo a message back'
155
- );
156
- expect(
157
- requireTool(tools, 'myapp_item_delete').annotations?.destructiveHint
158
- ).toBe(true);
159
- });
160
-
161
- test('trailId identifies the source trail', () => {
162
- const app = topo('myapp', { deleteTrail, echoTrail });
163
- const tools = buildTools(app);
164
-
165
- expect(requireTool(tools, 'myapp_echo').trailId).toBe('echo');
166
- expect(requireTool(tools, 'myapp_item_delete').trailId).toBe(
167
- 'item.delete'
168
- );
169
- });
170
- });
171
-
172
- describe('handler execution', () => {
173
- test('handler validates input and returns isError on invalid', async () => {
174
- const app = topo('myapp', { echoTrail });
175
- const tool = requireOnlyTool(buildTools(app));
176
-
177
- const result = await tool.handler({ notMessage: 123 }, noExtra);
178
- expect(result?.isError).toBe(true);
179
- expect(result?.content[0]?.type).toBe('text');
180
- expect(result?.content[0]?.text).toBeDefined();
181
- });
182
-
183
- test('handler calls implementation and returns result as text content', async () => {
184
- const app = topo('myapp', { echoTrail });
185
- const tool = requireOnlyTool(buildTools(app));
186
-
187
- const result = await tool.handler({ message: 'hello' }, noExtra);
188
- expect(result?.isError).toBeUndefined();
189
- expect(result?.content[0]?.type).toBe('text');
190
- expect(parseJsonContent(result?.content[0])).toEqual({
191
- reply: 'hello',
192
- });
193
- });
194
-
195
- test('handler maps errors to isError content', async () => {
196
- const app = topo('myapp', { failTrail });
197
- const tool = requireOnlyTool(buildTools(app));
198
-
199
- const result = await tool.handler({ reason: 'broken' }, noExtra);
200
- expect(result?.isError).toBe(true);
201
- expect(result?.content[0]?.text).toBe('broken');
202
- });
203
-
204
- test('handler catches thrown exceptions', async () => {
205
- const throwTrail = trail('throw', {
206
- blaze: () => {
207
- throw new Error('unexpected crash');
208
- },
209
- input: z.object({}),
210
- });
211
-
212
- const tool = requireOnlyTool(buildTools(topo('myapp', { throwTrail })));
213
- const result = await tool.handler({}, noExtra);
214
-
215
- expect(result?.isError).toBe(true);
216
- expect(result?.content[0]?.text).toBe('unexpected crash');
217
- });
218
- });
219
-
220
- describe('filters', () => {
221
- test('include filter limits which trails become tools', () => {
222
- const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
223
- const tools = buildTools(app, {
224
- includeTrails: ['echo'],
225
- });
226
-
227
- expect(tools).toHaveLength(1);
228
- expect(requireOnlyTool(tools).name).toBe('myapp_echo');
229
- });
230
-
231
- test('exclude filter removes specific trails', () => {
232
- const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
233
- const tools = buildTools(app, {
234
- excludeTrails: ['fail'],
235
- });
236
-
237
- expect(tools).toHaveLength(2);
238
- const names = tools.map((t) => t.name);
239
- expect(names).not.toContain('myapp_fail');
240
- });
241
-
242
- test('include takes precedence over exclude', () => {
243
- const app = topo('myapp', { deleteTrail, echoTrail, failTrail });
244
- const tools = buildTools(app, {
245
- excludeTrails: ['fail'],
246
- includeTrails: ['echo', 'fail'],
247
- });
248
-
249
- expect(tools).toHaveLength(2);
250
- const names = tools.map((t) => t.name);
251
- expect(names).toContain('myapp_echo');
252
- expect(names).toContain('myapp_fail');
253
- });
254
- });
255
-
256
- describe('composition', () => {
257
- test('gates compose and execute around the implementation', async () => {
258
- const calls: string[] = [];
259
-
260
- const testGate: Gate = {
261
- name: 'test-gate',
262
- wrap(_trail, impl) {
263
- return async (input, ctx) => {
264
- calls.push('before');
265
- const result = await impl(input, ctx);
266
- calls.push('after');
267
- return result;
268
- };
269
- },
270
- };
271
-
272
- const app = topo('myapp', { echoTrail });
273
- const tool = requireOnlyTool(buildTools(app, { gates: [testGate] }));
274
-
275
- await tool.handler({ message: 'hi' }, noExtra);
276
- expect(calls).toEqual(['before', 'after']);
277
- });
278
-
279
- test('AbortSignal propagates from MCP extra to TrailContext', async () => {
280
- let capturedSignal: AbortSignal | undefined;
281
-
282
- const signalTrail = trail('signal.check', {
283
- blaze: (_input, ctx) => {
284
- capturedSignal = ctx.abortSignal;
285
- return Result.ok({ ok: true });
286
- },
287
- input: z.object({}),
288
- });
289
-
290
- const controller = new AbortController();
291
- const tool = requireOnlyTool(buildTools(topo('myapp', { signalTrail })));
292
-
293
- await tool.handler({}, { abortSignal: controller.signal });
294
- expect(capturedSignal).toBe(controller.signal);
295
- });
296
-
297
- test('description includes first example input when present', () => {
298
- const app = topo('myapp', { exampleTrail });
299
- const tool = requireOnlyTool(buildTools(app));
300
-
301
- expect(tool.description).toContain('A trail with examples');
302
- expect(tool.description).toContain('"name":"world"');
303
- });
304
-
305
- test('custom createContext is used when provided', async () => {
306
- let contextUsed = false;
307
- let trailheadMarkerUsed = false;
308
-
309
- const ctxTrail = trail('ctx.check', {
310
- blaze: (_input, ctx) => {
311
- contextUsed = ctx.extensions?.['custom'] === true;
312
- trailheadMarkerUsed = ctx.extensions?.[TRAILHEAD_KEY] === 'mcp';
313
- return Result.ok({ ok: true });
314
- },
315
- input: z.object({}),
316
- });
317
-
318
- const app = topo('myapp', { ctxTrail });
319
- const tool = requireOnlyTool(
320
- buildTools(app, {
321
- createContext: () => ({
322
- abortSignal: new AbortController().signal,
323
- extensions: { custom: true },
324
- requestId: 'test-id',
325
- }),
326
- })
327
- );
328
-
329
- await tool.handler({}, noExtra);
330
- expect(contextUsed).toBe(true);
331
- expect(trailheadMarkerUsed).toBe(true);
332
- });
333
-
334
- test('provision overrides are forwarded to executeTrail', async () => {
335
- const provisionTrail = trail('provision.check', {
336
- blaze: (_input, ctx) =>
337
- Result.ok({ source: dbProvision.from(ctx).source as string }),
338
- input: z.object({}),
339
- output: z.object({ source: z.string() }),
340
- provisions: [dbProvision],
341
- });
342
-
343
- const tool = requireOnlyTool(
344
- buildTools(topo('myapp', { dbProvision, provisionTrail }), {
345
- provisions: { 'db.main': { source: 'override' } },
346
- })
347
- );
348
-
349
- const result = await tool.handler({}, noExtra);
350
- expect(result?.isError).toBeUndefined();
351
- expect(parseJsonContent(result?.content[0])).toEqual({
352
- source: 'override',
353
- });
354
- });
355
- });
356
-
357
- describe('blob outputs', () => {
358
- test('BlobRef output converts to image content', async () => {
359
- const blobTrail = trail('blob.image', {
360
- blaze: () =>
361
- Result.ok(
362
- createBlobRef({
363
- data: new Uint8Array([1, 2, 3]),
364
- mimeType: 'image/png',
365
- name: 'test.png',
366
- size: 3,
367
- })
368
- ),
369
- input: z.object({}),
370
- });
371
-
372
- const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
373
- const result = await tool.handler({}, noExtra);
374
-
375
- expect(result?.content[0]?.type).toBe('image');
376
- expect(result?.content[0]?.mimeType).toBe('image/png');
377
- expect(result?.content[0]?.data).toBeDefined();
378
- });
379
-
380
- test('BlobRef output converts to resource content for non-images', async () => {
381
- const blobTrail = trail('blob.file', {
382
- blaze: () =>
383
- Result.ok(
384
- createBlobRef({
385
- data: new Uint8Array([1, 2, 3]),
386
- mimeType: 'application/pdf',
387
- name: 'doc.pdf',
388
- size: 3,
389
- })
390
- ),
391
- input: z.object({}),
392
- });
393
-
394
- const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
395
- const result = await tool.handler({}, noExtra);
396
-
397
- expect(result?.content[0]?.type).toBe('resource');
398
- expect(result?.content[0]?.uri).toBe('blob://doc.pdf');
399
- expect(result?.content[0]?.mimeType).toBe('application/pdf');
400
- });
401
-
402
- test('BlobRef with ReadableStream data is collected and serialized', async () => {
403
- const bytes = new Uint8Array([10, 20, 30]);
404
- const stream = new ReadableStream<Uint8Array>({
405
- start(controller) {
406
- controller.enqueue(bytes);
407
- controller.close();
408
- },
409
- });
410
- const blobTrail = trail('blob.stream', {
411
- blaze: () =>
412
- Result.ok(
413
- createBlobRef({
414
- data: stream,
415
- mimeType: 'image/gif',
416
- name: 'anim.gif',
417
- size: 3,
418
- })
419
- ),
420
- input: z.object({}),
421
- });
422
-
423
- const tool = requireOnlyTool(buildTools(topo('myapp', { blobTrail })));
424
- const result = await tool.handler({}, noExtra);
425
-
426
- expect(result?.content[0]?.type).toBe('image');
427
- expect(result?.content[0]?.mimeType).toBe('image/gif');
428
- expect(result?.content[0]?.data).toBeDefined();
429
- });
430
- });
431
-
432
- describe('tool-name collision detection', () => {
433
- test('returns Err on trails that produce the same derived tool name', () => {
434
- const dotTrail = trail('foo.bar', {
435
- blaze: () => Result.ok({ ok: true }),
436
- input: z.object({}),
437
- });
438
- const underscoreTrail = trail('foo_bar', {
439
- blaze: () => Result.ok({ ok: true }),
440
- input: z.object({}),
441
- });
442
-
443
- const app = topo('myapp', { dotTrail, underscoreTrail });
444
- const result = buildMcpTools(app);
445
- expect(result.isErr()).toBe(true);
446
- expect(result.error?.message).toMatch(/tool-name collision/i);
447
- });
448
-
449
- test('returns Err on trails where hyphen and underscore collide', () => {
450
- const hyphenTrail = trail('foo-bar', {
451
- blaze: () => Result.ok({ ok: true }),
452
- input: z.object({}),
453
- });
454
- const underscoreTrail = trail('foo_bar', {
455
- blaze: () => Result.ok({ ok: true }),
456
- input: z.object({}),
457
- });
458
-
459
- const app = topo('myapp', { hyphenTrail, underscoreTrail });
460
- const result = buildMcpTools(app);
461
- expect(result.isErr()).toBe(true);
462
- expect(result.error?.message).toMatch(/tool-name collision/i);
463
- });
464
-
465
- test('returns Ok when trail names are distinct after normalization', () => {
466
- const fooTrail = trail('foo', {
467
- blaze: () => Result.ok({ ok: true }),
468
- input: z.object({}),
469
- });
470
- const barTrail = trail('bar', {
471
- blaze: () => Result.ok({ ok: true }),
472
- input: z.object({}),
473
- });
474
-
475
- const app = topo('myapp', { barTrail, fooTrail });
476
- const result = buildMcpTools(app);
477
- expect(result.isOk()).toBe(true);
478
- });
479
- });
480
-
481
- describe('end-to-end', () => {
482
- test('full pipeline from trail to MCP response', async () => {
483
- const greetTrail = trail('greet', {
484
- blaze: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
485
- description: 'Greet someone',
486
- idempotent: true,
487
- input: z.object({ name: z.string() }),
488
- intent: 'read',
489
- output: z.object({ greeting: z.string() }),
490
- });
491
-
492
- const tool = requireOnlyTool(buildTools(topo('testapp', { greetTrail })));
493
-
494
- expect(tool).toMatchObject({
495
- annotations: {
496
- idempotentHint: true,
497
- readOnlyHint: true,
498
- },
499
- description: 'Greet someone',
500
- name: 'testapp_greet',
501
- });
502
- expect(tool.inputSchema['type']).toBe('object');
503
-
504
- const successResult = await tool.handler({ name: 'World' }, noExtra);
505
- expect(successResult?.isError).toBeUndefined();
506
- expect(parseJsonContent(successResult?.content[0])).toEqual({
507
- greeting: 'Hello, World!',
508
- });
509
-
510
- const errorResult = await tool.handler({}, noExtra);
511
- expect(errorResult?.isError).toBe(true);
512
- });
513
- });
514
-
515
- describe('established graph enforcement', () => {
516
- test('returns Err when draft contamination remains', () => {
517
- const draftTrail = trail('entity.export', {
518
- blaze: () => Result.ok({ ok: true }),
519
- crosses: ['_draft.entity.prepare'],
520
- input: z.object({}),
521
- });
522
-
523
- const result = buildMcpTools(topo('myapp', { draftTrail }));
524
-
525
- expect(result.isErr()).toBe(true);
526
- expect(result.error?.message).toMatch(/draft/i);
527
- });
528
- });
529
- });