@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +123 -0
- package/README.md +12 -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 +2 -0
- package/dist/blaze.d.ts.map +1 -1
- package/dist/blaze.js +12 -2
- package/dist/blaze.js.map +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +79 -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 +59 -14
- package/src/__tests__/build.test.ts +159 -61
- package/src/annotations.ts +11 -11
- package/src/blaze.ts +16 -2
- package/src/build.ts +119 -91
|
@@ -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('
|
|
7
|
-
const annotations = deriveAnnotations({
|
|
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('
|
|
14
|
-
const annotations = deriveAnnotations({
|
|
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({
|
|
20
|
+
const annotations = deriveAnnotations({
|
|
21
|
+
idempotent: true,
|
|
22
|
+
intent: 'write',
|
|
23
|
+
});
|
|
21
24
|
expect(annotations.idempotentHint).toBe(true);
|
|
22
25
|
});
|
|
23
26
|
|
|
24
|
-
test('
|
|
27
|
+
test('read intent with idempotent combines correctly', () => {
|
|
25
28
|
const annotations = deriveAnnotations({
|
|
26
29
|
idempotent: true,
|
|
27
|
-
|
|
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('
|
|
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
|
|
53
|
+
test('all hints plus description', () => {
|
|
50
54
|
const annotations = deriveAnnotations({
|
|
51
55
|
description: 'A trail',
|
|
52
|
-
destructive: true,
|
|
53
56
|
idempotent: true,
|
|
54
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
contextUsed =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: {
|
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;
|