@sodiumhq/mcp-pm 0.1.0-beta.2600 → 0.1.0-beta.2611
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/index.js +437 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,13 @@ Then ask: *"give me a summary of my practice"*.
|
|
|
48
48
|
| `SODIUM_API_KEY` | yes | — | Your Sodium API key |
|
|
49
49
|
| `SODIUM_TENANT` | yes | — | Your tenant code |
|
|
50
50
|
| `SODIUM_API_URL` | no | `https://api.sodiumhq.com` | Override for staging/dev |
|
|
51
|
+
| `SODIUM_ENABLE_WRITES` | no | `false` | Set to `true` or `1` to allow write tools (equivalent to `--enable-writes`) |
|
|
52
|
+
|
|
53
|
+
## Write mode
|
|
54
|
+
|
|
55
|
+
Write tools are **off by default** — the server exposes only read tools unless you opt in. Enable writes by adding `--enable-writes` to `args`, or by setting `SODIUM_ENABLE_WRITES=true` (or `1`) in `env`. Destructive and bulk operations (delete, batch) stay blocked either way.
|
|
56
|
+
|
|
57
|
+
Only enable write mode with an AI client you trust — it hands the client the ability to modify data under your API key. To check whether writes are on, ask your AI assistant *"can you make changes to my Sodium data?"* — it will tell you.
|
|
51
58
|
|
|
52
59
|
## What it can do today
|
|
53
60
|
|
|
@@ -66,15 +73,23 @@ Then ask: *"give me a summary of my practice"*.
|
|
|
66
73
|
- **`list_proposals`** and **`list_engagement_letters`** — aliased tools for the same underlying engagement pipeline (pre-acceptance vs signed). Filter by status, client, date range.
|
|
67
74
|
- **`get_proposal_summary`** and **`get_engagement_letter_summary`** — aliased drill-ins: identity + client + value breakdown + services with pricing + PDFs + email history + acceptance record
|
|
68
75
|
|
|
76
|
+
**Services**
|
|
77
|
+
- **`list_services`** — find and count services in the practice's catalogue by search, category, applicable client type, or archived status. Answers "what do we offer?", "list our tax services", "what do we offer limited companies?", "how many active services do we have?"
|
|
78
|
+
- **`get_service_details`** — drill into one service: identity + applicable client types + HMRC agent authorisations + pricing options per billing frequency + custom tiers + professional clearance items + pricing-factor overview. Enables service-audit workflows like "is Self Assessment correctly restricted to Individual clients?"
|
|
79
|
+
|
|
69
80
|
**Team**
|
|
70
81
|
- **`list_users`** — find team members by name, email, role, or status — supports "who is Jane?", "list all partners", "who has been invited but not joined?"
|
|
71
82
|
|
|
83
|
+
**Write tools** (require `--enable-writes` — see [Write mode](#write-mode))
|
|
84
|
+
- **`add_task_note`** — capture a note against a specific task. Attributed to your API user, timestamped to now.
|
|
85
|
+
- **`add_client_note`** — capture a note against a client record. Same shape as `add_task_note` but scoped to the client.
|
|
86
|
+
|
|
72
87
|
More tools land iteratively as the beta progresses.
|
|
73
88
|
|
|
74
89
|
## Requirements
|
|
75
90
|
|
|
76
91
|
- Node.js 20 or later
|
|
77
|
-
- An active Sodium Practice Management subscription
|
|
92
|
+
- An active Sodium Practice Management subscription
|
|
78
93
|
- API key and tenant code from your Sodium account
|
|
79
94
|
|
|
80
95
|
## Licence
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
7
8
|
//#region ../mcp-core/src/generated/core/bodySerializer.gen.ts
|
|
8
9
|
const jsonBodySerializer = { bodySerializer: (body) => JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value) };
|
|
9
10
|
Object.entries({
|
|
@@ -669,6 +670,26 @@ const getClientDates = (options) => (options.client ?? client).get({
|
|
|
669
670
|
...options
|
|
670
671
|
});
|
|
671
672
|
/**
|
|
673
|
+
* Create Note for Client
|
|
674
|
+
*
|
|
675
|
+
* Creates a new Note for the specified client.
|
|
676
|
+
*/
|
|
677
|
+
const createClientNoteForClient = (options) => (options.client ?? client).post({
|
|
678
|
+
security: [{
|
|
679
|
+
name: "x-api-key",
|
|
680
|
+
type: "apiKey"
|
|
681
|
+
}, {
|
|
682
|
+
scheme: "bearer",
|
|
683
|
+
type: "http"
|
|
684
|
+
}],
|
|
685
|
+
url: "/tenants/{tenant}/clients/{client}/clientnote",
|
|
686
|
+
...options,
|
|
687
|
+
headers: {
|
|
688
|
+
"Content-Type": "application/json",
|
|
689
|
+
...options.headers
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
/**
|
|
672
693
|
* List Client Services for Client
|
|
673
694
|
*
|
|
674
695
|
* Lists all Client Services for the specified client.
|
|
@@ -787,6 +808,38 @@ const getPracticeDetails = (options) => (options.client ?? client).get({
|
|
|
787
808
|
...options
|
|
788
809
|
});
|
|
789
810
|
/**
|
|
811
|
+
* List BillableServices
|
|
812
|
+
*
|
|
813
|
+
* Lists all BillableServices for the given tenant.
|
|
814
|
+
*/
|
|
815
|
+
const listBillableServices = (options) => (options.client ?? client).get({
|
|
816
|
+
security: [{
|
|
817
|
+
name: "x-api-key",
|
|
818
|
+
type: "apiKey"
|
|
819
|
+
}, {
|
|
820
|
+
scheme: "bearer",
|
|
821
|
+
type: "http"
|
|
822
|
+
}],
|
|
823
|
+
url: "/tenants/{tenant}/services",
|
|
824
|
+
...options
|
|
825
|
+
});
|
|
826
|
+
/**
|
|
827
|
+
* Get BillableService
|
|
828
|
+
*
|
|
829
|
+
* Gets a BillableService for the specified tenant.
|
|
830
|
+
*/
|
|
831
|
+
const getBillableService = (options) => (options.client ?? client).get({
|
|
832
|
+
security: [{
|
|
833
|
+
name: "x-api-key",
|
|
834
|
+
type: "apiKey"
|
|
835
|
+
}, {
|
|
836
|
+
scheme: "bearer",
|
|
837
|
+
type: "http"
|
|
838
|
+
}],
|
|
839
|
+
url: "/tenants/{tenant}/services/{code}",
|
|
840
|
+
...options
|
|
841
|
+
});
|
|
842
|
+
/**
|
|
790
843
|
* List Notes for Task
|
|
791
844
|
*
|
|
792
845
|
* Lists notes for the specified task. By default only returns task-level notes. Set includeStepNotes=true to also include notes attached to workflow steps.
|
|
@@ -803,6 +856,26 @@ const listTaskItemNotes = (options) => (options.client ?? client).get({
|
|
|
803
856
|
...options
|
|
804
857
|
});
|
|
805
858
|
/**
|
|
859
|
+
* Create Note
|
|
860
|
+
*
|
|
861
|
+
* Creates a new note for the specified task.
|
|
862
|
+
*/
|
|
863
|
+
const createTaskItemNote = (options) => (options.client ?? client).post({
|
|
864
|
+
security: [{
|
|
865
|
+
name: "x-api-key",
|
|
866
|
+
type: "apiKey"
|
|
867
|
+
}, {
|
|
868
|
+
scheme: "bearer",
|
|
869
|
+
type: "http"
|
|
870
|
+
}],
|
|
871
|
+
url: "/tenants/{tenant}/tasks/{taskCode}/taskitemnote",
|
|
872
|
+
...options,
|
|
873
|
+
headers: {
|
|
874
|
+
"Content-Type": "application/json",
|
|
875
|
+
...options.headers
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
/**
|
|
806
879
|
* Get Task Workflow Groups
|
|
807
880
|
*
|
|
808
881
|
* Retrieves comprehensive workflow progress information for a TaskItem.
|
|
@@ -1099,6 +1172,32 @@ var SodiumApiClient = class {
|
|
|
1099
1172
|
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list users");
|
|
1100
1173
|
return data;
|
|
1101
1174
|
}
|
|
1175
|
+
async listServices(query = {}) {
|
|
1176
|
+
const correlationId = randomUUID();
|
|
1177
|
+
const { data, error, response } = await listBillableServices({
|
|
1178
|
+
path: { tenant: this.ctx.tenant },
|
|
1179
|
+
query: {
|
|
1180
|
+
...query,
|
|
1181
|
+
limit: query.limit ?? 10,
|
|
1182
|
+
offset: query.offset ?? 0
|
|
1183
|
+
},
|
|
1184
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1185
|
+
});
|
|
1186
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list services");
|
|
1187
|
+
return data;
|
|
1188
|
+
}
|
|
1189
|
+
async getService(code) {
|
|
1190
|
+
const correlationId = randomUUID();
|
|
1191
|
+
const { data, error, response } = await getBillableService({
|
|
1192
|
+
path: {
|
|
1193
|
+
tenant: this.ctx.tenant,
|
|
1194
|
+
code
|
|
1195
|
+
},
|
|
1196
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1197
|
+
});
|
|
1198
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get service ${code}`);
|
|
1199
|
+
return data;
|
|
1200
|
+
}
|
|
1102
1201
|
async listTasks(query = {}) {
|
|
1103
1202
|
const correlationId = randomUUID();
|
|
1104
1203
|
const { data, error, response } = await listTaskItems({
|
|
@@ -1192,6 +1291,32 @@ var SodiumApiClient = class {
|
|
|
1192
1291
|
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get workflow groups for task ${taskCode}`);
|
|
1193
1292
|
return data;
|
|
1194
1293
|
}
|
|
1294
|
+
async createTaskNote(taskCode, body) {
|
|
1295
|
+
const correlationId = randomUUID();
|
|
1296
|
+
const { data, error, response } = await createTaskItemNote({
|
|
1297
|
+
path: {
|
|
1298
|
+
tenant: this.ctx.tenant,
|
|
1299
|
+
taskCode
|
|
1300
|
+
},
|
|
1301
|
+
body,
|
|
1302
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1303
|
+
});
|
|
1304
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to task ${taskCode}`);
|
|
1305
|
+
return data;
|
|
1306
|
+
}
|
|
1307
|
+
async createClientNote(clientCode, body) {
|
|
1308
|
+
const correlationId = randomUUID();
|
|
1309
|
+
const { data, error, response } = await createClientNoteForClient({
|
|
1310
|
+
path: {
|
|
1311
|
+
tenant: this.ctx.tenant,
|
|
1312
|
+
client: clientCode
|
|
1313
|
+
},
|
|
1314
|
+
body,
|
|
1315
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1316
|
+
});
|
|
1317
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to client ${clientCode}`);
|
|
1318
|
+
return data;
|
|
1319
|
+
}
|
|
1195
1320
|
toError(response, error, correlationId, operation) {
|
|
1196
1321
|
const status = response.status;
|
|
1197
1322
|
let message = `Failed to ${operation} (HTTP ${status})`;
|
|
@@ -1203,7 +1328,7 @@ var SodiumApiClient = class {
|
|
|
1203
1328
|
//#endregion
|
|
1204
1329
|
//#region ../mcp-core/src/context/instructions.ts
|
|
1205
1330
|
const ROSTER_CAP = 20;
|
|
1206
|
-
async function buildInstructions(api) {
|
|
1331
|
+
async function buildInstructions(api, writesEnabled) {
|
|
1207
1332
|
const [user, tenant, practice, team] = await Promise.allSettled([
|
|
1208
1333
|
api.getCurrentUser(),
|
|
1209
1334
|
api.getTenantDetails(),
|
|
@@ -1237,6 +1362,7 @@ async function buildInstructions(api) {
|
|
|
1237
1362
|
}
|
|
1238
1363
|
if (tenant.status === "fulfilled") lines.push(`Tenant: ${tenant.value.name} (${tenant.value.code})`);
|
|
1239
1364
|
if (practice.status === "fulfilled") lines.push(`Practice: ${practice.value.name}`);
|
|
1365
|
+
lines.push("", writesEnabled ? "Write mode: ENABLED. Create/update tools are available; destructive and bulk operations are not." : "Write mode: DISABLED. Read-only — tell the user to relaunch with --enable-writes if they want changes made.");
|
|
1240
1366
|
if (team.status === "fulfilled") {
|
|
1241
1367
|
const members = team.value.data ?? [];
|
|
1242
1368
|
const total = team.value.totalCount ?? members.length;
|
|
@@ -1255,7 +1381,7 @@ async function buildInstructions(api) {
|
|
|
1255
1381
|
}
|
|
1256
1382
|
//#endregion
|
|
1257
1383
|
//#region ../mcp-core/src/tools/get-practice-details.ts
|
|
1258
|
-
function format$
|
|
1384
|
+
function format$4(tenant, practice) {
|
|
1259
1385
|
const lines = [];
|
|
1260
1386
|
lines.push(`Practice: ${practice.name}`);
|
|
1261
1387
|
lines.push(`Tenant: ${tenant.name} (${tenant.code})`);
|
|
@@ -1289,7 +1415,7 @@ async function handleGetPracticeDetails(api) {
|
|
|
1289
1415
|
const [tenant, practice] = await Promise.all([api.getTenantDetails(), api.getPracticeDetails()]);
|
|
1290
1416
|
return { content: [{
|
|
1291
1417
|
type: "text",
|
|
1292
|
-
text: format$
|
|
1418
|
+
text: format$4(tenant, practice)
|
|
1293
1419
|
}] };
|
|
1294
1420
|
} catch (error) {
|
|
1295
1421
|
return {
|
|
@@ -1319,7 +1445,7 @@ const typeEnum = z.enum([
|
|
|
1319
1445
|
"Charity",
|
|
1320
1446
|
"SoleTrader"
|
|
1321
1447
|
]);
|
|
1322
|
-
const sortByEnum$
|
|
1448
|
+
const sortByEnum$3 = z.enum(["Name", "InternalReference"]);
|
|
1323
1449
|
const ListClientsInputSchema = {
|
|
1324
1450
|
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."),
|
|
1325
1451
|
status: z.array(statusEnum$2).optional().describe("Filter by client status. Defaults to all statuses if omitted. Example: ['Active'] for active clients only."),
|
|
@@ -1329,7 +1455,7 @@ const ListClientsInputSchema = {
|
|
|
1329
1455
|
associateCode: z.array(z.string()).optional().describe("Filter by assigned associate user codes."),
|
|
1330
1456
|
serviceCode: z.array(z.string()).optional().describe("Filter by billable service codes that clients have assigned."),
|
|
1331
1457
|
savedFilter: z.string().optional().describe("Code of a user-saved filter to apply. Other filter parameters override fields from the saved filter."),
|
|
1332
|
-
sortBy: sortByEnum$
|
|
1458
|
+
sortBy: sortByEnum$3.optional().describe("Field to sort by. Defaults to Name."),
|
|
1333
1459
|
sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
|
|
1334
1460
|
limit: z.number().int().min(0).max(50).optional().describe("Maximum number of clients to return per page. Default 10, max 50. Pass 0 to return only the total count without any client data — use this for 'how many X?' questions so the API doesn't fetch a full page just to be counted."),
|
|
1335
1461
|
offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination. Default 0.")
|
|
@@ -1347,20 +1473,20 @@ async function handleListClients(api, args) {
|
|
|
1347
1473
|
const items = result.data ?? [];
|
|
1348
1474
|
const total = result.totalCount ?? items.length;
|
|
1349
1475
|
if (args.limit === 0) {
|
|
1350
|
-
const desc = describeFilters$
|
|
1476
|
+
const desc = describeFilters$4(args);
|
|
1351
1477
|
return { content: [{
|
|
1352
1478
|
type: "text",
|
|
1353
1479
|
text: desc ? `Total: ${total} client${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} client${total === 1 ? "" : "s"}.`
|
|
1354
1480
|
}] };
|
|
1355
1481
|
}
|
|
1356
1482
|
if (items.length === 0) {
|
|
1357
|
-
const desc = describeFilters$
|
|
1483
|
+
const desc = describeFilters$4(args);
|
|
1358
1484
|
return { content: [{
|
|
1359
1485
|
type: "text",
|
|
1360
1486
|
text: desc ? `No clients match ${desc}.` : "No clients found."
|
|
1361
1487
|
}] };
|
|
1362
1488
|
}
|
|
1363
|
-
const desc = describeFilters$
|
|
1489
|
+
const desc = describeFilters$4(args);
|
|
1364
1490
|
const lines = [
|
|
1365
1491
|
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"}:`,
|
|
1366
1492
|
"",
|
|
@@ -1384,7 +1510,7 @@ async function handleListClients(api, args) {
|
|
|
1384
1510
|
};
|
|
1385
1511
|
}
|
|
1386
1512
|
}
|
|
1387
|
-
function describeFilters$
|
|
1513
|
+
function describeFilters$4(args) {
|
|
1388
1514
|
const parts = [];
|
|
1389
1515
|
if (args.search) parts.push(`search "${args.search}"`);
|
|
1390
1516
|
if (args.status?.length) parts.push(`status ${args.status.join("/")}`);
|
|
@@ -1430,7 +1556,7 @@ async function handleGetClientSummary(api, { code }) {
|
|
|
1430
1556
|
}
|
|
1431
1557
|
return { content: [{
|
|
1432
1558
|
type: "text",
|
|
1433
|
-
text: format$
|
|
1559
|
+
text: format$3({
|
|
1434
1560
|
client: clientResult.value,
|
|
1435
1561
|
contacts: extract(contactsResult),
|
|
1436
1562
|
services: extract(servicesResult),
|
|
@@ -1453,7 +1579,7 @@ function extract(result) {
|
|
|
1453
1579
|
if (result.status !== "fulfilled") return [];
|
|
1454
1580
|
return result.value.data ?? [];
|
|
1455
1581
|
}
|
|
1456
|
-
function format$
|
|
1582
|
+
function format$3(input) {
|
|
1457
1583
|
const { client, contacts, services, businessDetails, clientDates, overdueTasks, upcomingTasks, gaps } = input;
|
|
1458
1584
|
const lines = [];
|
|
1459
1585
|
const name = client.name ?? "(no name)";
|
|
@@ -1570,14 +1696,14 @@ async function handleGetEngagementSummary(api, { code }) {
|
|
|
1570
1696
|
const engagement = engagementResult.value;
|
|
1571
1697
|
return { content: [{
|
|
1572
1698
|
type: "text",
|
|
1573
|
-
text: format$
|
|
1699
|
+
text: format$2({
|
|
1574
1700
|
engagement,
|
|
1575
1701
|
emails: emailsResult.status === "fulfilled" ? emailsResult.value : [],
|
|
1576
1702
|
gaps: emailsResult.status === "rejected" ? ["email history"] : []
|
|
1577
1703
|
})
|
|
1578
1704
|
}] };
|
|
1579
1705
|
}
|
|
1580
|
-
function format$
|
|
1706
|
+
function format$2(input) {
|
|
1581
1707
|
const { engagement: e, emails, gaps } = input;
|
|
1582
1708
|
const lines = [];
|
|
1583
1709
|
const code = e.code ?? "(no code)";
|
|
@@ -1597,7 +1723,7 @@ function format$1(input) {
|
|
|
1597
1723
|
const services = e.proposalServices ?? [];
|
|
1598
1724
|
if (services.length > 0) {
|
|
1599
1725
|
lines.push("", `--- Services (${services.length}) ---`);
|
|
1600
|
-
for (const s of services) lines.push(`- ${formatService(s)}`);
|
|
1726
|
+
for (const s of services) lines.push(`- ${formatService$1(s)}`);
|
|
1601
1727
|
}
|
|
1602
1728
|
if (e.acceptance && e.status === "Accepted") {
|
|
1603
1729
|
lines.push("", "--- Acceptance ---");
|
|
@@ -1632,7 +1758,7 @@ function formatValues(e) {
|
|
|
1632
1758
|
if (e.totalValue && e.totalValue > 0) out.push(`Total contracted value: £${formatMoney$1(e.totalValue)}`);
|
|
1633
1759
|
return out;
|
|
1634
1760
|
}
|
|
1635
|
-
function formatService(s) {
|
|
1761
|
+
function formatService$1(s) {
|
|
1636
1762
|
const name = s.billableService?.name ?? "(unnamed)";
|
|
1637
1763
|
const freq = s.billingFrequency ? ` ${s.billingFrequency}` : "";
|
|
1638
1764
|
if (s.effectivePrice !== void 0 && s.effectivePrice !== null) return `${name} — £${formatMoney$1(s.effectivePrice)}${freq}`;
|
|
@@ -1648,6 +1774,83 @@ function formatMoney$1(n) {
|
|
|
1648
1774
|
});
|
|
1649
1775
|
}
|
|
1650
1776
|
//#endregion
|
|
1777
|
+
//#region ../mcp-core/src/tools/get-service-details.ts
|
|
1778
|
+
const GetServiceDetailsInputSchema = { code: z.string().min(1, "Service code is required").describe("The service code (identifier). Usually discovered via list_services first.") };
|
|
1779
|
+
async function handleGetServiceDetails(api, { code }) {
|
|
1780
|
+
try {
|
|
1781
|
+
return { content: [{
|
|
1782
|
+
type: "text",
|
|
1783
|
+
text: format$1(await api.getService(code))
|
|
1784
|
+
}] };
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
return {
|
|
1787
|
+
content: [{
|
|
1788
|
+
type: "text",
|
|
1789
|
+
text: error instanceof SodiumApiError ? `Error getting service: ${error.message} (correlation: ${error.correlationId})` : `Error getting service: ${error instanceof Error ? error.message : String(error)}`
|
|
1790
|
+
}],
|
|
1791
|
+
isError: true
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function format$1(s) {
|
|
1796
|
+
const lines = [];
|
|
1797
|
+
const name = s.name ?? "(no name)";
|
|
1798
|
+
const code = s.code ?? "(no code)";
|
|
1799
|
+
lines.push(`Service: ${name} (${code})`);
|
|
1800
|
+
const meta = [];
|
|
1801
|
+
if (s.category) meta.push(s.category);
|
|
1802
|
+
meta.push(s.isArchived ? "Archived" : "Active");
|
|
1803
|
+
if (s.vatRate) meta.push(`VAT: ${s.vatRate}`);
|
|
1804
|
+
if (s.pricingMode) meta.push(`Pricing: ${s.pricingMode}`);
|
|
1805
|
+
if (meta.length > 0) lines.push(meta.join(" · "));
|
|
1806
|
+
if (s.accountingCode) lines.push(`Accounting code: ${s.accountingCode}`);
|
|
1807
|
+
if (s.description) lines.push(`Description: ${s.description}`);
|
|
1808
|
+
if (s.defaultManagedByUser) lines.push(`Default manager: ${s.defaultManagedByUser.name} (${s.defaultManagedByUser.code})`);
|
|
1809
|
+
lines.push("", "--- Applicable Client Types ---");
|
|
1810
|
+
if (!s.clientTypes || s.clientTypes.length === 0) lines.push("All client types (no restriction configured).");
|
|
1811
|
+
else for (const ct of s.clientTypes) lines.push(`- ${ct}`);
|
|
1812
|
+
lines.push("", `--- HMRC Agent Authorisations (${s.agentAuthorisations?.length ?? 0}) ---`);
|
|
1813
|
+
if (!s.agentAuthorisations || s.agentAuthorisations.length === 0) lines.push("None configured.");
|
|
1814
|
+
else for (const a of s.agentAuthorisations) lines.push(`- ${a}`);
|
|
1815
|
+
lines.push("", `--- Pricing Options (${s.pricing?.length ?? 0}) ---`);
|
|
1816
|
+
if (!s.pricing || s.pricing.length === 0) lines.push("No pricing options configured.");
|
|
1817
|
+
else for (const p of s.pricing) lines.push(formatPricingOption(p));
|
|
1818
|
+
if (s.pricingMode === "CustomTiers" && s.pricingTiers && s.pricingTiers.length > 0) {
|
|
1819
|
+
lines.push("", `--- Custom Pricing Tiers (${s.pricingTiers.length}) ---`);
|
|
1820
|
+
const tiers = [...s.pricingTiers].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
1821
|
+
for (const t of tiers) lines.push(formatTier(t));
|
|
1822
|
+
}
|
|
1823
|
+
lines.push("", `--- Professional Clearance Items (${s.pcrItems?.length ?? 0}) ---`);
|
|
1824
|
+
if (!s.pcrItems || s.pcrItems.length === 0) lines.push("None configured.");
|
|
1825
|
+
else for (const item of s.pcrItems) lines.push(formatCodeAndName$1(item));
|
|
1826
|
+
const factorCount = s.pricingFactors?.length ?? 0;
|
|
1827
|
+
lines.push("", "--- Pricing Factors ---");
|
|
1828
|
+
if (factorCount === 0) lines.push("None configured.");
|
|
1829
|
+
else {
|
|
1830
|
+
lines.push(`${factorCount} pricing factor${factorCount === 1 ? "" : "s"} configured.`);
|
|
1831
|
+
for (const f of s.pricingFactors ?? []) if (f.description) lines.push(`- ${f.description}`);
|
|
1832
|
+
lines.push("Note: exact pricing computed from factors is available via the proposal tools (get_proposal_summary / get_engagement_letter_summary) — those return effectivePrice per service.");
|
|
1833
|
+
}
|
|
1834
|
+
if (s.recurringTaskCount && s.recurringTaskCount > 0) lines.push("", `Recurring tasks using this service: ${s.recurringTaskCount}`);
|
|
1835
|
+
return lines.join("\n");
|
|
1836
|
+
}
|
|
1837
|
+
function formatPricingOption(p) {
|
|
1838
|
+
const freq = p.frequency ?? "(no frequency)";
|
|
1839
|
+
const price = typeof p.price === "number" ? `£${p.price.toFixed(2)}` : "(no price)";
|
|
1840
|
+
const rangeCount = p.revenueRangeOverrides?.length ?? 0;
|
|
1841
|
+
const tierCount = p.tierOverrides?.length ?? 0;
|
|
1842
|
+
const overrides = [];
|
|
1843
|
+
if (rangeCount > 0) overrides.push(`${rangeCount} revenue-range override${rangeCount === 1 ? "" : "s"}`);
|
|
1844
|
+
if (tierCount > 0) overrides.push(`${tierCount} tier override${tierCount === 1 ? "" : "s"}`);
|
|
1845
|
+
return `- ${freq}: ${price}${overrides.length > 0 ? ` (+ ${overrides.join(", ")})` : ""}`;
|
|
1846
|
+
}
|
|
1847
|
+
function formatTier(t) {
|
|
1848
|
+
return `- ${t.name ?? "(unnamed)"} (${t.code ?? "(no code)"})`;
|
|
1849
|
+
}
|
|
1850
|
+
function formatCodeAndName$1(item) {
|
|
1851
|
+
return `- ${item.name ?? "(unnamed)"} (${item.code ?? "(no code)"})`;
|
|
1852
|
+
}
|
|
1853
|
+
//#endregion
|
|
1651
1854
|
//#region ../mcp-core/src/tools/get-task-context.ts
|
|
1652
1855
|
const GetTaskContextInputSchema = { code: z.string().min(1, "Task code is required").describe("The task code (identifier). Usually discovered via list_tasks first, or supplied directly by the user when they quote a task code.") };
|
|
1653
1856
|
async function handleGetTaskContext(api, { code }) {
|
|
@@ -1845,20 +2048,20 @@ async function handleListEngagements(api, args) {
|
|
|
1845
2048
|
const items = result.data ?? [];
|
|
1846
2049
|
const total = result.totalCount ?? items.length;
|
|
1847
2050
|
if (args.limit === 0) {
|
|
1848
|
-
const desc = describeFilters$
|
|
2051
|
+
const desc = describeFilters$3(args);
|
|
1849
2052
|
return { content: [{
|
|
1850
2053
|
type: "text",
|
|
1851
2054
|
text: desc ? `Total: ${total} engagement${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} engagement${total === 1 ? "" : "s"}.`
|
|
1852
2055
|
}] };
|
|
1853
2056
|
}
|
|
1854
2057
|
if (items.length === 0) {
|
|
1855
|
-
const desc = describeFilters$
|
|
2058
|
+
const desc = describeFilters$3(args);
|
|
1856
2059
|
return { content: [{
|
|
1857
2060
|
type: "text",
|
|
1858
2061
|
text: desc ? `No engagements match ${desc}.` : "No engagements found."
|
|
1859
2062
|
}] };
|
|
1860
2063
|
}
|
|
1861
|
-
const desc = describeFilters$
|
|
2064
|
+
const desc = describeFilters$3(args);
|
|
1862
2065
|
const lines = [
|
|
1863
2066
|
desc ? total > items.length ? `Found ${total} engagements matching ${desc} (showing ${items.length}):` : `Found ${items.length} engagement${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} engagements:` : `${items.length} engagement${items.length === 1 ? "" : "s"}:`,
|
|
1864
2067
|
"",
|
|
@@ -1896,7 +2099,7 @@ function formatMoney(n) {
|
|
|
1896
2099
|
maximumFractionDigits: 2
|
|
1897
2100
|
});
|
|
1898
2101
|
}
|
|
1899
|
-
function describeFilters$
|
|
2102
|
+
function describeFilters$3(args) {
|
|
1900
2103
|
const parts = [];
|
|
1901
2104
|
if (args.status) parts.push(`status ${args.status}`);
|
|
1902
2105
|
if (args.search) parts.push(`search "${args.search}"`);
|
|
@@ -1905,6 +2108,101 @@ function describeFilters$2(args) {
|
|
|
1905
2108
|
return parts.join(", ");
|
|
1906
2109
|
}
|
|
1907
2110
|
//#endregion
|
|
2111
|
+
//#region ../mcp-core/src/tools/list-services.ts
|
|
2112
|
+
const categoryEnum = z.enum([
|
|
2113
|
+
"Other",
|
|
2114
|
+
"CoreAccounting",
|
|
2115
|
+
"Tax",
|
|
2116
|
+
"Payroll",
|
|
2117
|
+
"CompanySecretarial",
|
|
2118
|
+
"Advisory",
|
|
2119
|
+
"SoftwareAndTraining"
|
|
2120
|
+
]);
|
|
2121
|
+
const clientTypeEnum = z.enum([
|
|
2122
|
+
"PrivateLimitedCompany",
|
|
2123
|
+
"PublicLimitedCompany",
|
|
2124
|
+
"LimitedLiabilityPartnership",
|
|
2125
|
+
"Partnership",
|
|
2126
|
+
"Individual",
|
|
2127
|
+
"Trust",
|
|
2128
|
+
"Charity",
|
|
2129
|
+
"SoleTrader"
|
|
2130
|
+
]);
|
|
2131
|
+
const sortByEnum$2 = z.enum([
|
|
2132
|
+
"Name",
|
|
2133
|
+
"Category",
|
|
2134
|
+
"AccountingCode"
|
|
2135
|
+
]);
|
|
2136
|
+
const ListServicesInputSchema = {
|
|
2137
|
+
search: z.string().min(3, "Search must be at least 3 characters when provided").optional().describe("Free-text search across service code and name. Minimum 3 characters. Use for 'find our VAT services' or 'does the practice have a bookkeeping service?' when the exact code isn't known."),
|
|
2138
|
+
category: categoryEnum.optional().describe("Filter by service category. Use 'Tax' for 'all tax services', 'Payroll' for payroll, 'CoreAccounting' for year-end / accounts / bookkeeping, 'CompanySecretarial' for confirmation statements / registered office, 'Advisory' for consulting / planning, 'SoftwareAndTraining' for software setup / training. Single value — to see multiple categories, call once per category or omit to see all."),
|
|
2139
|
+
clientType: clientTypeEnum.optional().describe("Filter by the client type the service applies to. Use for service-audit questions like 'which services do we offer to individuals?' or 'what do we offer private limited companies?'. Returns services configured for that client type plus any service with no client-type restriction (those apply to everyone)."),
|
|
2140
|
+
isArchived: z.boolean().optional().describe("Filter by archived status. Omit (default) to return everything; pass false for active services only; pass true for the archive. Most practice-manager questions ('what do we offer?') want isArchived=false."),
|
|
2141
|
+
sortBy: sortByEnum$2.optional().describe("Field to sort by. Defaults to Name. Use 'Category' to group the output by category, 'AccountingCode' when reconciling against a chart of accounts."),
|
|
2142
|
+
sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
|
|
2143
|
+
limit: z.number().int().min(0).max(50).optional().describe("Maximum number of services per page. Default 10, max 50. Pass 0 to return only the total count without any service data — use for 'how many services do we offer?' questions."),
|
|
2144
|
+
offset: z.number().int().min(0).optional().describe("Pagination offset. Default 0.")
|
|
2145
|
+
};
|
|
2146
|
+
function formatService(s) {
|
|
2147
|
+
const code = s.code ?? "(no code)";
|
|
2148
|
+
const name = s.name ?? "(unnamed)";
|
|
2149
|
+
const category = s.category ? ` · ${s.category}` : "";
|
|
2150
|
+
const pricing = s.pricingMode ? ` · ${s.pricingMode}` : "";
|
|
2151
|
+
return `- ${name} (${code})${s.isArchived ? " [ARCHIVED]" : ""}${category}${pricing}${s.accountingCode ? ` · a/c ${s.accountingCode}` : ""}${s.recurringTaskCount && s.recurringTaskCount > 0 ? ` · ${s.recurringTaskCount} recurring task${s.recurringTaskCount === 1 ? "" : "s"}` : ""}`;
|
|
2152
|
+
}
|
|
2153
|
+
async function handleListServices(api, args) {
|
|
2154
|
+
try {
|
|
2155
|
+
const query = args;
|
|
2156
|
+
const result = await api.listServices(query);
|
|
2157
|
+
const items = result.data ?? [];
|
|
2158
|
+
const total = result.totalCount ?? items.length;
|
|
2159
|
+
if (args.limit === 0) {
|
|
2160
|
+
const desc = describeFilters$2(args);
|
|
2161
|
+
return { content: [{
|
|
2162
|
+
type: "text",
|
|
2163
|
+
text: desc ? `Total: ${total} service${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} service${total === 1 ? "" : "s"}.`
|
|
2164
|
+
}] };
|
|
2165
|
+
}
|
|
2166
|
+
if (items.length === 0) {
|
|
2167
|
+
const desc = describeFilters$2(args);
|
|
2168
|
+
return { content: [{
|
|
2169
|
+
type: "text",
|
|
2170
|
+
text: desc ? `No services match ${desc}.` : "No services found."
|
|
2171
|
+
}] };
|
|
2172
|
+
}
|
|
2173
|
+
const desc = describeFilters$2(args);
|
|
2174
|
+
const lines = [
|
|
2175
|
+
desc ? total > items.length ? `Found ${total} services matching ${desc} (showing ${items.length}):` : `Found ${items.length} service${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} services:` : `${items.length} service${items.length === 1 ? "" : "s"}:`,
|
|
2176
|
+
"",
|
|
2177
|
+
...items.map(formatService)
|
|
2178
|
+
];
|
|
2179
|
+
if (result.hasMore) {
|
|
2180
|
+
const nextOffset = (args.offset ?? 0) + items.length;
|
|
2181
|
+
lines.push("", `More results available — call again with offset: ${nextOffset} to see the next page.`);
|
|
2182
|
+
}
|
|
2183
|
+
return { content: [{
|
|
2184
|
+
type: "text",
|
|
2185
|
+
text: lines.join("\n")
|
|
2186
|
+
}] };
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
return {
|
|
2189
|
+
content: [{
|
|
2190
|
+
type: "text",
|
|
2191
|
+
text: error instanceof SodiumApiError ? `Error listing services: ${error.message} (correlation: ${error.correlationId})` : `Error listing services: ${error instanceof Error ? error.message : String(error)}`
|
|
2192
|
+
}],
|
|
2193
|
+
isError: true
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
function describeFilters$2(args) {
|
|
2198
|
+
const parts = [];
|
|
2199
|
+
if (args.search) parts.push(`search "${args.search}"`);
|
|
2200
|
+
if (args.category) parts.push(`category ${args.category}`);
|
|
2201
|
+
if (args.clientType) parts.push(`clientType ${args.clientType}`);
|
|
2202
|
+
if (args.isArchived !== void 0) parts.push(`isArchived=${args.isArchived}`);
|
|
2203
|
+
return parts.join(", ");
|
|
2204
|
+
}
|
|
2205
|
+
//#endregion
|
|
1908
2206
|
//#region ../mcp-core/src/tools/list-tasks.ts
|
|
1909
2207
|
const statusEnum$1 = z.enum([
|
|
1910
2208
|
"NotStarted",
|
|
@@ -2121,10 +2419,74 @@ function describeFilters(args) {
|
|
|
2121
2419
|
return parts.join(", ");
|
|
2122
2420
|
}
|
|
2123
2421
|
//#endregion
|
|
2422
|
+
//#region ../mcp-core/src/tools/add-client-note.ts
|
|
2423
|
+
const AddClientNoteInputSchema = {
|
|
2424
|
+
clientCode: z.string().min(1, "Client code is required").describe("The client code (identifier) to attach the note to. Usually discovered via list_clients or get_client_summary."),
|
|
2425
|
+
text: z.string().min(1, "Note text cannot be empty").describe("The note body. Keep it concise and factual — something the user can scan later. The user can edit or delete the note in the UI if the wording isn't right."),
|
|
2426
|
+
pinnedLevel: z.number().int().min(0).max(2).optional().describe("Pin level for the note. 0 = not pinned (default), 1 = pinned to the notes section (surfaces at the top of the client's notes list), 2 = pinned to the client page (surfaces on the main client record, most prominent). Only set above 0 if the user explicitly asks for the note to be pinned; use 2 when they say 'pin to the client' / 'client-level pin' / 'pin at the top of the client page', and 1 when they just say 'pin' or 'pin in notes'.")
|
|
2427
|
+
};
|
|
2428
|
+
async function handleAddClientNote(api, args) {
|
|
2429
|
+
try {
|
|
2430
|
+
const user = await api.getCurrentUser();
|
|
2431
|
+
const note = await api.createClientNote(args.clientCode, {
|
|
2432
|
+
text: args.text,
|
|
2433
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2434
|
+
noteFromUserCode: user.code ?? "",
|
|
2435
|
+
pinnedLevel: args.pinnedLevel ?? 0
|
|
2436
|
+
});
|
|
2437
|
+
return { content: [{
|
|
2438
|
+
type: "text",
|
|
2439
|
+
text: `Added note to client ${args.clientCode} (note code: ${note.code ?? "(no code)"}).`
|
|
2440
|
+
}] };
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
return {
|
|
2443
|
+
content: [{
|
|
2444
|
+
type: "text",
|
|
2445
|
+
text: error instanceof SodiumApiError ? `Error adding client note: ${error.message} (correlation: ${error.correlationId})` : `Error adding client note: ${error instanceof Error ? error.message : String(error)}`
|
|
2446
|
+
}],
|
|
2447
|
+
isError: true
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
//#endregion
|
|
2452
|
+
//#region ../mcp-core/src/tools/add-task-note.ts
|
|
2453
|
+
const AddTaskNoteInputSchema = {
|
|
2454
|
+
taskCode: z.string().min(1, "Task code is required").describe("The task code (identifier) to attach the note to. Usually discovered via list_tasks or get_task_context."),
|
|
2455
|
+
text: z.string().min(1, "Note text cannot be empty").describe("The note body. Keep it concise and factual — something the user can scan later. The user can edit or delete the note in the UI if the wording isn't right."),
|
|
2456
|
+
pinnedLevel: z.number().int().min(0).max(2).optional().describe("Pin level for the note. 0 = not pinned (default), 1 = pinned to the notes section (surfaces at the top of the task's notes list), 2 = pinned to the task page (surfaces on the main task record, most prominent). Only set above 0 if the user explicitly asks for the note to be pinned; use 2 when they say 'pin to the task' / 'task-level pin' / 'pin at the top of the task page', and 1 when they just say 'pin' or 'pin in notes'.")
|
|
2457
|
+
};
|
|
2458
|
+
async function handleAddTaskNote(api, args) {
|
|
2459
|
+
try {
|
|
2460
|
+
const user = await api.getCurrentUser();
|
|
2461
|
+
const note = await api.createTaskNote(args.taskCode, {
|
|
2462
|
+
text: args.text,
|
|
2463
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2464
|
+
noteFromUserCode: user.code ?? "",
|
|
2465
|
+
pinnedLevel: args.pinnedLevel ?? 0
|
|
2466
|
+
});
|
|
2467
|
+
return { content: [{
|
|
2468
|
+
type: "text",
|
|
2469
|
+
text: `Added note to task ${args.taskCode} (note code: ${note.code ?? "(no code)"}).`
|
|
2470
|
+
}] };
|
|
2471
|
+
} catch (error) {
|
|
2472
|
+
return {
|
|
2473
|
+
content: [{
|
|
2474
|
+
type: "text",
|
|
2475
|
+
text: error instanceof SodiumApiError ? `Error adding task note: ${error.message} (correlation: ${error.correlationId})` : `Error adding task note: ${error instanceof Error ? error.message : String(error)}`
|
|
2476
|
+
}],
|
|
2477
|
+
isError: true
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
//#endregion
|
|
2124
2482
|
//#region ../mcp-core/src/server.ts
|
|
2483
|
+
function registerWriteTool(server, ctx, name, config, cb) {
|
|
2484
|
+
if (!ctx.writesEnabled) return;
|
|
2485
|
+
server.registerTool(name, config, cb);
|
|
2486
|
+
}
|
|
2125
2487
|
async function buildServer(config) {
|
|
2126
2488
|
const api = new SodiumApiClient(config.context, { serverVersion: config.serverVersion });
|
|
2127
|
-
const instructions = await buildInstructions(api);
|
|
2489
|
+
const instructions = await buildInstructions(api, config.context.writesEnabled);
|
|
2128
2490
|
const server = new McpServer({
|
|
2129
2491
|
name: config.serverName,
|
|
2130
2492
|
version: config.serverVersion
|
|
@@ -2229,6 +2591,26 @@ async function buildServer(config) {
|
|
|
2229
2591
|
openWorldHint: true
|
|
2230
2592
|
}
|
|
2231
2593
|
}, (args) => handleGetEngagementSummary(api, args));
|
|
2594
|
+
server.registerTool("list_services", {
|
|
2595
|
+
title: "List / search / filter the practice's service catalogue",
|
|
2596
|
+
description: "List the practice's configured billable services with any combination of: search (3+ chars over code and name), category (Tax / Payroll / CoreAccounting / CompanySecretarial / Advisory / SoftwareAndTraining / Other — single value), clientType (PrivateLimitedCompany / PublicLimitedCompany / LimitedLiabilityPartnership / Partnership / Individual / Trust / Charity / SoleTrader — single value; matches services configured for that type plus services with no client-type restriction), isArchived (omit for all, false for active only, true for archive), sort (Name / Category / AccountingCode), and pagination. Use for: 'what services do we offer?' (no filter, isArchived=false), 'list our tax services' (category=Tax), 'what do we offer limited companies?' (clientType=PrivateLimitedCompany, isArchived=false), 'how many active services do we have?' (isArchived=false, limit=0 for count-only). Returns up to 50 per page. Follow up with get_service_details for one service's full configuration (client types, pricing options, clearance items, HMRC authorisations).",
|
|
2597
|
+
inputSchema: ListServicesInputSchema,
|
|
2598
|
+
annotations: {
|
|
2599
|
+
readOnlyHint: true,
|
|
2600
|
+
idempotentHint: true,
|
|
2601
|
+
openWorldHint: true
|
|
2602
|
+
}
|
|
2603
|
+
}, (args) => handleListServices(api, args));
|
|
2604
|
+
server.registerTool("get_service_details", {
|
|
2605
|
+
title: "Get a single service's full configuration",
|
|
2606
|
+
description: "Get a consolidated view of one billable service by code: identity (name, code, category, status, VAT rate, accounting code, description, default manager, pricing mode), applicable client types (explicit list, or 'all client types' if unrestricted), HMRC agent authorisations, pricing options (one per billing frequency with base price + override counts), custom pricing tiers (only when pricingMode=CustomTiers), professional clearance request items, and pricing-factor presence (count + factor questions only — exact factor band values are intentionally not dumped; computed pricing per client lives in the proposal tools). Use this AFTER list_services identifies the service, or when the user references a specific service by code. Enables service-audit workflows like 'is Self Assessment correctly restricted to Individual clients?' and 'what clearance items do we request for new bookkeeping clients?'",
|
|
2607
|
+
inputSchema: GetServiceDetailsInputSchema,
|
|
2608
|
+
annotations: {
|
|
2609
|
+
readOnlyHint: true,
|
|
2610
|
+
idempotentHint: true,
|
|
2611
|
+
openWorldHint: true
|
|
2612
|
+
}
|
|
2613
|
+
}, (args) => handleGetServiceDetails(api, args));
|
|
2232
2614
|
server.registerTool("list_users", {
|
|
2233
2615
|
title: "List / search / filter tenant users",
|
|
2234
2616
|
description: "Find tenant users by name, email, role, or status. Use this when the user mentioned in a request isn't present in the startup roster (large teams have more than the top 20 active members shown there), or when filtering is needed beyond name resolution. Typical queries: 'find Jane' (search), 'list all partners' (isPartner=true), 'who's been invited but not joined yet?' (status=Invited), 'how many active users do we have?' (status=Active, limit=0). For 'how many X?' questions, pass limit=0 to get just the total count without fetching any user data.",
|
|
@@ -2239,6 +2621,28 @@ async function buildServer(config) {
|
|
|
2239
2621
|
openWorldHint: true
|
|
2240
2622
|
}
|
|
2241
2623
|
}, (args) => handleListUsers(api, args));
|
|
2624
|
+
registerWriteTool(server, config.context, "add_task_note", {
|
|
2625
|
+
title: "Add a note to a task",
|
|
2626
|
+
description: "Create a new note on a task. Additive — does not modify or delete existing notes. The note is attributed to the authenticated API user (the current practice member) and timestamped to 'now'. Use this when the user asks you to capture something on a task: 'add a note on the Greggs year-end task that we're waiting on the rental schedule', 'log on the task that I called John today and got voicemail'. Notes can be pinned; only pin when the user explicitly asks for it. The user can always edit or delete notes in the Sodium UI if the wording isn't right.",
|
|
2627
|
+
inputSchema: AddTaskNoteInputSchema,
|
|
2628
|
+
annotations: {
|
|
2629
|
+
readOnlyHint: false,
|
|
2630
|
+
destructiveHint: false,
|
|
2631
|
+
idempotentHint: false,
|
|
2632
|
+
openWorldHint: true
|
|
2633
|
+
}
|
|
2634
|
+
}, (args) => handleAddTaskNote(api, args));
|
|
2635
|
+
registerWriteTool(server, config.context, "add_client_note", {
|
|
2636
|
+
title: "Add a note to a client",
|
|
2637
|
+
description: "Create a new note on a client. Additive — does not modify or delete existing notes. The note is attributed to the authenticated API user and timestamped to 'now'. Use this when the user asks you to capture something on a client record: 'add a note on ACME that they mentioned expanding into Ireland', 'log on Greggs that they're switching bookkeeping software next quarter'. Client notes are the right place for persistent, client-level context; for task-specific notes use add_task_note. The user can edit or delete notes in the Sodium UI.",
|
|
2638
|
+
inputSchema: AddClientNoteInputSchema,
|
|
2639
|
+
annotations: {
|
|
2640
|
+
readOnlyHint: false,
|
|
2641
|
+
destructiveHint: false,
|
|
2642
|
+
idempotentHint: false,
|
|
2643
|
+
openWorldHint: true
|
|
2644
|
+
}
|
|
2645
|
+
}, (args) => handleAddClientNote(api, args));
|
|
2242
2646
|
return server;
|
|
2243
2647
|
}
|
|
2244
2648
|
//#endregion
|
|
@@ -2249,10 +2653,21 @@ function loadContext() {
|
|
|
2249
2653
|
const baseUrl = process.env.SODIUM_API_URL ?? "https://api.sodiumhq.com";
|
|
2250
2654
|
if (!apiKey) throw new Error("SODIUM_API_KEY environment variable is required. Generate one in Sodium → Settings → API Keys.");
|
|
2251
2655
|
if (!tenant) throw new Error("SODIUM_TENANT environment variable is required. Find your tenant code in Sodium → Settings → Practice.");
|
|
2656
|
+
const { values } = parseArgs({
|
|
2657
|
+
args: process.argv.slice(2),
|
|
2658
|
+
options: { "enable-writes": {
|
|
2659
|
+
type: "boolean",
|
|
2660
|
+
default: false
|
|
2661
|
+
} },
|
|
2662
|
+
strict: false
|
|
2663
|
+
});
|
|
2664
|
+
const cliWrites = values["enable-writes"] === true;
|
|
2665
|
+
const envRaw = (process.env.SODIUM_ENABLE_WRITES ?? "").trim().toLowerCase();
|
|
2252
2666
|
return {
|
|
2253
2667
|
apiKey,
|
|
2254
2668
|
tenant,
|
|
2255
|
-
baseUrl
|
|
2669
|
+
baseUrl,
|
|
2670
|
+
writesEnabled: cliWrites || envRaw === "true" || envRaw === "1"
|
|
2256
2671
|
};
|
|
2257
2672
|
}
|
|
2258
2673
|
//#endregion
|
|
@@ -2267,7 +2682,7 @@ async function main() {
|
|
|
2267
2682
|
});
|
|
2268
2683
|
const transport = new StdioServerTransport();
|
|
2269
2684
|
await server.connect(transport);
|
|
2270
|
-
console.error(`[sodium-pm-mcp] v${VERSION} ready (tenant: ${context.tenant})`);
|
|
2685
|
+
console.error(`[sodium-pm-mcp] v${VERSION} ready (tenant: ${context.tenant}, writes: ${context.writesEnabled ? "enabled" : "disabled"})`);
|
|
2271
2686
|
}
|
|
2272
2687
|
main().catch((error) => {
|
|
2273
2688
|
const message = error instanceof Error ? error.message : String(error);
|