Skip to main content
Glama
jmandel

Smart EHR MCP Server

by jmandel
index.tsx51.3 kB
import React, { useState, useEffect, useCallback, useRef, ChangeEvent } from 'react'; import { McpServer, IntraBrowserServerTransport, z, registerEhrTools } from '@jmandel/ehr-mcp/src/tools-browser-entry.js'; import type { ClientFullEHR } from '@jmandel/ehr-mcp/clientTypes'; import JSZip from 'jszip'; // Import JSZip // Import logic functions - Assuming they are accessible via the build process // Might need adjustment based on your bundling setup import { grepRecordLogic, readResourceLogic, readAttachmentLogic } from '@jmandel/ehr-mcp/src/tools-browser-entry.js'; // Placeholder path // --- Constants --- const LOG_PREFIX = '[EHR-MCP-Provider]'; const DB_NAME = 'ehrMcpData'; const DB_VERSION = 1; const DB_STORE_NAME = 'configurations'; // Changed from 'configuration' for clarity const DEFAULT_CONFIG_LS_KEY = 'ehrMcpDefaultConfigName'; const DEFAULT_CONFIG_NAME = 'default'; const CONFIG_LIST_KEY = 'ehrMcpConfigNames'; // localStorage key // --- Helper Functions (Can be moved to utils.ts) --- function log(...args: any[]) { console.log(LOG_PREFIX, ...args); // Logging to UI will be handled via state } // Helper to get IndexedDB factory (Uses SAA handle if provided, simplifies prefixes) function getIdbFactory(handle: any | null): IDBFactory { if (handle && handle.indexedDB) { log("Using handle.indexedDB for unpartitioned access."); return handle.indexedDB; } else { if (!handle) { log("No SAA handle provided, falling back to window.indexedDB (might be partitioned)."); } else { log("SAA handle provided but lacks .indexedDB, falling back to window.indexedDB (might be partitioned)."); } if (typeof window.indexedDB !== 'undefined') { return window.indexedDB; } else { throw new Error('IndexedDB is not supported in this browser.'); } } } // Helper to derive per-config keys (Add validation if needed) function getKeys(configName: string | null | undefined) { const key = configName || 'global'; // Add validation if desired return { dataKey: `ehrJsonData::${key}`, originsKey: `ehrMcpAllowedOrigins::${key}`, configName: key }; } // Helper to get storage permission state (Using 'storage-access' for SAA) async function getStoragePermissionState(): Promise<PermissionState> { log("Querying 'storage-access' permission state..."); if (navigator.permissions && typeof navigator.permissions.query === 'function') { try { // Use 'storage-access' for the Storage Access API permission query const permissionStatus = await navigator.permissions.query({ name: 'storage-access' as any }); log("'storage-access' permission state:", permissionStatus.state); return permissionStatus.state; } catch (e) { log("Error querying 'storage-access' permission:", e); // Browsers might deny querying this. Assume prompt if query fails. return 'prompt'; } } else { log("Permissions API or query method not available. Assuming 'prompt'."); return 'prompt'; } } // Helper to try activating storage access (Returns handle or null) async function tryActivateStorageAccess(appendLog: (msg: string, ...args: any[]) => void): Promise<any | null> { appendLog("Attempting to request storage access for IndexedDB..."); try { if (typeof (document as any).requestStorageAccess !== 'function') { appendLog("document.requestStorageAccess API not available."); return null; } try { appendLog("Attempting document.requestStorageAccess({ indexedDB: true })…"); const handle = await (document as any).requestStorageAccess({ indexedDB: true }); appendLog("Successfully called requestStorageAccess({ indexedDB: true })."); appendLog("requestStorageAccess({ indexedDB: true }) returned handle:", handle); return handle; // Return the handle } catch (e) { appendLog("Error invoking requestStorageAccess({ indexedDB: true }):", e); if (e instanceof TypeError) { appendLog("Call with { indexedDB: true } failed (API shape mismatch or browser policy). No fallback attempted."); } return null; } } catch (err) { appendLog("Error during requestStorageAccess process:", err); return null; } } // Helper to extract EHR data from file async function extractEhrFromZipOrJson(file: File, appendLog: (msg: string, ...args: any[]) => void): Promise<ClientFullEHR | null> { appendLog(`Processing file: ${file.name} (${file.type})`); if (file.type === 'application/zip') { appendLog('Attempting to read ZIP file...'); try { const zip = await JSZip.loadAsync(file); const jsonFile = zip.file(/\.json$/i)[0]; // Find the first .json file if (!jsonFile) { appendLog('Error: No .json file found inside the ZIP.'); return null; } appendLog(`Found JSON file in ZIP: ${jsonFile.name}`); const jsonContent = await jsonFile.async('string'); const ehrData = JSON.parse(jsonContent) as ClientFullEHR; appendLog('Successfully parsed EHR data from ZIP.'); return ehrData; } catch (error) { appendLog('Error reading or parsing ZIP file:', error); return null; } } else if (file.type === 'application/json') { appendLog('Attempting to read JSON file...'); try { const jsonContent = await file.text(); const ehrData = JSON.parse(jsonContent) as ClientFullEHR; appendLog('Successfully parsed EHR data from JSON.'); return ehrData; } catch (error) { appendLog('Error reading or parsing JSON file:', error); return null; } } else { appendLog(`Error: Unsupported file type: ${file.type}. Please upload a .zip or .json file.`); return null; } } // Helper to save config and EHR data to IndexedDB (Now needs handle passed) async function saveConfigAndEhr( configName: string, ehrData: ClientFullEHR, clientOrigin: string | null, handle: any | null, // Pass handle appendLog: (msg: string, ...args: any[]) => void ): Promise<boolean> { appendLog(`Attempting to save configuration: ${configName}`); const idbFactory = getIdbFactory(handle); // Pass handle if (!idbFactory) { appendLog('Error: IndexedDB not supported.'); return false; } return new Promise<boolean>((resolve, reject) => { const request = idbFactory.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { appendLog('IndexedDB upgrade needed.'); const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(DB_STORE_NAME)) { appendLog(`Creating object store: ${DB_STORE_NAME}`); db.createObjectStore(DB_STORE_NAME); } }; request.onsuccess = (event) => { appendLog('IndexedDB opened successfully for saving.'); const db = (event.target as IDBOpenDBRequest).result; const transaction = db.transaction(DB_STORE_NAME, 'readwrite'); const store = transaction.objectStore(DB_STORE_NAME); const keys = getKeys(configName); // Get the keys object const dataToStore = { ehrData: ehrData, allowedOrigin: clientOrigin, // Store the origin that configured it timestamp: new Date().toISOString(), }; appendLog(`Storing data under key: ${keys.dataKey}`); // Use dataKey const putRequest = store.put(dataToStore, keys.dataKey); // Use dataKey putRequest.onsuccess = () => { appendLog(`Configuration '${configName}' saved successfully.`); resolve(true); }; putRequest.onerror = (putEvent) => { appendLog(`Error saving configuration '${configName}':`, (putEvent.target as IDBRequest).error); resolve(false); // Resolve false on error, don't reject promise }; transaction.oncomplete = () => { appendLog('Save transaction completed.'); db.close(); }; transaction.onerror = (txEvent) => { appendLog('Save transaction error:', (txEvent.target as IDBTransaction).error); db.close(); resolve(false); }; }; request.onerror = (event) => { appendLog('Error opening IndexedDB for saving:', (event.target as IDBOpenDBRequest).error); resolve(false); }; request.onblocked = (event) => { appendLog('IndexedDB open request blocked during save attempt.', event); resolve(false); }; }); } // Helper to load config and EHR data from IndexedDB (Now needs handle passed) async function loadEhrDataFromDB( configName: string, handle: any | null, // Pass handle appendLog: (msg: string, ...args: any[]) => void ): Promise<{ ehrData: ClientFullEHR; allowedOrigin: string | null } | null> { appendLog(`Attempting to load configuration: ${configName}`); const idbFactory = getIdbFactory(handle); // Pass handle if (!idbFactory) { appendLog('Error: IndexedDB not supported.'); return null; } return new Promise<{ ehrData: ClientFullEHR; allowedOrigin: string | null } | null>((resolve, reject) => { try { const request = idbFactory.open(DB_NAME, DB_VERSION); // No need to specify version for read usually request.onsuccess = (event) => { appendLog('IndexedDB opened successfully for loading.'); const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(DB_STORE_NAME)) { appendLog(`Error: Object store '${DB_STORE_NAME}' not found.`); db.close(); resolve(null); // Resolve null if store doesn't exist return; } const transaction = db.transaction(DB_STORE_NAME, 'readonly'); const store = transaction.objectStore(DB_STORE_NAME); const keys = getKeys(configName); const getRequest = store.get(keys.dataKey); getRequest.onsuccess = () => { if (getRequest.result) { appendLog(`Configuration '${configName}' loaded successfully.`); const data = getRequest.result as { ehrData: ClientFullEHR; allowedOrigin: string | null; timestamp: string }; resolve({ ehrData: data.ehrData, allowedOrigin: data.allowedOrigin }); } else { appendLog(`Error: Configuration '${configName}' not found in IndexedDB.`); resolve(null); } }; getRequest.onerror = (getEvent) => { appendLog(`Error loading configuration '${configName}' from store:`, (getEvent.target as IDBRequest).error); resolve(null); }; transaction.oncomplete = () => { appendLog('Load transaction completed.'); db.close(); }; transaction.onerror = (txEvent) => { appendLog('Load transaction error:', (txEvent.target as IDBTransaction).error); db.close(); resolve(null); }; }; request.onerror = (event) => { appendLog('Error opening IndexedDB for loading:', (event.target as IDBOpenDBRequest).error); resolve(null); }; request.onblocked = (event) => { appendLog('IndexedDB open request blocked during load attempt.', event); resolve(null); // Resolve null if blocked }; } catch (error) { appendLog('Error initiating IndexedDB open for load:', error); resolve(null); } }); } // Function to post messages to the parent window (used in setup phase) function postSetupStatusToParent(status: 'SERVER_SETUP_COMPLETE' | 'SERVER_SETUP_ABORT', payload: any, clientOrigin: string | null) { if (!window.opener && window.parent === window) { log("Error: Cannot post setup status, not in an iframe or popup."); return; } if (!clientOrigin) { log("Error: Cannot post setup status, clientOrigin is required."); return; } const target = window.opener || window.parent; const message = { type: status, // Set the top-level type to the status success: status === 'SERVER_SETUP_COMPLETE', // Add boolean success field ...payload // Spread the rest of the payload }; log(`Posting setup status to parent/opener (${clientOrigin}):`, message); target.postMessage(message, clientOrigin); // Optionally close the window after sending status // if (status === 'SERVER_SETUP_ABORT' || status === 'SERVER_SETUP_COMPLETE') { // setTimeout(() => window.close(), 500); // Small delay // } } // Helper to generate a unique config name from patient's first name function generateUniqueConfigName(ehrData: ClientFullEHR | null, existingNames: string[]): string { const baseName = extractFirstName(ehrData); let configName = baseName; let counter = 1; const lowerCaseExisting = new Set(existingNames.map(n => n.toLowerCase())); while (lowerCaseExisting.has(configName.toLowerCase())) { configName = `${baseName}_${counter}`; counter++; } return configName; } // Helper to extract first name from the first Patient resource found function extractFirstName(ehrData: ClientFullEHR | null): string { if (!ehrData || !ehrData.fhir || !Array.isArray(ehrData.fhir.Patient) || ehrData.fhir.Patient.length === 0) { return DEFAULT_CONFIG_NAME; // Fallback if no patient data } const patient = ehrData.fhir.Patient[0]; if (patient.name && Array.isArray(patient.name) && patient.name.length > 0) { const name = patient.name[0]; if (name.given && Array.isArray(name.given) && name.given.length > 0) { // Basic sanitization: take first given name, lowercase, replace non-alphanum with underscore const firstName = name.given[0].toLowerCase().replace(/[^a-z0-9]/g, '_'); return firstName || DEFAULT_CONFIG_NAME; // Fallback if sanitized name is empty } } return DEFAULT_CONFIG_NAME; // Fallback if structure is unexpected } // --- Main Component --- const EhrMcpTool = () => { const [phase, setPhase] = useState<'setup' | 'transport' | 'unknown'>('unknown'); const [logs, setLogs] = useState<string[]>([]); const [statusMessage, setStatusMessage] = useState<string>('Initializing...'); const [statusType, setStatusType] = useState<'loading' | 'error' | 'ready' | 'info' | 'success' | 'warning'>('loading'); const [clientOrigin, setClientOrigin] = useState<string | null>(null); const [permissionState, setPermissionState] = useState<PermissionState | 'unknown'>('unknown'); const [activationStatus, setActivationStatus] = useState<'idle' | 'activating' | 'activated' | 'failed'>('idle'); const [ehrDataString, setEhrDataString] = useState<string | null>(null); const [isConnecting, setIsConnecting] = useState<boolean>(false); const [savedConfigs, setSavedConfigs] = useState<string[]>([]); const [defaultConfigName, setDefaultConfigNameState] = useState<string | null>(null); const [isConfigUIEnabled, setIsConfigUIEnabled] = useState<boolean>(false); const [showAbortButton, setShowAbortButton] = useState<boolean>(true); const [currentConfigName, setCurrentConfigName] = useState<string | null>(null); const fileInputRef = useRef<HTMLInputElement>(null); const saHandleRef = useRef<any | null>(null); const mcpServerRef = useRef<McpServer | null>(null); const transportRef = useRef<IntraBrowserServerTransport | null>(null); const fullEhrRef = useRef<ClientFullEHR | null>(null); // Function to append logs const appendLog = useCallback((message: string, ...args: any[]) => { log(message, ...args); // Log to console const time = new Date().toLocaleTimeString(); const detailString = args.length > 0 ? ' ' + args.map(a => JSON.stringify(a)).join(' ') : ''; const fullMessage = `[${time}] ${message}${detailString}`; setLogs(prevLogs => [...prevLogs, fullMessage]); }, []); // Effect to load saved configuration names and default config on mount (or after activation) const populateConfigsList = useCallback(async () => { appendLog("Populating config list..."); try { const storedNames = localStorage.getItem(CONFIG_LIST_KEY); if (storedNames) { const names = JSON.parse(storedNames); if (Array.isArray(names)) { setSavedConfigs(names); appendLog('Loaded saved configuration names:', names); } } const storedDefault = localStorage.getItem(DEFAULT_CONFIG_LS_KEY); if (storedDefault) { setDefaultConfigNameState(storedDefault); appendLog('Loaded default configuration name:', storedDefault); } } catch (error) { appendLog('Error loading configuration names/default from localStorage:', error); } }, [appendLog]); // Dependency on appendLog only // Helper function for saving configuration const saveConfiguration = useCallback(async (configName: string, dataString: string | null) => { if (!dataString) { appendLog("Save Configuration: No data string provided."); setStatusMessage('Error: No data available to save.'); setStatusType('error'); return; } if (!isConfigUIEnabled || activationStatus !== 'activated') { appendLog("Save Configuration: UI not enabled or access not activated."); setStatusMessage('Error: Cannot save configuration without storage access.'); setStatusType('error'); return; } const effectiveConfigName = configName.trim() || DEFAULT_CONFIG_NAME; appendLog(`Attempting to save configuration: '${effectiveConfigName}'`); setStatusMessage(`Saving configuration '${effectiveConfigName}'...`); setStatusType('loading'); setIsConnecting(false); // Ensure connection state is reset let ehrData: ClientFullEHR | null = null; try { ehrData = JSON.parse(dataString) as ClientFullEHR; // Optional: Add more validation for ClientFullEHR structure here if (!ehrData || typeof ehrData.fhir !== 'object' || !Array.isArray(ehrData.attachments)) { throw new Error('Data does not match expected ClientFullEHR structure.'); } } catch (parseError: any) { appendLog("Save Configuration: Error parsing EHR data string:", parseError); setStatusMessage(`Error: Invalid data format. ${parseError.message}`); setStatusType('error'); setEhrDataString(null); // Clear invalid data return; } const saved = await saveConfigAndEhr(effectiveConfigName, ehrData, clientOrigin, saHandleRef.current, appendLog); if (saved) { appendLog(`Configuration '${effectiveConfigName}' saved successfully.`); setStatusMessage(`Configuration '${effectiveConfigName}' saved.`); setStatusType('success'); // Update config list in state and localStorage const newConfigList = [...new Set([...savedConfigs, effectiveConfigName])]; setSavedConfigs(newConfigList); localStorage.setItem(CONFIG_LIST_KEY, JSON.stringify(newConfigList)); // --- Add default setting logic --- if (newConfigList.length === 1 && effectiveConfigName !== defaultConfigName) { appendLog(`Setting '${effectiveConfigName}' as default since it's the only configuration.`); // Directly update localStorage and state here, as handleSetDefaultConfig isn't needed // and calling it might cause issues if it relies on state updates that haven't propagated yet. localStorage.setItem(DEFAULT_CONFIG_LS_KEY, effectiveConfigName); setDefaultConfigNameState(effectiveConfigName); } // --- End default setting logic --- // Reset form for next potential save setEhrDataString(null); if (fileInputRef.current) fileInputRef.current.value = ''; await populateConfigsList(); // Refresh list view } else { appendLog(`Save Configuration: Failed to save '${effectiveConfigName}' to storage.`); setStatusMessage(`Error: Failed to save configuration '${effectiveConfigName}' to storage.`); setStatusType('error'); } }, [clientOrigin, appendLog, savedConfigs, isConfigUIEnabled, activationStatus, populateConfigsList]); // --- Effects --- useEffect(() => { // Determine phase and client origin on mount appendLog("DOM equivalent ready. Checking phase..."); const urlParams = new URLSearchParams(window.location.search); const detectedPhase = urlParams.get('phase'); const detectedClientOrigin = urlParams.get('client'); appendLog("Current search string:", window.location.search); appendLog("Detected phase parameter:", detectedPhase); appendLog("Detected client origin:", detectedClientOrigin); setClientOrigin(detectedClientOrigin); const initialPhase = detectedPhase === 'setup' ? 'setup' : 'transport'; setPhase(initialPhase); if (initialPhase === 'setup') { // --- Setup Phase Initial Permission Logic --- setStatusMessage('Checking storage access permission...'); setStatusType('loading'); setIsConfigUIEnabled(false); // Start disabled setShowAbortButton(true); // Show abort initially getStoragePermissionState().then(async (initialState) => { // Make async setPermissionState(initialState); if (initialState === 'granted') { appendLog("Setup: Initial permission state is 'granted'. Attempting to activate..."); setStatusMessage("Permission previously granted. Activating access..."); setStatusType('loading'); const handle = await tryActivateStorageAccess(appendLog); if (handle) { saHandleRef.current = handle; // Store handle in ref appendLog("Setup: Storage access activated successfully (initial state was granted)."); setStatusMessage("Storage access active. Ready for configuration."); setStatusType('ready'); setActivationStatus('activated'); setIsConfigUIEnabled(true); // Hide Grant button, show config UI await populateConfigsList(); // Load configs now } else { appendLog("Setup: Failed to activate storage access even though permission was granted."); setStatusMessage("Error: Permission granted, but failed to activate storage access automatically. Cannot proceed."); setStatusType('error'); setActivationStatus('failed'); setIsConfigUIEnabled(false); // Abort setup postSetupStatusToParent('SERVER_SETUP_ABORT', { code: 'FAILED', reason: 'Storage access activation failed.' }, detectedClientOrigin); setShowAbortButton(false); // Hide abort after sending } } else if (initialState === 'prompt') { appendLog("Setup: Initial permission state is 'prompt'. User interaction required."); setStatusMessage("This tool needs storage access to save configurations."); setStatusType('info'); setActivationStatus('idle'); setIsConfigUIEnabled(false); // Ensure Grant button is visible and enabled (handled by render logic) } else { // initialState === 'denied' appendLog("Setup: Initial permission state is 'denied'. Access cannot be requested."); setStatusMessage("Storage access has been denied. Please enable Storage Access for this site in your browser settings."); setStatusType('error'); setActivationStatus('failed'); // Treat as failed state setIsConfigUIEnabled(false); // Abort setup postSetupStatusToParent('SERVER_SETUP_ABORT', { code: 'PERMISSION_DENIED', reason: 'Storage access permission denied.' }, detectedClientOrigin); setShowAbortButton(false); // Hide abort after sending } }); } else { // Transport Phase setStatusMessage('Initializing transport...'); setStatusType('loading'); setShowAbortButton(false); // No abort in transport } }, [appendLog, populateConfigsList]); // appendLog is stable // Effect for Setup Phase - Activation via Button Click useEffect(() => { // Only run when activationStatus is set to 'activating' by the button click if (phase === 'setup' && activationStatus === 'activating') { appendLog('Activation triggered by button click...'); tryActivateStorageAccess(appendLog).then(async (handle) => { // make async if (handle) { saHandleRef.current = handle; // Store handle in ref appendLog('Storage access activation successful via button click.'); setPermissionState('granted'); // Update permission state setActivationStatus('activated'); setStatusMessage('Storage access granted. Ready to configure.'); setStatusType('ready'); setIsConfigUIEnabled(true); await populateConfigsList(); // Load configs now } else { appendLog('Storage access activation failed after button click.'); setActivationStatus('failed'); setStatusMessage('Storage access denied or failed. Check browser settings or try again.'); setStatusType('error'); setIsConfigUIEnabled(false); // Don't abort automatically here, allow user to retry grant or abort manually // Re-enable grant button via render logic based on activationStatus === 'failed' } }); } }, [phase, activationStatus, appendLog]); // Removed clientOrigin dependency here // Effect for Transport Phase Initialization useEffect(() => { if (phase !== 'transport') return; let isActive = true; // Flag to prevent state updates if component unmounts const initializeTransport = async () => { appendLog('Starting Transport Phase Initialization...'); // --- Transport Storage Access Check --- appendLog('Transport Phase: Checking storage permission state...'); const permissionState = await getStoragePermissionState(); if (permissionState === 'granted') { appendLog("Transport Phase: Permission is granted. Attempting to activate access silently..."); const handle = await tryActivateStorageAccess(appendLog); if (handle) { saHandleRef.current = handle; // Store handle in ref appendLog("Transport Phase: Unpartitioned storage access activated."); } else { appendLog("WARNING: Failed to activate unpartitioned storage access in Transport Phase even though permission was granted. Proceeding with default (potentially partitioned) IndexedDB."); } } else { appendLog(`WARNING: Storage access permission is '${permissionState}' in Transport Phase. Activation cannot be requested silently. Proceeding with default (potentially partitioned) IndexedDB.`); // Optionally update status, but don't block // setStatusMessage('Warning: Using potentially partitioned storage.'); // setStatusType('warning'); } // --- End Transport Storage Access Check --- const urlParams = new URLSearchParams(window.location.search); let targetConfigName = urlParams.get('config'); appendLog(`Config name from URL: ${targetConfigName}`); if (!targetConfigName) { targetConfigName = localStorage.getItem(DEFAULT_CONFIG_LS_KEY) || DEFAULT_CONFIG_NAME; appendLog(`Using default/fallback config name: ${targetConfigName}`); } if (!targetConfigName) { // Should not happen with default fallback, but safety check appendLog('Error: No configuration name specified or found.'); if (isActive) { setStatusMessage('Error: Configuration not specified.'); setStatusType('error'); } return; } if (!isActive) return; // Check before async load setStatusMessage(`Loading configuration: ${targetConfigName}...`); setStatusType('loading'); const loadedData = await loadEhrDataFromDB(targetConfigName, saHandleRef.current, appendLog); if (!loadedData || !loadedData.ehrData) { appendLog('Failed to load EHR data from IndexedDB.'); if (isActive) { setStatusMessage(`Error: Could not load data for config '${targetConfigName}'.`); setStatusType('error'); } return; } if (isActive) { appendLog('EHR data loaded. Initializing MCP Server...'); fullEhrRef.current = loadedData.ehrData; // **Use the loaded allowedOrigin for the transport** const loadedAllowedOrigin = loadedData.allowedOrigin; // Get the origin saved with the config appendLog(`Configuration allows connections from origin: ${loadedAllowedOrigin || '(Not set during config - allowing any \'*\' - check setup logic)'}`); // Determine trusted origins based on loaded data, similar to old code let trustedOrigins: string[] | '*' = '*'; // Default to '*' if not set if (loadedAllowedOrigin) { trustedOrigins = loadedAllowedOrigin.split(',').map(s => s.trim()).filter(Boolean); } if (Array.isArray(trustedOrigins) && trustedOrigins.length === 0) { trustedOrigins = '*'; // Fallback to '*' if split/filter results in empty array } appendLog('Derived trustedClientOrigins for transport:', trustedOrigins); try { // Instantiate Transport using derived trusted origins const transport = new IntraBrowserServerTransport({ trustedClientOrigins: trustedOrigins, // NOTE: Assumes constructor sets up listening }); transportRef.current = transport; appendLog('IntraBrowserServerTransport initialized.'); // Instantiate Server const server = new McpServer({ name: 'EHR-MCP-React-Provider', version: '1.0.0' }); mcpServerRef.current = server; appendLog('McpServer initialized.'); // Define the getContext function expected by registerEhrTools // It should return the currently loaded EHR data. async function getContext(toolName: string, extra?: Record<string, any>): Promise<{ fullEhr?: ClientFullEHR | undefined; db?: undefined }> { appendLog(`Server requested context for tool: ${toolName}`, extra); if (!fullEhrRef.current) { appendLog("Error: getContext called but fullEhr data is not available."); // Depending on server behavior, might need to reject or return empty throw new Error('EHR data context not available'); } // Return the required structure return { fullEhr: fullEhrRef.current, db: undefined }; } // Register Tool Implementations - Pass the getContext function registerEhrTools(server, getContext); appendLog('MCP Tools registered using getContext.'); // **Connect the server and transport** appendLog('Connecting server and transport...'); await server.connect(transport); // Use await if connect is async appendLog('Server connected to transport successfully!'); setStatusMessage(`Ready. Listening for requests for config '${targetConfigName}'.`); setStatusType('ready'); setCurrentConfigName(targetConfigName); // Use the setter here } catch (error) { appendLog('Error initializing transport or MCP server:', error); setStatusMessage('Error initializing server.'); setStatusType('error'); } } }; initializeTransport(); return () => { isActive = false; // Prevent state updates on unmount appendLog('Transport phase cleanup: Stopping server (if applicable)...'); // mcpServerRef.current?.stop(); // Check if McpServer has a stop/disconnect method mcpServerRef.current = null; transportRef.current = null; fullEhrRef.current = null; }; }, [phase, appendLog]); // Removed clientOrigin dependency, useEffect should only run once for transport // Effect for postMessage Listener (Setup Phase - EHR Connect) useEffect(() => { if (phase !== 'setup') return; const handleConnectMessage = async (event: MessageEvent) => { if (event.origin !== 'https://mcp.fhir.me') return; appendLog("Received message from mcp.fhir.me:", event.data); if (event.data && typeof event.data === 'object' && event.data.fhir) { appendLog("Message appears to be valid ClientFullEHR data from connection."); try { const receivedEhrData = event.data as ClientFullEHR; const receivedDataString = JSON.stringify(receivedEhrData); setEhrDataString(receivedDataString); setIsConnecting(false); const generatedName = generateUniqueConfigName(receivedEhrData, savedConfigs); appendLog(`Generated unique config name: ${generatedName}`); await saveConfiguration(generatedName, receivedDataString); } catch (error: any) { appendLog("Error processing/saving EHR data from connection:", error); setStatusMessage(`Error processing received EHR data: ${error.message}`); setStatusType('error'); setIsConnecting(false); setEhrDataString(null); } } else { appendLog("Received message from mcp.fhir.me, but it does not match expected format."); // Might receive other messages, ignore them silently? } }; appendLog('Adding postMessage listener for EHR Connect.'); window.addEventListener('message', handleConnectMessage); return () => { appendLog('Removing postMessage listener for EHR Connect.'); window.removeEventListener('message', handleConnectMessage); }; }, [phase, appendLog, saveConfiguration, savedConfigs]); // Removed configNameInput dep // --- Event Handlers --- const handleGrantAccessClick = useCallback(async () => { if (activationStatus === 'activating' || activationStatus === 'activated') return; // Prevent clicks if activating/activated appendLog('Grant Access button clicked.'); setActivationStatus('activating'); // Trigger the activation effect setStatusMessage('Requesting storage access...'); setStatusType('loading'); }, [activationStatus, appendLog]); const handleFileChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; setIsConnecting(false); // If user selects file, cancel connection wait setEhrDataString(null); // Clear any previous data if (!file) { appendLog('File selection cleared.'); return; } appendLog(`File selected: ${file.name} (${file.type}, ${file.size} bytes)`); if (file.type !== 'application/zip' && file.type !== 'application/json') { appendLog('Invalid file type selected.'); setStatusMessage('Error: Please select a .zip or .json file.'); setStatusType('error'); if (fileInputRef.current) fileInputRef.current.value = ''; return; } setStatusMessage('Reading and processing file...'); setStatusType('loading'); setEhrDataString(null); try { const extractedEhrData = await extractEhrFromZipOrJson(file, appendLog); if (extractedEhrData) { const dataString = JSON.stringify(extractedEhrData); setEhrDataString(dataString); // Store data string temporarily const generatedName = generateUniqueConfigName(extractedEhrData, savedConfigs); appendLog(`Generated unique config name: ${generatedName}`); setStatusMessage(`File processed. Saving config '${generatedName}'...`); await saveConfiguration(generatedName, dataString); } else { setStatusMessage('Error: Could not extract valid data from file.'); setStatusType('error'); if (fileInputRef.current) fileInputRef.current.value = ''; // Clear invalid file } } catch (error) { // Catch errors from extract or stringify or save appendLog("Error processing file:", error); setStatusMessage('Error processing file.'); setStatusType('error'); setEhrDataString(null); if (fileInputRef.current) fileInputRef.current.value = ''; } }, [appendLog, saveConfiguration, savedConfigs]); // Removed configNameInput dep const handleConnectEhrClick = useCallback(() => { if (!isConfigUIEnabled) return; const connectUrl = `https://mcp.fhir.me/ehr-connect#deliver-to-opener:${window.location.origin}`; appendLog(`Opening EHR connection window: ${connectUrl}`); try { const popup = window.open(connectUrl, "ehrConnectWindow", "width=1000,height=800,scrollbars=yes,resizable=yes"); if (!popup) { setStatusMessage("Failed to open EHR connection window. Please disable popup blockers for this site."); setStatusType('error'); return; } setIsConnecting(true); setStatusMessage("Waiting for data from EHR connection window..."); setStatusType('loading'); setEhrDataString(null); // Clear any previous data/file if (fileInputRef.current) fileInputRef.current.value = ''; // Clear file input } catch (error) { appendLog("Error opening popup:", error); setStatusMessage("Error opening EHR connection window."); setStatusType('error'); setIsConnecting(false); } }, [isConfigUIEnabled, appendLog]); const handleDeleteConfig = useCallback(async (configNameToDelete: string) => { appendLog(`Attempting to delete configuration: ${configNameToDelete}`); const newConfigList = savedConfigs.filter(name => name !== configNameToDelete); setSavedConfigs(newConfigList); localStorage.setItem(CONFIG_LIST_KEY, JSON.stringify(newConfigList)); if (defaultConfigName === configNameToDelete) { localStorage.removeItem(DEFAULT_CONFIG_LS_KEY); setDefaultConfigNameState(null); appendLog(`Removed default config setting as '${configNameToDelete}' was deleted.`); } const idbFactory = getIdbFactory(saHandleRef.current); if (!idbFactory) { appendLog('Cannot delete from IDB: Factory not available.'); return; } try { const db = await new Promise<IDBDatabase>((resolve, reject) => { const request = idbFactory.open(DB_NAME, DB_VERSION); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error('IDB open blocked')); }); const transaction = db.transaction(DB_STORE_NAME, 'readwrite'); const store = transaction.objectStore(DB_STORE_NAME); const keys = getKeys(configNameToDelete); store.delete(keys.dataKey); await new Promise<void>((resolve, reject) => { transaction.oncomplete = () => { appendLog(`Successfully deleted '${configNameToDelete}' data from IndexedDB.`); db.close(); resolve(); }; transaction.onerror = () => { appendLog(`Error in IndexedDB delete transaction for '${configNameToDelete}'.`); db.close(); reject(transaction.error); }; }); } catch (error) { appendLog(`Error deleting '${configNameToDelete}' from IndexedDB:`, error); } }, [savedConfigs, defaultConfigName, appendLog]); const handleSetDefaultConfig = useCallback((configNameToSet: string) => { if (configNameToSet === defaultConfigName) return; // No change appendLog(`Setting '${configNameToSet}' as the default configuration.`); try { localStorage.setItem(DEFAULT_CONFIG_LS_KEY, configNameToSet); setDefaultConfigNameState(configNameToSet); } catch (e) { appendLog("Error setting default config name in localStorage:", e); } }, [defaultConfigName, appendLog]); const handleAbortClick = useCallback(() => { appendLog("Abort button clicked by user."); postSetupStatusToParent('SERVER_SETUP_ABORT', { code: 'USER_CANCELED', reason: 'User aborted setup.' }, clientOrigin); setShowAbortButton(false); // Correctly use setter // Maybe close window? // setTimeout(() => window.close(), 300); }, [clientOrigin, appendLog]); const handleDoneClick = useCallback(() => { appendLog("Done button clicked."); postSetupStatusToParent('SERVER_SETUP_COMPLETE', {}, clientOrigin); // Send simple completion setShowAbortButton(false); // Correctly use setter // Maybe close window? // setTimeout(() => window.close(), 300); }, [clientOrigin, appendLog]); // --- Rendering Logic --- return ( <div> <h1>EHR MCP Tool (React Version)</h1> {/* Config List Section */} {phase === 'setup' && isConfigUIEnabled && ( <div className="config-section"> <h2>Existing Configurations</h2> {savedConfigs.length === 0 ? ( <p>No configurations saved yet.</p> ) : ( <ul> {savedConfigs.map(name => ( <li key={name}> <span className="config-name">{name}</span> {name === defaultConfigName && <strong>(Default)</strong>} <div className="config-actions"> {name !== defaultConfigName && ( <button onClick={() => handleSetDefaultConfig(name)} className="icon icon-default" title="Set Default"> Set Default </button> )} <button onClick={() => handleDeleteConfig(name)} className="icon icon-delete" title="Delete"> Delete </button> </div> </li> ))} </ul> )} </div> )} {/* Initial Loading Indicator */} {statusType === 'loading' && phase === 'unknown' && ( <div className="status status-loading">{statusMessage}</div> )} {/* Setup Phase Content */} {phase === 'setup' && ( <div id="setup-phase-content"> <h4>EHR Tool Provider Setup</h4> <div className={`status status-${statusType}`} style={{ margin: '1em 0' }}>{statusMessage}</div> {/* Permission Grant Section */} {(activationStatus === 'idle' || activationStatus === 'failed') && permissionState === 'prompt' && ( <button onClick={handleGrantAccessClick} className="icon icon-grant" > Grant Storage Access </button> )} {activationStatus === 'activating' && ( <span>Requesting access... Please follow browser prompts.</span> )} {/* No specific message needed for activated state here, config UI shows */} {/* Message for denied/unrecoverable failed state is handled by the main status div */} {/* Configuration Input Section */} {isConfigUIEnabled && ( <div className="load-options"> <h5>Load Data and Auto-Save Configuration</h5> {/* Config Name Input REMOVED */} {/* Note about auto-name REMOVED */} {/* File Input */} <div> <label htmlFor="ehr-file">1. Upload EHR Data File (.zip or .json): </label> <input type="file" id="ehr-file" ref={fileInputRef} accept=".zip,application/zip,.json,application/json" onChange={handleFileChange} disabled={isConnecting || statusType === 'loading'} /> </div> {/* OR Separator */} <div className="separator">- OR -</div> {/* Connect to Live EHR Button */} <div> <label htmlFor="connect-ehr-btn">2. Connect to Live EHR: </label> <button id="connect-ehr-btn" onClick={handleConnectEhrClick} disabled={isConnecting || statusType === 'loading'} className="icon icon-connect" > {isConnecting ? 'Waiting...' : 'Connect to EHR Provider'} </button> </div> </div> )} {/* Setup Actions Area */} <div className="setup-actions"> {showAbortButton && ( <button onClick={handleAbortClick} className="icon icon-abort"> Abort Setup </button> )} {isConfigUIEnabled && ( <button onClick={handleDoneClick} disabled={statusType === 'loading'} // Disable while saving className="icon icon-done" title="Finish Setup" > Done (Close Setup) </button> )} </div> </div> )} {/* Transport Phase Content */} {phase === 'transport' && ( <div id="transport-section"> <div className={`status status-${statusType}`} style={{ marginBottom: '1em' }}>{statusMessage}</div> <p>Current Configuration: <strong>{currentConfigName || 'Loading...'}</strong></p> </div> )} </div> ); }; export default EhrMcpTool;

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