@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.
Files changed (3) hide show
  1. package/README.md +16 -1
  2. package/dist/index.js +437 -22
  3. 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 at the Pro tier
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$3(tenant, practice) {
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$3(tenant, practice)
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$2 = z.enum(["Name", "InternalReference"]);
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$2.optional().describe("Field to sort by. Defaults to Name."),
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$3(args);
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$3(args);
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$3(args);
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$3(args) {
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$2({
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$2(input) {
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$1({
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$1(input) {
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$2(args);
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$2(args);
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$2(args);
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$2(args) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2600",
3
+ "version": "0.1.0-beta.2611",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {