Skip to main content
Glama

Smart EHR MCP Server

by jmandel
ehretriever.ts63.1 kB
import pkceChallenge from 'pkce-challenge'; // Import the library import { fetchAllEhrDataClientSideParallel } from './clientFhirUtils'; // UPDATED: Import the parallel data fetching function // --- Declare potential global constants injected by build --- declare const __CONFIG_FHIR_BASE_URL__: string | undefined; declare const __CONFIG_CLIENT_ID__: string | undefined; declare const __CONFIG_SCOPES__: string | undefined; declare const __DELIVERY_ENDPOINTS__: Record<string, { postUrl: string}> | undefined; // NEW BRAND INDEX CONSTANT (injected at build time) // Structure: Array of { url: string; tags: string[]; vendorConfig: { clientId: string; scopes: string; redirectUrl?: string } } declare const __BRAND_FILE_INDEX__: { url: string; tags: string[]; vendorConfig: { clientId: string; scopes: string; redirectUrl?: string } }[] | undefined; // ---------------------------------------------------------- // Keys for sessionStorage const AUTH_STORAGE_KEY = 'smart_auth_state'; const DELIVERY_TARGET_KEY = 'delivery_target_name'; const OPENER_TARGET_VALUE = '__opener__'; // Special value for opener target const OPENER_TARGET_ORIGIN_KEY = 'opener_target_origin'; // NEW: Key for storing opener's origin interface StoredAuthState { codeVerifier: string; state: string; tokenEndpoint: string; clientId: string; redirectUri: string; fhirBaseUrl: string; } // --- DOM Element References --- let brandSelectorContainer: HTMLElement | null; let brandSearchInput: HTMLInputElement | null; let brandSearchSpinner: HTMLElement | null; let brandResultsContainer: HTMLElement | null; let brandModalBackdrop: HTMLElement | null; let brandModal: HTMLElement | null; let brandModalTitle: HTMLElement | null; let brandModalDetails: HTMLElement | null; let brandModalCancel: HTMLButtonElement | null; let brandModalConnect: HTMLButtonElement | null; let brandInitialLoadingMessage: HTMLElement | null; // REMOVED form element variables // let formContainer: HTMLElement | null; // let ehrForm: HTMLFormElement | null; // let ehrBaseUrlInput: HTMLInputElement | null; // let ehrClientIdInput: HTMLInputElement | null; // let ehrScopesInput: HTMLInputElement | null; // let ehrRedirectUriInput: HTMLInputElement | null; let statusContainer: HTMLElement | null; let statusMessageElement: HTMLElement | null; let progressContainer: HTMLElement | null; let progressBar: HTMLProgressElement | null; let progressText: HTMLElement | null; // NEW: Inline Confirmation UI Elements let confirmationContainer: HTMLElement | null; let confirmationMessageElement: HTMLElement | null; let confirmSendBtn: HTMLButtonElement | null; let cancelSendBtn: HTMLButtonElement | null; // NEW: Download Button Element let downloadDataBtn: HTMLButtonElement | null; // --- Brand Selector State --- let allBrandItems: any[] = []; let selectedBrandItem: any | null = null; let currentBrandRenderAbortController: AbortController | null = null; let brandDebounceTimer: number | null = null; let currentFilteredItems: any[] = []; let currentPage = 1; const ITEMS_PER_PAGE = 1200; // Removed single currentVendorName; each brand item will now carry its own _vendorName property // NEW: Pagination DOM Elements let brandPaginationControls: HTMLElement | null; let brandPrevBtn: HTMLButtonElement | null; let brandNextBtn: HTMLButtonElement | null; let brandPageInfo: HTMLElement | null; // --- Brand Selector Configuration --- const RENDER_CHUNK_SIZE = 50; const RENDER_DELAY = 0; // ms delay between rendering chunks const DEBOUNCE_DELAY = 300; // ms delay for search input debounce // Helper function to update status message function updateStatus(message: string, isError: boolean = false) { if (statusMessageElement) { statusMessageElement.textContent = message; statusMessageElement.style.color = isError ? 'red' : 'black'; } console.log(`Status: ${message}`); if (isError) { console.error(`Status Error: ${message}`); } } // Helper function to manage display function showStatusContainer(show: boolean) { const formContainer = document.getElementById('form-container'); const statusContainer = document.getElementById('status-container'); if (formContainer) formContainer.style.display = show ? 'none' : 'block'; if (statusContainer) statusContainer.style.display = show ? 'block' : 'none'; } // Helper function to show/hide progress UI function showProgressContainer(show: boolean) { const progressContainer = document.getElementById('progress-container'); if (progressContainer) progressContainer.style.display = show ? 'block' : 'none'; } // NEW: Helper function to show/hide confirmation UI function showConfirmationContainer(show: boolean) { const confirmationContainer = document.getElementById('confirmation-container'); if (confirmationContainer) confirmationContainer.style.display = show ? 'block' : 'none'; } // Helper function to update progress UI function updateProgress(completed: number, total: number, message?: string) { const progressBar = document.getElementById('fetch-progress') as HTMLProgressElement; const progressText = document.getElementById('progress-text'); if (progressBar && progressText) { const percentage = total > 0 ? (completed / total) * 100 : 0; progressBar.value = percentage; progressText.textContent = `(${completed}/${total}) ${message || ''}`.trim(); console.log(`Progress: ${completed}/${total} (${percentage.toFixed(1)}%) ${message || ''}`); } // Show the container if it's not already visible and we have progress if (total > 0) { const progressContainer = document.getElementById('progress-container'); if (progressContainer && progressContainer.style.display === 'none') { showProgressContainer(true); } } // --- NEW: Get Confirmation UI References --- confirmationContainer = document.getElementById('confirmation-container'); confirmationMessageElement = document.getElementById('confirmation-message'); confirmSendBtn = document.getElementById('confirm-send-btn') as HTMLButtonElement | null; cancelSendBtn = document.getElementById('cancel-send-btn') as HTMLButtonElement | null; // ----------------------------------------- } // Helper function to resolve potentially relative URLs to absolute ones function makeAbsoluteUrl(urlStr: string): string { try { // Use the URL constructor with the current page's origin as the base // This correctly handles absolute URLs, root-relative URLs (/path), and other relative paths. const absoluteUrl = new URL(urlStr, window.location.origin); return absoluteUrl.toString(); } catch (e) { console.error(`Error creating absolute URL from "${urlStr}":`, e); return urlStr; // Return original string on error } } // Function to generate a random string for state function generateRandomString(length = 40) { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; let result = ''; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } // --- Brand Selector Helper Functions --- // Helper to safely get lowercase string or empty string const safeLower = (str: any): string => (str ? String(str).toLowerCase() : ''); // Creates a DOM element for a single brand item tile function createBrandTileElement(item: any): HTMLDivElement { const tile = document.createElement('div'); tile.className = 'brand-tile'; let detailsHTML = `<h3>${item.displayName}</h3>`; detailsHTML += `<p class="provider-info">Data Provider: ${item.brandName}</p>`; const hasCollapseInfo = typeof item._matchedCount === 'number' && typeof item._totalCount === 'number'; // Don't show specific location if it's a collapsed rep if (!hasCollapseInfo && item.itemType === 'facility') { const locationParts = [item.city, item.state, item.postalCode].filter(Boolean); if (locationParts.length > 0) { detailsHTML += `<p class="location-info">Location: ${locationParts.join(', ')}</p>`; } } // Show the "Matched X of Y cards" info if applicable if (hasCollapseInfo) { detailsHTML += `<p class="collapse-info">Matched ${item._matchedCount} of ${item._totalCount} card${item._totalCount !== 1 ? 's' : ''}</p>`; } tile.innerHTML = detailsHTML; tile.addEventListener('click', () => showBrandModal(item)); return tile; } // --- Helper: Collapse multiple items from the same brand into a single representative --- function collapseBrandItems(allItems: any[], matchedItems: any[], scoreMap: Map<any, number>): any[] { const matchedSet = new Set(matchedItems); const grouped: Record<string, any[]> = {}; allItems.forEach(itm => { const key = itm.brandId || itm.brandName; if (!grouped[key]) grouped[key] = []; grouped[key].push(itm); }); const collapsed: any[] = []; for (const key in grouped) { const group = grouped[key]; const matchedInGroup = group.filter(itm => matchedSet.has(itm)); const matchedCount = matchedInGroup.length; // Count of matched items const totalCount = group.length; // Total items in this brand group if (matchedCount === 0) continue; // Nothing matched in this group if (matchedCount === 1) { // Only one matched item – show it as-is (no collapsing) const single = matchedInGroup[0]; // Ensure score is attached, but no collapse info const repCloneSingle = { ...single, _score: scoreMap.get(single) ?? 0 }; delete repCloneSingle._matchedCount; // Remove potentially stale info delete repCloneSingle._totalCount; delete repCloneSingle._collapseCount; // Remove old property collapsed.push(repCloneSingle); } else { // Multiple matched items – collapse the group // Prefer brand-level item from the *original* group as representative let representative = group.find(g => g.itemType === 'brand'); if (!representative) { // Fallback: shortest displayName among matched items representative = matchedInGroup.slice().sort((a, b) => a.displayName.length - b.displayName.length)[0]; } // Use the best score from the *matched* items for sorting const bestScore = Math.min(...matchedInGroup.map(it => scoreMap.get(it) ?? 0)); // Clone representative and add new collapse info const repClone = { ...representative, _score: bestScore, _matchedCount: matchedCount, // Store how many matched _totalCount: totalCount // Store total in group }; delete repClone._collapseCount; // Explicitly remove old property collapsed.push(repClone); } } return collapsed; } // Renders a list of items into the results container in manageable chunks function renderBrandItemsInChunks(itemsToRender: any[]) { if (!brandResultsContainer || !brandSearchSpinner) return; if (currentBrandRenderAbortController) { currentBrandRenderAbortController.abort(); } currentBrandRenderAbortController = new AbortController(); const signal = currentBrandRenderAbortController.signal; if (brandResultsContainer) brandResultsContainer.innerHTML = ''; // Clear previous results if (itemsToRender.length === 0) { if (brandResultsContainer) brandResultsContainer.innerHTML = '<p class="brand-status-message">No matching organizations found.</p>'; if (brandSearchSpinner) brandSearchSpinner.style.display = 'none'; return; } let currentIndex = 0; const fragment = document.createDocumentFragment(); function renderNextChunk() { if (signal.aborted) { if (brandSearchSpinner) brandSearchSpinner.style.display = 'none'; return; // Stop if aborted } if (brandSearchSpinner) brandSearchSpinner.style.display = 'block'; // Show spinner during render const endTime = performance.now() + 16; // Target ~60fps budget let chunkCount = 0; while (performance.now() < endTime && currentIndex < itemsToRender.length) { fragment.appendChild(createBrandTileElement(itemsToRender[currentIndex])); currentIndex++; chunkCount++; if(chunkCount >= RENDER_CHUNK_SIZE) break; // Optional batch size limit per frame } if (brandResultsContainer) brandResultsContainer.appendChild(fragment); // Append the chunk if (currentIndex < itemsToRender.length) { setTimeout(renderNextChunk, RENDER_DELAY); // Schedule next chunk } else { if (brandSearchSpinner) brandSearchSpinner.style.display = 'none'; // Hide spinner when done currentBrandRenderAbortController = null; // Clear controller } } renderNextChunk(); // Start the rendering process } // *** NEW: Renders the items for the current page and updates controls *** function renderCurrentPage() { if (!brandResultsContainer || !brandPaginationControls || !brandPrevBtn || !brandNextBtn || !brandPageInfo) { console.error("Cannot render page, pagination elements missing."); return; } const totalItems = currentFilteredItems.length; const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); currentPage = Math.max(1, Math.min(currentPage, totalPages)); // Ensure currentPage is valid const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; // slice excludes end index const itemsToDisplay = currentFilteredItems.slice(startIndex, endIndex); console.log(`Rendering page ${currentPage} of ${totalPages}. Items ${startIndex + 1}-${Math.min(endIndex, totalItems)} of ${totalItems}.`); renderBrandItemsInChunks(itemsToDisplay); // Render only this page's items // Update page info text brandPageInfo.textContent = `Page ${currentPage} of ${totalPages || 1}`; // Update button states brandPrevBtn.disabled = currentPage <= 1; brandNextBtn.disabled = currentPage >= totalPages; // Show/hide pagination controls brandPaginationControls.style.display = totalPages > 1 ? 'block' : 'none'; } // Filters items based on search input and triggers rendering function handleBrandSearch() { if (!brandSearchInput || !brandSearchSpinner) return; const searchTerm = brandSearchInput.value.toLowerCase().trim(); const searchTokens = searchTerm.split(/[^\w\d]+/).filter(token => token.length > 0); brandSearchSpinner.style.display = 'block'; const scoreMap: Map<any, number> = new Map(); const searchFiltered = searchTokens.length === 0 ? allBrandItems : allBrandItems.filter(item => { let cumulativeScore = 0; const matched = searchTokens.every(token => { const fieldsToSearch = [ safeLower(item.displayName), safeLower(item.brandName), safeLower(item.city), safeLower(item.state), safeLower(item.postalCode) ]; let bestPos = Infinity; for (const field of fieldsToSearch) { const idx = field.indexOf(token); if (idx !== -1 && idx < bestPos) bestPos = idx; } if (bestPos !== Infinity) { cumulativeScore += bestPos; return true; } return false; // token not matched in any field }); if (matched) { // Add small tie-breaker based on displayName length cumulativeScore += (safeLower(item.displayName).length / 100); scoreMap.set(item, cumulativeScore); } return matched; }); const collapsed = collapseBrandItems(allBrandItems, searchFiltered, scoreMap); if (searchTokens.length === 0) { collapsed.sort((a, b) => safeLower(a.displayName).localeCompare(safeLower(b.displayName))); } else { collapsed.sort((a, b) => (a._score ?? Infinity) - (b._score ?? Infinity)); } currentFilteredItems = collapsed; // Reset to page 1 and render currentPage = 1; renderCurrentPage(); } // Debounce function function debounce(func: (...args: any[]) => void, delay: number) { return function(...args: any[]) { if (brandSearchSpinner) brandSearchSpinner.style.display = 'block'; // Show spinner immediately on input clearTimeout(brandDebounceTimer as number | undefined); brandDebounceTimer = window.setTimeout(() => { func.apply(this, args); }, delay); }; } const debouncedBrandSearchHandler = debounce(handleBrandSearch, DEBOUNCE_DELAY); // Shows the modal with details of the selected item function showBrandModal(item: any) { if (!brandModalBackdrop || !brandModalTitle || !brandModalDetails) return; if (currentBrandRenderAbortController) { return; } // Don't show modal during render selectedBrandItem = item; brandModalTitle.textContent = `Connect to ${item.displayName}?`; let detailsHTML = `<p><strong>Display Name:</strong> ${item.displayName}</p>`; detailsHTML += `<p><strong>Data Provider:</strong> ${item.brandName}</p>`; const hasCollapseInfo = typeof item._matchedCount === 'number' && typeof item._totalCount === 'number'; // Don't show specific location if it's a collapsed rep if (!hasCollapseInfo && item.itemType === 'facility') { const locationParts = [item.city, item.state, item.postalCode].filter(Boolean); if (locationParts.length > 0) { detailsHTML += `<p><strong>Location:</strong> ${locationParts.join(', ')}</p>`; } } // Show the "Matched X of Y cards" info if applicable if (hasCollapseInfo) { detailsHTML += `<p><strong>Matched:</strong> ${item._matchedCount} of ${item._totalCount} card${item._totalCount !== 1 ? 's' : ''}</p>`; } // Display endpoints - **Crucially, we need a FHIR endpoint here** if (item.endpoints && Array.isArray(item.endpoints) && item.endpoints.length > 0) { detailsHTML += `<p><strong>Endpoints:</strong></p><ul>`; item.endpoints.forEach((ep: { url: string, name?: string, type?: string }) => { // Highlight potential FHIR endpoints const isFhir = ep.type === 'FHIR_BASE_URL' || safeLower(ep.url).includes('fhir'); detailsHTML += `<li style="${isFhir ? 'font-weight: bold;' : ''}">${ep.url}${ep.name ? ` (${ep.name})` : ''}${ep.type ? ` [${ep.type}]` : ''}</li>`; }); detailsHTML += `</ul>`; } else { detailsHTML += `<p><strong>Endpoints:</strong> None found</p>`; } // Display sandbox login note if available const vc: VendorAuthConfig | undefined = (item as any)._vendorConfig; if (vc && vc.note) { detailsHTML += `<p style="margin-top:0.8rem;"><em>Login Info:</em> ${vc.note}</p>`; } brandModalDetails.innerHTML = detailsHTML; brandModalBackdrop.classList.add('visible'); } // Hides the modal function hideBrandModal() { if (!brandModalBackdrop) return; brandModalBackdrop.classList.remove('visible'); selectedBrandItem = null; } interface VendorAuthConfig { clientId: string; scopes: string; redirectUrl?: string; note?: string } async function initiateSmartAuth(fhirBaseUrl: string, vendorAuthConfig: VendorAuthConfig, vendorLabel: string = 'vendor') { // Define the default redirect URI (this page) - Defined here as it's only needed here const defaultRedirectUri = window.location.origin + window.location.pathname; console.log(`[initiateSmartAuth] Starting for FHIR Base URL: ${fhirBaseUrl}, VendorLabel: ${vendorLabel}`); showStatusContainer(true); showProgressContainer(false); updateStatus('Preparing authorization request...'); const { clientId, scopes, redirectUrl } = vendorAuthConfig; if (!clientId || !scopes) { updateStatus('Error: Missing SMART client configuration (clientId/scopes).', true); console.error('VendorAuthConfig missing fields:', vendorAuthConfig); return; } const redirectUri = redirectUrl ? makeAbsoluteUrl(redirectUrl) : defaultRedirectUri; console.log('[initiateSmartAuth] Using VendorAuthConfig:', vendorAuthConfig); try { updateStatus('Performing SMART discovery...'); // 1. SMART Discovery const fhirBaseUrlWithSlash = fhirBaseUrl.endsWith('/') ? fhirBaseUrl : fhirBaseUrl + '/'; const wellKnownUrlString = fhirBaseUrlWithSlash + '.well-known/smart-configuration'; console.log(`[initiateSmartAuth] Attempting SMART discovery at: ${wellKnownUrlString}`); const discoveryResponse = await fetch(wellKnownUrlString, { headers: { 'Accept': 'application/json' } }); if (!discoveryResponse.ok) { throw new Error(`SMART discovery failed: ${discoveryResponse.status} ${discoveryResponse.statusText}`); } const smartConfig = await discoveryResponse.json(); const authorizationEndpoint = smartConfig.authorization_endpoint; const tokenEndpoint = smartConfig.token_endpoint; if (!authorizationEndpoint || !tokenEndpoint) { throw new Error('SMART configuration missing required authorization or token endpoint.'); } updateStatus('SMART discovery successful.'); // 2. Generate PKCE & State updateStatus('Generating security parameters...'); const { code_verifier: codeVerifier, code_challenge: codeChallenge } = pkceChallenge(); const state = generateRandomString(); // 3. Store necessary state for redirect const authState: StoredAuthState = { codeVerifier: codeVerifier, state: state, tokenEndpoint: tokenEndpoint, clientId: clientId, // Use vendor-specific clientId redirectUri: redirectUri, // Use determined redirectUri fhirBaseUrl: fhirBaseUrl }; sessionStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authState)); console.log('[initiateSmartAuth] Stored auth state in sessionStorage'); // 4. Construct Authorization URL const authUrl = new URL(authorizationEndpoint); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', clientId); // Use vendor-specific clientId authUrl.searchParams.set('scope', scopes); // Use vendor-specific scopes authUrl.searchParams.set('redirect_uri', redirectUri); // Use determined redirectUri authUrl.searchParams.set('state', state); authUrl.searchParams.set('aud', fhirBaseUrl); // AUD is typically the FHIR base URL itself authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // 5. Redirect user updateStatus('Redirecting to EHR for authorization...'); console.log(`[initiateSmartAuth] Redirecting to: ${authUrl.toString()}`); window.location.href = authUrl.toString(); } catch (err: any) { updateStatus(`Error during authorization initiation: ${err.message}`, true); // Show brand selector again on error? if (brandSelectorContainer) brandSelectorContainer.style.display = 'block'; sessionStorage.removeItem(AUTH_STORAGE_KEY); sessionStorage.removeItem(DELIVERY_TARGET_KEY); } } // *** UPDATED FUNCTION: Handles the click on the modal's "Connect" button *** function handleBrandConnect() { if (!selectedBrandItem || !brandSelectorContainer) { console.error("Connect clicked but required elements or selection missing."); hideBrandModal(); updateStatus("Error: Cannot proceed with connection. Missing information.", true); if (brandSelectorContainer) brandSelectorContainer.style.display = 'block'; return; } const vendorConfig: VendorAuthConfig | undefined = (selectedBrandItem as any)._vendorConfig; if (!vendorConfig) { console.error("Selected brand item is missing _vendorConfig property."); updateStatus("Error: Selected organization is missing SMART client credentials.", true); return; } console.log("--- Brand Connect Button Clicked ---"); console.log("Selected Item:", selectedBrandItem); console.log('Using vendorConfig:', vendorConfig); // Find a suitable FHIR endpoint URL (take the first endpoint) let fhirEndpointUrl: string | null = null; if (selectedBrandItem.endpoints && Array.isArray(selectedBrandItem.endpoints) && selectedBrandItem.endpoints.length > 0) { fhirEndpointUrl = selectedBrandItem.endpoints[0].url; } // Capture label before we possibly clear selectedBrandItem const brandLabel: string = (selectedBrandItem as any).brandName || 'vendor'; // Close the modal immediately (this will clear selectedBrandItem) hideBrandModal(); if (fhirEndpointUrl) { console.log(`Found FHIR Endpoint: ${fhirEndpointUrl}`); if (brandSelectorContainer) brandSelectorContainer.style.display = 'none'; initiateSmartAuth(fhirEndpointUrl, vendorConfig, brandLabel); } else { console.error("Could not find a suitable FHIR endpoint for the selected organization."); updateStatus("Error: Could not find a FHIR endpoint for the selected organization. Please try another.", true); if (brandSelectorContainer) brandSelectorContainer.style.display = 'block'; } } // Fetches brand data files based on tag filtering and initializes selector UI async function fetchBrandsAndInitialize() { console.log("[fetchBrands] Function started."); if (!brandInitialLoadingMessage || !brandResultsContainer || !brandSearchInput || !brandSearchSpinner || !brandPaginationControls) { console.error("[fetchBrands] Error: Required DOM elements not found!"); if (brandInitialLoadingMessage) { brandInitialLoadingMessage.textContent = 'Initialization Error: UI elements missing.'; brandInitialLoadingMessage.style.color = 'red'; } return; } brandInitialLoadingMessage.textContent = 'Loading organizations data...'; brandResultsContainer.style.display = 'none'; brandPaginationControls.style.display = 'none'; brandSearchInput.disabled = true; brandSearchSpinner.style.display = 'block'; try { // Determine desired tags from URL (?brandTags=tag1,tag2) or default to ['prod'] const urlParams = new URLSearchParams(window.location.search); const tagParam = urlParams.get('brandTags'); let tagFilterGroups: string[][] = []; if (tagParam) { const orGroups = tagParam.split(',').map(g => g.trim()).filter(Boolean); tagFilterGroups = orGroups.map(group => group.split('^').map(t => t.trim().toLowerCase()).filter(Boolean) ).filter(group => group.length > 0); // Remove empty groups console.log(`[fetchBrands] Applying tag filters (OR groups of AND tags):`, tagFilterGroups); } else { // Default behavior if no tags specified: require 'prod' tag tagFilterGroups = [['prod']]; console.log(`[fetchBrands] No tags specified, defaulting to requiring 'prod' tag.`); } // Validate brand file index constant const brandIndex = (typeof __BRAND_FILE_INDEX__ !== 'undefined' && Array.isArray(__BRAND_FILE_INDEX__)) ? __BRAND_FILE_INDEX__ : []; if (brandIndex.length === 0) { throw new Error('No brand index entries provided'); } // Filter brand files based on the parsed tag groups const filesToLoad = brandIndex.filter(entry => { if (tagFilterGroups.length === 0) { // If tagParam was present but resulted in no valid groups, show nothing. // If tagParam was absent, the default [[prod]] was used, so this case shouldn't be hit unless default changes. return false; } const entryTagsLower = entry.tags.map(t => t.toLowerCase()); // Check if *any* OR group is satisfied return tagFilterGroups.some(andGroup => // Check if *all* tags within the AND group are present in the entry's tags andGroup.every(requiredTag => entryTagsLower.includes(requiredTag)) ); }); if (filesToLoad.length === 0) { throw new Error(`No brand files matched desired tags: ${tagFilterGroups.map(group => group.join(', ')).join(', ')}`); } console.log(`[fetchBrands] Loading ${filesToLoad.length} brand files...`); // Fetch all brand files in parallel const filePromises = filesToLoad.map(async entry => { try { const response = await fetch(entry.url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const json = await response.json(); if (!json || !Array.isArray(json.items)) throw new Error('Invalid structure: items array missing'); // Annotate each item with vendor name for later vendor config lookup json.items.forEach((itm: any) => (itm._vendorConfig = entry.vendorConfig)); return json.items; } catch (e: any) { console.error(`[fetchBrands] Failed to load brand file '${entry.url}':`, e); return []; } }); const brandItemsArrays = await Promise.all(filePromises); console.log(`[fetchBrands] Loaded ${brandItemsArrays.length} brand files.`, brandItemsArrays); const aggregatedItems: any[] = ([] as any[]).concat(...brandItemsArrays); if (aggregatedItems.length === 0) { throw new Error('No organization records loaded from brand files.'); } console.log(`[fetchBrands] Loaded ${aggregatedItems.length} organization records from brand files.`); allBrandItems = aggregatedItems; currentFilteredItems = collapseBrandItems(allBrandItems, aggregatedItems, new Map()); // Alphabetical order by brand displayName on initial load currentFilteredItems.sort((a, b) => safeLower(a.displayName).localeCompare(safeLower(b.displayName))); brandInitialLoadingMessage.style.display = 'none'; brandResultsContainer.style.display = 'grid'; brandSearchInput.disabled = false; currentPage = 1; renderCurrentPage(); // Autofocus search input brandSearchInput.focus(); // Attach listeners brandSearchInput.addEventListener('input', debouncedBrandSearchHandler); if (brandModalCancel) brandModalCancel.addEventListener('click', hideBrandModal); if (brandModalConnect) brandModalConnect.addEventListener('click', handleBrandConnect); if (brandModalBackdrop) brandModalBackdrop.addEventListener('click', (event) => { if (event.target === brandModalBackdrop) hideBrandModal(); }); if (brandPrevBtn) brandPrevBtn.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderCurrentPage(); } }); if (brandNextBtn) brandNextBtn.addEventListener('click', () => { const totalPages = Math.ceil(currentFilteredItems.length / ITEMS_PER_PAGE); if (currentPage < totalPages) { currentPage++; renderCurrentPage(); } }); // Add listener to close modal with Escape key document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && brandModalBackdrop?.classList.contains('visible')) { hideBrandModal(); } }); } catch (error: any) { console.error('[fetchBrands] Error:', error); if (brandInitialLoadingMessage) { brandInitialLoadingMessage.textContent = `Error loading organizations: ${error.message}`; brandInitialLoadingMessage.style.color = 'red'; } brandResultsContainer.style.display = 'none'; brandSearchInput.disabled = true; } finally { brandSearchSpinner.style.display = 'none'; } } // --- Main Application Logic --- document.addEventListener('DOMContentLoaded', () => { // --- Get All DOM References Once --- brandSelectorContainer = document.getElementById('brand-selector-container'); brandSearchInput = document.getElementById('brand-search-input') as HTMLInputElement | null; brandSearchSpinner = document.getElementById('brand-search-spinner'); brandResultsContainer = document.getElementById('brand-results-container'); brandModalBackdrop = document.getElementById('brand-modal-backdrop'); brandModal = document.getElementById('brand-modal'); brandModalTitle = document.getElementById('brand-modal-title'); brandModalDetails = document.getElementById('brand-modal-details'); brandModalCancel = document.getElementById('brand-modal-cancel') as HTMLButtonElement | null; brandModalConnect = document.getElementById('brand-modal-connect') as HTMLButtonElement | null; brandInitialLoadingMessage = document.getElementById('brand-initial-loading-message'); brandPaginationControls = document.getElementById('brand-pagination-controls'); brandPrevBtn = document.getElementById('brand-prev-btn') as HTMLButtonElement | null; brandNextBtn = document.getElementById('brand-next-btn') as HTMLButtonElement | null; brandPageInfo = document.getElementById('brand-page-info'); // REMOVED fetching references for deleted form elements // formContainer = document.getElementById('form-container'); // ehrForm = document.getElementById('ehr-form') as HTMLFormElement | null; // ehrBaseUrlInput = document.getElementById('ehr_base_url') as HTMLInputElement | null; // ehrClientIdInput = document.getElementById('ehr_client_id') as HTMLInputElement | null; // ehrScopesInput = document.getElementById('ehr_scopes') as HTMLInputElement | null; // ehrRedirectUriInput = document.getElementById('redirect_uri') as HTMLInputElement | null; statusContainer = document.getElementById('status-container'); statusMessageElement = document.getElementById('status-message'); progressContainer = document.getElementById('progress-container'); progressBar = document.getElementById('fetch-progress') as HTMLProgressElement | null; progressText = document.getElementById('progress-text'); // --------------------------------- // --- NEW: Get Download Button Reference --- downloadDataBtn = document.getElementById('download-data-btn') as HTMLButtonElement | null; // ------------------------------------ const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const state = urlParams.get('state'); const error = urlParams.get('error'); const errorDescription = urlParams.get('error_description'); // Define the default redirect URI (this page) const defaultRedirectUri = window.location.origin + window.location.pathname; if (error) { // Handle error response from EHR authorization showStatusContainer(true); updateStatus(`Authorization Error: ${error} - ${errorDescription || 'No description provided.'}`, true); sessionStorage.removeItem(AUTH_STORAGE_KEY); // Clean up state on error return; } if (code && state) { // --- Phase 2: Handle Redirect --- (async () => { // Wrap redirect handling in an async IIFE showStatusContainer(true); updateStatus('Received authorization code. Validating...'); console.log('Detected redirect from EHR.'); console.log(`Code: ${code.substring(0, 10)}...`, `State: ${state}`); // *** HIDE BRAND SELECTOR UI IMMEDIATELY ON REDIRECT *** if (brandSelectorContainer) brandSelectorContainer.style.display = 'none'; showProgressContainer(false); // Ensure progress is hidden initially in this phase too showConfirmationContainer(false); // Ensure confirmation is hidden initially too const storedStateString = sessionStorage.getItem(AUTH_STORAGE_KEY); if (!storedStateString) { updateStatus('Error: Auth state missing from storage. Please start over.', true); return; } let storedState: StoredAuthState; try { storedState = JSON.parse(storedStateString); } catch (e) { updateStatus('Error: Could not parse stored auth state.', true); sessionStorage.removeItem(AUTH_STORAGE_KEY); return; } // Validate state if (state !== storedState.state) { updateStatus('Error: State parameter mismatch. Potential CSRF attack.', true); sessionStorage.removeItem(AUTH_STORAGE_KEY); return; } updateStatus('State validated. Exchanging code for token...'); const { tokenEndpoint, codeVerifier, clientId, redirectUri, fhirBaseUrl } = storedState; console.log(`Using redirect_uri for token exchange: ${redirectUri}`); try { // 1. Exchange code for token const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: redirectUri, // Must match the URI used in the initial auth request client_id: clientId, code_verifier: codeVerifier, }); const tokenResponse = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' // Explicitly accept JSON }, body: tokenParams.toString(), }); const tokenData = await tokenResponse.json(); // Attempt to parse JSON regardless of status if (!tokenResponse.ok) { const errorDetails = tokenData.error_description || tokenData.error || JSON.stringify(tokenData); throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorDetails}`); } const accessToken = tokenData.access_token; const patientId = tokenData.patient; const grantedScopes = tokenData.scope; if (!accessToken || !patientId) { throw new Error('Token response missing required access_token or patient ID.'); } updateStatus('Token received successfully.'); console.log(`Access Token: ${accessToken.substring(0, 8)}...`); console.log(`Patient ID: ${patientId}`); console.log(`Granted Scopes: ${grantedScopes || 'N/A'}`); // Clear sensitive state now that exchange is successful sessionStorage.removeItem(AUTH_STORAGE_KEY); console.log('Cleared auth state from sessionStorage.'); // 2. Fetch FHIR data updateStatus('Fetching EHR data (this may take a while)...'); showProgressContainer(true); // Show progress bar early updateProgress(0, 0, 'Initiating fetch...'); // Initial progress message // *** Store fetched data in a variable accessible later *** let fetchedClientFullEhrObject: any | null = null; // Renamed for clarity try { fetchedClientFullEhrObject = await fetchAllEhrDataClientSideParallel( accessToken, fhirBaseUrl, patientId, updateProgress // Pass the progress update function ); } catch (fetchError: any) { // Handle fetch error specifically updateStatus(`Error fetching EHR data: ${fetchError.message}`, true); console.error("Error during fetchAllEhrDataClientSideParallel:", fetchError); // Hide progress bar on fetch error showProgressContainer(false); // Clear sensitive state if not already cleared sessionStorage.removeItem(AUTH_STORAGE_KEY); sessionStorage.removeItem(DELIVERY_TARGET_KEY); sessionStorage.removeItem(OPENER_TARGET_ORIGIN_KEY); return; // Stop execution here } console.log("Returned from fetchAllEhrDataClientSideParallel. EHR data:", fetchedClientFullEhrObject); // Hide progress bar on successful completion showProgressContainer(false); // 3. Log the result console.log("--- ClientFullEHR Object ---"); console.log(fetchedClientFullEhrObject); console.log("----------------------------"); // 4. Calculate Totals & Prepare Initial Final Status let totalResources = 0; let resourceTypeCount = 0; if (fetchedClientFullEhrObject?.fhir) { resourceTypeCount = Object.keys(fetchedClientFullEhrObject.fhir).length; for (const resourceType in fetchedClientFullEhrObject.fhir) { if (Object.prototype.hasOwnProperty.call(fetchedClientFullEhrObject.fhir, resourceType) && Array.isArray(fetchedClientFullEhrObject.fhir[resourceType])) { totalResources += fetchedClientFullEhrObject.fhir[resourceType].length; } } } const attachmentCount = fetchedClientFullEhrObject?.attachments?.length || 0; let finalStatus = `Data fetched successfully! ${resourceTypeCount} resource types, ${totalResources} total resources, and ${attachmentCount} attachments retrieved.`; updateStatus(finalStatus); // Update status initially // --- 5. Check for and perform delivery --- console.log("Proceeding to delivery check..."); console.log('[Delivery Check] Checking sessionStorage for key:', DELIVERY_TARGET_KEY); const deliveryTargetName = sessionStorage.getItem(DELIVERY_TARGET_KEY); const openerTargetOrigin = sessionStorage.getItem(OPENER_TARGET_ORIGIN_KEY); // Get opener origin console.log('[Delivery Check] Value found in sessionStorage (Target Name):', deliveryTargetName); console.log('[Delivery Check] Value found in sessionStorage (Opener Origin):', openerTargetOrigin); // Log opener origin // Ensure confirmation/download UI is hidden initially before potential delivery showConfirmationContainer(false); if (downloadDataBtn) downloadDataBtn.style.display = 'none'; // Hide download button initially if (deliveryTargetName) { console.log(`[Delivery Check] Delivery target found: ${deliveryTargetName}. Comparing with OPENER_TARGET_VALUE:`, OPENER_TARGET_VALUE); // --- Handle postMessage to opener --- if (deliveryTargetName === OPENER_TARGET_VALUE) { console.log('[Delivery Check] Target is opener. Preparing inline confirmation...'); if (openerTargetOrigin) { // *** SHOW INLINE CONFIRMATION *** updateStatus('Data fetched. Waiting for confirmation to send...'); console.log(`Preparing confirmation UI to send data to: ${openerTargetOrigin}`); // Update the confirmation message text if (confirmationMessageElement) { confirmationMessageElement.textContent = `You have successfully fetched your EHR data. Do you want to send this data back to the application at origin "${openerTargetOrigin}"?`; } // Hide status/progress, show confirmation showStatusContainer(false); showProgressContainer(false); showConfirmationContainer(true); // Define button handlers if (confirmSendBtn && cancelSendBtn) { confirmSendBtn.onclick = () => { console.log('User confirmed data delivery via inline button.'); if (confirmSendBtn) confirmSendBtn.disabled = true; if (cancelSendBtn) cancelSendBtn.disabled = true; showConfirmationContainer(false); showStatusContainer(true); updateStatus('Confirmed. Sending data...'); try { updateStatus(`${finalStatus} Delivering data via postMessage to ${openerTargetOrigin}...`); window.opener.postMessage(fetchedClientFullEhrObject, openerTargetOrigin); // Use fetched data finalStatus += ` Data successfully SENT via postMessage call to ${openerTargetOrigin}.`; updateStatus(finalStatus); console.log(`Successfully CALLED postMessage targeting ${openerTargetOrigin}.`); sessionStorage.removeItem(DELIVERY_TARGET_KEY); sessionStorage.removeItem(OPENER_TARGET_ORIGIN_KEY); } catch (postMessageError: any) { finalStatus += ` Delivery via postMessage CALL FAILED: ${postMessageError.message}`; updateStatus(finalStatus, true); console.error(`Failed to CALL postMessage to ${openerTargetOrigin}:`, postMessageError); } finally { console.log('Attempting to close retriever window after confirmed postMessage attempt.'); setTimeout(() => window.close(), 500); } }; cancelSendBtn.onclick = () => { console.log('User cancelled data delivery via inline button.'); if (confirmSendBtn) confirmSendBtn.disabled = true; if (cancelSendBtn) cancelSendBtn.disabled = true; showConfirmationContainer(false); showStatusContainer(true); finalStatus = 'Delivery cancelled by user. You may now close this window.'; updateStatus(finalStatus); // *** SHOW DOWNLOAD BUTTON ON CANCEL *** if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, 'ehr-data-cancelled-delivery.json'); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } sessionStorage.removeItem(DELIVERY_TARGET_KEY); sessionStorage.removeItem(OPENER_TARGET_ORIGIN_KEY); console.log('Closing retriever window after cancellation.'); // Don't close automatically if download is offered // setTimeout(() => window.close(), 500); }; } else { // Buttons not found console.error("Confirmation buttons not found!"); updateStatus("Error: Confirmation UI elements missing. Cannot proceed.", true); showConfirmationContainer(false); showStatusContainer(true); console.log('Attempting to close retriever window due to missing UI elements.'); setTimeout(() => window.close(), 500); } } else { // Opener origin missing finalStatus += ` Delivery via postMessage FAILED: Opener's target origin not found in session storage. Cannot target postMessage. You may download your data below.`; updateStatus(finalStatus, true); // *** SHOW DOWNLOAD BUTTON ON MISSING ORIGIN *** if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, 'ehr-data-missing-origin.json'); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } console.error("Cannot postMessage to opener: Target origin missing from session storage."); // Don't close automatically if download is offered // console.log('Attempting to close retriever window after failed opener delivery (missing origin).'); // setTimeout(() => window.close(), 500); } } // --- Handle named endpoint delivery --- else { let deliveryEndpoints: Record<string, { postUrl: string }> = {}; deliveryEndpoints = __DELIVERY_ENDPOINTS__ || {} const endpointConfig = deliveryEndpoints[deliveryTargetName]; if (endpointConfig && endpointConfig.postUrl) { const postUrl = makeAbsoluteUrl(endpointConfig.postUrl); updateStatus(`${finalStatus} Delivering data to ${deliveryTargetName} at ${postUrl}...`); try { const deliveryResponse = await fetch(postUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fetchedClientFullEhrObject) // Use fetched data }); if (!deliveryResponse.ok) { const errorBody = await deliveryResponse.text(); throw new Error(`Delivery POST failed (${deliveryResponse.status}): ${errorBody}`); } console.log(`Successfully POSTed data to ${deliveryTargetName} (${postUrl})`); sessionStorage.removeItem(DELIVERY_TARGET_KEY); try { const jsonData = await deliveryResponse.json(); console.log("JSON response from delivery endpoint:", jsonData); if (jsonData.success === true && typeof jsonData.redirectTo === 'string' && jsonData.redirectTo) { updateStatus(`${finalStatus} Data POST successful. Redirecting to complete flow...`); console.log(`Redirecting to: ${jsonData.redirectTo}`); window.location.href = jsonData.redirectTo; return; // Stop execution after redirect } else if (jsonData.success === true && !jsonData.redirectTo) { // *** SUCCESSFUL POST, NO REDIRECT -> SHOW DOWNLOAD *** finalStatus = `Data fetched and POST successful. No redirect specified. You may download your data or close this window.`; updateStatus(finalStatus); if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, `ehr-data-${deliveryTargetName}.json`); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } console.log('Delivery successful, no redirect specified, download offered.'); } else { const serverError = jsonData.error || 'unknown_server_error'; const serverErrorDesc = jsonData.error_description || 'Server did not provide redirect URL or indicated failure.'; throw new Error(`Server Error (${serverError}): ${serverErrorDesc}`); } } catch (parseError) { console.error("Failed to parse JSON response from delivery endpoint:", parseError); throw new Error("Received malformed response from the delivery server."); } } catch (deliveryError: any) { // *** DELIVERY POST FAILED -> SHOW DOWNLOAD *** console.error(`Failed to POST or process response for ${deliveryTargetName}:`, deliveryError); finalStatus += ` Delivery to ${deliveryTargetName} FAILED: ${deliveryError.message || 'Unknown delivery error'}. You may download your data below.`; updateStatus(finalStatus, true); if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, 'ehr-data-delivery-failed.json'); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } } } else { // *** INVALID DELIVERY TARGET CONFIG -> SHOW DOWNLOAD *** finalStatus += ` Delivery target '${deliveryTargetName}' configuration invalid or incomplete (missing postUrl). You may download your data below.`; updateStatus(finalStatus, true); if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, 'ehr-data-invalid-config.json'); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } console.error(`Delivery target '${deliveryTargetName}' requested but configuration is invalid in __DELIVERY_ENDPOINTS__.`); sessionStorage.removeItem(DELIVERY_TARGET_KEY); } } } else { // *** NO DELIVERY TARGET -> SHOW DOWNLOAD *** console.log('No delivery target specified in session storage.'); finalStatus += `. No delivery target specified. You may download your data or close this window.`; updateStatus(finalStatus); if (downloadDataBtn) { downloadDataBtn.style.display = 'inline-block'; downloadDataBtn.onclick = () => { triggerJsonDownload(fetchedClientFullEhrObject, 'ehr-data.json'); if(downloadDataBtn) downloadDataBtn.disabled = true; // Disable after click }; } } // --- End Delivery --- } catch (err: any) { // Catch errors during token exchange or *outer* fetch block (like JSON parsing of token) updateStatus(`Error during authorization or data processing: ${err.message}`, true); console.error("Unhandled error in redirect handler:", err); // Hide progress/confirmation, show status showProgressContainer(false); showConfirmationContainer(false); showStatusContainer(true); if (downloadDataBtn) downloadDataBtn.style.display = 'none'; // Ensure download not shown on these errors // Clear state even on error during these steps sessionStorage.removeItem(AUTH_STORAGE_KEY); sessionStorage.removeItem(DELIVERY_TARGET_KEY); sessionStorage.removeItem(OPENER_TARGET_ORIGIN_KEY); } })(); // Immediately invoke the async function } else { // --- Phase 1: Initial Load - Setup Brand Selector --- console.log('Initial page load. Setting up brand selector.'); // Ensure correct initial visibility (includes pagination) if (brandSelectorContainer) brandSelectorContainer.style.display = 'block'; if (brandPaginationControls) brandPaginationControls.style.display = 'none'; // Ensure hidden initially if (statusContainer) statusContainer.style.display = 'none'; showProgressContainer(false); // Ensure progress is hidden // Fetch brand data and initialize the selector UI fetchBrandsAndInitialize().then(() => { // Check for delivery target in hash (keep this logic) const hash = window.location.hash; sessionStorage.removeItem(DELIVERY_TARGET_KEY); // Clear any previous target first sessionStorage.removeItem(OPENER_TARGET_ORIGIN_KEY); // Clear previous origin too if (hash) { const openerPrefix = '#deliver-to-opener:'; if (hash.startsWith(openerPrefix)) { const targetOrigin = hash.substring(openerPrefix.length); if (targetOrigin) { // Validate if it looks like an origin (basic check) try { new URL(targetOrigin); // Check if it parses as a URL sessionStorage.setItem(DELIVERY_TARGET_KEY, OPENER_TARGET_VALUE); sessionStorage.setItem(OPENER_TARGET_ORIGIN_KEY, targetOrigin); console.log(`Found and stored delivery target: Opener Window`); console.log(`Stored opener target origin: ${targetOrigin}`); history.replaceState(null, '', ' '); // Clear hash } catch (e) { console.warn(`Invalid target origin provided in hash: ${targetOrigin}`); } } else { console.warn('Found #deliver-to-opener: but target origin is empty.'); } } else if (hash.startsWith('#deliver-to:')) { const targetName = hash.substring('#deliver-to:'.length); if (targetName) { sessionStorage.setItem(DELIVERY_TARGET_KEY, targetName); console.log(`Found and stored delivery target: ${targetName}`); history.replaceState(null, '', ' '); // Clear hash } else { console.warn('Found #deliver-to: but target name is empty.'); } } } }) // Ensure confirmation UI is hidden on initial load showConfirmationContainer(false); // Ensure download button is hidden on initial load if (downloadDataBtn) downloadDataBtn.style.display = 'none'; } }); // --- NEW: Helper Function to Trigger JSON Download --- function triggerJsonDownload(data: any, filename: string) { if (!data) { console.error("Download triggered but data is null."); alert("Error: No data available to download."); return; } try { const jsonString = JSON.stringify(data, null, 2); // Pretty print JSON const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); // Required for Firefox a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Clean up console.log(`Successfully triggered download of ${filename}`); } catch (error: any) { console.error(`Error creating or triggering download for ${filename}:`, error); alert(`Failed to initiate download: ${error.message}`); } } // --- END NEW HELPER FUNCTION ---

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