@ncodeuy/medplum-mcp 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.
@@ -0,0 +1,11 @@
1
+ import { MedplumClient } from "@medplum/core";
2
+ import type { MedplumMcpConfig, ProjectConfig } from "./config.js";
3
+ export declare class ClientRegistry {
4
+ private config;
5
+ private clients;
6
+ constructor(config: MedplumMcpConfig);
7
+ getClient(env?: string): MedplumClient;
8
+ envNames(): string[];
9
+ get defaultEnv(): string;
10
+ getProjectConfig(env?: string): ProjectConfig;
11
+ }
@@ -0,0 +1,43 @@
1
+ import { MedplumClient } from "@medplum/core";
2
+ function createClientFromConfig(config) {
3
+ const client = new MedplumClient({
4
+ baseUrl: config.baseUrl,
5
+ cacheTime: 0,
6
+ });
7
+ client.setBasicAuth(config.clientId, config.clientSecret);
8
+ return client;
9
+ }
10
+ export class ClientRegistry {
11
+ config;
12
+ clients = new Map();
13
+ constructor(config) {
14
+ this.config = config;
15
+ }
16
+ getClient(env) {
17
+ const name = env || this.config.defaultProject;
18
+ const projectConfig = this.config.projects[name];
19
+ if (!projectConfig) {
20
+ throw new Error(`Unknown environment "${name}". Available: ${this.envNames().join(", ")}`);
21
+ }
22
+ let client = this.clients.get(name);
23
+ if (!client) {
24
+ client = createClientFromConfig(projectConfig);
25
+ this.clients.set(name, client);
26
+ }
27
+ return client;
28
+ }
29
+ envNames() {
30
+ return Object.keys(this.config.projects);
31
+ }
32
+ get defaultEnv() {
33
+ return this.config.defaultProject;
34
+ }
35
+ getProjectConfig(env) {
36
+ const name = env || this.config.defaultProject;
37
+ const projectConfig = this.config.projects[name];
38
+ if (!projectConfig) {
39
+ throw new Error(`Unknown environment "${name}". Available: ${this.envNames().join(", ")}`);
40
+ }
41
+ return projectConfig;
42
+ }
43
+ }
@@ -0,0 +1,3 @@
1
+ import { MedplumClient } from "@medplum/core";
2
+ /** Creates a Medplum client for read-only MCP operations using env-based auth. */
3
+ export declare function createMedplumClient(): MedplumClient;
package/dist/client.js ADDED
@@ -0,0 +1,16 @@
1
+ import { MedplumClient } from "@medplum/core";
2
+ /** Creates a Medplum client for read-only MCP operations using env-based auth. */
3
+ export function createMedplumClient() {
4
+ const baseUrl = process.env.MEDPLUM_BASE_URL;
5
+ const clientId = process.env.MEDPLUM_CLIENT_ID;
6
+ const clientSecret = process.env.MEDPLUM_CLIENT_SECRET;
7
+ if (!clientId || !clientSecret) {
8
+ throw new Error("Missing MEDPLUM_CLIENT_ID or MEDPLUM_CLIENT_SECRET environment variables");
9
+ }
10
+ const client = new MedplumClient({
11
+ baseUrl: baseUrl || "https://api.medplum.com/",
12
+ cacheTime: 0,
13
+ });
14
+ client.setBasicAuth(clientId, clientSecret);
15
+ return client;
16
+ }
@@ -0,0 +1,11 @@
1
+ export interface ProjectConfig {
2
+ baseUrl: string;
3
+ clientId: string;
4
+ clientSecret: string;
5
+ projectId?: string;
6
+ }
7
+ export interface MedplumMcpConfig {
8
+ projects: Record<string, ProjectConfig>;
9
+ defaultProject: string;
10
+ }
11
+ export declare function loadConfig(): MedplumMcpConfig;
package/dist/config.js ADDED
@@ -0,0 +1,53 @@
1
+ export function loadConfig() {
2
+ const projectsJson = process.env.MEDPLUM_PROJECTS;
3
+ if (projectsJson) {
4
+ let parsed;
5
+ try {
6
+ parsed = JSON.parse(projectsJson);
7
+ }
8
+ catch {
9
+ throw new Error("MEDPLUM_PROJECTS contains invalid JSON. Expected a JSON object mapping environment names to project configs.");
10
+ }
11
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
12
+ throw new Error("MEDPLUM_PROJECTS must be a JSON object mapping environment names to project configs.");
13
+ }
14
+ const projects = {};
15
+ for (const [name, value] of Object.entries(parsed)) {
16
+ const cfg = value;
17
+ if (!cfg.clientId || !cfg.clientSecret) {
18
+ throw new Error(`Environment "${name}" is missing required clientId or clientSecret.`);
19
+ }
20
+ projects[name] = {
21
+ baseUrl: cfg.baseUrl || "https://api.medplum.com/",
22
+ clientId: cfg.clientId,
23
+ clientSecret: cfg.clientSecret,
24
+ projectId: cfg.projectId,
25
+ };
26
+ }
27
+ const keys = Object.keys(projects);
28
+ if (keys.length === 0) {
29
+ throw new Error("MEDPLUM_PROJECTS must contain at least one environment.");
30
+ }
31
+ const defaultProject = process.env.MEDPLUM_DEFAULT_PROJECT || keys[0];
32
+ if (!projects[defaultProject]) {
33
+ throw new Error(`MEDPLUM_DEFAULT_PROJECT "${defaultProject}" not found in MEDPLUM_PROJECTS. Available: ${keys.join(", ")}`);
34
+ }
35
+ return { projects, defaultProject };
36
+ }
37
+ // Legacy single-project fallback
38
+ const clientId = process.env.MEDPLUM_CLIENT_ID;
39
+ const clientSecret = process.env.MEDPLUM_CLIENT_SECRET;
40
+ if (!clientId || !clientSecret) {
41
+ throw new Error("Missing MEDPLUM_PROJECTS or MEDPLUM_CLIENT_ID/MEDPLUM_CLIENT_SECRET environment variables.");
42
+ }
43
+ return {
44
+ projects: {
45
+ default: {
46
+ baseUrl: process.env.MEDPLUM_BASE_URL || "https://api.medplum.com/",
47
+ clientId,
48
+ clientSecret,
49
+ },
50
+ },
51
+ defaultProject: "default",
52
+ };
53
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ClientRegistry } from "./client-registry.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { registerGetEnvironmentsTool } from "./tools/get-environments.js";
7
+ import { registerGetResourceTypesTool } from "./tools/get-resource-types.js";
8
+ import { registerReadHistoryTool } from "./tools/read-history.js";
9
+ import { registerReadResourceTool } from "./tools/read-resource.js";
10
+ import { registerSearchResourcesTool } from "./tools/search-resources.js";
11
+ const server = new McpServer({
12
+ name: "medplum-fhir",
13
+ version: "0.1.0",
14
+ });
15
+ const config = loadConfig();
16
+ const registry = new ClientRegistry(config);
17
+ registerGetEnvironmentsTool(server, registry);
18
+ registerReadResourceTool(server, registry);
19
+ registerReadHistoryTool(server, registry);
20
+ registerSearchResourcesTool(server, registry);
21
+ registerGetResourceTypesTool(server);
22
+ const transport = new StdioServerTransport();
23
+ await server.connect(transport);
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ClientRegistry } from "../client-registry.js";
3
+ export declare function registerGetEnvironmentsTool(server: McpServer, registry: ClientRegistry): void;
@@ -0,0 +1,21 @@
1
+ export function registerGetEnvironmentsTool(server, registry) {
2
+ server.tool("fhir_get_environments", "List available Medplum environments and which is the default. Call this first to discover environments before using other tools.", async () => {
3
+ const environments = registry.envNames().map((name) => {
4
+ const config = registry.getProjectConfig(name);
5
+ return {
6
+ name,
7
+ baseUrl: config.baseUrl,
8
+ projectId: config.projectId,
9
+ isDefault: name === registry.defaultEnv,
10
+ };
11
+ });
12
+ return {
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: JSON.stringify({ environments, default: registry.defaultEnv }, null, 2),
17
+ },
18
+ ],
19
+ };
20
+ });
21
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerGetResourceTypesTool(server: McpServer): void;
@@ -0,0 +1,320 @@
1
+ const RESOURCE_TYPES = [
2
+ {
3
+ resourceType: "Patient",
4
+ description: "Demographics and administrative information about a patient",
5
+ searchParams: [
6
+ "name",
7
+ "family",
8
+ "given",
9
+ "birthdate",
10
+ "gender",
11
+ "identifier",
12
+ "email",
13
+ "phone",
14
+ "address",
15
+ "active",
16
+ "organization",
17
+ ],
18
+ },
19
+ {
20
+ resourceType: "Practitioner",
21
+ description: "A person who is involved in the healthcare process",
22
+ searchParams: [
23
+ "name",
24
+ "family",
25
+ "given",
26
+ "identifier",
27
+ "email",
28
+ "phone",
29
+ "active",
30
+ ],
31
+ },
32
+ {
33
+ resourceType: "PractitionerRole",
34
+ description: "Roles/locations/specialties a practitioner may perform at an organization",
35
+ searchParams: [
36
+ "practitioner",
37
+ "organization",
38
+ "role",
39
+ "specialty",
40
+ "active",
41
+ ],
42
+ },
43
+ {
44
+ resourceType: "Organization",
45
+ description: "A formally or informally recognized grouping of people or organizations",
46
+ searchParams: ["name", "identifier", "type", "active", "address"],
47
+ },
48
+ {
49
+ resourceType: "Encounter",
50
+ description: "An interaction between a patient and healthcare provider(s)",
51
+ searchParams: [
52
+ "patient",
53
+ "status",
54
+ "class",
55
+ "date",
56
+ "type",
57
+ "participant",
58
+ "subject",
59
+ ],
60
+ },
61
+ {
62
+ resourceType: "Observation",
63
+ description: "Measurements and assertions about a patient or other subject",
64
+ searchParams: [
65
+ "patient",
66
+ "subject",
67
+ "code",
68
+ "category",
69
+ "date",
70
+ "status",
71
+ "value-concept",
72
+ "value-quantity",
73
+ ],
74
+ },
75
+ {
76
+ resourceType: "Condition",
77
+ description: "Detailed information about conditions or diagnoses",
78
+ searchParams: [
79
+ "patient",
80
+ "subject",
81
+ "code",
82
+ "clinical-status",
83
+ "category",
84
+ "onset-date",
85
+ "recorded-date",
86
+ ],
87
+ },
88
+ {
89
+ resourceType: "MedicationRequest",
90
+ description: "An order or request for medication",
91
+ searchParams: [
92
+ "patient",
93
+ "subject",
94
+ "status",
95
+ "intent",
96
+ "medication",
97
+ "encounter",
98
+ "date",
99
+ ],
100
+ },
101
+ {
102
+ resourceType: "AllergyIntolerance",
103
+ description: "Risk of harmful or undesirable response to a substance",
104
+ searchParams: [
105
+ "patient",
106
+ "clinical-status",
107
+ "type",
108
+ "category",
109
+ "code",
110
+ "date",
111
+ ],
112
+ },
113
+ {
114
+ resourceType: "Procedure",
115
+ description: "An action performed on or for a patient",
116
+ searchParams: [
117
+ "patient",
118
+ "subject",
119
+ "code",
120
+ "status",
121
+ "date",
122
+ "encounter",
123
+ ],
124
+ },
125
+ {
126
+ resourceType: "DiagnosticReport",
127
+ description: "Findings and interpretation of diagnostic tests",
128
+ searchParams: [
129
+ "patient",
130
+ "subject",
131
+ "code",
132
+ "category",
133
+ "status",
134
+ "date",
135
+ "encounter",
136
+ ],
137
+ },
138
+ {
139
+ resourceType: "ServiceRequest",
140
+ description: "A request for a procedure, diagnostic, or other service",
141
+ searchParams: [
142
+ "patient",
143
+ "subject",
144
+ "status",
145
+ "intent",
146
+ "code",
147
+ "encounter",
148
+ "occurrence",
149
+ ],
150
+ },
151
+ {
152
+ resourceType: "CarePlan",
153
+ description: "Healthcare plan for a patient or group",
154
+ searchParams: [
155
+ "patient",
156
+ "subject",
157
+ "status",
158
+ "category",
159
+ "date",
160
+ "encounter",
161
+ ],
162
+ },
163
+ {
164
+ resourceType: "CareTeam",
165
+ description: "Participants involved in the care of a patient",
166
+ searchParams: [
167
+ "patient",
168
+ "subject",
169
+ "status",
170
+ "participant",
171
+ "category",
172
+ ],
173
+ },
174
+ {
175
+ resourceType: "Appointment",
176
+ description: "A booking of a healthcare event",
177
+ searchParams: [
178
+ "patient",
179
+ "status",
180
+ "date",
181
+ "practitioner",
182
+ "actor",
183
+ "service-type",
184
+ ],
185
+ },
186
+ {
187
+ resourceType: "Schedule",
188
+ description: "A container for slots of time available for booking",
189
+ searchParams: ["actor", "date", "active", "service-type"],
190
+ },
191
+ {
192
+ resourceType: "Slot",
193
+ description: "A slot of time on a schedule available for booking",
194
+ searchParams: ["schedule", "status", "start"],
195
+ },
196
+ {
197
+ resourceType: "Task",
198
+ description: "A task to be performed",
199
+ searchParams: [
200
+ "patient",
201
+ "subject",
202
+ "status",
203
+ "owner",
204
+ "code",
205
+ "focus",
206
+ "encounter",
207
+ ],
208
+ },
209
+ {
210
+ resourceType: "Communication",
211
+ description: "A clinical or administrative communication",
212
+ searchParams: [
213
+ "patient",
214
+ "subject",
215
+ "status",
216
+ "category",
217
+ "sender",
218
+ "recipient",
219
+ ],
220
+ },
221
+ {
222
+ resourceType: "DocumentReference",
223
+ description: "A reference to a document of any kind",
224
+ searchParams: [
225
+ "patient",
226
+ "subject",
227
+ "status",
228
+ "type",
229
+ "category",
230
+ "date",
231
+ ],
232
+ },
233
+ {
234
+ resourceType: "Questionnaire",
235
+ description: "A structured set of questions",
236
+ searchParams: ["name", "title", "status", "code", "url"],
237
+ },
238
+ {
239
+ resourceType: "QuestionnaireResponse",
240
+ description: "A response to a questionnaire",
241
+ searchParams: [
242
+ "patient",
243
+ "subject",
244
+ "questionnaire",
245
+ "status",
246
+ "authored",
247
+ ],
248
+ },
249
+ {
250
+ resourceType: "Subscription",
251
+ description: "Server push notification channel",
252
+ searchParams: ["status", "type", "url", "criteria"],
253
+ },
254
+ {
255
+ resourceType: "Coverage",
256
+ description: "Insurance or medical plan or payment agreement",
257
+ searchParams: [
258
+ "patient",
259
+ "beneficiary",
260
+ "status",
261
+ "type",
262
+ "payor",
263
+ ],
264
+ },
265
+ {
266
+ resourceType: "Consent",
267
+ description: "A healthcare consumer's choices/agreements",
268
+ searchParams: [
269
+ "patient",
270
+ "status",
271
+ "category",
272
+ "date",
273
+ ],
274
+ },
275
+ {
276
+ resourceType: "Goal",
277
+ description: "Describes the intended objective(s) for a patient",
278
+ searchParams: [
279
+ "patient",
280
+ "subject",
281
+ "lifecycle-status",
282
+ "category",
283
+ "target-date",
284
+ ],
285
+ },
286
+ {
287
+ resourceType: "Immunization",
288
+ description: "Immunization event information",
289
+ searchParams: [
290
+ "patient",
291
+ "status",
292
+ "vaccine-code",
293
+ "date",
294
+ ],
295
+ },
296
+ {
297
+ resourceType: "Composition",
298
+ description: "A set of resources composed into a single coherent clinical statement",
299
+ searchParams: [
300
+ "patient",
301
+ "subject",
302
+ "type",
303
+ "status",
304
+ "date",
305
+ "encounter",
306
+ ],
307
+ },
308
+ ];
309
+ export function registerGetResourceTypesTool(server) {
310
+ server.tool("fhir_get_resource_types", "List available FHIR resource types and their common search parameters", async () => {
311
+ return {
312
+ content: [
313
+ {
314
+ type: "text",
315
+ text: JSON.stringify(RESOURCE_TYPES, null, 2),
316
+ },
317
+ ],
318
+ };
319
+ });
320
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ClientRegistry } from "../client-registry.js";
3
+ export declare function registerReadHistoryTool(server: McpServer, registry: ClientRegistry): void;
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ const DEFAULT_COUNT = 20;
3
+ const MAX_COUNT = 100;
4
+ export function registerReadHistoryTool(server, registry) {
5
+ server.tool("fhir_read_history", "Read the version history of a FHIR resource by type and ID. Returns a Bundle of historical versions ordered from most recent to oldest.", {
6
+ resourceType: z
7
+ .string()
8
+ .describe("FHIR resource type (e.g. Patient, Practitioner, Organization)"),
9
+ id: z.string().describe("The resource ID"),
10
+ count: z
11
+ .number()
12
+ .optional()
13
+ .describe(`Number of history entries to return (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`),
14
+ offset: z
15
+ .number()
16
+ .optional()
17
+ .describe("Number of history entries to skip (for pagination)"),
18
+ env: z
19
+ .string()
20
+ .optional()
21
+ .describe("Environment name to use. Omit for the default environment. Use fhir_get_environments to list available environments."),
22
+ }, async ({ resourceType, id, count, offset, env }) => {
23
+ try {
24
+ const medplum = registry.getClient(env);
25
+ const effectiveCount = Math.min(count ?? DEFAULT_COUNT, MAX_COUNT);
26
+ const bundle = await medplum.readHistory(resourceType, id, { count: effectiveCount, offset });
27
+ return {
28
+ content: [
29
+ { type: "text", text: JSON.stringify(bundle, null, 2) },
30
+ ],
31
+ };
32
+ }
33
+ catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ return {
36
+ content: [
37
+ {
38
+ type: "text",
39
+ text: `Error reading history for ${resourceType}/${id}: ${message}`,
40
+ },
41
+ ],
42
+ isError: true,
43
+ };
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ClientRegistry } from "../client-registry.js";
3
+ export declare function registerReadResourceTool(server: McpServer, registry: ClientRegistry): void;
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ export function registerReadResourceTool(server, registry) {
3
+ server.tool("fhir_read", "Read a single FHIR resource by type and ID", {
4
+ resourceType: z
5
+ .string()
6
+ .describe("FHIR resource type (e.g. Patient, Practitioner, Organization)"),
7
+ id: z.string().describe("The resource ID"),
8
+ env: z
9
+ .string()
10
+ .optional()
11
+ .describe("Environment name to use. Omit for the default environment. Use fhir_get_environments to list available environments."),
12
+ }, async ({ resourceType, id, env }) => {
13
+ try {
14
+ const medplum = registry.getClient(env);
15
+ const resource = await medplum.readResource(resourceType, id);
16
+ return {
17
+ content: [
18
+ { type: "text", text: JSON.stringify(resource, null, 2) },
19
+ ],
20
+ };
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return {
25
+ content: [
26
+ {
27
+ type: "text",
28
+ text: `Error reading ${resourceType}/${id}: ${message}`,
29
+ },
30
+ ],
31
+ isError: true,
32
+ };
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ClientRegistry } from "../client-registry.js";
3
+ export declare function registerSearchResourcesTool(server: McpServer, registry: ClientRegistry): void;
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ const DEFAULT_COUNT = 20;
3
+ const MAX_COUNT = 100;
4
+ export function registerSearchResourcesTool(server, registry) {
5
+ server.tool("fhir_search", "Search for FHIR resources with query parameters. Use fhir_get_resource_types to discover available types and search params.", {
6
+ resourceType: z
7
+ .string()
8
+ .describe("FHIR resource type to search (e.g. Patient, Observation)"),
9
+ params: z
10
+ .record(z.string(), z.string())
11
+ .optional()
12
+ .describe("Search parameters as key-value pairs (e.g. { \"name\": \"John\", \"birthdate\": \"1990-01-01\" })"),
13
+ count: z
14
+ .number()
15
+ .optional()
16
+ .describe(`Number of results to return (default ${DEFAULT_COUNT}, max ${MAX_COUNT})`),
17
+ env: z
18
+ .string()
19
+ .optional()
20
+ .describe("Environment name to use. Omit for the default environment. Use fhir_get_environments to list available environments."),
21
+ }, async ({ resourceType, params, count, env }) => {
22
+ try {
23
+ const medplum = registry.getClient(env);
24
+ const searchParams = new URLSearchParams();
25
+ if (params) {
26
+ for (const [key, value] of Object.entries(params)) {
27
+ searchParams.set(key, value);
28
+ }
29
+ }
30
+ const effectiveCount = Math.min(count ?? DEFAULT_COUNT, MAX_COUNT);
31
+ searchParams.set("_count", String(effectiveCount));
32
+ const bundle = await medplum.search(resourceType, searchParams);
33
+ return {
34
+ content: [
35
+ { type: "text", text: JSON.stringify(bundle, null, 2) },
36
+ ],
37
+ };
38
+ }
39
+ catch (error) {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: `Error searching ${resourceType}: ${message}`,
46
+ },
47
+ ],
48
+ isError: true,
49
+ };
50
+ }
51
+ });
52
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@ncodeuy/medplum-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": {
8
+ "medplum-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "dependencies": {
21
+ "@medplum/core": "^5.0.15",
22
+ "@medplum/fhirtypes": "^5.0.15",
23
+ "@modelcontextprotocol/sdk": "^1.12.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^25.3.0",
27
+ "typescript": "^5.8.3",
28
+ "vitest": "^3.0.5"
29
+ }
30
+ }