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