@tkawen/ambulancedz-mcp 1.1.0 → 2.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 +91 -13
- package/package.json +6 -3
- package/src/index.js +397 -84
package/README.md
CHANGED
|
@@ -1,32 +1,90 @@
|
|
|
1
1
|
# @tkawen/ambulancedz-mcp
|
|
2
2
|
|
|
3
|
-
MCP server for **[
|
|
4
|
-
|
|
5
|
-
Claude Code,
|
|
6
|
-
|
|
3
|
+
MCP server for **[ambulancedz.com](https://ambulancedz.com)** — Algeria's ambulance
|
|
4
|
+
and medical-transport marketplace across all 69 wilayas. It lets any MCP client
|
|
5
|
+
(Claude Desktop, Claude Code, …) find and vet ambulance drivers, locate the
|
|
6
|
+
nearest available one by GPS, read a driver's reviews, estimate a trip's cost,
|
|
7
|
+
check coverage, and set up recurring dialysis transport.
|
|
7
8
|
|
|
8
|
-
##
|
|
9
|
+
## ⚠️ Read this first
|
|
10
|
+
|
|
11
|
+
**ambulancedz is a directory of private ambulance operators. It is not a
|
|
12
|
+
government ambulance service and not an emergency dispatch centre.** It does not
|
|
13
|
+
send anyone, and it cannot guarantee availability or response time.
|
|
14
|
+
|
|
15
|
+
In a life-threatening emergency in Algeria, call these first:
|
|
16
|
+
|
|
17
|
+
| Number | Service |
|
|
18
|
+
|--------|---------|
|
|
19
|
+
| **14** | الحماية المدنية — Civil Protection |
|
|
20
|
+
| **16** | الاستعجالات الطبية — SAMU (medical) |
|
|
21
|
+
| **17** | الأمن الوطني — National Police |
|
|
22
|
+
|
|
23
|
+
Every tool that returns drivers also returns a `safety` object carrying these
|
|
24
|
+
numbers, and `emergency_numbers` answers from a built-in constant even if the
|
|
25
|
+
API is unreachable. If you extend this server, keep that property.
|
|
26
|
+
|
|
27
|
+
## Tools (12)
|
|
9
28
|
|
|
10
29
|
| Tool | Description |
|
|
11
30
|
|------|-------------|
|
|
12
31
|
| `list_wilayas` | All 69 wilayas with driver counts + how many are available now |
|
|
13
32
|
| `list_communes` | Communes of a wilaya (by id) |
|
|
14
|
-
| `search_ambulances` | Search drivers by wilaya / commune / service type / status
|
|
15
|
-
| `find_nearest_ambulance` | Nearest **available** ambulances to a GPS point, with distance + ETA |
|
|
16
|
-
| `get_ambulance` | Full details of one driver
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
33
|
+
| `search_ambulances` | Search drivers by wilaya / commune / service type / status / **verified**, with sorting |
|
|
34
|
+
| `find_nearest_ambulance` | Nearest **available** ambulances to a GPS point, with real distance + ETA |
|
|
35
|
+
| `get_ambulance` | Full details of one driver: phone, WhatsApp, plate, coordinates, gallery, member-since |
|
|
36
|
+
| `get_ambulance_reviews` | A driver's reviews **plus the star distribution**, so a rating can be reconciled against its evidence |
|
|
37
|
+
| `estimate_price` | Indicative DZD cost **range** for a trip between two wilayas |
|
|
38
|
+
| `platform_stats` | Drivers / verified / available / coverage / average rating |
|
|
39
|
+
| `coverage_report` | Per-wilaya coverage **including the wilayas with no drivers at all** |
|
|
40
|
+
| `emergency_numbers` | Algeria's national emergency numbers and what each is for |
|
|
41
|
+
| `driver_join_info` | For ambulance owners: trial length, plans and prices, payment methods, verification documents |
|
|
42
|
+
| `request_recurring_booking` | ✍️ Register a recurring (dialysis) transport request — creates a **pending** booking |
|
|
43
|
+
|
|
44
|
+
All tools are read-only except `request_recurring_booking`, and they are
|
|
45
|
+
annotated as such (`readOnlyHint`) so clients can treat them accordingly.
|
|
46
|
+
|
|
47
|
+
### Resources
|
|
19
48
|
|
|
20
|
-
|
|
49
|
+
| URI | Contents |
|
|
50
|
+
|-----|----------|
|
|
51
|
+
| `ambulancedz://safety` | What the platform is and is not, plus the emergency numbers |
|
|
52
|
+
| `ambulancedz://coverage` | Live per-wilaya driver coverage |
|
|
53
|
+
|
|
54
|
+
### Prompts
|
|
55
|
+
|
|
56
|
+
| Prompt | Purpose |
|
|
57
|
+
|--------|---------|
|
|
58
|
+
| `find_ambulance` | Guided, safety-first flow for finding an ambulance |
|
|
59
|
+
| `plan_dialysis_transport` | Guided flow for setting up recurring dialysis transport |
|
|
60
|
+
|
|
61
|
+
## Honesty guarantees
|
|
62
|
+
|
|
63
|
+
These are enforced by the API this server reads, and are worth preserving in any
|
|
64
|
+
client that presents the data:
|
|
65
|
+
|
|
66
|
+
- **A rating with no reviews behind it is returned as `null`**, never as a score.
|
|
67
|
+
`get_ambulance_reviews` returns a star breakdown that always sums to the
|
|
68
|
+
reported total.
|
|
69
|
+
- **Prices are ranges, not quotes.** The platform is not party to the agreement
|
|
70
|
+
between patient and driver, and takes no commission on trips.
|
|
71
|
+
- **«موثّق» (verified) means a human reviewed the driver's licence, vehicle
|
|
72
|
+
registration and vehicle photo.** Drivers who are not verified are reported as
|
|
73
|
+
not verified rather than being quietly presented the same way.
|
|
74
|
+
- **Availability is whatever the driver last set** — `status` and
|
|
75
|
+
`last_active_at` are reported as-is, with no invented green dots.
|
|
21
76
|
|
|
22
77
|
## Install & run
|
|
23
78
|
|
|
24
79
|
```bash
|
|
25
80
|
npm install
|
|
26
81
|
node src/index.js # stdio MCP server
|
|
27
|
-
npm test # smoke test against the live API
|
|
82
|
+
npm test # full smoke test against the live API
|
|
28
83
|
```
|
|
29
84
|
|
|
85
|
+
The smoke test creates one pending recurring booking to exercise the write path
|
|
86
|
+
and prints its id — delete it from the admin panel afterwards.
|
|
87
|
+
|
|
30
88
|
## Configure
|
|
31
89
|
|
|
32
90
|
| Env | Default | Purpose |
|
|
@@ -37,6 +95,19 @@ npm test # smoke test against the live API
|
|
|
37
95
|
|
|
38
96
|
Add to your MCP config (e.g. `claude_desktop_config.json`):
|
|
39
97
|
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"ambulancedz": {
|
|
102
|
+
"command": "npx",
|
|
103
|
+
"args": ["-y", "@tkawen/ambulancedz-mcp"]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Running from a local checkout instead:
|
|
110
|
+
|
|
40
111
|
```json
|
|
41
112
|
{
|
|
42
113
|
"mcpServers": {
|
|
@@ -48,7 +119,14 @@ Add to your MCP config (e.g. `claude_desktop_config.json`):
|
|
|
48
119
|
}
|
|
49
120
|
```
|
|
50
121
|
|
|
51
|
-
|
|
122
|
+
## Underlying API
|
|
123
|
+
|
|
124
|
+
Public, read-only, no credentials: `https://ambulancedz.com/api/v1`
|
|
125
|
+
|
|
126
|
+
`/wilayas` · `/wilayas/{id}/communes` · `/ambulances` · `/ambulances/nearest` ·
|
|
127
|
+
`/ambulances/{id}` · `/ambulances/{id}/reviews` · `/price-estimate` · `/stats` ·
|
|
128
|
+
`/coverage` · `/driver-plans` · `/emergency-numbers` ·
|
|
129
|
+
`POST /recurring-bookings` (rate-limited)
|
|
52
130
|
|
|
53
131
|
## License
|
|
54
132
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tkawen/ambulancedz-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP server for
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server for ambulancedz.com — Algeria's ambulance marketplace: find and vet ambulance drivers across 69 wilayas, locate the nearest available one by GPS, read driver reviews, estimate trip cost, check coverage, and book recurring dialysis transport.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ambulancedz-mcp": "src/index.js"
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"ambulance",
|
|
25
25
|
"algeria",
|
|
26
26
|
"ambulancedz",
|
|
27
|
-
"tkawen"
|
|
27
|
+
"tkawen",
|
|
28
|
+
"dialysis",
|
|
29
|
+
"emergency",
|
|
30
|
+
"healthcare"
|
|
28
31
|
],
|
|
29
32
|
"license": "MIT",
|
|
30
33
|
"dependencies": {
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Exposes Algeria's ambulance-transport marketplace (ambulancedz.com)
|
|
6
|
-
*
|
|
7
|
-
* ambulance by GPS, estimate trip cost,
|
|
3
|
+
* ambulancedz MCP server
|
|
4
|
+
* ----------------------
|
|
5
|
+
* Exposes Algeria's ambulance-transport marketplace (ambulancedz.com) over MCP:
|
|
6
|
+
* discover wilayas/communes, search drivers, find the nearest available
|
|
7
|
+
* ambulance by GPS, read a driver's reviews, estimate trip cost, check coverage,
|
|
8
|
+
* read the driver subscription offer, and register recurring (dialysis)
|
|
9
|
+
* transport.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY — read before adding tools:
|
|
12
|
+
* ambulancedz is a directory of PRIVATE ambulance operators. It is NOT a
|
|
13
|
+
* government ambulance service and NOT an emergency dispatch centre. A client
|
|
14
|
+
* of this server may be an AI assistant acting for someone in a life-threatening
|
|
15
|
+
* situation, so every tool that returns drivers also returns Algeria's national
|
|
16
|
+
* emergency numbers and states they should be called first. Do not remove that
|
|
17
|
+
* field, and do not add a tool implying this platform dispatches anyone or
|
|
18
|
+
* guarantees a response.
|
|
8
19
|
*
|
|
9
20
|
* Talks to the public read-only JSON API at {BASE}/api/v1.
|
|
10
21
|
* Configure the target with AMBULANCEDZ_BASE_URL (default https://ambulancedz.com).
|
|
@@ -13,37 +24,51 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
13
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
25
|
import { z } from "zod";
|
|
15
26
|
|
|
16
|
-
|
|
27
|
+
/** Trim trailing slashes without a backtracking-prone regex. */
|
|
28
|
+
function trimTrailingSlashes(s) {
|
|
29
|
+
let end = s.length;
|
|
30
|
+
while (end > 0 && s[end - 1] === "/") end--;
|
|
31
|
+
return s.slice(0, end);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BASE = trimTrailingSlashes(process.env.AMBULANCEDZ_BASE_URL || "https://ambulancedz.com");
|
|
17
35
|
const API = BASE + "/api/v1";
|
|
36
|
+
const VERSION = "2.0.0";
|
|
18
37
|
|
|
19
|
-
|
|
38
|
+
/** Everything here reads public data; only request_recurring_booking writes. */
|
|
39
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
40
|
+
|
|
41
|
+
/** Attached to every response that returns drivers. */
|
|
42
|
+
const SAFETY = {
|
|
43
|
+
life_threatening_call_first: [
|
|
44
|
+
{ number: "14", ar: "الحماية المدنية" },
|
|
45
|
+
{ number: "16", ar: "الاستعجالات الطبية SAMU" },
|
|
46
|
+
{ number: "17", ar: "الأمن الوطني" },
|
|
47
|
+
],
|
|
48
|
+
notice_en:
|
|
49
|
+
"ambulancedz lists private ambulance operators; it does not dispatch them and cannot guarantee availability or response time. In a life-threatening emergency, call 14 / 16 / 17 first.",
|
|
50
|
+
notice_ar:
|
|
51
|
+
"ambulancedz دليل لسائقي إسعاف خواص — لا يرسلهم ولا يضمن التوفر أو زمن الوصول. في حالة خطر على الحياة اتصل بـ 14 / 16 / 17 أولاً.",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function request(path, { method = "GET", params, body } = {}) {
|
|
20
55
|
const url = new URL(API + path);
|
|
21
56
|
if (params) {
|
|
22
57
|
for (const [k, v] of Object.entries(params)) {
|
|
23
58
|
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
24
59
|
}
|
|
25
60
|
}
|
|
26
|
-
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
27
|
-
const text = await res.text();
|
|
28
|
-
let json;
|
|
29
|
-
try {
|
|
30
|
-
json = JSON.parse(text);
|
|
31
|
-
} catch {
|
|
32
|
-
json = { raw: text.slice(0, 500) };
|
|
33
|
-
}
|
|
34
|
-
if (!res.ok) {
|
|
35
|
-
const msg = json?.error || json?.message || JSON.stringify(json);
|
|
36
|
-
throw new Error(`HTTP ${res.status}: ${msg}`);
|
|
37
|
-
}
|
|
38
|
-
return json;
|
|
39
|
-
}
|
|
40
61
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
const res = await fetch(url, {
|
|
63
|
+
method,
|
|
64
|
+
headers: {
|
|
65
|
+
Accept: "application/json",
|
|
66
|
+
"User-Agent": `ambulancedz-mcp/${VERSION}`,
|
|
67
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
68
|
+
},
|
|
69
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
46
70
|
});
|
|
71
|
+
|
|
47
72
|
const text = await res.text();
|
|
48
73
|
let json;
|
|
49
74
|
try {
|
|
@@ -51,118 +76,406 @@ async function apiPost(path, body) {
|
|
|
51
76
|
} catch {
|
|
52
77
|
json = { raw: text.slice(0, 500) };
|
|
53
78
|
}
|
|
79
|
+
|
|
54
80
|
if (!res.ok) {
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
// Surface Laravel validation errors usefully instead of "[object Object]".
|
|
82
|
+
const detail =
|
|
83
|
+
json?.error ||
|
|
84
|
+
json?.message ||
|
|
85
|
+
(json?.errors ? Object.values(json.errors).flat().join("; ") : null) ||
|
|
86
|
+
JSON.stringify(json).slice(0, 300);
|
|
87
|
+
throw new Error(`HTTP ${res.status}: ${detail}`);
|
|
57
88
|
}
|
|
58
89
|
return json;
|
|
59
90
|
}
|
|
60
91
|
|
|
92
|
+
const api = (path, params) => request(path, { params });
|
|
93
|
+
const apiPost = (path, body) => request(path, { method: "POST", body });
|
|
94
|
+
|
|
61
95
|
const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
|
|
62
|
-
const
|
|
96
|
+
const okDrivers = (data) => ok({ ...data, safety: SAFETY });
|
|
97
|
+
const fail = (e) => ({
|
|
98
|
+
content: [{ type: "text", text: "Error: " + (e?.message || String(e)) }],
|
|
99
|
+
isError: true,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const server = new McpServer({ name: "ambulancedz", version: VERSION });
|
|
63
103
|
|
|
64
|
-
|
|
104
|
+
/* ─────────────────────────── Discovery ─────────────────────────── */
|
|
65
105
|
|
|
66
|
-
server.
|
|
106
|
+
server.registerTool(
|
|
67
107
|
"list_wilayas",
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
{
|
|
109
|
+
title: "List Algerian wilayas",
|
|
110
|
+
description:
|
|
111
|
+
"List all 69 Algerian wilayas (provinces) with how many ambulance drivers are listed and how many are available right now in each. Use this to resolve a wilaya name to the id other tools need.",
|
|
112
|
+
inputSchema: {},
|
|
113
|
+
annotations: READ_ONLY,
|
|
114
|
+
},
|
|
70
115
|
async () => {
|
|
71
|
-
try {
|
|
116
|
+
try {
|
|
117
|
+
return ok(await api("/wilayas"));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return fail(e);
|
|
120
|
+
}
|
|
72
121
|
}
|
|
73
122
|
);
|
|
74
123
|
|
|
75
|
-
server.
|
|
124
|
+
server.registerTool(
|
|
76
125
|
"list_communes",
|
|
77
|
-
|
|
78
|
-
|
|
126
|
+
{
|
|
127
|
+
title: "List communes of a wilaya",
|
|
128
|
+
description: "List the communes (municipalities) of a wilaya, by the wilaya id from list_wilayas.",
|
|
129
|
+
inputSchema: { wilaya_id: z.number().int().describe("Wilaya id") },
|
|
130
|
+
annotations: READ_ONLY,
|
|
131
|
+
},
|
|
79
132
|
async ({ wilaya_id }) => {
|
|
80
|
-
try {
|
|
133
|
+
try {
|
|
134
|
+
return ok(await api(`/wilayas/${wilaya_id}/communes`));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return fail(e);
|
|
137
|
+
}
|
|
81
138
|
}
|
|
82
139
|
);
|
|
83
140
|
|
|
84
|
-
|
|
141
|
+
/* ─────────────────────────── Finding a driver ─────────────────────────── */
|
|
142
|
+
|
|
143
|
+
server.registerTool(
|
|
85
144
|
"search_ambulances",
|
|
86
|
-
"Search listed ambulance drivers, optionally filtered by wilaya, commune, service type or availability. Verified drivers and higher ratings come first.",
|
|
87
145
|
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
146
|
+
title: "Search ambulance drivers",
|
|
147
|
+
description:
|
|
148
|
+
"Search listed ambulance drivers, filtered by wilaya, commune, service type, availability or verification. Returns phone, licence plate, status, rating and profile URL. Available drivers come first, then verified, then best-rated.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
wilaya: z.number().int().optional().describe("Wilaya id (from list_wilayas)"),
|
|
151
|
+
commune: z.number().int().optional().describe("Commune id (from list_communes)"),
|
|
152
|
+
service_type: z
|
|
153
|
+
.enum(["emergency", "transport", "inter_wilaya"])
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("emergency = urgent case, transport = medical transfer, inter_wilaya = between provinces"),
|
|
156
|
+
status: z.enum(["available", "busy", "offline"]).optional(),
|
|
157
|
+
verified: z.boolean().optional().describe("Only drivers whose documents were manually reviewed"),
|
|
158
|
+
sort: z.enum(["best", "rating", "reviews", "newest"]).optional().describe("Default: best (available first)"),
|
|
159
|
+
per_page: z.number().int().min(1).max(50).optional().describe("Results per page (max 50)"),
|
|
160
|
+
},
|
|
161
|
+
annotations: READ_ONLY,
|
|
93
162
|
},
|
|
94
163
|
async (args) => {
|
|
95
|
-
try {
|
|
164
|
+
try {
|
|
165
|
+
return okDrivers(await api("/ambulances", { ...args, verified: args.verified ? 1 : undefined }));
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return fail(e);
|
|
168
|
+
}
|
|
96
169
|
}
|
|
97
170
|
);
|
|
98
171
|
|
|
99
|
-
server.
|
|
172
|
+
server.registerTool(
|
|
100
173
|
"find_nearest_ambulance",
|
|
101
|
-
"Find the nearest AVAILABLE ambulances to a GPS location inside Algeria, sorted by distance, with a rough ETA in minutes.",
|
|
102
174
|
{
|
|
103
|
-
|
|
104
|
-
|
|
175
|
+
title: "Find nearest available ambulance",
|
|
176
|
+
description:
|
|
177
|
+
"Find the nearest AVAILABLE ambulances to a GPS point inside Algeria, sorted by real distance, with a rough ETA in minutes. Use when you have the patient's coordinates; otherwise use search_ambulances by wilaya.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
lat: z.number().describe("Latitude — must be inside Algeria (~18..38)"),
|
|
180
|
+
lng: z.number().describe("Longitude — must be inside Algeria (~-9..12)"),
|
|
181
|
+
},
|
|
182
|
+
annotations: READ_ONLY,
|
|
105
183
|
},
|
|
106
184
|
async ({ lat, lng }) => {
|
|
107
|
-
try {
|
|
185
|
+
try {
|
|
186
|
+
return okDrivers(await api("/ambulances/nearest", { lat, lng }));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return fail(e);
|
|
189
|
+
}
|
|
108
190
|
}
|
|
109
191
|
);
|
|
110
192
|
|
|
111
|
-
server.
|
|
193
|
+
server.registerTool(
|
|
112
194
|
"get_ambulance",
|
|
113
|
-
|
|
114
|
-
|
|
195
|
+
{
|
|
196
|
+
title: "Get driver details",
|
|
197
|
+
description:
|
|
198
|
+
"Get full details of one driver by id: phone, WhatsApp, licence plate, exact coordinates, verification date, vehicle photo gallery, rating and member-since date.",
|
|
199
|
+
inputSchema: { id: z.number().int().describe("Ambulance id") },
|
|
200
|
+
annotations: READ_ONLY,
|
|
201
|
+
},
|
|
115
202
|
async ({ id }) => {
|
|
116
|
-
try {
|
|
203
|
+
try {
|
|
204
|
+
return okDrivers(await api(`/ambulances/${id}`));
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return fail(e);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
server.registerTool(
|
|
212
|
+
"get_ambulance_reviews",
|
|
213
|
+
{
|
|
214
|
+
title: "Read a driver's reviews",
|
|
215
|
+
description:
|
|
216
|
+
"Read the reviews of one driver, with the star distribution (how many 5-star, 4-star …). The headline rating is computed from these rows, so it can always be reconciled against its evidence. A driver with no reviews returns rating null rather than a fabricated score.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
id: z.number().int().describe("Ambulance id"),
|
|
219
|
+
per_page: z.number().int().min(1).max(50).optional().describe("Reviews per page (max 50, default 10)"),
|
|
220
|
+
},
|
|
221
|
+
annotations: READ_ONLY,
|
|
222
|
+
},
|
|
223
|
+
async ({ id, per_page }) => {
|
|
224
|
+
try {
|
|
225
|
+
return ok(await api(`/ambulances/${id}/reviews`, { per_page }));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return fail(e);
|
|
228
|
+
}
|
|
117
229
|
}
|
|
118
230
|
);
|
|
119
231
|
|
|
120
|
-
|
|
232
|
+
/* ─────────────────────────── Cost ─────────────────────────── */
|
|
233
|
+
|
|
234
|
+
server.registerTool(
|
|
121
235
|
"estimate_price",
|
|
122
|
-
"Estimate the approximate cost range (DZD) of an ambulance trip between two wilayas. Indicative only — the final price is agreed with the driver.",
|
|
123
236
|
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
237
|
+
title: "Estimate trip cost",
|
|
238
|
+
description:
|
|
239
|
+
"Estimate the approximate cost range (DZD) of an ambulance trip between two wilayas. INDICATIVE ONLY — the platform is not party to the agreement and the final price is settled directly with the driver. Always present it as a range, never as a quoted price.",
|
|
240
|
+
inputSchema: {
|
|
241
|
+
from_wilaya: z.number().int().describe("Pickup wilaya id"),
|
|
242
|
+
to_wilaya: z.number().int().describe("Destination wilaya id"),
|
|
243
|
+
service: z.enum(["basic", "equipped"]).optional().describe("basic (default) or medically-equipped"),
|
|
244
|
+
night: z.boolean().optional().describe("Night-time pricing (×1.4)"),
|
|
245
|
+
},
|
|
246
|
+
annotations: READ_ONLY,
|
|
128
247
|
},
|
|
129
248
|
async (args) => {
|
|
130
|
-
try {
|
|
249
|
+
try {
|
|
250
|
+
return ok(await api("/price-estimate", args));
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return fail(e);
|
|
253
|
+
}
|
|
131
254
|
}
|
|
132
255
|
);
|
|
133
256
|
|
|
134
|
-
|
|
257
|
+
/* ─────────────────────────── Platform ─────────────────────────── */
|
|
258
|
+
|
|
259
|
+
server.registerTool(
|
|
135
260
|
"platform_stats",
|
|
136
|
-
|
|
137
|
-
|
|
261
|
+
{
|
|
262
|
+
title: "Platform statistics",
|
|
263
|
+
description:
|
|
264
|
+
"Platform totals: listed drivers, verified drivers, available right now, wilaya coverage, commune count, average rating.",
|
|
265
|
+
inputSchema: {},
|
|
266
|
+
annotations: READ_ONLY,
|
|
267
|
+
},
|
|
268
|
+
async () => {
|
|
269
|
+
try {
|
|
270
|
+
return ok(await api("/stats"));
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return fail(e);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
server.registerTool(
|
|
278
|
+
"coverage_report",
|
|
279
|
+
{
|
|
280
|
+
title: "Coverage by wilaya",
|
|
281
|
+
description:
|
|
282
|
+
"Per-wilaya coverage: how many drivers, how many available now, how many verified — including the wilayas with NO drivers at all. Use this to answer 'is my area covered?' and to see where supply is missing.",
|
|
283
|
+
inputSchema: {},
|
|
284
|
+
annotations: READ_ONLY,
|
|
285
|
+
},
|
|
286
|
+
async () => {
|
|
287
|
+
try {
|
|
288
|
+
return ok(await api("/coverage"));
|
|
289
|
+
} catch (e) {
|
|
290
|
+
return fail(e);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
server.registerTool(
|
|
296
|
+
"emergency_numbers",
|
|
297
|
+
{
|
|
298
|
+
title: "Algeria emergency numbers",
|
|
299
|
+
description:
|
|
300
|
+
"Algeria's national emergency numbers (14 Civil Protection, 16 SAMU medical, 17 Police) and what each is for. Call this FIRST for any life-threatening situation — ambulancedz lists private operators and does not dispatch emergency services.",
|
|
301
|
+
inputSchema: {},
|
|
302
|
+
annotations: READ_ONLY,
|
|
303
|
+
},
|
|
138
304
|
async () => {
|
|
139
|
-
try {
|
|
305
|
+
try {
|
|
306
|
+
return ok(await api("/emergency-numbers"));
|
|
307
|
+
} catch {
|
|
308
|
+
// Emergency numbers must survive an API outage — they are constants.
|
|
309
|
+
return ok({
|
|
310
|
+
data: {
|
|
311
|
+
country: "DZ",
|
|
312
|
+
numbers: SAFETY.life_threatening_call_first,
|
|
313
|
+
notice_ar: SAFETY.notice_ar,
|
|
314
|
+
notice_en: SAFETY.notice_en,
|
|
315
|
+
note: "Served from the client's built-in fallback: the ambulancedz API was unreachable.",
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
/* ─────────────────────────── Drivers (supply side) ─────────────────────────── */
|
|
323
|
+
|
|
324
|
+
server.registerTool(
|
|
325
|
+
"driver_join_info",
|
|
326
|
+
{
|
|
327
|
+
title: "How ambulance owners join",
|
|
328
|
+
description:
|
|
329
|
+
"For ambulance OWNERS asking how to join ambulancedz: the free-trial length, the subscription plans and prices in DZD (with the per-month equivalent), accepted payment methods, the documents required for the «موثّق» verified badge, and the registration URL. There is no commission on trips.",
|
|
330
|
+
inputSchema: {},
|
|
331
|
+
annotations: READ_ONLY,
|
|
332
|
+
},
|
|
333
|
+
async () => {
|
|
334
|
+
try {
|
|
335
|
+
return ok(await api("/driver-plans"));
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return fail(e);
|
|
338
|
+
}
|
|
140
339
|
}
|
|
141
340
|
);
|
|
142
341
|
|
|
143
|
-
|
|
342
|
+
/* ─────────────────────────── Recurring transport (write) ─────────────────────────── */
|
|
343
|
+
|
|
344
|
+
server.registerTool(
|
|
144
345
|
"request_recurring_booking",
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
346
|
+
{
|
|
347
|
+
title: "Request recurring dialysis transport",
|
|
348
|
+
description:
|
|
349
|
+
"Register a recurring (dialysis / chronic-care) transport request for a patient — e.g. three trips a week to the same centre with the same driver. Creates a PENDING booking that a driver must accept; it is NOT a confirmation. Resolve ids first with list_wilayas / list_communes, and optionally pick a preferred driver with search_ambulances.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
patient_name: z.string().describe("Patient full name"),
|
|
352
|
+
patient_phone: z.string().describe("Algerian mobile, e.g. 0551234567"),
|
|
353
|
+
pickup_wilaya_id: z.number().int().describe("Pickup wilaya id"),
|
|
354
|
+
dest_wilaya_id: z.number().int().describe("Destination (dialysis centre/hospital) wilaya id"),
|
|
355
|
+
days: z
|
|
356
|
+
.array(z.number().int().min(0).max(6))
|
|
357
|
+
.min(1)
|
|
358
|
+
.describe("Weekdays: 0=Sunday .. 6=Saturday, e.g. [0,2,4] for Sun/Tue/Thu"),
|
|
359
|
+
time: z.string().describe("Pickup time HH:MM (24h), e.g. 07:00"),
|
|
360
|
+
pickup_commune_id: z.number().int().optional(),
|
|
361
|
+
dest_commune_id: z.number().int().optional(),
|
|
362
|
+
pickup_note: z.string().optional().describe("Exact pickup address / landmark"),
|
|
363
|
+
dest_note: z.string().optional().describe("Hospital / dialysis centre name"),
|
|
364
|
+
service_type: z.enum(["emergency", "transport", "inter_wilaya"]).optional(),
|
|
365
|
+
ambulance_id: z.number().int().optional().describe("Preferred driver id (from search_ambulances)"),
|
|
366
|
+
notes: z.string().optional().describe("Patient condition notes (e.g. needs a wheelchair)"),
|
|
367
|
+
},
|
|
368
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
160
369
|
},
|
|
161
370
|
async (args) => {
|
|
162
|
-
try {
|
|
371
|
+
try {
|
|
372
|
+
return ok(await apiPost("/recurring-bookings", args));
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return fail(e);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
/* ─────────────────────────── Resources ─────────────────────────── */
|
|
380
|
+
|
|
381
|
+
server.registerResource(
|
|
382
|
+
"safety-notice",
|
|
383
|
+
"ambulancedz://safety",
|
|
384
|
+
{
|
|
385
|
+
title: "Safety notice",
|
|
386
|
+
description: "What ambulancedz is and is not, plus Algeria's national emergency numbers.",
|
|
387
|
+
mimeType: "application/json",
|
|
388
|
+
},
|
|
389
|
+
async (uri) => ({
|
|
390
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(SAFETY, null, 2) }],
|
|
391
|
+
})
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
server.registerResource(
|
|
395
|
+
"coverage-snapshot",
|
|
396
|
+
"ambulancedz://coverage",
|
|
397
|
+
{
|
|
398
|
+
title: "Coverage snapshot",
|
|
399
|
+
description: "Live per-wilaya driver coverage across Algeria's 69 wilayas.",
|
|
400
|
+
mimeType: "application/json",
|
|
401
|
+
},
|
|
402
|
+
async (uri) => {
|
|
403
|
+
const data = await api("/coverage");
|
|
404
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
|
|
163
405
|
}
|
|
164
406
|
);
|
|
165
407
|
|
|
408
|
+
/* ─────────────────────────── Prompts ─────────────────────────── */
|
|
409
|
+
|
|
410
|
+
server.registerPrompt(
|
|
411
|
+
"find_ambulance",
|
|
412
|
+
{
|
|
413
|
+
title: "Find an ambulance",
|
|
414
|
+
description: "Guided flow for helping someone find an ambulance in Algeria, safely.",
|
|
415
|
+
argsSchema: {
|
|
416
|
+
location: z.string().describe("Where the patient is — a wilaya, commune, or 'lat,lng'"),
|
|
417
|
+
situation: z.string().optional().describe("Brief description of the case"),
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
({ location, situation }) => ({
|
|
421
|
+
messages: [
|
|
422
|
+
{
|
|
423
|
+
role: "user",
|
|
424
|
+
content: {
|
|
425
|
+
type: "text",
|
|
426
|
+
text: [
|
|
427
|
+
`Help find an ambulance in Algeria. Patient location: ${location}.`,
|
|
428
|
+
situation ? `Situation: ${situation}` : "",
|
|
429
|
+
"",
|
|
430
|
+
"Follow this order:",
|
|
431
|
+
"1. If this sounds life-threatening, give the national emergency numbers (14 / 16 / 17) FIRST and say plainly that ambulancedz lists private operators and does not dispatch them.",
|
|
432
|
+
"2. Resolve the location: use find_nearest_ambulance if you have coordinates, otherwise list_wilayas then search_ambulances.",
|
|
433
|
+
"3. Prefer drivers who are available now and verified. Show name, phone, licence plate, distance/ETA and whether they are verified.",
|
|
434
|
+
"4. If the person asks about cost, use estimate_price and present it as an indicative RANGE, never a quoted price.",
|
|
435
|
+
"5. Never invent a phone number, a plate, an ETA or a rating — report only what the tools returned.",
|
|
436
|
+
]
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.join("\n"),
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
})
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
server.registerPrompt(
|
|
446
|
+
"plan_dialysis_transport",
|
|
447
|
+
{
|
|
448
|
+
title: "Plan dialysis transport",
|
|
449
|
+
description: "Guided flow for setting up recurring dialysis transport for a chronic patient.",
|
|
450
|
+
argsSchema: {
|
|
451
|
+
patient_area: z.string().describe("Where the patient lives (wilaya / commune)"),
|
|
452
|
+
centre: z.string().optional().describe("Dialysis centre or hospital name"),
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
({ patient_area, centre }) => ({
|
|
456
|
+
messages: [
|
|
457
|
+
{
|
|
458
|
+
role: "user",
|
|
459
|
+
content: {
|
|
460
|
+
type: "text",
|
|
461
|
+
text: [
|
|
462
|
+
`Set up recurring dialysis transport. Patient area: ${patient_area}.`,
|
|
463
|
+
centre ? `Destination centre: ${centre}` : "",
|
|
464
|
+
"",
|
|
465
|
+
"Steps:",
|
|
466
|
+
"1. Resolve wilaya/commune ids with list_wilayas and list_communes.",
|
|
467
|
+
"2. Suggest a preferred driver with search_ambulances (verified: true), and check get_ambulance_reviews before recommending one.",
|
|
468
|
+
"3. Collect: patient name, Algerian mobile, weekdays (0=Sunday..6=Saturday), pickup time, and pickup/destination addresses.",
|
|
469
|
+
"4. Call request_recurring_booking, then state clearly that it is a PENDING request awaiting the driver's acceptance — not a confirmed booking.",
|
|
470
|
+
]
|
|
471
|
+
.filter(Boolean)
|
|
472
|
+
.join("\n"),
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
|
|
166
479
|
const transport = new StdioServerTransport();
|
|
167
480
|
await server.connect(transport);
|
|
168
|
-
console.error(`ambulancedz MCP server ready — base: ${BASE}`);
|
|
481
|
+
console.error(`ambulancedz MCP server v${VERSION} ready — base: ${BASE}`);
|