Skip to main content
Glama
jmandel

Smart EHR MCP Server

by jmandel
intralib.js24.2 kB
/** * MCP Tool Server Client Library (for iframe using postMessage) * Simplifies implementing MCP server logic within an iframe. * Implements delayed handshake: Uses a Promise and signalReady() to coordinate. */ // Define a reasonable timeout for waiting for the client handshake const HANDSHAKE_TIMEOUT_MS = 15000; // e.g., 15 seconds export default class MCPToolServer { /** * Creates an MCPToolServer instance. * @param {object} [config] - Configuration options. * @param {object} [config.serverInfo] - Information about this server (e.g., { name: "my-tool", version: "1.0" }). * @param {string[] | string} [config.trustedClientOrigins] - An array of specific origins (e.g., ['https://client.com', 'http://localhost:3000']) * or a single origin string allowed to connect. * Use '*' ONLY for local development to allow any origin (INSECURE for production). * If omitted or invalid, an error is thrown unless '*' is used. * @param {boolean} [config.debug] - Enable verbose logging to the console. Defaults to false. */ constructor(config = {}) { this.serverInfo = config.serverInfo || { name: "mcp-tool", version: "0.0.0" }; this.trustedClientOrigins = new Set( Array.isArray(config.trustedClientOrigins) ? config.trustedClientOrigins.filter(o => o && o !== '*') : (config.trustedClientOrigins && config.trustedClientOrigins !== '*' ? [config.trustedClientOrigins] : []) ); this.allowAnyOrigin = this.trustedClientOrigins.size === 0 && config.trustedClientOrigins === '*'; this.tools = new Map(); this.parentWindow = null; this.actualClientOrigin = null; this.sessionId = `server-pending-${self.crypto.randomUUID()}`; this.debug = config.debug || false; // --- State --- this.isConnected = false; this._appHasSignaledReady = false; // Track if signalReady was called this._pendingClientDetails = null; // Store details upon handshake arrival this._handshakeTimeoutId = null; // Timer for handshake timeout // --- Promise for Handshake Arrival --- this._resolveHandshake = null; this._rejectHandshake = null; this._handshakePromise = new Promise((resolve, reject) => { this._resolveHandshake = resolve; this._rejectHandshake = reject; }); // Start timeout for handshake this._handshakeTimeoutId = setTimeout(() => { if (!this._pendingClientDetails) { // Only reject if handshake hasn't arrived yet this._log(`Timeout (${HANDSHAKE_TIMEOUT_MS}ms) waiting for client handshake.`); this._rejectHandshake?.(new Error("Timeout waiting for client handshake.")); } }, HANDSHAKE_TIMEOUT_MS); if (this.allowAnyOrigin) { console.warn("MCPToolServer initialized with wildcard origin '*'. Insecure for production."); } else if (this.trustedClientOrigins.size === 0) { throw new Error("MCPToolServer: No specific trustedClientOrigins provided and wildcard '*' not allowed."); } else { this._log("Initializing MCPToolServer - Trusted Origins:", Array.from(this.trustedClientOrigins)); } this._attachListener(); } _log(message, ...args) { // Use pending or actual session ID for logging const id = this.isConnected ? this.sessionId : (this._pendingClientDetails?.sessionId || this.sessionId); if (this.debug) { console.log(`[MCPToolServer ${id}] ${message}`, ...args); } } _attachListener() { window.removeEventListener('message', this._handleMessage); window.addEventListener('message', this._handleMessage.bind(this)); this._log("Attached message listener. Ready to receive handshake from trusted origins."); } _handleMessage(event) { // --- Security Checks: Source and Origin --- if (!event.source || event.source !== window.parent) { return; // Must be parent } let originToCheck = event.origin; let isHandshake = false; let messageData = event.data; // --- Handshake Detection --- if (typeof messageData === 'object' && messageData !== null && messageData.type === 'MCP_HANDSHAKE_CLIENT') { isHandshake = true; // *** Origin Check: Against configured trusted list or wildcard *** const isTrusted = this.allowAnyOrigin || this.trustedClientOrigins.has(originToCheck); if (!isTrusted) { this._log(`Ignoring handshake: Origin ${originToCheck} is not in the trusted list. Allowed: ${this.allowAnyOrigin ? '*' : Array.from(this.trustedClientOrigins)}`); return; // Exit if origin is not trusted } // *** Origin check passed *** const clientHandshake = messageData; this._log(`Received MCP_HANDSHAKE_CLIENT from TRUSTED origin ${originToCheck}:`, clientHandshake); if (!clientHandshake.sessionId) { this._log("Handshake ignored: Missing sessionId."); return; } // Decide how to handle this valid handshake message... if (this.isConnected && this.actualClientOrigin === originToCheck) { // Re-handshake from same client (e.g., client reload) this._log(`Duplicate handshake from connected origin ${originToCheck}. Session ID: ${clientHandshake.sessionId}. Responding again.`); this.sessionId = clientHandshake.sessionId; // Re-adopt ID // Respond immediately since app is already ready this._sendHandshakeResponse(clientHandshake.sessionId, event.source, originToCheck); } else if (this.isConnected) { // Handshake from a different origin while already connected this._log(`Ignoring handshake from ${originToCheck}, already connected to ${this.actualClientOrigin}.`); } else if (this._pendingClientDetails) { // Already received a handshake, maybe updating details if session ID changed this._log(`Received another handshake while waiting for signalReady. Updating pending details.`); this._pendingClientDetails = { clientOrigin: originToCheck, sessionId: clientHandshake.sessionId, sourceWindow: event.source }; // No need to resolve the promise again } else { // First valid handshake received this._log(`Handshake details stored. Resolving handshake promise.`); this._pendingClientDetails = { clientOrigin: originToCheck, sessionId: clientHandshake.sessionId, sourceWindow: event.source }; // Resolve the promise to signal arrival this._resolveHandshake?.(); // Clear the timeout as we received the handshake if (this._handshakeTimeoutId) { clearTimeout(this._handshakeTimeoutId); this._handshakeTimeoutId = null; } // NOTE: We do NOT complete the connection here yet. signalReady() must still be called. } return; // Handshake handled (or stored) } // ... [rest of message handling for connected state] ... else if (this.isConnected) { // --- Standard MCP Message Handling (Only if connected) --- if (originToCheck !== this.actualClientOrigin) { // Check against the specific connected origin this._log(`Ignoring message: Origin ${originToCheck} does not match established client origin ${this.actualClientOrigin}.`); return; } // Assume event.data is the object directly messageData = event.data; } else { // Not a handshake and not connected this._log("Ignoring message received before connection established (signalReady not called or handshake incomplete):", messageData); return; } // --- Process Valid, Connected MCP Message --- try { const isValidRequest = typeof messageData === 'object' && messageData !== null && messageData.jsonrpc === "2.0" && typeof messageData.method === 'string' && messageData.id !== undefined && messageData.id !== null; const isNotification = typeof messageData === 'object' && messageData !== null && messageData.jsonrpc === "2.0" && typeof messageData.method === 'string' && messageData.id === undefined; if (!isValidRequest && !isNotification) { this._log("Ignoring invalid/unrecognized JSON-RPC message structure:", messageData); return; } if(isNotification) { this._log(`Received notification ${messageData.method}, ignoring.`); return; } // It's a valid request const request = messageData; this._routeRequest(request); } catch (err) { const messageId = (typeof messageData === 'object' && messageData !== null) ? messageData.id : undefined; this._log(`Internal error processing request ID ${messageId}:`, err); this._sendError(messageId, -32603, "Internal server error processing request.", { details: err.message }); } } _routeRequest(request) { // Ensure connection is established before routing if (!this.isConnected) { this._log(`Request ${request.method} received but connection not established. Ignoring.`); // Maybe send error? Need request ID. return; } switch (request.method) { case "initialize": this._handleInitialize(request); break; case "tools/list": this._handleToolsList(request); break; case "tools/call": Promise.resolve(this._handleToolsCall(request)) .catch(err => { this._log(`Internal error during tools/call handling for ID ${request.id}:`, err); this._sendError(request.id, -32603, "Internal server error processing tool call.", { details: err.message }); }); break; case "ping": this._handlePing(request); break; default: this._log(`Unknown method: ${request.method}`); this._sendError(request.id, -32601, `Method not found: ${request.method}`); break; } } /** * Registers a tool and its handler function. * Should ideally be called BEFORE signalReady(). * @param {object} toolDefinition - Tool definition (name, description, inputSchema, etc.) * @param {Function} handlerFn - Async function(args): Promise<{content: ToolResultContent[], isError: boolean}> */ registerTool(toolDefinition, handlerFn) { if (this.isConnected || this._appHasSignaledReady) { console.warn(`MCPToolServer: registerTool called after signalReady. Tool '${toolDefinition?.name}' might not be listed correctly.`); } if (!toolDefinition || typeof toolDefinition.name !== 'string') { console.error("MCPToolServer: Invalid tool definition provided to registerTool.", toolDefinition); return; } if (typeof handlerFn !== 'function') { console.error(`MCPToolServer: Invalid handler function provided for tool '${toolDefinition.name}'.`); return; } this._log(`Registering tool: ${toolDefinition.name}`); this.tools.set(toolDefinition.name, { definition: toolDefinition, handlerFn }); } /** * Signals that the application has finished setup (e.g., registering tools) * and is ready to complete the handshake and handle requests. * This function is now async and will wait for the client handshake if needed. * @returns {Promise<void>} Resolves when the connection is established, rejects on timeout or error. */ async signalReady() { this._log("signalReady() called by application."); if (this.isConnected) { this._log("signalReady() called, but already connected. Ignoring."); return; } if (this._appHasSignaledReady) { this._log("signalReady() called multiple times. Returning existing promise/state."); // Optionally return the handshake promise again or just return void await this._handshakePromise; // Ensure we still wait if called rapidly twice return; } this._appHasSignaledReady = true; // Mark application as ready try { this._log("Waiting for client handshake promise to resolve..."); // Wait for the handshake details promise to resolve (or reject on timeout) await this._handshakePromise; this._log("Client handshake promise resolved."); // Now that we have the handshake details (_pendingClientDetails is populated), complete the connection. if (this.isConnected) { this._log("Connection was already completed concurrently. Exiting signalReady."); return; } this._completeHandshake(); } catch (error) { this._log("Error during signalReady (likely handshake timeout):", error); // Ensure state reflects failure this.isConnected = false; this.parentWindow = null; this.actualClientOrigin = null; // Re-throw or handle as appropriate for the application throw error; } } // --- Helper to complete the handshake --- _completeHandshake() { if (!this._pendingClientDetails) { this._log("Error: _completeHandshake called without pending details (should not happen after await)."); // This case should be impossible if signalReady awaits correctly return; } if (this.isConnected) { this._log("Warning: _completeHandshake called when already connected."); return; // Avoid completing twice } const { clientOrigin, sessionId, sourceWindow } = this._pendingClientDetails; this.actualClientOrigin = clientOrigin; this.parentWindow = sourceWindow; this.sessionId = sessionId; this._log(`Completing handshake. Client: ${this.actualClientOrigin}, Session: ${this.sessionId}`); this._sendHandshakeResponse(this.sessionId, this.parentWindow, this.actualClientOrigin); if (this.parentWindow) { this.isConnected = true; // No need for _handshakePending flag anymore // _pendingClientDetails = null; // Keep details for reference? Or clear? Let's clear. this._pendingClientDetails = null; this._log("Connection established and ready for requests."); } else { this._log("Handshake completion failed (likely could not send response). State reset."); this.isConnected = false; // _pendingClientDetails = null; // Already cleared by error handling in sendHandshakeResponse } } /** * Sends a JSON-RPC notification to the parent. * Requires connection to be established (signalReady called and handshake completed). * @param {string} method - The notification method name (e.g., 'tool_status') * @param {object} [params] - Optional parameters for the notification. */ sendNotification(method, params) { if (!this.isConnected) { this._log("Cannot send notification: Connection not established."); return; } if (!method) return; const notification = { jsonrpc: "2.0", method: method, ...(params !== undefined && { params }) }; this._sendMessage(notification); } // --- Internal Handlers --- _sendHandshakeResponse(sessionId, targetWindow, targetOrigin) { if (!targetWindow || !targetOrigin) { this._log("Internal error: Cannot send handshake response - client details missing."); // Reset state as we cannot proceed this.isConnected = false; this._pendingClientDetails = null; this.parentWindow = null; this.actualClientOrigin = null; return; } try { const responsePayload = { type: 'MCP_HANDSHAKE_SERVER', sessionId: sessionId }; this._log(`Sending MCP_HANDSHAKE_SERVER to origin: ${targetOrigin}`); targetWindow.postMessage(responsePayload, targetOrigin); } catch (e) { this._log(`Error sending MCP_HANDSHAKE_SERVER: ${e.message || e}. Resetting connection state.`); // Reset state as the client will not receive the confirmation this.isConnected = false; this._pendingClientDetails = null; this.parentWindow = null; // Clear potentially invalid reference this.actualClientOrigin = null; } } _handleInitialize(request) { this._log("Handling 'initialize'"); const response = { jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: this.serverInfo } }; this._sendMessage(response); } _handleToolsList(request) { this._log("Handling 'tools/list'"); const toolDefs = Array.from(this.tools.values()).map(t => t.definition); const response = { jsonrpc: "2.0", id: request.id, result: { tools: toolDefs } }; this._sendMessage(response); } async _handleToolsCall(request) { const toolName = request.params?.name; const args = request.params?.arguments; const requestId = request.id; this._log(`Handling 'tools/call' for tool: ${toolName}`, args); const toolEntry = this.tools.get(toolName); if (!toolEntry) { this._log(`Tool not found: ${toolName}`); this._sendError(requestId, -32601, `Tool not found: ${toolName}`); return; } // --- Basic Argument Validation (Optional but Recommended) --- const schema = toolEntry.definition.inputSchema; if (schema && schema.required) { for (const requiredProp of schema.required) { if (args === undefined || args === null || args[requiredProp] === undefined) { this._log(`Missing required argument '${requiredProp}' for tool ${toolName}`); this._sendError(requestId, -32602, `Invalid params: Missing required argument '${requiredProp}' for tool ${toolName}.`, { schema }); return; } } } // Add more type checking based on schema.properties if needed // --- Execute Handler --- try { // Call the registered handler function // Expecting it to return { content: ToolResultContent[], isError: boolean } const handlerResult = await toolEntry.handlerFn(args || {}); // Check what the handler returned and normalize it let finalContent; let finalIsError = false; // Default to success if (Array.isArray(handlerResult)) { // Handler returned just the content array - wrap it this._log(`Handler for ${toolName} returned content array directly. Wrapping.`); finalContent = handlerResult; // finalIsError remains false (default) } else if (typeof handlerResult === 'object' && handlerResult !== null && Array.isArray(handlerResult.content)) { // Handler returned the full structure { content: [], isError?: boolean } this._log(`Handler for ${toolName} returned full structure.`); finalContent = handlerResult.content; finalIsError = handlerResult.isError || false; // Use provided isError or default to false } else { // Handler returned an invalid structure this._log(`Invalid structure returned by handler for tool ${toolName} (expected Array or {{content: [], isError?: boolean}}):`, handlerResult); this._sendError(requestId, -32603, `Internal error: Invalid structure returned by tool handler ${toolName}.`); return; } // Validate nested content array if (!finalContent.every(item => typeof item === 'object' && item !== null && typeof item.type === 'string')) { this._log(`Invalid final content array structure for tool ${toolName}:`, finalContent); this._sendError(requestId, -32603, `Internal error: Invalid content array structure returned by tool handler ${toolName}.`); return; } // Send Success/Error Response this._sendMessage({ jsonrpc: "2.0", id: requestId, result: { content: finalContent, isError: finalIsError } }); } catch (error) { // --- Send Error Response (from handler execution exception) --- this._log(`Error executing handler for tool ${toolName}:`, error); // Send back an internal error, potentially including the message this._sendError(requestId, -32603, `Internal server error executing tool ${toolName}.`, { details: error.message }); } } _handlePing(request) { this._log("Handling 'ping'"); this._sendMessage({ jsonrpc: "2.0", id: request.id, result: {} }); } // --- Messaging Helpers --- _sendMessage(payload) { if (!this.isConnected || !this.parentWindow || !this.actualClientOrigin) { this._log("Error sending: Connection not established or client details missing."); return; } try { this._log("Sending:", payload); // Send the object directly, postMessage handles serialization this.parentWindow.postMessage(payload, this.actualClientOrigin); } catch (error) { this._log("Error sending message via postMessage:", error, "Payload:", payload); // Consider if we need to handle errors here, e.g., disconnect? } } _sendError(id, code, message, data) { if (id !== undefined && id !== null && this.isConnected) { this._sendMessage({ jsonrpc: "2.0", id: id, error: { code: code, message: message, ...(data !== undefined && { data }) // Include data if provided } }); } else { this._log(`Attempted to send error for request without ID or before connected. Code: ${code}, Message: ${message}`); } } } // Export the class // If used via <script type="module">, this isn't strictly necessary // but good practice if bundled later. // export default MCPToolServer; // If used via classic <script>, attach to window: // window.MCPToolServer = MCPToolServer;

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