Skip to content

Commit 7c7f736

Browse files
committed
add Phase 8.5 MCP write gating + safety controls
Three new dispatcher gates for write verbs (create/update/patch/delete): writeResourceTypes narrows the writable surface below the read surface, confirmWrites requires an explicit {confirm:true} arg per call, and dryRun short-circuits the upstream entirely and audits the attempt as 'dry-run' — useful for evaluation, regulator review, or staging. The gates compose: allowlist runs before confirmation, which runs before dryRun. Read verbs are untouched.
1 parent c98891e commit 7c7f736

3 files changed

Lines changed: 308 additions & 16 deletions

File tree

packages/mcp/src/dispatcher.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AuditSink, Dispatcher, McpRequest, McpResponse, ResourceType, VerbCall } from "./types.js";
1+
import type { AuditSink, Dispatcher, McpRequest, McpResponse, ResourceType, VerbCall, VerbResult } from "./types.js";
22
import type { FhirUpstream } from "./upstream.js";
33

44
// Phase 8.2 — dispatcher with the upstream HTTP client wired in.
@@ -11,6 +11,28 @@ export interface DispatcherConfig {
1111
resourceTypes: readonly ResourceType[];
1212
/** Whether write verbs (`create`, `update`, `patch`, `delete`) are exposed. */
1313
writes?: readonly Exclude<VerbCall["verb"], "read" | "vread" | "search" | "history" | "operation" | "capabilities">[];
14+
/**
15+
* Phase 8.5 — narrow which resource types are writable. When undefined,
16+
* any resource in `resourceTypes` is fair game; when set, write verbs
17+
* are rejected for any other type. Lets you expose Patient-create to an
18+
* agent while keeping Observation read-only.
19+
*/
20+
writeResourceTypes?: readonly ResourceType[];
21+
/**
22+
* Phase 8.5 — when true, write verbs short-circuit before hitting the
23+
* upstream, audit-tagged as `dryRun`, and return a synthetic
24+
* OperationOutcome describing what would have happened. Useful for
25+
* agents under evaluation, regulator review, or staging environments.
26+
*/
27+
dryRun?: boolean;
28+
/**
29+
* Phase 8.5 — when true, every write call must include `confirm: true`
30+
* in its arguments. Missing confirmations short-circuit with a
31+
* required-element error, matching FHIR `OperationOutcome.code:
32+
* "required"`. The flag flips the burden of avoiding accidental writes
33+
* onto the calling LLM, which is the safe default for production.
34+
*/
35+
confirmWrites?: boolean;
1436
/** Server identity broadcast through MCP `initialize`. */
1537
identity: { name: string; version: string };
1638
/** Audit hook invoked on every verb attempt. */
@@ -23,6 +45,8 @@ export interface DispatcherConfig {
2345
upstream?: FhirUpstream;
2446
}
2547

48+
const WRITE_VERBS = new Set<VerbCall["verb"]>(["create", "update", "patch", "delete"]);
49+
2650
const READ_VERBS = ["read", "vread", "search", "history", "operation", "capabilities"] as const;
2751

2852
const PROTOCOL_VERSION = "2025-06-18";
@@ -271,27 +295,36 @@ async function handleToolCall(request: McpRequest, config: DispatcherConfig): Pr
271295
if (args.patch !== undefined) call.patch = args.patch;
272296
if (typeof args.operation === "string") call.operation = args.operation;
273297

274-
const result = config.upstream
275-
? await config.upstream.run(call)
276-
: {
277-
ok: false,
278-
outcome: {
279-
resourceType: "OperationOutcome",
280-
issue: [
281-
{
282-
severity: "error",
283-
code: "not-supported",
284-
diagnostics: "Server is not bound to an upstream — pass `baseUrl` to createServer()",
285-
},
286-
],
287-
},
288-
};
298+
const gateOutcome = checkWriteGates(call, args, config);
299+
let result: VerbResult;
300+
if (gateOutcome) {
301+
result = { ok: false, outcome: gateOutcome };
302+
} else if (config.dryRun && WRITE_VERBS.has(call.verb)) {
303+
result = { ok: true, body: dryRunResponse(call) };
304+
} else if (config.upstream) {
305+
result = await config.upstream.run(call);
306+
} else {
307+
result = {
308+
ok: false,
309+
outcome: {
310+
resourceType: "OperationOutcome",
311+
issue: [
312+
{
313+
severity: "error",
314+
code: "not-supported",
315+
diagnostics: "Server is not bound to an upstream — pass `baseUrl` to createServer()",
316+
},
317+
],
318+
},
319+
};
320+
}
289321

290322
await config.audit.record({
291323
id: cryptoRandomId(),
292324
ts: new Date().toISOString(),
293325
call,
294326
result,
327+
...(config.dryRun && WRITE_VERBS.has(call.verb) ? { actor: "dry-run" } : {}),
295328
});
296329

297330
const payload = result.ok ? result.body : result.outcome;
@@ -301,6 +334,51 @@ async function handleToolCall(request: McpRequest, config: DispatcherConfig): Pr
301334
});
302335
}
303336

337+
function checkWriteGates(call: VerbCall, args: Record<string, unknown>, config: DispatcherConfig): unknown | null {
338+
if (!WRITE_VERBS.has(call.verb)) return null;
339+
340+
if (config.writeResourceTypes && !config.writeResourceTypes.includes(call.resourceType)) {
341+
return {
342+
resourceType: "OperationOutcome",
343+
issue: [
344+
{
345+
severity: "error",
346+
code: "forbidden",
347+
diagnostics: `Writes to ${call.resourceType} are not permitted by this server (allowed: ${config.writeResourceTypes.join(", ") || "none"})`,
348+
},
349+
],
350+
};
351+
}
352+
353+
if (config.confirmWrites && args.confirm !== true) {
354+
return {
355+
resourceType: "OperationOutcome",
356+
issue: [
357+
{
358+
severity: "error",
359+
code: "required",
360+
diagnostics: `Writes require an explicit \`confirm: true\` argument (this server has confirmWrites=true)`,
361+
},
362+
],
363+
};
364+
}
365+
366+
return null;
367+
}
368+
369+
function dryRunResponse(call: VerbCall): unknown {
370+
return {
371+
resourceType: "OperationOutcome",
372+
issue: [
373+
{
374+
severity: "information",
375+
code: "informational",
376+
diagnostics: `dry-run: would have ${call.verb}d ${call.resourceType}${call.id ? `/${call.id}` : ""} (no upstream call made)`,
377+
},
378+
],
379+
};
380+
}
381+
304382
function ok(req: McpRequest, result: unknown): McpResponse {
305383
return { jsonrpc: "2.0", id: req.id ?? null, result };
306384
}

packages/mcp/src/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface ServerConfig {
2424
audit?: AuditSink;
2525
/** Whitelist of write verbs to expose. Empty (default) = read-only. */
2626
writes?: readonly Exclude<VerbCall["verb"], "read" | "vread" | "search" | "history" | "operation" | "capabilities">[];
27+
/** Phase 8.5 — narrow which resource types accept writes (subset of `resourceTypes`). */
28+
writeResourceTypes?: readonly ResourceType[];
29+
/** Phase 8.5 — when true, write verbs short-circuit and return a synthetic OperationOutcome. */
30+
dryRun?: boolean;
31+
/** Phase 8.5 — when true, write verbs require `confirm: true` in their args. */
32+
confirmWrites?: boolean;
2733
/** Override the global `fetch` — handy for tests and custom transports. */
2834
fetch?: typeof globalThis.fetch;
2935
}
@@ -46,6 +52,9 @@ export function createServer(config: ServerConfig): McpServer {
4652
upstream,
4753
};
4854
if (config.writes) dispatcherConfig.writes = config.writes;
55+
if (config.writeResourceTypes) dispatcherConfig.writeResourceTypes = config.writeResourceTypes;
56+
if (config.dryRun !== undefined) dispatcherConfig.dryRun = config.dryRun;
57+
if (config.confirmWrites !== undefined) dispatcherConfig.confirmWrites = config.confirmWrites;
4958
const dispatcher = createDispatcher(dispatcherConfig);
5059

5160
return {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { MemoryAuditSink } from "../src/audit.js";
3+
import { createServer } from "../src/server.js";
4+
import type { McpRequest } from "../src/types.js";
5+
6+
const fetchOk = (body: unknown) =>
7+
vi.fn(
8+
async () =>
9+
new Response(JSON.stringify(body), {
10+
status: 200,
11+
headers: { "content-type": "application/fhir+json" },
12+
}),
13+
);
14+
15+
const req = (params?: Record<string, unknown>): McpRequest => ({
16+
jsonrpc: "2.0",
17+
id: 1,
18+
method: "tools/call",
19+
...(params ? { params } : {}),
20+
});
21+
22+
describe("dryRun mode", () => {
23+
it("short-circuits writes without hitting upstream and returns an informational outcome", async () => {
24+
const fetchFn = fetchOk({});
25+
const audit = new MemoryAuditSink();
26+
const server = createServer({
27+
baseUrl: "https://example.test/baseR4",
28+
resourceTypes: ["Patient"],
29+
writes: ["create"],
30+
dryRun: true,
31+
audit,
32+
fetch: fetchFn,
33+
});
34+
35+
const res = await server.dispatcher.handleRequest(
36+
req({
37+
name: "fhir.create",
38+
arguments: { resourceType: "Patient", params: { resourceType: "Patient", name: [{ given: ["A"] }] } },
39+
}),
40+
);
41+
42+
expect(fetchFn).not.toHaveBeenCalled();
43+
const result = res.result as { content: Array<{ text: string }>; isError: boolean };
44+
expect(result.isError).toBe(false);
45+
const body = JSON.parse(result.content[0]!.text);
46+
expect(body.resourceType).toBe("OperationOutcome");
47+
expect(body.issue[0].severity).toBe("information");
48+
expect(body.issue[0].diagnostics).toContain("dry-run");
49+
50+
expect(audit.events[0]?.actor).toBe("dry-run");
51+
expect(audit.events[0]?.result.ok).toBe(true);
52+
});
53+
54+
it("does not affect read verbs", async () => {
55+
const fetchFn = fetchOk({ resourceType: "Patient", id: "abc" });
56+
const server = createServer({
57+
baseUrl: "https://example.test/baseR4",
58+
resourceTypes: ["Patient"],
59+
dryRun: true,
60+
audit: new MemoryAuditSink(),
61+
fetch: fetchFn,
62+
});
63+
await server.dispatcher.handleRequest(
64+
req({ name: "fhir.read", arguments: { resourceType: "Patient", id: "abc" } }),
65+
);
66+
expect(fetchFn).toHaveBeenCalledOnce();
67+
});
68+
});
69+
70+
describe("writeResourceTypes allowlist", () => {
71+
it("rejects writes to resource types outside the allowlist", async () => {
72+
const fetchFn = fetchOk({});
73+
const server = createServer({
74+
baseUrl: "https://example.test/baseR4",
75+
resourceTypes: ["Patient", "Observation"],
76+
writes: ["create"],
77+
writeResourceTypes: ["Patient"],
78+
audit: new MemoryAuditSink(),
79+
fetch: fetchFn,
80+
});
81+
82+
const res = await server.dispatcher.handleRequest(
83+
req({ name: "fhir.create", arguments: { resourceType: "Observation", params: {} } }),
84+
);
85+
86+
expect(fetchFn).not.toHaveBeenCalled();
87+
const body = JSON.parse((res.result as { content: Array<{ text: string }> }).content[0]!.text);
88+
expect(body.issue[0].code).toBe("forbidden");
89+
expect(body.issue[0].diagnostics).toContain("Observation");
90+
});
91+
92+
it("allows writes to resource types inside the allowlist", async () => {
93+
const fetchFn = fetchOk({ resourceType: "Patient", id: "new" });
94+
const server = createServer({
95+
baseUrl: "https://example.test/baseR4",
96+
resourceTypes: ["Patient", "Observation"],
97+
writes: ["create"],
98+
writeResourceTypes: ["Patient"],
99+
audit: new MemoryAuditSink(),
100+
fetch: fetchFn,
101+
});
102+
103+
await server.dispatcher.handleRequest(
104+
req({ name: "fhir.create", arguments: { resourceType: "Patient", params: {} } }),
105+
);
106+
expect(fetchFn).toHaveBeenCalledOnce();
107+
});
108+
});
109+
110+
describe("confirmWrites", () => {
111+
it("rejects writes without confirm: true", async () => {
112+
const fetchFn = fetchOk({});
113+
const server = createServer({
114+
baseUrl: "https://example.test/baseR4",
115+
resourceTypes: ["Patient"],
116+
writes: ["create"],
117+
confirmWrites: true,
118+
audit: new MemoryAuditSink(),
119+
fetch: fetchFn,
120+
});
121+
122+
const res = await server.dispatcher.handleRequest(
123+
req({ name: "fhir.create", arguments: { resourceType: "Patient", params: {} } }),
124+
);
125+
126+
expect(fetchFn).not.toHaveBeenCalled();
127+
const body = JSON.parse((res.result as { content: Array<{ text: string }> }).content[0]!.text);
128+
expect(body.issue[0].code).toBe("required");
129+
expect(body.issue[0].diagnostics).toContain("confirm: true");
130+
});
131+
132+
it("accepts writes when confirm: true is present", async () => {
133+
const fetchFn = fetchOk({ resourceType: "Patient", id: "new" });
134+
const server = createServer({
135+
baseUrl: "https://example.test/baseR4",
136+
resourceTypes: ["Patient"],
137+
writes: ["create"],
138+
confirmWrites: true,
139+
audit: new MemoryAuditSink(),
140+
fetch: fetchFn,
141+
});
142+
143+
await server.dispatcher.handleRequest(
144+
req({ name: "fhir.create", arguments: { resourceType: "Patient", params: {}, confirm: true } }),
145+
);
146+
expect(fetchFn).toHaveBeenCalledOnce();
147+
});
148+
149+
it("does not affect read verbs", async () => {
150+
const fetchFn = fetchOk({});
151+
const server = createServer({
152+
baseUrl: "https://example.test/baseR4",
153+
resourceTypes: ["Patient"],
154+
confirmWrites: true,
155+
audit: new MemoryAuditSink(),
156+
fetch: fetchFn,
157+
});
158+
159+
await server.dispatcher.handleRequest(
160+
req({ name: "fhir.read", arguments: { resourceType: "Patient", id: "abc" } }),
161+
);
162+
expect(fetchFn).toHaveBeenCalledOnce();
163+
});
164+
});
165+
166+
describe("write gates compose", () => {
167+
it("dryRun + writeResourceTypes + confirmWrites all fire in order", async () => {
168+
const fetchFn = fetchOk({});
169+
const server = createServer({
170+
baseUrl: "https://example.test/baseR4",
171+
resourceTypes: ["Patient", "Observation"],
172+
writes: ["create"],
173+
writeResourceTypes: ["Patient"],
174+
dryRun: true,
175+
confirmWrites: true,
176+
audit: new MemoryAuditSink(),
177+
fetch: fetchFn,
178+
});
179+
180+
// Wrong type — fails the allowlist before dryRun even kicks in.
181+
const wrongType = await server.dispatcher.handleRequest(
182+
req({ name: "fhir.create", arguments: { resourceType: "Observation", params: {}, confirm: true } }),
183+
);
184+
expect(JSON.parse((wrongType.result as { content: Array<{ text: string }> }).content[0]!.text).issue[0].code).toBe(
185+
"forbidden",
186+
);
187+
188+
// Right type, missing confirm — fails the confirmation gate.
189+
const noConfirm = await server.dispatcher.handleRequest(
190+
req({ name: "fhir.create", arguments: { resourceType: "Patient", params: {} } }),
191+
);
192+
expect(JSON.parse((noConfirm.result as { content: Array<{ text: string }> }).content[0]!.text).issue[0].code).toBe(
193+
"required",
194+
);
195+
196+
// Right type + confirmed — falls through to dryRun.
197+
const dry = await server.dispatcher.handleRequest(
198+
req({ name: "fhir.create", arguments: { resourceType: "Patient", params: {}, confirm: true } }),
199+
);
200+
expect(
201+
JSON.parse((dry.result as { content: Array<{ text: string }> }).content[0]!.text).issue[0].diagnostics,
202+
).toContain("dry-run");
203+
expect(fetchFn).not.toHaveBeenCalled();
204+
});
205+
});

0 commit comments

Comments
 (0)