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 +457 -0
- package/LICENSE.md +21 -0
- package/README.md +271 -0
- package/definitions.json +59 -0
- package/instructions.md +23 -0
- package/package.json +71 -0
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.
|
package/definitions.json
ADDED
|
@@ -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
|
+
]
|
package/instructions.md
ADDED
|
@@ -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
|
+
|