server.js•13 kB
#!/usr/bin/env node
/**
* Interactive Feedback MCP Server - Node.js Implementation
* Compliant with MCP Specification and JSON-RPC 2.0
*
* Author: STMMO Project
* Version: 1.0.0
*/
// Load environment variables from .env file
// Ensure .env is loaded from the script directory, not the current working directory
const path = require('path');
const envPath = path.join(__dirname, '.env');
const dotenvResult = require('dotenv').config({ path: envPath });
// Debug logging for environment loading
if (dotenvResult.error) {
console.error('❌ Error loading .env file:', dotenvResult.error.message);
console.error(' Expected path:', envPath);
} else {
console.log('✅ Environment variables loaded from:', envPath);
console.log(' OPENAI_API_KEY exists:', !!process.env.OPENAI_API_KEY);
console.log(' WHISPER_LANGUAGE:', process.env.WHISPER_LANGUAGE || 'not set');
}
const fs = require('fs-extra');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
/**
* Get first line from text
* @param {string} text - Input text
* @returns {string} First line trimmed
*/
function firstLine(text) {
if (!text || typeof text !== 'string') {
return '';
}
return text.split('\n')[0].trim();
}
/**
* Launch Web UI and wait for feedback result
* @param {string} projectDirectory - Project directory
* @param {string} summary - Request summary
* @returns {Promise<Object>} Feedback result from UI
*/
async function launchFeedbackUI(projectDirectory, summary) {
// Create temporary file for result
const tempDir = os.tmpdir();
const uuid = crypto.randomUUID();
const outputFile = path.join(tempDir, `feedback-${uuid}.json`);
try {
// Get path to web-ui.js
const scriptDir = __dirname;
const webUIPath = path.join(scriptDir, 'web-ui.js');
// Prepare arguments for web UI process
const args = [
webUIPath,
'--project-directory', projectDirectory,
'--prompt', summary,
'--output-file', outputFile
];
// Spawn Web UI process
const childProcess = spawn('node', args, {
stdio: ['ignore', 'ignore', 'ignore'],
detached: false
});
// Wait for process completion
await new Promise((resolve, reject) => {
childProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Web UI process exited with code ${code}`));
}
});
childProcess.on('error', (error) => {
reject(error);
});
});
// Read result from temp file
const result = await fs.readJson(outputFile);
// Cleanup temp file
await fs.unlink(outputFile);
return result;
} catch (error) {
// Cleanup temp file if error occurs
try {
await fs.unlink(outputFile);
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Wrapper function for interactive feedback
* @param {string} projectDirectory - Project directory
* @param {string} summary - Request summary
* @returns {Promise<Object>} Feedback result
*/
async function interactiveFeedback(projectDirectory, summary) {
// Validate OPENAI_API_KEY before proceeding
if (!process.env.OPENAI_API_KEY) {
const error = new Error('OpenAI API key not configured. Please set OPENAI_API_KEY in your .env file.');
console.error('❌ API Key Validation Failed:', error.message);
console.error(' Expected .env path:', path.join(__dirname, '.env'));
console.error(' Current working directory:', process.cwd());
console.error(' Script directory (__dirname):', __dirname);
throw error;
}
// Validate API key format
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey.startsWith('sk-') || apiKey.length < 20) {
const error = new Error('Invalid OpenAI API key format. Key should start with "sk-" and be at least 20 characters long.');
console.error('❌ API Key Format Validation Failed:', error.message);
console.error(' Key length:', apiKey.length);
console.error(' Key prefix:', apiKey.substring(0, 3));
throw error;
}
console.log('✅ API Key validation passed for interactive feedback');
// Apply firstLine only to projectDirectory to ensure it's a valid path
// Keep summary intact to preserve multi-line content
const cleanProjectDirectory = firstLine(projectDirectory);
const cleanSummary = summary || 'I implemented the changes you requested.';
return await launchFeedbackUI(cleanProjectDirectory, cleanSummary);
}
/**
* MCP Server Class
* Handles communication with AI assistants via MCP protocol
* Compliant with MCP Specification and JSON-RPC 2.0
*/
class MCPServer {
constructor() {
this.initialized = false;
this.clientCapabilities = null;
// Initialize tools object with interactive_feedback tool
this.tools = {
interactive_feedback: {
description: 'Request interactive feedback for a given project directory and summary',
inputSchema: {
type: 'object',
properties: {
project_directory: {
type: 'string',
description: 'Path to the project directory'
},
summary: {
type: 'string',
description: 'Summary of the request or context'
}
},
required: ['project_directory', 'summary']
},
handler: interactiveFeedback
}
};
// Server capabilities
this.serverCapabilities = {
tools: {
listChanged: false
}
};
// Server info
this.serverInfo = {
name: 'interactive-feedback-mcp',
version: '1.0.0'
};
}
/**
* Launch MCP Server
*/
run() {
// Setup stdin encoding
process.stdin.setEncoding('utf8');
// Listen for stdin data events
process.stdin.on('data', async (data) => {
try {
const lines = data.trim().split('\n');
for (const line of lines) {
if (line.trim()) {
const request = JSON.parse(line);
const response = await this.handleRequest(request);
if (response) {
this.sendMessage(response);
}
}
}
} catch (error) {
// Send error response
const errorResponse = {
jsonrpc: '2.0',
id: null,
error: {
code: -32700,
message: 'Parse error',
data: error.message
}
};
this.sendMessage(errorResponse);
}
});
// Handle process termination
process.on('SIGINT', () => {
process.exit(0);
});
process.on('SIGTERM', () => {
process.exit(0);
});
}
/**
* Send message to stdout
* @param {Object} message - Message to send
*/
sendMessage(message) {
console.log(JSON.stringify(message));
}
/**
* Handle MCP request
* @param {Object} request - MCP request object
* @returns {Object|null} MCP response object or null for notifications
*/
async handleRequest(request) {
try {
// Validate JSON-RPC 2.0 format
if (request.jsonrpc !== '2.0') {
return {
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32600,
message: 'Invalid Request',
data: 'Invalid JSON-RPC version'
}
};
}
switch (request.method) {
case 'initialize':
return this.handleInitialize(request);
case 'initialized':
// Notification - no response needed
this.initialized = true;
return null;
case 'tools/list':
return this.handleToolsList(request);
case 'tools/call':
return await this.handleToolsCall(request);
default:
return {
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32601,
message: 'Method not found',
data: `Unknown method: ${request.method}`
}
};
}
} catch (error) {
return {
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32603,
message: 'Internal error',
data: error.message
}
};
}
}
/**
* Handle initialize request
* @param {Object} request - Initialize request
* @returns {Object} Initialize response
*/
handleInitialize(request) {
const { params } = request;
// Store client capabilities
this.clientCapabilities = params.capabilities || {};
// Return server capabilities and info
return {
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: '2024-11-05',
capabilities: this.serverCapabilities,
serverInfo: this.serverInfo
}
};
}
/**
* Handle tools/list request
* @param {Object} request - Tools list request
* @returns {Object} Tools list response
*/
handleToolsList(request) {
const tools = Object.keys(this.tools).map(name => ({
name,
description: this.tools[name].description,
inputSchema: this.tools[name].inputSchema
}));
return {
jsonrpc: '2.0',
id: request.id,
result: {
tools
}
};
}
/**
* Handle tools/call request
* @param {Object} request - Tools call request
* @returns {Object} Tools call response
*/
async handleToolsCall(request) {
const { name: toolName, arguments: toolArgs } = request.params;
// Validate tool exists
if (!this.tools[toolName]) {
return {
jsonrpc: '2.0',
id: request.id,
error: {
code: -32602,
message: 'Invalid params',
data: `Unknown tool: ${toolName}`
}
};
}
try {
// Call tool handler
const result = await this.tools[toolName].handler(
toolArgs.project_directory,
toolArgs.summary
);
// Return MCP response format
return {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
}
};
} catch (error) {
return {
jsonrpc: '2.0',
id: request.id,
error: {
code: -32603,
message: 'Internal error',
data: `Tool execution failed: ${error.message}`
}
};
}
}
}
// Command line interface
if (require.main === module) {
try {
const server = new MCPServer();
server.run();
} catch (error) {
console.error('Error starting MCP Server:', error.message);
process.exit(1);
}
}
// Module exports
module.exports = {
MCPServer,
interactiveFeedback,
launchFeedbackUI,
firstLine
};