Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions app/src/clinical-mcp-broker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { modelVisibleClinicalSchema, prepareClinicalMcpArguments } from "./clinical-mcp-broker";
import * as patientCases from "./patient-cases-store";
import * as backend from "./shared-backend-client";

vi.mock("./patient-cases-store");
vi.mock("./shared-backend-client");

const policy = { entryId: "10000000-0000-4000-8000-000000000001", organizationId: "10000000-0000-4000-8000-000000000002", allowedTools: "*" as const, dataEgressPolicy: "unrestricted" as const, integrationProfile: "modelforge-clinical" as const };

describe("clinical MCP broker", () => {
beforeEach(() => vi.clearAllMocks());

it("removes infrastructure-only fields from model-visible tool schemas", () => {
expect(modelVisibleClinicalSchema({ type: "object", properties: { contextGrantId: {}, approvalTicket: {}, idempotencyKey: {}, rationale: {} }, required: ["contextGrantId", "approvalTicket", "idempotencyKey", "rationale"] })).toEqual({ type: "object", properties: { rationale: {} }, required: ["rationale"] });
});

it("hides authoritative medication fields without mutating the wire schema", () => {
const schema = { type: "object", additionalProperties: false, properties: { medications: {}, allergies: {}, contextGrantId: {} }, required: ["medications", "allergies", "contextGrantId"] };
expect(modelVisibleClinicalSchema(schema, "clinical.medication_conflict_check")).toEqual({ type: "object", additionalProperties: false, properties: {}, required: [] });
expect(schema.required).toEqual(["medications", "allergies", "contextGrantId"]);
expect(Object.keys(schema.properties)).toEqual(["medications", "allergies", "contextGrantId"]);
expect(modelVisibleClinicalSchema(undefined, "clinical.medication_conflict_check")).toBeUndefined();
});

it("does not remove similarly named domain fields from other tools", () => {
const schema = { properties: { medications: {} }, required: ["medications"] };
expect(modelVisibleClinicalSchema(schema, "clinical.response_contract_check")).toEqual(schema);
});

it("rejects missing or excluded case data before requesting a grant", async () => {
await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {})).rejects.toThrow(/Attach a patient case/);
vi.mocked(patientCases.getCase).mockResolvedValue(null);
await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {}, { patientCaseId: "case-1" })).rejects.toThrow(/no longer available/);
vi.mocked(patientCases.getCase).mockResolvedValue({ medications: { includeInContext: false, value: [] }, allergies: { includeInContext: true, value: [] } } as never);
await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {}, { patientCaseId: "case-1" })).rejects.toThrow(/Include both/);
expect(backend.createMcpContextGrant).not.toHaveBeenCalled();
});

it("uses medications and allergies from the attached case and injects only the grant handle", async () => {
vi.mocked(patientCases.getCase).mockResolvedValue({ medications: { includeInContext: true, value: ["warfarin"] }, allergies: { includeInContext: true, value: ["aspirin"] } } as never);
vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "grant-1" } as never);
await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", { medications: ["untrusted"] }, { patientCaseId: "case-1" })).resolves.toEqual({ medications: ["warfarin"], allergies: ["aspirin"], contextGrantId: "grant-1" });
expect(backend.createMcpContextGrant).toHaveBeenCalledWith(expect.objectContaining({ caseId: "case-1", requestedFields: ["allergies", "medications"] }));
});

it("requires a human-approved review and injects an operation-bound ticket and idempotency key", async () => {
vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "grant-2" } as never);
vi.mocked(backend.prepareMcpApproval).mockResolvedValue({ approvalRequest: { id: "10000000-0000-4000-8000-000000000003" }, challenge: {} } as never);
vi.mocked(backend.confirmMcpApproval).mockResolvedValue({ approvalRequest: {}, approvalTicket: "ticket-1" } as never);
const args = { reviewedOperationId: "10000000-0000-4000-8000-000000000004", decision: "approved", rationale: "Checked." };
await expect(prepareClinicalMcpArguments(policy, "clinical.record_review_decision", args, { patientCaseId: "case-1" })).rejects.toThrow(/explicit approval/);
expect(backend.createMcpContextGrant).not.toHaveBeenCalled();
expect(backend.prepareMcpApproval).not.toHaveBeenCalled();
expect(backend.confirmMcpApproval).not.toHaveBeenCalled();
const result = await prepareClinicalMcpArguments(policy, "clinical.record_review_decision", args, { patientCaseId: "case-1", humanApproved: true });
expect(result).toMatchObject({ ...args, contextGrantId: "grant-2", approvalTicket: "ticket-1" });
expect(result.idempotencyKey).toEqual(expect.any(String));
});

it("strips model-provided infrastructure credentials and leaves generic tools unchanged", async () => {
vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "trusted-grant" } as never);
const args = { assistantResponse: "draft", contextGrantId: "model-grant", approvalTicket: "model-ticket", idempotencyKey: "model-key" };
await expect(prepareClinicalMcpArguments(policy, "clinical.response_contract_check", args, { patientCaseId: "case-1" })).resolves.toEqual({ assistantResponse: "draft", contextGrantId: "trusted-grant" });
await expect(prepareClinicalMcpArguments({ ...policy, integrationProfile: "generic" }, "generic.tool", args)).resolves.toBe(args);
});
});
84 changes: 84 additions & 0 deletions app/src/clinical-mcp-broker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import type { ManagedMcpPolicy } from "./managed-mcp-policy";
import * as patientCases from "./patient-cases-store";
import { confirmMcpApproval, createMcpContextGrant, prepareMcpApproval } from "./shared-backend-client";

export interface ClinicalMcpExecutionContext {
patientCaseId?: string;
humanApproved?: boolean;
}

const INFRASTRUCTURE_FIELDS = new Set(["contextGrantId", "approvalTicket", "idempotencyKey"]);
const TOOL_FIELDS: Record<string, string[]> = {
"clinical.medication_conflict_check": ["allergies", "medications"],
"clinical.response_contract_check": ["assistantResponse"],
"clinical.response_contract_check_batch": ["items"],
"clinical.record_review_decision": ["rationale"],
};
const PURPOSE = {
"clinical.medication_conflict_check": "medication-review",
"clinical.response_contract_check": "documentation-assist",
"clinical.response_contract_check_batch": "documentation-assist",
"clinical.record_review_decision": "documentation-assist",
} as const;

export function modelVisibleClinicalSchema(schema: Record<string, unknown> | undefined, toolName?: string): Record<string, unknown> | undefined {
if (!schema) return schema;
const brokerFields = new Set(INFRASTRUCTURE_FIELDS);
if (toolName === "clinical.medication_conflict_check") {
brokerFields.add("medications");
brokerFields.add("allergies");
}
const properties = { ...((schema.properties ?? {}) as Record<string, unknown>) };
for (const field of brokerFields) delete properties[field];
const required = Array.isArray(schema.required) ? schema.required.filter((field) => typeof field !== "string" || !brokerFields.has(field)) : undefined;
return { ...schema, properties, ...(required ? { required } : {}) };
}

function stripInfrastructureArgs(args: Record<string, unknown>): Record<string, unknown> {
const clean = { ...args };
for (const field of INFRASTRUCTURE_FIELDS) delete clean[field];
return clean;
}

async function authoritativeArguments(toolName: string, args: Record<string, unknown>, caseId?: string): Promise<Record<string, unknown>> {
const clean = stripInfrastructureArgs(args);
if (toolName !== "clinical.medication_conflict_check") return clean;
if (!caseId) throw new Error("Attach a patient case before running a clinical medication check.");
const patientCase = await patientCases.getCase(caseId);
if (!patientCase) throw new Error("The attached patient case is no longer available.");
if (!patientCase.medications.includeInContext || !patientCase.allergies.includeInContext) {
throw new Error("Include both medications and allergies in the attached case context before running this check.");
}
return { medications: patientCase.medications.value, allergies: patientCase.allergies.value };
}

export async function prepareClinicalMcpArguments(
policy: ManagedMcpPolicy,
toolName: string,
args: Record<string, unknown>,
context: ClinicalMcpExecutionContext = {}
): Promise<Record<string, unknown>> {
if (policy.integrationProfile !== "modelforge-clinical") return args;
if (toolName === "clinical.submit_compute_request") throw new Error("Governed compute submission is not enabled in this clinical-review release.");
if (toolName === "clinical.record_review_decision" && !context.humanApproved) {
throw new Error("This controlled clinical write requires explicit approval for this call.");
}
const domainArguments = await authoritativeArguments(toolName, args, context.patientCaseId);
const fields = TOOL_FIELDS[toolName] ?? [];
let contextGrantId: string | undefined;
if (fields.length > 0) {
if (!context.patientCaseId) throw new Error(`Attach a patient case before running "${toolName}".`);
const purpose = PURPOSE[toolName as keyof typeof PURPOSE] ?? "documentation-assist";
const grant = await createMcpContextGrant({ registryEntryId: policy.entryId, caseId: context.patientCaseId, purpose, toolNames: [toolName], requestedFields: fields });
contextGrantId = grant.id;
}
const injected: Record<string, unknown> = { ...domainArguments, ...(contextGrantId ? { contextGrantId } : {}) };
if (toolName === "clinical.record_review_decision") {
const prepared = await prepareMcpApproval({ registryEntryId: policy.entryId, toolName, arguments: domainArguments, contextGrantId, caseId: context.patientCaseId });
const confirmed = await confirmMcpApproval(prepared.approvalRequest.id);
injected.approvalTicket = confirmed.approvalTicket;
injected.idempotencyKey = randomUUID();
}
return injected;
}
49 changes: 49 additions & 0 deletions app/src/hl7-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { hl7IngestionJobSchema, type Hl7IngestionJob } from "@modelforge/contracts";
import { z } from "zod";
import { authorizedRequest, SharedBackendClientError } from "./shared-backend-client";
import { getSharedBackendConfig } from "./shared-backend-config-store";

// REST glue for server/src/routes/hl7.ts's inbound-ingestion review queue —
// GET .../inbound/jobs and POST .../inbound/jobs/:jobId/resolve. Outbound
// ORU^R01 generation and the raw /parse endpoint have no UI need yet (no
// clinician-facing use for either) so aren't wired here.

export type Hl7ResolveDecision = { action: "apply"; caseId: string } | { action: "reject"; reason: string };

function organizationId(): string {
const id = getSharedBackendConfig()?.organizationId;
if (!id) throw new SharedBackendClientError("Select a shared-backend organization before using HL7 ingestion review.");
return id;
}

async function expectJson<T>(response: Response, action: string): Promise<T> {
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const body = (await response.json()) as { message?: string; error?: string };
detail = body.message ?? body.error ?? detail;
} catch { /* response was not JSON */ }
throw new SharedBackendClientError(`${action} failed: ${detail}`);
}
return response.json() as Promise<T>;
}

export async function listHl7IngestionJobs(status?: Hl7IngestionJob["status"]): Promise<Hl7IngestionJob[]> {
const org = organizationId();
const query = status ? `?status=${encodeURIComponent(status)}` : "";
const body = await expectJson<{ jobs: unknown[] }>(
await authorizedRequest(`/organizations/${encodeURIComponent(org)}/hl7/v2/inbound/jobs${query}`),
"Loading HL7 ingestion queue"
);
return z.array(hl7IngestionJobSchema).parse(body.jobs);
}

export async function resolveHl7IngestionJob(jobId: string, decision: Hl7ResolveDecision): Promise<Hl7IngestionJob> {
const org = organizationId();
return hl7IngestionJobSchema.parse(
await expectJson(
await authorizedRequest(`/organizations/${encodeURIComponent(org)}/hl7/v2/inbound/jobs/${encodeURIComponent(jobId)}/resolve`, { method: "POST", body: JSON.stringify(decision) }),
"Resolving HL7 ingestion job"
)
);
}
11 changes: 9 additions & 2 deletions app/src/ipc/agent-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ export function registerAgentIpc(): void {
"tools:execute",
async (
event: IpcMainInvokeEvent,
{ workspaceRoot, name, args, requestId }: { workspaceRoot: string; name: string; args: unknown; requestId?: string }
{ workspaceRoot, name, args, requestId, clinicalContext }: {
workspaceRoot: string;
name: string;
args: unknown;
requestId?: string;
clinicalContext?: { patientCaseId?: string; humanApproved?: boolean };
}
) => {
requireString(workspaceRoot, "workspace root");
requireString(name, "tool name");
Expand All @@ -46,9 +52,10 @@ export function registerAgentIpc(): void {
const controller = requestId ? new AbortController() : undefined;
if (requestId && controller) activeMcpToolRequests.set(requestId, controller);
try {
result = await mcpClient.callMcpTool(name, validatedArgs, {
result = await mcpClient.callMcpToolStructured(name, validatedArgs, {
signal: controller?.signal,
onProgress: requestId ? (p) => event.sender.send(`mcp:toolProgress:${requestId}`, p) : undefined,
clinicalContext,
});
} finally {
if (requestId) activeMcpToolRequests.delete(requestId);
Expand Down
5 changes: 5 additions & 0 deletions app/src/ipc/mcp-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import * as mcpOAuth from "../mcp-oauth";
import { mcpServerConfigSchema, parseOrThrow } from "../schemas";
import { requireString, getMainWindow, activeMcpToolRequests } from "../app-state";
import { buildMastervaultServerConfig, isMastervaultBuiltinAvailable } from "../mastervault-builtin";
import { listManagedClinicalMcpServers } from "../managed-mcp-policy";

export function registerMcpIpc(): void {
ipcMain.handle("mcp:listManagedClinicalServers", async () => {
try { return { servers: await listManagedClinicalMcpServers() }; }
catch (error) { return { error: (error as Error).message }; }
});
ipcMain.handle("mcp:isMastervaultBuiltinAvailable", () => isMastervaultBuiltinAvailable());

// Convenience one-click add for the built-in MasterVault server: prompts
Expand Down
46 changes: 46 additions & 0 deletions app/src/ipc/shared-backend-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { requireString } from "../app-state";
import * as caseMigration from "../case-migration";
import * as imagingClient from "../imaging-client";
import * as clinicalAiClient from "../clinical-ai-client";
import * as hl7Client from "../hl7-client";
import * as smartLaunchClient from "../smart-launch-client";
import { runSmartLaunch } from "../smart-launch-flow";
import { closeOhifLaunch, createOhifLaunch } from "../ohif-viewer";

// IPC surface for enterprise-mode shared-backend connection management
Expand Down Expand Up @@ -142,4 +145,47 @@ export function registerSharedBackendIpc(): void {
ipcMain.handle("clinicalAi:submit", (_event, input: { caseId: string; request: clinicalAiClient.ClinicalAiSubmitInput }) => { requireString(input?.caseId,"case id");return clinicalAiClient.submitClinicalAiRequest(input.caseId,input.request); });
ipcMain.handle("clinicalAi:listActivity", (_event, caseId: string) => { requireString(caseId,"case id");return clinicalAiClient.listClinicalAiActivity(caseId); });
ipcMain.handle("clinicalAi:review", (_event, input: { outputId: string; review: { decision: "accepted"|"rejected"|"corrected"|"escalated"; correctedText?: string; escalationReason?: string } }) => { requireString(input?.outputId,"output id");return clinicalAiClient.reviewClinicalAiOutput(input.outputId,input.review); });

ipcMain.handle("hl7:listJobs", (_event: IpcMainInvokeEvent, status?: "pending-review" | "applied" | "rejected") => hl7Client.listHl7IngestionJobs(status));
ipcMain.handle("hl7:resolveJob", (_event: IpcMainInvokeEvent, input: { jobId: string; decision: hl7Client.Hl7ResolveDecision }) => {
requireString(input?.jobId, "ingestion job id");
if (input?.decision?.action !== "apply" && input?.decision?.action !== "reject") throw new Error('Resolution decision must have action "apply" or "reject".');
if (input.decision.action === "apply") requireString(input.decision.caseId, "case id");
else requireString(input.decision.reason, "rejection reason");
return hl7Client.resolveHl7IngestionJob(input.jobId, input.decision);
});

ipcMain.handle("smartLaunch:listTrustedIssuers", () => smartLaunchClient.listTrustedIssuers());
ipcMain.handle("smartLaunch:upsertTrustedIssuer", (_event: IpcMainInvokeEvent, input: { issuer: string; clientId: string; redirectUris: string[] }) => {
requireString(input?.issuer, "issuer");
requireString(input?.clientId, "client id");
if (!Array.isArray(input?.redirectUris) || input.redirectUris.length === 0) throw new Error("At least one redirect URI is required.");
return smartLaunchClient.upsertTrustedIssuer(input);
});
ipcMain.handle("smartLaunch:deleteTrustedIssuer", (_event: IpcMainInvokeEvent, issuer: string) => {
requireString(issuer, "issuer");
return smartLaunchClient.deleteTrustedIssuer(issuer);
});
ipcMain.handle("smartLaunch:listSessions", () => smartLaunchClient.listLaunchSessions());
ipcMain.handle("smartLaunch:revokeSession", (_event: IpcMainInvokeEvent, sessionId: string) => {
requireString(sessionId, "session id");
return smartLaunchClient.revokeLaunchSession(sessionId);
});
// Opens the system browser and waits on user interaction at the EHR —
// same long-running-external-flow shape as sharedBackend:connect/
// mcp:startOAuthFlow above, so it catches and returns {error} rather
// than rejecting, letting the renderer show an inline error instead of
// an unhandled-IPC-rejection for an ordinary "user closed the tab" or
// "5-minute timeout" outcome.
ipcMain.handle("smartLaunch:start", async (_event: IpcMainInvokeEvent, issuer: string) => {
try {
requireString(issuer, "issuer");
const token = await runSmartLaunch(issuer);
return { token };
} catch (err) {
const error = err as Error;
logger.error(`SMART launch failed: ${error.message}`);
return { error: error.message };
}
});
}
Loading
Loading