Skip to main content
Glama

Smart EHR MCP Server

by jmandel
tools.ts60.9 kB
// Create file: src/tools.ts import { z } from 'zod'; import _ from 'lodash'; import { Database } from 'bun:sqlite'; // import vm from 'vm'; // REMOVE static import import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { createFhirRenderer } from './fhirToPlaintext.js'; // Import the renderer import { ClientFullEHR } from '../clientTypes.js'; // Import using relative path // --- Configuration --- const MAX_GREP_JSON_LENGTH = 2 * 1024 * 1024; // 2 MB limit - Applies to overall output? Revisit. const MAX_QUERY_JSON_LENGTH = 500 * 1024; // 500 KB limit const MAX_EVAL_JSON_LENGTH = 1 * 1024 * 1024; // 1 MB limit const MAX_QUERY_ROWS = 500; // Limit rows for query results before stringification check const GREP_CONTEXT_LENGTH = 200; // Characters before/after match - Increased from 50 const DEFAULT_PAGE_SIZE = 50; // Default number of hits per page const DEFAULT_PAGE = 1; // Default page number const PLAINTEXT_RENDER_LIMIT = 5 * 1024; // 5 KB limit for rendered resource plaintext const JSON_RENDER_LIMIT = 10 * 1024; // 10 KB limit for resource JSON string // Map from ResourceType to ordered list of potential date fields (FHIRPath-like) const RESOURCE_DATE_PATHS: Record<string, string[]> = { "AllergyIntolerance": [], // No obvious primary date "CarePlan": [], // Complex, relies on activity dates usually "CareTeam": [], // Period might exist, but not primary focus "Condition": [ "onsetDateTime", "onsetPeriod.start", "recordedDate", "abatementDateTime", // Less preferred than onset/recorded "abatementPeriod.start", "extension(url='http://hl7.org/fhir/StructureDefinition/condition-assertedDate').valueDateTime", "meta.lastUpdated" ], "Coverage": [ "period.start", "period.end" // Less preferred ], "Device": [ "manufactureDate", "expirationDate" ], "DiagnosticReport": [ "effectiveDateTime", "effectivePeriod.start", "issued", "meta.lastUpdated" ], "DocumentReference": [ "date", "context.period.start", "extension(url='http://hl7.org/fhir/us/core/StructureDefinition/us-core-authentication-time').valueDateTime" ], "Encounter": [ "period.start", "period.end", // Less preferred "meta.lastUpdated" ], "Goal": [ "startDate", "target.dueDate" ], "Immunization": [ "occurrenceDateTime" ], "Location": [], "Medication": [], // No date on resource itself "MedicationDispense": [ "whenHandedOver" ], "MedicationRequest": [ "authoredOn", // Nested extension example - needs specific handling "extension(url='http://hl7.org/fhir/us/core/StructureDefinition/us-core-medication-adherence').extension(url='dateAsserted').valueDateTime" ], "Observation": [ "effectiveDateTime", "effectivePeriod.start", "effectiveInstant", "issued", "meta.lastUpdated" ], "Organization": [], "Patient": [ "birthDate" ], "Practitioner": [], "PractitionerRole": [], "Procedure": [ "performedDateTime", "performedPeriod.start" ], "Provenance": [ "recorded" ], "QuestionnaireResponse": [ "authored" ], "RelatedPerson": [], "ServiceRequest": [ "occurrenceDateTime", "occurrencePeriod.start", "authoredOn" ], "Specimen": [] // Collection has period, but maybe not primary }; // --- Tool Schemas --- export const GrepRecordInputSchema = z.object({ query: z.string().min(1).describe("The text string or JavaScript-style regular expression to search for (case-insensitive). Example: 'heart attack|myocardial infarction|mi'. Best for finding specific text/keywords or variations across *all* record parts (FHIR+notes). Use regex with `|` for related terms (e.g., `'diabetes|diabetic'`)."), resource_types: z.array(z.string()).optional().describe( `Optional list to filter the search scope. Supports FHIR resource type names (e.g., "Patient", "Observation") and the special keyword "Attachment". Behavior based on the list: - **If omitted or an empty list is provided:** Searches EVERYTHING - all FHIR resources and all attachment plaintext. - **List contains only FHIR types (e.g., ["Condition", "Procedure"]):** Searches ONLY the specified FHIR resource types AND the plaintext of attachments belonging *only* to those specified resource types. - **List contains only ["Attachment"]:** Searches ONLY the plaintext content of ALL attachments, regardless of which resource they belong to. - **List contains FHIR types AND "Attachment" (e.g., ["DocumentReference", "Attachment"]):** Searches the specified FHIR resource types (e.g., DocumentReference) AND the plaintext of ALL attachments (including those not belonging to the specified FHIR types).` ), resource_format: z.enum(['plaintext', 'json']).optional().default('plaintext').describe( "Determines the output format for matching FHIR *resources*. " + "'plaintext' (default): Shows the rendered plaintext representation of the resource. " + "'json': Shows the full FHIR JSON of the resource. " + "Matching *attachments* always show plaintext snippets." ), page_size: z.number().int().min(1).max(50).optional().describe("Number of hits to display per page (1-50). Defaults to 50."), page: z.number().int().min(1).optional().describe("Page number to display. Defaults to 1.") }); // --- Grep Output Schemas --- export const QueryRecordInputSchema = z.object({ sql: z.string().min(1).describe("The read-only SQL SELECT statement to execute against the in-memory FHIR data. FHIR resources are stored in the 'fhir_resources' table with columns 'resource_type', 'resource_id', and 'json'. For example, 'SELECT json FROM fhir_resources WHERE resource_type = \"Patient\"' or 'SELECT json FROM fhir_resources WHERE resource_type = \"Observation\" AND json LIKE \"%diabetes%\"'. Best for precisely selecting specific FHIR resources or fields using known structure (e.g., Observations by LOINC). Limited to structured FHIR data.") }); export const QueryRecordOutputSchema = z.union([ z.array(z.record(z.unknown())).describe("An array of rows returned by the SQL query. Each row is an object where keys are column names."), z.object({ warning: z.string().optional(), error: z.string().optional(), truncated_results: z.array(z.record(z.unknown())).optional() }).describe("Object indicating truncated results or an error during processing/truncation.") ]); // TODO: Define AskQuestion schemas if this tool is re-enabled // export const AskQuestionInputSchema = z.object({ // question: z.string().min(1).describe("The natural language question to ask about the patient's record.") // }); // export const AskQuestionOutputSchema = z.object({ answer: z.string() }).describe("The natural language answer generated by the LLM based on the record context."); // Simple wrapper for now // TODO: Define ResyncRecord schemas if this tool is re-enabled // export const ResyncRecordInputSchema = z.object({}).describe("No arguments needed."); // export const ResyncRecordOutputSchema = z.object({ message: z.string() }).describe("A confirmation message indicating the outcome of the resync attempt."); export const EvalRecordInputSchema = z.object({ code: z.string().min(1).describe( `A string containing the body of an async JavaScript function. This function receives the following arguments: 1. 'fullEhr': An object containing the patient's EHR data (ClientFullEHR format): - 'fullEhr.fhir': An object where keys are FHIR resource type strings (e.g., "Patient", "Observation") and values are arrays of the corresponding FHIR resource JSON objects. - 'fullEhr.attachments': An array of processed attachment objects (ClientProcessedAttachment format from 'clientTypes.ts'). Each object includes: - 'resourceType', 'resourceId', 'path', 'contentType' - 'contentPlaintext': The extracted plaintext content (string or null). - 'contentBase64': Raw content encoded as a base64 string (string or null). - 'json': The original JSON string of the attachment node. 2. 'console': A limited console object with 'log', 'warn', and 'error' methods that capture output. 3. '_': The Lodash library (v4). Useful for data manipulation (e.g., _.find, _.map, _.filter). 4. 'Buffer': The Node.js Buffer class. Useful for operations like base64 decoding (e.g., Buffer.from(base64String, 'base64').toString('utf8')). The function MUST conclude with a 'return' statement specifying the JSON-serializable value to send back. Do NOT define the function signature ('async function(...) { ... }') within the code string, only provide the body. Console output will be captured separately and returned alongside the function's result or any execution errors. Example Input (Note: Access .contentBase64 for binary, .contentPlaintext for text): { "code": "const conditions = fullEhr.fhir['Condition'] || [];\\nconst activeProblems = conditions.filter(c => c.clinicalStatus?.coding?.[0]?.code === 'active');\\nconst diabeteConditions = activeProblems.filter(c => JSON.stringify(c.code).toLowerCase().includes('diabete'));\\n\\n// Get patient name (handle potential missing data)\\nconst patient = (fullEhr.fhir['Patient'] || [])[0];\\nlet patientName = 'Unknown';\\nif (patient && patient.name && patient.name[0]) {\\n patientName = \`\${patient.name[0].given?.join(' ') || ''} \${patient.name[0].family || ''}\`.trim();\\n}\\n\\nconsole.log(\`Found \${diabeteConditions.length} active diabetes condition(s) for patient \${patientName}.\`);\\n\\n// Find PDF attachments\\nconst pdfAttachments = fullEhr.attachments.filter(a => a.contentType === 'application/pdf');\\nconsole.warn(\`Found \${pdfAttachments.length} PDF attachments.\`);\\n\\n// Example of decoding base64 (if needed, check contentPlaintext first!)\\nconst firstAttachment = fullEhr.attachments[0];\\nif (firstAttachment && firstAttachment.contentBase64) {\\n try {\\n // Only decode if contentPlaintext wasn't useful\\n // const decodedText = Buffer.from(firstAttachment.contentBase4, 'base64').toString('utf8');\\n // console.log('Decoded snippet:', decodedText.substring(0, 50));\\n } catch (e) { console.error('Error decoding base64 for first attachment'); }\\n}\\n\\nreturn { \\n patient: patientName,\\n activeDiabetesCount: diabeteConditions.length,\\n diabetesDetails: diabeteConditions.map(c => ({ id: c.id, code: c.code?.text || JSON.stringify(c.code), onset: c.onsetDateTime || c.onsetAge?.value }))\\n};" } Most flexible tool. Best for complex analysis, calculations, combining data from multiple resource types/attachments, or custom output formatting. Use when \`grep\` or \`query\` alone are insufficient.` ) }); export const EvalRecordOutputSchema = z.object({ result: z.any().optional().describe("The JSON-serializable result returned by the executed code (if successful). Will be 'undefined' if the code threw an error or did not return a value. Can be '[Result omitted due to excessive size]' if truncated."), logs: z.array(z.string()).describe("An array of messages logged via console.log or console.warn during execution. Can contain truncation messages."), errors: z.array(z.string()).describe("An array of messages logged via console.error during execution, or internal execution errors (like timeouts or syntax errors). Can contain truncation messages.") }).describe("The result of executing the provided JavaScript code against the patient record, including the returned value and captured console output/errors."); // --- Read Resource Schemas --- export const ReadResourceInputSchema = z.object({ resourceType: z.string().describe("The FHIR resource type (e.g., 'Patient', 'Observation')."), resourceId: z.string().describe("The ID of the FHIR resource.") }); export const ReadResourceOutputSchema = z.object({ resource: z.record(z.unknown()).nullable().describe("The full FHIR resource JSON object, or null if not found."), error: z.string().optional().describe("Error message if the resource could not be retrieved.") }).describe("The requested FHIR resource."); // --- End Read Resource Schemas --- // --- Read Attachment Schema (Input Only) --- export const ReadAttachmentInputSchema = z.object({ resourceType: z.string().describe("The FHIR resource type the attachment belongs to."), resourceId: z.string().describe("The ID of the FHIR resource the attachment belongs to."), attachmentPath: z.string().describe("The JSON path within the source resource where the attachment is located (e.g., 'content.attachment', 'photo[0]'). Provided by the grep tool."), includeRawBase64: z.boolean().optional().default(false).describe("Set to true to include the raw base64 content in the response. Defaults to false.") }); // --- End Read Attachment Schema --- // --- Logic Functions --- /** * Extracts and formats the most relevant date from a FHIR resource based on predefined paths. * @param resource The FHIR resource object. * @returns Formatted date string (YYYY-MM-DD) or null. */ function extractResourceDate(resource: any): string | null { if (!resource || !resource.resourceType) { return null; } const paths = RESOURCE_DATE_PATHS[resource.resourceType] || []; if (paths.length === 0) { return null; } for (const path of paths) { let value: any = null; // Handle specific extension cases first if (path.startsWith('extension(url=')) { const urlMatch = path.match(/extension\(url='([^']+)'\)/); if (urlMatch && resource.extension) { const url = urlMatch[1]; const extension = _.find(resource.extension, { url: url }); if (extension) { const remainingPath = path.substring(urlMatch[0].length + 1); // +1 for the dot if (!remainingPath) { // e.g., extension(url='...').valueDateTime // This case shouldn't happen with current map, expects .valueXXX } else if (remainingPath.startsWith('value')) { value = _.get(extension, remainingPath); } else if (remainingPath.startsWith('extension(url=')) { // Handle nested extension e.g., extension(url='...').extension(url='...').valueDateTime const nestedUrlMatch = remainingPath.match(/extension\(url='([^']+)'\)/); if (nestedUrlMatch && extension.extension) { const nestedUrl = nestedUrlMatch[1]; const nestedExtension = _.find(extension.extension, { url: nestedUrl }); if (nestedExtension) { const finalPathPart = remainingPath.substring(nestedUrlMatch[0].length + 1); value = _.get(nestedExtension, finalPathPart); } } } } } } else { // Handle simple paths and period starts value = _.get(resource, path); } if (value && typeof value === 'string') { // Extract YYYY-MM-DD part from FHIR date/dateTime/instant const dateMatch = value.match(/^(\d{4}(-\d{2}(-\d{2})?)?)/); if (dateMatch && dateMatch[1]) { return dateMatch[1]; // Return YYYY or YYYY-MM or YYYY-MM-DD } } } return null; // No suitable date found } /** * Truncates SQL query results if they exceed limits (row count or stringified size). * * @param results The array of result rows from the SQL query. * @param limit The maximum allowed length of the JSON string. * @returns The original results array, or a truncation object { warning, truncated_results, error }. */ function truncateQueryResults(results: Record<string, unknown>[], limit: number): any { try { // 1. Check row limit first const originalRowCount = results.length; let potentiallyTruncatedResults = results; let rowLimitWarning: string | null = null; if (originalRowCount > MAX_QUERY_ROWS) { potentiallyTruncatedResults = results.slice(0, MAX_QUERY_ROWS); rowLimitWarning = `Result limited to first ${potentiallyTruncatedResults.length} of ${originalRowCount} rows.`; console.warn(`[TRUNCATE QUERY] Row limit exceeded (${originalRowCount} > ${MAX_QUERY_ROWS}).`); } // 2. Check JSON size limit on (potentially row-limited) results let jsonString = JSON.stringify(potentiallyTruncatedResults); if (jsonString.length <= limit) { // If only row limit was applied, return the truncation object structure if (rowLimitWarning) { return { warning: rowLimitWarning, truncated_results: potentiallyTruncatedResults }; } return potentiallyTruncatedResults; // Return original array if no limits hit } // 3. Apply size limit truncation (which means returning only the warning object) console.warn(`[TRUNCATE QUERY] Size limit exceeded (${(jsonString.length / 1024).toFixed(0)} KB > ${(limit / 1024).toFixed(0)} KB).`); const sizeLimitWarning = `Result truncated due to size limit (${(limit / 1024).toFixed(0)} KB).` + (rowLimitWarning ? ` (Already limited to ${potentiallyTruncatedResults.length} rows)` : ` Original query returned ${originalRowCount} rows.`); // For size limit, we cannot return any results, just the warning. // We prioritize the size warning message. const truncatedData = { warning: sizeLimitWarning, // truncated_results: [] // Or potentially a very small subset if needed, but let's omit for now }; // Final check: Stringify the *warning object itself* and see if it's too large (highly unlikely) let finalJsonString = JSON.stringify(truncatedData); if (finalJsonString.length > limit) { console.error(`[TRUNCATE QUERY] Result STILL too large after size truncation (warning object too big).`); return { error: "Result too large to return, even after truncation." }; } return truncatedData; } catch (stringifyError: any) { console.error(`[TRUNCATE QUERY] Error during stringification/truncation:`, stringifyError); return { error: `Internal error during result processing/truncation: ${stringifyError.message}` }; } } /** * Truncates Eval tool output if its stringified representation exceeds a limit. * * @param output The EvalRecordOutputSchema object (result, logs, errors). * @param limit The maximum allowed length of the JSON string. * @returns The potentially modified output object. */ function truncateEvalResult(output: z.infer<typeof EvalRecordOutputSchema>, limit: number): any { try { let jsonString = JSON.stringify(output); if (jsonString.length <= limit) { return output; // No truncation needed } console.warn(`[TRUNCATE EVAL] Result exceeds limit (${(jsonString.length / 1024).toFixed(0)} KB > ${(limit / 1024).toFixed(0)} KB), applying truncation.`); let truncatedData: any; let warningMessage = `Result truncated due to size limit (${(limit / 1024).toFixed(0)} KB).`; // Prioritize errors > logs > result const originalLogs = output.logs || []; const originalErrors = output.errors || []; truncatedData = { result: "[Result omitted due to excessive size]", logs: originalLogs.slice(0, 20), errors: [...originalErrors] // Copy original errors }; warningMessage += " Result omitted, logs potentially truncated."; if (truncatedData.logs.length < originalLogs.length) { truncatedData.logs.push("... [Logs truncated due to size limit]"); } // Add the primary warning to the errors array as well truncatedData.errors.push(`Execution result (or combined output) was too large to return fully. ${warningMessage}`); // Add the warning to the truncated data structure truncatedData.warning = warningMessage; // Final check: Stringify the *truncated* data and see if it's STILL too large let finalJsonString = JSON.stringify(truncatedData); if (finalJsonString.length > limit) { console.error(`[TRUNCATE EVAL] Result STILL too large after truncation.`); return { error: "Result too large to return, even after truncation.", logs: [], result: undefined, errors: ["Output exceeded size limit even after truncation."] }; } return truncatedData; // Return the successfully truncated data object } catch (stringifyError: any) { console.error(`[TRUNCATE EVAL] Error during stringification/truncation:`, stringifyError); return { error: `Internal error during result processing/truncation: ${stringifyError.message}` }; } } /** * Finds all occurrences of a regex in text and returns context snippets. * @param text The text to search within. * @param regex The regular expression (should have 'g' flag). * @param contextLen Number of characters before/after the match to include. * @returns Array of strings, each containing a context snippet. */ function findContextualMatches(text: string, regex: RegExp, contextLen: number): string[] { const snippets: string[] = []; let match; // Ensure regex has global flag for exec loop const globalRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g'); while ((match = globalRegex.exec(text)) !== null) { const matchStart = match.index; const matchEnd = matchStart + match[0].length; const contextStart = Math.max(0, matchStart - contextLen); const contextEnd = Math.min(text.length, matchEnd + contextLen); const prefix = text.substring(contextStart, matchStart); const suffix = text.substring(matchEnd, contextEnd); // Construct snippet without highlighting and remove newlines let snippet = `...${prefix}${match[0]}${suffix}...`; snippet = snippet.replace(/\r?\n|\r/g, ' '); // More robust replacement for \n, \r\n, \r snippets.push(snippet); // Prevent infinite loops for zero-length matches if (match[0].length === 0) { globalRegex.lastIndex++; } } return snippets; } // --- REFACTORED grepRecordLogic --- interface GrepResourceHit { resource_ref: string; resource_type: string; resource_id: string; rendered_content: string | object; // Plaintext string or JSON object date: string | null; format: 'plaintext' | 'json'; // The format used for this hit match_found_in_json: boolean; // True if the regex matched the resource JSON itself content_truncated: boolean; // True if rendered_content was truncated } interface GrepAttachmentHit { resource_ref: string; // Reference to the resource owning the attachment resource_type: string; resource_id: string; path: string; // Path within the owning resource contentType?: string; context_snippets: string[]; date: string | null; // Date extracted from the owning resource } export async function grepRecordLogic( fullEhr: ClientFullEHR, query: string, inputResourceTypes?: string[], resourceFormat: 'plaintext' | 'json' = 'plaintext', // New parameter pageSize: number = DEFAULT_PAGE_SIZE, page: number = DEFAULT_PAGE ): Promise<string> { // Returns Markdown string // --- Instantiate Renderer --- let renderResource: (resource: any) => string; try { renderResource = createFhirRenderer(fullEhr); } catch (rendererError: any) { console.error(`[GREP Logic] Failed to create FHIR renderer:`, rendererError); return `**Error:** Failed to initialize resource renderer. Cannot proceed. ${rendererError.message}`; } // --- End Renderer Instantiation --- let regex: RegExp; let originalQuery = query; // Keep original for reporting try { // Ensure regex is case-insensitive and global for snippet extraction if (query.startsWith('/') && query.endsWith('/')) query = query.slice(1, -1); regex = new RegExp(query, 'gi'); // Add 'g' flag console.error(`[GREP Logic] Using regex: ${regex}`); } catch (e: any) { console.error(`[GREP Logic] Invalid regex: "${originalQuery}"`, e); return `**Error:** Invalid regular expression provided: \`${originalQuery}\`. ${e.message}`; // Return Markdown error } // Store hits before pagination const allResourceHits: GrepResourceHit[] = []; const allAttachmentHits: GrepAttachmentHit[] = []; let resourcesSearched = 0; let attachmentsSearched = 0; const matchedResourceRefs = new Set<string>(); // Track ResourceRefs with hits const matchedAttachmentRefs = new Set<string>(); // Track AttachmentRefs with hits const searchOnlyAttachments = inputResourceTypes?.length === 1 && inputResourceTypes[0] === "Attachment"; let typesForResourceSearch: string[] = []; let typesForAttachmentFilter: string[] | null = null; // Determine search scope based on inputResourceTypes if (searchOnlyAttachments) { typesForResourceSearch = []; typesForAttachmentFilter = null; console.error("[GREP Logic] Scope: Attachments Only"); } else if (!inputResourceTypes || inputResourceTypes.length === 0) { typesForResourceSearch = Object.keys(fullEhr.fhir); typesForAttachmentFilter = null; console.error("[GREP Logic] Scope: All Resources and All Attachments (Default)"); } else { typesForResourceSearch = inputResourceTypes.filter(t => t !== "Attachment"); if (inputResourceTypes.includes("Attachment")) { typesForAttachmentFilter = null; console.error(`[GREP Logic] Scope: Resources [${typesForResourceSearch.join(', ')}] and ALL Attachments`); } else { typesForAttachmentFilter = typesForResourceSearch; console.error(`[GREP Logic] Scope: Resources [${typesForAttachmentFilter.join(', ')}] and their specific Attachments`); } } // 1. Search FHIR Resources if (typesForResourceSearch.length > 0) { console.error(`[GREP Logic] Searching ${typesForResourceSearch.length} resource types for format '${resourceFormat}'...`); for (const resourceType of typesForResourceSearch) { const resources = fullEhr.fhir[resourceType] || []; for (const resource of resources) { if (!resource || typeof resource !== 'object' || !resource.resourceType || !resource.id) { console.warn(`[GREP Logic] Skipping invalid resource structure in type '${resourceType}'.`); continue; } resourcesSearched++; const resourceRef = `${resource.resourceType}/${resource.id}`; // Don't search again if already found if (matchedResourceRefs.has(resourceRef)) continue; let matchFound = false; let renderedContent: string | object = {}; let contentTruncated = false; try { const resourceString = JSON.stringify(resource); if (regex.test(resourceString)) { matchFound = true; regex.lastIndex = 0; // Reset regex index after test } } catch (stringifyError) { console.warn(`[GREP Logic] Error stringifying resource ${resourceRef}:`, stringifyError); // Store error as rendered content? For now, skip adding the hit. continue; } if (matchFound) { matchedResourceRefs.add(resourceRef); const date = extractResourceDate(resource); // Render or get JSON based on format if (resourceFormat === 'plaintext') { try { let plaintext = renderResource(resource); // Use the instantiated renderer if (plaintext.length > PLAINTEXT_RENDER_LIMIT) { renderedContent = plaintext.substring(0, PLAINTEXT_RENDER_LIMIT) + "\n\n[... Plaintext rendering truncated due to size limit ...]"; contentTruncated = true; console.warn(`[GREP Logic] Truncated plaintext for ${resourceRef}`); } else { renderedContent = plaintext; } } catch (renderError: any) { console.error(`[GREP Logic] Error rendering resource ${resourceRef} to plaintext:`, renderError); renderedContent = `[Error rendering resource to plaintext: ${renderError.message}]`; contentTruncated = true; // Mark as truncated due to error } } else { // resourceFormat === 'json' try { const jsonString = JSON.stringify(resource, null, 2); if (jsonString.length > JSON_RENDER_LIMIT) { // Just store a message, not the truncated JSON object renderedContent = `"[FHIR JSON truncated due to size limit (${(jsonString.length/1024).toFixed(0)} KB > ${(JSON_RENDER_LIMIT/1024).toFixed(0)} KB)]"`; contentTruncated = true; console.warn(`[GREP Logic] Truncated JSON for ${resourceRef}`); } else { renderedContent = resource; // Store the actual JSON object } } catch (jsonError: any) { console.error(`[GREP Logic] Error stringifying resource ${resourceRef} for JSON output:`, jsonError); renderedContent = `"[Error preparing resource JSON: ${jsonError.message}]"`; contentTruncated = true; // Mark as truncated due to error } } allResourceHits.push({ resource_ref: resourceRef, resource_type: resource.resourceType, resource_id: resource.id, rendered_content: renderedContent, date: date, format: resourceFormat, match_found_in_json: true, // Match was in the resource itself content_truncated: contentTruncated }); } } } console.error(`[GREP Logic] Found matches in ${allResourceHits.length} resources after searching ${resourcesSearched}.`); } else { console.error("[GREP Logic] Skipping resource search based on scope."); } // 2. Select and Search Attachments (Prioritize one per source resource, ALWAYS return snippets) console.error(`[GREP Logic] Grouping and selecting best attachment from ${fullEhr.attachments.length} total attachments...`); const contentTypePriority: { [key: string]: number } = { 'text/plain': 1, 'text/html': 2, 'text/rtf': 3, 'text/xml': 4 // Other types have lower priority (Infinity) }; // Store the *anchor* resource with the best attachment for date extraction later const bestAttachmentPerSource: { [sourceRef: string]: { attachment: ClientFullEHR['attachments'][0], anchorResource: any | null } } = {}; for (const attachment of fullEhr.attachments) { if (!attachment || !attachment.resourceType || !attachment.resourceId || !attachment.path) { console.warn(`[GREP Logic] Skipping invalid attachment structure during selection.`); continue; } attachmentsSearched++; // Count every attachment encountered const sourceRef = `${attachment.resourceType}/${attachment.resourceId}`; // --- Attachment Filtering Logic --- // Skip if we are filtering by resource type and this attachment's type doesn't match if (typesForAttachmentFilter && !typesForAttachmentFilter.includes(attachment.resourceType)) { continue; } // --- End Filtering --- const currentBestData = bestAttachmentPerSource[sourceRef]; const currentPriority = attachment.contentType ? (contentTypePriority[attachment.contentType.split(';')[0].trim()] ?? Infinity) : Infinity; // Handle content-type parameters like charset const bestPriority = currentBestData?.attachment.contentType ? (contentTypePriority[currentBestData.attachment.contentType.split(';')[0].trim()] ?? Infinity) : Infinity; if (!currentBestData || currentPriority < bestPriority) { // Find the anchor resource to store alongside the attachment for date extraction const anchorResource = (fullEhr.fhir[attachment.resourceType] || []).find(r => r.id === attachment.resourceId) || null; bestAttachmentPerSource[sourceRef] = { attachment: attachment, anchorResource: anchorResource }; } } const selectedAttachmentsData = Object.values(bestAttachmentPerSource); console.error(`[GREP Logic] Selected ${selectedAttachmentsData.length} unique source attachments for searching (Filter: ${typesForAttachmentFilter ? `Only types [${typesForAttachmentFilter.join(', ')}]` : 'All'})...`); // Now search *only* the selected attachments for snippets let totalSnippetsFound = 0; // Reset snippet count for attachments only for (const { attachment, anchorResource } of selectedAttachmentsData) { const attachmentRef = `${attachment.resourceType}/${attachment.resourceId}#${attachment.path}`; // Don't search again if already found if (matchedAttachmentRefs.has(attachmentRef)) continue; if (attachment.contentPlaintext && typeof attachment.contentPlaintext === 'string' && attachment.contentPlaintext.length > 0) { const snippets = findContextualMatches(attachment.contentPlaintext, regex, GREP_CONTEXT_LENGTH); if (snippets.length > 0) { matchedAttachmentRefs.add(attachmentRef); totalSnippetsFound += snippets.length; const date = extractResourceDate(anchorResource); // Extract date from anchor allAttachmentHits.push({ resource_ref: `${attachment.resourceType}/${attachment.resourceId}`, // Reference to the owner resource_type: attachment.resourceType, resource_id: attachment.resourceId, path: attachment.path, contentType: attachment.contentType, context_snippets: snippets, date: date }); } } } console.error(`[GREP Logic] Found matches in ${allAttachmentHits.length} attachments (total ${totalSnippetsFound} snippets).`); // --- Pagination and Formatting --- const totalResources = allResourceHits.length; const totalAttachments = allAttachmentHits.length; const totalResults = totalResources + totalAttachments; const totalPages = Math.max(1, Math.ceil(totalResults / pageSize)); const normalizedPage = Math.min(Math.max(1, page), totalPages); const startIndex = (normalizedPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, totalResults); // Sort all hits by date (most recent first) if date is available const sortHits = <T extends { date: string | null }>(hits: T[]): T[] => { return [...hits].sort((a, b) => { if (!a.date && !b.date) return 0; if (!a.date) return 1; if (!b.date) return -1; return b.date.localeCompare(a.date); // Most recent first }); }; const sortedResourceHits = sortHits(allResourceHits); const sortedAttachmentHits = sortHits(allAttachmentHits); // Combine and paginate the sorted hits const allCombinedHits: (GrepResourceHit | GrepAttachmentHit)[] = [...sortedResourceHits, ...sortedAttachmentHits]; const paginatedHits = allCombinedHits.slice(startIndex, endIndex); console.error(`[GREP Logic] Pagination: Page ${normalizedPage}/${totalPages}, showing ${paginatedHits.length} of ${totalResults} total matching resources/attachments`); // 3. Format results as Markdown let markdownOutput = `## Grep Results for \`${originalQuery.replace(/`/g, '\\`')}\`\n\n`; markdownOutput += `_(Hint: Use \`read_resource\` or \`read_attachment\` for full details on interesting hits. Format used for resources: \`${resourceFormat}\`)_\n\n`; markdownOutput += `Searched ${resourcesSearched} resources and ${attachmentsSearched} attachments. Found ${totalResources} matching resources and ${totalAttachments} matching attachments.\n\n`; markdownOutput += `**Page ${normalizedPage} of ${totalPages}** (${pageSize} matching resources/attachments per page)\n\n`; if (totalResults === 0) { markdownOutput += "**No matches found.**\n"; } else { // Iterate through paginated combined hits for (const hit of paginatedHits) { const dateString = hit.date ? ` (${hit.date})` : ''; // Check if it's a Resource Hit if ('rendered_content' in hit) { // --- REMOVED PLAINTEXT HEADER LOGIC --- let finalRenderedContent = ''; // Default header only used for JSON now const header = `### Resource: ${hit.resource_ref}${dateString}`; if (hit.format === 'plaintext') { let renderedString = (typeof hit.rendered_content === 'string' ? hit.rendered_content : '[Error: Expected plaintext string, got object]'); // Prepend truncation warning if necessary if (hit.content_truncated) { markdownOutput += `_(Content truncated due to size limits or error)_\n`; } // Split into lines, modify the first line, rejoin const lines = renderedString.split('\n'); if (lines.length > 0) { lines[0] = `${lines[0]} (Ref: ${hit.resource_ref})`; // Append ref to first line } const finalRenderedString = lines.join('\n'); markdownOutput += finalRenderedString; // Print the modified content markdownOutput += "\n\n"; } else { // format === 'json' // Use the default header for JSON markdownOutput += header + '\n'; if (hit.content_truncated) { markdownOutput += `_(Content truncated due to size limits or error)_\n`; } markdownOutput += "```json\n"; if (typeof hit.rendered_content === 'object') { markdownOutput += JSON.stringify(hit.rendered_content, null, 2); } else { // If truncated, rendered_content is already a string message markdownOutput += hit.rendered_content; } markdownOutput += "\n```\n\n"; } } // Check if it's an Attachment Hit else if ('context_snippets' in hit) { markdownOutput += `### Attachment Snippets for ${hit.resource_ref}${dateString}\n`; markdownOutput += `Path: \`${hit.path.replace(/`/g, '\\`')}\`\n`; if (hit.contentType) { markdownOutput += `Content-Type: \`${hit.contentType.replace(/`/g, '\\`')}\`\n`; } markdownOutput += "Matching Snippets:\n"; // Show all snippets for (const snippet of hit.context_snippets) { // Simple Markdown escaping for snippets let escapedSnippet = snippet.replace(/([*_[\]()``\\\\])/g, '\\$1'); // Simplified formatting: Always use list item since newlines are removed markdownOutput += `* ${escapedSnippet}\n`; } markdownOutput += "\n"; } } // Add pagination navigation links (same as before) if (totalPages > 1) { markdownOutput += "### Navigation\n\n"; if (normalizedPage > 1) markdownOutput += `* Previous Page (${normalizedPage - 1}/${totalPages}): Use \`page=${normalizedPage - 1}\`\n`; if (normalizedPage < totalPages) markdownOutput += `* Next Page (${normalizedPage + 1}/${totalPages}): Use \`page=${normalizedPage + 1}\`\n`; if (totalPages > 5) markdownOutput += `* Jump to specific page (1-${totalPages}): Use \`page=X\`\n`; markdownOutput += "\n"; } } markdownOutput += "---"; return markdownOutput; } export async function queryRecordLogic(db: Database, sql: string): Promise<string> { // Returns JSON string console.error(`[SQL Logic] Executing query: ${sql.substring(0, 100)}${sql.length > 100 ? '...' : ''}`); // Basic validation to prevent modifications const sqlLower = sql.trim().toLowerCase(); if (!sqlLower.startsWith('select')) { console.error("[SQL Logic] Validation failed: Query does not start with SELECT."); const errorResult = { error: "Only SELECT queries are allowed." }; return JSON.stringify(errorResult, null, 2); } const writeKeywords = ['insert ', 'update ', 'delete ', 'drop ', 'create ', 'alter ', 'attach ', 'detach ', 'replace ', 'pragma ']; if (writeKeywords.some(keyword => sqlLower.includes(keyword))) { if (!sqlLower.startsWith('pragma table_info') && !sqlLower.startsWith('pragma user_version')) { console.error(`[SQL Logic] Validation failed: Potentially harmful SQL keyword detected.`); const errorResult = { error: "Potentially harmful SQL operation detected. Only SELECT statements and specific PRAGMAs are permitted." }; return JSON.stringify(errorResult, null, 2); } } try { // The .all() method returns unknown[], cast appropriately const results = await db.query(sql).all() as Record<string, unknown>[]; console.error(`[SQL Logic] Query returned ${results.length} rows.`); // Truncate if needed const finalData = truncateQueryResults(results, MAX_QUERY_JSON_LENGTH); return JSON.stringify(finalData, null, 2); } catch (err: any) { console.error("[SQL Logic] Query execution error:", err); const errorResult = { error: `SQL execution failed: ${err.message}` }; return JSON.stringify(errorResult, null, 2); } } export async function evalRecordLogic( fullEhr: ClientFullEHR, userCode: string ): Promise<string> { // Returns JSON string // --- Dynamic Import for vm --- let vm: typeof import('vm'); try { vm = (await import('vm')).default; } catch (e) { console.error("[EVAL Logic] Failed to dynamically import 'vm'. This tool is likely running in an unsupported environment.", e); return JSON.stringify({ error: "Internal server error: VM module not available." }); } // --------------------------- const logs: string[] = []; const errors: string[] = []; const MAX_LOG_MESSAGES = 100; console.error(`[EVAL Logic] Preparing to execute sandboxed code...`); const sandboxConsole = { log: (...args: any[]) => { if (logs.length < MAX_LOG_MESSAGES) logs.push(args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' ')); else if (logs.length === MAX_LOG_MESSAGES) logs.push("... [Max log messages reached]"); }, warn: (...args: any[]) => { if (logs.length < MAX_LOG_MESSAGES) logs.push(`WARN: ${args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' ')}`); else if (logs.length === MAX_LOG_MESSAGES) logs.push("... [Max log messages reached]"); }, error: (...args: any[]) => errors.push(`CONSOLE.ERROR: ${args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' ')}`), }; const sandbox = { fullEhr, console: sandboxConsole, _: _, Buffer: Buffer, __resultPromise__: undefined as Promise<any> | undefined }; const scriptCode = `async function userFunction(fullEhr, console, _, Buffer) { "use strict"; ${userCode} }\n__resultPromise__ = userFunction(fullEhr, console, _, Buffer);`; const context = vm.createContext(sandbox); const script = new vm.Script(scriptCode, { filename: 'userCode.vm' }); const timeoutMs = 5000; let executionResult: any = undefined; let executionError: Error | null = null; try { console.error(`[EVAL Logic] Executing sandboxed code (Timeout: ${timeoutMs}ms)...`); script.runInContext(context, { timeout: timeoutMs / 2, displayErrors: true }); executionResult = await Promise.race([ sandbox.__resultPromise__, new Promise((_, reject) => setTimeout(() => reject(new Error('Async operation timed out')), timeoutMs)) ]); console.error(`[EVAL Logic] Sandboxed code finished successfully.`); } catch (error: any) { console.error("[EVAL Logic] Error executing sandboxed code:", error); executionError = error; // Store error to include in final output } // Compile final output structure including result/logs/errors const finalOutput: z.infer<typeof EvalRecordOutputSchema> = { result: executionResult, // Might be undefined if error occurred logs: logs, errors: errors }; // Add execution error message if one occurred if (executionError) { let errorMessage: string; if (executionError.message.includes('timed out')) errorMessage = `Code execution timed out after ${timeoutMs / 1000} seconds.`; else if (executionError instanceof SyntaxError) errorMessage = `Syntax error in provided code: ${executionError.message}`; else errorMessage = `Error during code execution: ${executionError.message}`; finalOutput.errors.push(`Execution Error: ${errorMessage}`); finalOutput.result = undefined; // Ensure result is undefined on execution error } // Check JSON serializability before final truncation check try { JSON.stringify(finalOutput.result); } catch (stringifyError: any) { console.error("[EVAL Logic] Result is not JSON serializable:", stringifyError); finalOutput.errors.push(`Execution Error: Result is not JSON-serializable: ${stringifyError.message}.`); finalOutput.result = undefined; // Set result to undefined as it cannot be sent } // Truncate the final compiled output object if needed const finalData = truncateEvalResult(finalOutput, MAX_EVAL_JSON_LENGTH); return JSON.stringify(finalData, null, 2); } export async function readResourceLogic( fullEhr: ClientFullEHR, resourceType: string, resourceId: string ): Promise<string> { // Returns JSON string console.error(`[READ Resource Logic] Attempting to read ${resourceType}/${resourceId}`); const resources = fullEhr.fhir[resourceType] || []; const resource = resources.find(r => r && r.id === resourceId); let result: z.infer<typeof ReadResourceOutputSchema>; if (resource) { console.error(`[READ Resource Logic] Found ${resourceType}/${resourceId}`); result = { resource: resource }; } else { console.error(`[READ Resource Logic] Resource ${resourceType}/${resourceId} not found.`); result = { resource: null, error: `Resource ${resourceType}/${resourceId} not found.` }; } try { // No size limit for reading a single resource, assume it's manageable return JSON.stringify(result, null, 2); } catch (stringifyError: any) { console.error(`[READ Resource Logic] Error stringifying result for ${resourceType}/${resourceId}:`, stringifyError); return JSON.stringify({ resource: null, error: `Internal error stringifying resource: ${stringifyError.message}` }, null, 2); } } export async function readAttachmentLogic( fullEhr: ClientFullEHR, resourceType: string, resourceId: string, attachmentPath: string, includeRawBase64: boolean ): Promise<string> { // Returns Markdown string console.error(`[READ Attachment Logic] Attempting to read attachment at ${resourceType}/${resourceId}#${attachmentPath} (Include Base64: ${includeRawBase64})`); const attachment = fullEhr.attachments.find(a => a.resourceType === resourceType && a.resourceId === resourceId && a.path === attachmentPath ); if (attachment) { console.error(`[READ Attachment Logic] Found attachment at ${resourceType}/${resourceId}#${attachmentPath}`); // Construct Markdown output let markdown = `## Attachment Content\n\n`; markdown += `**Source:** \`${attachment.resourceType}/${attachment.resourceId}\`\n`; markdown += `**Path:** \`${attachment.path.replace(/`/g, '\\`')}\`\n`; if (attachment.contentType) { markdown += `**Content-Type:** \`${attachment.contentType.replace(/`/g, '\\`')}\`\n`; } markdown += `\n---\n\n`; // Separator if (attachment.contentPlaintext) { // Process plaintext: trim whitespace-only lines, limit consecutive blank lines const lines = attachment.contentPlaintext.replace(/\r\n/g, '\n').split('\n'); const processedLines: string[] = []; let consecutiveBlankLines = 0; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === '') { consecutiveBlankLines++; if (consecutiveBlankLines <= 2) { processedLines.push(''); // Add max 2 blank lines } } else { consecutiveBlankLines = 0; processedLines.push(line); // Add original line with whitespace if it wasn't blank } } // Trim leading and trailing blank lines from the processed lines array let firstNonBlank = -1; let lastNonBlank = -1; for (let i = 0; i < processedLines.length; i++) { if (processedLines[i] !== '') { if (firstNonBlank === -1) { firstNonBlank = i; } lastNonBlank = i; } } let finalLines: string[]; if (firstNonBlank === -1) { // All lines were blank finalLines = []; } else { finalLines = processedLines.slice(firstNonBlank, lastNonBlank + 1); } const processedPlaintext = finalLines.join('\n'); // Wrap the processed plaintext in <plaintext> tags without escaping markdown += `\n<plaintext>\n${processedPlaintext}\n</plaintext>\n\n`; } else { markdown += `_(No plaintext content available or extracted)_\n\n`; } if (includeRawBase64) { markdown += `\n---\n\n**Raw Base64 Content:**\n`; if (attachment.contentBase64) { markdown += "```\n" + attachment.contentBase64 + "\n```\n"; } else { markdown += `_(No base64 content available)_\n`; } } return markdown; } else { console.error(`[READ Attachment Logic] Attachment at ${resourceType}/${resourceId}#${attachmentPath} not found.`); // Return Markdown error message return `**Error:** Attachment at \`${resourceType}/${resourceId}#${attachmentPath.replace(/`/g, '\\`')}\` not found.`; } } /** * Registers the standard EHR interaction tools (grep, query, eval, read_resource, read_attachment) with an McpServer instance. * This function abstracts the context retrieval (finding EHR data and DB connection) * to allow reuse in different server environments (e.g., SSE, CLI). * * @param mcpServer The McpServer instance to register tools with. * @param getContext A function that resolves the necessary context (fullEhr, db) based on the tool name and optional extra data (like session ID). */ export function registerEhrTools( mcpServer: McpServer, getContext: ( toolName: 'grep_record' | 'query_record' | 'eval_record' | 'read_resource' | 'read_attachment', extra?: Record<string, any> ) => Promise<{ fullEhr?: ClientFullEHR, db?: Database }> ): void { mcpServer.tool( "grep_record", GrepRecordInputSchema.shape, // Use the updated schema async (args, extra) => { try { const { fullEhr } = await getContext("grep_record", extra); if (!fullEhr) { throw new McpError(ErrorCode.InternalError, "EHR data context not found for this session/request."); } // --- Pass the new resource_format argument --- console.error(`[TOOL grep_record] Context retrieved. Query: "${args.query}", Types: ${args.resource_types?.join(',') || 'All'}, Format: ${args.resource_format || 'plaintext'}, Page: ${args.page || DEFAULT_PAGE}, PageSize: ${args.page_size || DEFAULT_PAGE_SIZE}`); const resultString = await grepRecordLogic( fullEhr, args.query, args.resource_types, args.resource_format || 'plaintext', // Pass format args.page_size || DEFAULT_PAGE_SIZE, args.page || DEFAULT_PAGE ); // Check if the result starts with the error marker we defined const isError = resultString.startsWith("**Error:**"); return { content: [{ type: "text", text: resultString }], isError: isError }; // Return Markdown directly } catch (error: any) { console.error(`[TOOL grep_record] Error during context retrieval or execution:`, error); const errorMessage = error instanceof McpError ? error.message : `Internal server error: ${error.message}`; const errorMarkdown = `**Error:** ${errorMessage}`; return { content: [{ type: "text", text: errorMarkdown }], isError: true }; // Return Markdown error } } ); mcpServer.tool( "query_record", QueryRecordInputSchema.shape, async (args, extra) => { try { const { db } = await getContext("query_record", extra); if (!db) { throw new McpError(ErrorCode.InternalError, "Database context not found for this session/request."); } console.error(`[TOOL query_record] Context retrieved. SQL: ${args.sql.substring(0, 100)}...`); const resultString = await queryRecordLogic(db, args.sql); const isError = resultString.includes('"error":'); return { content: [{ type: "text", text: resultString }], isError: isError }; } catch (error: any) { console.error(`[TOOL query_record] Error during context retrieval or execution:`, error); const errorMessage = error instanceof McpError ? error.message : `Internal server error: ${error.message}`; const errorResult = JSON.stringify({ error: errorMessage }); return { content: [{ type: "text", text: errorResult }], isError: true }; } } ); mcpServer.tool( "eval_record", EvalRecordInputSchema.shape, async (args, extra) => { try { const { fullEhr } = await getContext("eval_record", extra); if (!fullEhr) { throw new McpError(ErrorCode.InternalError, "EHR data context not found for this session/request."); } console.error(`[TOOL eval_record] Context retrieved. Code length: ${args.code.length}`); const resultString = await evalRecordLogic(fullEhr, args.code); const isError = resultString.includes('"error":') || resultString.includes('Execution Error:'); return { content: [{ type: "text", text: resultString }], isError: isError }; } catch (error: any) { console.error(`[TOOL eval_record] Error during context retrieval or execution:`, error); const errorMessage = error instanceof McpError ? error.message : `Internal server error: ${error.message}`; const errorResult = JSON.stringify({ error: errorMessage }); return { content: [{ type: "text", text: errorResult }], isError: true }; } } ); mcpServer.tool( "read_resource", ReadResourceInputSchema.shape, async (args, extra) => { try { const { fullEhr } = await getContext("read_resource", extra); if (!fullEhr) { throw new McpError(ErrorCode.InternalError, "EHR data context not found for this session/request."); } console.error(`[TOOL read_resource] Context retrieved. Reading ${args.resourceType}/${args.resourceId}`); const resultString = await readResourceLogic(fullEhr, args.resourceType, args.resourceId); const isError = resultString.includes('"error":'); // Check if logic function returned an error message return { content: [{ type: "text", text: resultString }], isError: isError }; } catch (error: any) { console.error(`[TOOL read_resource] Error during context retrieval or execution:`, error); const errorMessage = error instanceof McpError ? error.message : `Internal server error: ${error.message}`; const errorResult = JSON.stringify({ resource: null, error: errorMessage }); return { content: [{ type: "text", text: errorResult }], isError: true }; } } ); mcpServer.tool( "read_attachment", ReadAttachmentInputSchema.shape, async (args, extra) => { try { const { fullEhr } = await getContext("read_attachment", extra); if (!fullEhr) { throw new McpError(ErrorCode.InternalError, "EHR data context not found for this session/request."); } console.error(`[TOOL read_attachment] Context retrieved. Reading ${args.resourceType}/${args.resourceId}#${args.attachmentPath}`); const resultString = await readAttachmentLogic(fullEhr, args.resourceType, args.resourceId, args.attachmentPath, args.includeRawBase64); // Check if the result starts with the Markdown error marker const isError = resultString.startsWith('**Error:**'); return { content: [{ type: "text", text: resultString }], isError: isError }; // Return Markdown directly } catch (error: any) { console.error(`[TOOL read_attachment] Error during context retrieval or execution:`, error); const errorMessage = error instanceof McpError ? error.message : `Internal server error: ${error.message}`; const errorMarkdown = `**Error:** ${errorMessage}`; return { content: [{ type: "text", text: errorMarkdown }], isError: true }; // Return Markdown error } } ); }

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