@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
JavaScript
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);
});