This document is the detailed reference for everything specific to ModelForge Medical — the clinical layer built on top of the general-purpose Modelforge chat/agent client. If you're looking for how the underlying Electron app is put together, start with Architecture; for the generic tool-calling system, Agent mode. This document assumes both as background and covers only what's specific to the clinical workspace: Patient Cases, Evidence Library, Knowledge Graph, Clinical Assistant's safety layer, Audit & Privacy (including encryption and session locking), and the MCP integrations built for medical use (Graphify, BioMCP, DICOM MCP) plus the client rework that preceded them.
- Product boundary
- Navigation map
- Clinical Assistant
- Patient Cases
- Evidence Library
- Knowledge Graph
- Audit & Privacy
- Encryption at rest
- Automatic session locking
- Medical MCP integrations
- MCP client architecture
- Data model reference
- IPC surface reference
- File storage layout
- Testing this layer
- Known limitations
ModelForge Medical is a clinical decision-support, medical research, and documentation assistant for physicians, clinical staff, medical researchers, and medical students. It is deliberately not:
- An autonomous diagnostician — every clinically relevant answer is structured as
"possible interpretations," never a single settled diagnosis, and every
model-generated message carries a "Not verified" badge (
frontend/src/pages/Chat.tsx, theMessageBubblecomponent). - A prescriber — the app places no orders and submits nothing on a clinician's behalf; every tool call (built-in or MCP) still requires per-call human approval. See Agent mode for that approval model in full.
- A certified HIPAA/HITRUST/FDA-compliant system. Local-first storage, an audit trail, encryption at rest, and session locking are real privacy controls — none of them are a compliance certification. See Known limitations.
The sidebar's Clinical group (frontend/src/components/layout.tsx) adds four
routes on top of the base app's Chat/Settings/Compare/etc.:
| Route | Page | Purpose |
|---|---|---|
/cases |
pages/PatientCases.tsx |
List, create, delete patient cases |
/cases/:caseId |
pages/PatientCaseDetail.tsx |
Structured case fields, per-field context selection, conflict warnings |
/evidence |
pages/EvidenceLibrary.tsx |
Add-by-URL evidence source library |
/knowledge-graph |
pages/KnowledgeGraph.tsx |
Per-case concept graph (conditions/allergies/medications) |
/audit |
pages/AuditPrivacy.tsx |
Audit trail, encryption management, session-lock settings, retention |
Clinical Assistant itself is the existing / and /chat/:sessionId routes
(pages/Chat.tsx) — it wasn't given a new route, only a safety layer layered on top
(below).
Every message sent from Clinical Assistant carries a system-prompt addendum,
CLINICAL_RESPONSE_CONTRACT (frontend/src/pages/Chat.tsx), requiring exactly
eight sections in order whenever the answer is clinically relevant:
- Summary
- Known patient facts
- Assessment or possible interpretations
- Missing information
- Red flags and urgent concerns
- Suggested next clinical steps
- Evidence and citations
- Uncertainty and limitations
This is a prompt, not a guarantee — a model can still fail to follow it. That's why the safety-critical pieces below (emergency detection, drug-conflict warnings) run as plain code, independent of anything the model says.
A dropdown in the composer toolbar (CLINICAL_MODES in Chat.tsx) prepends a
mode-specific instruction on top of the response contract:
| Key | Label | Instruction focus |
|---|---|---|
none |
General | No addition (default) |
soap |
SOAP note | Subjective/Objective/Assessment/Plan |
differential |
Differential diagnosis support | Ranked possible interpretations, not a single diagnosis |
medicationReview |
Medication review | Interactions, duplication, dosing concerns |
dischargeSummary |
Discharge summary | — |
patientEducation |
Patient education | Plain-language, no unexplained jargon |
researchReview |
Research/literature review | Summarize literature/guidelines, cite sources |
app/src/medical-safety.ts's checkForEmergencyFlags(text) scans the user's own
message — before any model call — for plain-language emergency phrasing:
difficulty breathing, stroke symptoms (facial droop, slurred speech, sudden
weakness/numbness), severe chest pain, anaphylaxis, major bleeding, loss of
consciousness, active self-harm risk, overdose. A match sets emergencyFlags state
in Chat.tsx, rendering a persistent, dismissible banner
(EMERGENCY_BANNER_TEXT) telling the user to contact emergency services — this
banner's appearance never depends on, or waits for, a model response; it's
computed and shown before the message is even sent to a model. Reached via
IPC medicalSafety:checkEmergency (deterministic regex matching, no model call —
see medical-safety.test.ts for the exact
pattern list and what does/doesn't trigger it).
checkMedicationConflicts(allergies, medications) in the same file runs
deterministic keyword/synonym matching against a Patient Case's recorded allergies
and medications:
- Allergy matches: a small synonym table (
ALLERGY_CLASS_SYNONYMS) maps common allergy classes (penicillin, sulfa, NSAID) to their member drug names. - Known interaction pairs: a small demonstration list (
KNOWN_INTERACTIONS, e.g. warfarin+aspirin, MAOI+SSRI, sildenafil+nitrate) — explicitly not a licensed clinical drug-interaction database (First Databank, Lexicomp, Multum).
Every warning rendered from this (PatientCaseDetail.tsx) is labeled "Generated by
simple keyword matching, not a licensed drug-interaction database. Verify
independently." Reached via IPC patientCases:checkConflicts.
Sending to a remote provider (anything not in LOCAL_PROVIDERS — i.e. not Ollama,
llama.cpp, or a local MLX/ROCm/vLLM runtime) while a patient case is attached or
files are attached triggers a native confirm() dialog listing exactly what's
about to leave the device and to which provider, before the request fires
(handleSend in Chat.tsx). Declining aborts the send entirely — nothing is sent
silently.
A checkbox in the composer ("Redact identifiers before sending"), shown only when
the selected model is a remote provider, runs medicalSafety.redact(content)
(redactIdentifiers — regex patterns for email, phone, SSN, MRN, DOB) on the fully
assembled outgoing content before the transmission-preview dialog, so the
confirmation the user sees already reflects the redacted text and the redaction
count. Off by default — this is pattern-based scrubbing, not clinical-grade
de-identification (HIPAA Safe Harbor requires far more: free-text narrative
mentions of names, locations, rare ages, device identifiers, etc., none of which
regex can reliably catch).
Attached files and RAG-retrieved content are wrapped with an explicit
UNTRUSTED_CONTENT_PREAMBLE (Chat.tsx, buildMessageContent) telling the model
this is reference material to inform its answer, never an instruction to follow —
a mitigation (not an elimination) of prompt injection via an imported clinical
document. Patient-case field content is not wrapped this way, since it's
clinician-typed, trusted input, not imported/untrusted content.
A dropdown in the composer lets the user attach one Patient Case to the current
message. Only the fields the user has explicitly marked includeInContext: true
on that case (see Patient Cases) are pulled in — via
patientCases:buildContext — and prefixed as Patient case context (clinician-entered, fields explicitly included by the user): ahead of the user's
own message text.
app/src/patient-cases-store.ts + app/src/schemas.ts (patientCaseSchema).
Every clinical field on a PatientCase is a CaseField<T> = { value: T; includeInContext: boolean } — the includeInContext flag is what the "user
controls exactly which fields are sent" requirement is actually built on; nothing
is included in a model prompt unless that flag is explicitly true, and it
defaults to false on every new case and every new field.
| Field | Type | Notes |
|---|---|---|
demographics |
CaseField<{age?, sex?, notes?}> |
|
presentingComplaint |
CaseField<string> |
|
symptomsTimeline |
CaseField<string> |
|
vitalSigns |
CaseField<string> |
Free text, e.g. "BP 122/78, HR 76, RR 16, Temp 37.0°C, SpO2 98%" |
conditions |
CaseField<string[]> |
|
allergies |
CaseField<string[]> |
Feeds the conflict checker |
medications |
CaseField<string[]> |
Feeds the conflict checker |
labResults |
CaseField<LabResult[]> |
{id, name, value, unit?, referenceRange?, observedAt?} |
imagingAndReports |
CaseField<string> |
|
clinicalNotes |
ClinicalNote[] |
{id, author: "clinician" | "model-inference", text, createdAt} — provenance-tagged, never blended |
attachments |
AttachmentRef[] |
{id, name, mimeType?, addedAt} |
consentNote, enteredBy |
string? |
Plain text — no verified-identity system behind enteredBy (see Known limitations) |
Every read goes through getCase(id) / listCases(), which never does a raw array
index or unfiltered scan — a caller can only ever get back the one case it asked
for. This is the actual guarantee behind "one patient's data never leaks into
another case's context." See patient-cases-store.test.ts's "isolates cases from
one another" test.
The single choke point that assembles a model-prompt-ready text block: iterates
every field, includes a line only if includeInContext is true and the field is
non-empty, and returns both the text and the list of included field labels (so the
transmission preview can show exactly what was included in plain language, not
just a raw diff).
app/src/evidence-store.ts + app/src/schemas.ts (evidenceSourceSchema).
Add-by-URL only — deliberately, to avoid presenting unreviewed live search results
as vetted medical evidence. addSourceFromUrl(url):
- Validates the URL is
http(s)://(rejects anything else, e.g.file://). - Fetches the page (15s timeout) and extracts only what it can honestly find: the
<title>tag and a<meta name="description">tag. Never fabricates a title, author, or date it couldn't find — a missing title falls back to the URL's path, never an invented string. - Guesses
organizationandsourceType(peer-reviewed/guideline/reference-database/other) from a small table of known domains (KNOWN_ORGANIZATIONS: PubMed/NCBI, NIH, WHO, CDC, FDA, Cochrane, UpToDate) — this is a UI convenience default, not a claim that every page on that domain is peer-reviewed.
Sources added here are what checkCitations (medical-safety.ts) can cross-check
a model's inline citation markers ([1], (Smith, 2020)) against — a marker with
no matching known source is flagged as unverified rather than trusted at face
value. (This citation-checking function exists and is tested; it is not yet wired
into Clinical Assistant's message rendering — see Known limitations.)
frontend/src/pages/KnowledgeGraph.tsx — intentionally simple and honest about
what it is: a per-case concept graph, not a medical knowledge base. Nodes are
built directly from a case's conditions / allergies / medications fields
(rendered via the existing Mermaid diagram support), with every node's provenance
listed in a table below the diagram (which case field it came from). No
UMLS/SNOMED/RxNorm linkage, no inferred relationships — it shows only what's
directly on the case.
For anything richer (building a real knowledge graph from a folder of documents, papers, or imaging reports, and letting Clinical Assistant query/path/explain it), the page links to connecting Graphify as an MCP server — see Medical MCP integrations.
app/src/audit-log-store.ts + app/src/schemas.ts (auditEventSchema),
UI at frontend/src/pages/AuditPrivacy.tsx.
Every AuditEvent has id, timestamp, actionCategory, and optional
targetType / targetId / detail. The categories:
| Category | Recorded when |
|---|---|
case-created / case-updated / case-deleted / case-viewed |
Patient Cases CRUD (patient-cases-handlers.ts) |
model-call-local / model-call-remote |
Every chat send, split by whether the provider is local or remote |
mcp-tool-call |
Every MCP tool call — approved, auto-approved, or denied (see below) |
export / data-deleted / settings-changed |
Data export, deletions, and settings changes such as enabling encryption |
mcp-tool-call events additionally carry mcpServerId, mcpServerName,
mcpToolName, approvalOutcome ("approved" | "auto-approved" | "denied"), and
durationMs — recorded from Chat.tsx's respondToToolCall, which measures wall
time around the tool execution and calls window.api.audit.record(...) regardless
of whether the call was approved, auto-approved (via a trust profile — see
MCP client architecture), or denied.
No field on AuditEvent carries a tool call's actual arguments or result.
detail is documented as "short, non-clinical" and callers are responsible for
keeping it that way — the store itself doesn't (and structurally can't) inspect it
for clinical content, but nothing in the codebase puts clinical narrative into it.
This is a deliberate design constraint: the audit trail's whole purpose is
accountability (who did what, when, was it approved), and turning it into a second
place PHI could leak from would defeat that purpose.
Settings → Audit & Privacy offers 30 days / 90 days / 1 year / forever
(AppSettings.auditLogRetentionDays). Purging happens both on write (recordEvent)
and on read (listEvents), so lowering the retention window takes effect
immediately rather than waiting for the next recorded event. This sits on top of a
fixed MAX_EVENTS = 5000 hard cap that always applies regardless of the
age-based setting.
app/src/case-encryption.ts, IPC in app/src/ipc/encryption-handlers.ts, UI in
AuditPrivacy.tsx's EncryptionSection.
Protects patient-cases.json (the one store holding real clinical detail —
allergies, medications, conditions, notes) against someone with filesystem
access but not the passphrase: a stolen laptop that's powered off, a backup tool,
a synced folder. It does not protect against an attacker with control of the
running, unlocked app — that's narrowed by session locking,
not eliminated by it.
- AES-256-GCM, key derived from a user passphrase via
crypto.scryptSyncwith a random 16-byte salt. - The passphrase itself is never stored anywhere, in any form. Only the salt
(safe to store — its job is defeating rainbow tables, not being secret) and a
verifier — an HMAC of a fixed message, computed with the derived key —
persist to
case-encryption-config.json. A passphrase check is "derive the key, recompute the HMAC, compare to the stored verifier" — the passphrase is compared to nothing; only its derived key's fingerprint is. - The derived key lives only in main-process memory, only while the session considers itself unlocked. Nothing persists it across an app restart — every fresh launch starts locked if encryption is enabled.
- Two physical files, never both authoritative at once:
patient-cases.json(plaintext) andpatient-cases.enc.json(an envelope of{ivHex, ciphertextHex, authTagHex}). Switching modes always deletes the now-stale file — enabling encryption deletes the plaintext copy after the encrypted one is written; disabling does the reverse. Stale plaintext is never left lying around next to a freshly-encrypted copy, or vice versa.
| Action | What happens |
|---|---|
| Setup | Read existing (plaintext) cases → caseEncryption.setup(passphrase) (new salt/key/verifier, unlocks) → re-write cases (now encrypted) → audit settings-changed |
| Unlock | Derive key from stored salt, compare verifier — no state change to case files |
| Lock | Clear the in-memory key only — case files untouched, unreadable again until unlock |
| Disable | Verify passphrase (which also unlocks) → read cases (decrypts with current key) → clear encryption config → re-write cases (now plaintext) → audit |
| Change passphrase | Verify old passphrase → read cases (decrypts with old key) → rotate to new salt/key/verifier → re-write cases (encrypts with new key) → audit |
Reading (readAll) or writing (writeAll) case data while encryption is enabled
but locked throws CaseDataLockedError rather than silently returning an empty
list — an empty case list and "you haven't unlocked it" must never look the same
to a caller. PatientCases.tsx / PatientCaseDetail.tsx check encryption:status
before listing/loading and render a <CaseLockScreen> passphrase prompt instead of
case content whenever locked.
frontend/src/lib/use-case-auto-lock.ts (DOM/timer wiring, mounted once in
layout.tsx) + frontend/src/lib/case-auto-lock.ts (pure decision logic,
unit-tested separately from the timer wiring).
- Configurable timeout in Settings → Audit & Privacy: Never / 5 / 15 (default) /
30 / 60 minutes (
AppSettings.caseAutoLockMinutes). - Implemented as polling against wall-clock time since last activity (checked
every 30s) rather than one long
setTimeout, specifically because a laptop sleep/wake cycle doesn't reliably advance a suspendedsetTimeoutdelay across platforms — polling againstDate.now()is self-correcting regardless of suspend/resume. - Listens for
mousemove/mousedown/keydown/touchstart/scroll/wheelto reset the "last activity" timestamp. - A no-op whenever encryption isn't enabled (nothing to poll for) — re-checks its
config whenever a custom
modelforge:encryption-status-changedevent fires (dispatched byAuditPrivacy.tsxafter any encryption-state-changing action), so turning encryption on mid-session arms the timer without requiring an app restart. - On firing: calls
encryption:lock, then dispatchesmodelforge:case-locked—PatientCases.tsx/PatientCaseDetail.tsxlisten for this and immediately swap to the lock screen if the user is already on one of those pages when auto-lock fires, rather than only discovering it on next navigation.
This locks case data specifically, not the whole application — chat history, Settings, and every other page remain reachable. There is no separate whole-app lock independent of case encryption; see Known limitations.
Settings → MCP Servers offers one-click quick-add presets
(frontend/src/lib/mcp-presets.ts) for three MCP servers, each verified against
its own upstream documentation before being hardcoded — none of the command
strings or tool names below are guessed.
| Preset | Command | What it gives the model |
|---|---|---|
| Graphify | graphify <path-to-folder> --mcp |
query/path/explain over a folder of documents/papers/case attachments — this project's own knowledge-graph tool, confirmed via .claude/skills/graphify/SKILL.md's documented --mcp flag |
| BioMCP | biomcp serve |
PubMed, ClinicalTrials.gov, MyVariant.info — public databases, no API key |
| DICOM MCP | uvx dicom-mcp <path-to-config.yaml> |
Connection verification, study/series/patient/instance metadata queries, report-text extraction — scoped, see below |
All three require their own CLI installed first — the quick-add button only
prefills the command into the existing manual "Add MCP server" form (filling in
any <placeholder>, e.g. a folder path, is left to the user); it never installs or
connects anything without an explicit review-and-click step.
The upstream dicom-mcp server's own tool catalog includes move_series and
move_study (DICOM C-MOVE — transfers studies between nodes). ModelForge Medical
hard-blocks both via McpServerConfig.blockedTools, enforced in two places:
filterBlockedTools()(app/src/mcp-client.ts) removes any blocked tool name from the connection's tool list at connect time, before it's ever returned bygetConnectedTools()— so a blocked tool never reaches the model's tool catalog or the approval card in the first place. Filtering is logged (logger.warn) rather than silent, since a server offering a blocked name (or, on reconnect, a new move-shaped tool) is exactly the "don't blindly trust the server's self-reported tool list" case this exists for.callMcpToolStructured()independently re-checksconn.config.blockedToolsbefore dispatching any call — defense in depth, not just "hidden from the list."
Both layers are exercised in mcp-client.test.ts's blockedTools describe block
against a real (stub) MCP server that actually offers a move_series tool.
The upstream project's own README states plainly: "DICOM-MCP is not meant for
clinical use, and should not be connected with live hospital databases or
databases with patient-sensitive data. Doing so could lead to both loss of patient
data, and leakage of patient data onto the internet." This exact text is carried
as warningBanner on the preset, and surfaced in two places: in Settings when
adding the preset, and — via the same field, threaded through
Chat.tsx's mcpServerInfoForCall — on every tool-approval card for any of this
server's tools, so the warning is unavoidable at the point a tool call is actually
about to run, not just once at setup.
buildStructuredResult() (mcp-client.ts) is the single place every MCP tool
result passes through before becoming the text a model sees. An image or
audio content block is only ever summarized as [image content, image/png]
— its raw base64 data is never copied into the text output. This is enforced
generically (for any MCP server, not DICOM-specific code) and locked in by
mcp-client.test.ts's "never forwards raw image content data" test, which asserts
a real base64 payload from a stub tool never appears in the structured result's
.text.
Before adding DICOM MCP, the entire MCP client (app/src/mcp-client.ts) was
rebuilt from a hand-rolled JSON-RPC implementation onto the official
@modelcontextprotocol/sdk. This section is the detailed reference for that
rework; each piece below is independently unit-tested (see
Testing this layer).
| Concern | Where | What it does |
|---|---|---|
| Transport | mcp-client.ts — Client + StdioClientTransport / StreamableHTTPClientTransport |
Official SDK, both transports. Protocol version negotiation happens inside the SDK's own connect() (validates the server's returned version against SUPPORTED_PROTOCOL_VERSIONS, throws on mismatch) rather than a hardcoded, unchecked version string. |
| Schema validation | mcp-schema-validation.ts |
AJV, compiled and cached per server+tool at connect time. Full JSON Schema support ($ref/oneOf/pattern/enum/nested schemas) — the previous implementation only checked top-level required/type. An uncompilable schema is treated as "no validator" (permissive) rather than crashing the connection. |
| Resources / prompts | mcp-client.ts — listResources, listResourceTemplates, readResource, listPrompts, getPrompt |
Thin wrappers over the SDK client. Not yet wired into any model-facing tool — backend plumbing only (see Known limitations). |
| Structured results | mcp-client.ts — callMcpToolStructured / buildStructuredResult |
Preserves structuredContent, MIME types, and resource links instead of flattening everything to a lossy string. callMcpTool (the model-facing entry point) still returns plain text for backward compatibility — the structured version is for audit logging and future UI. |
| Trust profiles | McpServerConfig.trustProfile.autoApprovedTools, frontend/src/lib/tool-approval.ts's trustedMcpToolNames |
Per-tool-name allowlist a user builds one tool at a time in Settings — never a blanket "trust this server" flag. Chat.tsx seeds the session's autoApprovedTools set with the flattened list on load, so a trusted MCP tool auto-resolves exactly like an already-approved built-in tool. |
| Progress / cancellation | mcp-client.ts's McpToolCallOptions (signal, onProgress); app-state.ts's activeMcpToolRequests map; IPC mcp:cancelTool; preload.ts's agent.executeToolWithProgress |
A progress bar + Cancel button render on the approval card only for an executing MCP tool call (built-in tools have no server-side progress support, so they're unaffected). Cancellation turns into the SDK's notifications/cancelled, not just a client-side give-up. |
| OAuth 2.1 + PKCE | mcp-oauth.ts |
Implements the SDK's OAuthClientProvider interface — the SDK itself does PKCE generation, RFC 9728/8414 discovery, and RFC 8707 resource-indicator scoping (so a token obtained for one server is never reusable against another); this module only supplies storage (via the existing secrets-store.ts, namespaced per server) and the browser/redirect plumbing (a loopback HTTP listener on 127.0.0.1:51823, opened only while a flow is in progress). |
| PHI transmission preview (MCP) | Chat.tsx's ToolApprovalCard rendering, gated on mcpServerInfoForCall(call)?.transport === "http" |
Same pattern as the model-call transmission preview — shown only for http-transport (remote) MCP servers, since a stdio server is a local child process. |
| Audit + case binding | AuditEvent's mcpServerId/mcpServerName/mcpToolName/approvalOutcome/durationMs fields; Chat.tsx's respondToToolCall |
Every MCP tool call — approved, auto-approved, or denied — is audited with the currently-attached patient case as targetId, never the call's actual arguments/result. |
| Untrusted descriptions | ToolApprovalCard's mcpServerInfo block |
Renders the server's own tool description in a visibly separate "server-provided, unverified" block, alongside readOnlyHint/destructiveHint annotations if the server declared them — never treated as trusted UI text or as an instruction. |
Full Zod schemas live in app/src/schemas.ts; TypeScript interfaces are mirrored
in frontend/src/types/electron.d.ts for the renderer (main-process types in
app/src/*.ts are the source of truth). Key schemas added for the clinical layer:
patientCaseSchema/patientCasesFileSchemaauditEventSchema/auditLogFileSchemaevidenceSourceSchema/evidenceSourcesFileSchemamcpServerConfigSchema— extended withtrustProfile,auth,blockedTools,warningBannerappSettingsSchema— extended withcaseAutoLockMinutes,redactBeforeRemoteSend,auditLogRetentionDays
All channels below follow the same pattern as the base app (see
Architecture): registered in
app/src/ipc/*-handlers.ts, called from main.ts's registerIpcHandlers(),
bridged in preload.ts, typed in frontend/src/types/electron.d.ts.
| Namespace | Channels |
|---|---|
patientCases |
list, get, create, update, delete, buildContext, checkConflicts |
audit |
list, clearAll, record |
evidence |
list, addFromUrl, delete |
medicalSafety |
checkEmergency, redact |
encryption |
status, setup, unlock, lock, disable, changePassphrase |
mcp (extended) |
cancelTool, startOAuthFlow, hasOAuthTokens, clearOAuthCredentials — in addition to the base app's connect/disconnect/status/isMastervaultBuiltinAvailable/pickMastervaultVault |
All under Electron's userData directory, through the same atomic-write/
corruption-recovery helpers (json-store.ts) described in
Architecture: persistence pattern, except
where noted:
| File | Contents | Encrypted? |
|---|---|---|
patient-cases.json |
Patient cases (plaintext mode) | No |
patient-cases.enc.json |
Patient cases (encrypted mode) — {ivHex, ciphertextHex, authTagHex} envelope |
Yes (AES-256-GCM) |
case-encryption-config.json |
{enabled, saltHex, verifierHex} — never the passphrase itself |
N/A (not sensitive) |
evidence-sources.json |
Evidence Library entries | No |
audit-log.json |
Audit trail (deliberately no clinical content — see Audit & Privacy) | No |
Exactly one of patient-cases.json / patient-cases.enc.json is ever
authoritative at a time — switching encryption modes always deletes the
now-stale one (see Encryption at rest).
Every module described above has a corresponding *.test.ts next to it
(app/src/*.test.ts), following the same Vitest pattern as the rest of the app
(see Development: testing) — run with npm test --prefix app.
Notably:
mcp-client.test.tsspawns a real, tiny stub MCP server (app/src/test/fixtures/stub-mcp-server.cjs), built with the same official SDK the client uses server-side, so tests exercise a genuine wire-protocol round-trip rather than a hand-mocked transport — including progress notifications, cancellation, and theblockedTools/image-content-filtering guarantees above.case-encryption.test.tscovers the crypto primitives directly (setup, lock, unlock with correct/incorrect passphrase, key rotation, tamper detection via GCM's auth tag) independent of the store layer;patient-cases-store.test.ts's "encryption at rest" block covers the store-level migration flows (plaintext → encrypted → back to plaintext, locked-state errors).medical-safety.test.tsandevidence-store.test.tsuse only synthetic fixtures — no real patient data anywhere in the test suite.- Frontend-side pure logic (
case-auto-lock.ts'sshouldAutoLock,tool-approval.ts'strustedMcpToolNames) is unit-tested separately from the DOM/timer wiring that calls it, since component-level rendering isn't otherwise tested in this codebase (see Development: testing).
Playwright e2e (e2e/tests/*.spec.ts) has not been extended for the clinical
layer or run against it in this environment — see Known limitations.
Also documented in the README; repeated here with the specific code pointers:
- Drug-interaction/allergy checking (
KNOWN_INTERACTIONS,ALLERGY_CLASS_SYNONYMSinmedical-safety.ts) is a small demonstration list, not a licensed clinical database. checkCitations(medical-safety.ts) exists and is tested but is not yet wired into Clinical Assistant's rendering — a model's citation markers aren't currently cross-checked against Evidence Library entries in the UI.- MCP resources/prompts (
listResources/readResource/listPrompts/getPromptinmcp-client.ts) are backend-only plumbing — no IPC/UI exposes them yet, and no model-facing tool can trigger a resource read. DICOM MCP's report retrieval currently goes throughextract_pdf_text_from_dicom(a regular tool call), not a resource read. - No clinician identity/authentication system —
enteredByand audit actor context are plain text, not a verified identity. Session locking narrows when case data is accessible, not by whom. - No whole-app lock — session locking (above) is scoped to case-encryption state specifically; chat history, Settings, and every other page stay reachable regardless of the case-data lock state.
- No regulatory certification of any kind.
- Accessibility: ad hoc
aria-*labeling, no full WCAG 2.2 AA audit performed on the clinical pages. - No literature-search API integration (e.g. PubMed live search) — Evidence Library is add-by-URL only, by design.
- PHI redaction (
redactIdentifiers) is regex pattern matching, not clinical-grade de-identification — a "0 redacted" result is not confirmation the text was actually clean. - Playwright e2e has not been extended to cover the clinical layer, and wasn't run in the environment this was built in (no GUI available).
- Architecture — the base Electron app's process model, IPC bridge, and persistence pattern
- Agent mode — the full built-in tool catalog, workspace sandboxing, and the general (non-medical-specific) tool-approval model
- Development — running, testing, building, packaging