@gera-services/mcp-gera-clinic 0.1.0 → 1.0.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/README.md +70 -37
- package/bin/cli.js +11 -1
- package/dist/calculators.d.ts +108 -0
- package/dist/calculators.js +227 -0
- package/dist/cqc.d.ts +102 -0
- package/dist/cqc.js +102 -0
- package/dist/data/cqc-cluster.json +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -28
- package/dist/server.d.ts +17 -0
- package/dist/server.js +248 -316
- package/llms.txt +13 -22
- package/package.json +21 -20
- package/server.json +8 -13
package/dist/server.js
CHANGED
|
@@ -1,325 +1,257 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
/**
|
|
2
|
+
* GeraClinic MCP Server (stdio)
|
|
3
|
+
*
|
|
4
|
+
* Exposes GeraClinic's real UK healthcare-provider directory (Care Quality
|
|
5
|
+
* Commission registered locations) and its non-diagnostic health calculators to
|
|
6
|
+
* AI agents over the Model Context Protocol. Everything is computed locally from
|
|
7
|
+
* a bundled snapshot of real CQC data and pure reference math — no backend, no
|
|
8
|
+
* network, no auth required — so an agent (Claude, ChatGPT with tools, any MCP
|
|
9
|
+
* client) can find a care provider, read area care statistics, and run a health
|
|
10
|
+
* calculator entirely offline.
|
|
11
|
+
*
|
|
12
|
+
* Product: GeraClinic — https://geraclinic.com (a Gera Systems product)
|
|
13
|
+
* Data: Care Quality Commission www.cqc.org.uk, Open Government Licence v3.0.
|
|
14
|
+
*/
|
|
15
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { CQC_META, CQC_AUTHORITIES, CQC_ATTRIBUTION, SERVICE_TYPES, ALL_PROVIDERS, searchProviders, findArea, } from './cqc.js';
|
|
19
|
+
import { CALCULATORS, bmi, bmrTdee, idealWeightKg, bloodPressureCategory, heartRateZones, a1c, waterIntakeLitres, waistToHeight, } from './calculators.js';
|
|
20
|
+
export const server = new McpServer({
|
|
21
|
+
name: 'gera-clinic',
|
|
22
|
+
version: '1.0.0',
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const API_BASE = process.env.GERACLINIC_API_URL || "https://api.geraclinic.com";
|
|
28
|
-
async function apiCall(path, options = {}) {
|
|
29
|
-
const res = await fetch(`${API_BASE}${path}`, {
|
|
30
|
-
...options,
|
|
31
|
-
headers: {
|
|
32
|
-
"Content-Type": "application/json",
|
|
33
|
-
"X-MCP-Client": "mcp-gera-clinic/1.0.0",
|
|
34
|
-
...options.headers
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
if (!res.ok) {
|
|
38
|
-
throw new Error(`API error ${res.status}: ${await res.text()}`);
|
|
39
|
-
}
|
|
40
|
-
return res.json();
|
|
24
|
+
function asText(payload) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
27
|
+
};
|
|
41
28
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
const CQC_DISCLAIMER = 'Directory of CQC-registered locations. CQC ratings are categorical (Outstanding/Good/Requires improvement/Inadequate), never numeric. Verify details on cqc.org.uk before relying on them. Source: Care Quality Commission, Open Government Licence v3.0.';
|
|
30
|
+
// ── Tool 1: find_care_provider ───────────────────────────────────────────────
|
|
31
|
+
// Search the real CQC dataset by name / postcode / service type / area.
|
|
32
|
+
server.registerTool('find_care_provider', {
|
|
33
|
+
title: 'Find a CQC-registered care or health provider',
|
|
34
|
+
description: "Search GeraClinic's directory of real Care Quality Commission (CQC) registered UK providers — GP surgeries, dentists, hospitals, clinics, care/nursing homes, hospices, urgent-care centres and more. Filter by name, postcode (full or outward, e.g. 'TN12'), service type (e.g. 'Dentist', 'Doctors/GPs', 'Hospital'), and/or local authority (e.g. 'kent'). Returns provider name, address, postcode, phone, website, service types and last-inspected date. All filters are AND-combined; omit any. Offline — real CQC records only.",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
query: z.string().optional().describe('Provider or organisation name substring.'),
|
|
37
|
+
postcode: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Full postcode or outward code, e.g. "TN12 6AX" or "TN12".'),
|
|
41
|
+
serviceType: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('Service type, e.g. "Dentist", "Doctors/GPs", "Hospital", "Nursing homes".'),
|
|
45
|
+
authority: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Local authority slug or name, e.g. "kent", "Essex".'),
|
|
49
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max results (default 10).'),
|
|
50
|
+
},
|
|
51
|
+
}, async ({ query, postcode, serviceType, authority, limit }) => {
|
|
52
|
+
const results = searchProviders({
|
|
53
|
+
query,
|
|
54
|
+
postcode,
|
|
55
|
+
serviceType,
|
|
56
|
+
authority,
|
|
57
|
+
limit: limit ?? 10,
|
|
58
|
+
});
|
|
59
|
+
return asText({
|
|
60
|
+
query: {
|
|
61
|
+
query: query ?? null,
|
|
62
|
+
postcode: postcode ?? null,
|
|
63
|
+
serviceType: serviceType ?? null,
|
|
64
|
+
authority: authority ?? null,
|
|
65
|
+
},
|
|
66
|
+
count: results.length,
|
|
67
|
+
providers: results.map((r) => ({
|
|
68
|
+
cqc_location_id: r.provider.cqcLocationId,
|
|
69
|
+
name: r.provider.name,
|
|
70
|
+
provider_name: r.provider.providerName,
|
|
71
|
+
address: r.provider.address,
|
|
72
|
+
postcode: r.provider.postcode,
|
|
73
|
+
phone: r.provider.phone,
|
|
74
|
+
website: r.provider.website,
|
|
75
|
+
service_types: r.provider.serviceTypes,
|
|
76
|
+
last_inspected: r.provider.lastInspected,
|
|
77
|
+
cqc_profile_url: `https://www.cqc.org.uk/location/${r.provider.cqcLocationId}`,
|
|
78
|
+
authority: r.authorityName,
|
|
79
|
+
region: r.region,
|
|
80
|
+
})),
|
|
81
|
+
disclaimer: CQC_DISCLAIMER,
|
|
82
|
+
});
|
|
45
83
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
84
|
-
}
|
|
85
|
-
);
|
|
86
|
-
server.tool(
|
|
87
|
-
"get_available_slots",
|
|
88
|
-
"Get available appointment time slots for a specific doctor on a given date. Returns a list of open slots with start/end times.",
|
|
89
|
-
{
|
|
90
|
-
doctor_id: import_zod.z.string().uuid().describe("Doctor UUID"),
|
|
91
|
-
date: import_zod.z.string().describe("Date in ISO format YYYY-MM-DD"),
|
|
92
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code")
|
|
93
|
-
},
|
|
94
|
-
async (params) => {
|
|
95
|
-
const headers = {};
|
|
96
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
97
|
-
const result = await apiCall(`/doctors/${params.doctor_id}/slots?date=${params.date}`, { headers });
|
|
98
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
99
|
-
}
|
|
100
|
-
);
|
|
101
|
-
server.tool(
|
|
102
|
-
"list_specialties",
|
|
103
|
-
"List all medical specialties available in a given country. Useful for discovering what types of doctors are available before searching.",
|
|
104
|
-
{
|
|
105
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code")
|
|
106
|
-
},
|
|
107
|
-
async (params) => {
|
|
108
|
-
const headers = {};
|
|
109
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
110
|
-
const result = await apiCall("/doctors/specialties", { headers });
|
|
111
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
112
|
-
}
|
|
113
|
-
);
|
|
114
|
-
server.tool(
|
|
115
|
-
"get_featured_doctors",
|
|
116
|
-
"Get top-rated, verified doctors. Useful for recommending the best available doctors to users.",
|
|
117
|
-
{
|
|
118
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
|
|
119
|
-
limit: import_zod.z.number().int().min(1).max(20).optional().describe("Number of featured doctors to return")
|
|
120
|
-
},
|
|
121
|
-
async (params) => {
|
|
122
|
-
const headers = {};
|
|
123
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
124
|
-
const query = params.limit ? `?limit=${params.limit}` : "";
|
|
125
|
-
const result = await apiCall(`/doctors/featured${query}`, { headers });
|
|
126
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
127
|
-
}
|
|
128
|
-
);
|
|
129
|
-
server.tool(
|
|
130
|
-
"get_health_services",
|
|
131
|
-
"Get all health service types available in a specific country on GeraClinic. Returns services such as telemedicine video consultations, in-person appointments, pharmacy delivery, lab test booking, and home nursing.",
|
|
132
|
-
{
|
|
133
|
-
country: import_zod.z.string().describe('ISO 3166-1 alpha-2 country code (e.g., "GB", "US", "GE", "KE")')
|
|
134
|
-
},
|
|
135
|
-
async (params) => {
|
|
136
|
-
const headers = {
|
|
137
|
-
"X-Tenant-ID": params.country
|
|
138
|
-
};
|
|
139
|
-
const result = await apiCall("/health-services", { headers });
|
|
140
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
141
|
-
}
|
|
142
|
-
);
|
|
143
|
-
server.tool(
|
|
144
|
-
"book_appointment",
|
|
145
|
-
"Book a medical appointment with a doctor. Requires authentication. Returns booking confirmation with payment URL if applicable.",
|
|
146
|
-
{
|
|
147
|
-
doctor_id: import_zod.z.string().uuid().describe("Doctor UUID"),
|
|
148
|
-
date: import_zod.z.string().describe("Appointment date in ISO format YYYY-MM-DD"),
|
|
149
|
-
start_time: import_zod.z.string().describe("Start time in HH:MM format"),
|
|
150
|
-
end_time: import_zod.z.string().describe("End time in HH:MM format"),
|
|
151
|
-
appointment_type: import_zod.z.enum(["VIDEO", "IN_PERSON", "CHAT"]).describe("Type of consultation"),
|
|
152
|
-
reason: import_zod.z.string().optional().describe("Reason for visit / symptoms"),
|
|
153
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
|
|
154
|
-
auth_token: import_zod.z.string().describe("User bearer token for authentication")
|
|
155
|
-
},
|
|
156
|
-
async (params) => {
|
|
157
|
-
const headers = {
|
|
158
|
-
Authorization: `Bearer ${params.auth_token}`
|
|
159
|
-
};
|
|
160
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
161
|
-
const result = await apiCall("/appointments", {
|
|
162
|
-
method: "POST",
|
|
163
|
-
headers,
|
|
164
|
-
body: JSON.stringify({
|
|
165
|
-
doctor_id: params.doctor_id,
|
|
166
|
-
date: params.date,
|
|
167
|
-
start_time: params.start_time,
|
|
168
|
-
end_time: params.end_time,
|
|
169
|
-
type: params.appointment_type,
|
|
170
|
-
reason: params.reason
|
|
171
|
-
})
|
|
84
|
+
// ── Tool 2: get_cqc_area_stats ───────────────────────────────────────────────
|
|
85
|
+
// Aggregated, real CQC statistics for an authority or locality.
|
|
86
|
+
server.registerTool('get_cqc_area_stats', {
|
|
87
|
+
title: 'Get CQC care statistics for a UK area',
|
|
88
|
+
description: "Return GeraClinic's aggregated Care Quality Commission statistics for a UK local authority or locality (e.g. 'kent', 'essex'). Includes total registered providers, how many publish a phone number / website, the breakdown by service type (GPs, dentists, care homes, hospitals, etc.) with counts and percentages, the most common service type, and the latest inspection date in the area. Use list_care_authorities first to discover valid area names. Real CQC data, offline.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
area: z
|
|
91
|
+
.string()
|
|
92
|
+
.describe('Authority or locality slug/name, e.g. "kent", "essex", "surrey".'),
|
|
93
|
+
},
|
|
94
|
+
}, async ({ area }) => {
|
|
95
|
+
const found = findArea(area);
|
|
96
|
+
if (!found) {
|
|
97
|
+
return asText({
|
|
98
|
+
error: `No CQC area found for "${area}". Call list_care_authorities to see valid areas.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const a = found.area;
|
|
102
|
+
return asText({
|
|
103
|
+
area: 'name' in a ? a.name : a.slug,
|
|
104
|
+
kind: found.kind,
|
|
105
|
+
authority: found.authorityName,
|
|
106
|
+
region: 'region' in a ? a.region : null,
|
|
107
|
+
stats: {
|
|
108
|
+
total_providers: a.stats.total,
|
|
109
|
+
with_phone: a.stats.withPhone,
|
|
110
|
+
with_phone_pct: a.stats.withPhonePct,
|
|
111
|
+
with_website: a.stats.withWebsite,
|
|
112
|
+
top_service_type: a.stats.topServiceType,
|
|
113
|
+
latest_inspection: a.stats.latestCheck,
|
|
114
|
+
service_type_breakdown: a.stats.serviceTypes.map((s) => ({
|
|
115
|
+
type: s.type,
|
|
116
|
+
count: s.count,
|
|
117
|
+
pct: s.pct,
|
|
118
|
+
})),
|
|
119
|
+
},
|
|
120
|
+
disclaimer: CQC_DISCLAIMER,
|
|
172
121
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
server.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"cancel_appointment",
|
|
205
|
-
"Cancel an existing appointment. Requires authentication. Returns cancellation confirmation and refund details if applicable. Must be called at least 1 hour before the scheduled appointment time.",
|
|
206
|
-
{
|
|
207
|
-
appointment_id: import_zod.z.string().uuid().describe("Appointment UUID to cancel"),
|
|
208
|
-
reason: import_zod.z.string().optional().describe("Reason for cancellation (helps improve the platform)"),
|
|
209
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
|
|
210
|
-
auth_token: import_zod.z.string().describe("User bearer token for authentication")
|
|
211
|
-
},
|
|
212
|
-
async (params) => {
|
|
213
|
-
const headers = {
|
|
214
|
-
Authorization: `Bearer ${params.auth_token}`
|
|
215
|
-
};
|
|
216
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
217
|
-
const result = await apiCall(`/appointments/${params.appointment_id}/cancel`, {
|
|
218
|
-
method: "POST",
|
|
219
|
-
headers,
|
|
220
|
-
body: JSON.stringify({ reason: params.reason })
|
|
122
|
+
});
|
|
123
|
+
// ── Tool 3: list_care_authorities ────────────────────────────────────────────
|
|
124
|
+
// Discover the areas available, optionally filtered by region.
|
|
125
|
+
server.registerTool('list_care_authorities', {
|
|
126
|
+
title: 'List UK areas covered by the CQC directory',
|
|
127
|
+
description: "List the UK local authorities GeraClinic's CQC directory covers, with the number of registered providers and the top service type in each. Optionally filter by region (e.g. 'London', 'South East', 'North West'). Use this to find a valid `area` value for get_cqc_area_stats or an `authority` for find_care_provider.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
region: z.string().optional().describe('Filter by region, e.g. "London", "South East".'),
|
|
130
|
+
},
|
|
131
|
+
}, async ({ region }) => {
|
|
132
|
+
const rNorm = region ? region.toLowerCase().trim() : null;
|
|
133
|
+
const authorities = CQC_AUTHORITIES.filter((a) => !rNorm || (a.region ?? '').toLowerCase() === rNorm).map((a) => ({
|
|
134
|
+
slug: a.slug,
|
|
135
|
+
name: a.name,
|
|
136
|
+
region: a.region,
|
|
137
|
+
total_providers: a.stats.total,
|
|
138
|
+
top_service_type: a.stats.topServiceType,
|
|
139
|
+
localities: a.localities.length,
|
|
140
|
+
}));
|
|
141
|
+
return asText({
|
|
142
|
+
dataset: {
|
|
143
|
+
total_providers_indexed: CQC_META.totalProviders,
|
|
144
|
+
total_localities: CQC_META.totalLocalities,
|
|
145
|
+
generated_at: CQC_META.generatedAt,
|
|
146
|
+
attribution: CQC_ATTRIBUTION,
|
|
147
|
+
attribution_url: CQC_META.attributionUrl,
|
|
148
|
+
},
|
|
149
|
+
region_filter: region ?? null,
|
|
150
|
+
count: authorities.length,
|
|
151
|
+
service_types_available: SERVICE_TYPES,
|
|
152
|
+
authorities,
|
|
221
153
|
});
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
154
|
+
});
|
|
155
|
+
// ── Tool 4: list_health_calculators ──────────────────────────────────────────
|
|
156
|
+
server.registerTool('list_health_calculators', {
|
|
157
|
+
title: 'List GeraClinic health calculators',
|
|
158
|
+
description: 'List the health calculators available via run_health_calculator — BMI, BMR/TDEE calories, ideal weight, blood-pressure category, heart-rate zones, A1C↔glucose, water intake, and waist-to-height ratio — each with its id, required inputs, and the public reference standard it uses. All are objective and non-diagnostic.',
|
|
159
|
+
inputSchema: {},
|
|
160
|
+
}, async () => asText({
|
|
161
|
+
count: CALCULATORS.length,
|
|
162
|
+
calculators: CALCULATORS,
|
|
163
|
+
note: 'Objective reference math only — educational, not a diagnosis or prescription.',
|
|
164
|
+
}));
|
|
165
|
+
// ── Tool 5: run_health_calculator ────────────────────────────────────────────
|
|
166
|
+
server.registerTool('run_health_calculator', {
|
|
167
|
+
title: 'Run a GeraClinic health calculator',
|
|
168
|
+
description: "Run one of GeraClinic's non-diagnostic health calculators and return the result. Pick `calculator` (see list_health_calculators) and pass the metric inputs it needs (heights in cm, weights in kg). Examples: bmi {weightKg, heightCm}; bmr_tdee {weightKg, heightCm, ageYears, sex, activity}; ideal_weight {heightCm, sex}; blood_pressure {systolic, diastolic}; heart_rate_zones {ageYears}; a1c {a1cPercent} or {avgGlucoseMgDl}; water_intake {weightKg, activityMinutes?}; waist_to_height {waistCm, heightCm}. Results are educational reference values, not medical advice.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
calculator: z
|
|
171
|
+
.enum([
|
|
172
|
+
'bmi',
|
|
173
|
+
'bmr_tdee',
|
|
174
|
+
'ideal_weight',
|
|
175
|
+
'blood_pressure',
|
|
176
|
+
'heart_rate_zones',
|
|
177
|
+
'a1c',
|
|
178
|
+
'water_intake',
|
|
179
|
+
'waist_to_height',
|
|
180
|
+
])
|
|
181
|
+
.describe('Which calculator to run.'),
|
|
182
|
+
weightKg: z.number().positive().optional(),
|
|
183
|
+
heightCm: z.number().positive().optional(),
|
|
184
|
+
ageYears: z.number().int().positive().optional(),
|
|
185
|
+
sex: z.enum(['male', 'female']).optional(),
|
|
186
|
+
activity: z.enum(['sedentary', 'light', 'moderate', 'active', 'very_active']).optional(),
|
|
187
|
+
systolic: z.number().positive().optional(),
|
|
188
|
+
diastolic: z.number().positive().optional(),
|
|
189
|
+
a1cPercent: z.number().positive().optional(),
|
|
190
|
+
avgGlucoseMgDl: z.number().positive().optional(),
|
|
191
|
+
activityMinutes: z.number().min(0).optional(),
|
|
192
|
+
waistCm: z.number().positive().optional(),
|
|
193
|
+
},
|
|
194
|
+
}, async (args) => {
|
|
195
|
+
const need = (cond, msg) => {
|
|
196
|
+
if (!cond)
|
|
197
|
+
throw new Error(msg);
|
|
241
198
|
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
199
|
+
let result;
|
|
200
|
+
switch (args.calculator) {
|
|
201
|
+
case 'bmi':
|
|
202
|
+
need(args.weightKg != null && args.heightCm != null, 'bmi requires weightKg and heightCm');
|
|
203
|
+
result = bmi(args.weightKg, args.heightCm);
|
|
204
|
+
break;
|
|
205
|
+
case 'bmr_tdee':
|
|
206
|
+
need(args.weightKg != null &&
|
|
207
|
+
args.heightCm != null &&
|
|
208
|
+
args.ageYears != null &&
|
|
209
|
+
args.sex != null &&
|
|
210
|
+
args.activity != null, 'bmr_tdee requires weightKg, heightCm, ageYears, sex, activity');
|
|
211
|
+
result = bmrTdee(args.weightKg, args.heightCm, args.ageYears, args.sex, args.activity);
|
|
212
|
+
break;
|
|
213
|
+
case 'ideal_weight':
|
|
214
|
+
need(args.heightCm != null && args.sex != null, 'ideal_weight requires heightCm and sex');
|
|
215
|
+
result = idealWeightKg(args.heightCm, args.sex);
|
|
216
|
+
break;
|
|
217
|
+
case 'blood_pressure':
|
|
218
|
+
need(args.systolic != null && args.diastolic != null, 'blood_pressure requires systolic and diastolic');
|
|
219
|
+
result = bloodPressureCategory(args.systolic, args.diastolic);
|
|
220
|
+
break;
|
|
221
|
+
case 'heart_rate_zones':
|
|
222
|
+
need(args.ageYears != null, 'heart_rate_zones requires ageYears');
|
|
223
|
+
result = heartRateZones(args.ageYears);
|
|
224
|
+
break;
|
|
225
|
+
case 'a1c':
|
|
226
|
+
need(args.a1cPercent != null || args.avgGlucoseMgDl != null, 'a1c requires a1cPercent or avgGlucoseMgDl');
|
|
227
|
+
result = a1c(args.a1cPercent, args.avgGlucoseMgDl);
|
|
228
|
+
break;
|
|
229
|
+
case 'water_intake':
|
|
230
|
+
need(args.weightKg != null, 'water_intake requires weightKg');
|
|
231
|
+
result = waterIntakeLitres(args.weightKg, args.activityMinutes ?? 0);
|
|
232
|
+
break;
|
|
233
|
+
case 'waist_to_height':
|
|
234
|
+
need(args.waistCm != null && args.heightCm != null, 'waist_to_height requires waistCm and heightCm');
|
|
235
|
+
result = waistToHeight(args.waistCm, args.heightCm);
|
|
236
|
+
break;
|
|
246
237
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (params.page) query.set("page", String(params.page));
|
|
252
|
-
if (params.limit) query.set("limit", String(params.limit));
|
|
253
|
-
const result = await apiCall(`/lab-results?${query}`, { headers });
|
|
254
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
255
|
-
}
|
|
256
|
-
);
|
|
257
|
-
server.tool(
|
|
258
|
-
"order_medication",
|
|
259
|
-
"Order medication from a pharmacy via GeraClinic. Can be linked to a doctor's prescription or ordered as OTC (over-the-counter). Requires authentication. Returns order confirmation with estimated delivery time and pharmacy details.",
|
|
260
|
-
{
|
|
261
|
-
prescription_id: import_zod.z.string().uuid().optional().describe("Prescription UUID from a GeraClinic doctor \u2014 required for prescription-only medications"),
|
|
262
|
-
items: import_zod.z.array(import_zod.z.object({
|
|
263
|
-
medication_name: import_zod.z.string().describe('Medication name (e.g., "Paracetamol 500mg", "Amoxicillin 250mg")'),
|
|
264
|
-
quantity: import_zod.z.number().int().positive().describe("Number of units/packs to order"),
|
|
265
|
-
notes: import_zod.z.string().optional().describe('Additional instructions (e.g., "brand preferred: X")')
|
|
266
|
-
})).min(1).describe("List of medications to order"),
|
|
267
|
-
delivery_address: import_zod.z.string().describe("Full delivery address for the medication"),
|
|
268
|
-
pharmacy_id: import_zod.z.string().uuid().optional().describe("Preferred pharmacy UUID. If omitted, the nearest available pharmacy is selected automatically."),
|
|
269
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
|
|
270
|
-
auth_token: import_zod.z.string().describe("User bearer token for authentication")
|
|
271
|
-
},
|
|
272
|
-
async (params) => {
|
|
273
|
-
const headers = {
|
|
274
|
-
Authorization: `Bearer ${params.auth_token}`
|
|
275
|
-
};
|
|
276
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
277
|
-
const result = await apiCall("/pharmacy/orders", {
|
|
278
|
-
method: "POST",
|
|
279
|
-
headers,
|
|
280
|
-
body: JSON.stringify({
|
|
281
|
-
prescription_id: params.prescription_id,
|
|
282
|
-
items: params.items,
|
|
283
|
-
delivery_address: params.delivery_address,
|
|
284
|
-
pharmacy_id: params.pharmacy_id
|
|
285
|
-
})
|
|
238
|
+
return asText({
|
|
239
|
+
calculator: args.calculator,
|
|
240
|
+
result,
|
|
241
|
+
disclaimer: 'Educational reference value only — not a medical diagnosis, treatment, or prescription. Consult a clinician for personal advice.',
|
|
286
242
|
});
|
|
287
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
288
|
-
}
|
|
289
|
-
);
|
|
290
|
-
server.tool(
|
|
291
|
-
"search_pharmacies",
|
|
292
|
-
"Find pharmacies near a location that deliver medications via GeraClinic. Returns pharmacies with their operating hours, delivery radius, and available payment methods.",
|
|
293
|
-
{
|
|
294
|
-
lat: import_zod.z.number().optional().describe("Latitude for proximity search"),
|
|
295
|
-
lng: import_zod.z.number().optional().describe("Longitude for proximity search"),
|
|
296
|
-
city: import_zod.z.string().optional().describe("City name to search within"),
|
|
297
|
-
country: import_zod.z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
|
|
298
|
-
open_now: import_zod.z.boolean().optional().describe("Filter to only show pharmacies currently open"),
|
|
299
|
-
page: import_zod.z.number().int().positive().optional().describe("Page number"),
|
|
300
|
-
limit: import_zod.z.number().int().min(1).max(50).optional().describe("Results per page (max 50)")
|
|
301
|
-
},
|
|
302
|
-
async (params) => {
|
|
303
|
-
const headers = {};
|
|
304
|
-
if (params.country) headers["X-Tenant-ID"] = params.country;
|
|
305
|
-
const query = new URLSearchParams();
|
|
306
|
-
if (params.lat !== void 0) query.set("lat", String(params.lat));
|
|
307
|
-
if (params.lng !== void 0) query.set("lng", String(params.lng));
|
|
308
|
-
if (params.city) query.set("city", params.city);
|
|
309
|
-
if (params.open_now !== void 0) query.set("open_now", String(params.open_now));
|
|
310
|
-
if (params.page) query.set("page", String(params.page));
|
|
311
|
-
if (params.limit) query.set("limit", String(params.limit));
|
|
312
|
-
const result = await apiCall(`/pharmacies?${query}`, { headers });
|
|
313
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
314
|
-
}
|
|
315
|
-
);
|
|
316
|
-
async function main() {
|
|
317
|
-
const transport = new import_stdio.StdioServerTransport();
|
|
318
|
-
await server.connect(transport);
|
|
319
|
-
console.error("GeraClinic MCP server running on stdio");
|
|
320
|
-
}
|
|
321
|
-
main().catch(console.error);
|
|
322
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
323
|
-
0 && (module.exports = {
|
|
324
|
-
server
|
|
325
243
|
});
|
|
244
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
245
|
+
export async function main() {
|
|
246
|
+
const transport = new StdioServerTransport();
|
|
247
|
+
await server.connect(transport);
|
|
248
|
+
// stderr only — stdout is the MCP transport.
|
|
249
|
+
console.error(`GeraClinic MCP server running on stdio (gera-clinic v1.0.0) — ${ALL_PROVIDERS.length} CQC providers indexed`);
|
|
250
|
+
}
|
|
251
|
+
const isMain = typeof process !== 'undefined' && process.argv[1] && /server\.js$/.test(process.argv[1]);
|
|
252
|
+
if (isMain) {
|
|
253
|
+
main().catch((err) => {
|
|
254
|
+
console.error('Fatal:', err);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
});
|
|
257
|
+
}
|