@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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +146 -0
- package/README.md +16 -9
- package/dist/annotations.d.ts +2 -2
- package/dist/annotations.d.ts.map +1 -1
- package/dist/annotations.js +8 -6
- package/dist/annotations.js.map +1 -1
- package/dist/blaze.d.ts +5 -2
- package/dist/blaze.d.ts.map +1 -1
- package/dist/blaze.js +13 -2
- package/dist/blaze.js.map +1 -1
- package/dist/build.d.ts +7 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +80 -62
- package/dist/build.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/annotations.test.ts +15 -22
- package/src/__tests__/blaze.test.ts +67 -14
- package/src/__tests__/build.test.ts +188 -61
- package/src/annotations.ts +11 -11
- package/src/blaze.ts +25 -4
- package/src/build.ts +129 -92
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
contextUsed =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: {
|
package/src/annotations.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Derive MCP tool annotations from trail spec
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 {
|
|
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
|
-
| (() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|