@sodiumhq/mcp-pm 0.1.0-beta.2588
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/LICENSE +13 -0
- package/README.md +68 -0
- package/dist/index.js +1241 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026 Sodium Software Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary to Sodium Software Ltd. Use of this software
|
|
4
|
+
requires an active Sodium Practice Management subscription at the Pro tier
|
|
5
|
+
or higher. Redistribution, modification, or commercial use of this package
|
|
6
|
+
outside the terms of a valid Sodium subscription is prohibited without
|
|
7
|
+
prior written consent from Sodium Software Ltd.
|
|
8
|
+
|
|
9
|
+
This license applies only to the `@sodiumhq/mcp-pm` npm package. It does
|
|
10
|
+
not grant access to the Sodium Practice Management service itself, which
|
|
11
|
+
is governed by separate terms of service available at https://sodiumhq.com.
|
|
12
|
+
|
|
13
|
+
For questions about licensing, contact hello@sodiumhq.com.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @sodiumhq/mcp-pm
|
|
2
|
+
|
|
3
|
+
Model Context Protocol (MCP) server for [Sodium Practice Management](https://sodiumhq.com). Lets AI assistants like Claude Desktop, Claude Code, Cursor, and ChatGPT Desktop interact with your Sodium tenant.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
**Beta.** Iterating toward a stable 1.0. Install explicitly with `@beta` to opt in.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
### 1. Generate an API key
|
|
12
|
+
|
|
13
|
+
In Sodium, go to **Settings → API Keys** and create a new key. Copy it along with your tenant code.
|
|
14
|
+
|
|
15
|
+
### 2. Configure your MCP client
|
|
16
|
+
|
|
17
|
+
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"sodium-pm": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@sodiumhq/mcp-pm@beta"],
|
|
25
|
+
"env": {
|
|
26
|
+
"SODIUM_API_KEY": "your-api-key",
|
|
27
|
+
"SODIUM_TENANT": "your-tenant-code"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Claude Code, Cursor, VS Code, and other MCP clients use the same config shape in their own config files.
|
|
35
|
+
|
|
36
|
+
### 3. Restart your client
|
|
37
|
+
|
|
38
|
+
Then ask: *"give me a summary of my practice"*.
|
|
39
|
+
|
|
40
|
+
## Environment variables
|
|
41
|
+
|
|
42
|
+
| Variable | Required | Default | Description |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `SODIUM_API_KEY` | yes | — | Your Sodium API key |
|
|
45
|
+
| `SODIUM_TENANT` | yes | — | Your tenant code |
|
|
46
|
+
| `SODIUM_API_URL` | no | `https://api.sodiumhq.com` | Override for staging/dev |
|
|
47
|
+
|
|
48
|
+
## What it can do today
|
|
49
|
+
|
|
50
|
+
- **`get_practice_details`** — consolidated practice overview (counts, connections, settings)
|
|
51
|
+
- **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
|
|
52
|
+
- **`get_client_summary`** — one-call composite: client identity + contacts + active services + overdue + upcoming tasks
|
|
53
|
+
|
|
54
|
+
More tools land iteratively as the beta progresses.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Node.js 20 or later
|
|
59
|
+
- An active Sodium Practice Management subscription at the Pro tier
|
|
60
|
+
- API key and tenant code from your Sodium account
|
|
61
|
+
|
|
62
|
+
## Licence
|
|
63
|
+
|
|
64
|
+
Proprietary — see [LICENSE](./LICENSE).
|
|
65
|
+
|
|
66
|
+
## Support
|
|
67
|
+
|
|
68
|
+
[hello@sodiumhq.com](mailto:hello@sodiumhq.com) · [sodiumhq.com](https://sodiumhq.com)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region ../mcp-core/src/generated/core/bodySerializer.gen.ts
|
|
7
|
+
const jsonBodySerializer = { bodySerializer: (body) => JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value) };
|
|
8
|
+
Object.entries({
|
|
9
|
+
$body_: "body",
|
|
10
|
+
$headers_: "headers",
|
|
11
|
+
$path_: "path",
|
|
12
|
+
$query_: "query"
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region ../mcp-core/src/generated/core/serverSentEvents.gen.ts
|
|
16
|
+
function createSseClient({ onRequest, onSseError, onSseEvent, responseTransformer, responseValidator, sseDefaultRetryDelay, sseMaxRetryAttempts, sseMaxRetryDelay, sseSleepFn, url, ...options }) {
|
|
17
|
+
let lastEventId;
|
|
18
|
+
const sleep = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
19
|
+
const createStream = async function* () {
|
|
20
|
+
let retryDelay = sseDefaultRetryDelay ?? 3e3;
|
|
21
|
+
let attempt = 0;
|
|
22
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
23
|
+
while (true) {
|
|
24
|
+
if (signal.aborted) break;
|
|
25
|
+
attempt++;
|
|
26
|
+
const headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers);
|
|
27
|
+
if (lastEventId !== void 0) headers.set("Last-Event-ID", lastEventId);
|
|
28
|
+
try {
|
|
29
|
+
const requestInit = {
|
|
30
|
+
redirect: "follow",
|
|
31
|
+
...options,
|
|
32
|
+
body: options.serializedBody,
|
|
33
|
+
headers,
|
|
34
|
+
signal
|
|
35
|
+
};
|
|
36
|
+
let request = new Request(url, requestInit);
|
|
37
|
+
if (onRequest) request = await onRequest(url, requestInit);
|
|
38
|
+
const response = await (options.fetch ?? globalThis.fetch)(request);
|
|
39
|
+
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
|
40
|
+
if (!response.body) throw new Error("No body in SSE response");
|
|
41
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
42
|
+
let buffer = "";
|
|
43
|
+
const abortHandler = () => {
|
|
44
|
+
try {
|
|
45
|
+
reader.cancel();
|
|
46
|
+
} catch {}
|
|
47
|
+
};
|
|
48
|
+
signal.addEventListener("abort", abortHandler);
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
buffer += value;
|
|
54
|
+
buffer = buffer.replace(/\r\n?/g, "\n");
|
|
55
|
+
const chunks = buffer.split("\n\n");
|
|
56
|
+
buffer = chunks.pop() ?? "";
|
|
57
|
+
for (const chunk of chunks) {
|
|
58
|
+
const lines = chunk.split("\n");
|
|
59
|
+
const dataLines = [];
|
|
60
|
+
let eventName;
|
|
61
|
+
for (const line of lines) if (line.startsWith("data:")) dataLines.push(line.replace(/^data:\s*/, ""));
|
|
62
|
+
else if (line.startsWith("event:")) eventName = line.replace(/^event:\s*/, "");
|
|
63
|
+
else if (line.startsWith("id:")) lastEventId = line.replace(/^id:\s*/, "");
|
|
64
|
+
else if (line.startsWith("retry:")) {
|
|
65
|
+
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
|
66
|
+
if (!Number.isNaN(parsed)) retryDelay = parsed;
|
|
67
|
+
}
|
|
68
|
+
let data;
|
|
69
|
+
let parsedJson = false;
|
|
70
|
+
if (dataLines.length) {
|
|
71
|
+
const rawData = dataLines.join("\n");
|
|
72
|
+
try {
|
|
73
|
+
data = JSON.parse(rawData);
|
|
74
|
+
parsedJson = true;
|
|
75
|
+
} catch {
|
|
76
|
+
data = rawData;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (parsedJson) {
|
|
80
|
+
if (responseValidator) await responseValidator(data);
|
|
81
|
+
if (responseTransformer) data = await responseTransformer(data);
|
|
82
|
+
}
|
|
83
|
+
onSseEvent?.({
|
|
84
|
+
data,
|
|
85
|
+
event: eventName,
|
|
86
|
+
id: lastEventId,
|
|
87
|
+
retry: retryDelay
|
|
88
|
+
});
|
|
89
|
+
if (dataLines.length) yield data;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
signal.removeEventListener("abort", abortHandler);
|
|
94
|
+
reader.releaseLock();
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
onSseError?.(error);
|
|
99
|
+
if (sseMaxRetryAttempts !== void 0 && attempt >= sseMaxRetryAttempts) break;
|
|
100
|
+
await sleep(Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 3e4));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return { stream: createStream() };
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region ../mcp-core/src/generated/core/pathSerializer.gen.ts
|
|
108
|
+
const separatorArrayExplode = (style) => {
|
|
109
|
+
switch (style) {
|
|
110
|
+
case "label": return ".";
|
|
111
|
+
case "matrix": return ";";
|
|
112
|
+
case "simple": return ",";
|
|
113
|
+
default: return "&";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const separatorArrayNoExplode = (style) => {
|
|
117
|
+
switch (style) {
|
|
118
|
+
case "form": return ",";
|
|
119
|
+
case "pipeDelimited": return "|";
|
|
120
|
+
case "spaceDelimited": return "%20";
|
|
121
|
+
default: return ",";
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const separatorObjectExplode = (style) => {
|
|
125
|
+
switch (style) {
|
|
126
|
+
case "label": return ".";
|
|
127
|
+
case "matrix": return ";";
|
|
128
|
+
case "simple": return ",";
|
|
129
|
+
default: return "&";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const serializeArrayParam = ({ allowReserved, explode, name, style, value }) => {
|
|
133
|
+
if (!explode) {
|
|
134
|
+
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v))).join(separatorArrayNoExplode(style));
|
|
135
|
+
switch (style) {
|
|
136
|
+
case "label": return `.${joinedValues}`;
|
|
137
|
+
case "matrix": return `;${name}=${joinedValues}`;
|
|
138
|
+
case "simple": return joinedValues;
|
|
139
|
+
default: return `${name}=${joinedValues}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const separator = separatorArrayExplode(style);
|
|
143
|
+
const joinedValues = value.map((v) => {
|
|
144
|
+
if (style === "label" || style === "simple") return allowReserved ? v : encodeURIComponent(v);
|
|
145
|
+
return serializePrimitiveParam({
|
|
146
|
+
allowReserved,
|
|
147
|
+
name,
|
|
148
|
+
value: v
|
|
149
|
+
});
|
|
150
|
+
}).join(separator);
|
|
151
|
+
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
|
152
|
+
};
|
|
153
|
+
const serializePrimitiveParam = ({ allowReserved, name, value }) => {
|
|
154
|
+
if (value === void 0 || value === null) return "";
|
|
155
|
+
if (typeof value === "object") throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");
|
|
156
|
+
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
|
157
|
+
};
|
|
158
|
+
const serializeObjectParam = ({ allowReserved, explode, name, style, value, valueOnly }) => {
|
|
159
|
+
if (value instanceof Date) return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
|
160
|
+
if (style !== "deepObject" && !explode) {
|
|
161
|
+
let values = [];
|
|
162
|
+
Object.entries(value).forEach(([key, v]) => {
|
|
163
|
+
values = [
|
|
164
|
+
...values,
|
|
165
|
+
key,
|
|
166
|
+
allowReserved ? v : encodeURIComponent(v)
|
|
167
|
+
];
|
|
168
|
+
});
|
|
169
|
+
const joinedValues = values.join(",");
|
|
170
|
+
switch (style) {
|
|
171
|
+
case "form": return `${name}=${joinedValues}`;
|
|
172
|
+
case "label": return `.${joinedValues}`;
|
|
173
|
+
case "matrix": return `;${name}=${joinedValues}`;
|
|
174
|
+
default: return joinedValues;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const separator = separatorObjectExplode(style);
|
|
178
|
+
const joinedValues = Object.entries(value).map(([key, v]) => serializePrimitiveParam({
|
|
179
|
+
allowReserved,
|
|
180
|
+
name: style === "deepObject" ? `${name}[${key}]` : key,
|
|
181
|
+
value: v
|
|
182
|
+
})).join(separator);
|
|
183
|
+
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
|
184
|
+
};
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region ../mcp-core/src/generated/core/utils.gen.ts
|
|
187
|
+
const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
|
188
|
+
const defaultPathSerializer = ({ path, url: _url }) => {
|
|
189
|
+
let url = _url;
|
|
190
|
+
const matches = _url.match(PATH_PARAM_RE);
|
|
191
|
+
if (matches) for (const match of matches) {
|
|
192
|
+
let explode = false;
|
|
193
|
+
let name = match.substring(1, match.length - 1);
|
|
194
|
+
let style = "simple";
|
|
195
|
+
if (name.endsWith("*")) {
|
|
196
|
+
explode = true;
|
|
197
|
+
name = name.substring(0, name.length - 1);
|
|
198
|
+
}
|
|
199
|
+
if (name.startsWith(".")) {
|
|
200
|
+
name = name.substring(1);
|
|
201
|
+
style = "label";
|
|
202
|
+
} else if (name.startsWith(";")) {
|
|
203
|
+
name = name.substring(1);
|
|
204
|
+
style = "matrix";
|
|
205
|
+
}
|
|
206
|
+
const value = path[name];
|
|
207
|
+
if (value === void 0 || value === null) continue;
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
url = url.replace(match, serializeArrayParam({
|
|
210
|
+
explode,
|
|
211
|
+
name,
|
|
212
|
+
style,
|
|
213
|
+
value
|
|
214
|
+
}));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (typeof value === "object") {
|
|
218
|
+
url = url.replace(match, serializeObjectParam({
|
|
219
|
+
explode,
|
|
220
|
+
name,
|
|
221
|
+
style,
|
|
222
|
+
value,
|
|
223
|
+
valueOnly: true
|
|
224
|
+
}));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (style === "matrix") {
|
|
228
|
+
url = url.replace(match, `;${serializePrimitiveParam({
|
|
229
|
+
name,
|
|
230
|
+
value
|
|
231
|
+
})}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const replaceValue = encodeURIComponent(style === "label" ? `.${value}` : value);
|
|
235
|
+
url = url.replace(match, replaceValue);
|
|
236
|
+
}
|
|
237
|
+
return url;
|
|
238
|
+
};
|
|
239
|
+
const getUrl = ({ baseUrl, path, query, querySerializer, url: _url }) => {
|
|
240
|
+
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
|
241
|
+
let url = (baseUrl ?? "") + pathUrl;
|
|
242
|
+
if (path) url = defaultPathSerializer({
|
|
243
|
+
path,
|
|
244
|
+
url
|
|
245
|
+
});
|
|
246
|
+
let search = query ? querySerializer(query) : "";
|
|
247
|
+
if (search.startsWith("?")) search = search.substring(1);
|
|
248
|
+
if (search) url += `?${search}`;
|
|
249
|
+
return url;
|
|
250
|
+
};
|
|
251
|
+
function getValidRequestBody(options) {
|
|
252
|
+
const hasBody = options.body !== void 0;
|
|
253
|
+
if (hasBody && options.bodySerializer) {
|
|
254
|
+
if ("serializedBody" in options) return options.serializedBody !== void 0 && options.serializedBody !== "" ? options.serializedBody : null;
|
|
255
|
+
return options.body !== "" ? options.body : null;
|
|
256
|
+
}
|
|
257
|
+
if (hasBody) return options.body;
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region ../mcp-core/src/generated/core/auth.gen.ts
|
|
261
|
+
const getAuthToken = async (auth, callback) => {
|
|
262
|
+
const token = typeof callback === "function" ? await callback(auth) : callback;
|
|
263
|
+
if (!token) return;
|
|
264
|
+
if (auth.scheme === "bearer") return `Bearer ${token}`;
|
|
265
|
+
if (auth.scheme === "basic") return `Basic ${btoa(token)}`;
|
|
266
|
+
return token;
|
|
267
|
+
};
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region ../mcp-core/src/generated/client/utils.gen.ts
|
|
270
|
+
const createQuerySerializer = ({ parameters = {}, ...args } = {}) => {
|
|
271
|
+
const querySerializer = (queryParams) => {
|
|
272
|
+
const search = [];
|
|
273
|
+
if (queryParams && typeof queryParams === "object") for (const name in queryParams) {
|
|
274
|
+
const value = queryParams[name];
|
|
275
|
+
if (value === void 0 || value === null) continue;
|
|
276
|
+
const options = parameters[name] || args;
|
|
277
|
+
if (Array.isArray(value)) {
|
|
278
|
+
const serializedArray = serializeArrayParam({
|
|
279
|
+
allowReserved: options.allowReserved,
|
|
280
|
+
explode: true,
|
|
281
|
+
name,
|
|
282
|
+
style: "form",
|
|
283
|
+
value,
|
|
284
|
+
...options.array
|
|
285
|
+
});
|
|
286
|
+
if (serializedArray) search.push(serializedArray);
|
|
287
|
+
} else if (typeof value === "object") {
|
|
288
|
+
const serializedObject = serializeObjectParam({
|
|
289
|
+
allowReserved: options.allowReserved,
|
|
290
|
+
explode: true,
|
|
291
|
+
name,
|
|
292
|
+
style: "deepObject",
|
|
293
|
+
value,
|
|
294
|
+
...options.object
|
|
295
|
+
});
|
|
296
|
+
if (serializedObject) search.push(serializedObject);
|
|
297
|
+
} else {
|
|
298
|
+
const serializedPrimitive = serializePrimitiveParam({
|
|
299
|
+
allowReserved: options.allowReserved,
|
|
300
|
+
name,
|
|
301
|
+
value
|
|
302
|
+
});
|
|
303
|
+
if (serializedPrimitive) search.push(serializedPrimitive);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return search.join("&");
|
|
307
|
+
};
|
|
308
|
+
return querySerializer;
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* Infers parseAs value from provided Content-Type header.
|
|
312
|
+
*/
|
|
313
|
+
const getParseAs = (contentType) => {
|
|
314
|
+
if (!contentType) return "stream";
|
|
315
|
+
const cleanContent = contentType.split(";")[0]?.trim();
|
|
316
|
+
if (!cleanContent) return;
|
|
317
|
+
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) return "json";
|
|
318
|
+
if (cleanContent === "multipart/form-data") return "formData";
|
|
319
|
+
if ([
|
|
320
|
+
"application/",
|
|
321
|
+
"audio/",
|
|
322
|
+
"image/",
|
|
323
|
+
"video/"
|
|
324
|
+
].some((type) => cleanContent.startsWith(type))) return "blob";
|
|
325
|
+
if (cleanContent.startsWith("text/")) return "text";
|
|
326
|
+
};
|
|
327
|
+
const checkForExistence = (options, name) => {
|
|
328
|
+
if (!name) return false;
|
|
329
|
+
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) return true;
|
|
330
|
+
return false;
|
|
331
|
+
};
|
|
332
|
+
const setAuthParams = async ({ security, ...options }) => {
|
|
333
|
+
for (const auth of security) {
|
|
334
|
+
if (checkForExistence(options, auth.name)) continue;
|
|
335
|
+
const token = await getAuthToken(auth, options.auth);
|
|
336
|
+
if (!token) continue;
|
|
337
|
+
const name = auth.name ?? "Authorization";
|
|
338
|
+
switch (auth.in) {
|
|
339
|
+
case "query":
|
|
340
|
+
if (!options.query) options.query = {};
|
|
341
|
+
options.query[name] = token;
|
|
342
|
+
break;
|
|
343
|
+
case "cookie":
|
|
344
|
+
options.headers.append("Cookie", `${name}=${token}`);
|
|
345
|
+
break;
|
|
346
|
+
default:
|
|
347
|
+
options.headers.set(name, token);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const buildUrl = (options) => getUrl({
|
|
353
|
+
baseUrl: options.baseUrl,
|
|
354
|
+
path: options.path,
|
|
355
|
+
query: options.query,
|
|
356
|
+
querySerializer: typeof options.querySerializer === "function" ? options.querySerializer : createQuerySerializer(options.querySerializer),
|
|
357
|
+
url: options.url
|
|
358
|
+
});
|
|
359
|
+
const mergeConfigs = (a, b) => {
|
|
360
|
+
const config = {
|
|
361
|
+
...a,
|
|
362
|
+
...b
|
|
363
|
+
};
|
|
364
|
+
if (config.baseUrl?.endsWith("/")) config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
|
365
|
+
config.headers = mergeHeaders(a.headers, b.headers);
|
|
366
|
+
return config;
|
|
367
|
+
};
|
|
368
|
+
const headersEntries = (headers) => {
|
|
369
|
+
const entries = [];
|
|
370
|
+
headers.forEach((value, key) => {
|
|
371
|
+
entries.push([key, value]);
|
|
372
|
+
});
|
|
373
|
+
return entries;
|
|
374
|
+
};
|
|
375
|
+
const mergeHeaders = (...headers) => {
|
|
376
|
+
const mergedHeaders = new Headers();
|
|
377
|
+
for (const header of headers) {
|
|
378
|
+
if (!header) continue;
|
|
379
|
+
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
|
380
|
+
for (const [key, value] of iterator) if (value === null) mergedHeaders.delete(key);
|
|
381
|
+
else if (Array.isArray(value)) for (const v of value) mergedHeaders.append(key, v);
|
|
382
|
+
else if (value !== void 0) mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : value);
|
|
383
|
+
}
|
|
384
|
+
return mergedHeaders;
|
|
385
|
+
};
|
|
386
|
+
var Interceptors = class {
|
|
387
|
+
fns = [];
|
|
388
|
+
clear() {
|
|
389
|
+
this.fns = [];
|
|
390
|
+
}
|
|
391
|
+
eject(id) {
|
|
392
|
+
const index = this.getInterceptorIndex(id);
|
|
393
|
+
if (this.fns[index]) this.fns[index] = null;
|
|
394
|
+
}
|
|
395
|
+
exists(id) {
|
|
396
|
+
const index = this.getInterceptorIndex(id);
|
|
397
|
+
return Boolean(this.fns[index]);
|
|
398
|
+
}
|
|
399
|
+
getInterceptorIndex(id) {
|
|
400
|
+
if (typeof id === "number") return this.fns[id] ? id : -1;
|
|
401
|
+
return this.fns.indexOf(id);
|
|
402
|
+
}
|
|
403
|
+
update(id, fn) {
|
|
404
|
+
const index = this.getInterceptorIndex(id);
|
|
405
|
+
if (this.fns[index]) {
|
|
406
|
+
this.fns[index] = fn;
|
|
407
|
+
return id;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
use(fn) {
|
|
412
|
+
this.fns.push(fn);
|
|
413
|
+
return this.fns.length - 1;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const createInterceptors = () => ({
|
|
417
|
+
error: new Interceptors(),
|
|
418
|
+
request: new Interceptors(),
|
|
419
|
+
response: new Interceptors()
|
|
420
|
+
});
|
|
421
|
+
const defaultQuerySerializer = createQuerySerializer({
|
|
422
|
+
allowReserved: false,
|
|
423
|
+
array: {
|
|
424
|
+
explode: true,
|
|
425
|
+
style: "form"
|
|
426
|
+
},
|
|
427
|
+
object: {
|
|
428
|
+
explode: true,
|
|
429
|
+
style: "deepObject"
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
const defaultHeaders = { "Content-Type": "application/json" };
|
|
433
|
+
const createConfig = (override = {}) => ({
|
|
434
|
+
...jsonBodySerializer,
|
|
435
|
+
headers: defaultHeaders,
|
|
436
|
+
parseAs: "auto",
|
|
437
|
+
querySerializer: defaultQuerySerializer,
|
|
438
|
+
...override
|
|
439
|
+
});
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region ../mcp-core/src/generated/client/client.gen.ts
|
|
442
|
+
const createClient = (config = {}) => {
|
|
443
|
+
let _config = mergeConfigs(createConfig(), config);
|
|
444
|
+
const getConfig = () => ({ ..._config });
|
|
445
|
+
const setConfig = (config) => {
|
|
446
|
+
_config = mergeConfigs(_config, config);
|
|
447
|
+
return getConfig();
|
|
448
|
+
};
|
|
449
|
+
const interceptors = createInterceptors();
|
|
450
|
+
const beforeRequest = async (options) => {
|
|
451
|
+
const opts = {
|
|
452
|
+
..._config,
|
|
453
|
+
...options,
|
|
454
|
+
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
|
455
|
+
headers: mergeHeaders(_config.headers, options.headers),
|
|
456
|
+
serializedBody: void 0
|
|
457
|
+
};
|
|
458
|
+
if (opts.security) await setAuthParams({
|
|
459
|
+
...opts,
|
|
460
|
+
security: opts.security
|
|
461
|
+
});
|
|
462
|
+
if (opts.requestValidator) await opts.requestValidator(opts);
|
|
463
|
+
if (opts.body !== void 0 && opts.bodySerializer) opts.serializedBody = opts.bodySerializer(opts.body);
|
|
464
|
+
if (opts.body === void 0 || opts.serializedBody === "") opts.headers.delete("Content-Type");
|
|
465
|
+
const resolvedOpts = opts;
|
|
466
|
+
return {
|
|
467
|
+
opts: resolvedOpts,
|
|
468
|
+
url: buildUrl(resolvedOpts)
|
|
469
|
+
};
|
|
470
|
+
};
|
|
471
|
+
const request = async (options) => {
|
|
472
|
+
const { opts, url } = await beforeRequest(options);
|
|
473
|
+
const requestInit = {
|
|
474
|
+
redirect: "follow",
|
|
475
|
+
...opts,
|
|
476
|
+
body: getValidRequestBody(opts)
|
|
477
|
+
};
|
|
478
|
+
let request = new Request(url, requestInit);
|
|
479
|
+
for (const fn of interceptors.request.fns) if (fn) request = await fn(request, opts);
|
|
480
|
+
const _fetch = opts.fetch;
|
|
481
|
+
let response;
|
|
482
|
+
try {
|
|
483
|
+
response = await _fetch(request);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
let finalError = error;
|
|
486
|
+
for (const fn of interceptors.error.fns) if (fn) finalError = await fn(error, void 0, request, opts);
|
|
487
|
+
finalError = finalError || {};
|
|
488
|
+
if (opts.throwOnError) throw finalError;
|
|
489
|
+
return opts.responseStyle === "data" ? void 0 : {
|
|
490
|
+
error: finalError,
|
|
491
|
+
request,
|
|
492
|
+
response: void 0
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
for (const fn of interceptors.response.fns) if (fn) response = await fn(response, request, opts);
|
|
496
|
+
const result = {
|
|
497
|
+
request,
|
|
498
|
+
response
|
|
499
|
+
};
|
|
500
|
+
if (response.ok) {
|
|
501
|
+
const parseAs = (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
|
502
|
+
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
|
503
|
+
let emptyData;
|
|
504
|
+
switch (parseAs) {
|
|
505
|
+
case "arrayBuffer":
|
|
506
|
+
case "blob":
|
|
507
|
+
case "text":
|
|
508
|
+
emptyData = await response[parseAs]();
|
|
509
|
+
break;
|
|
510
|
+
case "formData":
|
|
511
|
+
emptyData = new FormData();
|
|
512
|
+
break;
|
|
513
|
+
case "stream":
|
|
514
|
+
emptyData = response.body;
|
|
515
|
+
break;
|
|
516
|
+
default:
|
|
517
|
+
emptyData = {};
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
return opts.responseStyle === "data" ? emptyData : {
|
|
521
|
+
data: emptyData,
|
|
522
|
+
...result
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
let data;
|
|
526
|
+
switch (parseAs) {
|
|
527
|
+
case "arrayBuffer":
|
|
528
|
+
case "blob":
|
|
529
|
+
case "formData":
|
|
530
|
+
case "text":
|
|
531
|
+
data = await response[parseAs]();
|
|
532
|
+
break;
|
|
533
|
+
case "json": {
|
|
534
|
+
const text = await response.text();
|
|
535
|
+
data = text ? JSON.parse(text) : {};
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case "stream": return opts.responseStyle === "data" ? response.body : {
|
|
539
|
+
data: response.body,
|
|
540
|
+
...result
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (parseAs === "json") {
|
|
544
|
+
if (opts.responseValidator) await opts.responseValidator(data);
|
|
545
|
+
if (opts.responseTransformer) data = await opts.responseTransformer(data);
|
|
546
|
+
}
|
|
547
|
+
return opts.responseStyle === "data" ? data : {
|
|
548
|
+
data,
|
|
549
|
+
...result
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const textError = await response.text();
|
|
553
|
+
let jsonError;
|
|
554
|
+
try {
|
|
555
|
+
jsonError = JSON.parse(textError);
|
|
556
|
+
} catch {}
|
|
557
|
+
const error = jsonError ?? textError;
|
|
558
|
+
let finalError = error;
|
|
559
|
+
for (const fn of interceptors.error.fns) if (fn) finalError = await fn(error, response, request, opts);
|
|
560
|
+
finalError = finalError || {};
|
|
561
|
+
if (opts.throwOnError) throw finalError;
|
|
562
|
+
return opts.responseStyle === "data" ? void 0 : {
|
|
563
|
+
error: finalError,
|
|
564
|
+
...result
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
const makeMethodFn = (method) => (options) => request({
|
|
568
|
+
...options,
|
|
569
|
+
method
|
|
570
|
+
});
|
|
571
|
+
const makeSseFn = (method) => async (options) => {
|
|
572
|
+
const { opts, url } = await beforeRequest(options);
|
|
573
|
+
return createSseClient({
|
|
574
|
+
...opts,
|
|
575
|
+
body: opts.body,
|
|
576
|
+
headers: opts.headers,
|
|
577
|
+
method,
|
|
578
|
+
onRequest: async (url, init) => {
|
|
579
|
+
let request = new Request(url, init);
|
|
580
|
+
for (const fn of interceptors.request.fns) if (fn) request = await fn(request, opts);
|
|
581
|
+
return request;
|
|
582
|
+
},
|
|
583
|
+
serializedBody: getValidRequestBody(opts),
|
|
584
|
+
url
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
const _buildUrl = (options) => buildUrl({
|
|
588
|
+
..._config,
|
|
589
|
+
...options
|
|
590
|
+
});
|
|
591
|
+
return {
|
|
592
|
+
buildUrl: _buildUrl,
|
|
593
|
+
connect: makeMethodFn("CONNECT"),
|
|
594
|
+
delete: makeMethodFn("DELETE"),
|
|
595
|
+
get: makeMethodFn("GET"),
|
|
596
|
+
getConfig,
|
|
597
|
+
head: makeMethodFn("HEAD"),
|
|
598
|
+
interceptors,
|
|
599
|
+
options: makeMethodFn("OPTIONS"),
|
|
600
|
+
patch: makeMethodFn("PATCH"),
|
|
601
|
+
post: makeMethodFn("POST"),
|
|
602
|
+
put: makeMethodFn("PUT"),
|
|
603
|
+
request,
|
|
604
|
+
setConfig,
|
|
605
|
+
sse: {
|
|
606
|
+
connect: makeSseFn("CONNECT"),
|
|
607
|
+
delete: makeSseFn("DELETE"),
|
|
608
|
+
get: makeSseFn("GET"),
|
|
609
|
+
head: makeSseFn("HEAD"),
|
|
610
|
+
options: makeSseFn("OPTIONS"),
|
|
611
|
+
patch: makeSseFn("PATCH"),
|
|
612
|
+
post: makeSseFn("POST"),
|
|
613
|
+
put: makeSseFn("PUT"),
|
|
614
|
+
trace: makeSseFn("TRACE")
|
|
615
|
+
},
|
|
616
|
+
trace: makeMethodFn("TRACE")
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region ../mcp-core/src/generated/client.gen.ts
|
|
621
|
+
const client = createClient(createConfig());
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region ../mcp-core/src/generated/sdk.gen.ts
|
|
624
|
+
/**
|
|
625
|
+
* List Contacts for Client
|
|
626
|
+
*
|
|
627
|
+
* Lists all Contacts for the specified client.
|
|
628
|
+
*/
|
|
629
|
+
const listClientContactsForClient = (options) => (options.client ?? client).get({
|
|
630
|
+
security: [{
|
|
631
|
+
name: "x-api-key",
|
|
632
|
+
type: "apiKey"
|
|
633
|
+
}, {
|
|
634
|
+
scheme: "bearer",
|
|
635
|
+
type: "http"
|
|
636
|
+
}],
|
|
637
|
+
url: "/tenants/{tenant}/clients/{client}/clientcontact",
|
|
638
|
+
...options
|
|
639
|
+
});
|
|
640
|
+
/**
|
|
641
|
+
* List Client Services for Client
|
|
642
|
+
*
|
|
643
|
+
* Lists all Client Services for the specified client.
|
|
644
|
+
*/
|
|
645
|
+
const listClientBillableServicesForClient = (options) => (options.client ?? client).get({
|
|
646
|
+
security: [{
|
|
647
|
+
name: "x-api-key",
|
|
648
|
+
type: "apiKey"
|
|
649
|
+
}, {
|
|
650
|
+
scheme: "bearer",
|
|
651
|
+
type: "http"
|
|
652
|
+
}],
|
|
653
|
+
url: "/tenants/{tenant}/clients/{client}/services/clientbillableservice",
|
|
654
|
+
...options
|
|
655
|
+
});
|
|
656
|
+
/**
|
|
657
|
+
* List Clients
|
|
658
|
+
*
|
|
659
|
+
* Lists Clients for the given tenant.
|
|
660
|
+
*
|
|
661
|
+
* Supports filtering by manager, partner, associate, status, type, service, and search term.
|
|
662
|
+
* Optionally apply a saved filter by code — saved filter values are used unless explicitly overridden by query parameters.
|
|
663
|
+
*/
|
|
664
|
+
const listClients = (options) => (options.client ?? client).get({
|
|
665
|
+
security: [{
|
|
666
|
+
name: "x-api-key",
|
|
667
|
+
type: "apiKey"
|
|
668
|
+
}, {
|
|
669
|
+
scheme: "bearer",
|
|
670
|
+
type: "http"
|
|
671
|
+
}],
|
|
672
|
+
url: "/tenants/{tenant}/clients",
|
|
673
|
+
...options
|
|
674
|
+
});
|
|
675
|
+
/**
|
|
676
|
+
* Get Client
|
|
677
|
+
*
|
|
678
|
+
* Gets a Client for the specified tenant.
|
|
679
|
+
*/
|
|
680
|
+
const getClient = (options) => (options.client ?? client).get({
|
|
681
|
+
security: [{
|
|
682
|
+
name: "x-api-key",
|
|
683
|
+
type: "apiKey"
|
|
684
|
+
}, {
|
|
685
|
+
scheme: "bearer",
|
|
686
|
+
type: "http"
|
|
687
|
+
}],
|
|
688
|
+
url: "/tenants/{tenant}/clients/{code}",
|
|
689
|
+
...options
|
|
690
|
+
});
|
|
691
|
+
/**
|
|
692
|
+
* Get Practice Details
|
|
693
|
+
*
|
|
694
|
+
* Returns the practice details for the specified tenant
|
|
695
|
+
*/
|
|
696
|
+
const getPracticeDetails = (options) => (options.client ?? client).get({
|
|
697
|
+
security: [{
|
|
698
|
+
name: "x-api-key",
|
|
699
|
+
type: "apiKey"
|
|
700
|
+
}, {
|
|
701
|
+
scheme: "bearer",
|
|
702
|
+
type: "http"
|
|
703
|
+
}],
|
|
704
|
+
url: "/tenants/{tenant}/practice",
|
|
705
|
+
...options
|
|
706
|
+
});
|
|
707
|
+
/**
|
|
708
|
+
* List TaskItems
|
|
709
|
+
*
|
|
710
|
+
* Lists TaskItems for the given tenant.
|
|
711
|
+
*
|
|
712
|
+
* **Date Range Options:**
|
|
713
|
+
* - Use dateRange for preset ranges (ThisWeek, ThisMonth, Today, etc.)
|
|
714
|
+
* - Use startDate/endDate for custom ranges
|
|
715
|
+
* - Date range is required when querying NotStarted tasks (or no status filter)
|
|
716
|
+
* - Date range is NOT required when filtering by non-NotStarted statuses only (e.g., InProgress, Completed)
|
|
717
|
+
* - Date range is NOT required when using isOverdue=true
|
|
718
|
+
*
|
|
719
|
+
* **Overdue Mode (isOverdue=true):**
|
|
720
|
+
* - Returns only tasks where DueDate < today
|
|
721
|
+
* - Automatically excludes Completed and Skipped statuses unless you specify a status filter
|
|
722
|
+
* - No date range required
|
|
723
|
+
*
|
|
724
|
+
* **Standard Mode (includeWorkflowSteps=false, default):**
|
|
725
|
+
* - Returns only tasks (materialised and optionally projected)
|
|
726
|
+
* - Use dateBasis to specify which date field to use for filtering: StartDate (default) or DueDate
|
|
727
|
+
*
|
|
728
|
+
* **Agenda Mode (includeWorkflowSteps=true):**
|
|
729
|
+
* - Returns both tasks AND workflow steps as TaskItemDto objects
|
|
730
|
+
* - Tasks: Returned with all standard TaskItem properties, WorkflowStepDetails = null
|
|
731
|
+
* - Workflow Steps: Returned with parent task properties populated, WorkflowStepDetails contains step-specific information
|
|
732
|
+
*
|
|
733
|
+
* Supports filtering by user(s), client(s), recurring task(s), category, date range, status, and isOverdue.
|
|
734
|
+
*/
|
|
735
|
+
const listTaskItems = (options) => (options.client ?? client).get({
|
|
736
|
+
security: [{
|
|
737
|
+
name: "x-api-key",
|
|
738
|
+
type: "apiKey"
|
|
739
|
+
}, {
|
|
740
|
+
scheme: "bearer",
|
|
741
|
+
type: "http"
|
|
742
|
+
}],
|
|
743
|
+
url: "/tenants/{tenant}/tasks",
|
|
744
|
+
...options
|
|
745
|
+
});
|
|
746
|
+
/**
|
|
747
|
+
* Get Tenant
|
|
748
|
+
*
|
|
749
|
+
* Retrieves the details of a tenant using its Code identifier.
|
|
750
|
+
*/
|
|
751
|
+
const getTenantByCode = (options) => (options.client ?? client).get({
|
|
752
|
+
security: [{
|
|
753
|
+
name: "x-api-key",
|
|
754
|
+
type: "apiKey"
|
|
755
|
+
}, {
|
|
756
|
+
scheme: "bearer",
|
|
757
|
+
type: "http"
|
|
758
|
+
}],
|
|
759
|
+
url: "/tenants/{code}",
|
|
760
|
+
...options
|
|
761
|
+
});
|
|
762
|
+
/**
|
|
763
|
+
* Get current authenticated user
|
|
764
|
+
*
|
|
765
|
+
* Returns the profile information of the currently authenticated user
|
|
766
|
+
*/
|
|
767
|
+
const getCurrentUser = (options) => (options?.client ?? client).get({
|
|
768
|
+
security: [{
|
|
769
|
+
name: "x-api-key",
|
|
770
|
+
type: "apiKey"
|
|
771
|
+
}, {
|
|
772
|
+
scheme: "bearer",
|
|
773
|
+
type: "http"
|
|
774
|
+
}],
|
|
775
|
+
url: "/users/me",
|
|
776
|
+
...options
|
|
777
|
+
});
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region ../mcp-core/src/http/client.ts
|
|
780
|
+
var SodiumApiError = class extends Error {
|
|
781
|
+
constructor(message, statusCode, correlationId) {
|
|
782
|
+
super(message);
|
|
783
|
+
this.statusCode = statusCode;
|
|
784
|
+
this.correlationId = correlationId;
|
|
785
|
+
this.name = "SodiumApiError";
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
var SodiumApiClient = class {
|
|
789
|
+
constructor(ctx) {
|
|
790
|
+
this.ctx = ctx;
|
|
791
|
+
client.setConfig({
|
|
792
|
+
baseUrl: ctx.baseUrl,
|
|
793
|
+
headers: { "x-api-key": ctx.apiKey }
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
async getPracticeDetails() {
|
|
797
|
+
const correlationId = randomUUID();
|
|
798
|
+
const { data, error, response } = await getPracticeDetails({
|
|
799
|
+
path: { tenant: this.ctx.tenant },
|
|
800
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
801
|
+
});
|
|
802
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "get practice details");
|
|
803
|
+
return data;
|
|
804
|
+
}
|
|
805
|
+
async getTenantDetails() {
|
|
806
|
+
const correlationId = randomUUID();
|
|
807
|
+
const { data, error, response } = await getTenantByCode({
|
|
808
|
+
path: { code: this.ctx.tenant },
|
|
809
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
810
|
+
});
|
|
811
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "get tenant details");
|
|
812
|
+
return data;
|
|
813
|
+
}
|
|
814
|
+
async getCurrentUser() {
|
|
815
|
+
const correlationId = randomUUID();
|
|
816
|
+
const { data, error, response } = await getCurrentUser({ headers: { "X-Correlation-Id": correlationId } });
|
|
817
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "get current user");
|
|
818
|
+
return data;
|
|
819
|
+
}
|
|
820
|
+
async listClients(query = {}) {
|
|
821
|
+
const correlationId = randomUUID();
|
|
822
|
+
const { data, error, response } = await listClients({
|
|
823
|
+
path: { tenant: this.ctx.tenant },
|
|
824
|
+
query: {
|
|
825
|
+
...query,
|
|
826
|
+
limit: query.limit ?? 10,
|
|
827
|
+
offset: query.offset ?? 0
|
|
828
|
+
},
|
|
829
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
830
|
+
});
|
|
831
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list clients");
|
|
832
|
+
return data;
|
|
833
|
+
}
|
|
834
|
+
async getClient(code) {
|
|
835
|
+
const correlationId = randomUUID();
|
|
836
|
+
const { data, error, response } = await getClient({
|
|
837
|
+
path: {
|
|
838
|
+
tenant: this.ctx.tenant,
|
|
839
|
+
code
|
|
840
|
+
},
|
|
841
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
842
|
+
});
|
|
843
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get client ${code}`);
|
|
844
|
+
return data;
|
|
845
|
+
}
|
|
846
|
+
async listClientContacts(clientCode, options) {
|
|
847
|
+
const correlationId = randomUUID();
|
|
848
|
+
const { data, error, response } = await listClientContactsForClient({
|
|
849
|
+
path: {
|
|
850
|
+
tenant: this.ctx.tenant,
|
|
851
|
+
client: clientCode
|
|
852
|
+
},
|
|
853
|
+
query: {
|
|
854
|
+
limit: options?.limit ?? 50,
|
|
855
|
+
offset: options?.offset ?? 0
|
|
856
|
+
},
|
|
857
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
858
|
+
});
|
|
859
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list contacts for client ${clientCode}`);
|
|
860
|
+
return data;
|
|
861
|
+
}
|
|
862
|
+
async listClientServices(clientCode, options) {
|
|
863
|
+
const correlationId = randomUUID();
|
|
864
|
+
const { data, error, response } = await listClientBillableServicesForClient({
|
|
865
|
+
path: {
|
|
866
|
+
tenant: this.ctx.tenant,
|
|
867
|
+
client: clientCode
|
|
868
|
+
},
|
|
869
|
+
query: {
|
|
870
|
+
limit: options?.limit ?? 50,
|
|
871
|
+
offset: options?.offset ?? 0
|
|
872
|
+
},
|
|
873
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
874
|
+
});
|
|
875
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list services for client ${clientCode}`);
|
|
876
|
+
return data;
|
|
877
|
+
}
|
|
878
|
+
async listTasks(query = {}) {
|
|
879
|
+
const correlationId = randomUUID();
|
|
880
|
+
const { data, error, response } = await listTaskItems({
|
|
881
|
+
path: { tenant: this.ctx.tenant },
|
|
882
|
+
query: {
|
|
883
|
+
...query,
|
|
884
|
+
limit: query.limit ?? 10,
|
|
885
|
+
offset: query.offset ?? 0
|
|
886
|
+
},
|
|
887
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
888
|
+
});
|
|
889
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list tasks");
|
|
890
|
+
return data;
|
|
891
|
+
}
|
|
892
|
+
toError(response, error, correlationId, operation) {
|
|
893
|
+
const status = response.status;
|
|
894
|
+
let message = `Failed to ${operation} (HTTP ${status})`;
|
|
895
|
+
if (error && typeof error === "object" && "message" in error) message = String(error.message);
|
|
896
|
+
else if (typeof error === "string") message = error;
|
|
897
|
+
return new SodiumApiError(message, status, correlationId);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region ../mcp-core/src/context/instructions.ts
|
|
902
|
+
async function buildInstructions(api) {
|
|
903
|
+
const [user, tenant, practice] = await Promise.allSettled([
|
|
904
|
+
api.getCurrentUser(),
|
|
905
|
+
api.getTenantDetails(),
|
|
906
|
+
api.getPracticeDetails()
|
|
907
|
+
]);
|
|
908
|
+
const now = /* @__PURE__ */ new Date();
|
|
909
|
+
const lines = [
|
|
910
|
+
"You are assisting a user of Sodium Practice Management.",
|
|
911
|
+
"",
|
|
912
|
+
`Today: ${now.toISOString().slice(0, 10)} (${now.toLocaleDateString("en-GB", { weekday: "long" })})`,
|
|
913
|
+
`Current UTC time: ${now.toISOString()}`
|
|
914
|
+
];
|
|
915
|
+
if (user.status === "fulfilled") {
|
|
916
|
+
const name = user.value.fullName ?? user.value.email ?? user.value.code ?? "unknown";
|
|
917
|
+
lines.push(`Current user: ${name}${user.value.code ? ` (${user.value.code})` : ""}`);
|
|
918
|
+
}
|
|
919
|
+
if (tenant.status === "fulfilled") lines.push(`Tenant: ${tenant.value.name} (${tenant.value.code})`);
|
|
920
|
+
if (practice.status === "fulfilled") lines.push(`Practice: ${practice.value.name}`);
|
|
921
|
+
lines.push("", "When interpreting relative dates (today, this week, next month), use the date above.", "All codes (client, task, engagement) are string identifiers, not numeric IDs.");
|
|
922
|
+
return lines.join("\n");
|
|
923
|
+
}
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region ../mcp-core/src/tools/get-practice-details.ts
|
|
926
|
+
function format$1(tenant, practice) {
|
|
927
|
+
const lines = [];
|
|
928
|
+
lines.push(`Practice: ${practice.name}`);
|
|
929
|
+
lines.push(`Tenant: ${tenant.name} (${tenant.code})`);
|
|
930
|
+
if (practice.address) lines.push(`Address: ${practice.address}`);
|
|
931
|
+
if (practice.email) lines.push(`Email: ${practice.email}`);
|
|
932
|
+
if (practice.telephone) lines.push(`Telephone: ${practice.telephone}`);
|
|
933
|
+
if (practice.mobile) lines.push(`Mobile: ${practice.mobile}`);
|
|
934
|
+
if (practice.website) lines.push(`Website: ${practice.website}`);
|
|
935
|
+
if (practice.professionalBody) lines.push(`Professional Body: ${practice.professionalBody}`);
|
|
936
|
+
if (practice.companyNumber) lines.push(`Company Number: ${practice.companyNumber}`);
|
|
937
|
+
lines.push(`VAT Registered: ${practice.isVatRegistered ? "Yes" : "No"}`);
|
|
938
|
+
lines.push("", "--- Counts ---");
|
|
939
|
+
if (tenant.activeClientCount !== void 0) lines.push(`Active Clients: ${tenant.activeClientCount}`);
|
|
940
|
+
if (tenant.prospectClientCount !== void 0) lines.push(`Prospect Clients: ${tenant.prospectClientCount}`);
|
|
941
|
+
if (tenant.inactiveClientCount !== void 0) lines.push(`Inactive Clients: ${tenant.inactiveClientCount}`);
|
|
942
|
+
if (tenant.lostProspectClientCount !== void 0) lines.push(`Lost Prospects: ${tenant.lostProspectClientCount}`);
|
|
943
|
+
if (tenant.servicesCount !== void 0) lines.push(`Services: ${tenant.servicesCount}`);
|
|
944
|
+
if (tenant.workFlowsCount !== void 0) lines.push(`Workflows: ${tenant.workFlowsCount}`);
|
|
945
|
+
if (tenant.usersCount !== void 0) lines.push(`Users: ${tenant.usersCount}`);
|
|
946
|
+
if (tenant.proposalsSentThisMonth !== void 0) lines.push(`Proposals Sent This Month: ${tenant.proposalsSentThisMonth}`);
|
|
947
|
+
const connections = [];
|
|
948
|
+
if (tenant.accountingConnection) connections.push(`Accounting: ${tenant.accountingConnection.name}`);
|
|
949
|
+
if (tenant.directDebitConnection) connections.push(`Direct Debit: ${tenant.directDebitConnection.name}`);
|
|
950
|
+
if (tenant.amlConnection) connections.push(`AML: ${tenant.amlConnection.name}`);
|
|
951
|
+
if (tenant.documentStorageConnection) connections.push(`Document Storage: ${tenant.documentStorageConnection.name}`);
|
|
952
|
+
if (connections.length > 0) lines.push("", "--- Connections ---", ...connections);
|
|
953
|
+
return lines.join("\n");
|
|
954
|
+
}
|
|
955
|
+
async function handleGetPracticeDetails(api) {
|
|
956
|
+
try {
|
|
957
|
+
const [tenant, practice] = await Promise.all([api.getTenantDetails(), api.getPracticeDetails()]);
|
|
958
|
+
return { content: [{
|
|
959
|
+
type: "text",
|
|
960
|
+
text: format$1(tenant, practice)
|
|
961
|
+
}] };
|
|
962
|
+
} catch (error) {
|
|
963
|
+
return {
|
|
964
|
+
content: [{
|
|
965
|
+
type: "text",
|
|
966
|
+
text: error instanceof SodiumApiError ? `Error getting practice details: ${error.message} (correlation: ${error.correlationId})` : `Error getting practice details: ${error instanceof Error ? error.message : String(error)}`
|
|
967
|
+
}],
|
|
968
|
+
isError: true
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region ../mcp-core/src/tools/list-clients.ts
|
|
974
|
+
const statusEnum = z.enum([
|
|
975
|
+
"Active",
|
|
976
|
+
"Inactive",
|
|
977
|
+
"Prospect",
|
|
978
|
+
"LostProspect"
|
|
979
|
+
]);
|
|
980
|
+
const typeEnum = z.enum([
|
|
981
|
+
"PrivateLimitedCompany",
|
|
982
|
+
"PublicLimitedCompany",
|
|
983
|
+
"LimitedLiabilityPartnership",
|
|
984
|
+
"Partnership",
|
|
985
|
+
"Individual",
|
|
986
|
+
"Trust",
|
|
987
|
+
"Charity",
|
|
988
|
+
"SoleTrader"
|
|
989
|
+
]);
|
|
990
|
+
const sortByEnum = z.enum(["Name", "InternalReference"]);
|
|
991
|
+
const ListClientsInputSchema = {
|
|
992
|
+
search: z.string().min(3, "Search must be at least 3 characters when provided").optional().describe("Free-text search across client code, name, and internal reference. Minimum 3 characters. Omit to browse by filter only."),
|
|
993
|
+
status: z.array(statusEnum).optional().describe("Filter by client status. Defaults to all statuses if omitted. Example: ['Active'] for active clients only."),
|
|
994
|
+
type: z.array(typeEnum).optional().describe("Filter by organisation type. Use ['PrivateLimitedCompany', 'PublicLimitedCompany'] for 'limited companies'. Use ['LimitedLiabilityPartnership'] for LLPs. Defaults to all types if omitted."),
|
|
995
|
+
managerCode: z.array(z.string()).optional().describe("Filter by assigned manager user codes."),
|
|
996
|
+
partnerCode: z.array(z.string()).optional().describe("Filter by assigned partner user codes."),
|
|
997
|
+
associateCode: z.array(z.string()).optional().describe("Filter by assigned associate user codes."),
|
|
998
|
+
serviceCode: z.array(z.string()).optional().describe("Filter by billable service codes that clients have assigned."),
|
|
999
|
+
savedFilter: z.string().optional().describe("Code of a user-saved filter to apply. Other filter parameters override fields from the saved filter."),
|
|
1000
|
+
sortBy: sortByEnum.optional().describe("Field to sort by. Defaults to Name."),
|
|
1001
|
+
sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
|
|
1002
|
+
limit: z.number().int().min(1).max(50).optional().describe("Maximum number of clients to return per page. Default 10, max 50."),
|
|
1003
|
+
offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination. Default 0.")
|
|
1004
|
+
};
|
|
1005
|
+
function formatClient(c) {
|
|
1006
|
+
const code = c.code ?? "(no code)";
|
|
1007
|
+
const name = c.name ?? "(no name)";
|
|
1008
|
+
const meta = [c.status, c.type].filter(Boolean).join(", ");
|
|
1009
|
+
return meta ? `- ${name} (${code}) — ${meta}` : `- ${name} (${code})`;
|
|
1010
|
+
}
|
|
1011
|
+
async function handleListClients(api, args) {
|
|
1012
|
+
try {
|
|
1013
|
+
const query = args;
|
|
1014
|
+
const result = await api.listClients(query);
|
|
1015
|
+
const items = result.data ?? [];
|
|
1016
|
+
if (items.length === 0) {
|
|
1017
|
+
const desc = describeFilters(args);
|
|
1018
|
+
return { content: [{
|
|
1019
|
+
type: "text",
|
|
1020
|
+
text: desc ? `No clients match ${desc}.` : "No clients found."
|
|
1021
|
+
}] };
|
|
1022
|
+
}
|
|
1023
|
+
const total = result.totalCount ?? items.length;
|
|
1024
|
+
const desc = describeFilters(args);
|
|
1025
|
+
const lines = [
|
|
1026
|
+
desc ? total > items.length ? `Found ${total} clients matching ${desc} (showing ${items.length}):` : `Found ${items.length} client${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} clients:` : `${items.length} client${items.length === 1 ? "" : "s"}:`,
|
|
1027
|
+
"",
|
|
1028
|
+
...items.map(formatClient)
|
|
1029
|
+
];
|
|
1030
|
+
if (result.hasMore) {
|
|
1031
|
+
const nextOffset = (args.offset ?? 0) + items.length;
|
|
1032
|
+
lines.push("", `More results available — call again with offset: ${nextOffset} to see the next page.`);
|
|
1033
|
+
}
|
|
1034
|
+
return { content: [{
|
|
1035
|
+
type: "text",
|
|
1036
|
+
text: lines.join("\n")
|
|
1037
|
+
}] };
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
return {
|
|
1040
|
+
content: [{
|
|
1041
|
+
type: "text",
|
|
1042
|
+
text: error instanceof SodiumApiError ? `Error listing clients: ${error.message} (correlation: ${error.correlationId})` : `Error listing clients: ${error instanceof Error ? error.message : String(error)}`
|
|
1043
|
+
}],
|
|
1044
|
+
isError: true
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function describeFilters(args) {
|
|
1049
|
+
const parts = [];
|
|
1050
|
+
if (args.search) parts.push(`search "${args.search}"`);
|
|
1051
|
+
if (args.status?.length) parts.push(`status ${args.status.join("/")}`);
|
|
1052
|
+
if (args.type?.length) parts.push(`type ${args.type.join("/")}`);
|
|
1053
|
+
if (args.managerCode?.length) parts.push(`manager ${args.managerCode.join(",")}`);
|
|
1054
|
+
if (args.partnerCode?.length) parts.push(`partner ${args.partnerCode.join(",")}`);
|
|
1055
|
+
if (args.associateCode?.length) parts.push(`associate ${args.associateCode.join(",")}`);
|
|
1056
|
+
if (args.serviceCode?.length) parts.push(`service ${args.serviceCode.join(",")}`);
|
|
1057
|
+
if (args.savedFilter) parts.push(`saved filter "${args.savedFilter}"`);
|
|
1058
|
+
return parts.join(", ");
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region ../mcp-core/src/tools/get-client-summary.ts
|
|
1062
|
+
const GetClientSummaryInputSchema = { code: z.string().min(1, "Client code is required").describe("The client code (identifier). Usually discovered via list_clients first.") };
|
|
1063
|
+
async function handleGetClientSummary(api, { code }) {
|
|
1064
|
+
const [clientResult, contactsResult, servicesResult, overdueResult, upcomingResult] = await Promise.allSettled([
|
|
1065
|
+
api.getClient(code),
|
|
1066
|
+
api.listClientContacts(code),
|
|
1067
|
+
api.listClientServices(code),
|
|
1068
|
+
api.listTasks({
|
|
1069
|
+
client: [code],
|
|
1070
|
+
isOverdue: true,
|
|
1071
|
+
limit: 50
|
|
1072
|
+
}),
|
|
1073
|
+
api.listTasks({
|
|
1074
|
+
client: [code],
|
|
1075
|
+
dateRange: "Next7Days",
|
|
1076
|
+
dateBasis: "DueDate",
|
|
1077
|
+
limit: 50
|
|
1078
|
+
})
|
|
1079
|
+
]);
|
|
1080
|
+
if (clientResult.status === "rejected") {
|
|
1081
|
+
const err = clientResult.reason;
|
|
1082
|
+
return {
|
|
1083
|
+
content: [{
|
|
1084
|
+
type: "text",
|
|
1085
|
+
text: err instanceof SodiumApiError ? `Error getting client: ${err.message} (correlation: ${err.correlationId})` : `Error getting client: ${err instanceof Error ? err.message : String(err)}`
|
|
1086
|
+
}],
|
|
1087
|
+
isError: true
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
return { content: [{
|
|
1091
|
+
type: "text",
|
|
1092
|
+
text: format({
|
|
1093
|
+
client: clientResult.value,
|
|
1094
|
+
contacts: extract(contactsResult),
|
|
1095
|
+
services: extract(servicesResult),
|
|
1096
|
+
overdueTasks: extract(overdueResult),
|
|
1097
|
+
upcomingTasks: extract(upcomingResult),
|
|
1098
|
+
gaps: [
|
|
1099
|
+
contactsResult.status === "rejected" ? "contacts" : null,
|
|
1100
|
+
servicesResult.status === "rejected" ? "services" : null,
|
|
1101
|
+
overdueResult.status === "rejected" ? "overdue tasks" : null,
|
|
1102
|
+
upcomingResult.status === "rejected" ? "upcoming tasks" : null
|
|
1103
|
+
].filter((v) => v !== null)
|
|
1104
|
+
})
|
|
1105
|
+
}] };
|
|
1106
|
+
}
|
|
1107
|
+
function extract(result) {
|
|
1108
|
+
if (result.status !== "fulfilled") return [];
|
|
1109
|
+
return result.value.data ?? [];
|
|
1110
|
+
}
|
|
1111
|
+
function format(input) {
|
|
1112
|
+
const { client, contacts, services, overdueTasks, upcomingTasks, gaps } = input;
|
|
1113
|
+
const lines = [];
|
|
1114
|
+
const name = client.name ?? "(no name)";
|
|
1115
|
+
const code = client.code ?? "(no code)";
|
|
1116
|
+
lines.push(`Client: ${name} (${code})`);
|
|
1117
|
+
const statusType = [client.status, client.type].filter(Boolean).join(" · ");
|
|
1118
|
+
if (statusType) lines.push(`Status / Type: ${statusType}`);
|
|
1119
|
+
if (client.email) lines.push(`Email: ${client.email}`);
|
|
1120
|
+
if (client.telephone) lines.push(`Telephone: ${client.telephone}`);
|
|
1121
|
+
if (client.internalReference) lines.push(`Internal Reference: ${client.internalReference}`);
|
|
1122
|
+
if (client.manager) lines.push(`Manager: ${client.manager.name} (${client.manager.code})`);
|
|
1123
|
+
if (client.partner) lines.push(`Partner: ${client.partner.name} (${client.partner.code})`);
|
|
1124
|
+
if (client.associate) lines.push(`Associate: ${client.associate.name} (${client.associate.code})`);
|
|
1125
|
+
lines.push("", `--- Contacts (${contacts.length}) ---`);
|
|
1126
|
+
if (contacts.length === 0) lines.push("No contacts.");
|
|
1127
|
+
else for (const c of contacts) {
|
|
1128
|
+
const contact = c.contact;
|
|
1129
|
+
if (!contact) continue;
|
|
1130
|
+
const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(" ") || "(no name)";
|
|
1131
|
+
const types = c.types?.length ? c.types.join(", ") : c.role ?? "";
|
|
1132
|
+
const email = contact.email ? ` <${contact.email}>` : "";
|
|
1133
|
+
const suffix = types ? ` — ${types}` : "";
|
|
1134
|
+
lines.push(`- ${fullName}${suffix}${email}`);
|
|
1135
|
+
}
|
|
1136
|
+
const activeServices = services.filter((s) => s.status !== "Inactive" && !s.endDate);
|
|
1137
|
+
lines.push("", `--- Active Services (${activeServices.length} of ${services.length} total) ---`);
|
|
1138
|
+
if (activeServices.length === 0) lines.push("No active services.");
|
|
1139
|
+
else for (const s of activeServices) {
|
|
1140
|
+
const svcName = s.billableService?.name ?? "(unnamed)";
|
|
1141
|
+
const freq = s.billingFrequency ? ` ${s.billingFrequency}` : "";
|
|
1142
|
+
const price = s.effectivePrice !== void 0 ? ` — £${s.effectivePrice.toFixed(2)}${freq}` : freq ? ` —${freq}` : "";
|
|
1143
|
+
lines.push(`- ${svcName}${price}`);
|
|
1144
|
+
}
|
|
1145
|
+
lines.push("", "--- Tasks ---");
|
|
1146
|
+
lines.push(`Overdue: ${overdueTasks.length}`);
|
|
1147
|
+
if (overdueTasks.length > 0) {
|
|
1148
|
+
for (const t of overdueTasks.slice(0, 5)) lines.push(` - ${formatTask(t)}`);
|
|
1149
|
+
if (overdueTasks.length > 5) lines.push(` ... and ${overdueTasks.length - 5} more`);
|
|
1150
|
+
}
|
|
1151
|
+
lines.push(`Due in next 7 days: ${upcomingTasks.length}`);
|
|
1152
|
+
if (upcomingTasks.length > 0) {
|
|
1153
|
+
for (const t of upcomingTasks.slice(0, 5)) lines.push(` - ${formatTask(t)}`);
|
|
1154
|
+
if (upcomingTasks.length > 5) lines.push(` ... and ${upcomingTasks.length - 5} more`);
|
|
1155
|
+
}
|
|
1156
|
+
if (gaps.length > 0) lines.push("", `Note: could not load ${gaps.join(", ")} (partial failure). Retry for a complete picture.`);
|
|
1157
|
+
return lines.join("\n");
|
|
1158
|
+
}
|
|
1159
|
+
function formatTask(t) {
|
|
1160
|
+
const taskCode = t.code ?? "(no code)";
|
|
1161
|
+
return `${t.name ?? "(unnamed)"} (${taskCode})${t.dueDate ? ` — due ${t.dueDate}` : ""}${t.assignedUser?.name ? ` — ${t.assignedUser.name}` : ""}`;
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region ../mcp-core/src/server.ts
|
|
1165
|
+
async function buildServer(config) {
|
|
1166
|
+
const api = new SodiumApiClient(config.context);
|
|
1167
|
+
const instructions = await buildInstructions(api);
|
|
1168
|
+
const server = new McpServer({
|
|
1169
|
+
name: config.serverName,
|
|
1170
|
+
version: config.serverVersion
|
|
1171
|
+
}, {
|
|
1172
|
+
instructions,
|
|
1173
|
+
capabilities: { tools: {} }
|
|
1174
|
+
});
|
|
1175
|
+
server.registerTool("get_practice_details", {
|
|
1176
|
+
title: "Get practice details",
|
|
1177
|
+
description: "Get a consolidated overview of the practice including name, contact details, client/service/user counts, connections, and settings. Use this when the user asks about their practice, tenant, or wants a summary of their account.",
|
|
1178
|
+
inputSchema: {},
|
|
1179
|
+
annotations: {
|
|
1180
|
+
readOnlyHint: true,
|
|
1181
|
+
idempotentHint: true,
|
|
1182
|
+
openWorldHint: true
|
|
1183
|
+
}
|
|
1184
|
+
}, () => handleGetPracticeDetails(api));
|
|
1185
|
+
server.registerTool("list_clients", {
|
|
1186
|
+
title: "List / search / filter clients",
|
|
1187
|
+
description: "List clients with any combination of: search (code/name/internal reference, 3+ chars), status (Active/Inactive/Prospect/LostProspect), type (PrivateLimitedCompany/PublicLimitedCompany/LimitedLiabilityPartnership/Partnership/Individual/Trust/Charity/SoleTrader), manager/partner/associate user codes, service codes, a saved filter code, sort, and pagination. Use search for 'find ACME'-style queries. Use type for 'list limited companies' (pass PrivateLimitedCompany + PublicLimitedCompany). Use status: ['Active'] to exclude prospects/inactive. Returns up to 50 clients per page — paginate via offset for more. Follow up with get_client_summary for full detail on a specific client.",
|
|
1188
|
+
inputSchema: ListClientsInputSchema,
|
|
1189
|
+
annotations: {
|
|
1190
|
+
readOnlyHint: true,
|
|
1191
|
+
idempotentHint: true,
|
|
1192
|
+
openWorldHint: true
|
|
1193
|
+
}
|
|
1194
|
+
}, (args) => handleListClients(api, args));
|
|
1195
|
+
server.registerTool("get_client_summary", {
|
|
1196
|
+
title: "Get a full summary of one client",
|
|
1197
|
+
description: "Get a consolidated overview of a single client by code: identity (name, status, type, assignments), all contacts, active services with pricing, overdue task count + top 5, and tasks due in the next 7 days. Use this AFTER list_clients identifies the client of interest, or when the user references a specific client by code. Tolerates partial failures — if one section can't be loaded, the rest is still returned with a note about what's missing.",
|
|
1198
|
+
inputSchema: GetClientSummaryInputSchema,
|
|
1199
|
+
annotations: {
|
|
1200
|
+
readOnlyHint: true,
|
|
1201
|
+
idempotentHint: true,
|
|
1202
|
+
openWorldHint: true
|
|
1203
|
+
}
|
|
1204
|
+
}, (args) => handleGetClientSummary(api, args));
|
|
1205
|
+
return server;
|
|
1206
|
+
}
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/config.ts
|
|
1209
|
+
function loadContext() {
|
|
1210
|
+
const apiKey = process.env.SODIUM_API_KEY;
|
|
1211
|
+
const tenant = process.env.SODIUM_TENANT;
|
|
1212
|
+
const baseUrl = process.env.SODIUM_API_URL ?? "https://api.sodiumhq.com";
|
|
1213
|
+
if (!apiKey) throw new Error("SODIUM_API_KEY environment variable is required. Generate one in Sodium → Settings → API Keys.");
|
|
1214
|
+
if (!tenant) throw new Error("SODIUM_TENANT environment variable is required. Find your tenant code in Sodium → Settings → Practice.");
|
|
1215
|
+
return {
|
|
1216
|
+
apiKey,
|
|
1217
|
+
tenant,
|
|
1218
|
+
baseUrl
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/index.ts
|
|
1223
|
+
const VERSION = "0.0.1";
|
|
1224
|
+
async function main() {
|
|
1225
|
+
const context = loadContext();
|
|
1226
|
+
const server = await buildServer({
|
|
1227
|
+
context,
|
|
1228
|
+
serverName: "Sodium Practice Management",
|
|
1229
|
+
serverVersion: VERSION
|
|
1230
|
+
});
|
|
1231
|
+
const transport = new StdioServerTransport();
|
|
1232
|
+
await server.connect(transport);
|
|
1233
|
+
console.error(`[sodium-pm-mcp] v${VERSION} ready (tenant: ${context.tenant})`);
|
|
1234
|
+
}
|
|
1235
|
+
main().catch((error) => {
|
|
1236
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1237
|
+
console.error(`[sodium-pm-mcp] fatal: ${message}`);
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
});
|
|
1240
|
+
//#endregion
|
|
1241
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sodiumhq/mcp-pm",
|
|
3
|
+
"version": "0.1.0-beta.2588",
|
|
4
|
+
"description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-pm": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"sodium",
|
|
18
|
+
"sodiumhq",
|
|
19
|
+
"practice-management",
|
|
20
|
+
"accounting",
|
|
21
|
+
"ai",
|
|
22
|
+
"claude"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://sodiumhq.com",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://dev.azure.com/sodium-software/sodium/_git/sodium-pm-mcp"
|
|
28
|
+
},
|
|
29
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown && chmod +x dist/index.js",
|
|
38
|
+
"dev": "tsx src/index.ts",
|
|
39
|
+
"start": "node dist/index.js",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"clean": "rm -rf dist",
|
|
42
|
+
"prepublishOnly": "pnpm typecheck && pnpm build"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
46
|
+
"zod": "^3.25.67"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@sodiumhq/mcp-core": "workspace:*",
|
|
50
|
+
"@types/node": "^22.15.33",
|
|
51
|
+
"tsdown": "^0.21.9",
|
|
52
|
+
"tsx": "^4.20.3",
|
|
53
|
+
"typescript": "^5.8.3"
|
|
54
|
+
}
|
|
55
|
+
}
|