@sodiumhq/mcp-pm 0.1.0-beta.2765 → 0.1.0-beta.2771

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 +5 -0
  2. package/dist/index.js +458 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,6 +65,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
65
65
  **Clients**
66
66
  - **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
67
67
  - **`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
68
+ - **`get_custom_field_details`** — get the full definition of a custom field by code, including allowed options for Select/MultiSelect fields. Use before setting a dropdown field to discover valid values.
68
69
 
69
70
  **Tasks**
70
71
  - **`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?"
@@ -78,6 +79,9 @@ Only enable write mode with an AI client you trust — it hands the client the a
78
79
  - **`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?"
79
80
  - **`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?"
80
81
 
82
+ **Contacts**
83
+ - **`list_contacts`** — search and filter contacts across the practice by name, email, phone, or client. Answers "find John Smith", "who are the contacts for ACME?", "search for jane@example.com".
84
+
81
85
  **Team**
82
86
  - **`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?"
83
87
 
@@ -85,6 +89,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
85
89
  - **`add_task_note`** — capture a note against a specific task. Attributed to your API user, timestamped to now.
86
90
  - **`add_client_note`** — capture a note against a client record. Same shape as `add_task_note` but scoped to the client.
87
91
  - **`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`.
92
+ - **`update_contact`** — update a contact's details: name, email, phone, mobile, date of birth, UTR, NI number, address. Requires the contact code (discoverable via `list_contacts`).
88
93
 
89
94
  More tools land iteratively as the beta progresses.
90
95
 
package/dist/index.js CHANGED
@@ -441,7 +441,7 @@ const createConfig = (override = {}) => ({
441
441
  });
442
442
  //#endregion
443
443
  //#region ../mcp-core/src/generated/client/client.gen.ts
444
- const createClient = (config = {}) => {
444
+ const createClient$1 = (config = {}) => {
445
445
  let _config = mergeConfigs(createConfig(), config);
446
446
  const getConfig = () => ({ ..._config });
447
447
  const setConfig = (config) => {
@@ -620,7 +620,7 @@ const createClient = (config = {}) => {
620
620
  };
621
621
  //#endregion
622
622
  //#region ../mcp-core/src/generated/client.gen.ts
623
- const client = createClient(createConfig());
623
+ const client = createClient$1(createConfig());
624
624
  //#endregion
625
625
  //#region ../mcp-core/src/generated/sdk.gen.ts
626
626
  /**
@@ -706,6 +706,22 @@ const getClientDates = (options) => (options.client ?? client).get({
706
706
  ...options
707
707
  });
708
708
  /**
709
+ * List Notes for Client
710
+ *
711
+ * Lists all Notes for the specified client.
712
+ */
713
+ const listClientNotesForClient = (options) => (options.client ?? client).get({
714
+ security: [{
715
+ name: "x-api-key",
716
+ type: "apiKey"
717
+ }, {
718
+ scheme: "bearer",
719
+ type: "http"
720
+ }],
721
+ url: "/tenants/{tenant}/clients/{client}/clientnote",
722
+ ...options
723
+ });
724
+ /**
709
725
  * Create Note for Client
710
726
  *
711
727
  * Creates a new Note for the specified client.
@@ -761,6 +777,26 @@ const listClients = (options) => (options.client ?? client).get({
761
777
  ...options
762
778
  });
763
779
  /**
780
+ * Create Client
781
+ *
782
+ * Creates a new Client for the specified tenant.
783
+ */
784
+ const createClient = (options) => (options.client ?? client).post({
785
+ security: [{
786
+ name: "x-api-key",
787
+ type: "apiKey"
788
+ }, {
789
+ scheme: "bearer",
790
+ type: "http"
791
+ }],
792
+ url: "/tenants/{tenant}/clients",
793
+ ...options,
794
+ headers: {
795
+ "Content-Type": "application/json",
796
+ ...options.headers
797
+ }
798
+ });
799
+ /**
764
800
  * Get Client
765
801
  *
766
802
  * Gets a Client for the specified tenant.
@@ -777,6 +813,58 @@ const getClient = (options) => (options.client ?? client).get({
777
813
  ...options
778
814
  });
779
815
  /**
816
+ * List Contacts
817
+ *
818
+ * Lists all Contacts for the given tenant.
819
+ */
820
+ const listContacts = (options) => (options.client ?? client).get({
821
+ security: [{
822
+ name: "x-api-key",
823
+ type: "apiKey"
824
+ }, {
825
+ scheme: "bearer",
826
+ type: "http"
827
+ }],
828
+ url: "/tenants/{tenant}/contacts",
829
+ ...options
830
+ });
831
+ /**
832
+ * Get Contact
833
+ *
834
+ * Gets a Contact for the specified tenant.
835
+ */
836
+ const getContact = (options) => (options.client ?? client).get({
837
+ security: [{
838
+ name: "x-api-key",
839
+ type: "apiKey"
840
+ }, {
841
+ scheme: "bearer",
842
+ type: "http"
843
+ }],
844
+ url: "/tenants/{tenant}/contacts/{code}",
845
+ ...options
846
+ });
847
+ /**
848
+ * Update Contact
849
+ *
850
+ * Updates a Contact for the specified tenant.
851
+ */
852
+ const updateContact = (options) => (options.client ?? client).put({
853
+ security: [{
854
+ name: "x-api-key",
855
+ type: "apiKey"
856
+ }, {
857
+ scheme: "bearer",
858
+ type: "http"
859
+ }],
860
+ url: "/tenants/{tenant}/contacts/{code}",
861
+ ...options,
862
+ headers: {
863
+ "Content-Type": "application/json",
864
+ ...options.headers
865
+ }
866
+ });
867
+ /**
780
868
  * List CustomFieldDefinitions
781
869
  *
782
870
  * Lists all CustomFieldDefinitions for the given tenant.
@@ -793,6 +881,22 @@ const listCustomFieldDefinitions = (options) => (options.client ?? client).get({
793
881
  ...options
794
882
  });
795
883
  /**
884
+ * Get CustomFieldDefinition
885
+ *
886
+ * Gets a CustomFieldDefinition for the specified tenant.
887
+ */
888
+ const getCustomFieldDefinition = (options) => (options.client ?? client).get({
889
+ security: [{
890
+ name: "x-api-key",
891
+ type: "apiKey"
892
+ }, {
893
+ scheme: "bearer",
894
+ type: "http"
895
+ }],
896
+ url: "/tenants/{tenant}/custom-fields/{code}",
897
+ ...options
898
+ });
899
+ /**
796
900
  * List Engagements
797
901
  *
798
902
  * Lists all Engagements for the given tenant.
@@ -1369,6 +1473,33 @@ var SodiumApiClient = class {
1369
1473
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to client ${clientCode}`);
1370
1474
  return data;
1371
1475
  }
1476
+ async createClient(body) {
1477
+ const correlationId = randomUUID();
1478
+ const { data, error, response } = await createClient({
1479
+ path: { tenant: this.ctx.tenant },
1480
+ body,
1481
+ headers: { "X-Correlation-Id": correlationId }
1482
+ });
1483
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "create client");
1484
+ return data;
1485
+ }
1486
+ async listClientNotes(clientCode, query = {}) {
1487
+ const correlationId = randomUUID();
1488
+ const { data, error, response } = await listClientNotesForClient({
1489
+ path: {
1490
+ tenant: this.ctx.tenant,
1491
+ client: clientCode
1492
+ },
1493
+ query: {
1494
+ ...query,
1495
+ limit: query.limit ?? 10,
1496
+ offset: query.offset ?? 0
1497
+ },
1498
+ headers: { "X-Correlation-Id": correlationId }
1499
+ });
1500
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list notes for client ${clientCode}`);
1501
+ return data;
1502
+ }
1372
1503
  async listCustomFieldDefinitions(query = {}) {
1373
1504
  const correlationId = randomUUID();
1374
1505
  const { data, error, response } = await listCustomFieldDefinitions({
@@ -1383,6 +1514,18 @@ var SodiumApiClient = class {
1383
1514
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list custom field definitions");
1384
1515
  return data;
1385
1516
  }
1517
+ async getCustomFieldDefinition(code) {
1518
+ const correlationId = randomUUID();
1519
+ const { data, error, response } = await getCustomFieldDefinition({
1520
+ path: {
1521
+ tenant: this.ctx.tenant,
1522
+ code
1523
+ },
1524
+ headers: { "X-Correlation-Id": correlationId }
1525
+ });
1526
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get custom field definition ${code}`);
1527
+ return data;
1528
+ }
1386
1529
  async getClientCustomFieldValues(clientCode) {
1387
1530
  const correlationId = randomUUID();
1388
1531
  const { data, error, response } = await getClientCustomFieldValues({
@@ -1407,6 +1550,45 @@ var SodiumApiClient = class {
1407
1550
  });
1408
1551
  if (error !== void 0) throw this.toError(response, error, correlationId, `set custom field values for client ${clientCode}`);
1409
1552
  }
1553
+ async listContacts(query = {}) {
1554
+ const correlationId = randomUUID();
1555
+ const { data, error, response } = await listContacts({
1556
+ path: { tenant: this.ctx.tenant },
1557
+ query: {
1558
+ ...query,
1559
+ limit: query.limit ?? 10,
1560
+ offset: query.offset ?? 0
1561
+ },
1562
+ headers: { "X-Correlation-Id": correlationId }
1563
+ });
1564
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list contacts");
1565
+ return data;
1566
+ }
1567
+ async getContact(code) {
1568
+ const correlationId = randomUUID();
1569
+ const { data, error, response } = await getContact({
1570
+ path: {
1571
+ tenant: this.ctx.tenant,
1572
+ code
1573
+ },
1574
+ headers: { "X-Correlation-Id": correlationId }
1575
+ });
1576
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get contact ${code}`);
1577
+ return data;
1578
+ }
1579
+ async updateContact(code, body) {
1580
+ const correlationId = randomUUID();
1581
+ const { data, error, response } = await updateContact({
1582
+ path: {
1583
+ tenant: this.ctx.tenant,
1584
+ code
1585
+ },
1586
+ body,
1587
+ headers: { "X-Correlation-Id": correlationId }
1588
+ });
1589
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `update contact ${code}`);
1590
+ return data;
1591
+ }
1410
1592
  toError(response, error, correlationId, operation) {
1411
1593
  const status = response.status;
1412
1594
  let message = `Failed to ${operation} (HTTP ${status})`;
@@ -2075,7 +2257,7 @@ function format(input) {
2075
2257
  return (b.date ?? b.createdDate ?? "").localeCompare(da);
2076
2258
  });
2077
2259
  lines.push("", `--- Notes (${notes.length}) ---`);
2078
- for (const note of sorted.slice(0, 20)) lines.push(formatNote(note));
2260
+ for (const note of sorted.slice(0, 20)) lines.push(formatNote$1(note));
2079
2261
  if (notes.length > 20) lines.push(`... and ${notes.length - 20} more notes`);
2080
2262
  }
2081
2263
  const clients = task.clients ?? [];
@@ -2100,7 +2282,7 @@ function formatStep(step) {
2100
2282
  if (step.blockedReason) bits.push(`blocked: ${step.blockedReason}`);
2101
2283
  return `Step ${num}: ${name}${bits.length > 0 ? ` — ${bits.join(" · ")}` : ""}`;
2102
2284
  }
2103
- function formatNote(note) {
2285
+ function formatNote$1(note) {
2104
2286
  const pinPrefix = (note.pinnedLevel ?? 0) > 0 ? "[pinned] " : "";
2105
2287
  const dateStr = note.date ?? note.createdDate ?? "";
2106
2288
  const author = note.noteFromUser?.name ?? "(unknown)";
@@ -2250,7 +2432,7 @@ const categoryEnum = z.enum([
2250
2432
  "Advisory",
2251
2433
  "SoftwareAndTraining"
2252
2434
  ]);
2253
- const clientTypeEnum = z.enum([
2435
+ const clientTypeEnum$1 = z.enum([
2254
2436
  "PrivateLimitedCompany",
2255
2437
  "PublicLimitedCompany",
2256
2438
  "LimitedLiabilityPartnership",
@@ -2268,7 +2450,7 @@ const sortByEnum$2 = z.enum([
2268
2450
  const ListServicesInputSchema = {
2269
2451
  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."),
2270
2452
  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."),
2271
- 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)."),
2453
+ clientType: clientTypeEnum$1.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)."),
2272
2454
  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."),
2273
2455
  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."),
2274
2456
  sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
@@ -2630,6 +2812,13 @@ async function handleSetClientCustomFields(api, args) {
2630
2812
  });
2631
2813
  const defMap = /* @__PURE__ */ new Map();
2632
2814
  for (const d of definitions.data ?? []) if (d.code) defMap.set(d.code, d);
2815
+ const selectFields = entries.filter(([code, val]) => val !== null && val !== "" && (defMap.get(code)?.dataType === "Select" || defMap.get(code)?.dataType === "MultiSelect"));
2816
+ await Promise.all(selectFields.map(async ([code]) => {
2817
+ try {
2818
+ const full = await api.getCustomFieldDefinition(code);
2819
+ defMap.set(code, full);
2820
+ } catch {}
2821
+ }));
2633
2822
  const errors = [];
2634
2823
  for (const [fieldCode, value] of entries) {
2635
2824
  const def = defMap.get(fieldCode);
@@ -2695,6 +2884,217 @@ function validateFieldValue(def, value) {
2695
2884
  }
2696
2885
  }
2697
2886
  //#endregion
2887
+ //#region ../mcp-core/src/tools/get-custom-field-details.ts
2888
+ const GetCustomFieldDetailsInputSchema = { fieldCode: z.string().min(1, "Field code is required").describe("The custom field definition code. Discoverable via the Custom Fields section of get_client_summary.") };
2889
+ async function handleGetCustomFieldDetails(api, args) {
2890
+ try {
2891
+ const def = await api.getCustomFieldDefinition(args.fieldCode);
2892
+ const lines = [];
2893
+ lines.push(`Custom Field: ${def.label ?? "(no label)"} (${def.code ?? args.fieldCode})`);
2894
+ lines.push(`Data type: ${def.dataType ?? "(unknown)"}`);
2895
+ lines.push(`Entity type: ${def.entityType ?? "(unknown)"}`);
2896
+ lines.push(`Archived: ${def.isArchived ? "yes" : "no"}`);
2897
+ if (def.sortOrder !== void 0) lines.push(`Sort order: ${def.sortOrder}`);
2898
+ if (def.clientTypes && def.clientTypes.length > 0) lines.push(`Applicable client types: ${def.clientTypes.join(", ")}`);
2899
+ else lines.push("Applicable client types: all");
2900
+ if (def.options && def.options.length > 0) {
2901
+ lines.push("", `Allowed options (${def.options.length}):`);
2902
+ for (const opt of def.options) lines.push(`- ${opt}`);
2903
+ } else if (def.dataType === "Select" || def.dataType === "MultiSelect") lines.push("", "Allowed options: (none configured)");
2904
+ return { content: [{
2905
+ type: "text",
2906
+ text: lines.join("\n")
2907
+ }] };
2908
+ } catch (error) {
2909
+ return {
2910
+ content: [{
2911
+ type: "text",
2912
+ text: error instanceof SodiumApiError ? `Error getting custom field definition: ${error.message} (correlation: ${error.correlationId})` : `Error getting custom field definition: ${error instanceof Error ? error.message : String(error)}`
2913
+ }],
2914
+ isError: true
2915
+ };
2916
+ }
2917
+ }
2918
+ //#endregion
2919
+ //#region ../mcp-core/src/tools/create-client.ts
2920
+ const clientTypeEnum = z.enum([
2921
+ "PrivateLimitedCompany",
2922
+ "PublicLimitedCompany",
2923
+ "LimitedLiabilityPartnership",
2924
+ "Partnership",
2925
+ "Individual",
2926
+ "Trust",
2927
+ "Charity",
2928
+ "SoleTrader"
2929
+ ]);
2930
+ const clientStatusEnum = z.enum([
2931
+ "Active",
2932
+ "Inactive",
2933
+ "Prospect",
2934
+ "LostProspect"
2935
+ ]);
2936
+ const CreateClientInputSchema = {
2937
+ name: z.string().min(1, "Client name is required").describe("The name of the client (e.g. 'ACME Ltd', 'John Smith')."),
2938
+ type: clientTypeEnum.describe("The client type. Determines which services and custom fields are applicable. Common types: PrivateLimitedCompany (most UK companies), Individual (sole traders' personal tax), SoleTrader (unincorporated businesses), Partnership, LimitedLiabilityPartnership."),
2939
+ status: clientStatusEnum.optional().describe("Client status (default: Active). Use Prospect for leads not yet onboarded."),
2940
+ email: z.string().nullable().optional().describe("Client email address."),
2941
+ telephone: z.string().nullable().optional().describe("Client telephone number."),
2942
+ internalReference: z.string().nullable().optional().describe("Internal reference number/code for the client."),
2943
+ managerCode: z.string().nullable().optional().describe("Code of the user who will manage this client. Discoverable via list_users or the team roster in the startup context."),
2944
+ partnerCode: z.string().nullable().optional().describe("Code of the partner user for this client."),
2945
+ associateCode: z.string().nullable().optional().describe("Code of the associate user for this client."),
2946
+ teamCode: z.string().nullable().optional().describe("Code of the team to assign to this client.")
2947
+ };
2948
+ async function handleCreateClient(api, args) {
2949
+ try {
2950
+ const created = await api.createClient(args);
2951
+ const lines = [
2952
+ `Created client: ${created.name} (${created.code ?? "(no code)"})`,
2953
+ `Type: ${created.type ?? args.type}`,
2954
+ `Status: ${created.status ?? "Active"}`
2955
+ ];
2956
+ if (created.email) lines.push(`Email: ${created.email}`);
2957
+ if (created.manager) lines.push(`Manager: ${created.manager.name} (${created.manager.code})`);
2958
+ if (created.partner) lines.push(`Partner: ${created.partner.name} (${created.partner.code})`);
2959
+ return { content: [{
2960
+ type: "text",
2961
+ text: lines.join("\n")
2962
+ }] };
2963
+ } catch (error) {
2964
+ return {
2965
+ content: [{
2966
+ type: "text",
2967
+ text: error instanceof SodiumApiError ? `Error creating client: ${error.message} (correlation: ${error.correlationId})` : `Error creating client: ${error instanceof Error ? error.message : String(error)}`
2968
+ }],
2969
+ isError: true
2970
+ };
2971
+ }
2972
+ }
2973
+ //#endregion
2974
+ //#region ../mcp-core/src/tools/list-client-notes.ts
2975
+ const sortFieldEnum$1 = z.enum(["Date", "UpdatedDate"]);
2976
+ const ListClientNotesInputSchema = {
2977
+ clientCode: z.string().min(1, "Client code is required").describe("The client code whose notes to list. Discoverable via list_clients or get_client_summary."),
2978
+ search: z.string().min(3, "Search must be at least 3 characters when provided").optional().describe("Search across note text. Minimum 3 characters."),
2979
+ authorCode: z.string().optional().describe("Filter by the user who wrote the note. Use the current user's code for 'my notes on ACME', or another user's code for 'what has Jane noted about this client?'."),
2980
+ sortBy: sortFieldEnum$1.optional().describe("Sort by Date (note date) or UpdatedDate. Default: Date descending (newest first)."),
2981
+ sortDesc: z.boolean().optional().describe("Sort descending (default: true for newest first)."),
2982
+ limit: z.number().int().min(0).max(50).optional().describe("Max results to return (default: 10, max: 50)."),
2983
+ offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination.")
2984
+ };
2985
+ async function handleListClientNotes(api, args) {
2986
+ try {
2987
+ const { clientCode, ...query } = args;
2988
+ const result = await api.listClientNotes(clientCode, query);
2989
+ const notes = result.data ?? [];
2990
+ const total = result.totalCount ?? notes.length;
2991
+ if (notes.length === 0) return { content: [{
2992
+ type: "text",
2993
+ text: total === 0 ? `No notes found for client ${clientCode}.` : `${total} note(s) found, but none in this page (offset ${result.offset}).`
2994
+ }] };
2995
+ const lines = [`Notes for ${clientCode}: ${notes.length} of ${total}${result.hasMore ? " (more available)" : ""}`, ""];
2996
+ for (const n of notes) lines.push(formatNote(n));
2997
+ return { content: [{
2998
+ type: "text",
2999
+ text: lines.join("\n")
3000
+ }] };
3001
+ } catch (error) {
3002
+ return {
3003
+ content: [{
3004
+ type: "text",
3005
+ text: error instanceof SodiumApiError ? `Error listing client notes: ${error.message} (correlation: ${error.correlationId})` : `Error listing client notes: ${error instanceof Error ? error.message : String(error)}`
3006
+ }],
3007
+ isError: true
3008
+ };
3009
+ }
3010
+ }
3011
+ function formatNote(n) {
3012
+ return `- [${n.code ?? "(no code)"}] ${n.date ? n.date.slice(0, 10) : "(no date)"} by ${n.noteFromUser?.name ?? "(unknown)"}${n.pinnedLevel && n.pinnedLevel > 0 ? " [pinned]" : ""}: ${n.text ? n.text.length > 200 ? n.text.slice(0, 200) + "..." : n.text : "(empty)"}`;
3013
+ }
3014
+ //#endregion
3015
+ //#region ../mcp-core/src/tools/list-contacts.ts
3016
+ const sortFieldEnum = z.enum([
3017
+ "Name",
3018
+ "FirstName",
3019
+ "LastName",
3020
+ "ClientCount"
3021
+ ]);
3022
+ const ListContactsInputSchema = {
3023
+ search: z.string().min(3, "Search must be at least 3 characters when provided").optional().describe("Free-text search across contact code, name, email, and phone. Minimum 3 characters. Use for 'find John', 'who is jane@example.com', 'search for Smith'."),
3024
+ clientCode: z.string().optional().describe("Filter to contacts linked to a specific client. Use when the user asks 'who are the contacts for ACME?' or 'list contacts on client CLI001'."),
3025
+ sortBy: sortFieldEnum.optional().describe("Field to sort results by."),
3026
+ sortDesc: z.boolean().optional().describe("Sort in descending order (default: ascending)."),
3027
+ limit: z.number().int().min(0).max(50).optional().describe("Max results to return (default: 10, max: 50). Use limit=0 for count-only."),
3028
+ offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination.")
3029
+ };
3030
+ async function handleListContacts(api, args) {
3031
+ try {
3032
+ const result = await api.listContacts(args);
3033
+ const contacts = result.data ?? [];
3034
+ const total = result.totalCount ?? contacts.length;
3035
+ if (contacts.length === 0) return { content: [{
3036
+ type: "text",
3037
+ text: total === 0 ? "No contacts found matching the criteria." : `${total} contact(s) found, but none in this page (offset ${result.offset}).`
3038
+ }] };
3039
+ const lines = [`Contacts: ${contacts.length} of ${total}${result.hasMore ? " (more available)" : ""}`, ""];
3040
+ for (const c of contacts) lines.push(formatContact(c));
3041
+ return { content: [{
3042
+ type: "text",
3043
+ text: lines.join("\n")
3044
+ }] };
3045
+ } catch (error) {
3046
+ return {
3047
+ content: [{
3048
+ type: "text",
3049
+ text: error instanceof SodiumApiError ? `Error listing contacts: ${error.message} (correlation: ${error.correlationId})` : `Error listing contacts: ${error instanceof Error ? error.message : String(error)}`
3050
+ }],
3051
+ isError: true
3052
+ };
3053
+ }
3054
+ }
3055
+ function formatContact(c) {
3056
+ const code = c.code ?? "(no code)";
3057
+ return `- ${[c.firstName, c.lastName].filter(Boolean).join(" ") || "(no name)"} (${code})${c.email ? ` <${c.email}>` : ""}${c.phone || c.mobile ? ` | ${c.phone || c.mobile}` : ""}`;
3058
+ }
3059
+ //#endregion
3060
+ //#region ../mcp-core/src/tools/update-contact.ts
3061
+ const UpdateContactInputSchema = {
3062
+ contactCode: z.string().min(1, "Contact code is required").describe("The contact code (identifier). Discoverable via list_contacts or the contacts section of get_client_summary."),
3063
+ lastName: z.string().min(1).describe("The contact's last name (required by the API even if unchanged)."),
3064
+ title: z.string().nullable().optional().describe("Title (e.g. Mr, Mrs, Dr). Pass null to clear."),
3065
+ firstName: z.string().nullable().optional().describe("First name. Pass null to clear."),
3066
+ middleName: z.string().nullable().optional().describe("Middle name. Pass null to clear."),
3067
+ email: z.string().nullable().optional().describe("Email address. Pass null to clear."),
3068
+ phone: z.string().nullable().optional().describe("Landline phone number. Pass null to clear."),
3069
+ mobile: z.string().nullable().optional().describe("Mobile phone number. Pass null to clear."),
3070
+ dateOfBirth: z.string().nullable().optional().describe("Date of birth in ISO 8601 format (e.g. '1985-03-15'). Pass null to clear."),
3071
+ utr: z.string().nullable().optional().describe("Self Assessment UTR. Pass null to clear."),
3072
+ niNumber: z.string().nullable().optional().describe("National Insurance Number. Pass null to clear."),
3073
+ address: z.string().nullable().optional().describe("Contact address (only for Individual client type contacts). Pass null to clear.")
3074
+ };
3075
+ async function handleUpdateContact(api, args) {
3076
+ try {
3077
+ const { contactCode, ...body } = args;
3078
+ const updated = await api.updateContact(contactCode, body);
3079
+ const lines = [`Updated contact: ${[updated.firstName, updated.lastName].filter(Boolean).join(" ") || "(no name)"} (${updated.code ?? contactCode})`];
3080
+ if (updated.email) lines.push(`Email: ${updated.email}`);
3081
+ if (updated.phone) lines.push(`Phone: ${updated.phone}`);
3082
+ if (updated.mobile) lines.push(`Mobile: ${updated.mobile}`);
3083
+ return { content: [{
3084
+ type: "text",
3085
+ text: lines.join("\n")
3086
+ }] };
3087
+ } catch (error) {
3088
+ return {
3089
+ content: [{
3090
+ type: "text",
3091
+ text: error instanceof SodiumApiError ? `Error updating contact: ${error.message} (correlation: ${error.correlationId})` : `Error updating contact: ${error instanceof Error ? error.message : String(error)}`
3092
+ }],
3093
+ isError: true
3094
+ };
3095
+ }
3096
+ }
3097
+ //#endregion
2698
3098
  //#region ../mcp-core/src/tools/whoami.ts
2699
3099
  async function handleWhoami(api) {
2700
3100
  try {
@@ -2830,6 +3230,26 @@ async function buildServer(config) {
2830
3230
  openWorldHint: true
2831
3231
  }
2832
3232
  }, (args) => handleGetClientSummary(api, args));
3233
+ server.registerTool("get_custom_field_details", {
3234
+ title: "Get full details of a custom field definition",
3235
+ description: "Get the full definition of a single custom field by code, including allowed options for Select and MultiSelect fields. The list endpoint and get_client_summary show field codes, labels, and data types but omit the allowed options — use this tool when you need to know the valid values before setting a Select or MultiSelect field via update_client_custom_fields. Also shows applicable client types and archived status.",
3236
+ inputSchema: GetCustomFieldDetailsInputSchema,
3237
+ annotations: {
3238
+ readOnlyHint: true,
3239
+ idempotentHint: true,
3240
+ openWorldHint: true
3241
+ }
3242
+ }, (args) => handleGetCustomFieldDetails(api, args));
3243
+ server.registerTool("list_client_notes", {
3244
+ title: "List / search notes on a client",
3245
+ description: "List notes attached to a client, with optional search and author filter. Answers 'show me the notes on ACME', 'what has Jane noted about this client?', 'search notes for VAT'. Notes are returned newest first by default. Each note shows its code, date, author, pinned status, and text (truncated to 200 chars).",
3246
+ inputSchema: ListClientNotesInputSchema,
3247
+ annotations: {
3248
+ readOnlyHint: true,
3249
+ idempotentHint: true,
3250
+ openWorldHint: true
3251
+ }
3252
+ }, (args) => handleListClientNotes(api, args));
2833
3253
  server.registerTool("list_tasks", {
2834
3254
  title: "List / filter tasks across the practice",
2835
3255
  description: "List tasks with any combination of filters: assigned user(s), client(s), status, overdue flag, preset date range (Today / ThisWeek / Next7Days / CustomDateRange etc), category, team, recurring task template, saved filter, include-projected, include-workflow-steps (Agenda Mode), sort, and pagination. Use for: 'my tasks' (pass current user's code from startup context), 'Jane's overdue tasks' (user + isOverdue), 'tasks for ACME due this week' (client + dateRange=Next7Days + dateBasis=DueDate), 'what is the team working on this month' (dateRange=ThisMonth, no user filter). Returns up to 50 tasks per page. IMPORTANT constraints to avoid API errors and keep queries efficient: (1) Querying NotStarted tasks requires one of — a dateRange, isOverdue=true, or restricting status to non-NotStarted values. (2) Prefer the narrowest date range that answers the question — broad ranges (quarterly/yearly) are expensive; prefer Today / ThisWeek / Next7Days / ThisMonth over larger windows unless explicitly asked. (3) For 'oldest incomplete tasks' prefer status=['InProgress','Blocked'] with sortBy=StartDate (no date range needed), or add isOverdue=true if 'oldest overdue' is meant. (4) For 'how many X?' questions, pass limit=0 to get just the total count without fetching any task data — much cheaper than fetching a full page and counting.",
@@ -2920,6 +3340,16 @@ async function buildServer(config) {
2920
3340
  openWorldHint: true
2921
3341
  }
2922
3342
  }, (args) => handleListUsers(api, args));
3343
+ server.registerTool("list_contacts", {
3344
+ title: "List / search contacts across the practice",
3345
+ description: "Find contacts by free-text search (name, email, phone, code — minimum 3 characters) or filter to contacts linked to a specific client. Answers 'find John Smith', 'who are the contacts for ACME?', 'search for jane@example.com', 'how many contacts do we have?'. Supports pagination and sorting by Name, FirstName, LastName, or ClientCount.",
3346
+ inputSchema: ListContactsInputSchema,
3347
+ annotations: {
3348
+ readOnlyHint: true,
3349
+ idempotentHint: true,
3350
+ openWorldHint: true
3351
+ }
3352
+ }, (args) => handleListContacts(api, args));
2923
3353
  registerWriteTool(server, config.context, "add_task_note", {
2924
3354
  title: "Add a note to a task",
2925
3355
  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.",
@@ -2931,6 +3361,17 @@ async function buildServer(config) {
2931
3361
  openWorldHint: true
2932
3362
  }
2933
3363
  }, (args) => handleAddTaskNote(api, args));
3364
+ registerWriteTool(server, config.context, "create_client", {
3365
+ title: "Create a new client",
3366
+ description: "Create a new client record in the practice. Requires name and type (PrivateLimitedCompany, Individual, SoleTrader, etc.). Optionally set status (Active/Prospect/Inactive/LostProspect), email, telephone, internal reference, and assign a manager/partner/associate/team. The client code is auto-generated. Use when the user says 'add a new client', 'create client ACME Ltd', 'onboard a new prospect'.",
3367
+ inputSchema: CreateClientInputSchema,
3368
+ annotations: {
3369
+ readOnlyHint: false,
3370
+ destructiveHint: false,
3371
+ idempotentHint: false,
3372
+ openWorldHint: true
3373
+ }
3374
+ }, (args) => handleCreateClient(api, args));
2934
3375
  registerWriteTool(server, config.context, "add_client_note", {
2935
3376
  title: "Add a note to a client",
2936
3377
  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.",
@@ -2953,6 +3394,17 @@ async function buildServer(config) {
2953
3394
  openWorldHint: true
2954
3395
  }
2955
3396
  }, (args) => handleSetClientCustomFields(api, args));
3397
+ registerWriteTool(server, config.context, "update_contact", {
3398
+ title: "Update a contact's details",
3399
+ description: "Update fields on an existing contact: name, email, phone, mobile, date of birth, UTR, NI number, address. The contact's lastName is always required (even if unchanged). Only include fields you want to change — but note the API replaces the entire contact record, so omitted optional fields may be cleared. To be safe, fetch the contact via list_contacts first, then pass all current values plus your changes. Use when the user says things like 'update John's email to ...', 'change the phone number for contact CON-001', 'set Jane's date of birth to ...'.",
3400
+ inputSchema: UpdateContactInputSchema,
3401
+ annotations: {
3402
+ readOnlyHint: false,
3403
+ destructiveHint: false,
3404
+ idempotentHint: true,
3405
+ openWorldHint: true
3406
+ }
3407
+ }, (args) => handleUpdateContact(api, args));
2956
3408
  return server;
2957
3409
  }
2958
3410
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2765",
3
+ "version": "0.1.0-beta.2771",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {