bayane 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 +72 -0
- package/dist/cli/index.js +94 -0
- package/dist/core/api.js +62 -0
- package/dist/core/api.test.js +64 -0
- package/dist/core/config.js +20 -0
- package/dist/core/format.js +30 -0
- package/dist/core/format.test.js +43 -0
- package/dist/core/types.js +1 -0
- package/dist/mcp/index.js +50 -0
- package/package.json +32 -0
- package/src/cli/index.ts +102 -0
- package/src/core/api.test.ts +76 -0
- package/src/core/api.ts +65 -0
- package/src/core/config.ts +23 -0
- package/src/core/format.test.ts +50 -0
- package/src/core/format.ts +40 -0
- package/src/core/types.ts +19 -0
- package/src/mcp/index.ts +76 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# bayane
|
|
2
|
+
|
|
3
|
+
CLI and MCP server for hospital satisfaction survey data.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @anouar-bm/bayane
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @anouar-bm/bayane setup
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bayane setup # enter your API URL and key
|
|
21
|
+
bayane stats # satisfaction stats for the last 7 days
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bayane setup # configure API URL and key (~/.health-survey-rc)
|
|
28
|
+
bayane whoami # show current config
|
|
29
|
+
bayane stats # stats for last 7 days
|
|
30
|
+
bayane stats --period 30d # stats for last 30 days
|
|
31
|
+
bayane stats --period all # all-time stats
|
|
32
|
+
bayane alerts # critical alerts from last 7 days
|
|
33
|
+
bayane report --latest # download latest PDF report
|
|
34
|
+
bayane report --date 2026-03-21 # download report for a specific date
|
|
35
|
+
bayane report --output ~/out.pdf # save to a custom path
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Auth
|
|
39
|
+
|
|
40
|
+
`bayane setup` stores an API key (`sk_*`) in `~/.health-survey-rc` (mode 0600). Keys are created from the admin dashboard and never expire.
|
|
41
|
+
|
|
42
|
+
## MCP server
|
|
43
|
+
|
|
44
|
+
`bayane` also ships as an [MCP](https://modelcontextprotocol.io) server so Claude Code and claude.ai can query hospital data directly.
|
|
45
|
+
|
|
46
|
+
Add to `~/.claude/mcp.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"health-survey": {
|
|
51
|
+
"command": "node",
|
|
52
|
+
"args": ["<path-to>/node_modules/@anouar-bm/bayane/dist/mcp/index.js"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Available MCP tools: `get_stats`, `get_alerts`, `download_report`, `display_report`.
|
|
58
|
+
|
|
59
|
+
## Claude Code skill
|
|
60
|
+
|
|
61
|
+
Install the companion skill for natural-language queries inside Claude Code:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx skills add https://github.com/anouar-bm/bayane
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then use `/bayane` inside any Claude Code session to ask questions like _"show me NPS for the last 30 days"_.
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Node.js >= 18
|
|
72
|
+
- Access to a running [1337 hospital survey API](https://github.com/anouar-bm/1337)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { password, input } from "@inquirer/prompts";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
|
|
7
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
|
|
8
|
+
import { formatStats, formatAlerts } from "../core/format.js";
|
|
9
|
+
const DEFAULT_API_URL = "https://health-bd.nonas.app";
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program.name("bayane").description("Hospital satisfaction survey CLI").version("1.0.0");
|
|
12
|
+
// bayane setup
|
|
13
|
+
program
|
|
14
|
+
.command("setup")
|
|
15
|
+
.description("Configure API URL and API key")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
|
|
18
|
+
const apiKey = await password({ message: "API key (sk_...):" });
|
|
19
|
+
saveConfig({ apiUrl, apiKey });
|
|
20
|
+
console.log(`✓ Config saved to ${CONFIG_PATH}`);
|
|
21
|
+
});
|
|
22
|
+
// bayane whoami
|
|
23
|
+
program
|
|
24
|
+
.command("whoami")
|
|
25
|
+
.description("Show current config")
|
|
26
|
+
.action(() => {
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
console.log(`API URL: ${config.apiUrl}`);
|
|
29
|
+
console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
|
|
30
|
+
});
|
|
31
|
+
// bayane stats
|
|
32
|
+
program
|
|
33
|
+
.command("stats")
|
|
34
|
+
.description("Show satisfaction statistics")
|
|
35
|
+
.option("--period <period>", "7d, 30d, or all", "7d")
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
try {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const data = await fetchStats(config, opts.period);
|
|
40
|
+
console.log(formatStats(data, opts.period));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
// bayane report
|
|
48
|
+
program
|
|
49
|
+
.command("report")
|
|
50
|
+
.description("Download a daily PDF report")
|
|
51
|
+
.option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
|
|
52
|
+
.option("--latest", "Download the most recent available report")
|
|
53
|
+
.option("--output <path>", "Output file path")
|
|
54
|
+
.action(async (opts) => {
|
|
55
|
+
try {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
const date = opts.date ??
|
|
58
|
+
(opts.latest
|
|
59
|
+
? await fetchLatestReportDate(config)
|
|
60
|
+
: new Date().toISOString().split("T")[0]);
|
|
61
|
+
const outPath = opts.output ?? `report-${date}.pdf`;
|
|
62
|
+
const buffer = await fetchReportPdf(config, date);
|
|
63
|
+
writeFileSync(outPath, buffer);
|
|
64
|
+
console.log(`✓ Report saved to ${outPath}`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// bayane alerts
|
|
72
|
+
program
|
|
73
|
+
.command("alerts")
|
|
74
|
+
.description("Show critical alerts from the last 7 days")
|
|
75
|
+
.action(async () => {
|
|
76
|
+
try {
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const data = await fetchStats(config, "7d");
|
|
79
|
+
console.log(formatAlerts(data));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// bayane install
|
|
87
|
+
program
|
|
88
|
+
.command("install")
|
|
89
|
+
.description("Install the bayane skill for your AI agent")
|
|
90
|
+
.action(() => {
|
|
91
|
+
console.log("Installing bayane skill...");
|
|
92
|
+
spawnSync("npx", ["skills", "add", "anouar-bm/bayane"], { stdio: "inherit" });
|
|
93
|
+
});
|
|
94
|
+
program.parse();
|
package/dist/core/api.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const TIMEOUT_MS = 10_000;
|
|
2
|
+
async function apiFetch(config, path) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
5
|
+
try {
|
|
6
|
+
const res = await fetch(`${config.apiUrl}${path}`, {
|
|
7
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
8
|
+
signal: controller.signal,
|
|
9
|
+
});
|
|
10
|
+
if (res.status === 401)
|
|
11
|
+
throw new Error("Invalid API key. Run: bayane setup");
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
const text = await res.text().catch(() => res.statusText);
|
|
14
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
15
|
+
}
|
|
16
|
+
return res.json();
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
20
|
+
throw new Error("Request timed out. Check your connection.");
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function fetchStats(config, period) {
|
|
29
|
+
return apiFetch(config, `/api/analytics?range=${period}`);
|
|
30
|
+
}
|
|
31
|
+
export async function fetchReportPdf(config, date) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
|
|
36
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
if (res.status === 401)
|
|
40
|
+
throw new Error("Invalid API key. Run: bayane setup");
|
|
41
|
+
if (res.status === 404)
|
|
42
|
+
throw new Error(`No report found for ${date}`);
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(`Failed to download report: ${res.status}`);
|
|
45
|
+
return Buffer.from(await res.arrayBuffer());
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
49
|
+
throw new Error("Report download timed out.");
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function fetchLatestReportDate(config) {
|
|
58
|
+
const reports = await apiFetch(config, "/api/reports");
|
|
59
|
+
if (!reports.length)
|
|
60
|
+
throw new Error("No reports available yet.");
|
|
61
|
+
return reports[0].date;
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
|
|
3
|
+
const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.resetAllMocks();
|
|
6
|
+
});
|
|
7
|
+
describe("fetchStats", () => {
|
|
8
|
+
it("throws on 401", async () => {
|
|
9
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
10
|
+
await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
|
|
11
|
+
});
|
|
12
|
+
it("throws on network timeout", async () => {
|
|
13
|
+
vi.stubGlobal("fetch", () => new Promise((_, reject) => {
|
|
14
|
+
const err = new Error("aborted");
|
|
15
|
+
err.name = "AbortError";
|
|
16
|
+
setTimeout(() => reject(err), 0);
|
|
17
|
+
}));
|
|
18
|
+
await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
|
|
19
|
+
});
|
|
20
|
+
it("returns data on success", async () => {
|
|
21
|
+
const mockData = {
|
|
22
|
+
totalSubmissions: 10,
|
|
23
|
+
averageScore: 8,
|
|
24
|
+
nps: 50,
|
|
25
|
+
serviceScores: [],
|
|
26
|
+
criticalAlerts: [],
|
|
27
|
+
};
|
|
28
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
|
|
29
|
+
const result = await fetchStats(mockConfig, "7d");
|
|
30
|
+
expect(result.totalSubmissions).toBe(10);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("fetchReportPdf", () => {
|
|
34
|
+
it("throws on 401", async () => {
|
|
35
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
|
|
36
|
+
await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
|
|
37
|
+
});
|
|
38
|
+
it("throws on 404", async () => {
|
|
39
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
|
|
40
|
+
await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
|
|
41
|
+
});
|
|
42
|
+
it("returns buffer on success", async () => {
|
|
43
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
44
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
|
|
45
|
+
const buf = await fetchReportPdf(mockConfig, "2026-03-22");
|
|
46
|
+
expect(buf).toBeInstanceOf(Buffer);
|
|
47
|
+
expect(buf.length).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("fetchLatestReportDate", () => {
|
|
51
|
+
it("throws when no reports available", async () => {
|
|
52
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
|
|
53
|
+
await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
|
|
54
|
+
});
|
|
55
|
+
it("returns the first report date", async () => {
|
|
56
|
+
vi.stubGlobal("fetch", async () => ({
|
|
57
|
+
ok: true,
|
|
58
|
+
status: 200,
|
|
59
|
+
json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
|
|
60
|
+
}));
|
|
61
|
+
const date = await fetchLatestReportDate(mockConfig);
|
|
62
|
+
expect(date).toBe("2026-03-22");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
4
|
+
export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
|
|
5
|
+
export function loadConfig() {
|
|
6
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
7
|
+
console.error("Not configured. Run: bayane setup");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
console.error("Config file is corrupted. Run: bayane setup");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function saveConfig(config) {
|
|
19
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function formatStats(data, period) {
|
|
2
|
+
const lines = [
|
|
3
|
+
``,
|
|
4
|
+
`📊 Hospital Satisfaction — last ${period}`,
|
|
5
|
+
` Responses: ${data.totalSubmissions}`,
|
|
6
|
+
` Avg score: ${data.averageScore}/10`,
|
|
7
|
+
` NPS: ${data.nps}`,
|
|
8
|
+
];
|
|
9
|
+
if (data.serviceScores.length > 0) {
|
|
10
|
+
lines.push(``, ` By service:`);
|
|
11
|
+
[...data.serviceScores]
|
|
12
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
13
|
+
.forEach((s) => lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`));
|
|
14
|
+
}
|
|
15
|
+
if (data.criticalAlerts.length > 0) {
|
|
16
|
+
lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
|
|
17
|
+
data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
|
|
18
|
+
}
|
|
19
|
+
return lines.join("\n");
|
|
20
|
+
}
|
|
21
|
+
export function formatAlerts(data) {
|
|
22
|
+
if (data.criticalAlerts.length === 0) {
|
|
23
|
+
return "✓ No critical alerts in the last 7 days.";
|
|
24
|
+
}
|
|
25
|
+
const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
|
|
26
|
+
data.criticalAlerts.forEach((a) => {
|
|
27
|
+
lines.push(` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`);
|
|
28
|
+
});
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatStats, formatAlerts } from "./format.js";
|
|
3
|
+
const mockData = {
|
|
4
|
+
totalSubmissions: 42,
|
|
5
|
+
averageScore: 7.8,
|
|
6
|
+
nps: 35,
|
|
7
|
+
serviceScores: [
|
|
8
|
+
{ service: "Emergency", avgScore: 8.2, count: 20 },
|
|
9
|
+
{ service: "Maternity", avgScore: 7.1, count: 22 },
|
|
10
|
+
],
|
|
11
|
+
criticalAlerts: [],
|
|
12
|
+
};
|
|
13
|
+
describe("formatStats", () => {
|
|
14
|
+
it("shows key metrics", () => {
|
|
15
|
+
const out = formatStats(mockData, "7d");
|
|
16
|
+
expect(out).toContain("42");
|
|
17
|
+
expect(out).toContain("7.8/10");
|
|
18
|
+
expect(out).toContain("35");
|
|
19
|
+
});
|
|
20
|
+
it("sorts services by score descending", () => {
|
|
21
|
+
const out = formatStats(mockData, "7d");
|
|
22
|
+
expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
|
|
23
|
+
});
|
|
24
|
+
it("shows alerts when present", () => {
|
|
25
|
+
const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
|
|
26
|
+
expect(formatStats(data, "7d")).toContain("Score below 5");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("formatAlerts", () => {
|
|
30
|
+
it("returns ok message when no alerts", () => {
|
|
31
|
+
expect(formatAlerts(mockData)).toContain("No critical alerts");
|
|
32
|
+
});
|
|
33
|
+
it("lists alerts with service and date", () => {
|
|
34
|
+
const data = {
|
|
35
|
+
...mockData,
|
|
36
|
+
criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
|
|
37
|
+
};
|
|
38
|
+
const out = formatAlerts(data);
|
|
39
|
+
expect(out).toContain("Low score");
|
|
40
|
+
expect(out).toContain("[ER]");
|
|
41
|
+
expect(out).toContain("2026-03-21");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { loadConfig } from "../core/config.js";
|
|
6
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
|
|
7
|
+
import { formatStats, formatAlerts } from "../core/format.js";
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "health-survey",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
});
|
|
12
|
+
server.tool("get_stats", "Get hospital satisfaction statistics", { period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") }, async ({ period }) => {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const data = await fetchStats(config, period);
|
|
15
|
+
return { content: [{ type: "text", text: formatStats(data, period) }] };
|
|
16
|
+
});
|
|
17
|
+
server.tool("get_alerts", "Get critical satisfaction alerts from the last 7 days", {}, async () => {
|
|
18
|
+
const config = loadConfig();
|
|
19
|
+
const data = await fetchStats(config, "7d");
|
|
20
|
+
return { content: [{ type: "text", text: formatAlerts(data) }] };
|
|
21
|
+
});
|
|
22
|
+
server.tool("download_report", "Download a hospital satisfaction PDF report", {
|
|
23
|
+
date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
|
|
24
|
+
output: z.string().optional().describe("Output file path"),
|
|
25
|
+
}, async ({ date, output }) => {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const reportDate = date ?? (await fetchLatestReportDate(config));
|
|
28
|
+
const outPath = output ?? `report-${reportDate}.pdf`;
|
|
29
|
+
const buffer = await fetchReportPdf(config, reportDate);
|
|
30
|
+
writeFileSync(outPath, buffer);
|
|
31
|
+
return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
|
|
32
|
+
});
|
|
33
|
+
server.tool("display_report", "Display a hospital satisfaction PDF report (returns base64 for rendering)", {
|
|
34
|
+
date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
|
|
35
|
+
}, async ({ date }) => {
|
|
36
|
+
const config = loadConfig();
|
|
37
|
+
const reportDate = date ?? (await fetchLatestReportDate(config));
|
|
38
|
+
const buffer = await fetchReportPdf(config, reportDate);
|
|
39
|
+
const base64 = buffer.toString("base64");
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
const transport = new StdioServerTransport();
|
|
50
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bayane",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI and MCP server for hospital satisfaction survey data",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bayane": "./dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./mcp": "./dist/mcp/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev:cli": "tsx src/cli/index.ts",
|
|
15
|
+
"dev:mcp": "tsx src/mcp/index.ts",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^12.0.0",
|
|
21
|
+
"@inquirer/prompts": "^5.0.0",
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
23
|
+
"zod": "^3.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.0.0",
|
|
27
|
+
"typescript": "^5.4.0",
|
|
28
|
+
"tsx": "^4.0.0",
|
|
29
|
+
"vitest": "^1.6.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": { "node": ">=18" }
|
|
32
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { password, input } from "@inquirer/prompts";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
|
|
8
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
|
|
9
|
+
import { formatStats, formatAlerts } from "../core/format.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_API_URL = "https://health-bd.nonas.app";
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program.name("bayane").description("Hospital satisfaction survey CLI").version("1.0.0");
|
|
15
|
+
|
|
16
|
+
// bayane setup
|
|
17
|
+
program
|
|
18
|
+
.command("setup")
|
|
19
|
+
.description("Configure API URL and API key")
|
|
20
|
+
.action(async () => {
|
|
21
|
+
const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
|
|
22
|
+
const apiKey = await password({ message: "API key (sk_...):" });
|
|
23
|
+
saveConfig({ apiUrl, apiKey });
|
|
24
|
+
console.log(`✓ Config saved to ${CONFIG_PATH}`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// bayane whoami
|
|
28
|
+
program
|
|
29
|
+
.command("whoami")
|
|
30
|
+
.description("Show current config")
|
|
31
|
+
.action(() => {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
console.log(`API URL: ${config.apiUrl}`);
|
|
34
|
+
console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// bayane stats
|
|
38
|
+
program
|
|
39
|
+
.command("stats")
|
|
40
|
+
.description("Show satisfaction statistics")
|
|
41
|
+
.option("--period <period>", "7d, 30d, or all", "7d")
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
try {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
const data = await fetchStats(config, opts.period);
|
|
46
|
+
console.log(formatStats(data, opts.period));
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// bayane report
|
|
54
|
+
program
|
|
55
|
+
.command("report")
|
|
56
|
+
.description("Download a daily PDF report")
|
|
57
|
+
.option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
|
|
58
|
+
.option("--latest", "Download the most recent available report")
|
|
59
|
+
.option("--output <path>", "Output file path")
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
try {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
const date =
|
|
64
|
+
opts.date ??
|
|
65
|
+
(opts.latest
|
|
66
|
+
? await fetchLatestReportDate(config)
|
|
67
|
+
: new Date().toISOString().split("T")[0]);
|
|
68
|
+
const outPath = opts.output ?? `report-${date}.pdf`;
|
|
69
|
+
const buffer = await fetchReportPdf(config, date);
|
|
70
|
+
writeFileSync(outPath, buffer);
|
|
71
|
+
console.log(`✓ Report saved to ${outPath}`);
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// bayane alerts
|
|
79
|
+
program
|
|
80
|
+
.command("alerts")
|
|
81
|
+
.description("Show critical alerts from the last 7 days")
|
|
82
|
+
.action(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
const data = await fetchStats(config, "7d");
|
|
86
|
+
console.log(formatAlerts(data));
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// bayane install
|
|
94
|
+
program
|
|
95
|
+
.command("install")
|
|
96
|
+
.description("Install the bayane skill for your AI agent")
|
|
97
|
+
.action(() => {
|
|
98
|
+
console.log("Installing bayane skill...");
|
|
99
|
+
spawnSync("npx", ["skills", "add", "anouar-bm/bayane"], { stdio: "inherit" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
program.parse();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
|
|
3
|
+
|
|
4
|
+
const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("fetchStats", () => {
|
|
11
|
+
it("throws on 401", async () => {
|
|
12
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
13
|
+
await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("throws on network timeout", async () => {
|
|
17
|
+
vi.stubGlobal("fetch", () =>
|
|
18
|
+
new Promise((_, reject) => {
|
|
19
|
+
const err = new Error("aborted");
|
|
20
|
+
err.name = "AbortError";
|
|
21
|
+
setTimeout(() => reject(err), 0);
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns data on success", async () => {
|
|
28
|
+
const mockData = {
|
|
29
|
+
totalSubmissions: 10,
|
|
30
|
+
averageScore: 8,
|
|
31
|
+
nps: 50,
|
|
32
|
+
serviceScores: [],
|
|
33
|
+
criticalAlerts: [],
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
|
|
36
|
+
const result = await fetchStats(mockConfig, "7d");
|
|
37
|
+
expect(result.totalSubmissions).toBe(10);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("fetchReportPdf", () => {
|
|
42
|
+
it("throws on 401", async () => {
|
|
43
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
|
|
44
|
+
await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws on 404", async () => {
|
|
48
|
+
vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
|
|
49
|
+
await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns buffer on success", async () => {
|
|
53
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
54
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
|
|
55
|
+
const buf = await fetchReportPdf(mockConfig, "2026-03-22");
|
|
56
|
+
expect(buf).toBeInstanceOf(Buffer);
|
|
57
|
+
expect(buf.length).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("fetchLatestReportDate", () => {
|
|
62
|
+
it("throws when no reports available", async () => {
|
|
63
|
+
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
|
|
64
|
+
await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns the first report date", async () => {
|
|
68
|
+
vi.stubGlobal("fetch", async () => ({
|
|
69
|
+
ok: true,
|
|
70
|
+
status: 200,
|
|
71
|
+
json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
|
|
72
|
+
}));
|
|
73
|
+
const date = await fetchLatestReportDate(mockConfig);
|
|
74
|
+
expect(date).toBe("2026-03-22");
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/core/api.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Config, AnalyticsData, Period } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
async function apiFetch<T>(config: Config, path: string): Promise<T> {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`${config.apiUrl}${path}`, {
|
|
11
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
12
|
+
signal: controller.signal,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (res.status === 401) throw new Error("Invalid API key. Run: bayane setup");
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const text = await res.text().catch(() => res.statusText);
|
|
18
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return res.json() as Promise<T>;
|
|
22
|
+
} catch (err: unknown) {
|
|
23
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
24
|
+
throw new Error("Request timed out. Check your connection.");
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function fetchStats(config: Config, period: Period): Promise<AnalyticsData> {
|
|
33
|
+
return apiFetch<AnalyticsData>(config, `/api/analytics?range=${period}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchReportPdf(config: Config, date: string): Promise<Buffer> {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
|
|
42
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (res.status === 401) throw new Error("Invalid API key. Run: bayane setup");
|
|
47
|
+
if (res.status === 404) throw new Error(`No report found for ${date}`);
|
|
48
|
+
if (!res.ok) throw new Error(`Failed to download report: ${res.status}`);
|
|
49
|
+
|
|
50
|
+
return Buffer.from(await res.arrayBuffer());
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
53
|
+
throw new Error("Report download timed out.");
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fetchLatestReportDate(config: Config): Promise<string> {
|
|
62
|
+
const reports = await apiFetch<Array<{ date: string }>>(config, "/api/reports");
|
|
63
|
+
if (!reports.length) throw new Error("No reports available yet.");
|
|
64
|
+
return reports[0].date;
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
4
|
+
import { Config } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
|
|
7
|
+
|
|
8
|
+
export function loadConfig(): Config {
|
|
9
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
10
|
+
console.error("Not configured. Run: bayane setup");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as Config;
|
|
15
|
+
} catch {
|
|
16
|
+
console.error("Config file is corrupted. Run: bayane setup");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveConfig(config: Config): void {
|
|
22
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatStats, formatAlerts } from "./format.js";
|
|
3
|
+
import { AnalyticsData } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const mockData: AnalyticsData = {
|
|
6
|
+
totalSubmissions: 42,
|
|
7
|
+
averageScore: 7.8,
|
|
8
|
+
nps: 35,
|
|
9
|
+
serviceScores: [
|
|
10
|
+
{ service: "Emergency", avgScore: 8.2, count: 20 },
|
|
11
|
+
{ service: "Maternity", avgScore: 7.1, count: 22 },
|
|
12
|
+
],
|
|
13
|
+
criticalAlerts: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("formatStats", () => {
|
|
17
|
+
it("shows key metrics", () => {
|
|
18
|
+
const out = formatStats(mockData, "7d");
|
|
19
|
+
expect(out).toContain("42");
|
|
20
|
+
expect(out).toContain("7.8/10");
|
|
21
|
+
expect(out).toContain("35");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("sorts services by score descending", () => {
|
|
25
|
+
const out = formatStats(mockData, "7d");
|
|
26
|
+
expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("shows alerts when present", () => {
|
|
30
|
+
const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
|
|
31
|
+
expect(formatStats(data, "7d")).toContain("Score below 5");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("formatAlerts", () => {
|
|
36
|
+
it("returns ok message when no alerts", () => {
|
|
37
|
+
expect(formatAlerts(mockData)).toContain("No critical alerts");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("lists alerts with service and date", () => {
|
|
41
|
+
const data = {
|
|
42
|
+
...mockData,
|
|
43
|
+
criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
|
|
44
|
+
};
|
|
45
|
+
const out = formatAlerts(data);
|
|
46
|
+
expect(out).toContain("Low score");
|
|
47
|
+
expect(out).toContain("[ER]");
|
|
48
|
+
expect(out).toContain("2026-03-21");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AnalyticsData, Period } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function formatStats(data: AnalyticsData, period: Period): string {
|
|
4
|
+
const lines: string[] = [
|
|
5
|
+
``,
|
|
6
|
+
`📊 Hospital Satisfaction — last ${period}`,
|
|
7
|
+
` Responses: ${data.totalSubmissions}`,
|
|
8
|
+
` Avg score: ${data.averageScore}/10`,
|
|
9
|
+
` NPS: ${data.nps}`,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
if (data.serviceScores.length > 0) {
|
|
13
|
+
lines.push(``, ` By service:`);
|
|
14
|
+
[...data.serviceScores]
|
|
15
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
16
|
+
.forEach((s) =>
|
|
17
|
+
lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (data.criticalAlerts.length > 0) {
|
|
22
|
+
lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
|
|
23
|
+
data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatAlerts(data: AnalyticsData): string {
|
|
30
|
+
if (data.criticalAlerts.length === 0) {
|
|
31
|
+
return "✓ No critical alerts in the last 7 days.";
|
|
32
|
+
}
|
|
33
|
+
const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
|
|
34
|
+
data.criticalAlerts.forEach((a) => {
|
|
35
|
+
lines.push(
|
|
36
|
+
` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AnalyticsData {
|
|
7
|
+
totalSubmissions: number;
|
|
8
|
+
averageScore: number;
|
|
9
|
+
nps: number;
|
|
10
|
+
serviceScores: Array<{ service: string; avgScore: number; count: number }>;
|
|
11
|
+
criticalAlerts: Array<{ message: string; service?: string; date?: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReportMeta {
|
|
15
|
+
date: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Period = "7d" | "30d" | "all";
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { loadConfig } from "../core/config.js";
|
|
6
|
+
import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
|
|
7
|
+
import { formatStats, formatAlerts } from "../core/format.js";
|
|
8
|
+
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "health-survey",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
server.tool(
|
|
15
|
+
"get_stats",
|
|
16
|
+
"Get hospital satisfaction statistics",
|
|
17
|
+
{ period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") },
|
|
18
|
+
async ({ period }) => {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
const data = await fetchStats(config, period);
|
|
21
|
+
return { content: [{ type: "text", text: formatStats(data, period) }] };
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
server.tool(
|
|
26
|
+
"get_alerts",
|
|
27
|
+
"Get critical satisfaction alerts from the last 7 days",
|
|
28
|
+
{},
|
|
29
|
+
async () => {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const data = await fetchStats(config, "7d");
|
|
32
|
+
return { content: [{ type: "text", text: formatAlerts(data) }] };
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
server.tool(
|
|
37
|
+
"download_report",
|
|
38
|
+
"Download a hospital satisfaction PDF report",
|
|
39
|
+
{
|
|
40
|
+
date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
|
|
41
|
+
output: z.string().optional().describe("Output file path"),
|
|
42
|
+
},
|
|
43
|
+
async ({ date, output }) => {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
const reportDate = date ?? (await fetchLatestReportDate(config));
|
|
46
|
+
const outPath = output ?? `report-${reportDate}.pdf`;
|
|
47
|
+
const buffer = await fetchReportPdf(config, reportDate);
|
|
48
|
+
writeFileSync(outPath, buffer);
|
|
49
|
+
return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
server.tool(
|
|
54
|
+
"display_report",
|
|
55
|
+
"Display a hospital satisfaction PDF report (returns base64 for rendering)",
|
|
56
|
+
{
|
|
57
|
+
date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
|
|
58
|
+
},
|
|
59
|
+
async ({ date }) => {
|
|
60
|
+
const config = loadConfig();
|
|
61
|
+
const reportDate = date ?? (await fetchLatestReportDate(config));
|
|
62
|
+
const buffer = await fetchReportPdf(config, reportDate);
|
|
63
|
+
const base64 = buffer.toString("base64");
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const transport = new StdioServerTransport();
|
|
76
|
+
await server.connect(transport);
|
package/tsconfig.json
ADDED