oth-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # oth-mcp
2
+
3
+ MCP server for the [OTH (OpenTeleHealth) API](https://doccla-dev.oth.io/api/docs). Gives Claude direct access to query and manage OTH resources across environments.
4
+
5
+ ## Setup
6
+
7
+ ### 1. Configure credentials
8
+
9
+ **Environment variables** (works everywhere):
10
+
11
+ ```bash
12
+ export OTH_USERNAME="your-username"
13
+ export OTH_PASSWORD="your-password"
14
+ export OTH_ENV="dev" # optional, defaults to dev
15
+ ```
16
+
17
+ **macOS Keychain** (no plaintext on disk):
18
+
19
+ ```bash
20
+ # For each environment (dev, stag, demo):
21
+ security add-generic-password -a "oth-dev" -s "oth-mcp-username" -w
22
+ security add-generic-password -a "oth-dev" -s "oth-mcp-password" -w
23
+ ```
24
+
25
+ ### 2. Add to Claude Code
26
+
27
+ Add to your Claude Code MCP settings (`~/.claude/settings.json` or project `.claude/settings.json`):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "oth": {
33
+ "command": "npx",
34
+ "args": ["-y", "oth-mcp"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Or with inline credentials:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "oth": {
46
+ "command": "npx",
47
+ "args": ["-y", "oth-mcp"],
48
+ "env": {
49
+ "OTH_USERNAME": "your-username",
50
+ "OTH_PASSWORD": "your-password",
51
+ "OTH_ENV": "dev"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## Tools
59
+
60
+ ### `oth_get`
61
+
62
+ GET any OTH API endpoint. Common paths are listed in the tool description; read the `oth://api-guide` resource for the full reference.
63
+
64
+ ```
65
+ oth_get({ path: "/clinician/api/patients", query: { max: "10" } })
66
+ ```
67
+
68
+ ### `oth_mutate`
69
+
70
+ POST, PUT, PATCH, or DELETE to any OTH API endpoint.
71
+
72
+ ```
73
+ oth_mutate({ method: "POST", path: "/clinician/api/patient-notes", body: { note: "Test", type: "normal", links: { patient: "https://doccla-dev.oth.io/clinician/api/patients/123" } } })
74
+ ```
75
+
76
+ ### `oth_env`
77
+
78
+ Switch environment or check current state.
79
+
80
+ ```
81
+ oth_env({ environment: "stag" })
82
+ oth_env({}) // show current env + auth state
83
+ ```
84
+
85
+ ## Limitations
86
+
87
+ - **MFA not supported** — accounts with MFA enabled will fail to authenticate. Use an account without MFA.
88
+ - **macOS Keychain only on macOS** — Linux/Windows users must use environment variables.
@@ -0,0 +1 @@
1
+ export declare const API_GUIDE = "# OTH API Endpoint Reference\n\n## Patients\nGET /clinician/api/patients ?username= &firstName= &lastName= &uniqueId= &status= &max= &offset=\nGET /clinician/api/patients/{id}\nGET /clinician/api/patients/me\nGET /clinician/api/patients/{id}/measurements ?from= &to= &max=\nGET /clinician/api/patients/{id}/measurements/aggregate\nGET /clinician/api/patients/{id}/measurement-types\nGET /clinician/api/patients/{id}/questionnaires\nGET /clinician/api/patients/{id}/questionnaire-results ?from= &to= &max=\nGET /clinician/api/patients/{id}/questionnaire_schedules\nPOST /clinician/api/patients/{id}/questionnaire_schedules\nGET /clinician/api/patients/{id}/contact-info\nPUT /clinician/api/patients/{id}/contact-info\nGET /clinician/api/patients/{id}/acknowledgements\n\n## Clinicians\nGET /clinician/api/clinicians ?max= &offset=\nPOST /clinician/api/clinicians\nGET /clinician/api/clinicians/{id}\nPUT /clinician/api/clinicians/{id}\nGET /clinician/api/clinicians/me\nGET /clinician/api/clinicians/me/colleagues\n\n## Questionnaire Definitions\nGET /clinician/api/questionnaire_definitions ?max= &offset=\nGET /clinician/api/questionnaire_definitions/{id}\nPOST /clinician/api/questionnaire_definitions {name, requiresManualInspection}\nPUT /clinician/api/questionnaire_definitions/{id}\n\n## Questionnaires (node graph)\nGET /clinician/api/questionnaires/{id}\nPOST /clinician/api/questionnaires/{id}/results\n\n## Questionnaire Results\nGET /clinician/api/questionnaire-results ?from= &to= &max= &offset=\nGET /clinician/api/questionnaire-results/{id}\n\n## Questionnaire Schedules\nGET /clinician/api/questionnaire_schedules ?max= &offset=\n\n## Questionnaire Groups\nGET /questionnaires/questionnaire-groups ?max=\nPOST /questionnaires/questionnaire-groups {name, questionnaireDefinitions}\n\n## Patient Notes\nGET /clinician/api/patient-notes ?max= &offset=\nPOST /clinician/api/patient-notes\nGET /clinician/api/patient-notes/{id}\nPUT /clinician/api/patient-notes/{id}\nDELETE /clinician/api/patient-notes/{id}\nPOST /clinician/api/patient-notes/{id}/read\n\n## Measurements\nGET /measurements/measurement-types\nGET /measurements/measurement-captures ?patient= &from= &to= &max=\nPOST /measurements/measurement-captures\nGET /measurements/measurement-captures/{uuid}\nPOST /measurements/measurement-captures/{uuid}/measurements\nGET /measurements/measurement-streams ?patient= &from= &to= &max=\nPOST /measurements/measurement-streams\nGET /measurements/measurement-streams/{uuid}\nGET /measurements/measurement-streams/{uuid}/measurements\nGET /measurements/measurement-streams/{uuid}/incidents\nGET /measurements/measurement-streams/incidents\nPOST /measurements/measurement-streams/acknowledgements\nGET /measurements/processors\nGET /measurements/processors/{name}\n\n## Thresholds\nGET /thresholds/patient-thresholds ?patient= &max=\nPOST /thresholds/patient-thresholds\nGET /thresholds/patient-thresholds/{uuid}\nPUT /thresholds/patient-thresholds/{uuid}\nDELETE /thresholds/patient-thresholds/{uuid}\nGET /thresholds/patient-group-thresholds ?max=\nPOST /thresholds/patient-group-thresholds\nGET /thresholds/patient-group-thresholds/{uuid}\nPUT /thresholds/patient-group-thresholds/{uuid}\nDELETE /thresholds/patient-group-thresholds/{uuid}\nPOST /thresholds/evaluation\n\n## Organizations\nGET /organizations/organizations\nGET /organizations/organizations/{uuid}\nPUT /organizations/organizations/{uuid}\nGET /organizations/organizations/{uuid}/metadata-fields\nGET /organizations/patients/{id}/metadata\nPUT /organizations/patients/{id}/metadata\n\n## Users & IDP\nGET /idp2/users/{id}\nPUT /idp2/users/{id}/locked\nDELETE /idp2/users/{id}/password\nGET /idp2/roles\nGET /idp2/permissions\n\n## Patient Groups\nGET /clinician/api/patientgroups ?max= &offset=\nGET /clinician/api/patientgroups/{id}\nPOST /clinician/api/patientgroups\nPUT /clinician/api/patientgroups/{id}\nDELETE /clinician/api/patientgroups/{id}\n\n## Audit\nGET /audit/resources/{resourceUrl} ?max= &offset=\n\n## Calendar\nGET /calendar/events ?patient= &from= &to=\nPOST /calendar/events\nGET /calendar/events/{uuid}\nPUT /calendar/events/{uuid}\nDELETE /calendar/events/{uuid}\n\n## Chat\nGET /chat/threads ?patient=\nGET /chat/threads/{uuid}\nGET /chat/threads/{uuid}/messages\nPOST /chat/threads/{uuid}/read\nPOST /chat/messages\nGET /chat/messages/{uuid}\nPOST /chat/messages/{uuid}/read\nGET /chat/snippets\nPOST /chat/snippets\nPUT /chat/snippets/{uuid}\nDELETE /chat/snippets/{uuid}\n\n## Notifications\nGET /notifications/devices ?user=\nPOST /notifications/devices\nGET /notifications/subscriptions\nPOST /notifications/subscriptions\nDELETE /notifications/subscriptions/{uuid}\n\n## Mutation Request Bodies\n\nPOST /clinician/api/patients\n required: uniqueId, username, firstName, lastName, sex(male|female|unknown),\n address, postalCode, city, links.patientGroups[uri...]\n optional: dateOfBirth(YYYY-MM-DD), phone, mobilePhone, email, comment,\n dueDate, profilePicture, place, relatives[{firstName,lastName,relation,phone}]\n\nPUT /clinician/api/patients/{id}\n same fields as create, all optional except links.patientGroups\n\nPOST /clinician/api/clinicians\n required: username, firstName, lastName, links.patientGroups[uri...], links.roles[uri...]\n optional: email, phone, mobilePhone, uniqueId\n\nPOST /clinician/api/patient-notes\n required: note, type(normal|important), links.patient(uri)\n optional: reminderDate(date-time)\n\nPOST /clinician/api/patientgroups\n required: name, messagingEnabled, blueAlarmsEnabled, links.organization(uri)\n optional: dueDateVisible, calculateBloodPressureWeeklyAverage, copdPrediction\n\nPOST /clinician/api/patients/{id}/questionnaire_schedules\n required: links.questionnaireDefinition(uri)\n optional: scheduledTime (varies by type \u2014 WEEKDAYS, MONTHLY, EVERY_NTH_DAY, SPECIFIC_DATE, etc.)\n\nPOST /measurements/measurement-captures\n required: source, sourceId, captureTime(date-time), measurements[], links.patient(uri)\n optional: historic\n note: source+sourceId must be unique per capture\n\nPOST /chat/messages\n required: body, links.organization(uri)\n variant 1: links.patient(uri), optional links.attachments[uri...]\n variant 2: links.patientGroup(uri)\n\nPOST /calendar/events\n required: type, description, schedule.startTime(date-time),\n party.clinicians[{links.clinician(uri)}],\n party.patients[{links.patient(uri)}],\n links.origin(uri)\n optional: schedule.endTime(date-time), party.name\n\n## Notes\n- Dates use ISO 8601: 2024-01-01T00:00:00.000Z\n- Default max is 100, default offset is 0\n- All list endpoints support pagination via max/offset\n- Clinician-service resources use numeric {id}; other services use {uuid}\n- Links are always absolute URIs: https://doccla-dev.oth.io/clinician/api/patients/123\n- Schemas discoverable via each service's root links.schemas; filenames vary by endpoint\n";
@@ -0,0 +1,187 @@
1
+ export const API_GUIDE = `# OTH API Endpoint Reference
2
+
3
+ ## Patients
4
+ GET /clinician/api/patients ?username= &firstName= &lastName= &uniqueId= &status= &max= &offset=
5
+ GET /clinician/api/patients/{id}
6
+ GET /clinician/api/patients/me
7
+ GET /clinician/api/patients/{id}/measurements ?from= &to= &max=
8
+ GET /clinician/api/patients/{id}/measurements/aggregate
9
+ GET /clinician/api/patients/{id}/measurement-types
10
+ GET /clinician/api/patients/{id}/questionnaires
11
+ GET /clinician/api/patients/{id}/questionnaire-results ?from= &to= &max=
12
+ GET /clinician/api/patients/{id}/questionnaire_schedules
13
+ POST /clinician/api/patients/{id}/questionnaire_schedules
14
+ GET /clinician/api/patients/{id}/contact-info
15
+ PUT /clinician/api/patients/{id}/contact-info
16
+ GET /clinician/api/patients/{id}/acknowledgements
17
+
18
+ ## Clinicians
19
+ GET /clinician/api/clinicians ?max= &offset=
20
+ POST /clinician/api/clinicians
21
+ GET /clinician/api/clinicians/{id}
22
+ PUT /clinician/api/clinicians/{id}
23
+ GET /clinician/api/clinicians/me
24
+ GET /clinician/api/clinicians/me/colleagues
25
+
26
+ ## Questionnaire Definitions
27
+ GET /clinician/api/questionnaire_definitions ?max= &offset=
28
+ GET /clinician/api/questionnaire_definitions/{id}
29
+ POST /clinician/api/questionnaire_definitions {name, requiresManualInspection}
30
+ PUT /clinician/api/questionnaire_definitions/{id}
31
+
32
+ ## Questionnaires (node graph)
33
+ GET /clinician/api/questionnaires/{id}
34
+ POST /clinician/api/questionnaires/{id}/results
35
+
36
+ ## Questionnaire Results
37
+ GET /clinician/api/questionnaire-results ?from= &to= &max= &offset=
38
+ GET /clinician/api/questionnaire-results/{id}
39
+
40
+ ## Questionnaire Schedules
41
+ GET /clinician/api/questionnaire_schedules ?max= &offset=
42
+
43
+ ## Questionnaire Groups
44
+ GET /questionnaires/questionnaire-groups ?max=
45
+ POST /questionnaires/questionnaire-groups {name, questionnaireDefinitions}
46
+
47
+ ## Patient Notes
48
+ GET /clinician/api/patient-notes ?max= &offset=
49
+ POST /clinician/api/patient-notes
50
+ GET /clinician/api/patient-notes/{id}
51
+ PUT /clinician/api/patient-notes/{id}
52
+ DELETE /clinician/api/patient-notes/{id}
53
+ POST /clinician/api/patient-notes/{id}/read
54
+
55
+ ## Measurements
56
+ GET /measurements/measurement-types
57
+ GET /measurements/measurement-captures ?patient= &from= &to= &max=
58
+ POST /measurements/measurement-captures
59
+ GET /measurements/measurement-captures/{uuid}
60
+ POST /measurements/measurement-captures/{uuid}/measurements
61
+ GET /measurements/measurement-streams ?patient= &from= &to= &max=
62
+ POST /measurements/measurement-streams
63
+ GET /measurements/measurement-streams/{uuid}
64
+ GET /measurements/measurement-streams/{uuid}/measurements
65
+ GET /measurements/measurement-streams/{uuid}/incidents
66
+ GET /measurements/measurement-streams/incidents
67
+ POST /measurements/measurement-streams/acknowledgements
68
+ GET /measurements/processors
69
+ GET /measurements/processors/{name}
70
+
71
+ ## Thresholds
72
+ GET /thresholds/patient-thresholds ?patient= &max=
73
+ POST /thresholds/patient-thresholds
74
+ GET /thresholds/patient-thresholds/{uuid}
75
+ PUT /thresholds/patient-thresholds/{uuid}
76
+ DELETE /thresholds/patient-thresholds/{uuid}
77
+ GET /thresholds/patient-group-thresholds ?max=
78
+ POST /thresholds/patient-group-thresholds
79
+ GET /thresholds/patient-group-thresholds/{uuid}
80
+ PUT /thresholds/patient-group-thresholds/{uuid}
81
+ DELETE /thresholds/patient-group-thresholds/{uuid}
82
+ POST /thresholds/evaluation
83
+
84
+ ## Organizations
85
+ GET /organizations/organizations
86
+ GET /organizations/organizations/{uuid}
87
+ PUT /organizations/organizations/{uuid}
88
+ GET /organizations/organizations/{uuid}/metadata-fields
89
+ GET /organizations/patients/{id}/metadata
90
+ PUT /organizations/patients/{id}/metadata
91
+
92
+ ## Users & IDP
93
+ GET /idp2/users/{id}
94
+ PUT /idp2/users/{id}/locked
95
+ DELETE /idp2/users/{id}/password
96
+ GET /idp2/roles
97
+ GET /idp2/permissions
98
+
99
+ ## Patient Groups
100
+ GET /clinician/api/patientgroups ?max= &offset=
101
+ GET /clinician/api/patientgroups/{id}
102
+ POST /clinician/api/patientgroups
103
+ PUT /clinician/api/patientgroups/{id}
104
+ DELETE /clinician/api/patientgroups/{id}
105
+
106
+ ## Audit
107
+ GET /audit/resources/{resourceUrl} ?max= &offset=
108
+
109
+ ## Calendar
110
+ GET /calendar/events ?patient= &from= &to=
111
+ POST /calendar/events
112
+ GET /calendar/events/{uuid}
113
+ PUT /calendar/events/{uuid}
114
+ DELETE /calendar/events/{uuid}
115
+
116
+ ## Chat
117
+ GET /chat/threads ?patient=
118
+ GET /chat/threads/{uuid}
119
+ GET /chat/threads/{uuid}/messages
120
+ POST /chat/threads/{uuid}/read
121
+ POST /chat/messages
122
+ GET /chat/messages/{uuid}
123
+ POST /chat/messages/{uuid}/read
124
+ GET /chat/snippets
125
+ POST /chat/snippets
126
+ PUT /chat/snippets/{uuid}
127
+ DELETE /chat/snippets/{uuid}
128
+
129
+ ## Notifications
130
+ GET /notifications/devices ?user=
131
+ POST /notifications/devices
132
+ GET /notifications/subscriptions
133
+ POST /notifications/subscriptions
134
+ DELETE /notifications/subscriptions/{uuid}
135
+
136
+ ## Mutation Request Bodies
137
+
138
+ POST /clinician/api/patients
139
+ required: uniqueId, username, firstName, lastName, sex(male|female|unknown),
140
+ address, postalCode, city, links.patientGroups[uri...]
141
+ optional: dateOfBirth(YYYY-MM-DD), phone, mobilePhone, email, comment,
142
+ dueDate, profilePicture, place, relatives[{firstName,lastName,relation,phone}]
143
+
144
+ PUT /clinician/api/patients/{id}
145
+ same fields as create, all optional except links.patientGroups
146
+
147
+ POST /clinician/api/clinicians
148
+ required: username, firstName, lastName, links.patientGroups[uri...], links.roles[uri...]
149
+ optional: email, phone, mobilePhone, uniqueId
150
+
151
+ POST /clinician/api/patient-notes
152
+ required: note, type(normal|important), links.patient(uri)
153
+ optional: reminderDate(date-time)
154
+
155
+ POST /clinician/api/patientgroups
156
+ required: name, messagingEnabled, blueAlarmsEnabled, links.organization(uri)
157
+ optional: dueDateVisible, calculateBloodPressureWeeklyAverage, copdPrediction
158
+
159
+ POST /clinician/api/patients/{id}/questionnaire_schedules
160
+ required: links.questionnaireDefinition(uri)
161
+ optional: scheduledTime (varies by type — WEEKDAYS, MONTHLY, EVERY_NTH_DAY, SPECIFIC_DATE, etc.)
162
+
163
+ POST /measurements/measurement-captures
164
+ required: source, sourceId, captureTime(date-time), measurements[], links.patient(uri)
165
+ optional: historic
166
+ note: source+sourceId must be unique per capture
167
+
168
+ POST /chat/messages
169
+ required: body, links.organization(uri)
170
+ variant 1: links.patient(uri), optional links.attachments[uri...]
171
+ variant 2: links.patientGroup(uri)
172
+
173
+ POST /calendar/events
174
+ required: type, description, schedule.startTime(date-time),
175
+ party.clinicians[{links.clinician(uri)}],
176
+ party.patients[{links.patient(uri)}],
177
+ links.origin(uri)
178
+ optional: schedule.endTime(date-time), party.name
179
+
180
+ ## Notes
181
+ - Dates use ISO 8601: 2024-01-01T00:00:00.000Z
182
+ - Default max is 100, default offset is 0
183
+ - All list endpoints support pagination via max/offset
184
+ - Clinician-service resources use numeric {id}; other services use {uuid}
185
+ - Links are always absolute URIs: https://doccla-dev.oth.io/clinician/api/patients/123
186
+ - Schemas discoverable via each service's root links.schemas; filenames vary by endpoint
187
+ `;
package/dist/auth.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type Environment = string;
2
+ interface Credentials {
3
+ username: string;
4
+ password: string;
5
+ }
6
+ export declare class AuthManager {
7
+ private env;
8
+ private state;
9
+ private fetchFn;
10
+ constructor(env?: Environment, fetchFn?: typeof fetch);
11
+ get environment(): Environment;
12
+ get baseUrl(): string;
13
+ get isAuthenticated(): boolean;
14
+ get userInfo(): {
15
+ username: string | null;
16
+ } | null;
17
+ switchEnvironment(env: Environment): void;
18
+ getToken(): Promise<string>;
19
+ private isExpiringSoon;
20
+ private authenticate;
21
+ private refresh;
22
+ private handleAuthError;
23
+ private extractTokenVerifier;
24
+ resolveCredentials(): Promise<Credentials>;
25
+ }
26
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,192 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ function envBaseUrl(env) {
5
+ return `https://doccla-${env}.oth.io`;
6
+ }
7
+ const REFRESH_BUFFER_SECONDS = 60;
8
+ const AUTH_ERROR_MESSAGES = {
9
+ locked: "Account is locked. Contact your administrator.",
10
+ inactive: "Account is deactivated.",
11
+ invalid: "Invalid credentials. Check your username and password.",
12
+ missing: "User not found.",
13
+ unknown: "User not found.",
14
+ expired: "Password has expired. Reset your password via the OTH web UI.",
15
+ pincode_required: "MFA is enabled on this account. The MCP server does not support MFA. " +
16
+ "Use an account without MFA enabled, or disable MFA for this account.",
17
+ };
18
+ export class AuthManager {
19
+ env;
20
+ state = null;
21
+ fetchFn;
22
+ constructor(env, fetchFn) {
23
+ this.env = env ?? process.env.OTH_ENV ?? "dev";
24
+ this.fetchFn = fetchFn ?? fetch;
25
+ }
26
+ get environment() {
27
+ return this.env;
28
+ }
29
+ get baseUrl() {
30
+ return envBaseUrl(this.env);
31
+ }
32
+ get isAuthenticated() {
33
+ return this.state !== null;
34
+ }
35
+ get userInfo() {
36
+ if (!this.state)
37
+ return null;
38
+ return { username: this.state.username };
39
+ }
40
+ switchEnvironment(env) {
41
+ if (env === this.env)
42
+ return;
43
+ this.env = env;
44
+ this.state = null;
45
+ }
46
+ async getToken() {
47
+ if (this.state && !this.isExpiringSoon()) {
48
+ return this.state.token;
49
+ }
50
+ if (this.state && this.isExpiringSoon()) {
51
+ try {
52
+ await this.refresh();
53
+ return this.state.token;
54
+ }
55
+ catch {
56
+ // Refresh failed — fall through to full re-auth
57
+ this.state = null;
58
+ }
59
+ }
60
+ await this.authenticate();
61
+ return this.state.token;
62
+ }
63
+ isExpiringSoon() {
64
+ if (!this.state)
65
+ return true;
66
+ const nowSeconds = Math.floor(Date.now() / 1000);
67
+ return this.state.exp - nowSeconds < REFRESH_BUFFER_SECONDS;
68
+ }
69
+ async authenticate() {
70
+ const creds = await this.resolveCredentials();
71
+ const basic = Buffer.from(`${creds.username}:${creds.password}`).toString("base64");
72
+ const response = await this.fetchFn(`${this.baseUrl}/idp2/users/auth`, {
73
+ method: "GET",
74
+ headers: {
75
+ Authorization: `Basic ${basic}`,
76
+ Accept: "application/json",
77
+ },
78
+ });
79
+ if (!response.ok) {
80
+ await this.handleAuthError(response);
81
+ }
82
+ const cookieHeader = response.headers.get("set-cookie");
83
+ const tokenVerifier = this.extractTokenVerifier(cookieHeader);
84
+ const body = await response.json();
85
+ this.state = {
86
+ token: body.token,
87
+ refreshToken: body.refreshToken ?? null,
88
+ tokenVerifier,
89
+ exp: body.exp,
90
+ username: creds.username,
91
+ };
92
+ }
93
+ async refresh() {
94
+ if (!this.state?.refreshToken) {
95
+ throw new Error("No refresh token available");
96
+ }
97
+ const headers = {
98
+ Authorization: `Refresh ${this.state.refreshToken}`,
99
+ Accept: "application/json",
100
+ };
101
+ if (this.state.tokenVerifier) {
102
+ headers["Cookie"] = `tokenverifier=${this.state.tokenVerifier}`;
103
+ }
104
+ const response = await this.fetchFn(`${this.baseUrl}/idp2/users/auth`, { method: "GET", headers });
105
+ if (!response.ok) {
106
+ throw new Error(`Refresh failed: ${response.status}`);
107
+ }
108
+ const cookieHeader = response.headers.get("set-cookie");
109
+ const tokenVerifier = this.extractTokenVerifier(cookieHeader);
110
+ const body = await response.json();
111
+ this.state = {
112
+ ...this.state,
113
+ token: body.token,
114
+ refreshToken: body.refreshToken ?? this.state.refreshToken,
115
+ tokenVerifier: tokenVerifier ?? this.state.tokenVerifier,
116
+ exp: body.exp,
117
+ };
118
+ }
119
+ async handleAuthError(response) {
120
+ let errorCode = "unknown";
121
+ try {
122
+ const body = await response.json();
123
+ if (body.errors?.[0]?.error) {
124
+ errorCode = body.errors[0].error;
125
+ }
126
+ const message = AUTH_ERROR_MESSAGES[errorCode] ??
127
+ body.message ??
128
+ `Authentication failed (${response.status})`;
129
+ throw new Error(message);
130
+ }
131
+ catch (e) {
132
+ if (e instanceof Error && AUTH_ERROR_MESSAGES[errorCode])
133
+ throw e;
134
+ throw new Error(`Authentication failed (${response.status})`);
135
+ }
136
+ }
137
+ extractTokenVerifier(cookieHeader) {
138
+ if (!cookieHeader)
139
+ return null;
140
+ const match = cookieHeader.match(/tokenverifier=([^;]+)/);
141
+ return match?.[1] ?? null;
142
+ }
143
+ async resolveCredentials() {
144
+ // 1. Environment variables
145
+ const envUser = process.env.OTH_USERNAME;
146
+ const envPass = process.env.OTH_PASSWORD;
147
+ if (envUser && envPass) {
148
+ return { username: envUser, password: envPass };
149
+ }
150
+ // 2. macOS Keychain
151
+ if (process.platform === "darwin") {
152
+ try {
153
+ const account = `oth-${this.env}`;
154
+ const [userResult, passResult] = await Promise.all([
155
+ execFileAsync("security", [
156
+ "find-generic-password",
157
+ "-a",
158
+ account,
159
+ "-s",
160
+ "oth-mcp-username",
161
+ "-w",
162
+ ]),
163
+ execFileAsync("security", [
164
+ "find-generic-password",
165
+ "-a",
166
+ account,
167
+ "-s",
168
+ "oth-mcp-password",
169
+ "-w",
170
+ ]),
171
+ ]);
172
+ const username = userResult.stdout.trim();
173
+ const password = passResult.stdout.trim();
174
+ if (username && password) {
175
+ return { username, password };
176
+ }
177
+ }
178
+ catch {
179
+ // Keychain lookup failed — fall through to error
180
+ }
181
+ }
182
+ // 3. Fail with instructions
183
+ throw new Error(`No OTH credentials found for environment "${this.env}".\n\n` +
184
+ "Set credentials via environment variables:\n" +
185
+ " OTH_USERNAME=your-username\n" +
186
+ " OTH_PASSWORD=your-password\n" +
187
+ " OTH_ENV=dev (optional, defaults to dev)\n\n" +
188
+ "Or on macOS, store in Keychain:\n" +
189
+ ` security add-generic-password -a "oth-${this.env}" -s "oth-mcp-username" -w\n` +
190
+ ` security add-generic-password -a "oth-${this.env}" -s "oth-mcp-password" -w`);
191
+ }
192
+ }
@@ -0,0 +1,11 @@
1
+ import { AuthManager } from "./auth.js";
2
+ export declare class OthClient {
3
+ private auth;
4
+ constructor(auth: AuthManager);
5
+ get(path: string, query?: Record<string, string>): Promise<unknown>;
6
+ mutate(method: "POST" | "PUT" | "PATCH" | "DELETE", path: string, body?: Record<string, unknown>): Promise<unknown>;
7
+ private validatePath;
8
+ private buildUrl;
9
+ private processResponse;
10
+ private normalizeError;
11
+ }
package/dist/client.js ADDED
@@ -0,0 +1,90 @@
1
+ export class OthClient {
2
+ auth;
3
+ constructor(auth) {
4
+ this.auth = auth;
5
+ }
6
+ async get(path, query) {
7
+ this.validatePath(path);
8
+ const url = this.buildUrl(path, query);
9
+ const token = await this.auth.getToken();
10
+ const response = await fetch(url.toString(), {
11
+ method: "GET",
12
+ headers: {
13
+ Authorization: `Bearer ${token}`,
14
+ Accept: "application/json",
15
+ },
16
+ });
17
+ return this.processResponse(response);
18
+ }
19
+ async mutate(method, path, body) {
20
+ this.validatePath(path);
21
+ const url = this.buildUrl(path);
22
+ const token = await this.auth.getToken();
23
+ const headers = {
24
+ Authorization: `Bearer ${token}`,
25
+ Accept: "application/json",
26
+ };
27
+ const init = { method, headers };
28
+ if (body !== undefined) {
29
+ headers["Content-Type"] = "application/json";
30
+ init.body = JSON.stringify(body);
31
+ }
32
+ const response = await fetch(url.toString(), init);
33
+ return this.processResponse(response);
34
+ }
35
+ validatePath(path) {
36
+ if (/^https?:\/\//i.test(path)) {
37
+ throw new Error("Absolute URLs are not allowed. Use a relative path like /clinician/api/patients");
38
+ }
39
+ }
40
+ buildUrl(path, query) {
41
+ const normalized = path.startsWith("/") ? path : `/${path}`;
42
+ const url = new URL(normalized, this.auth.baseUrl);
43
+ if (query) {
44
+ for (const [key, value] of Object.entries(query)) {
45
+ url.searchParams.set(key, value);
46
+ }
47
+ }
48
+ return url;
49
+ }
50
+ async processResponse(response) {
51
+ if (response.ok) {
52
+ if (response.status === 204)
53
+ return { success: true };
54
+ return response.json();
55
+ }
56
+ return this.normalizeError(response);
57
+ }
58
+ async normalizeError(response) {
59
+ const contentType = response.headers.get("content-type") ?? "";
60
+ let message;
61
+ try {
62
+ if (contentType.includes("application/json")) {
63
+ const body = (await response.json());
64
+ if (body.message) {
65
+ message = body.message;
66
+ }
67
+ else if (body.errors?.length) {
68
+ message = body.errors
69
+ .map((e) => `${e.error}${e.field ? ` (${e.field})` : ""}${e.resource ? ` on ${e.resource}` : ""}`)
70
+ .join("; ");
71
+ }
72
+ else {
73
+ message = `HTTP ${response.status} ${response.statusText}`;
74
+ }
75
+ }
76
+ else {
77
+ const text = await response.text();
78
+ // Strip HTML tags if present
79
+ message = text.replace(/<[^>]*>/g, "").trim();
80
+ if (!message || message.length > 500) {
81
+ message = `HTTP ${response.status} ${response.statusText}`;
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ message = `HTTP ${response.status} ${response.statusText}`;
87
+ }
88
+ return { error: true, status: response.status, message };
89
+ }
90
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { AuthManager } from "./auth.js";
5
+ import { OthClient } from "./client.js";
6
+ import { registerTools } from "./tools.js";
7
+ import { API_GUIDE } from "./api-guide.js";
8
+ const server = new McpServer({
9
+ name: "oth-mcp",
10
+ version: "0.1.0",
11
+ });
12
+ const auth = new AuthManager();
13
+ const client = new OthClient(auth);
14
+ registerTools(server, auth, client);
15
+ server.registerResource("api-guide", "oth://api-guide", {
16
+ description: "OTH API endpoint reference — paths, query params, and request body schemas",
17
+ mimeType: "text/markdown",
18
+ annotations: {
19
+ audience: ["assistant"],
20
+ },
21
+ }, async () => ({
22
+ contents: [
23
+ {
24
+ uri: "oth://api-guide",
25
+ mimeType: "text/markdown",
26
+ text: API_GUIDE,
27
+ },
28
+ ],
29
+ }));
30
+ async function main() {
31
+ const transport = new StdioServerTransport();
32
+ await server.connect(transport);
33
+ console.error("oth-mcp server running on stdio");
34
+ }
35
+ main().catch((err) => {
36
+ console.error("Fatal error:", err);
37
+ process.exit(1);
38
+ });
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { OthClient } from "./client.js";
3
+ import { AuthManager } from "./auth.js";
4
+ export declare function registerTools(server: McpServer, auth: AuthManager, client: OthClient): void;
package/dist/tools.js ADDED
@@ -0,0 +1,150 @@
1
+ import { z } from "zod";
2
+ function isApiError(result) {
3
+ return (typeof result === "object" &&
4
+ result !== null &&
5
+ "error" in result &&
6
+ result.error === true);
7
+ }
8
+ export function registerTools(server, auth, client) {
9
+ server.registerTool("oth_get", {
10
+ title: "OTH GET Request",
11
+ description: `GET any OTH API endpoint. Auth handled automatically.
12
+ Common paths:
13
+ /clinician/api/patients?username=&firstName=&max=
14
+ /clinician/api/patients/{id}
15
+ /clinician/api/questionnaire_definitions?max=
16
+ /clinician/api/questionnaires/{id}
17
+ /clinician/api/questionnaire-results?from=&to=&max=
18
+ /measurements/measurement-types
19
+ /organizations/organizations
20
+ Read the oth://api-guide resource for full endpoint reference.`,
21
+ annotations: {
22
+ readOnlyHint: true,
23
+ destructiveHint: false,
24
+ idempotentHint: true,
25
+ openWorldHint: true,
26
+ },
27
+ inputSchema: {
28
+ path: z
29
+ .string()
30
+ .describe("API path, e.g. /clinician/api/patients/123"),
31
+ query: z
32
+ .record(z.union([z.string(), z.number(), z.boolean()]))
33
+ .optional()
34
+ .describe("Query parameters as key-value pairs"),
35
+ },
36
+ }, async ({ path, query }) => {
37
+ try {
38
+ const coerced = query
39
+ ? Object.fromEntries(Object.entries(query).map(([k, v]) => [k, String(v)]))
40
+ : undefined;
41
+ const result = await client.get(path, coerced);
42
+ if (isApiError(result)) {
43
+ return {
44
+ content: [
45
+ { type: "text", text: JSON.stringify(result, null, 2) },
46
+ ],
47
+ isError: true,
48
+ };
49
+ }
50
+ return {
51
+ content: [
52
+ { type: "text", text: JSON.stringify(result, null, 2) },
53
+ ],
54
+ };
55
+ }
56
+ catch (e) {
57
+ return {
58
+ content: [
59
+ {
60
+ type: "text",
61
+ text: e instanceof Error ? e.message : String(e),
62
+ },
63
+ ],
64
+ isError: true,
65
+ };
66
+ }
67
+ });
68
+ server.registerTool("oth_mutate", {
69
+ title: "OTH Mutate Request",
70
+ description: "POST, PUT, PATCH, or DELETE to any OTH API endpoint. Auth handled automatically.",
71
+ annotations: {
72
+ readOnlyHint: false,
73
+ destructiveHint: true,
74
+ idempotentHint: false,
75
+ openWorldHint: true,
76
+ },
77
+ inputSchema: {
78
+ method: z
79
+ .enum(["POST", "PUT", "PATCH", "DELETE"])
80
+ .default("POST")
81
+ .describe("HTTP method"),
82
+ path: z.string().describe("API path"),
83
+ body: z
84
+ .record(z.unknown())
85
+ .optional()
86
+ .describe("Request body (JSON)"),
87
+ },
88
+ }, async ({ method, path, body }) => {
89
+ try {
90
+ const result = await client.mutate(method, path, body);
91
+ if (isApiError(result)) {
92
+ return {
93
+ content: [
94
+ { type: "text", text: JSON.stringify(result, null, 2) },
95
+ ],
96
+ isError: true,
97
+ };
98
+ }
99
+ return {
100
+ content: [
101
+ { type: "text", text: JSON.stringify(result, null, 2) },
102
+ ],
103
+ };
104
+ }
105
+ catch (e) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: e instanceof Error ? e.message : String(e),
111
+ },
112
+ ],
113
+ isError: true,
114
+ };
115
+ }
116
+ });
117
+ server.registerTool("oth_env", {
118
+ title: "OTH Environment",
119
+ description: "Switch OTH environment or show current state. Returns environment, base URL, and authenticated user info.",
120
+ annotations: {
121
+ readOnlyHint: false,
122
+ destructiveHint: false,
123
+ idempotentHint: true,
124
+ openWorldHint: false,
125
+ },
126
+ inputSchema: {
127
+ environment: z
128
+ .enum(["dev", "stag", "demo"])
129
+ .optional()
130
+ .describe("Target environment. Omit to show current state."),
131
+ },
132
+ }, async ({ environment }) => {
133
+ if (environment) {
134
+ auth.switchEnvironment(environment);
135
+ }
136
+ const info = {
137
+ environment: auth.environment,
138
+ baseUrl: auth.baseUrl,
139
+ authenticated: auth.isAuthenticated,
140
+ };
141
+ if (auth.isAuthenticated) {
142
+ info.user = auth.userInfo;
143
+ }
144
+ return {
145
+ content: [
146
+ { type: "text", text: JSON.stringify(info, null, 2) },
147
+ ],
148
+ };
149
+ });
150
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "oth-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the OTH (OpenTeleHealth) API",
5
+ "type": "module",
6
+ "bin": {
7
+ "oth-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsc --watch",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp"
21
+ ],
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "zod": "^3.23.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "typescript": "^5.7.0",
30
+ "vitest": "^3.0.0"
31
+ }
32
+ }