Skip to content

Commit a1f8c27

Browse files
feat(mcp): add typescript check to code execution tool
1 parent 21d7cbb commit a1f8c27

File tree

2 files changed

+100
-23
lines changed

2 files changed

+100
-23
lines changed

packages/mcp-server/src/code-tool-worker.ts

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3+
import path from 'node:path';
34
import util from 'node:util';
45

56
import Fuse from 'fuse.js';
@@ -8,30 +9,41 @@ import ts from 'typescript';
89
import { WorkerInput, WorkerSuccess, WorkerError } from './code-tool-types';
910
import { Isaacus } from 'isaacus';
1011

11-
function getRunFunctionNode(
12-
code: string,
13-
): ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | null {
12+
function getRunFunctionSource(code: string): {
13+
type: 'declaration' | 'expression';
14+
client: string | undefined;
15+
code: string;
16+
} | null {
1417
const sourceFile = ts.createSourceFile('code.ts', code, ts.ScriptTarget.Latest, true);
18+
const printer = ts.createPrinter();
1519

1620
for (const statement of sourceFile.statements) {
1721
// Check for top-level function declarations
1822
if (ts.isFunctionDeclaration(statement)) {
1923
if (statement.name?.text === 'run') {
20-
return statement;
24+
return {
25+
type: 'declaration',
26+
client: statement.parameters[0]?.name.getText(),
27+
code: printer.printNode(ts.EmitHint.Unspecified, statement.body!, sourceFile),
28+
};
2129
}
2230
}
2331

2432
// Check for variable declarations: const run = () => {} or const run = function() {}
2533
if (ts.isVariableStatement(statement)) {
2634
for (const declaration of statement.declarationList.declarations) {
27-
if (ts.isIdentifier(declaration.name) && declaration.name.text === 'run') {
35+
if (
36+
ts.isIdentifier(declaration.name) &&
37+
declaration.name.text === 'run' &&
2838
// Check if it's initialized with a function
29-
if (
30-
declaration.initializer &&
31-
(ts.isFunctionExpression(declaration.initializer) || ts.isArrowFunction(declaration.initializer))
32-
) {
33-
return declaration.initializer;
34-
}
39+
declaration.initializer &&
40+
(ts.isFunctionExpression(declaration.initializer) || ts.isArrowFunction(declaration.initializer))
41+
) {
42+
return {
43+
type: 'expression',
44+
client: declaration.initializer.parameters[0]?.name.getText(),
45+
code: printer.printNode(ts.EmitHint.Unspecified, declaration.initializer, sourceFile),
46+
};
3547
}
3648
}
3749
}
@@ -40,6 +52,61 @@ function getRunFunctionNode(
4052
return null;
4153
}
4254

55+
function getTSDiagnostics(code: string): string[] {
56+
const functionSource = getRunFunctionSource(code)!;
57+
const codeWithImport = [
58+
'import { Isaacus } from "isaacus";',
59+
functionSource.type === 'declaration' ?
60+
`async function run(${functionSource.client}: Isaacus)`
61+
: `const run: (${functionSource.client}: Isaacus) => Promise<unknown> =`,
62+
functionSource.code,
63+
].join('\n');
64+
const sourcePath = path.resolve('code.ts');
65+
const ast = ts.createSourceFile(sourcePath, codeWithImport, ts.ScriptTarget.Latest, true);
66+
const options = ts.getDefaultCompilerOptions();
67+
options.target = ts.ScriptTarget.Latest;
68+
options.module = ts.ModuleKind.NodeNext;
69+
options.moduleResolution = ts.ModuleResolutionKind.NodeNext;
70+
const host = ts.createCompilerHost(options, true);
71+
const newHost: typeof host = {
72+
...host,
73+
getSourceFile: (...args) => {
74+
if (path.resolve(args[0]) === sourcePath) {
75+
return ast;
76+
}
77+
return host.getSourceFile(...args);
78+
},
79+
readFile: (...args) => {
80+
if (path.resolve(args[0]) === sourcePath) {
81+
return codeWithImport;
82+
}
83+
return host.readFile(...args);
84+
},
85+
fileExists: (...args) => {
86+
if (path.resolve(args[0]) === sourcePath) {
87+
return true;
88+
}
89+
return host.fileExists(...args);
90+
},
91+
};
92+
const program = ts.createProgram({
93+
options,
94+
rootNames: [sourcePath],
95+
host: newHost,
96+
});
97+
const diagnostics = ts.getPreEmitDiagnostics(program, ast);
98+
return diagnostics.map((d) => {
99+
const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
100+
if (!d.file || !d.start) return `- ${message}`;
101+
const { line: tsLine } = ts.getLineAndCharacterOfPosition(d.file, d.start);
102+
// We add two lines in the beginning, for the client import and the function declaration.
103+
// So the actual (zero-based) line number is tsLine - 2.
104+
const lineNumber = tsLine - 2;
105+
const line = code.split('\n').at(lineNumber)?.trim();
106+
return line ? `- ${message}\n at line ${lineNumber + 1}\n ${line}` : `- ${message}`;
107+
});
108+
}
109+
43110
const fuse = new Fuse(
44111
[
45112
'client.embeddings.create',
@@ -141,24 +208,28 @@ function parseError(code: string, error: unknown): string | undefined {
141208

142209
const fetch = async (req: Request): Promise<Response> => {
143210
const { opts, code } = (await req.json()) as WorkerInput;
144-
if (code == null) {
211+
212+
const runFunctionSource = code ? getRunFunctionSource(code) : null;
213+
if (!runFunctionSource) {
214+
const message =
215+
code ?
216+
'The code is missing a top-level `run` function.'
217+
: 'The code argument is missing. Provide one containing a top-level `run` function.';
145218
return Response.json(
146219
{
147-
message:
148-
'The code param is missing. Provide one containing a top-level `run` function. Write code within this template:\n\n```\nasync function run(client) {\n // Fill this out\n}\n```',
220+
message: `${message} Write code within this template:\n\n\`\`\`\nasync function run(client) {\n // Fill this out\n}\n\`\`\``,
149221
logLines: [],
150222
errLines: [],
151223
} satisfies WorkerError,
152224
{ status: 400, statusText: 'Code execution error' },
153225
);
154226
}
155227

156-
const runFunctionNode = getRunFunctionNode(code);
157-
if (!runFunctionNode) {
228+
const diagnostics = getTSDiagnostics(code);
229+
if (diagnostics.length > 0) {
158230
return Response.json(
159231
{
160-
message:
161-
'The code is missing a top-level `run` function. Write code within this template:\n\n```\nasync function run(client) {\n // Fill this out\n}\n```',
232+
message: `The code contains TypeScript diagnostics:\n${diagnostics.join('\n')}`,
162233
logLines: [],
163234
errLines: [],
164235
} satisfies WorkerError,

packages/mcp-server/src/code-tool.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3-
import { dirname } from 'node:path';
4-
import { pathToFileURL } from 'node:url';
3+
import path from 'node:path';
4+
import url from 'node:url';
55
import Isaacus, { ClientOptions } from 'isaacus';
66
import { ContentBlock, Endpoint, Metadata, ToolCallResult } from './tools/types';
77

@@ -35,18 +35,24 @@ export async function codeTool(): Promise<Endpoint> {
3535
const baseURLHostname = new URL(client.baseURL).hostname;
3636
const { code } = args as { code: string };
3737

38-
const worker = await newDenoHTTPWorker(pathToFileURL(workerPath), {
38+
const allowRead = [
39+
'code-tool-worker.mjs',
40+
`${workerPath.replace(/([\/\\]node_modules)[\/\\].+$/, '$1')}/`,
41+
path.resolve(path.dirname(workerPath), '..'),
42+
].join(',');
43+
44+
const worker = await newDenoHTTPWorker(url.pathToFileURL(workerPath), {
3945
runFlags: [
4046
`--node-modules-dir=manual`,
41-
`--allow-read=code-tool-worker.mjs,${workerPath.replace(/([\/\\]node_modules)[\/\\].+$/, '$1')}/`,
47+
`--allow-read=${allowRead}`,
4248
`--allow-net=${baseURLHostname}`,
4349
// Allow environment variables because instantiating the client will try to read from them,
4450
// even though they are not set.
4551
'--allow-env',
4652
],
4753
printOutput: true,
4854
spawnOptions: {
49-
cwd: dirname(workerPath),
55+
cwd: path.dirname(workerPath),
5056
},
5157
});
5258

0 commit comments

Comments
 (0)