fhirhydrant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.out/server.js ADDED
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ts/server.ts
4
+ import { readFileSync as readFileSync2, watch } from "fs";
5
+ import { basename, dirname as dirname2, join as join2 } from "path";
6
+ import { fileURLToPath as fileURLToPath2 } from "url";
7
+ import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
8
+
9
+ // ts/config.ts
10
+ var get = (key) => {
11
+ const val = process.env[key];
12
+ if (!val) throw new Error(`Missing required env var: ${key}`);
13
+ return val;
14
+ };
15
+ var opt = (key) => process.env[key];
16
+ var parseTransport = () => {
17
+ const val = (opt("MCP_TRANSPORT") ?? "http").toLowerCase();
18
+ if (val !== "http" && val !== "stdio")
19
+ throw new Error(
20
+ `Invalid MCP_TRANSPORT="${val}" \u2014 must be "http" or "stdio"`
21
+ );
22
+ return val;
23
+ };
24
+ var parsePort = () => {
25
+ const raw = opt("PORT") ?? "5000", port = parseInt(raw, 10);
26
+ if (!Number.isFinite(port) || port < 1 || port > 65535)
27
+ throw new Error(`Invalid PORT="${raw}" \u2014 must be 1\u201365535`);
28
+ return port;
29
+ };
30
+ var parseAllowedHosts = () => opt("ALLOWED_HOSTS")?.split(",").map((s) => s.trim()).filter(Boolean) || void 0;
31
+ var config = {
32
+ fhirBaseUrl: get("FHIR_BASE_URL").replace(/\/$/, ""),
33
+ get fhirServerUrl() {
34
+ return opt("FHIR_SERVER_URL") ?? `${this.fhirBaseUrl}/api/FHIR/R4`;
35
+ },
36
+ get fhirTokenEndpoint() {
37
+ return opt("FHIR_TOKEN_URL") ?? `${this.fhirBaseUrl}/oauth2/token`;
38
+ },
39
+ fhirClientId: get("FHIR_CLIENT_ID"),
40
+ fhirPrivateKey: get("FHIR_PRIVATE_KEY"),
41
+ fhirJwksUrl: opt("FHIR_JWKS_URL"),
42
+ fhirKeyId: opt("FHIR_KEY_ID"),
43
+ port: parsePort(),
44
+ bindHost: opt("BIND_HOST") ?? "127.0.0.1",
45
+ allowedHosts: parseAllowedHosts(),
46
+ transport: parseTransport(),
47
+ debug: opt("DEBUG")?.toLowerCase() === "true"
48
+ };
49
+
50
+ // ts/fhir/auth.ts
51
+ import FHIRStarter from "fhirstarterjs";
52
+
53
+ // ts/fhir/definitions.ts
54
+ import { existsSync, readFileSync } from "fs";
55
+ import { dirname, join } from "path";
56
+ import { fileURLToPath } from "url";
57
+ import * as z from "zod";
58
+
59
+ // ts/fhir/validate-definitions.ts
60
+ var text = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
61
+ var validateDefinitions = (raw) => {
62
+ const errors = [];
63
+ if (!Array.isArray(raw))
64
+ return errors.push("definitions.json must be a JSON array"), { entries: [], errors };
65
+ const seen = /* @__PURE__ */ new Set(), entries = [];
66
+ for (const value of raw) {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
68
+ errors.push("definitions.json entries must be objects");
69
+ continue;
70
+ }
71
+ const entry = value, searchParams = entry["searchParams"];
72
+ if (searchParams !== void 0 && (!searchParams || typeof searchParams !== "object" || Array.isArray(searchParams))) {
73
+ errors.push(
74
+ `Invalid entry for resourceType "${text(entry["resourceType"]) ?? "(missing)"}": searchParams must be an object when provided`
75
+ );
76
+ continue;
77
+ }
78
+ const rt = text(entry["resourceType"]), name = text(entry["toolName"]), desc = text(entry["description"]);
79
+ if (!rt || !name || !desc || typeof entry.supportsDirectRead !== "boolean") {
80
+ errors.push(
81
+ `Invalid entry for resourceType "${rt ?? "(missing)"}": requires resourceType, toolName, description (non-empty strings) and supportsDirectRead (boolean)`
82
+ );
83
+ continue;
84
+ }
85
+ if (seen.has(name)) {
86
+ errors.push(`Duplicate toolName "${name}"`);
87
+ continue;
88
+ }
89
+ seen.add(name);
90
+ const params = searchParams ?? {};
91
+ for (const [key, val] of Object.entries(params))
92
+ if (typeof key !== "string" || typeof val !== "string")
93
+ errors.push(`"${name}": searchParams keys and values must be strings (got key="${key}")`);
94
+ if (!entry.supportsDirectRead && Object.keys(params).length === 0) {
95
+ errors.push(`"${name}" has no searchParams and supportsDirectRead is false`);
96
+ continue;
97
+ }
98
+ const rawRequire = entry["requireOneOf"], requireOneOf = Array.isArray(rawRequire) && rawRequire.length > 0 && rawRequire.every((v) => typeof v === "string" && v.trim()) ? rawRequire : void 0;
99
+ if (rawRequire !== void 0 && !requireOneOf)
100
+ errors.push(`"${name}": requireOneOf must be a non-empty array of strings when provided`);
101
+ if (requireOneOf) {
102
+ const paramKeys = new Set(Object.keys(params));
103
+ for (const key of requireOneOf)
104
+ if (!paramKeys.has(key))
105
+ errors.push(`"${name}": requireOneOf key "${key}" is not in searchParams`);
106
+ }
107
+ entries.push({
108
+ resourceType: rt,
109
+ toolName: name,
110
+ description: desc,
111
+ supportsDirectRead: entry["supportsDirectRead"],
112
+ searchParams: Object.keys(params).length > 0 ? params : void 0,
113
+ requireOneOf
114
+ });
115
+ }
116
+ return { entries, errors };
117
+ };
118
+
119
+ // ts/fhir/definitions.ts
120
+ var packaged = join(dirname(fileURLToPath(import.meta.url)), "../..", "definitions.json");
121
+ var getDefinitionsPath = () => {
122
+ const cwd = join(process.cwd(), "definitions.json");
123
+ return existsSync(cwd) ? cwd : packaged;
124
+ };
125
+ var parse = () => {
126
+ const raw = JSON.parse(
127
+ readFileSync(getDefinitionsPath(), "utf8")
128
+ ), result = validateDefinitions(raw);
129
+ if (result.errors.length > 0)
130
+ throw new Error(`definitions.json: ${result.errors.join("; ")}`);
131
+ const seen = /* @__PURE__ */ new Set(), definitions = result.entries.map((entry) => {
132
+ if (seen.has(entry.toolName))
133
+ throw new Error(
134
+ `definitions.json: duplicate toolName "${entry.toolName}"`
135
+ );
136
+ seen.add(entry.toolName);
137
+ const params = entry.searchParams ?? {}, shape = Object.fromEntries(
138
+ Object.entries(params).map(([key, desc]) => [
139
+ key,
140
+ z.string().optional().describe(desc)
141
+ ])
142
+ );
143
+ if (entry.supportsDirectRead && !shape["_id"]) {
144
+ shape["_id"] = z.string().optional().describe(
145
+ `${entry.resourceType} resource ID \u2014 performs direct read when provided alone`
146
+ );
147
+ console.warn(
148
+ `[definitions] "${entry.toolName}": auto-injected _id for supportsDirectRead`
149
+ );
150
+ }
151
+ const schema = z.object(shape);
152
+ return {
153
+ resourceType: entry.resourceType,
154
+ toolName: entry.toolName,
155
+ description: entry.description,
156
+ supportsDirectRead: entry.supportsDirectRead,
157
+ requireOneOf: entry.requireOneOf,
158
+ searchSchema: schema
159
+ };
160
+ }), scopes = definitions.map(
161
+ (d) => d.supportsDirectRead ? `system/${d.resourceType}.rs` : `system/${d.resourceType}.s`
162
+ );
163
+ return { definitions, scopes };
164
+ };
165
+ var snapshot = parse();
166
+ var getDefinitions = () => snapshot.definitions;
167
+ var getScopes = () => snapshot.scopes;
168
+ var reloadDefinitions = () => {
169
+ try {
170
+ snapshot = parse();
171
+ return true;
172
+ } catch (err) {
173
+ console.error(
174
+ "[definitions] Reload failed \u2014 keeping last valid snapshot:",
175
+ err instanceof Error ? err.message : err
176
+ );
177
+ return false;
178
+ }
179
+ };
180
+
181
+ // ts/fhir/auth.ts
182
+ var starter;
183
+ var startAuth = async () => {
184
+ starter = new FHIRStarter({
185
+ clientId: config.fhirClientId,
186
+ privateKey: config.fhirPrivateKey,
187
+ tokenEndpointUrl: config.fhirTokenEndpoint,
188
+ scopes: getScopes(),
189
+ ...config.fhirJwksUrl && { jwksUrl: config.fhirJwksUrl },
190
+ ...config.fhirKeyId && { keyId: config.fhirKeyId }
191
+ });
192
+ await starter.start();
193
+ };
194
+ var stopAuth = () => {
195
+ starter?.stop();
196
+ };
197
+ var restartAuth = async () => {
198
+ stopAuth();
199
+ await startAuth();
200
+ };
201
+ var getTokenResponse = () => starter.tokenResponse();
202
+
203
+ // ts/fhir/registry.ts
204
+ import { z as z2 } from "zod";
205
+
206
+ // ts/fhir/client.ts
207
+ import FHIR from "fhirclient";
208
+ var smart = FHIR({});
209
+ var createFhirClient = () => smart.client({
210
+ serverUrl: config.fhirServerUrl,
211
+ tokenResponse: getTokenResponse()
212
+ });
213
+
214
+ // ts/fhir/registry.ts
215
+ var retryable = (err) => {
216
+ if (err instanceof Error) {
217
+ const msg = err.message.toLowerCase();
218
+ return msg.includes("econnreset") || msg.includes("epipe") || msg.includes("etimedout") || msg.includes("socket hang up") || msg.includes("forcibly closed") || msg.includes("network") || msg.includes("fetch failed");
219
+ }
220
+ return false;
221
+ };
222
+ var withRetry = async (label, fn, attempts = 3) => {
223
+ for (let i = 0; i < attempts; i++) {
224
+ try {
225
+ return await fn();
226
+ } catch (err) {
227
+ if (i + 1 >= attempts || !retryable(err)) throw err;
228
+ const delay = 1e3 * 2 ** i;
229
+ console.warn(`[fhir] ${label} transient error, retrying in ${delay}ms (${i + 1}/${attempts})`);
230
+ await new Promise((r) => setTimeout(r, delay));
231
+ }
232
+ }
233
+ throw new Error("unreachable");
234
+ };
235
+ var buildSearchUrl = (resourceType, args) => {
236
+ const params = new URLSearchParams();
237
+ for (const [key, val] of Object.entries(args))
238
+ val !== void 0 && val !== "" && params.append(key, String(val));
239
+ const qs = params.toString();
240
+ return qs ? `${resourceType}?${qs}` : resourceType;
241
+ };
242
+ var isDirectRead = (args, supportsDirectRead) => {
243
+ if (!supportsDirectRead) return void 0;
244
+ const id = typeof args["_id"] === "string" && args["_id"] ? args["_id"] : void 0;
245
+ if (!id) return void 0;
246
+ const otherKeys = Object.entries(args).some(
247
+ ([k, v]) => k !== "_id" && v !== void 0 && v !== ""
248
+ );
249
+ return otherKeys ? void 0 : id;
250
+ };
251
+ var makeHandler = (def) => async (args) => {
252
+ const directId = isDirectRead(args, def.supportsDirectRead);
253
+ if (!directId && def.requireOneOf) {
254
+ const ok = def.requireOneOf.some((k) => {
255
+ const v = args[k];
256
+ return typeof v === "string" && v !== "";
257
+ });
258
+ if (!ok)
259
+ return {
260
+ content: [{ type: "text", text: `Search requires at least one of: ${def.requireOneOf.join(", ")}` }],
261
+ isError: true
262
+ };
263
+ }
264
+ try {
265
+ const client = createFhirClient(), url = directId ? `${def.resourceType}/${directId}` : buildSearchUrl(def.resourceType, args), op = directId ? "read" : "search";
266
+ config.debug ? console.log(`[fhir] ${def.resourceType} ${op} \u2192 ${url}`) : console.log(`[fhir] ${def.resourceType} ${op}`);
267
+ const result = await withRetry(`${def.resourceType} ${op}`, () => client.request(url)), summary = result && typeof result === "object" && result.resourceType === "Bundle" ? `Bundle total=${result.total ?? "?"}` : result?.resourceType ?? "ok";
268
+ console.log(`[fhir] ${def.resourceType} OK ${summary}`);
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text",
273
+ text: JSON.stringify(result, null, 2)
274
+ }
275
+ ]
276
+ };
277
+ } catch (err) {
278
+ const message = err instanceof Error ? err.message : String(err);
279
+ console.error(`[fhir] ${def.resourceType} ERR ${message}`);
280
+ return {
281
+ content: [{ type: "text", text: message }],
282
+ isError: true
283
+ };
284
+ }
285
+ };
286
+ var registerAll = (server) => {
287
+ for (const def of getDefinitions())
288
+ server.registerTool(
289
+ def.toolName,
290
+ { description: def.description, inputSchema: def.searchSchema },
291
+ makeHandler(def)
292
+ );
293
+ };
294
+ var validatePageUrl = (url) => {
295
+ const baseHref = config.fhirServerUrl.replace(/\/?$/, "/"), serverUrl = new URL(baseHref), nextUrl = new URL(url, baseHref);
296
+ if (nextUrl.origin !== serverUrl.origin)
297
+ throw new Error(
298
+ `Pagination URL origin "${nextUrl.origin}" does not match FHIR server origin "${serverUrl.origin}"`
299
+ );
300
+ if (!nextUrl.pathname.startsWith(serverUrl.pathname))
301
+ throw new Error(
302
+ `Pagination URL path "${nextUrl.pathname}" is outside FHIR server base path "${serverUrl.pathname}"`
303
+ );
304
+ return nextUrl.toString();
305
+ };
306
+ var registerCoreTools = (server) => {
307
+ server.registerTool(
308
+ "fhir_fetch_page",
309
+ {
310
+ description: `Fetch a single page of FHIR Bundle results using a pagination URL. The url must come from a FHIR Bundle's link array where relation is "next". Do not construct pagination URLs manually \u2014 only use links returned by the FHIR server.`,
311
+ inputSchema: z2.object({
312
+ url: z2.string().describe(
313
+ "Pagination URL from a FHIR Bundle link[rel=next].url value"
314
+ )
315
+ })
316
+ },
317
+ async (args) => {
318
+ try {
319
+ const validatedUrl = validatePageUrl(args.url), client = createFhirClient();
320
+ config.debug ? console.log(`[fhir] fetch_page \u2192 ${validatedUrl}`) : console.log("[fhir] fetch_page");
321
+ const result = await withRetry("fetch_page", () => client.request(validatedUrl)), summary = result && typeof result === "object" && result.resourceType === "Bundle" ? `Bundle total=${result.total ?? "?"}` : "ok";
322
+ console.log(`[fhir] fetch_page OK ${summary}`);
323
+ return {
324
+ content: [
325
+ {
326
+ type: "text",
327
+ text: JSON.stringify(result, null, 2)
328
+ }
329
+ ]
330
+ };
331
+ } catch (err) {
332
+ const message = err instanceof Error ? err.message : String(err);
333
+ console.error(`[fhir] fetch_page ERR ${message}`);
334
+ return {
335
+ content: [
336
+ {
337
+ type: "text",
338
+ text: `${message}
339
+
340
+ Retry with the same url to resume from this page.`
341
+ }
342
+ ],
343
+ isError: true
344
+ };
345
+ }
346
+ }
347
+ );
348
+ };
349
+
350
+ // ts/server.ts
351
+ var { version: pkgVersion } = JSON.parse(
352
+ readFileSync2(join2(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json"), "utf8")
353
+ );
354
+ var SERVER_INFO = { name: "fhirhydrant", version: pkgVersion };
355
+ var SERVER_INSTRUCTIONS = readFileSync2(
356
+ join2(dirname2(fileURLToPath2(import.meta.url)), "..", "instructions.md"),
357
+ "utf8"
358
+ ).trim();
359
+ var makeServer = () => {
360
+ const s = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
361
+ registerAll(s);
362
+ registerCoreTools(s);
363
+ return s;
364
+ };
365
+ var restartingAuth = false;
366
+ var startDefinitionsWatcher = () => {
367
+ const defPath = getDefinitionsPath(), watchDir = dirname2(defPath), watchFile = basename(defPath);
368
+ let debounce;
369
+ watch(watchDir, (_eventType, filename) => {
370
+ if (filename !== watchFile) return;
371
+ clearTimeout(debounce);
372
+ debounce = setTimeout(async () => {
373
+ const prevScopes = getScopes().join(","), ok = reloadDefinitions();
374
+ if (!ok) return;
375
+ console.log(`[definitions] Reloaded from ${watchFile}`);
376
+ if (getScopes().join(",") !== prevScopes) {
377
+ if (restartingAuth)
378
+ return void console.log(
379
+ "[definitions] Auth restart already in progress \u2014 skipping"
380
+ );
381
+ restartingAuth = true;
382
+ try {
383
+ console.log(
384
+ "[definitions] Scopes changed \u2014 restarting auth..."
385
+ );
386
+ await restartAuth();
387
+ console.log("[definitions] Auth restarted with new scopes");
388
+ } catch (err) {
389
+ console.error(
390
+ "[definitions] Auth restart failed:",
391
+ err instanceof Error ? err.message : err
392
+ );
393
+ } finally {
394
+ restartingAuth = false;
395
+ }
396
+ }
397
+ }, 300);
398
+ });
399
+ console.log(`[definitions] Watching ${watchFile} for changes`);
400
+ };
401
+ var startHttp = async () => {
402
+ const { createMcpExpressApp } = await import("@modelcontextprotocol/express"), { NodeStreamableHTTPServerTransport } = await import("@modelcontextprotocol/node"), app = createMcpExpressApp(
403
+ config.allowedHosts ? { allowedHosts: config.allowedHosts } : void 0
404
+ ), server = makeServer(), transport = new NodeStreamableHTTPServerTransport({
405
+ sessionIdGenerator: void 0
406
+ });
407
+ await server.connect(transport);
408
+ app.get("/health", (_req, res) => res.json({ status: "ok" }));
409
+ app.all("/mcp", async (req, res) => {
410
+ const body = req.body, method = body?.method ?? req.method;
411
+ method && console.log(`[mcp] ${method}`);
412
+ await transport.handleRequest(req, res, req.body);
413
+ });
414
+ app.use((err, _req, res, _next) => {
415
+ console.error("[http] Request error:", err.message);
416
+ res.status(400).json({
417
+ jsonrpc: "2.0",
418
+ error: { code: -32700, message: "Parse error" },
419
+ id: null
420
+ });
421
+ });
422
+ const httpServer = app.listen(
423
+ config.port,
424
+ config.bindHost,
425
+ () => console.log(`fhirhydrant listening on ${config.bindHost}:${config.port}`)
426
+ );
427
+ return () => new Promise((resolve) => {
428
+ void transport.close();
429
+ void server.close();
430
+ httpServer.close(() => resolve());
431
+ setTimeout(() => resolve(), 5e3);
432
+ });
433
+ };
434
+ var startStdio = async () => {
435
+ console.log = (...args) => console.error(...args);
436
+ const server = makeServer(), transport = new StdioServerTransport();
437
+ await server.connect(transport);
438
+ console.log("fhirhydrant running in stdio mode");
439
+ return async () => {
440
+ await transport.close();
441
+ await server.close();
442
+ };
443
+ };
444
+ await startAuth();
445
+ var close = config.transport === "stdio" ? await startStdio() : await startHttp();
446
+ process.env["NODE_ENV"] !== "production" && startDefinitionsWatcher();
447
+ var shutdownInProgress = false;
448
+ var shutdown = async (code = 0) => {
449
+ if (shutdownInProgress) return;
450
+ shutdownInProgress = true;
451
+ console.log("Shutting down...");
452
+ stopAuth();
453
+ await close();
454
+ process.exit(code);
455
+ };
456
+ process.on("SIGINT", () => void shutdown(0));
457
+ process.on("SIGTERM", () => void shutdown(0));
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
4
+ software, either in source code form or as a compiled binary, for any purpose,
5
+ commercial or non-commercial, and by any means.
6
+
7
+ In jurisdictions that recognize copyright laws, the author or authors of this
8
+ software dedicate any and all copyright interest in the software to the public
9
+ domain. We make this dedication for the benefit of the public at large and to
10
+ the detriment of our heirs and successors. We intend this dedication to be an
11
+ overt act of relinquishment in perpetuity of all present and future rights to
12
+ this software under copyright law.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE
17
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
18
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
19
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+
21
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # fhirHydrant
2
+
3
+ An MCP server for connecting AI clients to FHIR APIs.
4
+
5
+ Authenticates via SMART Backend Services, exposes configurable resource tools
6
+ with FHIR Bundle pagination, and supports both Streamable HTTP and stdio
7
+ transports.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js ≥ 24
12
+ - A FHIR R4 server with SMART Backend Services (client credentials) support
13
+ - An RSA-2048 private key and a publicly hosted JWKS
14
+
15
+ ## Install
16
+
17
+ ### npm / npx (recommended)
18
+
19
+ ```sh
20
+ npm install -g fhirhydrant
21
+ # or run directly:
22
+ npx fhirhydrant
23
+ ```
24
+
25
+ ### From source
26
+
27
+ ```sh
28
+ git clone https://github.com/faulkj/fhirhydrant.git
29
+ cd fhirhydrant
30
+ npm install
31
+ npm run build
32
+ ```
33
+
34
+ ### Docker
35
+
36
+ ```sh
37
+ docker build -t fhirhydrant .
38
+ docker run \
39
+ -v /host/path/key.pem:/run/secrets/fhir-key.pem:ro \
40
+ -e FHIR_BASE_URL=https://fhir.example.org \
41
+ -e FHIR_CLIENT_ID=your-client-id \
42
+ -e FHIR_PRIVATE_KEY=/run/secrets/fhir-key.pem \
43
+ -p 5000:5000 \
44
+ fhirhydrant
45
+ ```
46
+
47
+ > **Docker key mounting:** `FHIR_PRIVATE_KEY=./private.pem` will not resolve
48
+ > inside the container. Mount the key file and use the container path as shown.
49
+
50
+ ## Setup
51
+
52
+ Generate a private key if you don't have one:
53
+
54
+ ```sh
55
+ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
56
+ ```
57
+
58
+ Host the corresponding public JWKS somewhere reachable by your FHIR auth server
59
+ (e.g. a GitHub Gist), and register it along with your `FHIR_CLIENT_ID`.
60
+
61
+ **From source:** copy `.env.example` to `.env` and fill in your values — `npm run dev` loads it automatically.
62
+
63
+ **npm / npx (HTTP mode):** set env vars in your shell or process manager before running.
64
+
65
+ **stdio mode:** pass env vars via the MCP client config's `env` block (see [Stdio](#stdio) below).
66
+
67
+ ## Environment
68
+
69
+ See [.env.example](.env.example) for all variables.
70
+
71
+ ### Required
72
+
73
+ | Variable | Description |
74
+ | ------------------ | --------------------------------------------------------------------------- |
75
+ | `FHIR_BASE_URL` | FHIR server base — `/api/FHIR/R4` and `/oauth2/token` are derived from this |
76
+ | `FHIR_CLIENT_ID` | Client ID registered with your FHIR auth server |
77
+ | `FHIR_PRIVATE_KEY` | Path to your RSA private key PEM file (relative to cwd or absolute) |
78
+
79
+ ### Commonly required
80
+
81
+ | Variable | Description |
82
+ | --------------- | ----------------------------------------------------- |
83
+ | `FHIR_JWKS_URL` | Public JWKS URL registered with your FHIR auth server |
84
+ | `FHIR_KEY_ID` | `kid` value in your JWKS matching the private key |
85
+
86
+ ### Optional
87
+
88
+ | Variable | Default | Description |
89
+ | ------------------------- | --------------------- | ----------------------------------------------------------------- |
90
+ | `FHIR_SERVER_URL` | `<base>/api/FHIR/R4` | Override the derived FHIR API URL for non-standard server layouts |
91
+ | `FHIR_TOKEN_URL` | `<base>/oauth2/token` | Override the derived token endpoint URL |
92
+ | `MCP_TRANSPORT` | `http` | `http` for Streamable HTTP, `stdio` for stdio |
93
+ | `PORT` | `5000` | HTTP listener port (1–65535) |
94
+ | `BIND_HOST` | `127.0.0.1` | Bind address for HTTP listener — set to `0.0.0.0` for container/LAN access |
95
+ | `ALLOWED_HOSTS` | — | Comma-separated hostnames for DNS rebinding protection |
96
+ | `DEBUG` | `false` | Enable verbose FHIR request logging (**may log PHI** — see below) |
97
+
98
+ When both a derived URL and an explicit override are available, the explicit
99
+ override takes precedence.
100
+
101
+ ## Definitions
102
+
103
+ fhirHydrant uses a `definitions.json` file to map FHIR resource types to MCP
104
+ tools. The resolution order is:
105
+
106
+ 1. `./definitions.json` in the current working directory (if it exists)
107
+ 2. Packaged default definitions
108
+
109
+ Edit `definitions.json` directly when customizing resources.
110
+
111
+ ### Definitions schema
112
+
113
+ | Field | Type | Description |
114
+ | -------------------- | ------------------------ | ----------------------------------------------------------------------------------------------- |
115
+ | `resourceType` | `string` | FHIR resource type (e.g. `AllergyIntolerance`) |
116
+ | `toolName` | `string` | MCP tool name — must be unique across all entries |
117
+ | `description` | `string` | Human-readable tool description shown to the AI client |
118
+ | `supportsDirectRead` | `boolean` | Enable `/ResourceType/{id}` reads when `_id` is provided alone |
119
+ | `searchParams` | `Record<string, string>` | Key = FHIR search param, value = parameter description (optional if supportsDirectRead is true) |
120
+ | `requireOneOf` | `string[]` (optional) | At least one of these search params must be provided for non-direct-read calls |
121
+
122
+ ### Direct read behavior
123
+
124
+ When `supportsDirectRead` is `true` and the caller supplies `_id` as the
125
+ **only** non-empty argument, fhirHydrant performs a direct
126
+ `GET /ResourceType/{id}` read instead of a search. If `_id` is combined with
127
+ other parameters, a search is performed so intent is not silently discarded.
128
+
129
+ If `supportsDirectRead` is `true` but `_id` is not listed in `searchParams`, it
130
+ is auto-injected into the tool's input schema.
131
+
132
+ ### Hot-reload (dev mode)
133
+
134
+ In development (`NODE_ENV` is not `production`), fhirHydrant watches the active
135
+ definitions file for changes:
136
+
137
+ - Invalid JSON keeps the last valid snapshot
138
+ - When scopes change, auth restarts automatically
139
+ - New tools are available on the next MCP request
140
+
141
+ In production, definitions are read once at startup.
142
+
143
+ ### Safe onboarding checklist
144
+
145
+ 1. Update `definitions.json` with your resources
146
+ 2. Confirm generated SMART scopes match your intended access
147
+ 3. Update/re-register your backend client with the FHIR authorization server if
148
+ required scopes changed
149
+ 4. Test with least-privilege access
150
+ 5. Deploy
151
+
152
+ ### Limitations
153
+
154
+ - All `searchParams` values are string-only — no type enforcement or enums
155
+ - `requireOneOf` enforces "at least one of" — it does not cover complex
156
+ conditional requirements or exact-one-of constraints
157
+ - No full FHIR capability negotiation — `searchParams` are tool input hints, not
158
+ a FHIR capability model
159
+ - Vendor-specific search rules may still apply
160
+
161
+ ## Transport
162
+
163
+ ### HTTP (default)
164
+
165
+ Stateless Streamable HTTP — no session management required.
166
+
167
+ ```
168
+ POST http://localhost:5000/mcp
169
+ Accept: application/json, text/event-stream
170
+ Content-Type: application/json
171
+ ```
172
+
173
+ MCP client config:
174
+
175
+ ```json
176
+ {
177
+ "mcpServers": {
178
+ "fhirhydrant": {
179
+ "url": "http://localhost:5000/mcp"
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ ### Stdio
186
+
187
+ Set `MCP_TRANSPORT=stdio` to use stdio transport. In stdio mode, stdout is
188
+ reserved for the MCP protocol — all logging is redirected to stderr.
189
+
190
+ MCP client config:
191
+
192
+ ```json
193
+ {
194
+ "mcpServers": {
195
+ "fhirhydrant": {
196
+ "command": "npx",
197
+ "args": ["fhirhydrant"],
198
+ "env": {
199
+ "MCP_TRANSPORT": "stdio",
200
+ "FHIR_BASE_URL": "https://fhir.example.org",
201
+ "FHIR_CLIENT_ID": "your-client-id",
202
+ "FHIR_PRIVATE_KEY": "/path/to/private-1.pem",
203
+ "FHIR_JWKS_URL": "https://example.org/.well-known/jwks.json",
204
+ "FHIR_KEY_ID": "key-1"
205
+ }
206
+ }
207
+ }
208
+ }
209
+ ```
210
+
211
+ Prefer stdio for local desktop clients, HTTP for remote/networked clients.
212
+
213
+ ## Tools
214
+
215
+ ### Resource tools
216
+
217
+ Defined in [definitions.json](definitions.json). The default set:
218
+
219
+ | Tool | Resource | Direct read |
220
+ | ------------- | ----------- | ----------- |
221
+ | `patient` | Patient | Yes |
222
+ | `observation` | Observation | Yes |
223
+ | `condition` | Condition | Yes |
224
+ | `encounter` | Encounter | Yes |
225
+
226
+ ### Built-in tools
227
+
228
+ | Tool | Description |
229
+ | ----------------- | ----------------------------------------------------------------- |
230
+ | `fhir_fetch_page` | Fetch a single page of FHIR Bundle results using a pagination URL |
231
+
232
+ Search results are FHIR Bundles that may include pagination links. When a Bundle
233
+ contains a `link` with `relation: "next"`, call `fhir_fetch_page` with that
234
+ link's `url` to fetch the next page. Repeat until no `next` link is present.
235
+
236
+ ## Dev
237
+
238
+ ```sh
239
+ npm run dev
240
+ ```
241
+
242
+ Watches `ts/server.ts` with native Node TS stripping. Edits to the active
243
+ definitions file are picked up live without restart; if scopes change, auth
244
+ restarts automatically.
245
+
246
+ ## Build & run
247
+
248
+ ```sh
249
+ npm run build
250
+ npm start
251
+ ```
252
+
253
+ Output goes to `.out/server.js`.
254
+
255
+ ## Security notes
256
+
257
+ - **TLS:** Use a reverse proxy (nginx, Caddy) to terminate TLS for HTTP mode
258
+ - **ALLOWED_HOSTS:** Set this when exposing HTTP mode on a public network
259
+ - **Private keys:** Keep keys outside the package/project repo — `.gitignore`
260
+ already excludes `*.pem` and `*.key`
261
+ - **PHI in logs:** Default logging does not include FHIR query parameters.
262
+ Setting `DEBUG=true` enables verbose URLs which **may contain
263
+ PHI** (patient names, identifiers, dates). Treat all logs as PHI-sensitive in
264
+ production environments.
265
+ - **Pagination URL validation:** The `fhir_fetch_page` tool validates that URLs
266
+ match the configured FHIR server origin before fetching.
267
+ - **PHI in tool responses:** FHIR resource data returned through MCP tool calls
268
+ contains PHI. Ensure your MCP client’s transcript storage and retention
269
+ policies meet your compliance requirements.
270
+ - **Health endpoint:** `GET /health` returns `{"status":"ok"}` for liveness
271
+ probes. No authentication, no PHI.
@@ -0,0 +1,59 @@
1
+ [
2
+ {
3
+ "resourceType": "Patient",
4
+ "toolName": "patient",
5
+ "description": "Search for or read a FHIR Patient resource. For direct read, provide _id alone. For search, many FHIR servers require one of these minimum data sets: (1) identifier (MRN or FHIR ID), (2) given + family + birthdate, or (3) given + family + legal-sex + telecom. Searching by name alone may not be sufficient.",
6
+ "supportsDirectRead": true,
7
+ "searchParams": {
8
+ "_id": "Patient resource ID — performs direct read when provided alone",
9
+ "family": "Family (last) name",
10
+ "given": "Given (first) name",
11
+ "birthdate": "Date of birth in YYYY-MM-DD format",
12
+ "identifier": "Business identifier in system|value format"
13
+ }
14
+ },
15
+ {
16
+ "resourceType": "Observation",
17
+ "toolName": "observation",
18
+ "description": "Search for or read a FHIR Observation resource. For direct read, provide _id alone. For search, patient is required. Optionally filter by category (e.g. laboratory, vital-signs, core-characteristics) and/or code (LOINC). Use category=core-characteristics with code 76516-4 (gestational age) or 8339-4 (birth weight) for core characteristic lookups.",
19
+ "supportsDirectRead": true,
20
+ "searchParams": {
21
+ "_id": "Observation resource ID — performs direct read when provided alone",
22
+ "patient": "Patient resource ID or reference — required for search",
23
+ "category": "Observation category (e.g. laboratory, vital-signs, core-characteristics)",
24
+ "code": "LOINC or other observation code",
25
+ "date": "Date or date range (e.g. ge2024-01-01)",
26
+ "status": "Observation status (e.g. final, preliminary)"
27
+ },
28
+ "requireOneOf": ["patient"]
29
+ },
30
+ {
31
+ "resourceType": "Condition",
32
+ "toolName": "condition",
33
+ "description": "Search for or read a FHIR Condition resource covering both problem list items and encounter diagnoses. For direct read, provide _id alone. For search, provide patient or encounter (at least one required). Use category=problem-list-item for the patient's problem list (clinical-status: active, resolved, inactive) or category=encounter-diagnosis for diagnoses documented during encounters. Omitting category returns both types.",
34
+ "supportsDirectRead": true,
35
+ "searchParams": {
36
+ "_id": "Condition resource ID — performs direct read when provided alone",
37
+ "patient": "Patient resource ID or reference — required unless encounter is provided",
38
+ "encounter": "Encounter resource ID — use instead of patient to get diagnoses for a specific encounter",
39
+ "category": "problem-list-item for problem list entries, encounter-diagnosis for encounter diagnoses; omit for both",
40
+ "code": "Condition code (ICD-10, SNOMED, etc.)",
41
+ "clinical-status": "Clinical status: active, resolved, or inactive"
42
+ },
43
+ "requireOneOf": ["patient", "encounter"]
44
+ },
45
+ {
46
+ "resourceType": "Encounter",
47
+ "toolName": "encounter",
48
+ "description": "Search for or read a FHIR Encounter resource. Provide an _id for direct read, or search by patient, status, class, or date.",
49
+ "supportsDirectRead": true,
50
+ "searchParams": {
51
+ "_id": "Encounter resource ID — performs direct read when provided alone",
52
+ "patient": "Patient resource ID or reference",
53
+ "status": "Encounter status (e.g. finished, in-progress, planned)",
54
+ "class": "Encounter class (e.g. AMB, IMP, EMER)",
55
+ "date": "Encounter date or date range (e.g. ge2024-01-01)"
56
+ },
57
+ "requireOneOf": ["patient"]
58
+ }
59
+ ]
@@ -0,0 +1,23 @@
1
+ # fhirHydrant
2
+
3
+ fhirHydrant's tools expose FHIR R4 endpoints over MCP.
4
+
5
+ Use these tools to search for or read clinical resources from the configured
6
+ FHIR server.
7
+
8
+ Prefer search first when the user describes a patient, encounter, condition,
9
+ observation, or other clinical concept without a known FHIR resource ID. Use
10
+ search results to identify the relevant resource IDs, then use those IDs for
11
+ direct reads when a tool supports `_id`.
12
+
13
+ Each tool maps to one FHIR resource type from `definitions.json`. Use `_id` for
14
+ direct reads when supported, or provide search parameters for FHIR search.
15
+
16
+ Tool results are returned as raw FHIR JSON and may include Bundles for searches
17
+ or resources for direct reads.
18
+
19
+ Search results are FHIR Bundles that may contain a `link` array. If a `link`
20
+ entry has `relation: "next"`, more results are available. Call `fhir_fetch_page`
21
+ with that entry's `url` to fetch the next page. Repeat until no `next` link is
22
+ present. Never construct pagination URLs manually — only use URLs returned by
23
+ the FHIR server.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "fhirhydrant",
3
+ "version": "0.1.0",
4
+ "description": "An MCP server for connecting AI clients to FHIR APIs.",
5
+ "author": "Joshua Faulkenberry <j@joshuafaulkenberry.com> (https://joshuafaulkenberry.com/)",
6
+ "license": "Unlicense",
7
+ "type": "module",
8
+ "bin": {
9
+ "fhirhydrant": "./.out/server.js"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/faulkj/fhirhydrant.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/faulkj/fhirhydrant/issues"
17
+ },
18
+ "homepage": "https://github.com/faulkj/fhirhydrant#readme",
19
+ "keywords": [
20
+ "fhir",
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "smart-on-fhir",
24
+ "backend-services",
25
+ "hl7",
26
+ "healthcare",
27
+ "ehr",
28
+ "fhirclient",
29
+ "fhirstarterjs"
30
+ ],
31
+ "files": [
32
+ ".out",
33
+ "definitions.json",
34
+ "instructions.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "engines": {
41
+ "node": ">=24"
42
+ },
43
+ "scripts": {
44
+ "check": "tsc --noEmit",
45
+ "build": "tsup ts/server.ts --format esm --out-dir .out --no-splitting --platform node --clean",
46
+ "prepublishOnly": "npm run build",
47
+ "start": "node .out/server.js",
48
+ "dev": "node --env-file=.env --experimental-strip-types --watch ts/server.ts",
49
+ "inspector": "npx @modelcontextprotocol/inspector"
50
+ },
51
+ "dependencies": {
52
+ "@cfworker/json-schema": "^4.1.1",
53
+ "@modelcontextprotocol/express": "2.0.0-alpha.2",
54
+ "@modelcontextprotocol/node": "2.0.0-alpha.2",
55
+ "@modelcontextprotocol/server": "2.0.0-alpha.2",
56
+ "express": "^5.2.1",
57
+ "fhirclient": "^2.6.3",
58
+ "fhirstarterjs": "^1.0.5",
59
+ "zod": "^4.4.3"
60
+ },
61
+ "devDependencies": {
62
+ "@types/express": "^5.0.6",
63
+ "@types/node": "^25.9.3",
64
+ "tsup": "^8.5.1",
65
+ "typescript": "^6.0.3"
66
+ },
67
+ "overrides": {
68
+ "uuid": ">=11.1.1"
69
+ }
70
+ }
71
+