Skip to content
Merged
15 changes: 4 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,7 +30,7 @@ async function handleGetDocs(
options: { prefix?: string; json?: boolean },
): Promise<void> {
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}`));
Expand All @@ -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));
}
}

Expand All @@ -50,7 +50,7 @@ async function handleGetSourcePath(
): Promise<void> {
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}`));
Expand Down
13 changes: 13 additions & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions src/evalation/code_executor.ts
Original file line number Diff line number Diff line change
@@ -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<EvaluatedModuleResult> {
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);
});
}
28 changes: 28 additions & 0 deletions src/evalation/eval_worker.ts
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
60 changes: 55 additions & 5 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolResult> {
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<CallToolResult> {
const docs = await TidewaveExtractor.extractDocs(reference);
const docs = await Tidewave.extractDocs(reference);

if (isExtractError(docs)) {
return {
Expand All @@ -41,13 +85,13 @@ async function handleGetDocs({ reference }: DocsInputSchema): Promise<CallToolRe
}

return {
content: [{ type: 'text', text: TidewaveExtractor.formatOutput(docs) }],
content: [{ type: 'text', text: Tidewave.formatOutput(docs) }],
isError: false,
};
}

async function handleGetSourcePath({ reference }: SourceInputSchema): Promise<CallToolResult> {
const sourceResult = await TidewaveExtractor.getSourceLocation(reference);
const sourceResult = await Tidewave.getSourceLocation(reference);

if (isResolveError(sourceResult)) {
return {
Expand Down Expand Up @@ -92,5 +136,11 @@ export async function serveMcp(transport: Transport): Promise<void> {
handleGetSourcePath,
);

server.registerTool(
evalMcp.name,
{ description: evalMcp.description, inputSchema: evalMcp.inputSchema.shape },
handleProjectEvaluation,
);

await server.connect(transport);
}
68 changes: 57 additions & 11 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';

export type DocsInputSchema = z.infer<typeof docsInputSchema>;
export type SourceInputSchema = z.infer<typeof sourceInputSchema>;
export type ProjectEvalInputSchema = z.infer<typeof projectEvalInputSchema>;

export interface Tool<InputSchema> {
mcp: {
Expand All @@ -21,22 +22,60 @@ export interface Tool<InputSchema> {
export interface Tools {
docs: Tool<typeof docsInputSchema>;
source: Tool<typeof sourceInputSchema>;
eval: Omit<Tool<typeof projectEvalInputSchema>, '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),
Expand Down Expand Up @@ -91,4 +130,11 @@ export const tools: Tools = {
},
},
},
eval: {
mcp: {
inputSchema: projectEvalInputSchema,
description: projectEvalDescription,
name: 'project_eval',
},
},
} as const;
Loading