From 2d5ae034c959a0440cea878b2ee73a89d87052dc Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Mon, 17 Mar 2025 18:30:33 +0100 Subject: [PATCH] chore: Enhance MCP server execution and compatibility with Cursor mode - Introduce environment variables for Cursor compatibility in silent-mcp.sh and npx-entry.cjs - Implement process cleanup for existing MCP instances to prevent conflicts - Adjust logging behavior based on execution context to ensure proper message handling - Add test-cursor.sh script to simulate Cursor environment for testing purposes - Refactor stdio-server.ts to manage logging and message flushing based on compatibility mode --- bin/npx-entry.cjs | 129 +++++++++++++++++++++++++++++++++----------- silent-mcp.sh | 19 +++++-- src/stdio-server.ts | 81 +++++++++++++++++++++------- test-cursor.sh | 13 +++++ 4 files changed, 187 insertions(+), 55 deletions(-) create mode 100755 test-cursor.sh diff --git a/bin/npx-entry.cjs b/bin/npx-entry.cjs index 7c968ae..786b79c 100755 --- a/bin/npx-entry.cjs +++ b/bin/npx-entry.cjs @@ -4,9 +4,24 @@ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); -// Set environment variable - enable stdio transport and silence output +// Set environment variable - enable stdio transport process.env.USE_STDIO_TRANSPORT = 'true'; -process.env.LOG_LEVEL = 'silent'; + +// Check if we're being called from Cursor (check for Cursor specific env vars) +const isCursor = process.env.CURSOR_SESSION || process.env.CURSOR_CHANNEL; + +// For Cursor, we need to ensure consistent stdio handling +if (isCursor) { + // Essential for Cursor compatibility + process.env.LOG_LEVEL = 'info'; + process.env.CURSOR_COMPATIBLE = 'true'; + + // Ensure we have a clean environment for Cursor + delete process.env.SILENT_MCP_RUNNING; +} else { + // For normal operation, silence logs + process.env.LOG_LEVEL = 'silent'; +} // Ensure logs directory exists const logsDir = path.join(process.cwd(), 'logs'); @@ -22,15 +37,80 @@ if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) { fs.copyFileSync(envExamplePath, envPath); } -// Start the MCP server with redirected stderr -try { - // Use our silent-mcp.sh script if it exists, otherwise use mcp-stdio.cjs - const silentScriptPath = path.join(process.cwd(), 'silent-mcp.sh'); +// Define a function to ensure the child process is properly cleaned up on exit +function setupCleanExit(childProcess) { + const exitHandler = () => { + if (childProcess && !childProcess.killed) { + childProcess.kill(); + } + process.exit(); + }; - if (fs.existsSync(silentScriptPath) && fs.statSync(silentScriptPath).isFile()) { - // Execute the silent-mcp.sh script instead - const childProcess = spawn('/bin/bash', [silentScriptPath], { + // Handle various termination signals + process.on('SIGINT', exitHandler); + process.on('SIGTERM', exitHandler); + process.on('exit', exitHandler); +} + +// Start the MCP server +try { + // Critical: For Cursor, we need a very specific execution environment + if (isCursor) { + // Careful process cleanup for Cursor (optional but can help) + try { + const { execSync } = require('child_process'); + execSync('pkill -f "node.*stdio-server" || true', { stdio: 'ignore' }); + } catch (e) { + // Ignore errors from process cleanup + } + + // Allow some time for process cleanup + setTimeout(() => { + const scriptPath = path.join(__dirname, 'mcp-stdio.cjs'); + + // For Cursor, we need very specific stdio handling + // Using pipe for both stdin and stdout is critical + const childProcess = spawn('node', [scriptPath], { + stdio: ['pipe', 'pipe', 'pipe'], // All piped for maximum control + env: { + ...process.env, + USE_STDIO_TRANSPORT: 'true', + CURSOR_COMPATIBLE: 'true', + // Make sure stdin/stdout are treated as binary + NODE_OPTIONS: '--no-force-async-hooks-checks' + } + }); + + // Ensure no buffering to prevent missed messages + childProcess.stdin.setDefaultEncoding('utf8'); + + // Create bidirectional pipes + process.stdin.pipe(childProcess.stdin); + childProcess.stdout.pipe(process.stdout); + childProcess.stderr.pipe(process.stderr); + + // Setup error handling + childProcess.on('error', (err) => { + console.error('Failed to start server:', err.message); + process.exit(1); + }); + + // Ensure child process is properly cleaned up + setupCleanExit(childProcess); + + }, 500); // Short delay to ensure clean start + } + // For regular use, if silent-mcp.sh exists, use it + else if (!isCursor && fs.existsSync(path.join(process.cwd(), 'silent-mcp.sh')) && + fs.statSync(path.join(process.cwd(), 'silent-mcp.sh')).isFile()) { + // Execute the silent-mcp.sh script + const childProcess = spawn('/bin/bash', [path.join(process.cwd(), 'silent-mcp.sh')], { stdio: ['inherit', 'inherit', 'ignore'], // Redirect stderr to /dev/null + env: { + ...process.env, + USE_STDIO_TRANSPORT: 'true', + LOG_LEVEL: 'silent' + } }); childProcess.on('error', (err) => { @@ -38,25 +118,18 @@ try { process.exit(1); }); - // Properly handle process termination - process.on('SIGINT', () => { - childProcess.kill('SIGINT'); - }); - - process.on('SIGTERM', () => { - childProcess.kill('SIGTERM'); - }); - } else { - // Fall back to original method if silent-mcp.sh doesn't exist + // Ensure child process is properly cleaned up + setupCleanExit(childProcess); + } + // Otherwise run normally (direct non-Cursor) + else { const scriptPath = path.join(__dirname, 'mcp-stdio.cjs'); - // Use 'pipe' for stdout and ignore (null) for stderr const childProcess = spawn('node', [scriptPath], { - stdio: ['inherit', 'pipe', 'ignore'], // Redirect stderr to /dev/null + stdio: ['inherit', 'pipe', 'ignore'], // Redirect stderr to /dev/null for normal use env: { ...process.env, - USE_STDIO_TRANSPORT: 'true', - LOG_LEVEL: 'silent' + USE_STDIO_TRANSPORT: 'true' } }); @@ -68,14 +141,8 @@ try { process.exit(1); }); - // Properly handle process termination - process.on('SIGINT', () => { - childProcess.kill('SIGINT'); - }); - - process.on('SIGTERM', () => { - childProcess.kill('SIGTERM'); - }); + // Ensure child process is properly cleaned up + setupCleanExit(childProcess); } } catch (error) { console.error('Error starting server:', error.message); diff --git a/silent-mcp.sh b/silent-mcp.sh index 3bee3c6..5381ca5 100755 --- a/silent-mcp.sh +++ b/silent-mcp.sh @@ -1,14 +1,25 @@ #!/bin/bash +# Ensure we're running in a clean environment for MCP # Set silent environment variables export LOG_LEVEL=silent export USE_STDIO_TRANSPORT=true -# Check if we're running from npx or directly +# Explicitly mark that we are NOT in Cursor mode +export CURSOR_COMPATIBLE=false + +# Flag to prevent recursive execution +export SILENT_MCP_RUNNING=true + +# Clean up any existing processes - optional but can help with "already" errors +# pkill -f "node.*stdio-server" >/dev/null 2>&1 || true + +# Direct execution - always use local file if [ -f "./dist/stdio-server.js" ]; then - # Direct run from project directory - use local file + # Keep stdout intact (for JSON-RPC messages) but redirect stderr to /dev/null node ./dist/stdio-server.js 2>/dev/null else - # Run using npx - npx homeassistant-mcp 2>/dev/null + # If no local file, run directly through node using the globally installed package + # This avoids calling npx again which would create a loop + node $(npm root -g)/homeassistant-mcp/dist/stdio-server.js 2>/dev/null fi \ No newline at end of file diff --git a/src/stdio-server.ts b/src/stdio-server.ts index badd2d4..51deecf 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -5,8 +5,10 @@ * over standard input/output using JSON-RPC 2.0 protocol. */ -// Force silent logging -process.env.LOG_LEVEL = 'silent'; +// Only force silent logging if not in Cursor compatibility mode +if (!process.env.CURSOR_COMPATIBLE) { + process.env.LOG_LEVEL = 'silent'; +} import { createStdioServer, BaseTool } from "./mcp/index.js"; import { z } from "zod"; @@ -17,18 +19,36 @@ import { MCPContext } from "./mcp/types.js"; import { LightsControlTool } from './tools/homeassistant/lights.tool.js'; import { ClimateControlTool } from './tools/homeassistant/climate.tool.js'; -// Check for silent startup mode - never silent in npx mode to ensure the JSON-RPC messages are sent -const silentStartup = true; +// Check for Cursor compatibility mode +const isCursorMode = process.env.CURSOR_COMPATIBLE === 'true'; +// Use silent startup except in Cursor mode +const silentStartup = !isCursorMode; const debugMode = process.env.DEBUG_STDIO === 'true'; -// Send a notification directly to stdout for Cursor compatibility +// Configure raw I/O handling if necessary +if (isCursorMode) { + // Ensure stdout doesn't buffer for Cursor + process.stdout.setDefaultEncoding('utf8'); + // Only try to set raw mode if it's a TTY and the method exists + if (process.stdout.isTTY && typeof (process.stdout as any).setRawMode === 'function') { + (process.stdout as any).setRawMode(true); + } +} + +// Send a notification directly to stdout for compatibility function sendNotification(method: string, params: any): void { const notification = { jsonrpc: '2.0', method, params }; - process.stdout.write(JSON.stringify(notification) + '\n'); + const message = JSON.stringify(notification) + '\n'; + process.stdout.write(message); + + // For Cursor mode, ensure messages are flushed if method exists + if (isCursorMode && typeof (process.stdout as any).flush === 'function') { + (process.stdout as any).flush(); + } } // Create system tools @@ -79,19 +99,7 @@ async function main() { // Combine all tools const allTools = [...systemTools, ...haTools]; - // Create server with stdio transport - const { server, transport } = createStdioServer({ - silent: silentStartup, - debug: debugMode, - tools: allTools - }); - - // Explicitly set the server reference to ensure access to tools - if ('setServer' in transport && typeof transport.setServer === 'function') { - transport.setServer(server); - } - - // Send initial notifications directly to stdout for Cursor compatibility + // Send initial notifications BEFORE server initialization for Cursor compatibility // Send system info sendNotification('system.info', { name: 'Home Assistant Model Context Protocol Server', @@ -118,9 +126,42 @@ async function main() { tools: toolDefinitions }); - // Start the server + // Create server with stdio transport + const { server, transport } = createStdioServer({ + silent: silentStartup, + debug: debugMode, + tools: allTools + }); + + // Explicitly set the server reference to ensure access to tools + if ('setServer' in transport && typeof transport.setServer === 'function') { + transport.setServer(server); + } + + // Start the server after initial notifications await server.start(); + // In Cursor mode, send notifications again after startup + if (isCursorMode) { + // Small delay to ensure all messages are processed + setTimeout(() => { + // Send system info again + sendNotification('system.info', { + name: 'Home Assistant Model Context Protocol Server', + version: '1.0.0', + transport: 'stdio', + protocol: 'json-rpc-2.0', + features: ['streaming'], + timestamp: new Date().toISOString() + }); + + // Send available tools again + sendNotification('tools.available', { + tools: toolDefinitions + }); + }, 100); + } + // Handle process exit process.on('SIGINT', async () => { await server.shutdown(); diff --git a/test-cursor.sh b/test-cursor.sh new file mode 100755 index 0000000..51b4cc6 --- /dev/null +++ b/test-cursor.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Clean up any existing processes first +pkill -f "node.*stdio-server" >/dev/null 2>&1 || true + +# Simulate Cursor environment by setting env variables +export CURSOR_SESSION=test-session +export CURSOR_COMPATIBLE=true +export USE_STDIO_TRANSPORT=true +export LOG_LEVEL=info + +# Run npx with the simulated environment +npx homeassistant-mcp \ No newline at end of file