@sodiumhq/mcp-pm 0.1.0-beta.2619 → 0.1.0-beta.2749

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 +2 -1
  2. package/dist/index.js +222 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
63
63
 
64
64
  **Clients**
65
65
  - **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
66
- - **`get_client_summary`** — one-call composite for a single client: identity + contacts + active services with pricing + business details (company number, VAT, UTR, trading address) + key statutory dates (year-end, accounts due, VAT return due, confirmation statement due) + overdue tasks + tasks due in next 7 days
66
+ - **`get_client_summary`** — one-call composite for a single client: identity + contacts + active services with pricing + business details (company number, VAT, UTR, trading address) + **custom fields** (all user-defined fields with current values, data types, and field codes) + key statutory dates (year-end, accounts due, VAT return due, confirmation statement due) + overdue tasks + tasks due in next 7 days
67
67
 
68
68
  **Tasks**
69
69
  - **`list_tasks`** — find and count tasks by assignee (including "my tasks"), client, status, overdue, date range, category, team, or workflow. Answers "what's on my plate?", "what's Jane working on?", "what's overdue for ACME?", "how many tasks are due this week?"
@@ -83,6 +83,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
83
83
  **Write tools** (require `--enable-writes` — see [Write mode](#write-mode))
84
84
  - **`add_task_note`** — capture a note against a specific task. Attributed to your API user, timestamped to now.
85
85
  - **`add_client_note`** — capture a note against a client record. Same shape as `add_task_note` but scoped to the client.
86
+ - **`update_client_custom_fields`** — set or clear custom field values on a client. Accepts a map of field codes to values, validates against the field's data type (Text, Number, Date, Boolean, Select, MultiSelect). Pass null to clear a field. Field codes are discoverable via `get_client_summary`.
86
87
 
87
88
  More tools land iteratively as the beta progresses.
88
89
 
package/dist/index.js CHANGED
@@ -654,6 +654,42 @@ const listClientContactsForClient = (options) => (options.client ?? client).get(
654
654
  ...options
655
655
  });
656
656
  /**
657
+ * Get Custom Field Values
658
+ *
659
+ * Returns field code and value pairs for the specified client. Use the custom field definitions endpoint to retrieve data types, labels, and allowed options.
660
+ */
661
+ const getClientCustomFieldValues = (options) => (options.client ?? client).get({
662
+ security: [{
663
+ name: "x-api-key",
664
+ type: "apiKey"
665
+ }, {
666
+ scheme: "bearer",
667
+ type: "http"
668
+ }],
669
+ url: "/tenants/{tenant}/clients/{client}/custom-field-values",
670
+ ...options
671
+ });
672
+ /**
673
+ * Set Custom Field Values
674
+ *
675
+ * Sets or updates custom field values for the specified client. Only fields included in the request are updated. Use the custom field definitions endpoint to determine valid field codes and data types.
676
+ */
677
+ const setClientCustomFieldValues = (options) => (options.client ?? client).put({
678
+ security: [{
679
+ name: "x-api-key",
680
+ type: "apiKey"
681
+ }, {
682
+ scheme: "bearer",
683
+ type: "http"
684
+ }],
685
+ url: "/tenants/{tenant}/clients/{client}/custom-field-values",
686
+ ...options,
687
+ headers: {
688
+ "Content-Type": "application/json",
689
+ ...options.headers
690
+ }
691
+ });
692
+ /**
657
693
  * Get Client Dates
658
694
  *
659
695
  * Returns all key dates for the specified client
@@ -741,6 +777,22 @@ const getClient = (options) => (options.client ?? client).get({
741
777
  ...options
742
778
  });
743
779
  /**
780
+ * List CustomFieldDefinitions
781
+ *
782
+ * Lists all CustomFieldDefinitions for the given tenant.
783
+ */
784
+ const listCustomFieldDefinitions = (options) => (options.client ?? client).get({
785
+ security: [{
786
+ name: "x-api-key",
787
+ type: "apiKey"
788
+ }, {
789
+ scheme: "bearer",
790
+ type: "http"
791
+ }],
792
+ url: "/tenants/{tenant}/custom-fields",
793
+ ...options
794
+ });
795
+ /**
744
796
  * List Engagements
745
797
  *
746
798
  * Lists all Engagements for the given tenant.
@@ -1317,6 +1369,44 @@ var SodiumApiClient = class {
1317
1369
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to client ${clientCode}`);
1318
1370
  return data;
1319
1371
  }
1372
+ async listCustomFieldDefinitions(query = {}) {
1373
+ const correlationId = randomUUID();
1374
+ const { data, error, response } = await listCustomFieldDefinitions({
1375
+ path: { tenant: this.ctx.tenant },
1376
+ query: {
1377
+ ...query,
1378
+ limit: query.limit ?? 50,
1379
+ offset: query.offset ?? 0
1380
+ },
1381
+ headers: { "X-Correlation-Id": correlationId }
1382
+ });
1383
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list custom field definitions");
1384
+ return data;
1385
+ }
1386
+ async getClientCustomFieldValues(clientCode) {
1387
+ const correlationId = randomUUID();
1388
+ const { data, error, response } = await getClientCustomFieldValues({
1389
+ path: {
1390
+ tenant: this.ctx.tenant,
1391
+ client: clientCode
1392
+ },
1393
+ headers: { "X-Correlation-Id": correlationId }
1394
+ });
1395
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get custom field values for client ${clientCode}`);
1396
+ return data;
1397
+ }
1398
+ async setClientCustomFieldValues(clientCode, values) {
1399
+ const correlationId = randomUUID();
1400
+ const { error, response } = await setClientCustomFieldValues({
1401
+ path: {
1402
+ tenant: this.ctx.tenant,
1403
+ client: clientCode
1404
+ },
1405
+ body: values,
1406
+ headers: { "X-Correlation-Id": correlationId }
1407
+ });
1408
+ if (error !== void 0) throw this.toError(response, error, correlationId, `set custom field values for client ${clientCode}`);
1409
+ }
1320
1410
  toError(response, error, correlationId, operation) {
1321
1411
  const status = response.status;
1322
1412
  let message = `Failed to ${operation} (HTTP ${status})`;
@@ -1534,7 +1624,7 @@ function describeFilters$4(args) {
1534
1624
  //#region ../mcp-core/src/tools/get-client-summary.ts
1535
1625
  const GetClientSummaryInputSchema = { code: z.string().min(1, "Client code is required").describe("The client code (identifier). Usually discovered via list_clients first.") };
1536
1626
  async function handleGetClientSummary(api, { code }) {
1537
- const [clientResult, contactsResult, servicesResult, businessResult, datesResult, overdueResult, upcomingResult] = await Promise.allSettled([
1627
+ const [clientResult, contactsResult, servicesResult, businessResult, datesResult, overdueResult, upcomingResult, customFieldDefsResult, customFieldValsResult] = await Promise.allSettled([
1538
1628
  api.getClient(code),
1539
1629
  api.listClientContacts(code),
1540
1630
  api.listClientServices(code),
@@ -1550,7 +1640,13 @@ async function handleGetClientSummary(api, { code }) {
1550
1640
  dateRange: "Next7Days",
1551
1641
  dateBasis: "DueDate",
1552
1642
  limit: 50
1553
- })
1643
+ }),
1644
+ api.listCustomFieldDefinitions({
1645
+ entityType: "Client",
1646
+ isArchived: false,
1647
+ limit: 50
1648
+ }),
1649
+ api.getClientCustomFieldValues(code)
1554
1650
  ]);
1555
1651
  if (clientResult.status === "rejected") {
1556
1652
  const err = clientResult.reason;
@@ -1562,6 +1658,7 @@ async function handleGetClientSummary(api, { code }) {
1562
1658
  isError: true
1563
1659
  };
1564
1660
  }
1661
+ const customFieldsAvailable = customFieldDefsResult.status === "fulfilled" && customFieldValsResult.status === "fulfilled";
1565
1662
  return { content: [{
1566
1663
  type: "text",
1567
1664
  text: format$3({
@@ -1572,13 +1669,16 @@ async function handleGetClientSummary(api, { code }) {
1572
1669
  clientDates: datesResult.status === "fulfilled" ? datesResult.value : [],
1573
1670
  overdueTasks: extract(overdueResult),
1574
1671
  upcomingTasks: extract(upcomingResult),
1672
+ customFieldDefs: customFieldsAvailable ? customFieldDefsResult.value.data ?? [] : [],
1673
+ customFieldValues: customFieldsAvailable ? customFieldValsResult.value : [],
1575
1674
  gaps: [
1576
1675
  contactsResult.status === "rejected" ? "contacts" : null,
1577
1676
  servicesResult.status === "rejected" ? "services" : null,
1578
1677
  businessResult.status === "rejected" ? "business details" : null,
1579
1678
  datesResult.status === "rejected" ? "key dates" : null,
1580
1679
  overdueResult.status === "rejected" ? "overdue tasks" : null,
1581
- upcomingResult.status === "rejected" ? "upcoming tasks" : null
1680
+ upcomingResult.status === "rejected" ? "upcoming tasks" : null,
1681
+ !customFieldsAvailable ? "custom fields" : null
1582
1682
  ].filter((v) => v !== null)
1583
1683
  })
1584
1684
  }] };
@@ -1588,7 +1688,7 @@ function extract(result) {
1588
1688
  return result.value.data ?? [];
1589
1689
  }
1590
1690
  function format$3(input) {
1591
- const { client, contacts, services, businessDetails, clientDates, overdueTasks, upcomingTasks, gaps } = input;
1691
+ const { client, contacts, services, businessDetails, clientDates, overdueTasks, upcomingTasks, customFieldDefs, customFieldValues, gaps } = input;
1592
1692
  const lines = [];
1593
1693
  const name = client.name ?? "(no name)";
1594
1694
  const code = client.code ?? "(no code)";
@@ -1603,6 +1703,18 @@ function format$3(input) {
1603
1703
  if (client.associate) lines.push(`Associate: ${client.associate.name} (${client.associate.code})`);
1604
1704
  const businessLines = formatBusinessDetails(businessDetails);
1605
1705
  if (businessLines.length > 0) lines.push("", "--- Business Details ---", ...businessLines);
1706
+ if (customFieldDefs.length > 0) {
1707
+ const valueMap = /* @__PURE__ */ new Map();
1708
+ for (const v of customFieldValues) if (v.fieldCode) valueMap.set(v.fieldCode, v.value ?? null);
1709
+ lines.push("", `--- Custom Fields (${customFieldDefs.length}) ---`);
1710
+ for (const def of customFieldDefs) {
1711
+ const code = def.code ?? "(no code)";
1712
+ const label = def.label ?? code;
1713
+ const val = valueMap.get(code);
1714
+ const typeHint = def.dataType ? ` [${def.dataType}]` : "";
1715
+ lines.push(`- ${label} (${code})${typeHint}: ${val ?? "(not set)"}`);
1716
+ }
1717
+ }
1606
1718
  if (clientDates.length > 0) {
1607
1719
  lines.push("", `--- Key Dates (${clientDates.length}) ---`);
1608
1720
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -2487,6 +2599,90 @@ async function handleAddTaskNote(api, args) {
2487
2599
  }
2488
2600
  }
2489
2601
  //#endregion
2602
+ //#region ../mcp-core/src/tools/set-client-custom-fields.ts
2603
+ const SetClientCustomFieldsInputSchema = {
2604
+ clientCode: z.string().min(1, "Client code is required").describe("The client code (identifier). Usually discovered via list_clients or get_client_summary."),
2605
+ fields: z.record(z.string(), z.string().nullable()).describe("A map of custom field codes to values. Use null to clear a field. Values must match the field's data type: Text — any string; Number — a numeric string like \"42\" or \"3.14\"; Date — ISO 8601 date like \"2025-06-15\"; Boolean — \"true\" or \"false\"; Select — one of the field's allowed options (exact match); MultiSelect — comma-separated list of allowed options. Use get_client_summary to discover available field codes and their current values before setting.")
2606
+ };
2607
+ async function handleSetClientCustomFields(api, args) {
2608
+ try {
2609
+ const entries = Object.entries(args.fields);
2610
+ if (entries.length === 0) return { content: [{
2611
+ type: "text",
2612
+ text: "No fields provided — nothing to update."
2613
+ }] };
2614
+ const definitions = await api.listCustomFieldDefinitions({
2615
+ entityType: "Client",
2616
+ isArchived: false,
2617
+ limit: 50
2618
+ });
2619
+ const defMap = /* @__PURE__ */ new Map();
2620
+ for (const d of definitions.data ?? []) if (d.code) defMap.set(d.code, d);
2621
+ const errors = [];
2622
+ for (const [fieldCode, value] of entries) {
2623
+ const def = defMap.get(fieldCode);
2624
+ if (!def) {
2625
+ errors.push(`Unknown custom field code: "${fieldCode}"`);
2626
+ continue;
2627
+ }
2628
+ if (value === null || value === "") continue;
2629
+ const validation = validateFieldValue(def, value);
2630
+ if (validation) errors.push(`${fieldCode}: ${validation}`);
2631
+ }
2632
+ if (errors.length > 0) return {
2633
+ content: [{
2634
+ type: "text",
2635
+ text: `Validation failed:\n${errors.map((e) => `- ${e}`).join("\n")}`
2636
+ }],
2637
+ isError: true
2638
+ };
2639
+ await api.setClientCustomFieldValues(args.clientCode, entries.map(([fieldCode, value]) => ({
2640
+ fieldCode,
2641
+ value: value ?? null
2642
+ })));
2643
+ const summary = entries.map(([code, val]) => `- ${defMap.get(code)?.label ?? code}: ${val === null ? "(cleared)" : val}`).join("\n");
2644
+ return { content: [{
2645
+ type: "text",
2646
+ text: `Updated ${entries.length} custom field(s) on client ${args.clientCode}:\n${summary}`
2647
+ }] };
2648
+ } catch (error) {
2649
+ return {
2650
+ content: [{
2651
+ type: "text",
2652
+ text: error instanceof SodiumApiError ? `Error setting custom fields: ${error.message} (correlation: ${error.correlationId})` : `Error setting custom fields: ${error instanceof Error ? error.message : String(error)}`
2653
+ }],
2654
+ isError: true
2655
+ };
2656
+ }
2657
+ }
2658
+ function validateFieldValue(def, value) {
2659
+ switch (def.dataType) {
2660
+ case "Number":
2661
+ if (isNaN(Number(value))) return `Expected a number, got "${value}"`;
2662
+ return null;
2663
+ case "Date": {
2664
+ const d = new Date(value);
2665
+ if (isNaN(d.getTime())) return `Expected an ISO 8601 date, got "${value}"`;
2666
+ return null;
2667
+ }
2668
+ case "Boolean":
2669
+ if (value !== "true" && value !== "false") return `Expected "true" or "false", got "${value}"`;
2670
+ return null;
2671
+ case "Select": {
2672
+ const options = def.options ?? [];
2673
+ if (!options.includes(value)) return `"${value}" is not a valid option. Allowed: ${options.join(", ")}`;
2674
+ return null;
2675
+ }
2676
+ case "MultiSelect": {
2677
+ const options = def.options ?? [];
2678
+ const invalid = value.split(",").map((s) => s.trim()).filter((s) => !options.includes(s));
2679
+ if (invalid.length > 0) return `Invalid option(s): ${invalid.join(", ")}. Allowed: ${options.join(", ")}`;
2680
+ return null;
2681
+ }
2682
+ default: return null;
2683
+ }
2684
+ }
2685
+ //#endregion
2490
2686
  //#region ../mcp-core/src/server.ts
2491
2687
  function registerWriteTool(server, ctx, name, config, cb) {
2492
2688
  if (!ctx.writesEnabled) return;
@@ -2497,7 +2693,16 @@ async function buildServer(config) {
2497
2693
  const instructions = await buildInstructions(api, config.context.writesEnabled);
2498
2694
  const server = new McpServer({
2499
2695
  name: config.serverName,
2500
- version: config.serverVersion
2696
+ version: config.serverVersion,
2697
+ icons: [{
2698
+ src: "https://sodiumhq.com/assets/logo-no-text.png",
2699
+ mimeType: "image/png",
2700
+ theme: "light"
2701
+ }, {
2702
+ src: "https://sodiumhq.com/assets/logo-no-text-darkbg.png",
2703
+ mimeType: "image/png",
2704
+ theme: "dark"
2705
+ }]
2501
2706
  }, {
2502
2707
  instructions,
2503
2708
  capabilities: { tools: {} }
@@ -2531,7 +2736,7 @@ async function buildServer(config) {
2531
2736
  }, (args) => handleListClients(api, args));
2532
2737
  server.registerTool("get_client_summary", {
2533
2738
  title: "Get a full summary of one client",
2534
- description: "Get a consolidated overview of a single client by code: identity (name, status, type, assignments), business details (company number, incorporation date, trading name, registered address, VAT status, UTR, PAYE ref), key statutory dates (year-end, accounts due, VAT return due, confirmation statement due, etc. — upcoming first, then past), all contacts, active services with pricing, overdue task count + top 5, and tasks due in the next 7 days. Use this AFTER list_clients identifies the client of interest, or when the user references a specific client by code. Also answers 'when is ACME's year-end?' / 'is ACME VAT registered?' in a single call. Tolerates partial failures — if one section can't be loaded, the rest is still returned with a note about what's missing.",
2739
+ description: "Get a consolidated overview of a single client by code: identity (name, status, type, assignments), business details (company number, incorporation date, trading name, registered address, VAT status, UTR, PAYE ref), custom fields (all user-defined fields with current values, data types, and field codes — use these codes with update_client_custom_fields), key statutory dates (year-end, accounts due, VAT return due, confirmation statement due, etc. — upcoming first, then past), all contacts, active services with pricing, overdue task count + top 5, and tasks due in the next 7 days. Use this AFTER list_clients identifies the client of interest, or when the user references a specific client by code. Also answers 'when is ACME's year-end?' / 'is ACME VAT registered?' / 'what are ACME's custom fields?' in a single call. Tolerates partial failures — if one section can't be loaded, the rest is still returned with a note about what's missing.",
2535
2740
  inputSchema: GetClientSummaryInputSchema,
2536
2741
  annotations: {
2537
2742
  readOnlyHint: true,
@@ -2651,6 +2856,17 @@ async function buildServer(config) {
2651
2856
  openWorldHint: true
2652
2857
  }
2653
2858
  }, (args) => handleAddClientNote(api, args));
2859
+ registerWriteTool(server, config.context, "update_client_custom_fields", {
2860
+ title: "Update custom field values on a client",
2861
+ description: "Set or clear custom field values on a client. Accepts a map of field codes to values. Only fields included in the map are updated — omitted fields keep their current values. Pass null to clear a field. Field codes and current values are visible in the Custom Fields section of get_client_summary. Values are validated against the field's data type (Text, Number, Date, Boolean, Select, MultiSelect) before sending — invalid values return a validation error with guidance. Use this when the user says things like 'set the referral source on ACME to Google', 'update the fee review date for Smith & Co', 'clear the VAT scheme field on Greggs'. Multiple fields can be updated in a single call.",
2862
+ inputSchema: SetClientCustomFieldsInputSchema,
2863
+ annotations: {
2864
+ readOnlyHint: false,
2865
+ destructiveHint: false,
2866
+ idempotentHint: true,
2867
+ openWorldHint: true
2868
+ }
2869
+ }, (args) => handleSetClientCustomFields(api, args));
2654
2870
  return server;
2655
2871
  }
2656
2872
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2619",
3
+ "version": "0.1.0-beta.2749",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {