UNPKG

@gsb-core/backend-mcp

Version:

GSB MCP Server CLI for AI editors - supports environment variables and -y flag to skip prompts

593 lines (533 loc) 25.2 kB
#!/usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import chalk from 'chalk'; import inquirer from 'inquirer'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { spawn } from 'child_process'; import { createInterface as createReadlineInterface } from 'node:readline'; // Renamed to avoid conflict import { EventEmitter } from 'events'; // MCP SDK imports (adjust paths if necessary, assuming @modelcontextprotocol/sdk is installed) import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ListToolsResultSchema, CallToolResultSchema, ListPromptsResultSchema, GetPromptResultSchema, ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js'; // Adjust if types are structured differently // Get the current file's directory const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const argv = yargs(hideBin(process.argv)) .usage('Usage: $0 [options]') .option('port', { alias: 'p', describe: 'Port to run the server on', default: 6278, type: 'number' }) .option('config', { alias: 'c', describe: 'Path to configuration file', type: 'string' }) .option('gsb-api-url', { describe: 'GSB API URL', type: 'string' }) .option('gsb-api-key', { describe: 'GSB API Key', type: 'string' }) .option('gsb-tenant-code', { describe: 'GSB Tenant Code', type: 'string' }) .option('shared-secret', { describe: 'Shared secret for authentication', type: 'string' }) .option('debug', { alias: 'd', describe: 'Run in debug mode', type: 'boolean', default: false }) .option('yes', { alias: 'y', describe: 'Skip all prompts and use environment variables only', type: 'boolean', default: false }) .option('interactive', { alias: 'i', describe: 'Run in interactive mode with a command-line interface', type: 'boolean', default: false }) .help() .alias('help', 'h') .version() .alias('version', 'v') .parse(); // --- Interactive Mode Globals --- let interactiveClient = null; let interactiveTransport = null; let interactiveServerProcess = null; let interactiveReadline = null; let notificationCount = 0; let notificationsToolLastEventId = undefined; let currentSessionId = undefined; // For potential session resumption logic if StdioTransport supports it // --- End Interactive Mode Globals --- function logInfo(message) { if (argv.interactive || !argv.yes) { // Show logs in interactive mode or if not strictly silent console.log(message); } } function logError(message) { if (argv.interactive || !argv.yes) { console.error(message); } } const displayLogoAndWelcome = () => { const logo = ` ██████╗ ███████╗██████╗ ███╗ ███╗ ██████╗██████╗ ██╔════╝ ██╔════╝██╔══██╗ ████╗ ████║██╔════╝██╔══██╗ ██║ ███╗███████╗██████╔╝ ██╔████╔██║██║ ██████╔╝ ██║ ██║╚════██║██╔══██╗ ██║╚██╔╝██║██║ ██╔═══╝ ╚██████╔╝███████║██████╔╝ ██║ ╚═╝ ██║╚██████╗██║ ╚═════╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ `; console.log(chalk.blue(logo)); console.log(chalk.green('Welcome to GSB MCP Server CLI - Interactive Mode')); console.log(chalk.yellow('This tool helps you interact with the MCP server.\n')); }; class StdioClientTransport extends EventEmitter { constructor(serverProcess) { super(); this.serverProcess = serverProcess; this.readline = createReadlineInterface({ input: this.serverProcess.stdout, crlfDelay: Infinity }); this.sessionId = `stdio-session-${Date.now()}`; // Mock session ID, MCP over stdio usually doesn't have complex sessions this.readline.on('line', (line) => { if (argv.debug) logInfo(`[Transport RECV]: ${line}`); this.emit('message', line); }); this.serverProcess.on('close', (code) => { logInfo(`Server process exited with code ${code}`); this.emit('close', code); }); this.serverProcess.on('error', (err) => { logError(`Server process error: ${err}`); this.emit('error', err); }); this.serverProcess.stderr.on('data', (data) => { // Stderr from server might be actual errors or debug info // In interactive mode, we log it more visibly. logError(chalk.red(`[Server STDERR]: ${data.toString().trim()}`)); }); } async connect() { // For stdio, connection is implicit with process spawn logInfo('StdioClientTransport: Server process is considered connected.'); return Promise.resolve(); } async send(jsonRpcString) { if (argv.debug) logInfo(`[Transport SEND]: ${jsonRpcString}`); this.serverProcess.stdin.write(jsonRpcString + '\\n'); } async close() { logInfo('StdioClientTransport: Closing connection and terminating server process...'); if (this.serverProcess && !this.serverProcess.killed) { this.serverProcess.kill(); } this.readline.close(); this.removeAllListeners(); // Clean up listeners to prevent memory leaks return Promise.resolve(); } async terminateSession() { // For stdio, terminating session is effectively closing the connection logInfo('StdioClientTransport: Terminating session (closing connection).'); await this.close(); } } async function main() { let config = {}; if (argv.config) { try { const configPath = path.resolve(process.cwd(), argv.config); const configData = fs.readFileSync(configPath, 'utf8'); config = JSON.parse(configData); logInfo(chalk.green(`Loaded configuration from ${configPath}`)); } catch (error) { logError(chalk.red(`Error loading config file: ${error.message}`)); process.exit(1); } } const envVars = { GSB_API_URL: argv['gsb-api-url'] || config.GSB_API_URL || process.env.GSB_API_URL, GSB_API_KEY: argv['gsb-api-key'] || config.GSB_API_KEY || process.env.GSB_API_KEY, GSB_TENANT_CODE: argv['gsb-tenant-code'] || config.GSB_TENANT_CODE || process.env.GSB_TENANT_CODE, SHARED_SECRET: argv['shared-secret'] || config.SHARED_SECRET || process.env.SHARED_SECRET, DEBUG: argv.debug ? 'true' : '' // Pass debug flag to server }; const missingEnvVars = Object.entries(envVars) .filter(([key]) => !['SHARED_SECRET', 'DEBUG'].includes(key)) // SHARED_SECRET is optional, DEBUG is for server .filter(([_, value]) => !value) .map(([key]) => key); if (argv.interactive) { displayLogoAndWelcome(); if (missingEnvVars.length > 0 && !argv.yes) { logInfo(chalk.yellow('Some required GSB connection parameters are missing.')); const prompts = []; if (!envVars.GSB_API_URL) prompts.push({ type: 'input', name: 'GSB_API_URL', message: 'Enter GSB API URL (defaults to gsbapps.net):', default: 'https://gsbapps.net' }); if (!envVars.GSB_API_KEY) prompts.push({ type: 'password', name: 'GSB_API_KEY', message: 'Enter GSB API Key:', validate: input => input ? true : 'GSB API Key is required', mask: '*' }); if (!envVars.GSB_TENANT_CODE) prompts.push({ type: 'input', name: 'GSB_TENANT_CODE', message: 'Enter GSB Tenant Code:' }); const answers = await inquirer.prompt(prompts); Object.assign(envVars, answers); } else if (missingEnvVars.length > 0 && argv.yes) { logInfo(chalk.yellow(`Running in interactive mode with -y and missing variables: ${missingEnvVars.join(', ')}`)); // Set defaults for interactive -y mode for GSB variables if (!envVars.GSB_API_URL) envVars.GSB_API_URL = 'https://gsbapps.net'; if (!envVars.GSB_API_KEY) envVars.GSB_API_KEY = 'demo-key'; if (!envVars.GSB_TENANT_CODE) envVars.GSB_TENANT_CODE = 'demo'; logInfo(chalk.yellow(`Using defaults: URL=${envVars.GSB_API_URL}, Key=${envVars.GSB_API_KEY}, Tenant=${envVars.GSB_TENANT_CODE}`)); } await startInteractive(envVars, argv.port, argv.debug); } else { if (missingEnvVars.length > 0) { if (!argv.yes) { logError(chalk.red(`Missing required GSB environment variables: ${missingEnvVars.join(', ')}`)); logError(chalk.red('Provide them via arguments, config file, environment, or use -y to accept defaults (not recommended for production).')); process.exit(1); } logInfo(chalk.yellow(`Using -y flag with missing environment variables: ${missingEnvVars.join(', ')}`)); logInfo(chalk.yellow('Using default values for missing variables:')); if (!envVars.GSB_API_URL) { envVars.GSB_API_URL = 'https://gsbapps.net'; logInfo(chalk.yellow(`- GSB_API_URL: ${envVars.GSB_API_URL}`)); } if (!envVars.GSB_API_KEY) { envVars.GSB_API_KEY = 'demo-key'; logInfo(chalk.yellow(`- GSB_API_KEY: ${envVars.GSB_API_KEY}`)); } if (!envVars.GSB_TENANT_CODE) { envVars.GSB_TENANT_CODE = 'demo'; logInfo(chalk.yellow(`- GSB_TENANT_CODE: ${envVars.GSB_TENANT_CODE}`)); } logInfo(chalk.yellow('Note: Default values may not connect to a real GSB instance.')); } startSilent(envVars, argv.port, argv.debug); } } function spawnServerProcess(envOpts, port, debugMode) { const serverEnv = { ...process.env, GSB_API_URL: envOpts.GSB_API_URL, GSB_API_KEY: envOpts.GSB_API_KEY, GSB_TENANT_CODE: envOpts.GSB_TENANT_CODE, SHARED_SECRET: envOpts.SHARED_SECRET, PORT: port.toString(), // Ensure port is a string for env NODE_ENV: debugMode ? 'development' : 'production', // Convention for some Node apps DEBUG: envOpts.DEBUG // Pass along our own debug state }; const serverScriptPath = path.join(__dirname, "..", "dist", "server.js"); if (debugMode) { logInfo(chalk.gray(`Spawning server: node ${serverScriptPath} with PORT=${port}`)); logInfo(chalk.gray(`Server ENV: GSB_API_URL=${serverEnv.GSB_API_URL}, GSB_TENANT_CODE=${serverEnv.GSB_TENANT_CODE}, DEBUG=${serverEnv.DEBUG}`)); } return spawn("node", [serverScriptPath], { env: serverEnv, stdio: ['pipe', 'pipe', 'pipe'] // Important for parent-child IPC }); } function startSilent(envOpts, port, debugMode) { logInfo(chalk.blue('Starting MCP server in silent mode...')); const serverProc = spawnServerProcess(envOpts, port, debugMode); serverProc.stdout.on('data', (data) => { process.stdout.write(data); // Pass server's stdout (JSON-RPC) directly }); serverProc.stderr.on('data', (data) => { // In silent mode, prefix stderr to distinguish from potential JSON on stdout process.stderr.write(chalk.red(`[Server Error]: ${data.toString()}`)); }); serverProc.on('close', (code) => { if (code !== 0) { logError(`Silent mode: Server process exited with code ${code}`); } if (debugMode) { logInfo('Silent mode: Server process closed'); } process.exit(code ?? 0); }); serverProc.on('error', (err) => { logError(`Failed to start server process in silent mode: ${err.message}`); process.exit(1); }); const cleanup = () => { if (serverProc && !serverProc.killed) { logInfo("Silent mode: Shutting down server..."); serverProc.kill(); } }; process.on('SIGINT', () => { logInfo("\nSIGINT received."); cleanup(); process.exit(0); }); process.on('SIGTERM', () => { logInfo("SIGTERM received."); cleanup(); process.exit(0); }); } async function startInteractive(envOpts, port, debugMode) { interactiveServerProcess = spawnServerProcess(envOpts, port, debugMode); interactiveTransport = new StdioClientTransport(interactiveServerProcess); interactiveClient = new Client({ name: 'gsb-mcp-interactive-cli', version: '1.0.0', // transport will be connected via client.connect() }); interactiveClient.onerror = (error) => { logError(chalk.redBright(`\n[MCP Client Error]: ${error.message || error}`)); if (error.stack && argv.debug) logError(error.stack); process.stdout.write('\n> '); // Re-display prompt }; // Setup notification handlers from example interactiveClient.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { notificationCount++; console.log(chalk.cyan(`\n[Notification #${notificationCount}]: ${notification.params.level} - ${notification.params.data}`)); process.stdout.write('> '); }); interactiveClient.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { console.log(chalk.magenta('\n[Notification]: Resource list changed!')); try { if (!interactiveClient || !interactiveClient.isConnected) { // Check if client is still valid logError('Client disconnected, cannot fetch resources'); return; } // await listResourcesCmd(); // Optionally auto-list resources } catch (e) { logError(`Failed to list resources after change notification: ${e.message}`); } process.stdout.write('> '); }); interactiveReadline = createReadlineInterface({ input: process.stdin, output: process.stdout, prompt: chalk.gray('> ') }); try { logInfo('Connecting to the spawned MCP server...'); await interactiveClient.connect(interactiveTransport); currentSessionId = interactiveTransport.sessionId; // Or however Client exposes it logInfo(chalk.green(`Connected to MCP server. Session ID (stdio): ${currentSessionId}`)); printInteractiveHelp(); interactiveCommandLoop(envOpts); // Pass envOpts for GSB creds } catch (error) { logError(chalk.red(`Failed to connect to local MCP server: ${error.message}`)); if (error.stack && argv.debug) logError(error.stack); await cleanupInteractive(); } } function printInteractiveHelp() { console.log(chalk.yellow('\nAvailable interactive commands:')); console.log(' list-tools - List available tools from the server'); console.log(' call <toolName> [jsonArgs] - Call a tool (e.g., call getById {"definitionType":"User","id":"123"})'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt <name> [jsonArgs] - Get a prompt template'); console.log(' list-resources - List available resources'); console.log(' disconnect - Disconnect from the server (and kill it)'); console.log(' reconnect - Reconnect to a new server instance'); console.log(' terminate-session - Terminate current session (effectively disconnects for stdio)'); console.log(' status - Show connection status'); console.log(' help - Show this help'); console.log(' quit / exit - Exit the interactive CLI'); } function interactiveCommandLoop(envOpts) { interactiveReadline.prompt(); interactiveReadline.on('line', async (line) => { const input = line.trim(); if (!input) { interactiveReadline.prompt(); return; } const args = input.match(/(?:[^\s"]+|"[^"]*")+/g) || []; // Handle quoted args const command = args[0]?.toLowerCase(); const commandArgs = args.slice(1); try { switch (command) { case 'list-tools': await listToolsCmd(); break; case 'call': if (commandArgs.length < 1) { logError('Usage: call <toolName> [jsonArgs]'); } else { const toolName = commandArgs[0]; let toolJsonArgs = {}; if (commandArgs.length > 1) { try { // Remove quotes for JSON parsing if necessary const rawJsonArgs = commandArgs.slice(1).join(' '); toolJsonArgs = JSON.parse(rawJsonArgs); } catch (e) { logError(`Invalid JSON arguments for ${toolName}: ${e.message}. Example: {"key":"value"}`); break; } } // Automatically add GSB credentials toolJsonArgs.token = envOpts.GSB_API_KEY; toolJsonArgs.tenantCode = envOpts.GSB_TENANT_CODE; await callToolCmd(toolName, toolJsonArgs); } break; case 'list-prompts': await listPromptsCmd(); break; case 'get-prompt': if (commandArgs.length < 1) logError('Usage: get-prompt <name> [jsonArgs]'); else { const promptName = commandArgs[0]; let promptJsonArgs = {}; if (commandArgs.length > 1) try { promptJsonArgs = JSON.parse(commandArgs.slice(1).join(' ')); } catch (e) { logError(`Invalid JSON for prompt args: ${e.message}`); break; } await getPromptCmd(promptName, promptJsonArgs); } break; case 'list-resources': await listResourcesCmd(); break; case 'disconnect': await disconnectInteractive(); break; case 'reconnect': await reconnectInteractive(envOpts, argv.port, argv.debug); break; case 'terminate-session': await terminateSessionInteractive(); break; case 'status': logInfo(`Connected: ${interactiveClient && interactiveClient.isConnected}`); if (interactiveClient && interactiveClient.isConnected) logInfo(`Session ID: ${currentSessionId || 'N/A'}`); break; case 'help': printInteractiveHelp(); break; case 'quit': case 'exit': await cleanupInteractive(); return; // Exit loop default: if (command) logError(`Unknown command: ${command}. Type 'help' for available commands.`); break; } } catch (error) { logError(chalk.red(`Error executing command '${command}': ${error.message}`)); if (error.stack && argv.debug) logError(error.stack); } if (interactiveReadline) interactiveReadline.prompt(); // Ensure prompt is shown unless exiting }).on('close', async () => { // Handle Ctrl+D logInfo('\nInput stream closed (Ctrl+D). Exiting...'); await cleanupInteractive(false); // false = don't call process.exit yet if already closing }); } async function listToolsCmd() { if (!interactiveClient || !interactiveClient.isConnected) { logError('Not connected.'); return; } try { const request = {}; // ListToolsRequest may define empty params or specific ones const result = await interactiveClient.request({ method: 'tools/list', params: request }, ListToolsResultSchema); logInfo(chalk.green('Available tools:')); if (result.tools.length === 0) { logInfo(' No tools available.'); } else { result.tools.forEach(tool => logInfo(` - ${chalk.bold(tool.name)}: ${tool.description}`)); } } catch (error) { logError(`Error listing tools: ${error.message}`); } } async function callToolCmd(name, args) { if (!interactiveClient || !interactiveClient.isConnected) { logError('Not connected.'); return; } logInfo(`Calling tool '${name}' with args: ${JSON.stringify(args)}`); try { const request = { name, arguments: args }; const result = await interactiveClient.request( { method: 'tools/call', params: request }, CallToolResultSchema // Add resumption token logic if needed here, based on example // { resumptionToken: notificationsToolLastEventId, onresumptiontoken: (event) => { notificationsToolLastEventId = event; }} ); logInfo(chalk.green('Tool result:')); result.content.forEach(item => { if (item.type === 'text') logInfo(chalk.blueBright(` ${item.text}`)); else if (item.type === 'error') logError(chalk.redBright(` Error: ${item.text}`)); else logInfo(` ${item.type} content: ${JSON.stringify(item)}`); }); } catch (error) { logError(`Error calling tool ${name}: ${error.message}`); } } async function listPromptsCmd() { if (!interactiveClient || !interactiveClient.isConnected) { logError('Not connected.'); return; } try { const result = await interactiveClient.request({ method: 'prompts/list', params: {} }, ListPromptsResultSchema); logInfo(chalk.green('Available prompts:')); if (result.prompts.length === 0) logInfo(' No prompts available.'); else result.prompts.forEach(p => logInfo(` - ${chalk.bold(p.name)}: ${p.description}`)); } catch (error) { logError(`Error listing prompts: ${error.message}`); } } async function getPromptCmd(name, args) { if (!interactiveClient || !interactiveClient.isConnected) { logError('Not connected.'); return; } try { const result = await interactiveClient.request({ method: 'prompts/get', params: { name, arguments: args } }, GetPromptResultSchema); logInfo(chalk.green(`Prompt template for '${name}':`)); result.messages.forEach((msg, index) => { logInfo(` [${index + 1}] ${chalk.bold(msg.role)}: ${msg.content.text || JSON.stringify(msg.content)}`); }); } catch (error) { logError(`Error getting prompt ${name}: ${error.message}`); } } async function listResourcesCmd() { if (!interactiveClient || !interactiveClient.isConnected) { logError('Not connected.'); return; } try { const result = await interactiveClient.request({ method: 'resources/list', params: {} }, ListResourcesResultSchema); logInfo(chalk.green('Available resources:')); if (result.resources.length === 0) logInfo(' No resources available.'); else result.resources.forEach(r => logInfo(` - ${chalk.bold(r.name)}: ${r.uri}`)); } catch (error) { logError(`Error listing resources: ${error.message}`); } } async function disconnectInteractive() { if (interactiveTransport) { logInfo('Disconnecting from server...'); await interactiveTransport.close(); // This will also kill the server process interactiveTransport = null; } if (interactiveClient) { // Client might have its own cleanup, though transport.close should handle underlying connection interactiveClient = null; } interactiveServerProcess = null; // Already handled by transport.close() logInfo('Disconnected.'); } async function terminateSessionInteractive() { if (interactiveTransport) { logInfo('Terminating session (closing connection)...'); await interactiveTransport.terminateSession(); // For Stdio, this is same as close interactiveTransport = null; interactiveClient = null; interactiveServerProcess = null; logInfo('Session terminated.'); } else { logError('Not connected.'); } } async function reconnectInteractive(envOpts, port, debugMode) { logInfo('Reconnecting...'); await disconnectInteractive(); // Disconnect existing if any // Reset globals that startInteractive will set interactiveClient = null; interactiveTransport = null; interactiveServerProcess = null; await startInteractive(envOpts, port, debugMode); } async function cleanupInteractive(doExit = true) { logInfo("\nShutting down interactive CLI..."); if (interactiveReadline) { interactiveReadline.close(); interactiveReadline = null; } await disconnectInteractive(); // Ensure server process is killed // Restore terminal to normal mode if it was changed (raw mode for Esc key) if (process.stdin.isTTY && process.stdin.setRawMode) { // Check if setRawMode was used try { process.stdin.setRawMode(false); } catch (e) { /* ignore*/ } } if (doExit) { logInfo('Goodbye!'); process.exit(0); } } // Handle Ctrl+C for interactive mode function setupInteractiveSignalHandlers() { process.removeAllListeners('SIGINT'); // Remove silent mode's SIGINT process.removeAllListeners('SIGTERM'); // Remove silent mode's SIGTERM process.on('SIGINT', async () => { // logInfo('\nSIGINT received. Cleaning up interactive mode...'); // cleanupInteractive logs already await cleanupInteractive(); }); process.on('SIGTERM', async () => { // logInfo('SIGTERM received. Cleaning up interactive mode...'); await cleanupInteractive(); }); // Optional: Raw mode for Escape key to disconnect (from example) // This can be tricky with readline, might need careful handling // if (process.stdin.isTTY && process.stdin.setRawMode) { // process.stdin.setRawMode(true); // process.stdin.on('data', async (key) => { // if (key[0] === 27) { // ESC key // logInfo('\nESC key pressed. Disconnecting...'); // await disconnectInteractive(); // if (interactiveReadline) interactiveReadline.prompt(); // } // }); // } } // --- Main Execution --- if (argv.interactive) { setupInteractiveSignalHandlers(); } main().catch(async (error) => { logError(chalk.redBright(`Unhandled error in main execution: ${error.message}`)); if (error.stack && argv.debug) logError(error.stack); if (argv.interactive) { await cleanupInteractive(); } process.exit(1); });