Skip to main content
Glama
jmandel

Smart EHR MCP Server

by jmandel
cli.ts22.8 kB
import { Command } from 'commander'; import { Database } from 'bun:sqlite'; import fs from 'fs/promises'; import path from 'path'; import { spawn } from 'bun'; // Needed for running build // --- Imports for --create-db mode --- import express, { Request, Response, NextFunction } from 'express'; import http from 'http'; import https from 'https'; // Import https import cors from 'cors'; // --- End imports for --create-db --- // Corrected MCP SDK imports import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Implementation } from "@modelcontextprotocol/sdk/types.js"; // Import common types // Corrected local module imports (assuming cli.ts is in src/) import { ClientFullEHR } from '../clientTypes.js'; // Assumes clientTypes.ts is in project root import { sqliteToEhr, ehrToSqlite } from './dbUtils.js'; // Assumes dbUtils.ts is in src/ import { AppConfig, loadConfig } from './config.ts'; // Import config loading and AppConfig type // --- Tool Schemas & Logic (Imported) --- import { GrepRecordInputSchema, QueryRecordInputSchema, EvalRecordInputSchema, grepRecordLogic, queryRecordLogic, evalRecordLogic, registerEhrTools } from './tools.js'; // Assumes tools.ts is in src/ // --- Server Info --- const SERVER_INFO: Implementation = { name: "EHR-Search-MCP-CLI", version: "0.1.0" }; // --- Function for --create-db mode --- async function startEhrFetchServer( dbPath: string, serverConfig: AppConfig['server'] // Use the server part of AppConfig ): Promise<void> { return new Promise(async (resolve, reject) => { // Make the outer function async for cert loading const app = express(); app.use(cors()); app.use(express.json({ limit: '50mb' })); // For receiving EHR data let server: http.Server | https.Server | null = null; // Union type const protocol = serverConfig.https.enabled ? 'https' : 'http'; const port = serverConfig.port; // Use port from config const host = serverConfig.host; // Use host from config const baseUrl = serverConfig.baseUrl || `${protocol}://${host}:${port}`; // Construct base URL const shutdown = (error?: Error) => { if (server) { server.close((closeErr) => { if (closeErr) { console.error(`[Server] Error closing server: ${closeErr.message}`); } else { console.error('[Server] Web server stopped.'); } // Resolve or reject the main promise if (error) { reject(error); } else { resolve(); } }); // Force close after timeout setTimeout(() => { console.error('[Server] Forcing shutdown after timeout.'); if (error) reject(error); else resolve(); // Might resolve/reject twice, but ensures exit }, 5000); } else { if (error) reject(error); else resolve(); } }; // 1. Serve static files (Retriever HTML/JS) // Assuming cli.ts is in src/, static is one level up const staticPath = path.resolve(__dirname, '..', 'static'); console.error(`[Server] Serving static files from: ${staticPath}`); // Serve static files relative to the base URL app.use(express.static(staticPath)); // 2. Initial endpoint to start the flow app.get('/start', (req, res) => { console.error('[Server] /start requested. Redirecting to retriever UI...'); // Construct URL relative to the dynamic base URL const retrieverUrl = `/ehretriever.html#deliver-to:cli-callback`; res.redirect(retrieverUrl); }); // 3. Placeholder Redirect URI for SMART flow within retriever app.get('/ehr-callback', (req, res) => { console.error('[Server] /ehr-callback hit (intermediate step). Redirecting back to retriever base.'); // Construct URL relative to the dynamic base URL const originalUrl = req.originalUrl; const queryIndex = originalUrl.indexOf('?'); const queryString = (queryIndex !== -1) ? originalUrl.substring(queryIndex) : ''; // Redirect back to the retriever's root path res.redirect(`/ehretriever.html${queryString}`); }); // 4. Endpoint to receive final EHR data FROM the retriever // Ensure this path matches the 'cli-callback' postUrl built into the retriever app.post('/ehr-data', async (req: Request, res: Response) => { console.error('[Server] /ehr-data received POST request.'); try { const clientFullEhr: ClientFullEHR = req.body; // Basic validation if (!clientFullEhr || typeof clientFullEhr !== 'object' || !clientFullEhr.fhir || !clientFullEhr.attachments) { throw new Error("Invalid or missing ClientFullEHR data structure in request body."); } console.error(`[Server] Received EHR data. Resource types: ${Object.keys(clientFullEhr.fhir).length}, Attachments: ${clientFullEhr.attachments.length}`); // Check if DB file already exists try { await fs.access(dbPath); console.warn(`[Server] Warning: Output database file "${dbPath}" already exists. It will be overwritten.`); // Optionally delete it first if Database() doesn't overwrite cleanly // await fs.unlink(dbPath); } catch (accessError: any) { if (accessError.code !== 'ENOENT') { throw new Error(`Cannot access target database path "${dbPath}": ${accessError.message}`); } // ENOENT is expected, means file doesn't exist yet console.error(`[Server] Output database file "${dbPath}" does not exist, will be created.`); } console.error(`[Server] Initializing database at: ${dbPath}`); const db = new Database(dbPath); // Bun automatically creates/opens try { console.error('[Server] Populating database...'); await ehrToSqlite(clientFullEhr, db); console.error(`[Server] Successfully saved EHR data to ${dbPath}`); // Tell the retriever JS that the POST was successful. // The retriever JS doesn't expect a redirect URL in this flow. res.status(200).json({ success: true }); // Initiate graceful shutdown after success console.error('[Server] Data saved. Shutting down server...'); shutdown(); } finally { // Ensure DB is closed even if ehrToSqlite fails try { db.close(); } catch (e) { console.error('[Server] Error closing DB:', e); } } } catch (error: any) { console.error('[Server] Error processing /ehr-data:', error.message); res.status(500).json({ success: false, error: "processing_failed", error_description: error.message }); // Shut down server on failure too shutdown(error); } }); // Error handling middleware app.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error("[Server] Unhandled Error:", err.stack); res.status(500).send('Internal Server Error'); shutdown(err); // Shut down on unhandled errors }); // --- Create Server (HTTP or HTTPS based on config) --- try { if (serverConfig.https.enabled) { console.log("[Server] HTTPS is enabled. Loading certificates..."); if (!serverConfig.https.keyPath || !serverConfig.https.certPath) { throw new Error("HTTPS enabled but keyPath or certPath missing in server config."); } try { const key = await fs.readFile(serverConfig.https.keyPath); const cert = await fs.readFile(serverConfig.https.certPath); const serverOptions: https.ServerOptions = { key: key, cert: cert }; console.log(`[Server] Certificates loaded successfully.`); server = https.createServer(serverOptions, app); } catch (certError: any) { console.error(`[Server] FATAL ERROR loading certificate files:`, certError.message); // Reject the main promise, triggering shutdown logic if needed return reject(new Error(`Failed to load certificates: ${certError.message}`)); } } else { console.log("[Server] HTTPS is disabled. Creating HTTP server."); server = http.createServer(app); } server.listen(port, host, () => { // Use host from config console.error(`[Server] Temporary ${protocol.toUpperCase()} server listening on ${baseUrl}`); console.error(`[Action Required] Please open your web browser to: ${baseUrl}/start`); console.error('[Server] Fill in the EHR details in the browser UI to connect and fetch data.'); console.error(`[Server] Waiting for data to be received at ${baseUrl}/ehr-data...`); }); server.on('error', (error: NodeJS.ErrnoException) => { console.error(`[Server] Failed to start server on ${host}:${port}: ${error.message}`); if (error.code === 'EADDRINUSE') { console.error(`[Server] Address ${host}:${port} is already in use. Check config or processes using the port.`); } server = null; // Ensure server is null so shutdown doesn't try to close it shutdown(error); // Reject the promise }); } catch (serverSetupError: any) { console.error(`[Server] Error during server setup:`, serverSetupError.message); reject(serverSetupError); // Reject promise if initial setup (like cert loading check) fails } }); } // --- Main CLI Function --- async function main() { const program = new Command(); program .name('ehr-mcp-cli') .description('Exposes EHR tools (grep, query, eval) over stdio or fetches EHR data to a DB.') .version(SERVER_INFO.version) .requiredOption('-d, --db <path>', 'Path to the SQLite database file (read for stdio mode, write for --create-db mode).') // Options for --create-db mode .option('--create-db', 'Initiate EHR fetch via browser UI and save to the --db path.') .option('-c, --config <path>', 'Optional path to config file (used by retriever build and server settings in --create-db mode).', './config.stdio.json') // Default config path // .option('--port <port>', 'Port for the temporary web server (for --create-db).', '8088') // Port now comes from config // Add new mutually exclusive flags for handling existing DB in --create-db mode .option('--force-overwrite', 'If --db exists in --create-db mode, delete it before creating a new one.') .option('--force-concat', 'If --db exists in --create-db mode, add new data to the existing file.') .parse(process.argv); const options = program.opts(); const dbPath = path.resolve(options.db); if (options.createDb) { // --- Create DB Mode --- console.error('[CLI] Running in --create-db mode.'); // --- Load Configuration --- const configPath = path.resolve(options.config); let appConfig: AppConfig; try { console.log(`[CLI] Loading configuration from: ${configPath}`); appConfig = await loadConfig(configPath); // Basic validation of server config needed for this mode if (!appConfig.server || typeof appConfig.server.port !== 'number' || !appConfig.server.host) { throw new Error("Server 'host' and 'port' must be defined in the config file."); } if (appConfig.server.https.enabled && (!appConfig.server.https.keyPath || !appConfig.server.https.certPath)) { throw new Error("HTTPS is enabled in config, but 'keyPath' or 'certPath' is missing."); } console.log(`[CLI] Configuration loaded successfully. Server Base URL: ${appConfig.server.baseUrl}`); } catch (configError: any) { console.error(`[CLI] FATAL ERROR loading or validating configuration from "${configPath}": ${configError.message}`); process.exit(1); } // --- End Load Configuration --- // const port = parseInt(options.port, 10); // Port now comes from config // if (isNaN(port)) { // console.error('[CLI] Error: Invalid port number provided.'); // process.exit(1); // } // --- Upfront check for existing DB file --- try { await fs.access(dbPath); // Check if file exists (throws if not) console.warn(`[CLI] Database file "${dbPath}" already exists.`); if (options.forceOverwrite && options.forceConcat) { console.error('[CLI] Error: --force-overwrite and --force-concat cannot be used together.'); process.exit(1); } else if (options.forceOverwrite) { console.warn(`[CLI] --force-overwrite specified. Deleting existing file: ${dbPath}`); try { await fs.unlink(dbPath); console.error(`[CLI] Successfully deleted existing file.`); } catch (unlinkError: any) { console.error(`[CLI] Error deleting existing file "${dbPath}": ${unlinkError.message}`); process.exit(1); } } else if (options.forceConcat) { console.warn(`[CLI] --force-concat specified. New data will be added to the existing file.`); // No action needed here, the database will be opened and appended to later. } else { console.error(`[CLI] Error: Database file "${dbPath}" already exists.`); console.error('[CLI] Use --force-overwrite to delete it or --force-concat to add to it.'); process.exit(1); } } catch (accessError: any) { if (accessError.code === 'ENOENT') { // File doesn't exist, which is the normal case, proceed silently. console.error(`[CLI] Database file "${dbPath}" does not exist, will be created.`); } else { // Other access error (e.g., permissions) console.error(`[CLI] Error checking database path "${dbPath}": ${accessError.message}`); process.exit(1); } } // --- End upfront check --- // --- Dynamically build ehretriever.ts for CLI mode --- // Define the specific endpoint needed for CLI mode, using the loaded config base URL const cliPostUrl = new URL('/ehr-data', appConfig.server.baseUrl).toString(); console.error(`[CLI] Configuring retriever to POST data to: ${cliPostUrl}`); const cliDeliveryEndpoint = { "cli-callback": { postUrl: cliPostUrl } // Use the fully qualified URL }; // Prepare arguments for the build script const buildScriptPath = path.resolve(__dirname, '..', 'scripts', 'build-ehretriever.ts'); const buildScriptArgs = [ buildScriptPath, // Pass the CLI-specific endpoint as a JSON string '--extra-endpoints', JSON.stringify(cliDeliveryEndpoint) // Pass the original config file path to the build script as well // so it can read retrieverConfig, vendorConfig etc. ]; // Always pass the config path used by the CLI to the build script console.error(`[CLI] Using config file for retriever build: ${configPath}`); buildScriptArgs.push('--config', configPath); // If a base config file is provided via CLI args, pass it to the build script // This logic is now handled by always passing options.config // if (options.config) { // const configPath = path.resolve(options.config); // console.error(`[CLI] Using config file for retriever build: ${configPath}`); // buildScriptArgs.push('--config', configPath); // } // Execute the build script console.error(`[CLI] Running build script: bun ${buildScriptArgs.join(' ')}`); const buildProc = spawn(['bun', ...buildScriptArgs], { stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr // cwd: process.cwd(), // Optional: ensure correct working directory if needed }); // Capture and log output in real-time (or after exit) let buildStdout = ''; let buildStderr = ''; buildProc.stdout.pipeTo(new WritableStream({ write(chunk) { buildStdout += chunk; console.error('[Build stdout]:', new TextDecoder().decode(chunk).trim()); } })); buildProc.stderr.pipeTo(new WritableStream({ write(chunk) { buildStderr += chunk; console.error('[Build stderr]:', new TextDecoder().decode(chunk).trim()); } })); const buildExitCode = await buildProc.exited; if (buildExitCode !== 0) { console.error('[CLI] FATAL ERROR: Failed to build ehretriever for --create-db mode.'); console.error(`[CLI] Exit Code: ${buildExitCode}`); process.exit(1); } console.error('[CLI] Successfully built ehretriever for CLI mode.'); // --- End dynamic build --- try { // Pass dbPath and the loaded server configuration await startEhrFetchServer(dbPath, appConfig.server); console.error(`[CLI] Successfully created database: ${dbPath}`); process.exit(0); } catch (error: any) { console.error(`[CLI] Failed to create database: ${error.message}`); process.exit(1); } } else { // --- Stdio Mode (Original Logic) --- console.error(`[CLI] Running in stdio mode.`); console.error(`[CLI] Using database: ${dbPath}`); // --- Database and Data Loading --- let db: Database | undefined = undefined; // Initialize as potentially undefined let fullEhr: ClientFullEHR; try { await fs.access(dbPath, fs.constants.R_OK); console.error(`[CLI] Database file found. Opening...`); db = new Database(dbPath, { readonly: true }); console.error(`[CLI] Database opened successfully.`); console.error(`[CLI] Loading EHR data from database...`); fullEhr = await sqliteToEhr(db); console.error(`[CLI] EHR data loaded. Resources: ${Object.values(fullEhr.fhir).flat().length}, Attachments: ${fullEhr.attachments.length}`); } catch (error: any) { console.error(`[CLI] FATAL ERROR loading database or EHR data from "${dbPath}":`, error.message); if (error.code === 'ENOENT') console.error(`[CLI] Error: Database file not found at ${dbPath}. Use --create-db mode to generate one.`); else if (error.code === 'EACCES') console.error(`[CLI] Error: Permission denied reading database file at ${dbPath}`); // Attempt to close DB if it was opened before the error during loading if (db) { try { db.close(); console.error("[CLI] Closed DB connection after load error."); } catch (closeErr) {} } process.exit(1); } // --- MCP Server Setup --- const server = new McpServer(SERVER_INFO, { capabilities: { tools: {}, sampling: {} }, instructions: `Server providing tools to interact with EHR data loaded from ${path.basename(dbPath)}.` }); // --- Register Tools (Using Imported Logic) --- // Context retrieval function for CLI stdio environment async function getCliContext( _toolName: string, extra?: Record<string, any> ): Promise<{ fullEhr?: ClientFullEHR, db?: Database }> { // In CLI stdio mode, db and fullEhr are pre-loaded in the outer scope // We don't need 'extra' here return { fullEhr, db }; } // Register tools using the centralized function registerEhrTools(server, getCliContext); // --- Start Stdio Transport --- const transport = new StdioServerTransport(); // Graceful shutdown handling const shutdown = async (signal: string) => { console.error(`\n[CLI] Received ${signal}. Shutting down...`); try { await server.close(); console.error("[CLI] MCP server closed."); } catch (e) { console.error("[CLI] Error closing MCP server:", e); } try { // Check if db object exists and attempt to close if (db) { db.close(); // Just attempt to close console.error("[CLI] Database connection closed."); } } catch(e) { console.error("[CLI] Error closing database:", e); } console.error("[CLI] Shutdown complete."); process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); console.error("[CLI] MCP Server initialized. Connecting to stdio transport..."); try { await server.connect(transport); console.error("[CLI] Connected. Waiting for MCP messages on stdin..."); } catch (error: any) { console.error("[CLI] FATAL ERROR connecting MCP server to stdio transport:", error.message); // Attempt to close db if it exists if (db) { try { db.close(); } catch (closeErr) {} } process.exit(1); } } } // Run the main function main().catch(err => { console.error("[CLI] Unhandled error in main function:", err); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmandel/health-record-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server