diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bc3e7..7fe83d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,22 +41,15 @@ jobs: test: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18, 20, 22] - steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: npm ci + run: bun install - name: Run tests - run: npx vitest run -c vitest.config.js + run: bun test diff --git a/package.json b/package.json index 5cf9abe..c2d5f5d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "CHANGELOG.md" ], "scripts": { - "dist": "bun build --outdir dist --root src --external commander --external chalk --external typescript src/**/*.ts", + "dist": "bun build --outdir dist --root src --external commander --external chalk --external typescript src/**/*.ts --target node", "dev": "bun run src/cli/index.ts", "test": "bun test", "lint": "eslint src/ --ext .ts,.js", diff --git a/src/cli/index.ts b/src/cli/index.ts index 2d95565..3b00647 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,7 +4,7 @@ import { program } from 'commander'; import chalk from 'chalk'; import { tools } from '../tools'; -import { TidewaveExtractor } from '../index'; +import { Tidewave } from '../index'; import { isExtractError, isResolveError } from '../core'; import { name, version } from '../../package.json'; @@ -30,7 +30,7 @@ async function handleGetDocs( options: { prefix?: string; json?: boolean }, ): Promise { if (options.prefix) chdir(options.prefix); - const docsResult = await TidewaveExtractor.extractDocs(modulePath); + const docsResult = await Tidewave.extractDocs(modulePath); if (isExtractError(docsResult)) { console.error(chalk.red(`Error: ${docsResult.error.message}`)); @@ -40,7 +40,7 @@ async function handleGetDocs( if (options.json) { console.log(JSON.stringify(docsResult, null, 2)); } else { - console.log(TidewaveExtractor.formatOutput(docsResult)); + console.log(Tidewave.formatOutput(docsResult)); } } @@ -50,7 +50,7 @@ async function handleGetSourcePath( ): Promise { if (options.prefix) chdir(options.prefix); - const sourceResult = await TidewaveExtractor.getSourceLocation(moduleName); + const sourceResult = await Tidewave.getSourceLocation(moduleName); if (isResolveError(sourceResult)) { console.error(chalk.red(`Error: ${sourceResult.error.message}`)); diff --git a/src/core.ts b/src/core.ts index 9e2b551..07c8984 100644 --- a/src/core.ts +++ b/src/core.ts @@ -68,6 +68,19 @@ export interface ExtractError { }; } +export interface EvaluationRequest { + code: string; + args: unknown[]; + timeout: number; +} + +export interface EvaluatedModuleResult { + success: boolean; + result: string | null; + stdout: string; + stderr: string; +} + export type ResolveResult = ResolvedModule | ResolveError; export type ExtractResult = SymbolInfo | ExtractError; export type InternalResolveResult = InternalResolvedModule | ResolveError; diff --git a/src/evalation/code_executor.ts b/src/evalation/code_executor.ts new file mode 100644 index 0000000..e2cbc80 --- /dev/null +++ b/src/evalation/code_executor.ts @@ -0,0 +1,59 @@ +import { fork } from 'child_process'; +import { join } from 'path'; +import type { EvaluatedModuleResult, EvaluationRequest } from '../core'; + +export async function executeIsolated(request: EvaluationRequest): Promise { + return new Promise(resolve => { + const workerPath = join(__dirname, 'eval_worker.ts'); + + const child = fork(workerPath, { silent: true }); + + const evaluation: EvaluatedModuleResult = { + success: false, + result: null, + stdout: '', + stderr: '', + }; + + child.stdout?.on('data', data => { + evaluation.stdout += data.toString(); + }); + + child.stderr?.on('data', data => { + evaluation.stderr += data.toString(); + }); + + child.on('message', (msg: { type: 'result'; data: string; success: boolean }) => { + if (msg.type === 'result') { + const { data, success } = msg; + evaluation.result = data; + evaluation.success = success; + } + }); + + child.on('exit', code => { + resolve({ + success: evaluation.success && code === 0, + result: evaluation.result, + stdout: evaluation.stdout.trim(), + stderr: evaluation.stderr.trim(), + } as EvaluatedModuleResult); + }); + + const { timeout } = request; + + const timeoutId = setTimeout(() => { + child.kill('SIGKILL'); + resolve({ + success: false, + result: `Evaluation timed out after ${timeout} milliseconds`, + stdout: evaluation.stdout, + stderr: evaluation.stderr, + } as EvaluatedModuleResult); + }, timeout); + + child.on('exit', () => clearTimeout(timeoutId)); + + child.send(request); + }); +} diff --git a/src/evalation/eval_worker.ts b/src/evalation/eval_worker.ts new file mode 100644 index 0000000..f57384f --- /dev/null +++ b/src/evalation/eval_worker.ts @@ -0,0 +1,28 @@ +import type { EvaluationRequest } from '../core'; + +process.on('message', async ({ code, args }: EvaluationRequest) => { + if (!process.send) { + console.error('Unable to establish communication channel with code-executor.'); + process.exit(1); + } + + try { + const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; + const fn = new AsyncFunction(code); + const result = await fn(...args); + + process.send({ + type: 'result', + success: true, + data: (result || null) && result, + }); + } catch (error) { + process.send({ + type: 'result', + success: false, + data: new String(error), + }); + } + + process.exit(0); +}); diff --git a/src/index.ts b/src/index.ts index f244546..1eae12f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,11 @@ export { extractSymbol, extractDocs, getSourceLocation, formatOutput } from './r export * from './core'; import { extractDocs, getSourceLocation, formatOutput } from './resolution'; +import { executeIsolated } from './evalation/code_executor'; -export const TidewaveExtractor = { +export const Tidewave = { extractDocs, getSourceLocation, formatOutput, + executeIsolated, }; diff --git a/src/mcp.ts b/src/mcp.ts index 65c28b8..cd3bf17 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -3,18 +3,62 @@ import { tools } from './tools'; import { name, version } from '../package.json'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { DocsInputSchema, SourceInputSchema } from './tools'; +import type { DocsInputSchema, ProjectEvalInputSchema, SourceInputSchema } from './tools'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { isExtractError, isResolveError } from './core'; -import { TidewaveExtractor } from '.'; +import { Tidewave } from '.'; const { docs: { mcp: docsMcp }, source: { mcp: sourceMcp }, + eval: { mcp: evalMcp }, } = tools; +async function handleProjectEvaluation({ + code, + timeout, + arguments: args, + json, +}: ProjectEvalInputSchema): Promise { + const result = await Tidewave.executeIsolated({ code, timeout, args }); + + if (!result.success) { + if (json) + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: true, + }; + + return { + content: [ + { + type: 'text', + text: `Failed to evaluate code. Process exited with reason: ${result.stderr}\n\n${result.result}`, + }, + ], + isError: true, + }; + } + + if (json) + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: false, + }; + + return { + content: [ + { + type: 'text', + text: `IO:\n\n${result.stdout}\n+${result.stderr}\n\nResult:${result.result}`, + }, + ], + isError: false, + }; +} + async function handleGetDocs({ reference }: DocsInputSchema): Promise { - const docs = await TidewaveExtractor.extractDocs(reference); + const docs = await Tidewave.extractDocs(reference); if (isExtractError(docs)) { return { @@ -41,13 +85,13 @@ async function handleGetDocs({ reference }: DocsInputSchema): Promise { - const sourceResult = await TidewaveExtractor.getSourceLocation(reference); + const sourceResult = await Tidewave.getSourceLocation(reference); if (isResolveError(sourceResult)) { return { @@ -92,5 +136,11 @@ export async function serveMcp(transport: Transport): Promise { handleGetSourcePath, ); + server.registerTool( + evalMcp.name, + { description: evalMcp.description, inputSchema: evalMcp.inputSchema.shape }, + handleProjectEvaluation, + ); + await server.connect(transport); } diff --git a/src/tools.ts b/src/tools.ts index 5a449ff..2ddd670 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export type DocsInputSchema = z.infer; export type SourceInputSchema = z.infer; +export type ProjectEvalInputSchema = z.infer; export interface Tool { mcp: { @@ -21,22 +22,60 @@ export interface Tool { export interface Tools { docs: Tool; source: Tool; + eval: Omit, 'cli'>; } +const projectEvalDescription = ` +Evaluates JavaScript/TypeScript code in the context of the project. + +The current NodeJS version is: ${process.version} + +Use this tool every time you need to evaluate JavaScript/TypeScript code, +including to test the behaviour of a function or to debug +something. The tool also returns anything written to standard +output. DO NOT use shell tools to evaluate JavaScript/TypeScript code. + +Imports are allowed only as the form of dynamic imports with async/await, e.g.: +const path = await import('node:path'); +`; + +export const projectEvalInputSchema = z.object({ + code: z.string().describe('The JavaScript/TypeScript code to evaluate.'), + arguments: z + .array(z.any()) + .optional() + .default([]) + .describe( + 'The arguments to pass to evaluation. They are available inside the evaluated code as `arguments`.', + ), + timeout: z + .number() + .optional() + .default(30_000) + .describe( + 'Optional. A timeout in milliseconds after which the execution stops if it did not finish yet.\nDefaults to 30000 (30 seconds).', + ), + json: z + .boolean() + .optional() + .default(false) + .describe('Whether to return the result as JSON or not (string)'), +}); + const referenceDescription = `Module path in format 'module:symbol[#method|.method]'. Supports local files, dependencies, and Node.js builtins. - Module reference format: - - module:symbol - Extract a top-level symbol - - module:Class#method - Extract an instance method - - module:Class.method - Extract a static method - - node:Class#method - Extract a global/builtin instance method - - node:Class.method - Extract a global/builtin static method +Module reference format: +- module:symbol - Extract a top-level symbol +- module:Class#method - Extract an instance method +- module:Class.method - Extract a static method +- node:Class#method - Extract a global/builtin instance method +- node:Class.method - Extract a global/builtin static method - Examples: - - src/types.ts:SymbolInfo (local file symbol) - - lodash:isEmpty (dependency function) - - react:Component#render (instance method) - - node:Math.max (builtin static method)`; +Examples: +- src/types.ts:SymbolInfo (local file symbol) +- lodash:isEmpty (dependency function) +- react:Component#render (instance method) +- node:Math.max (builtin static method)`; export const docsInputSchema = z.object({ reference: z.string().describe(referenceDescription), @@ -91,4 +130,11 @@ export const tools: Tools = { }, }, }, + eval: { + mcp: { + inputSchema: projectEvalInputSchema, + description: projectEvalDescription, + name: 'project_eval', + }, + }, } as const; diff --git a/test/integration.test.ts b/test/integration.test.ts index d12aa1b..4076462 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { TidewaveExtractor } from '../src/index'; -import { isExtractError, isResolveError } from '../src/core'; +import { Tidewave } from '../src/index'; +import { isExtractError, isResolveError, type EvaluationRequest } from '../src/core'; describe('Integration Tests', () => { describe('JavaScript Files', () => { it('should extract function from CommonJS export', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.js:greetUser'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.js:greetUser'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -19,7 +19,7 @@ describe('Integration Tests', () => { }); it('should extract class from CommonJS export', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.js:TestClass'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.js:TestClass'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -31,9 +31,7 @@ describe('Integration Tests', () => { }); it('should extract instance method from JavaScript class', async () => { - const result = await TidewaveExtractor.extractDocs( - './test/fixtures/sample.js:TestClass#getValue', - ); + const result = await Tidewave.extractDocs('./test/fixtures/sample.js:TestClass#getValue'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -45,9 +43,7 @@ describe('Integration Tests', () => { }); it('should extract static method from JavaScript class', async () => { - const result = await TidewaveExtractor.extractDocs( - './test/fixtures/sample.js:TestClass.create', - ); + const result = await Tidewave.extractDocs('./test/fixtures/sample.js:TestClass.create'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -61,7 +57,7 @@ describe('Integration Tests', () => { describe('TypeScript Files', () => { it('should extract interface from TypeScript export', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.ts:User'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:User'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -73,7 +69,7 @@ describe('Integration Tests', () => { }); it('should extract class from TypeScript export', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.ts:UserManager'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:UserManager'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -84,9 +80,7 @@ describe('Integration Tests', () => { }); it('should extract instance method from TypeScript class', async () => { - const result = await TidewaveExtractor.extractDocs( - './test/fixtures/sample.ts:UserManager#addUser', - ); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:UserManager#addUser'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -97,9 +91,7 @@ describe('Integration Tests', () => { }); it('should extract static method from TypeScript class', async () => { - const result = await TidewaveExtractor.extractDocs( - './test/fixtures/sample.ts:UserManager.create', - ); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:UserManager.create'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -110,7 +102,7 @@ describe('Integration Tests', () => { }); it('should extract generic function', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.ts:processItems'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:processItems'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -124,7 +116,7 @@ describe('Integration Tests', () => { describe('Source Path Resolution', () => { it('should resolve JavaScript file path', async () => { - const sourcePath = await TidewaveExtractor.getSourceLocation('./test/fixtures/sample.js'); + const sourcePath = await Tidewave.getSourceLocation('./test/fixtures/sample.js'); expect(isResolveError(sourcePath)).toBe(false); if (!isResolveError(sourcePath)) { @@ -133,7 +125,7 @@ describe('Integration Tests', () => { }); it('should resolve TypeScript file path', async () => { - const sourcePath = await TidewaveExtractor.getSourceLocation('./test/fixtures/sample.ts'); + const sourcePath = await Tidewave.getSourceLocation('./test/fixtures/sample.ts'); expect(isResolveError(sourcePath)).toBe(false); if (!isResolveError(sourcePath)) { @@ -142,7 +134,7 @@ describe('Integration Tests', () => { }); it('should resolve node_modules dependency', async () => { - const sourcePath = await TidewaveExtractor.getSourceLocation('typescript'); + const sourcePath = await Tidewave.getSourceLocation('typescript'); expect(isResolveError(sourcePath)).toBe(false); if (!isResolveError(sourcePath)) { @@ -153,7 +145,7 @@ describe('Integration Tests', () => { describe('Builtin Modules', () => { it('should extract Math global', async () => { - const result = await TidewaveExtractor.extractDocs('node:Math'); + const result = await Tidewave.extractDocs('node:Math'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -164,7 +156,7 @@ describe('Integration Tests', () => { }); it('should extract Math.max static method', async () => { - const result = await TidewaveExtractor.extractDocs('node:Math.max'); + const result = await Tidewave.extractDocs('node:Math.max'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { @@ -178,27 +170,25 @@ describe('Integration Tests', () => { describe('Error Handling', () => { it('should handle invalid module path format', async () => { - const result = await TidewaveExtractor.extractDocs('invalid-format'); + const result = await Tidewave.extractDocs('invalid-format'); expect(isExtractError(result)).toBe(true); }); it('should handle non-existent module', async () => { - const result = await TidewaveExtractor.extractDocs('non-existent-module:symbol'); + const result = await Tidewave.extractDocs('non-existent-module:symbol'); expect(isExtractError(result)).toBe(true); }); it('should handle non-existent symbol', async () => { - const result = await TidewaveExtractor.extractDocs( - './test/fixtures/sample.ts:NonExistentSymbol', - ); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:NonExistentSymbol'); expect(isExtractError(result)).toBe(true); }); it('should handle non-existent member', async () => { - const result = await TidewaveExtractor.extractDocs( + const result = await Tidewave.extractDocs( './test/fixtures/sample.ts:UserManager#nonExistentMethod', ); @@ -208,11 +198,11 @@ describe('Integration Tests', () => { describe('Output Formatting', () => { it('should format symbol info correctly', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.ts:User'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:User'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { - const formatted = TidewaveExtractor.formatOutput(result); + const formatted = Tidewave.formatOutput(result); expect(formatted).toContain('User'); expect(formatted).toContain('Kind: interface'); @@ -223,11 +213,11 @@ describe('Integration Tests', () => { }); it('should format function with signature', async () => { - const result = await TidewaveExtractor.extractDocs('./test/fixtures/sample.ts:processItems'); + const result = await Tidewave.extractDocs('./test/fixtures/sample.ts:processItems'); expect(isExtractError(result)).toBe(false); if (!isExtractError(result)) { - const formatted = TidewaveExtractor.formatOutput(result); + const formatted = Tidewave.formatOutput(result); expect(formatted).toContain('processItems'); expect(formatted).toContain('Signature:'); @@ -236,3 +226,91 @@ describe('Integration Tests', () => { }); }); }); + +describe('Project scoped evaluation', () => { + it('should fork the process correcly and finish execution', async () => { + const request: EvaluationRequest = { + args: [], + timeout: 1_000, + code: "console.log('hello, world!');", + }; + + const result = await Tidewave.executeIsolated(request); + expect(result.success).toBe(true); + expect(result.stderr).toBeFalsy(); + expect(result.result).toBe(null); + expect(result.stdout).toBe('hello, world!'); + }); + + it('should fork the process and return a custom result', async () => { + const request: EvaluationRequest = { + args: [], + timeout: 1_000, + code: ` + console.log('hello, world!'); + return 42; + `, + }; + + const result = await Tidewave.executeIsolated(request); + expect(result.success).toBe(true); + expect(result.stderr).toBeFalsy(); + expect(result.stdout).toBe('hello, world!'); + expect(result.result).toBe(42); + }); + + it('should fork the process and respect the timeout', async () => { + const request: EvaluationRequest = { + args: [], + timeout: 1, + code: "console.log('hello, world!');", + }; + + const result = await Tidewave.executeIsolated(request); + expect(result.success).toBe(false); + expect(result.stderr).toBeFalsy(); + expect(result.stdout).toBeFalsy(); + expect(result.result).toBe('Evaluation timed out after 1 milliseconds'); + }); + + it('should fork the process and finish the program with args', async () => { + const request: EvaluationRequest = { + args: [42], + timeout: 10_000, + code: ` + const digit = arguments[0] + console.log(\`Code is: $\{digit}\`); + return (new Number(digit) + 1); + `, + }; + + const result = await Tidewave.executeIsolated(request); + expect(result.success).toBe(true); + expect(result.stderr).toBeFalsy(); + expect(result.stdout).toBe('Code is: 42'); + expect(result.result).toBe(43); + }); + + it('should fork the process and finish the program with imports', async () => { + const request: EvaluationRequest = { + args: [], + timeout: 10_000, + code: ` + const {resolve} = await import('node:path'); + const path = resolve(process.cwd()); + + console.log(JSON.stringify({a: 1})); + console.log(\`Global length: \${Object.keys(global).length > 0}\`); + + return path; + `, + }; + + const result = await Tidewave.executeIsolated(request); + expect(result.success).toBe(true); + expect(result.stderr).toBeFalsy(); + expect(result.stdout).toContain('{"a":1}'); + expect(result.stdout).toContain('Global length: true'); + expect(result.result).toContain(process.cwd()); + }); +});