src / mcp-proxy.ts
import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events";
import * as readline from "readline";
import * as path from "path";
import * as fs from "fs";
export interface MCPToolParameter {
type: string;
description?: string;
required?: boolean;
default?: any;
enum?: string[];
items?: any;
}
export interface MCPTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, MCPToolParameter>;
required?: string[];
};
}
export class MCPProxyClient extends EventEmitter {
private process: ChildProcess | null = null;
private requestId = 0;
private pendingRequests = new Map<number | string, {
resolve: (value: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}>();
private rl: readline.Interface | null = null;
private tools: MCPTool[] = [];
private initialized = false;
private started = false;
constructor(
private pythonCommand: string,
private serverPath: string,
private workDir: string,
private enableLogging: boolean = false
) {
super();
// Prevent unhandled 'error' event from crashing the process
this.on("error", (err) => {
console.error("[MCP-Proxy] Unhandled error event:", err.message);
});
}
private getPythonExecutable(): string {
// 1. Try venv Python first (most reliable)
const venvPython = process.platform === "win32"
? path.join(this.workDir, "TWStockMCPServer", "venv", "Scripts", "python.exe")
: path.join(this.workDir, "TWStockMCPServer", "venv", "bin", "python");
if (fs.existsSync(venvPython)) {
if (this.enableLogging) {
console.log(`[MCP-Proxy] Found venv Python: ${venvPython}`);
}
return venvPython;
}
if (this.enableLogging) {
console.log(`[MCP-Proxy] venv python not found at: ${venvPython}`);
}
// 2. If configured command is an absolute path and exists, use it
if (path.isAbsolute(this.pythonCommand) && fs.existsSync(this.pythonCommand)) {
return this.pythonCommand;
}
// 3. Try system Python fallbacks (these are usually in PATH)
// For LM Studio plugin context, PATH might be restricted, so prefer absolute venv path.
// If configured command is "uv" or "python", we return it and hope it's in PATH
// or we use absolute venv path found above.
return this.pythonCommand;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
const serverFullPath = path.resolve(this.workDir, this.serverPath);
if (!fs.existsSync(serverFullPath)) {
const err = new Error(`Server file not found: ${serverFullPath}`);
console.error(`[MCP-Proxy] ${err.message}`);
// Don't throw - emit error instead so caller can handle it
this.emit("error", err);
return;
}
const pythonExe = this.getPythonExecutable();
const args = [serverFullPath];
// Inherit PATH but also ensure common Python locations are included
const env = {
...process.env,
MCP_STDIO: "1",
PYTHONUNBUFFERED: "1",
};
if (this.enableLogging) {
console.log(`[MCP-Proxy] Starting: ${pythonExe} ${args.join(" ")}`);
console.log(`[MCP-Proxy] Working directory: ${this.workDir}`);
console.log(`[MCP-Proxy] PATH: ${process.env.PATH}`);
}
try {
this.process = spawn(pythonExe, args, {
cwd: this.workDir,
env,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
console.error(`[MCP-Proxy] Failed to spawn process:`, err);
this.emit("error", err);
return;
}
this.process.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
const msg = `[MCP-Proxy] Python executable not found: "${pythonExe}". ` +
`Please ensure it is installed and in PATH, or run "npm run setup-python" to create the venv.`;
console.error(msg);
this.emit("error", new Error(msg));
} else {
console.error("[MCP-Proxy] Process error:", err);
this.emit("error", err);
}
});
this.process.on("exit", (code, signal) => {
if (this.enableLogging) {
console.log(`[MCP-Proxy] Process exited with code ${code}, signal ${signal}`);
}
this.cleanup();
this.emit("exit", code, signal);
});
if (this.process.stderr) {
this.process.stderr.on("data", (data) => {
const msg = data.toString().trim();
if (msg && this.enableLogging) {
console.log(`[MCP-Proxy][stderr] ${msg}`);
}
});
}
if (this.process.stdout) {
this.rl = readline.createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
this.rl.on("line", (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
if (this.enableLogging) {
console.log("[MCP-Proxy] Received:", JSON.stringify(msg).substring(0, 500));
}
this.handleMessage(msg);
} catch (e) {
if (this.enableLogging) {
console.log("[MCP-Proxy] Non-JSON line:", line);
}
}
});
}
try {
await this.initialize();
} catch (err) {
console.error("[MCP-Proxy] Initialization failed:", (err as Error).message);
this.emit("error", err);
}
}
private handleMessage(msg: any): void {
if (msg.id !== undefined) {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new Error(`MCP Error ${msg.error.code}: ${msg.error.message}`));
} else {
pending.resolve(msg.result);
}
}
} else {
this.emit("notification", msg);
}
}
private sendRequest(method: string, params?: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.process || !this.process.stdin) {
reject(new Error("MCP process not running"));
return;
}
const id = ++this.requestId;
const request = {
jsonrpc: "2.0",
id,
method,
params,
};
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}, 60000); // Increase to 60s for slow network calls
this.pendingRequests.set(id, { resolve, reject, timeout });
const msg = JSON.stringify(request);
if (this.enableLogging) {
console.log("[MCP-Proxy] Sending:", msg.substring(0, 500));
}
this.process.stdin.write(msg + "\n");
});
}
private sendNotification(method: string, params?: any): void {
if (!this.process || !this.process.stdin) return;
const notification = {
jsonrpc: "2.0",
method,
params,
};
const msg = JSON.stringify(notification);
if (this.enableLogging) {
console.log("[MCP-Proxy] Notification:", msg.substring(0, 500));
}
this.process.stdin.write(msg + "\n");
}
private async initialize(): Promise<void> {
if (this.initialized) return;
await this.sendRequest("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: "tw-stock-mcp-plugin",
version: "1.0.0",
},
});
this.sendNotification("notifications/initialized", {});
const toolsResult = await this.sendRequest("tools/list", {});
this.tools = toolsResult.tools || [];
if (this.enableLogging) {
console.log(`[MCP-Proxy] Loaded ${this.tools.length} tools`);
}
this.initialized = true;
this.emit("ready");
}
async callTool(name: string, args: Record<string, any>): Promise<any> {
if (!this.initialized) {
throw new Error("MCP client not initialized");
}
return this.sendRequest("tools/call", { name, arguments: args });
}
getTools(): MCPTool[] {
return this.tools;
}
isReady(): boolean {
return this.initialized;
}
private cleanup(): void {
if (this.rl) {
this.rl.close();
this.rl = null;
}
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error("Process terminated"));
}
this.pendingRequests.clear();
this.process = null;
this.initialized = false;
this.started = false;
}
}src / mcp-proxy.ts
import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events";
import * as readline from "readline";
import * as path from "path";
import * as fs from "fs";
export interface MCPToolParameter {
type: string;
description?: string;
required?: boolean;
default?: any;
enum?: string[];
items?: any;
}
export interface MCPTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, MCPToolParameter>;
required?: string[];
};
}
export class MCPProxyClient extends EventEmitter {
private process: ChildProcess | null = null;
private requestId = 0;
private pendingRequests = new Map<number | string, {
resolve: (value: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}>();
private rl: readline.Interface | null = null;
private tools: MCPTool[] = [];
private initialized = false;
private started = false;
constructor(
private pythonCommand: string,
private serverPath: string,
private workDir: string,
private enableLogging: boolean = false
) {
super();
// Prevent unhandled 'error' event from crashing the process
this.on("error", (err) => {
console.error("[MCP-Proxy] Unhandled error event:", err.message);
});
}
private getPythonExecutable(): string {
// 1. Try venv Python first (most reliable)
const venvPython = process.platform === "win32"
? path.join(this.workDir, "TWStockMCPServer", "venv", "Scripts", "python.exe")
: path.join(this.workDir, "TWStockMCPServer", "venv", "bin", "python");
if (fs.existsSync(venvPython)) {
if (this.enableLogging) {
console.log(`[MCP-Proxy] Found venv Python: ${venvPython}`);
}
return venvPython;
}
if (this.enableLogging) {
console.log(`[MCP-Proxy] venv python not found at: ${venvPython}`);
}
// 2. If configured command is an absolute path and exists, use it
if (path.isAbsolute(this.pythonCommand) && fs.existsSync(this.pythonCommand)) {
return this.pythonCommand;
}
// 3. Try system Python fallbacks (these are usually in PATH)
// For LM Studio plugin context, PATH might be restricted, so prefer absolute venv path.
// If configured command is "uv" or "python", we return it and hope it's in PATH
// or we use absolute venv path found above.
return this.pythonCommand;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
const serverFullPath = path.resolve(this.workDir, this.serverPath);
if (!fs.existsSync(serverFullPath)) {
const err = new Error(`Server file not found: ${serverFullPath}`);
console.error(`[MCP-Proxy] ${err.message}`);
// Don't throw - emit error instead so caller can handle it
this.emit("error", err);
return;
}
const pythonExe = this.getPythonExecutable();
const args = [serverFullPath];
// Inherit PATH but also ensure common Python locations are included
const env = {
...process.env,
MCP_STDIO: "1",
PYTHONUNBUFFERED: "1",
};
if (this.enableLogging) {
console.log(`[MCP-Proxy] Starting: ${pythonExe} ${args.join(" ")}`);
console.log(`[MCP-Proxy] Working directory: ${this.workDir}`);
console.log(`[MCP-Proxy] PATH: ${process.env.PATH}`);
}
try {
this.process = spawn(pythonExe, args, {
cwd: this.workDir,
env,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
console.error(`[MCP-Proxy] Failed to spawn process:`, err);
this.emit("error", err);
return;
}
this.process.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
const msg = `[MCP-Proxy] Python executable not found: "${pythonExe}". ` +
`Please ensure it is installed and in PATH, or run "npm run setup-python" to create the venv.`;
console.error(msg);
this.emit("error", new Error(msg));
} else {
console.error("[MCP-Proxy] Process error:", err);
this.emit("error", err);
}
});
this.process.on("exit", (code, signal) => {
if (this.enableLogging) {
console.log(`[MCP-Proxy] Process exited with code ${code}, signal ${signal}`);
}
this.cleanup();
this.emit("exit", code, signal);
});
if (this.process.stderr) {
this.process.stderr.on("data", (data) => {
const msg = data.toString().trim();
if (msg && this.enableLogging) {
console.log(`[MCP-Proxy][stderr] ${msg}`);
}
});
}
if (this.process.stdout) {
this.rl = readline.createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
this.rl.on("line", (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
if (this.enableLogging) {
console.log("[MCP-Proxy] Received:", JSON.stringify(msg).substring(0, 500));
}
this.handleMessage(msg);
} catch (e) {
if (this.enableLogging) {
console.log("[MCP-Proxy] Non-JSON line:", line);
}
}
});
}
try {
await this.initialize();
} catch (err) {
console.error("[MCP-Proxy] Initialization failed:", (err as Error).message);
this.emit("error", err);
}
}
private handleMessage(msg: any): void {
if (msg.id !== undefined) {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new Error(`MCP Error ${msg.error.code}: ${msg.error.message}`));
} else {
pending.resolve(msg.result);
}
}
} else {
this.emit("notification", msg);
}
}
private sendRequest(method: string, params?: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.process || !this.process.stdin) {
reject(new Error("MCP process not running"));
return;
}
const id = ++this.requestId;
const request = {
jsonrpc: "2.0",
id,
method,
params,
};
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}, 60000); // Increase to 60s for slow network calls
this.pendingRequests.set(id, { resolve, reject, timeout });
const msg = JSON.stringify(request);
if (this.enableLogging) {
console.log("[MCP-Proxy] Sending:", msg.substring(0, 500));
}
this.process.stdin.write(msg + "\n");
});
}
private sendNotification(method: string, params?: any): void {
if (!this.process || !this.process.stdin) return;
const notification = {
jsonrpc: "2.0",
method,
params,
};
const msg = JSON.stringify(notification);
if (this.enableLogging) {
console.log("[MCP-Proxy] Notification:", msg.substring(0, 500));
}
this.process.stdin.write(msg + "\n");
}
private async initialize(): Promise<void> {
if (this.initialized) return;
await this.sendRequest("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: "tw-stock-mcp-plugin",
version: "1.0.0",
},
});
this.sendNotification("notifications/initialized", {});
const toolsResult = await this.sendRequest("tools/list", {});
this.tools = toolsResult.tools || [];
if (this.enableLogging) {
console.log(`[MCP-Proxy] Loaded ${this.tools.length} tools`);
}
this.initialized = true;
this.emit("ready");
}
async callTool(name: string, args: Record<string, any>): Promise<any> {
if (!this.initialized) {
throw new Error("MCP client not initialized");
}
return this.sendRequest("tools/call", { name, arguments: args });
}
getTools(): MCPTool[] {
return this.tools;
}
isReady(): boolean {
return this.initialized;
}
private cleanup(): void {
if (this.rl) {
this.rl.close();
this.rl = null;
}
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error("Process terminated"));
}
this.pendingRequests.clear();
this.process = null;
this.initialized = false;
this.started = false;
}
}