@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.
- package/README.md +4 -0
- package/dist/index.js +298 -22
- 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
|
-
|
|
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: {
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 = "
|
|
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({
|