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

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 +272 -0
  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
@@ -777,6 +777,58 @@ const getClient = (options) => (options.client ?? client).get({
777
777
  ...options
778
778
  });
779
779
  /**
780
+ * List Contacts
781
+ *
782
+ * Lists all Contacts for the given tenant.
783
+ */
784
+ const listContacts = (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}/contacts",
793
+ ...options
794
+ });
795
+ /**
796
+ * Get Contact
797
+ *
798
+ * Gets a Contact for the specified tenant.
799
+ */
800
+ const getContact = (options) => (options.client ?? client).get({
801
+ security: [{
802
+ name: "x-api-key",
803
+ type: "apiKey"
804
+ }, {
805
+ scheme: "bearer",
806
+ type: "http"
807
+ }],
808
+ url: "/tenants/{tenant}/contacts/{code}",
809
+ ...options
810
+ });
811
+ /**
812
+ * Update Contact
813
+ *
814
+ * Updates a Contact for the specified tenant.
815
+ */
816
+ const updateContact = (options) => (options.client ?? client).put({
817
+ security: [{
818
+ name: "x-api-key",
819
+ type: "apiKey"
820
+ }, {
821
+ scheme: "bearer",
822
+ type: "http"
823
+ }],
824
+ url: "/tenants/{tenant}/contacts/{code}",
825
+ ...options,
826
+ headers: {
827
+ "Content-Type": "application/json",
828
+ ...options.headers
829
+ }
830
+ });
831
+ /**
780
832
  * List CustomFieldDefinitions
781
833
  *
782
834
  * Lists all CustomFieldDefinitions for the given tenant.
@@ -793,6 +845,22 @@ const listCustomFieldDefinitions = (options) => (options.client ?? client).get({
793
845
  ...options
794
846
  });
795
847
  /**
848
+ * Get CustomFieldDefinition
849
+ *
850
+ * Gets a CustomFieldDefinition for the specified tenant.
851
+ */
852
+ const getCustomFieldDefinition = (options) => (options.client ?? client).get({
853
+ security: [{
854
+ name: "x-api-key",
855
+ type: "apiKey"
856
+ }, {
857
+ scheme: "bearer",
858
+ type: "http"
859
+ }],
860
+ url: "/tenants/{tenant}/custom-fields/{code}",
861
+ ...options
862
+ });
863
+ /**
796
864
  * List Engagements
797
865
  *
798
866
  * Lists all Engagements for the given tenant.
@@ -1383,6 +1451,18 @@ var SodiumApiClient = class {
1383
1451
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list custom field definitions");
1384
1452
  return data;
1385
1453
  }
1454
+ async getCustomFieldDefinition(code) {
1455
+ const correlationId = randomUUID();
1456
+ const { data, error, response } = await getCustomFieldDefinition({
1457
+ path: {
1458
+ tenant: this.ctx.tenant,
1459
+ code
1460
+ },
1461
+ headers: { "X-Correlation-Id": correlationId }
1462
+ });
1463
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get custom field definition ${code}`);
1464
+ return data;
1465
+ }
1386
1466
  async getClientCustomFieldValues(clientCode) {
1387
1467
  const correlationId = randomUUID();
1388
1468
  const { data, error, response } = await getClientCustomFieldValues({
@@ -1407,6 +1487,45 @@ var SodiumApiClient = class {
1407
1487
  });
1408
1488
  if (error !== void 0) throw this.toError(response, error, correlationId, `set custom field values for client ${clientCode}`);
1409
1489
  }
1490
+ async listContacts(query = {}) {
1491
+ const correlationId = randomUUID();
1492
+ const { data, error, response } = await listContacts({
1493
+ path: { tenant: this.ctx.tenant },
1494
+ query: {
1495
+ ...query,
1496
+ limit: query.limit ?? 10,
1497
+ offset: query.offset ?? 0
1498
+ },
1499
+ headers: { "X-Correlation-Id": correlationId }
1500
+ });
1501
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list contacts");
1502
+ return data;
1503
+ }
1504
+ async getContact(code) {
1505
+ const correlationId = randomUUID();
1506
+ const { data, error, response } = await getContact({
1507
+ path: {
1508
+ tenant: this.ctx.tenant,
1509
+ code
1510
+ },
1511
+ headers: { "X-Correlation-Id": correlationId }
1512
+ });
1513
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get contact ${code}`);
1514
+ return data;
1515
+ }
1516
+ async updateContact(code, body) {
1517
+ const correlationId = randomUUID();
1518
+ const { data, error, response } = await updateContact({
1519
+ path: {
1520
+ tenant: this.ctx.tenant,
1521
+ code
1522
+ },
1523
+ body,
1524
+ headers: { "X-Correlation-Id": correlationId }
1525
+ });
1526
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `update contact ${code}`);
1527
+ return data;
1528
+ }
1410
1529
  toError(response, error, correlationId, operation) {
1411
1530
  const status = response.status;
1412
1531
  let message = `Failed to ${operation} (HTTP ${status})`;
@@ -2630,6 +2749,13 @@ async function handleSetClientCustomFields(api, args) {
2630
2749
  });
2631
2750
  const defMap = /* @__PURE__ */ new Map();
2632
2751
  for (const d of definitions.data ?? []) if (d.code) defMap.set(d.code, d);
2752
+ const selectFields = entries.filter(([code, val]) => val !== null && val !== "" && (defMap.get(code)?.dataType === "Select" || defMap.get(code)?.dataType === "MultiSelect"));
2753
+ await Promise.all(selectFields.map(async ([code]) => {
2754
+ try {
2755
+ const full = await api.getCustomFieldDefinition(code);
2756
+ defMap.set(code, full);
2757
+ } catch {}
2758
+ }));
2633
2759
  const errors = [];
2634
2760
  for (const [fieldCode, value] of entries) {
2635
2761
  const def = defMap.get(fieldCode);
@@ -2695,6 +2821,121 @@ function validateFieldValue(def, value) {
2695
2821
  }
2696
2822
  }
2697
2823
  //#endregion
2824
+ //#region ../mcp-core/src/tools/get-custom-field-details.ts
2825
+ 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.") };
2826
+ async function handleGetCustomFieldDetails(api, args) {
2827
+ try {
2828
+ const def = await api.getCustomFieldDefinition(args.fieldCode);
2829
+ const lines = [];
2830
+ lines.push(`Custom Field: ${def.label ?? "(no label)"} (${def.code ?? args.fieldCode})`);
2831
+ lines.push(`Data type: ${def.dataType ?? "(unknown)"}`);
2832
+ lines.push(`Entity type: ${def.entityType ?? "(unknown)"}`);
2833
+ lines.push(`Archived: ${def.isArchived ? "yes" : "no"}`);
2834
+ if (def.sortOrder !== void 0) lines.push(`Sort order: ${def.sortOrder}`);
2835
+ if (def.clientTypes && def.clientTypes.length > 0) lines.push(`Applicable client types: ${def.clientTypes.join(", ")}`);
2836
+ else lines.push("Applicable client types: all");
2837
+ if (def.options && def.options.length > 0) {
2838
+ lines.push("", `Allowed options (${def.options.length}):`);
2839
+ for (const opt of def.options) lines.push(`- ${opt}`);
2840
+ } else if (def.dataType === "Select" || def.dataType === "MultiSelect") lines.push("", "Allowed options: (none configured)");
2841
+ return { content: [{
2842
+ type: "text",
2843
+ text: lines.join("\n")
2844
+ }] };
2845
+ } catch (error) {
2846
+ return {
2847
+ content: [{
2848
+ type: "text",
2849
+ 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)}`
2850
+ }],
2851
+ isError: true
2852
+ };
2853
+ }
2854
+ }
2855
+ //#endregion
2856
+ //#region ../mcp-core/src/tools/list-contacts.ts
2857
+ const sortFieldEnum = z.enum([
2858
+ "Name",
2859
+ "FirstName",
2860
+ "LastName",
2861
+ "ClientCount"
2862
+ ]);
2863
+ const ListContactsInputSchema = {
2864
+ 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'."),
2865
+ 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'."),
2866
+ sortBy: sortFieldEnum.optional().describe("Field to sort results by."),
2867
+ sortDesc: z.boolean().optional().describe("Sort in descending order (default: ascending)."),
2868
+ limit: z.number().int().min(0).max(50).optional().describe("Max results to return (default: 10, max: 50). Use limit=0 for count-only."),
2869
+ offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination.")
2870
+ };
2871
+ async function handleListContacts(api, args) {
2872
+ try {
2873
+ const result = await api.listContacts(args);
2874
+ const contacts = result.data ?? [];
2875
+ const total = result.totalCount ?? contacts.length;
2876
+ if (contacts.length === 0) return { content: [{
2877
+ type: "text",
2878
+ text: total === 0 ? "No contacts found matching the criteria." : `${total} contact(s) found, but none in this page (offset ${result.offset}).`
2879
+ }] };
2880
+ const lines = [`Contacts: ${contacts.length} of ${total}${result.hasMore ? " (more available)" : ""}`, ""];
2881
+ for (const c of contacts) lines.push(formatContact(c));
2882
+ return { content: [{
2883
+ type: "text",
2884
+ text: lines.join("\n")
2885
+ }] };
2886
+ } catch (error) {
2887
+ return {
2888
+ content: [{
2889
+ type: "text",
2890
+ text: error instanceof SodiumApiError ? `Error listing contacts: ${error.message} (correlation: ${error.correlationId})` : `Error listing contacts: ${error instanceof Error ? error.message : String(error)}`
2891
+ }],
2892
+ isError: true
2893
+ };
2894
+ }
2895
+ }
2896
+ function formatContact(c) {
2897
+ const code = c.code ?? "(no code)";
2898
+ return `- ${[c.firstName, c.lastName].filter(Boolean).join(" ") || "(no name)"} (${code})${c.email ? ` <${c.email}>` : ""}${c.phone || c.mobile ? ` | ${c.phone || c.mobile}` : ""}`;
2899
+ }
2900
+ //#endregion
2901
+ //#region ../mcp-core/src/tools/update-contact.ts
2902
+ const UpdateContactInputSchema = {
2903
+ 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."),
2904
+ lastName: z.string().min(1).describe("The contact's last name (required by the API even if unchanged)."),
2905
+ title: z.string().nullable().optional().describe("Title (e.g. Mr, Mrs, Dr). Pass null to clear."),
2906
+ firstName: z.string().nullable().optional().describe("First name. Pass null to clear."),
2907
+ middleName: z.string().nullable().optional().describe("Middle name. Pass null to clear."),
2908
+ email: z.string().nullable().optional().describe("Email address. Pass null to clear."),
2909
+ phone: z.string().nullable().optional().describe("Landline phone number. Pass null to clear."),
2910
+ mobile: z.string().nullable().optional().describe("Mobile phone number. Pass null to clear."),
2911
+ dateOfBirth: z.string().nullable().optional().describe("Date of birth in ISO 8601 format (e.g. '1985-03-15'). Pass null to clear."),
2912
+ utr: z.string().nullable().optional().describe("Self Assessment UTR. Pass null to clear."),
2913
+ niNumber: z.string().nullable().optional().describe("National Insurance Number. Pass null to clear."),
2914
+ address: z.string().nullable().optional().describe("Contact address (only for Individual client type contacts). Pass null to clear.")
2915
+ };
2916
+ async function handleUpdateContact(api, args) {
2917
+ try {
2918
+ const { contactCode, ...body } = args;
2919
+ const updated = await api.updateContact(contactCode, body);
2920
+ const lines = [`Updated contact: ${[updated.firstName, updated.lastName].filter(Boolean).join(" ") || "(no name)"} (${updated.code ?? contactCode})`];
2921
+ if (updated.email) lines.push(`Email: ${updated.email}`);
2922
+ if (updated.phone) lines.push(`Phone: ${updated.phone}`);
2923
+ if (updated.mobile) lines.push(`Mobile: ${updated.mobile}`);
2924
+ return { content: [{
2925
+ type: "text",
2926
+ text: lines.join("\n")
2927
+ }] };
2928
+ } catch (error) {
2929
+ return {
2930
+ content: [{
2931
+ type: "text",
2932
+ text: error instanceof SodiumApiError ? `Error updating contact: ${error.message} (correlation: ${error.correlationId})` : `Error updating contact: ${error instanceof Error ? error.message : String(error)}`
2933
+ }],
2934
+ isError: true
2935
+ };
2936
+ }
2937
+ }
2938
+ //#endregion
2698
2939
  //#region ../mcp-core/src/tools/whoami.ts
2699
2940
  async function handleWhoami(api) {
2700
2941
  try {
@@ -2830,6 +3071,16 @@ async function buildServer(config) {
2830
3071
  openWorldHint: true
2831
3072
  }
2832
3073
  }, (args) => handleGetClientSummary(api, args));
3074
+ server.registerTool("get_custom_field_details", {
3075
+ title: "Get full details of a custom field definition",
3076
+ 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.",
3077
+ inputSchema: GetCustomFieldDetailsInputSchema,
3078
+ annotations: {
3079
+ readOnlyHint: true,
3080
+ idempotentHint: true,
3081
+ openWorldHint: true
3082
+ }
3083
+ }, (args) => handleGetCustomFieldDetails(api, args));
2833
3084
  server.registerTool("list_tasks", {
2834
3085
  title: "List / filter tasks across the practice",
2835
3086
  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 +3171,16 @@ async function buildServer(config) {
2920
3171
  openWorldHint: true
2921
3172
  }
2922
3173
  }, (args) => handleListUsers(api, args));
3174
+ server.registerTool("list_contacts", {
3175
+ title: "List / search contacts across the practice",
3176
+ 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.",
3177
+ inputSchema: ListContactsInputSchema,
3178
+ annotations: {
3179
+ readOnlyHint: true,
3180
+ idempotentHint: true,
3181
+ openWorldHint: true
3182
+ }
3183
+ }, (args) => handleListContacts(api, args));
2923
3184
  registerWriteTool(server, config.context, "add_task_note", {
2924
3185
  title: "Add a note to a task",
2925
3186
  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.",
@@ -2953,6 +3214,17 @@ async function buildServer(config) {
2953
3214
  openWorldHint: true
2954
3215
  }
2955
3216
  }, (args) => handleSetClientCustomFields(api, args));
3217
+ registerWriteTool(server, config.context, "update_contact", {
3218
+ title: "Update a contact's details",
3219
+ 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 ...'.",
3220
+ inputSchema: UpdateContactInputSchema,
3221
+ annotations: {
3222
+ readOnlyHint: false,
3223
+ destructiveHint: false,
3224
+ idempotentHint: true,
3225
+ openWorldHint: true
3226
+ }
3227
+ }, (args) => handleUpdateContact(api, args));
2956
3228
  return server;
2957
3229
  }
2958
3230
  //#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.2767",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {