@kernlang/mcp 3.1.6 → 3.1.7
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/dist/__tests__/transpiler-mcp-e2e.test.d.ts +6 -0
- package/dist/__tests__/transpiler-mcp-e2e.test.js +865 -0
- package/dist/__tests__/transpiler-mcp-e2e.test.js.map +1 -0
- package/dist/__tests__/transpiler-mcp-python.test.d.ts +1 -0
- package/dist/__tests__/transpiler-mcp-python.test.js +696 -0
- package/dist/__tests__/transpiler-mcp-python.test.js.map +1 -0
- package/dist/__tests__/transpiler-mcp.test.js +380 -12
- package/dist/__tests__/transpiler-mcp.test.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/transpiler-mcp-python.d.ts +3 -0
- package/dist/transpiler-mcp-python.js +453 -0
- package/dist/transpiler-mcp-python.js.map +1 -0
- package/dist/transpiler-mcp.d.ts +1 -1
- package/dist/transpiler-mcp.js +391 -83
- package/dist/transpiler-mcp.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { transpileMCPPython } from '../transpiler-mcp-python.js';
|
|
6
|
+
function node(type, props = {}, children = []) {
|
|
7
|
+
return { type, props, children, loc: { line: 1, col: 1, endLine: 1, endCol: 1 } };
|
|
8
|
+
}
|
|
9
|
+
describe('transpileMCPPython', () => {
|
|
10
|
+
it('should generate FastMCP server with tool', () => {
|
|
11
|
+
const ast = node('mcp', { name: 'TestPy' }, [
|
|
12
|
+
node('tool', { name: 'greet' }, [
|
|
13
|
+
node('description', { text: 'Say hello' }),
|
|
14
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
const result = transpileMCPPython(ast);
|
|
18
|
+
expect(result.code).toContain('from mcp.server.fastmcp import FastMCP');
|
|
19
|
+
expect(result.code).toContain('mcp = FastMCP(');
|
|
20
|
+
expect(result.code).toContain('@mcp.tool()');
|
|
21
|
+
expect(result.code).toContain('async def greet(name: str)');
|
|
22
|
+
expect(result.code).toContain('"""Say hello"""');
|
|
23
|
+
expect(result.code).toContain('logger.info');
|
|
24
|
+
});
|
|
25
|
+
it('should generate typed parameters', () => {
|
|
26
|
+
const ast = node('mcp', { name: 'TypedPy' }, [
|
|
27
|
+
node('tool', { name: 'calc' }, [
|
|
28
|
+
node('param', { name: 'value', type: 'number', required: 'true' }),
|
|
29
|
+
node('param', { name: 'label', type: 'string', required: 'false' }),
|
|
30
|
+
node('param', { name: 'count', type: 'int', default: '10' }),
|
|
31
|
+
]),
|
|
32
|
+
]);
|
|
33
|
+
const result = transpileMCPPython(ast);
|
|
34
|
+
expect(result.code).toContain('value: float');
|
|
35
|
+
expect(result.code).toContain('label: str | None = None');
|
|
36
|
+
expect(result.code).toContain('count: int = 10');
|
|
37
|
+
});
|
|
38
|
+
it('should use Python True/False for boolean defaults, not JS true/false', () => {
|
|
39
|
+
const ast = node('mcp', { name: 'BoolPy' }, [
|
|
40
|
+
node('tool', { name: 'toggle' }, [
|
|
41
|
+
node('param', { name: 'enabled', type: 'boolean', default: 'true' }),
|
|
42
|
+
node('param', { name: 'verbose', type: 'bool', default: 'false' }),
|
|
43
|
+
]),
|
|
44
|
+
]);
|
|
45
|
+
const result = transpileMCPPython(ast);
|
|
46
|
+
expect(result.code).toContain('enabled: bool = True');
|
|
47
|
+
expect(result.code).toContain('verbose: bool = False');
|
|
48
|
+
expect(result.code).not.toContain('= true');
|
|
49
|
+
expect(result.code).not.toContain('= false');
|
|
50
|
+
});
|
|
51
|
+
it('should auto-infer int when number type has integer default and constraints', () => {
|
|
52
|
+
const ast = node('mcp', { name: 'IntInfer' }, [
|
|
53
|
+
node('tool', { name: 'search' }, [
|
|
54
|
+
node('param', { name: 'limit', type: 'number', default: '10' }),
|
|
55
|
+
node('param', { name: 'offset', type: 'number', default: '0' }),
|
|
56
|
+
node('param', { name: 'ratio', type: 'number', default: '0.5' }),
|
|
57
|
+
node('guard', { type: 'validate', param: 'limit', min: '1', max: '100' }),
|
|
58
|
+
]),
|
|
59
|
+
]);
|
|
60
|
+
const result = transpileMCPPython(ast);
|
|
61
|
+
expect(result.code).toContain('limit: int = 10');
|
|
62
|
+
expect(result.code).toContain('offset: int = 0');
|
|
63
|
+
// ratio has float default, should stay float
|
|
64
|
+
expect(result.code).toContain('ratio: float = 0.5');
|
|
65
|
+
});
|
|
66
|
+
it('should generate sanitize guard with re.sub', () => {
|
|
67
|
+
const ast = node('mcp', { name: 'SanitizePy' }, [
|
|
68
|
+
node('tool', { name: 'search' }, [
|
|
69
|
+
node('param', { name: 'query', type: 'string' }),
|
|
70
|
+
node('guard', { type: 'sanitize', param: 'query' }),
|
|
71
|
+
]),
|
|
72
|
+
]);
|
|
73
|
+
const result = transpileMCPPython(ast);
|
|
74
|
+
expect(result.code).toContain('import re');
|
|
75
|
+
expect(result.code).toContain('re.sub(');
|
|
76
|
+
});
|
|
77
|
+
it('should generate pathContainment guard', () => {
|
|
78
|
+
const ast = node('mcp', { name: 'PathPy' }, [
|
|
79
|
+
node('tool', { name: 'readFile' }, [
|
|
80
|
+
node('param', { name: 'filePath', type: 'string' }),
|
|
81
|
+
node('guard', { type: 'pathContainment', param: 'filePath', allowlist: '/data' }),
|
|
82
|
+
]),
|
|
83
|
+
]);
|
|
84
|
+
const result = transpileMCPPython(ast);
|
|
85
|
+
expect(result.code).toContain('import os');
|
|
86
|
+
expect(result.code).toContain('os.path.realpath');
|
|
87
|
+
expect(result.code).toContain('Path escapes allowed directories');
|
|
88
|
+
});
|
|
89
|
+
it('should generate resource with URI template', () => {
|
|
90
|
+
const ast = node('mcp', { name: 'ResourcePy' }, [
|
|
91
|
+
node('resource', { name: 'getDocs', uri: 'docs://{docId}' }, [
|
|
92
|
+
node('description', { text: 'Fetch documentation' }),
|
|
93
|
+
]),
|
|
94
|
+
]);
|
|
95
|
+
const result = transpileMCPPython(ast);
|
|
96
|
+
expect(result.code).toContain('@mcp.resource(');
|
|
97
|
+
expect(result.code).toContain('docs://{docId}');
|
|
98
|
+
expect(result.code).toContain('docId: str');
|
|
99
|
+
});
|
|
100
|
+
it('should generate prompt', () => {
|
|
101
|
+
const ast = node('mcp', { name: 'PromptPy' }, [
|
|
102
|
+
node('prompt', { name: 'reviewCode' }, [
|
|
103
|
+
node('description', { text: 'Review code' }),
|
|
104
|
+
node('param', { name: 'code', type: 'string', required: 'true' }),
|
|
105
|
+
]),
|
|
106
|
+
]);
|
|
107
|
+
const result = transpileMCPPython(ast);
|
|
108
|
+
expect(result.code).toContain('@mcp.prompt()');
|
|
109
|
+
expect(result.code).toContain('async def reviewCode(code: str)');
|
|
110
|
+
});
|
|
111
|
+
it('should generate stdio entrypoint by default', () => {
|
|
112
|
+
const ast = node('mcp', { name: 'DefaultPy' });
|
|
113
|
+
const result = transpileMCPPython(ast);
|
|
114
|
+
expect(result.code).toContain('mcp.run(transport="stdio")');
|
|
115
|
+
});
|
|
116
|
+
it('should generate HTTP entrypoint when transport=http', () => {
|
|
117
|
+
const ast = node('mcp', { name: 'HttpPy', transport: 'streamable-http' });
|
|
118
|
+
const result = transpileMCPPython(ast);
|
|
119
|
+
expect(result.code).toContain('mcp.run(transport="streamable-http")');
|
|
120
|
+
});
|
|
121
|
+
it('should generate auth guard', () => {
|
|
122
|
+
const ast = node('mcp', { name: 'AuthPy' }, [
|
|
123
|
+
node('tool', { name: 'secret' }, [node('guard', { type: 'auth', env: 'API_KEY' })]),
|
|
124
|
+
]);
|
|
125
|
+
const result = transpileMCPPython(ast);
|
|
126
|
+
expect(result.code).toContain('os.environ.get');
|
|
127
|
+
expect(result.code).toContain('API_KEY');
|
|
128
|
+
});
|
|
129
|
+
it('should return valid TranspileResult', () => {
|
|
130
|
+
const ast = node('mcp', { name: 'ShapePy' }, [node('tool', { name: 'test' })]);
|
|
131
|
+
const result = transpileMCPPython(ast);
|
|
132
|
+
expect(result).toHaveProperty('code');
|
|
133
|
+
expect(result).toHaveProperty('sourceMap');
|
|
134
|
+
expect(result).toHaveProperty('irTokenCount');
|
|
135
|
+
expect(result).toHaveProperty('tsTokenCount');
|
|
136
|
+
expect(result).toHaveProperty('diagnostics');
|
|
137
|
+
expect(typeof result.code).toBe('string');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// ── Review fix regressions ──────────────────────────────────────────────
|
|
141
|
+
describe('transpileMCPPython review fixes', () => {
|
|
142
|
+
it('should import McpError and INTERNAL_ERROR for proper error semantics', () => {
|
|
143
|
+
const ast = node('mcp', { name: 'ErrorSemPy' }, [node('tool', { name: 'test' })]);
|
|
144
|
+
const result = transpileMCPPython(ast);
|
|
145
|
+
expect(result.code).toContain('from mcp.shared.exceptions import McpError');
|
|
146
|
+
expect(result.code).toContain('from mcp.types import INTERNAL_ERROR');
|
|
147
|
+
expect(result.code).toContain('raise McpError(INTERNAL_ERROR');
|
|
148
|
+
});
|
|
149
|
+
it('should use structured JSON logging, not flat f-strings', () => {
|
|
150
|
+
const ast = node('mcp', { name: 'LogPy' }, [node('tool', { name: 'search' })]);
|
|
151
|
+
const result = transpileMCPPython(ast);
|
|
152
|
+
expect(result.code).toContain('_JsonFormatter');
|
|
153
|
+
expect(result.code).toContain('extra={"tool":');
|
|
154
|
+
expect(result.code).not.toContain('f"tool:search called"');
|
|
155
|
+
});
|
|
156
|
+
it('should use structured logging for resources', () => {
|
|
157
|
+
const ast = node('mcp', { name: 'ResLogPy' }, [node('resource', { name: 'docs', uri: 'docs://readme' })]);
|
|
158
|
+
const result = transpileMCPPython(ast);
|
|
159
|
+
expect(result.code).toContain('extra={"resource":');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// ── Handler lang= support — Python transpiler skips TS handlers ─────────
|
|
163
|
+
describe('transpileMCPPython handler lang support', () => {
|
|
164
|
+
it('should skip handler with no lang (TS-only)', () => {
|
|
165
|
+
const ast = node('mcp', { name: 'LangSkipPy' }, [
|
|
166
|
+
node('tool', { name: 'greet' }, [
|
|
167
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
168
|
+
node('handler', { code: 'return { content: [{ type: "text", text: "Hello " + args.name }] };' }),
|
|
169
|
+
]),
|
|
170
|
+
]);
|
|
171
|
+
const result = transpileMCPPython(ast);
|
|
172
|
+
// Should NOT contain the TypeScript handler code
|
|
173
|
+
expect(result.code).not.toContain('args.name');
|
|
174
|
+
expect(result.code).not.toContain('content:');
|
|
175
|
+
// Should emit a default stub
|
|
176
|
+
expect(result.code).toContain('return f"greet completed"');
|
|
177
|
+
});
|
|
178
|
+
it('should use handler with lang=python', () => {
|
|
179
|
+
const ast = node('mcp', { name: 'LangPyPy' }, [
|
|
180
|
+
node('tool', { name: 'greet' }, [
|
|
181
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
182
|
+
node('handler', { lang: 'python', code: 'return f"Hello {name}"' }),
|
|
183
|
+
]),
|
|
184
|
+
]);
|
|
185
|
+
const result = transpileMCPPython(ast);
|
|
186
|
+
expect(result.code).toContain('return f"Hello {name}"');
|
|
187
|
+
});
|
|
188
|
+
it('should use lang=python handler when both TS and Python handlers exist', () => {
|
|
189
|
+
const ast = node('mcp', { name: 'DualPy' }, [
|
|
190
|
+
node('tool', { name: 'greet' }, [
|
|
191
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
192
|
+
node('handler', { code: 'return { content: [{ type: "text", text: "Hello " + args.name }] };' }),
|
|
193
|
+
node('handler', { lang: 'python', code: 'return f"Hello {name}"' }),
|
|
194
|
+
]),
|
|
195
|
+
]);
|
|
196
|
+
const result = transpileMCPPython(ast);
|
|
197
|
+
expect(result.code).toContain('return f"Hello {name}"');
|
|
198
|
+
// Should NOT contain the TypeScript handler
|
|
199
|
+
expect(result.code).not.toContain('args.name');
|
|
200
|
+
});
|
|
201
|
+
it('should emit stub for resource with TS-only handler', () => {
|
|
202
|
+
const ast = node('mcp', { name: 'ResourceLangPy' }, [
|
|
203
|
+
node('resource', { name: 'docs', uri: 'docs://readme' }, [
|
|
204
|
+
node('handler', { code: 'return { contents: [{ uri: uri.href, text: "data" }] };' }),
|
|
205
|
+
]),
|
|
206
|
+
]);
|
|
207
|
+
const result = transpileMCPPython(ast);
|
|
208
|
+
expect(result.code).not.toContain('uri.href');
|
|
209
|
+
expect(result.code).toContain('return f"docs content"');
|
|
210
|
+
});
|
|
211
|
+
it('should emit stub for prompt with TS-only handler', () => {
|
|
212
|
+
const ast = node('mcp', { name: 'PromptLangPy' }, [
|
|
213
|
+
node('prompt', { name: 'review' }, [
|
|
214
|
+
node('param', { name: 'code', type: 'string', required: 'true' }),
|
|
215
|
+
node('handler', {
|
|
216
|
+
code: 'return { messages: [{ role: "user", content: { type: "text", text: args.code } }] };',
|
|
217
|
+
}),
|
|
218
|
+
]),
|
|
219
|
+
]);
|
|
220
|
+
const result = transpileMCPPython(ast);
|
|
221
|
+
expect(result.code).not.toContain('args.code');
|
|
222
|
+
expect(result.code).toContain('return f"review prompt"');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
// ── Level 1: Python syntax verification — generated code must parse ─────
|
|
226
|
+
describe('transpileMCPPython syntax verification', () => {
|
|
227
|
+
function assertPythonParses(code, label) {
|
|
228
|
+
const dir = mkdtempSync(join(tmpdir(), 'kern-py-test-'));
|
|
229
|
+
const pyFile = join(dir, 'server.py');
|
|
230
|
+
try {
|
|
231
|
+
writeFileSync(pyFile, code);
|
|
232
|
+
execSync(`python3 -c "import ast; ast.parse(open('${pyFile}').read())"`, { timeout: 5000, stdio: 'pipe' });
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
const msg = e instanceof Error && 'stderr' in e ? e.stderr?.toString() : String(e);
|
|
236
|
+
throw new Error(`Generated Python for "${label}" has syntax errors:\n${msg}`);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
it('should generate valid Python for a full server', () => {
|
|
243
|
+
const ast = node('mcp', { name: 'FullPy' }, [
|
|
244
|
+
node('tool', { name: 'search' }, [
|
|
245
|
+
node('description', { text: 'Search' }),
|
|
246
|
+
node('param', { name: 'query', type: 'string', required: 'true' }),
|
|
247
|
+
node('param', { name: 'limit', type: 'int', default: '10' }),
|
|
248
|
+
node('param', { name: 'active', type: 'boolean', default: 'false' }),
|
|
249
|
+
]),
|
|
250
|
+
node('resource', { name: 'config', uri: 'config://app' }),
|
|
251
|
+
node('prompt', { name: 'review' }, [node('param', { name: 'code', type: 'string', required: 'true' })]),
|
|
252
|
+
]);
|
|
253
|
+
const result = transpileMCPPython(ast);
|
|
254
|
+
assertPythonParses(result.code, 'full Python server');
|
|
255
|
+
});
|
|
256
|
+
it('should generate valid Python with sanitize guard', () => {
|
|
257
|
+
const ast = node('mcp', { name: 'SanitizePy' }, [
|
|
258
|
+
node('tool', { name: 'search' }, [
|
|
259
|
+
node('param', { name: 'query', type: 'string' }),
|
|
260
|
+
node('guard', { type: 'sanitize', param: 'query' }),
|
|
261
|
+
]),
|
|
262
|
+
]);
|
|
263
|
+
const result = transpileMCPPython(ast);
|
|
264
|
+
assertPythonParses(result.code, 'sanitize guard Python');
|
|
265
|
+
});
|
|
266
|
+
it('should generate valid Python with pathContainment guard', () => {
|
|
267
|
+
const ast = node('mcp', { name: 'PathPy' }, [
|
|
268
|
+
node('tool', { name: 'read' }, [
|
|
269
|
+
node('param', { name: 'filePath', type: 'string' }),
|
|
270
|
+
node('guard', { type: 'pathContainment', param: 'filePath', allowlist: '/data' }),
|
|
271
|
+
]),
|
|
272
|
+
]);
|
|
273
|
+
const result = transpileMCPPython(ast);
|
|
274
|
+
assertPythonParses(result.code, 'pathContainment Python');
|
|
275
|
+
});
|
|
276
|
+
it('should generate valid Python for empty server', () => {
|
|
277
|
+
const ast = node('mcp', { name: 'EmptyPy' });
|
|
278
|
+
const result = transpileMCPPython(ast);
|
|
279
|
+
assertPythonParses(result.code, 'empty Python server');
|
|
280
|
+
});
|
|
281
|
+
it('should generate valid Python with lang=python handler', () => {
|
|
282
|
+
const ast = node('mcp', { name: 'PythonHandlerPy' }, [
|
|
283
|
+
node('tool', { name: 'greet' }, [
|
|
284
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
285
|
+
node('handler', { lang: 'python', code: 'return f"Hello {name}"' }),
|
|
286
|
+
]),
|
|
287
|
+
]);
|
|
288
|
+
const result = transpileMCPPython(ast);
|
|
289
|
+
assertPythonParses(result.code, 'Python handler server');
|
|
290
|
+
});
|
|
291
|
+
it('should generate valid Python when TS handler is skipped', () => {
|
|
292
|
+
const ast = node('mcp', { name: 'SkippedHandlerPy' }, [
|
|
293
|
+
node('tool', { name: 'greet' }, [
|
|
294
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
295
|
+
node('handler', { code: 'return { content: [{ type: "text", text: "Hello " + args.name }] };' }),
|
|
296
|
+
]),
|
|
297
|
+
node('resource', { name: 'docs', uri: 'docs://readme' }, [
|
|
298
|
+
node('handler', { code: 'return { contents: [{ uri: uri.href, text: "data" }] };' }),
|
|
299
|
+
]),
|
|
300
|
+
]);
|
|
301
|
+
const result = transpileMCPPython(ast);
|
|
302
|
+
assertPythonParses(result.code, 'skipped TS handler Python');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
/** Write Python code to temp dir, spawn it, send MCP messages, return responses. */
|
|
306
|
+
function runPythonMCP(code, messages, timeoutMs = 15000) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const dir = mkdtempSync(join(tmpdir(), 'kern-py-e2e-'));
|
|
309
|
+
const pyFile = join(dir, 'server.py');
|
|
310
|
+
writeFileSync(pyFile, code);
|
|
311
|
+
const cp = spawn('python3', ['-u', pyFile], {
|
|
312
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
314
|
+
});
|
|
315
|
+
let stderr = '';
|
|
316
|
+
let stdoutBuffer = '';
|
|
317
|
+
const responses = [];
|
|
318
|
+
let settled = false;
|
|
319
|
+
let timer;
|
|
320
|
+
const requestIds = messages.flatMap((msg) => {
|
|
321
|
+
const id = msg.id;
|
|
322
|
+
return typeof id === 'number' ? [id] : [];
|
|
323
|
+
});
|
|
324
|
+
const initRequest = messages.find((msg) => msg.method === 'initialize');
|
|
325
|
+
const initializedNotification = messages.find((msg) => msg.method === 'notifications/initialized');
|
|
326
|
+
const followupMessages = messages.filter((msg) => msg !== initRequest && msg !== initializedNotification);
|
|
327
|
+
const initId = typeof initRequest?.id === 'number'
|
|
328
|
+
? initRequest.id
|
|
329
|
+
: undefined;
|
|
330
|
+
let postInitSent = initRequest === undefined;
|
|
331
|
+
const cleanup = () => {
|
|
332
|
+
if (timer)
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
};
|
|
335
|
+
const finish = () => {
|
|
336
|
+
if (settled)
|
|
337
|
+
return;
|
|
338
|
+
settled = true;
|
|
339
|
+
cleanup();
|
|
340
|
+
if (!cp.killed)
|
|
341
|
+
cp.kill();
|
|
342
|
+
rmSync(dir, { recursive: true, force: true });
|
|
343
|
+
resolve({ responses, stderr });
|
|
344
|
+
};
|
|
345
|
+
const armTimeout = () => {
|
|
346
|
+
cleanup();
|
|
347
|
+
timer = setTimeout(finish, timeoutMs);
|
|
348
|
+
};
|
|
349
|
+
const maybeFinish = () => {
|
|
350
|
+
if (requestIds.every((id) => responses.some((response) => response.id === id))) {
|
|
351
|
+
finish();
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const sendMessage = (msg, callback) => {
|
|
355
|
+
cp.stdin.write(`${JSON.stringify(msg)}\n`, callback);
|
|
356
|
+
};
|
|
357
|
+
const sendFollowups = () => {
|
|
358
|
+
for (const msg of followupMessages) {
|
|
359
|
+
sendMessage(msg);
|
|
360
|
+
}
|
|
361
|
+
armTimeout();
|
|
362
|
+
maybeFinish();
|
|
363
|
+
};
|
|
364
|
+
const sendPostInit = () => {
|
|
365
|
+
if (postInitSent)
|
|
366
|
+
return;
|
|
367
|
+
postInitSent = true;
|
|
368
|
+
if (!initializedNotification) {
|
|
369
|
+
sendFollowups();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
sendMessage(initializedNotification, (err) => {
|
|
373
|
+
if (err || settled)
|
|
374
|
+
return;
|
|
375
|
+
// Give the Python process time to finish initialization on slow CI runners.
|
|
376
|
+
// 200ms wasn't enough — bumped to 500ms after continued flakiness on GH Actions.
|
|
377
|
+
setTimeout(() => {
|
|
378
|
+
if (!settled)
|
|
379
|
+
sendFollowups();
|
|
380
|
+
}, 500);
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
cp.stdout.on('data', (d) => {
|
|
384
|
+
stdoutBuffer += d.toString();
|
|
385
|
+
let newlineIndex = stdoutBuffer.indexOf('\n');
|
|
386
|
+
while (newlineIndex >= 0) {
|
|
387
|
+
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
|
388
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
389
|
+
if (line) {
|
|
390
|
+
try {
|
|
391
|
+
const response = JSON.parse(line);
|
|
392
|
+
responses.push(response);
|
|
393
|
+
if (initId !== undefined && !postInitSent && response.id === initId) {
|
|
394
|
+
sendPostInit();
|
|
395
|
+
}
|
|
396
|
+
maybeFinish();
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// Ignore non-JSON stdout noise from subprocess startup.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
newlineIndex = stdoutBuffer.indexOf('\n');
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
cp.stderr.on('data', (d) => {
|
|
406
|
+
stderr += d.toString();
|
|
407
|
+
});
|
|
408
|
+
cp.on('close', () => {
|
|
409
|
+
if (!settled)
|
|
410
|
+
finish();
|
|
411
|
+
});
|
|
412
|
+
cp.on('error', (err) => {
|
|
413
|
+
if (!settled) {
|
|
414
|
+
cleanup();
|
|
415
|
+
rmSync(dir, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
reject(err);
|
|
418
|
+
});
|
|
419
|
+
if (initRequest) {
|
|
420
|
+
sendMessage(initRequest);
|
|
421
|
+
armTimeout();
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
for (const msg of messages) {
|
|
425
|
+
sendMessage(msg);
|
|
426
|
+
}
|
|
427
|
+
armTimeout();
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
function initMessages() {
|
|
432
|
+
return [
|
|
433
|
+
{
|
|
434
|
+
jsonrpc: '2.0',
|
|
435
|
+
method: 'initialize',
|
|
436
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
|
437
|
+
id: 1,
|
|
438
|
+
},
|
|
439
|
+
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
function rpc(method, params, id) {
|
|
443
|
+
return { jsonrpc: '2.0', method, params, id };
|
|
444
|
+
}
|
|
445
|
+
function findResponse(responses, id, stderr = '') {
|
|
446
|
+
const r = responses.find((r) => r.id === id);
|
|
447
|
+
if (!r)
|
|
448
|
+
throw new Error(`No response for id=${id}. Got: ${JSON.stringify(responses)}${stderr ? `\nstderr: ${stderr.slice(0, 500)}` : ''}`);
|
|
449
|
+
return r;
|
|
450
|
+
}
|
|
451
|
+
// Check if Python + mcp package are available — skip E2E tests if not
|
|
452
|
+
let hasPythonMCP = false;
|
|
453
|
+
try {
|
|
454
|
+
execSync('python3 -c "from mcp.server.fastmcp import FastMCP"', { stdio: 'pipe', timeout: 10000 });
|
|
455
|
+
hasPythonMCP = true;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
/* python3 or mcp not installed */
|
|
459
|
+
}
|
|
460
|
+
const describeE2E = hasPythonMCP ? describe : describe.skip;
|
|
461
|
+
describeE2E('transpileMCPPython runtime E2E', () => {
|
|
462
|
+
// 1. Basic tool call with Python handler
|
|
463
|
+
it('should handle a tool call at runtime with lang=python handler', async () => {
|
|
464
|
+
const ast = node('mcp', { name: 'GreetPyE2E' }, [
|
|
465
|
+
node('tool', { name: 'greet' }, [
|
|
466
|
+
node('description', { text: 'Say hello' }),
|
|
467
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
468
|
+
node('handler', { lang: 'python', code: 'return f"Hello {name}"' }),
|
|
469
|
+
]),
|
|
470
|
+
]);
|
|
471
|
+
const result = transpileMCPPython(ast);
|
|
472
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
473
|
+
...initMessages(),
|
|
474
|
+
rpc('tools/call', { name: 'greet', arguments: { name: 'World' } }, 2),
|
|
475
|
+
]);
|
|
476
|
+
const toolResponse = findResponse(responses, 2);
|
|
477
|
+
const content = toolResponse.result.content;
|
|
478
|
+
expect(content[0].text).toBe('Hello World');
|
|
479
|
+
}, 30000);
|
|
480
|
+
// 2. Default handler — tool without Python handler gets stub
|
|
481
|
+
it('should return default stub for tool without Python handler', async () => {
|
|
482
|
+
const ast = node('mcp', { name: 'DefaultPyE2E' }, [
|
|
483
|
+
node('tool', { name: 'action' }, [node('description', { text: 'Do something' })]),
|
|
484
|
+
]);
|
|
485
|
+
const result = transpileMCPPython(ast);
|
|
486
|
+
const { responses, stderr } = await runPythonMCP(result.code, [
|
|
487
|
+
...initMessages(),
|
|
488
|
+
rpc('tools/call', { name: 'action', arguments: {} }, 2),
|
|
489
|
+
]);
|
|
490
|
+
const toolResponse = findResponse(responses, 2, stderr);
|
|
491
|
+
const text = toolResponse.result.content[0].text;
|
|
492
|
+
expect(text).toBe('action completed');
|
|
493
|
+
}, 30000);
|
|
494
|
+
// 3. Tool listing
|
|
495
|
+
it('should list tools in Python server', async () => {
|
|
496
|
+
const ast = node('mcp', { name: 'ListPyE2E' }, [
|
|
497
|
+
node('tool', { name: 'alpha' }, [
|
|
498
|
+
node('description', { text: 'Tool A' }),
|
|
499
|
+
node('param', { name: 'x', type: 'string' }),
|
|
500
|
+
]),
|
|
501
|
+
node('tool', { name: 'beta' }, [node('description', { text: 'Tool B' })]),
|
|
502
|
+
]);
|
|
503
|
+
const result = transpileMCPPython(ast);
|
|
504
|
+
const { responses } = await runPythonMCP(result.code, [...initMessages(), rpc('tools/list', {}, 2)]);
|
|
505
|
+
const listResponse = findResponse(responses, 2);
|
|
506
|
+
const tools = listResponse.result.tools;
|
|
507
|
+
const names = tools.map((t) => t.name);
|
|
508
|
+
expect(names).toContain('alpha');
|
|
509
|
+
expect(names).toContain('beta');
|
|
510
|
+
}, 30000);
|
|
511
|
+
// 4. Typed parameters — number param works correctly
|
|
512
|
+
it('should handle typed parameters in Python', async () => {
|
|
513
|
+
const ast = node('mcp', { name: 'TypedPyE2E' }, [
|
|
514
|
+
node('tool', { name: 'multiply' }, [
|
|
515
|
+
node('param', { name: 'a', type: 'number', required: 'true' }),
|
|
516
|
+
node('param', { name: 'b', type: 'number', required: 'true' }),
|
|
517
|
+
node('handler', { lang: 'python', code: 'return str(a * b)' }),
|
|
518
|
+
]),
|
|
519
|
+
]);
|
|
520
|
+
const result = transpileMCPPython(ast);
|
|
521
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
522
|
+
...initMessages(),
|
|
523
|
+
rpc('tools/call', { name: 'multiply', arguments: { a: 7, b: 6 } }, 2),
|
|
524
|
+
]);
|
|
525
|
+
const toolResponse = findResponse(responses, 2);
|
|
526
|
+
expect(toolResponse.result.content[0].text).toBe('42.0');
|
|
527
|
+
}, 30000);
|
|
528
|
+
// 5. Auth guard — rejects without env var
|
|
529
|
+
it('should enforce auth guard in Python', async () => {
|
|
530
|
+
const ast = node('mcp', { name: 'AuthPyE2E' }, [
|
|
531
|
+
node('tool', { name: 'secret' }, [
|
|
532
|
+
node('guard', { type: 'auth', env: 'KERN_PY_E2E_SECRET_DOES_NOT_EXIST' }),
|
|
533
|
+
node('handler', { lang: 'python', code: 'return "secret data"' }),
|
|
534
|
+
]),
|
|
535
|
+
]);
|
|
536
|
+
const result = transpileMCPPython(ast);
|
|
537
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
538
|
+
...initMessages(),
|
|
539
|
+
rpc('tools/call', { name: 'secret', arguments: {} }, 2),
|
|
540
|
+
]);
|
|
541
|
+
const toolResponse = findResponse(responses, 2);
|
|
542
|
+
expect(toolResponse.result?.isError).toBe(true);
|
|
543
|
+
}, 30000);
|
|
544
|
+
// 6. Sanitize guard — strips dangerous input
|
|
545
|
+
it('should sanitize input in Python', async () => {
|
|
546
|
+
const ast = node('mcp', { name: 'SanitizePyE2E' }, [
|
|
547
|
+
node('tool', { name: 'echo' }, [
|
|
548
|
+
node('param', { name: 'input', type: 'string', required: 'true' }),
|
|
549
|
+
node('guard', { type: 'sanitize', param: 'input' }),
|
|
550
|
+
node('handler', { lang: 'python', code: 'return str(input)' }),
|
|
551
|
+
]),
|
|
552
|
+
]);
|
|
553
|
+
const result = transpileMCPPython(ast);
|
|
554
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
555
|
+
...initMessages(),
|
|
556
|
+
rpc('tools/call', { name: 'echo', arguments: { input: '<script>alert(1)</script>' } }, 2),
|
|
557
|
+
]);
|
|
558
|
+
const toolResponse = findResponse(responses, 2);
|
|
559
|
+
const text = toolResponse.result.content[0].text;
|
|
560
|
+
expect(text).not.toContain('<script>');
|
|
561
|
+
}, 30000);
|
|
562
|
+
// 7. TS handler is skipped — doesn't crash Python
|
|
563
|
+
it('should not crash when TS-only handler is present', async () => {
|
|
564
|
+
const ast = node('mcp', { name: 'SkipTSPyE2E' }, [
|
|
565
|
+
node('tool', { name: 'greet' }, [
|
|
566
|
+
node('param', { name: 'name', type: 'string', required: 'true' }),
|
|
567
|
+
// TS handler — should be skipped by Python transpiler
|
|
568
|
+
node('handler', { code: 'return { content: [{ type: "text", text: "Hello " + args.name }] };' }),
|
|
569
|
+
]),
|
|
570
|
+
]);
|
|
571
|
+
const result = transpileMCPPython(ast);
|
|
572
|
+
const { responses, stderr } = await runPythonMCP(result.code, [
|
|
573
|
+
...initMessages(),
|
|
574
|
+
rpc('tools/call', { name: 'greet', arguments: { name: 'Test' } }, 2),
|
|
575
|
+
]);
|
|
576
|
+
const toolResponse = findResponse(responses, 2, stderr);
|
|
577
|
+
// Should get the default stub, not a crash
|
|
578
|
+
expect(toolResponse.result.content[0].text).toBe('greet completed');
|
|
579
|
+
}, 30000);
|
|
580
|
+
// 8. PathContainment guard — blocks traversal in Python
|
|
581
|
+
it('should block directory traversal in Python', async () => {
|
|
582
|
+
const ast = node('mcp', { name: 'PathPyE2E' }, [
|
|
583
|
+
node('tool', { name: 'readFile' }, [
|
|
584
|
+
node('param', { name: 'filePath', type: 'string', required: 'true' }),
|
|
585
|
+
node('guard', { type: 'pathContainment', param: 'filePath', allowlist: '/tmp/safe' }),
|
|
586
|
+
node('handler', { lang: 'python', code: 'return f"read: {filePath}"' }),
|
|
587
|
+
]),
|
|
588
|
+
]);
|
|
589
|
+
const result = transpileMCPPython(ast);
|
|
590
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
591
|
+
...initMessages(),
|
|
592
|
+
rpc('tools/call', { name: 'readFile', arguments: { filePath: '../../../etc/passwd' } }, 2),
|
|
593
|
+
]);
|
|
594
|
+
const toolResponse = findResponse(responses, 2);
|
|
595
|
+
expect(toolResponse.result?.isError).toBe(true);
|
|
596
|
+
}, 30000);
|
|
597
|
+
// 9. Validate guard — rejects out-of-range in Python
|
|
598
|
+
it('should reject out-of-range values in Python', async () => {
|
|
599
|
+
const ast = node('mcp', { name: 'ValidatePyE2E' }, [
|
|
600
|
+
node('tool', { name: 'setCount' }, [
|
|
601
|
+
node('param', { name: 'count', type: 'number', required: 'true' }),
|
|
602
|
+
node('guard', { type: 'validate', param: 'count', min: '1', max: '100' }),
|
|
603
|
+
node('handler', { lang: 'python', code: 'return f"count={count}"' }),
|
|
604
|
+
]),
|
|
605
|
+
]);
|
|
606
|
+
const result = transpileMCPPython(ast);
|
|
607
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
608
|
+
...initMessages(),
|
|
609
|
+
rpc('tools/call', { name: 'setCount', arguments: { count: 0 } }, 2),
|
|
610
|
+
]);
|
|
611
|
+
const toolResponse = findResponse(responses, 2);
|
|
612
|
+
expect(toolResponse.result?.isError).toBe(true);
|
|
613
|
+
}, 30000);
|
|
614
|
+
// 10. SizeLimit guard — rejects oversized input in Python
|
|
615
|
+
it('should reject oversized input in Python', async () => {
|
|
616
|
+
const ast = node('mcp', { name: 'SizePyE2E' }, [
|
|
617
|
+
node('tool', { name: 'upload' }, [
|
|
618
|
+
node('param', { name: 'data', type: 'string', required: 'true' }),
|
|
619
|
+
node('guard', { type: 'sizeLimit', param: 'data', max: '50' }),
|
|
620
|
+
node('handler', { lang: 'python', code: 'return "uploaded"' }),
|
|
621
|
+
]),
|
|
622
|
+
]);
|
|
623
|
+
const result = transpileMCPPython(ast);
|
|
624
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
625
|
+
...initMessages(),
|
|
626
|
+
rpc('tools/call', { name: 'upload', arguments: { data: 'x'.repeat(200) } }, 2),
|
|
627
|
+
]);
|
|
628
|
+
const toolResponse = findResponse(responses, 2);
|
|
629
|
+
expect(toolResponse.result?.isError).toBe(true);
|
|
630
|
+
}, 30000);
|
|
631
|
+
// 11. Error handling — Python handler that raises produces error response
|
|
632
|
+
it('should catch Python handler errors gracefully', async () => {
|
|
633
|
+
const ast = node('mcp', { name: 'ErrorPyE2E' }, [
|
|
634
|
+
node('tool', { name: 'crasher' }, [node('handler', { lang: 'python', code: 'raise ValueError("intentional")' })]),
|
|
635
|
+
]);
|
|
636
|
+
const result = transpileMCPPython(ast);
|
|
637
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
638
|
+
...initMessages(),
|
|
639
|
+
rpc('tools/call', { name: 'crasher', arguments: {} }, 2),
|
|
640
|
+
]);
|
|
641
|
+
const toolResponse = findResponse(responses, 2);
|
|
642
|
+
expect(toolResponse.result?.isError).toBe(true);
|
|
643
|
+
}, 30000);
|
|
644
|
+
// 12. Resource handler at runtime
|
|
645
|
+
it('should serve a resource in Python', async () => {
|
|
646
|
+
const ast = node('mcp', { name: 'ResourcePyE2E' }, [
|
|
647
|
+
node('resource', { name: 'readme', uri: 'docs://readme' }, [
|
|
648
|
+
node('description', { text: 'The readme' }),
|
|
649
|
+
node('handler', { lang: 'python', code: 'return "# Welcome to KERN"' }),
|
|
650
|
+
]),
|
|
651
|
+
]);
|
|
652
|
+
const result = transpileMCPPython(ast);
|
|
653
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
654
|
+
...initMessages(),
|
|
655
|
+
rpc('resources/read', { uri: 'docs://readme' }, 2),
|
|
656
|
+
]);
|
|
657
|
+
const resourceResponse = findResponse(responses, 2);
|
|
658
|
+
expect(resourceResponse.result).toBeDefined();
|
|
659
|
+
const contents = resourceResponse.result.contents;
|
|
660
|
+
expect(contents[0].text).toContain('Welcome to KERN');
|
|
661
|
+
}, 30000);
|
|
662
|
+
// 13. Prompt handler at runtime
|
|
663
|
+
it('should serve a prompt in Python', async () => {
|
|
664
|
+
const ast = node('mcp', { name: 'PromptPyE2E' }, [
|
|
665
|
+
node('prompt', { name: 'review' }, [
|
|
666
|
+
node('description', { text: 'Review code' }),
|
|
667
|
+
node('param', { name: 'code', type: 'string', required: 'true' }),
|
|
668
|
+
node('handler', { lang: 'python', code: 'return f"Please review: {code}"' }),
|
|
669
|
+
]),
|
|
670
|
+
]);
|
|
671
|
+
const result = transpileMCPPython(ast);
|
|
672
|
+
const { responses } = await runPythonMCP(result.code, [
|
|
673
|
+
...initMessages(),
|
|
674
|
+
rpc('prompts/get', { name: 'review', arguments: { code: 'def f(): pass' } }, 2),
|
|
675
|
+
]);
|
|
676
|
+
const promptResponse = findResponse(responses, 2);
|
|
677
|
+
expect(promptResponse.result).toBeDefined();
|
|
678
|
+
const messages = promptResponse.result.messages;
|
|
679
|
+
expect(messages[0].content.text).toContain('def f(): pass');
|
|
680
|
+
}, 30000);
|
|
681
|
+
// 14. Resource listing
|
|
682
|
+
it('should list resources in Python', async () => {
|
|
683
|
+
const ast = node('mcp', { name: 'ResourceListPyE2E' }, [
|
|
684
|
+
node('resource', { name: 'config', uri: 'app://config' }, [node('description', { text: 'App config' })]),
|
|
685
|
+
node('resource', { name: 'status', uri: 'app://status' }, [node('description', { text: 'App status' })]),
|
|
686
|
+
]);
|
|
687
|
+
const result = transpileMCPPython(ast);
|
|
688
|
+
const { responses } = await runPythonMCP(result.code, [...initMessages(), rpc('resources/list', {}, 2)]);
|
|
689
|
+
const listResponse = findResponse(responses, 2);
|
|
690
|
+
const resources = listResponse.result.resources;
|
|
691
|
+
const uris = resources.map((r) => r.uri);
|
|
692
|
+
expect(uris).toContain('app://config');
|
|
693
|
+
expect(uris).toContain('app://status');
|
|
694
|
+
}, 30000);
|
|
695
|
+
});
|
|
696
|
+
//# sourceMappingURL=transpiler-mcp-python.test.js.map
|