@sodiumhq/mcp-pm 0.1.0-beta.2599 → 0.1.0-beta.2603

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 +4 -0
  2. package/dist/index.js +298 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -66,6 +66,10 @@ Then ask: *"give me a summary of my practice"*.
66
66
  - **`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
67
  - **`get_proposal_summary`** and **`get_engagement_letter_summary`** — aliased drill-ins: identity + client + value breakdown + services with pricing + PDFs + email history + acceptance record
68
68
 
69
+ **Services**
70
+ - **`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?"
71
+ - **`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?"
72
+
69
73
  **Team**
70
74
  - **`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
75
 
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { randomUUID } from "node:crypto";
@@ -786,6 +787,38 @@ const getPracticeDetails = (options) => (options.client ?? client).get({
786
787
  ...options
787
788
  });
788
789
  /**
790
+ * List BillableServices
791
+ *
792
+ * Lists all BillableServices for the given tenant.
793
+ */
794
+ const listBillableServices = (options) => (options.client ?? client).get({
795
+ security: [{
796
+ name: "x-api-key",
797
+ type: "apiKey"
798
+ }, {
799
+ scheme: "bearer",
800
+ type: "http"
801
+ }],
802
+ url: "/tenants/{tenant}/services",
803
+ ...options
804
+ });
805
+ /**
806
+ * Get BillableService
807
+ *
808
+ * Gets a BillableService for the specified tenant.
809
+ */
810
+ const getBillableService = (options) => (options.client ?? client).get({
811
+ security: [{
812
+ name: "x-api-key",
813
+ type: "apiKey"
814
+ }, {
815
+ scheme: "bearer",
816
+ type: "http"
817
+ }],
818
+ url: "/tenants/{tenant}/services/{code}",
819
+ ...options
820
+ });
821
+ /**
789
822
  * List Notes for Task
790
823
  *
791
824
  * Lists notes for the specified task. By default only returns task-level notes. Set includeStepNotes=true to also include notes attached to workflow steps.
@@ -952,14 +985,32 @@ var SodiumApiError = class extends Error {
952
985
  this.name = "SodiumApiError";
953
986
  }
954
987
  };
988
+ function sanitizeToken(value) {
989
+ return value.replace(/[\s()\/]+/g, "-").replace(/[^\w.\-]/g, "") || "unknown";
990
+ }
955
991
  var SodiumApiClient = class {
956
- constructor(ctx) {
992
+ serverVersion;
993
+ mcpClient = null;
994
+ constructor(ctx, options) {
957
995
  this.ctx = ctx;
996
+ this.serverVersion = options.serverVersion;
958
997
  client.setConfig({
959
998
  baseUrl: ctx.baseUrl,
960
- headers: { "x-api-key": ctx.apiKey }
999
+ headers: {
1000
+ "x-api-key": ctx.apiKey,
1001
+ "User-Agent": this.buildUserAgent()
1002
+ }
961
1003
  });
962
1004
  }
1005
+ setMcpClientInfo(info) {
1006
+ this.mcpClient = info;
1007
+ client.setConfig({ headers: { "User-Agent": this.buildUserAgent() } });
1008
+ }
1009
+ buildUserAgent() {
1010
+ const base = `sodiumhq-mcp-pm/${this.serverVersion}`;
1011
+ if (!this.mcpClient) return base;
1012
+ return `${base} (client=${sanitizeToken(this.mcpClient.name)}/${sanitizeToken(this.mcpClient.version)})`;
1013
+ }
963
1014
  async getPracticeDetails() {
964
1015
  const correlationId = randomUUID();
965
1016
  const { data, error, response } = await getPracticeDetails({
@@ -1080,6 +1131,32 @@ var SodiumApiClient = class {
1080
1131
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list users");
1081
1132
  return data;
1082
1133
  }
1134
+ async listServices(query = {}) {
1135
+ const correlationId = randomUUID();
1136
+ const { data, error, response } = await listBillableServices({
1137
+ path: { tenant: this.ctx.tenant },
1138
+ query: {
1139
+ ...query,
1140
+ limit: query.limit ?? 10,
1141
+ offset: query.offset ?? 0
1142
+ },
1143
+ headers: { "X-Correlation-Id": correlationId }
1144
+ });
1145
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list services");
1146
+ return data;
1147
+ }
1148
+ async getService(code) {
1149
+ const correlationId = randomUUID();
1150
+ const { data, error, response } = await getBillableService({
1151
+ path: {
1152
+ tenant: this.ctx.tenant,
1153
+ code
1154
+ },
1155
+ headers: { "X-Correlation-Id": correlationId }
1156
+ });
1157
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get service ${code}`);
1158
+ return data;
1159
+ }
1083
1160
  async listTasks(query = {}) {
1084
1161
  const correlationId = randomUUID();
1085
1162
  const { data, error, response } = await listTaskItems({
@@ -1236,7 +1313,7 @@ async function buildInstructions(api) {
1236
1313
  }
1237
1314
  //#endregion
1238
1315
  //#region ../mcp-core/src/tools/get-practice-details.ts
1239
- function format$3(tenant, practice) {
1316
+ function format$4(tenant, practice) {
1240
1317
  const lines = [];
1241
1318
  lines.push(`Practice: ${practice.name}`);
1242
1319
  lines.push(`Tenant: ${tenant.name} (${tenant.code})`);
@@ -1270,7 +1347,7 @@ async function handleGetPracticeDetails(api) {
1270
1347
  const [tenant, practice] = await Promise.all([api.getTenantDetails(), api.getPracticeDetails()]);
1271
1348
  return { content: [{
1272
1349
  type: "text",
1273
- text: format$3(tenant, practice)
1350
+ text: format$4(tenant, practice)
1274
1351
  }] };
1275
1352
  } catch (error) {
1276
1353
  return {
@@ -1300,7 +1377,7 @@ const typeEnum = z.enum([
1300
1377
  "Charity",
1301
1378
  "SoleTrader"
1302
1379
  ]);
1303
- const sortByEnum$2 = z.enum(["Name", "InternalReference"]);
1380
+ const sortByEnum$3 = z.enum(["Name", "InternalReference"]);
1304
1381
  const ListClientsInputSchema = {
1305
1382
  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."),
1306
1383
  status: z.array(statusEnum$2).optional().describe("Filter by client status. Defaults to all statuses if omitted. Example: ['Active'] for active clients only."),
@@ -1310,7 +1387,7 @@ const ListClientsInputSchema = {
1310
1387
  associateCode: z.array(z.string()).optional().describe("Filter by assigned associate user codes."),
1311
1388
  serviceCode: z.array(z.string()).optional().describe("Filter by billable service codes that clients have assigned."),
1312
1389
  savedFilter: z.string().optional().describe("Code of a user-saved filter to apply. Other filter parameters override fields from the saved filter."),
1313
- sortBy: sortByEnum$2.optional().describe("Field to sort by. Defaults to Name."),
1390
+ sortBy: sortByEnum$3.optional().describe("Field to sort by. Defaults to Name."),
1314
1391
  sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
1315
1392
  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."),
1316
1393
  offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination. Default 0.")
@@ -1328,20 +1405,20 @@ async function handleListClients(api, args) {
1328
1405
  const items = result.data ?? [];
1329
1406
  const total = result.totalCount ?? items.length;
1330
1407
  if (args.limit === 0) {
1331
- const desc = describeFilters$3(args);
1408
+ const desc = describeFilters$4(args);
1332
1409
  return { content: [{
1333
1410
  type: "text",
1334
1411
  text: desc ? `Total: ${total} client${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} client${total === 1 ? "" : "s"}.`
1335
1412
  }] };
1336
1413
  }
1337
1414
  if (items.length === 0) {
1338
- const desc = describeFilters$3(args);
1415
+ const desc = describeFilters$4(args);
1339
1416
  return { content: [{
1340
1417
  type: "text",
1341
1418
  text: desc ? `No clients match ${desc}.` : "No clients found."
1342
1419
  }] };
1343
1420
  }
1344
- const desc = describeFilters$3(args);
1421
+ const desc = describeFilters$4(args);
1345
1422
  const lines = [
1346
1423
  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"}:`,
1347
1424
  "",
@@ -1365,7 +1442,7 @@ async function handleListClients(api, args) {
1365
1442
  };
1366
1443
  }
1367
1444
  }
1368
- function describeFilters$3(args) {
1445
+ function describeFilters$4(args) {
1369
1446
  const parts = [];
1370
1447
  if (args.search) parts.push(`search "${args.search}"`);
1371
1448
  if (args.status?.length) parts.push(`status ${args.status.join("/")}`);
@@ -1411,7 +1488,7 @@ async function handleGetClientSummary(api, { code }) {
1411
1488
  }
1412
1489
  return { content: [{
1413
1490
  type: "text",
1414
- text: format$2({
1491
+ text: format$3({
1415
1492
  client: clientResult.value,
1416
1493
  contacts: extract(contactsResult),
1417
1494
  services: extract(servicesResult),
@@ -1434,7 +1511,7 @@ function extract(result) {
1434
1511
  if (result.status !== "fulfilled") return [];
1435
1512
  return result.value.data ?? [];
1436
1513
  }
1437
- function format$2(input) {
1514
+ function format$3(input) {
1438
1515
  const { client, contacts, services, businessDetails, clientDates, overdueTasks, upcomingTasks, gaps } = input;
1439
1516
  const lines = [];
1440
1517
  const name = client.name ?? "(no name)";
@@ -1551,14 +1628,14 @@ async function handleGetEngagementSummary(api, { code }) {
1551
1628
  const engagement = engagementResult.value;
1552
1629
  return { content: [{
1553
1630
  type: "text",
1554
- text: format$1({
1631
+ text: format$2({
1555
1632
  engagement,
1556
1633
  emails: emailsResult.status === "fulfilled" ? emailsResult.value : [],
1557
1634
  gaps: emailsResult.status === "rejected" ? ["email history"] : []
1558
1635
  })
1559
1636
  }] };
1560
1637
  }
1561
- function format$1(input) {
1638
+ function format$2(input) {
1562
1639
  const { engagement: e, emails, gaps } = input;
1563
1640
  const lines = [];
1564
1641
  const code = e.code ?? "(no code)";
@@ -1578,7 +1655,7 @@ function format$1(input) {
1578
1655
  const services = e.proposalServices ?? [];
1579
1656
  if (services.length > 0) {
1580
1657
  lines.push("", `--- Services (${services.length}) ---`);
1581
- for (const s of services) lines.push(`- ${formatService(s)}`);
1658
+ for (const s of services) lines.push(`- ${formatService$1(s)}`);
1582
1659
  }
1583
1660
  if (e.acceptance && e.status === "Accepted") {
1584
1661
  lines.push("", "--- Acceptance ---");
@@ -1613,7 +1690,7 @@ function formatValues(e) {
1613
1690
  if (e.totalValue && e.totalValue > 0) out.push(`Total contracted value: £${formatMoney$1(e.totalValue)}`);
1614
1691
  return out;
1615
1692
  }
1616
- function formatService(s) {
1693
+ function formatService$1(s) {
1617
1694
  const name = s.billableService?.name ?? "(unnamed)";
1618
1695
  const freq = s.billingFrequency ? ` ${s.billingFrequency}` : "";
1619
1696
  if (s.effectivePrice !== void 0 && s.effectivePrice !== null) return `${name} — £${formatMoney$1(s.effectivePrice)}${freq}`;
@@ -1629,6 +1706,83 @@ function formatMoney$1(n) {
1629
1706
  });
1630
1707
  }
1631
1708
  //#endregion
1709
+ //#region ../mcp-core/src/tools/get-service-details.ts
1710
+ const GetServiceDetailsInputSchema = { code: z.string().min(1, "Service code is required").describe("The service code (identifier). Usually discovered via list_services first.") };
1711
+ async function handleGetServiceDetails(api, { code }) {
1712
+ try {
1713
+ return { content: [{
1714
+ type: "text",
1715
+ text: format$1(await api.getService(code))
1716
+ }] };
1717
+ } catch (error) {
1718
+ return {
1719
+ content: [{
1720
+ type: "text",
1721
+ text: error instanceof SodiumApiError ? `Error getting service: ${error.message} (correlation: ${error.correlationId})` : `Error getting service: ${error instanceof Error ? error.message : String(error)}`
1722
+ }],
1723
+ isError: true
1724
+ };
1725
+ }
1726
+ }
1727
+ function format$1(s) {
1728
+ const lines = [];
1729
+ const name = s.name ?? "(no name)";
1730
+ const code = s.code ?? "(no code)";
1731
+ lines.push(`Service: ${name} (${code})`);
1732
+ const meta = [];
1733
+ if (s.category) meta.push(s.category);
1734
+ meta.push(s.isArchived ? "Archived" : "Active");
1735
+ if (s.vatRate) meta.push(`VAT: ${s.vatRate}`);
1736
+ if (s.pricingMode) meta.push(`Pricing: ${s.pricingMode}`);
1737
+ if (meta.length > 0) lines.push(meta.join(" · "));
1738
+ if (s.accountingCode) lines.push(`Accounting code: ${s.accountingCode}`);
1739
+ if (s.description) lines.push(`Description: ${s.description}`);
1740
+ if (s.defaultManagedByUser) lines.push(`Default manager: ${s.defaultManagedByUser.name} (${s.defaultManagedByUser.code})`);
1741
+ lines.push("", "--- Applicable Client Types ---");
1742
+ if (!s.clientTypes || s.clientTypes.length === 0) lines.push("All client types (no restriction configured).");
1743
+ else for (const ct of s.clientTypes) lines.push(`- ${ct}`);
1744
+ lines.push("", `--- HMRC Agent Authorisations (${s.agentAuthorisations?.length ?? 0}) ---`);
1745
+ if (!s.agentAuthorisations || s.agentAuthorisations.length === 0) lines.push("None configured.");
1746
+ else for (const a of s.agentAuthorisations) lines.push(`- ${a}`);
1747
+ lines.push("", `--- Pricing Options (${s.pricing?.length ?? 0}) ---`);
1748
+ if (!s.pricing || s.pricing.length === 0) lines.push("No pricing options configured.");
1749
+ else for (const p of s.pricing) lines.push(formatPricingOption(p));
1750
+ if (s.pricingMode === "CustomTiers" && s.pricingTiers && s.pricingTiers.length > 0) {
1751
+ lines.push("", `--- Custom Pricing Tiers (${s.pricingTiers.length}) ---`);
1752
+ const tiers = [...s.pricingTiers].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
1753
+ for (const t of tiers) lines.push(formatTier(t));
1754
+ }
1755
+ lines.push("", `--- Professional Clearance Items (${s.pcrItems?.length ?? 0}) ---`);
1756
+ if (!s.pcrItems || s.pcrItems.length === 0) lines.push("None configured.");
1757
+ else for (const item of s.pcrItems) lines.push(formatCodeAndName$1(item));
1758
+ const factorCount = s.pricingFactors?.length ?? 0;
1759
+ lines.push("", "--- Pricing Factors ---");
1760
+ if (factorCount === 0) lines.push("None configured.");
1761
+ else {
1762
+ lines.push(`${factorCount} pricing factor${factorCount === 1 ? "" : "s"} configured.`);
1763
+ for (const f of s.pricingFactors ?? []) if (f.description) lines.push(`- ${f.description}`);
1764
+ 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.");
1765
+ }
1766
+ if (s.recurringTaskCount && s.recurringTaskCount > 0) lines.push("", `Recurring tasks using this service: ${s.recurringTaskCount}`);
1767
+ return lines.join("\n");
1768
+ }
1769
+ function formatPricingOption(p) {
1770
+ const freq = p.frequency ?? "(no frequency)";
1771
+ const price = typeof p.price === "number" ? `£${p.price.toFixed(2)}` : "(no price)";
1772
+ const rangeCount = p.revenueRangeOverrides?.length ?? 0;
1773
+ const tierCount = p.tierOverrides?.length ?? 0;
1774
+ const overrides = [];
1775
+ if (rangeCount > 0) overrides.push(`${rangeCount} revenue-range override${rangeCount === 1 ? "" : "s"}`);
1776
+ if (tierCount > 0) overrides.push(`${tierCount} tier override${tierCount === 1 ? "" : "s"}`);
1777
+ return `- ${freq}: ${price}${overrides.length > 0 ? ` (+ ${overrides.join(", ")})` : ""}`;
1778
+ }
1779
+ function formatTier(t) {
1780
+ return `- ${t.name ?? "(unnamed)"} (${t.code ?? "(no code)"})`;
1781
+ }
1782
+ function formatCodeAndName$1(item) {
1783
+ return `- ${item.name ?? "(unnamed)"} (${item.code ?? "(no code)"})`;
1784
+ }
1785
+ //#endregion
1632
1786
  //#region ../mcp-core/src/tools/get-task-context.ts
1633
1787
  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.") };
1634
1788
  async function handleGetTaskContext(api, { code }) {
@@ -1826,20 +1980,20 @@ async function handleListEngagements(api, args) {
1826
1980
  const items = result.data ?? [];
1827
1981
  const total = result.totalCount ?? items.length;
1828
1982
  if (args.limit === 0) {
1829
- const desc = describeFilters$2(args);
1983
+ const desc = describeFilters$3(args);
1830
1984
  return { content: [{
1831
1985
  type: "text",
1832
1986
  text: desc ? `Total: ${total} engagement${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} engagement${total === 1 ? "" : "s"}.`
1833
1987
  }] };
1834
1988
  }
1835
1989
  if (items.length === 0) {
1836
- const desc = describeFilters$2(args);
1990
+ const desc = describeFilters$3(args);
1837
1991
  return { content: [{
1838
1992
  type: "text",
1839
1993
  text: desc ? `No engagements match ${desc}.` : "No engagements found."
1840
1994
  }] };
1841
1995
  }
1842
- const desc = describeFilters$2(args);
1996
+ const desc = describeFilters$3(args);
1843
1997
  const lines = [
1844
1998
  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"}:`,
1845
1999
  "",
@@ -1877,7 +2031,7 @@ function formatMoney(n) {
1877
2031
  maximumFractionDigits: 2
1878
2032
  });
1879
2033
  }
1880
- function describeFilters$2(args) {
2034
+ function describeFilters$3(args) {
1881
2035
  const parts = [];
1882
2036
  if (args.status) parts.push(`status ${args.status}`);
1883
2037
  if (args.search) parts.push(`search "${args.search}"`);
@@ -1886,6 +2040,101 @@ function describeFilters$2(args) {
1886
2040
  return parts.join(", ");
1887
2041
  }
1888
2042
  //#endregion
2043
+ //#region ../mcp-core/src/tools/list-services.ts
2044
+ const categoryEnum = z.enum([
2045
+ "Other",
2046
+ "CoreAccounting",
2047
+ "Tax",
2048
+ "Payroll",
2049
+ "CompanySecretarial",
2050
+ "Advisory",
2051
+ "SoftwareAndTraining"
2052
+ ]);
2053
+ const clientTypeEnum = z.enum([
2054
+ "PrivateLimitedCompany",
2055
+ "PublicLimitedCompany",
2056
+ "LimitedLiabilityPartnership",
2057
+ "Partnership",
2058
+ "Individual",
2059
+ "Trust",
2060
+ "Charity",
2061
+ "SoleTrader"
2062
+ ]);
2063
+ const sortByEnum$2 = z.enum([
2064
+ "Name",
2065
+ "Category",
2066
+ "AccountingCode"
2067
+ ]);
2068
+ const ListServicesInputSchema = {
2069
+ 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."),
2070
+ 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."),
2071
+ 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)."),
2072
+ 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."),
2073
+ 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."),
2074
+ sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
2075
+ 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."),
2076
+ offset: z.number().int().min(0).optional().describe("Pagination offset. Default 0.")
2077
+ };
2078
+ function formatService(s) {
2079
+ const code = s.code ?? "(no code)";
2080
+ const name = s.name ?? "(unnamed)";
2081
+ const category = s.category ? ` · ${s.category}` : "";
2082
+ const pricing = s.pricingMode ? ` · ${s.pricingMode}` : "";
2083
+ 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"}` : ""}`;
2084
+ }
2085
+ async function handleListServices(api, args) {
2086
+ try {
2087
+ const query = args;
2088
+ const result = await api.listServices(query);
2089
+ const items = result.data ?? [];
2090
+ const total = result.totalCount ?? items.length;
2091
+ if (args.limit === 0) {
2092
+ const desc = describeFilters$2(args);
2093
+ return { content: [{
2094
+ type: "text",
2095
+ text: desc ? `Total: ${total} service${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} service${total === 1 ? "" : "s"}.`
2096
+ }] };
2097
+ }
2098
+ if (items.length === 0) {
2099
+ const desc = describeFilters$2(args);
2100
+ return { content: [{
2101
+ type: "text",
2102
+ text: desc ? `No services match ${desc}.` : "No services found."
2103
+ }] };
2104
+ }
2105
+ const desc = describeFilters$2(args);
2106
+ const lines = [
2107
+ 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"}:`,
2108
+ "",
2109
+ ...items.map(formatService)
2110
+ ];
2111
+ if (result.hasMore) {
2112
+ const nextOffset = (args.offset ?? 0) + items.length;
2113
+ lines.push("", `More results available — call again with offset: ${nextOffset} to see the next page.`);
2114
+ }
2115
+ return { content: [{
2116
+ type: "text",
2117
+ text: lines.join("\n")
2118
+ }] };
2119
+ } catch (error) {
2120
+ return {
2121
+ content: [{
2122
+ type: "text",
2123
+ text: error instanceof SodiumApiError ? `Error listing services: ${error.message} (correlation: ${error.correlationId})` : `Error listing services: ${error instanceof Error ? error.message : String(error)}`
2124
+ }],
2125
+ isError: true
2126
+ };
2127
+ }
2128
+ }
2129
+ function describeFilters$2(args) {
2130
+ const parts = [];
2131
+ if (args.search) parts.push(`search "${args.search}"`);
2132
+ if (args.category) parts.push(`category ${args.category}`);
2133
+ if (args.clientType) parts.push(`clientType ${args.clientType}`);
2134
+ if (args.isArchived !== void 0) parts.push(`isArchived=${args.isArchived}`);
2135
+ return parts.join(", ");
2136
+ }
2137
+ //#endregion
1889
2138
  //#region ../mcp-core/src/tools/list-tasks.ts
1890
2139
  const statusEnum$1 = z.enum([
1891
2140
  "NotStarted",
@@ -2104,7 +2353,7 @@ function describeFilters(args) {
2104
2353
  //#endregion
2105
2354
  //#region ../mcp-core/src/server.ts
2106
2355
  async function buildServer(config) {
2107
- const api = new SodiumApiClient(config.context);
2356
+ const api = new SodiumApiClient(config.context, { serverVersion: config.serverVersion });
2108
2357
  const instructions = await buildInstructions(api);
2109
2358
  const server = new McpServer({
2110
2359
  name: config.serverName,
@@ -2113,6 +2362,13 @@ async function buildServer(config) {
2113
2362
  instructions,
2114
2363
  capabilities: { tools: {} }
2115
2364
  });
2365
+ server.server.oninitialized = () => {
2366
+ const info = server.server.getClientVersion();
2367
+ if (info?.name && info?.version) api.setMcpClientInfo({
2368
+ name: info.name,
2369
+ version: info.version
2370
+ });
2371
+ };
2116
2372
  server.registerTool("get_practice_details", {
2117
2373
  title: "Get practice details",
2118
2374
  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.",
@@ -2203,6 +2459,26 @@ async function buildServer(config) {
2203
2459
  openWorldHint: true
2204
2460
  }
2205
2461
  }, (args) => handleGetEngagementSummary(api, args));
2462
+ server.registerTool("list_services", {
2463
+ title: "List / search / filter the practice's service catalogue",
2464
+ 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).",
2465
+ inputSchema: ListServicesInputSchema,
2466
+ annotations: {
2467
+ readOnlyHint: true,
2468
+ idempotentHint: true,
2469
+ openWorldHint: true
2470
+ }
2471
+ }, (args) => handleListServices(api, args));
2472
+ server.registerTool("get_service_details", {
2473
+ title: "Get a single service's full configuration",
2474
+ 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?'",
2475
+ inputSchema: GetServiceDetailsInputSchema,
2476
+ annotations: {
2477
+ readOnlyHint: true,
2478
+ idempotentHint: true,
2479
+ openWorldHint: true
2480
+ }
2481
+ }, (args) => handleGetServiceDetails(api, args));
2206
2482
  server.registerTool("list_users", {
2207
2483
  title: "List / search / filter tenant users",
2208
2484
  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.",
@@ -2231,7 +2507,7 @@ function loadContext() {
2231
2507
  }
2232
2508
  //#endregion
2233
2509
  //#region src/index.ts
2234
- const VERSION = "0.0.1";
2510
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
2235
2511
  async function main() {
2236
2512
  const context = loadContext();
2237
2513
  const server = await buildServer({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2599",
3
+ "version": "0.1.0-beta.2603",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {