@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.
Files changed (4) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +68 -0
  3. package/dist/index.js +1241 -0
  4. 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
+ }