@sodiumhq/mcp-pm 0.1.0-beta.2589 → 0.1.0-beta.2592

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 (2) hide show
  1. package/dist/index.js +457 -29
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -705,6 +705,60 @@ const getPracticeDetails = (options) => (options.client ?? client).get({
705
705
  ...options
706
706
  });
707
707
  /**
708
+ * List Notes for Task
709
+ *
710
+ * Lists notes for the specified task. By default only returns task-level notes. Set includeStepNotes=true to also include notes attached to workflow steps.
711
+ */
712
+ const listTaskItemNotes = (options) => (options.client ?? client).get({
713
+ security: [{
714
+ name: "x-api-key",
715
+ type: "apiKey"
716
+ }, {
717
+ scheme: "bearer",
718
+ type: "http"
719
+ }],
720
+ url: "/tenants/{tenant}/tasks/{taskCode}/taskitemnote",
721
+ ...options
722
+ });
723
+ /**
724
+ * Get Task Workflow Groups
725
+ *
726
+ * Retrieves comprehensive workflow progress information for a TaskItem.
727
+ *
728
+ * Returns detailed progress data including:
729
+ * - Overall completion statistics (total steps, completed steps, percentage)
730
+ * - All workflow steps with current status and assignments
731
+ * - Available steps that can be worked on next
732
+ * - Steps grouped by workflow groups for organized display
733
+ *
734
+ * Use this endpoint to:
735
+ * - Display workflow progress dashboards
736
+ * - Show step-by-step task execution status
737
+ * - Identify bottlenecks and available work
738
+ * - Track overall workflow completion
739
+ *
740
+ * Example Response:
741
+ * {
742
+ * "totalSteps": 8,
743
+ * "completedSteps": 3,
744
+ * "progressPercentage": 37.5,
745
+ * "allSteps": [...],
746
+ * "availableSteps": [...],
747
+ * "groupedSteps": [...]
748
+ * }
749
+ */
750
+ const getTaskWorkflowGroups = (options) => (options.client ?? client).get({
751
+ security: [{
752
+ name: "x-api-key",
753
+ type: "apiKey"
754
+ }, {
755
+ scheme: "bearer",
756
+ type: "http"
757
+ }],
758
+ url: "/tenants/{tenant}/tasks/{taskCode}/workflow/groups",
759
+ ...options
760
+ });
761
+ /**
708
762
  * List TaskItems
709
763
  *
710
764
  * Lists TaskItems for the given tenant.
@@ -744,6 +798,22 @@ const listTaskItems = (options) => (options.client ?? client).get({
744
798
  ...options
745
799
  });
746
800
  /**
801
+ * Get TaskItem
802
+ *
803
+ * Gets a TaskItem for the specified tenant.
804
+ */
805
+ const getTaskItem = (options) => (options.client ?? client).get({
806
+ security: [{
807
+ name: "x-api-key",
808
+ type: "apiKey"
809
+ }, {
810
+ scheme: "bearer",
811
+ type: "http"
812
+ }],
813
+ url: "/tenants/{tenant}/tasks/{code}",
814
+ ...options
815
+ });
816
+ /**
747
817
  * Get Tenant
748
818
  *
749
819
  * Retrieves the details of a tenant using its Code identifier.
@@ -775,6 +845,22 @@ const getCurrentUser = (options) => (options?.client ?? client).get({
775
845
  url: "/users/me",
776
846
  ...options
777
847
  });
848
+ /**
849
+ * List TenantUsers
850
+ *
851
+ * Lists all TenantUsers for the given tenant.
852
+ */
853
+ const listTenantUsers = (options) => (options.client ?? client).get({
854
+ security: [{
855
+ name: "x-api-key",
856
+ type: "apiKey"
857
+ }, {
858
+ scheme: "bearer",
859
+ type: "http"
860
+ }],
861
+ url: "/tenants/{tenant}/users",
862
+ ...options
863
+ });
778
864
  //#endregion
779
865
  //#region ../mcp-core/src/http/client.ts
780
866
  var SodiumApiError = class extends Error {
@@ -875,6 +961,20 @@ var SodiumApiClient = class {
875
961
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list services for client ${clientCode}`);
876
962
  return data;
877
963
  }
964
+ async listUsers(query = {}) {
965
+ const correlationId = randomUUID();
966
+ const { data, error, response } = await listTenantUsers({
967
+ path: { tenant: this.ctx.tenant },
968
+ query: {
969
+ ...query,
970
+ limit: query.limit ?? 10,
971
+ offset: query.offset ?? 0
972
+ },
973
+ headers: { "X-Correlation-Id": correlationId }
974
+ });
975
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list users");
976
+ return data;
977
+ }
878
978
  async listTasks(query = {}) {
879
979
  const correlationId = randomUUID();
880
980
  const { data, error, response } = await listTaskItems({
@@ -889,6 +989,47 @@ var SodiumApiClient = class {
889
989
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list tasks");
890
990
  return data;
891
991
  }
992
+ async getTask(code) {
993
+ const correlationId = randomUUID();
994
+ const { data, error, response } = await getTaskItem({
995
+ path: {
996
+ tenant: this.ctx.tenant,
997
+ code
998
+ },
999
+ headers: { "X-Correlation-Id": correlationId }
1000
+ });
1001
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get task ${code}`);
1002
+ return data;
1003
+ }
1004
+ async listTaskNotes(taskCode, options) {
1005
+ const correlationId = randomUUID();
1006
+ const { data, error, response } = await listTaskItemNotes({
1007
+ path: {
1008
+ tenant: this.ctx.tenant,
1009
+ taskCode
1010
+ },
1011
+ query: {
1012
+ limit: options?.limit ?? 50,
1013
+ offset: options?.offset ?? 0,
1014
+ includeStepNotes: options?.includeStepNotes ?? false
1015
+ },
1016
+ headers: { "X-Correlation-Id": correlationId }
1017
+ });
1018
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list notes for task ${taskCode}`);
1019
+ return data;
1020
+ }
1021
+ async getTaskWorkflowGroups(taskCode) {
1022
+ const correlationId = randomUUID();
1023
+ const { data, error, response } = await getTaskWorkflowGroups({
1024
+ path: {
1025
+ tenant: this.ctx.tenant,
1026
+ taskCode
1027
+ },
1028
+ headers: { "X-Correlation-Id": correlationId }
1029
+ });
1030
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get workflow groups for task ${taskCode}`);
1031
+ return data;
1032
+ }
892
1033
  toError(response, error, correlationId, operation) {
893
1034
  const status = response.status;
894
1035
  let message = `Failed to ${operation} (HTTP ${status})`;
@@ -899,11 +1040,17 @@ var SodiumApiClient = class {
899
1040
  };
900
1041
  //#endregion
901
1042
  //#region ../mcp-core/src/context/instructions.ts
1043
+ const ROSTER_CAP = 20;
902
1044
  async function buildInstructions(api) {
903
- const [user, tenant, practice] = await Promise.allSettled([
1045
+ const [user, tenant, practice, team] = await Promise.allSettled([
904
1046
  api.getCurrentUser(),
905
1047
  api.getTenantDetails(),
906
- api.getPracticeDetails()
1048
+ api.getPracticeDetails(),
1049
+ api.listUsers({
1050
+ status: "Active",
1051
+ limit: ROSTER_CAP,
1052
+ sortBy: "LastName"
1053
+ })
907
1054
  ]);
908
1055
  const now = /* @__PURE__ */ new Date();
909
1056
  const lines = [
@@ -918,12 +1065,25 @@ async function buildInstructions(api) {
918
1065
  }
919
1066
  if (tenant.status === "fulfilled") lines.push(`Tenant: ${tenant.value.name} (${tenant.value.code})`);
920
1067
  if (practice.status === "fulfilled") lines.push(`Practice: ${practice.value.name}`);
921
- lines.push("", "When interpreting relative dates (today, this week, next month), use the date above.", "All codes (client, task, engagement) are string identifiers, not numeric IDs.");
1068
+ if (team.status === "fulfilled") {
1069
+ const members = team.value.data ?? [];
1070
+ const total = team.value.totalCount ?? members.length;
1071
+ if (members.length > 0) {
1072
+ lines.push("", `Team members (${members.length}${total > members.length ? ` of ${total}` : ""} active):`);
1073
+ for (const m of members) {
1074
+ const code = m.code ?? "(no code)";
1075
+ const display = m.displayName ?? [m.firstName, m.lastName].filter(Boolean).join(" ") ?? m.email ?? "(no name)";
1076
+ lines.push(`- ${display} (${code})`);
1077
+ }
1078
+ if (total > members.length) lines.push(`... and ${total - members.length} more. Use list_users to see the rest or filter by role / status.`);
1079
+ }
1080
+ }
1081
+ lines.push("", "When interpreting relative dates (today, this week, next month), use the date above.", "When the user names a team member (e.g. 'Jane's tasks'), resolve to their code from the team roster above before calling other tools.", "All codes (client, task, engagement, user) are string identifiers, not numeric IDs.");
922
1082
  return lines.join("\n");
923
1083
  }
924
1084
  //#endregion
925
1085
  //#region ../mcp-core/src/tools/get-practice-details.ts
926
- function format$1(tenant, practice) {
1086
+ function format$2(tenant, practice) {
927
1087
  const lines = [];
928
1088
  lines.push(`Practice: ${practice.name}`);
929
1089
  lines.push(`Tenant: ${tenant.name} (${tenant.code})`);
@@ -957,7 +1117,7 @@ async function handleGetPracticeDetails(api) {
957
1117
  const [tenant, practice] = await Promise.all([api.getTenantDetails(), api.getPracticeDetails()]);
958
1118
  return { content: [{
959
1119
  type: "text",
960
- text: format$1(tenant, practice)
1120
+ text: format$2(tenant, practice)
961
1121
  }] };
962
1122
  } catch (error) {
963
1123
  return {
@@ -971,7 +1131,7 @@ async function handleGetPracticeDetails(api) {
971
1131
  }
972
1132
  //#endregion
973
1133
  //#region ../mcp-core/src/tools/list-clients.ts
974
- const statusEnum$1 = z.enum([
1134
+ const statusEnum$2 = z.enum([
975
1135
  "Active",
976
1136
  "Inactive",
977
1137
  "Prospect",
@@ -987,19 +1147,19 @@ const typeEnum = z.enum([
987
1147
  "Charity",
988
1148
  "SoleTrader"
989
1149
  ]);
990
- const sortByEnum$1 = z.enum(["Name", "InternalReference"]);
1150
+ const sortByEnum$2 = z.enum(["Name", "InternalReference"]);
991
1151
  const ListClientsInputSchema = {
992
1152
  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."),
993
- status: z.array(statusEnum$1).optional().describe("Filter by client status. Defaults to all statuses if omitted. Example: ['Active'] for active clients only."),
1153
+ status: z.array(statusEnum$2).optional().describe("Filter by client status. Defaults to all statuses if omitted. Example: ['Active'] for active clients only."),
994
1154
  type: z.array(typeEnum).optional().describe("Filter by organisation type. Use ['PrivateLimitedCompany', 'PublicLimitedCompany'] for 'limited companies'. Use ['LimitedLiabilityPartnership'] for LLPs. Defaults to all types if omitted."),
995
1155
  managerCode: z.array(z.string()).optional().describe("Filter by assigned manager user codes."),
996
1156
  partnerCode: z.array(z.string()).optional().describe("Filter by assigned partner user codes."),
997
1157
  associateCode: z.array(z.string()).optional().describe("Filter by assigned associate user codes."),
998
1158
  serviceCode: z.array(z.string()).optional().describe("Filter by billable service codes that clients have assigned."),
999
1159
  savedFilter: z.string().optional().describe("Code of a user-saved filter to apply. Other filter parameters override fields from the saved filter."),
1000
- sortBy: sortByEnum$1.optional().describe("Field to sort by. Defaults to Name."),
1160
+ sortBy: sortByEnum$2.optional().describe("Field to sort by. Defaults to Name."),
1001
1161
  sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
1002
- limit: z.number().int().min(1).max(50).optional().describe("Maximum number of clients to return per page. Default 10, max 50."),
1162
+ 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."),
1003
1163
  offset: z.number().int().min(0).optional().describe("Number of records to skip for pagination. Default 0.")
1004
1164
  };
1005
1165
  function formatClient(c) {
@@ -1013,15 +1173,22 @@ async function handleListClients(api, args) {
1013
1173
  const query = args;
1014
1174
  const result = await api.listClients(query);
1015
1175
  const items = result.data ?? [];
1176
+ const total = result.totalCount ?? items.length;
1177
+ if (args.limit === 0) {
1178
+ const desc = describeFilters$2(args);
1179
+ return { content: [{
1180
+ type: "text",
1181
+ text: desc ? `Total: ${total} client${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} client${total === 1 ? "" : "s"}.`
1182
+ }] };
1183
+ }
1016
1184
  if (items.length === 0) {
1017
- const desc = describeFilters$1(args);
1185
+ const desc = describeFilters$2(args);
1018
1186
  return { content: [{
1019
1187
  type: "text",
1020
1188
  text: desc ? `No clients match ${desc}.` : "No clients found."
1021
1189
  }] };
1022
1190
  }
1023
- const total = result.totalCount ?? items.length;
1024
- const desc = describeFilters$1(args);
1191
+ const desc = describeFilters$2(args);
1025
1192
  const lines = [
1026
1193
  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"}:`,
1027
1194
  "",
@@ -1045,7 +1212,7 @@ async function handleListClients(api, args) {
1045
1212
  };
1046
1213
  }
1047
1214
  }
1048
- function describeFilters$1(args) {
1215
+ function describeFilters$2(args) {
1049
1216
  const parts = [];
1050
1217
  if (args.search) parts.push(`search "${args.search}"`);
1051
1218
  if (args.status?.length) parts.push(`status ${args.status.join("/")}`);
@@ -1089,7 +1256,7 @@ async function handleGetClientSummary(api, { code }) {
1089
1256
  }
1090
1257
  return { content: [{
1091
1258
  type: "text",
1092
- text: format({
1259
+ text: format$1({
1093
1260
  client: clientResult.value,
1094
1261
  contacts: extract(contactsResult),
1095
1262
  services: extract(servicesResult),
@@ -1108,7 +1275,7 @@ function extract(result) {
1108
1275
  if (result.status !== "fulfilled") return [];
1109
1276
  return result.value.data ?? [];
1110
1277
  }
1111
- function format(input) {
1278
+ function format$1(input) {
1112
1279
  const { client, contacts, services, overdueTasks, upcomingTasks, gaps } = input;
1113
1280
  const lines = [];
1114
1281
  const name = client.name ?? "(no name)";
@@ -1158,11 +1325,150 @@ function format(input) {
1158
1325
  }
1159
1326
  function formatTask$1(t) {
1160
1327
  const taskCode = t.code ?? "(no code)";
1161
- return `${t.name ?? "(unnamed)"} (${taskCode})${t.dueDate ? ` — due ${t.dueDate}` : ""}${t.assignedUser?.name ? ` — ${t.assignedUser.name}` : ""}`;
1328
+ return `${t.name ?? "(unnamed)"} (${taskCode})${t.dueDate ? ` — due ${t.dueDate}` : ""}${t.assignedUser?.name ? ` — ${t.assignedUser.name}` : ""}${t.workflow ? ` — workflow ${t.workflowStepsComplete ?? 0}/${t.workflowSteps ?? 0}` : ""}`;
1329
+ }
1330
+ //#endregion
1331
+ //#region ../mcp-core/src/tools/get-task-context.ts
1332
+ 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.") };
1333
+ async function handleGetTaskContext(api, { code }) {
1334
+ const [taskResult, notesResult, workflowResult] = await Promise.allSettled([
1335
+ api.getTask(code),
1336
+ api.listTaskNotes(code, {
1337
+ includeStepNotes: false,
1338
+ limit: 50
1339
+ }),
1340
+ api.getTaskWorkflowGroups(code)
1341
+ ]);
1342
+ if (taskResult.status === "rejected") {
1343
+ const err = taskResult.reason;
1344
+ return {
1345
+ content: [{
1346
+ type: "text",
1347
+ text: err instanceof SodiumApiError ? `Error getting task: ${err.message} (correlation: ${err.correlationId})` : `Error getting task: ${err instanceof Error ? err.message : String(err)}`
1348
+ }],
1349
+ isError: true
1350
+ };
1351
+ }
1352
+ const task = taskResult.value;
1353
+ const notes = notesResult.status === "fulfilled" ? notesResult.value.data ?? [] : [];
1354
+ const workflowGroups = workflowResult.status === "fulfilled" ? workflowResult.value : [];
1355
+ const gaps = [];
1356
+ if (notesResult.status === "rejected") gaps.push("notes");
1357
+ if (workflowResult.status === "rejected" && task.workflow) gaps.push("workflow steps");
1358
+ return { content: [{
1359
+ type: "text",
1360
+ text: format({
1361
+ task,
1362
+ notes,
1363
+ workflowGroups,
1364
+ gaps
1365
+ })
1366
+ }] };
1367
+ }
1368
+ function format(input) {
1369
+ const { task, notes, workflowGroups, gaps } = input;
1370
+ const lines = [];
1371
+ const name = task.name ?? "(no name)";
1372
+ const code = task.code ?? "(no code)";
1373
+ lines.push(`Task: ${name} (${code})`);
1374
+ const statusBits = [];
1375
+ if (task.status) statusBits.push(task.status);
1376
+ if (task.isOverdue) statusBits.push("OVERDUE");
1377
+ if (task.isProjected) statusBits.push("projected");
1378
+ if (statusBits.length > 0) lines.push(`Status: ${statusBits.join(" · ")}`);
1379
+ const dateBits = [];
1380
+ if (task.startDate) dateBits.push(`start ${task.startDate}`);
1381
+ if (task.dueDate) dateBits.push(`due ${task.dueDate}`);
1382
+ if (task.statutoryDueDate) dateBits.push(`statutory ${task.statutoryDueDate}`);
1383
+ if (dateBits.length > 0) lines.push(`Dates: ${dateBits.join(" · ")}`);
1384
+ if (task.timeEstimate !== void 0 && task.timeEstimate !== null) lines.push(`Time estimate: ${task.timeEstimate}h`);
1385
+ if (task.assignedUser) lines.push(`Assigned user: ${formatCodeAndName(task.assignedUser)}`);
1386
+ if (task.assignedTeam) lines.push(`Assigned team: ${formatCodeAndName(task.assignedTeam)}`);
1387
+ if (task.category) lines.push(`Category: ${formatCodeAndName(task.category)}`);
1388
+ if (task.recurringTask) lines.push(`Recurring template: ${formatCodeAndName(task.recurringTask)}`);
1389
+ if (task.workflow) lines.push(`Workflow template: ${formatCodeAndName(task.workflow)}`);
1390
+ const description = task.description?.trim();
1391
+ if (description) lines.push("", "--- Description ---", description);
1392
+ const checklist = task.checklist ?? [];
1393
+ if (checklist.length > 0) {
1394
+ lines.push("", `--- Checklist (${countCompleted(checklist)}/${checklist.length}) ---`);
1395
+ const ordered = [...checklist].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
1396
+ for (const item of ordered) {
1397
+ const mark = item.isCompleted ? "[x]" : "[ ]";
1398
+ lines.push(`${mark} ${item.text ?? ""}`);
1399
+ }
1400
+ }
1401
+ if (workflowGroups.length > 0) {
1402
+ const totalSteps = task.workflowSteps ?? sumBy(workflowGroups, (g) => g.totalSteps ?? 0);
1403
+ const doneSteps = task.workflowStepsComplete ?? sumBy(workflowGroups, (g) => g.completedSteps ?? 0);
1404
+ lines.push("", `--- Workflow progress (${doneSteps}/${totalSteps} steps complete) ---`);
1405
+ for (const group of workflowGroups) {
1406
+ const gName = group.groupName ?? `Group ${group.groupNumber ?? "?"}`;
1407
+ const gTotal = group.totalSteps ?? 0;
1408
+ const gDone = group.completedSteps ?? 0;
1409
+ const deadline = group.groupDeadline ? ` — deadline ${group.groupDeadline}` : "";
1410
+ lines.push(`Group: ${gName} (${gDone}/${gTotal})${deadline}`);
1411
+ for (const step of group.steps ?? []) lines.push(` ${formatStep(step)}`);
1412
+ }
1413
+ }
1414
+ if (notes.length > 0) {
1415
+ const sorted = [...notes].sort((a, b) => {
1416
+ const pa = a.pinnedLevel ?? 0;
1417
+ const pb = b.pinnedLevel ?? 0;
1418
+ if (pa !== pb) return pb - pa;
1419
+ const da = a.date ?? a.createdDate ?? "";
1420
+ return (b.date ?? b.createdDate ?? "").localeCompare(da);
1421
+ });
1422
+ lines.push("", `--- Notes (${notes.length}) ---`);
1423
+ for (const note of sorted.slice(0, 20)) lines.push(formatNote(note));
1424
+ if (notes.length > 20) lines.push(`... and ${notes.length - 20} more notes`);
1425
+ }
1426
+ const clients = task.clients ?? [];
1427
+ const primaryCode = task.primaryClient?.code;
1428
+ const secondary = clients.filter((c) => c.code !== primaryCode);
1429
+ if (task.primaryClient || clients.length > 0) {
1430
+ lines.push("", "--- Parent client ---");
1431
+ if (task.primaryClient) lines.push(`Primary: ${formatCodeAndName(task.primaryClient)}`);
1432
+ if (secondary.length > 0) lines.push(`Also linked: ${secondary.map(formatCodeAndName).join(", ")}`);
1433
+ }
1434
+ if (gaps.length > 0) lines.push("", `Note: could not load ${gaps.join(", ")} (partial failure). Retry for a complete picture.`);
1435
+ return lines.join("\n");
1436
+ }
1437
+ function formatStep(step) {
1438
+ const num = step.stepNumber ?? "?";
1439
+ const name = step.stepName ?? "(unnamed)";
1440
+ const bits = [];
1441
+ if (step.status) bits.push(step.status);
1442
+ if (step.assignedUser?.name) bits.push(step.assignedUser.name);
1443
+ else if (step.assignedTeam?.name) bits.push(`team ${step.assignedTeam.name}`);
1444
+ if (step.completedDate) bits.push(`done ${step.completedDate}`);
1445
+ if (step.blockedReason) bits.push(`blocked: ${step.blockedReason}`);
1446
+ return `Step ${num}: ${name}${bits.length > 0 ? ` — ${bits.join(" · ")}` : ""}`;
1447
+ }
1448
+ function formatNote(note) {
1449
+ const pinPrefix = (note.pinnedLevel ?? 0) > 0 ? "[pinned] " : "";
1450
+ const dateStr = note.date ?? note.createdDate ?? "";
1451
+ const author = note.noteFromUser?.name ?? "(unknown)";
1452
+ const stepCtx = note.stepName ? ` [step: ${note.stepName}]` : "";
1453
+ const text = (note.text ?? "").replace(/\s+/g, " ").trim();
1454
+ return `${pinPrefix}${dateStr} — ${author}${stepCtx}: ${text.length > 200 ? `${text.slice(0, 200)}...` : text}`;
1455
+ }
1456
+ function formatCodeAndName(v) {
1457
+ if (!v) return "(none)";
1458
+ const name = v.name ?? "";
1459
+ const code = v.code ?? "";
1460
+ if (name && code) return `${name} (${code})`;
1461
+ return name || code || "(none)";
1462
+ }
1463
+ function countCompleted(items) {
1464
+ return items.filter((i) => i.isCompleted).length;
1465
+ }
1466
+ function sumBy(arr, fn) {
1467
+ return arr.reduce((acc, cur) => acc + fn(cur), 0);
1162
1468
  }
1163
1469
  //#endregion
1164
1470
  //#region ../mcp-core/src/tools/list-tasks.ts
1165
- const statusEnum = z.enum([
1471
+ const statusEnum$1 = z.enum([
1166
1472
  "NotStarted",
1167
1473
  "InProgress",
1168
1474
  "Blocked",
@@ -1194,7 +1500,7 @@ const dateRangeEnum = z.enum([
1194
1500
  "CustomDateRange"
1195
1501
  ]);
1196
1502
  const dateBasisEnum = z.enum(["StartDate", "DueDate"]);
1197
- const sortByEnum = z.enum([
1503
+ const sortByEnum$1 = z.enum([
1198
1504
  "Name",
1199
1505
  "DueDate",
1200
1506
  "StartDate",
@@ -1205,7 +1511,7 @@ const sortByEnum = z.enum([
1205
1511
  const ListTasksInputSchema = {
1206
1512
  user: z.array(z.string()).optional().describe("Filter by assigned user codes. For 'my tasks' use the current user's code from the startup context. For another team member's tasks, pass their code. Omit to see tasks across all users (useful for practice managers)."),
1207
1513
  client: z.array(z.string()).optional().describe("Filter by client codes — tasks belonging to these specific clients only."),
1208
- status: z.array(statusEnum).optional().describe("Filter by task status. Typical: ['NotStarted', 'InProgress'] to exclude completed/skipped. Leave empty for all statuses. IMPORTANT: if the query includes NotStarted (the default for new tasks), you must ALSO provide one of: dateRange, isOverdue=true, or restrict status to non-NotStarted values only — otherwise the API rejects the call to prevent unbounded queries."),
1514
+ status: z.array(statusEnum$1).optional().describe("Filter by task status. Typical: ['NotStarted', 'InProgress'] to exclude completed/skipped. Leave empty for all statuses. IMPORTANT: if the query includes NotStarted (the default for new tasks), you must ALSO provide one of: dateRange, isOverdue=true, or restrict status to non-NotStarted values only — otherwise the API rejects the call to prevent unbounded queries."),
1209
1515
  isOverdue: z.boolean().optional().describe("Set true to return only overdue tasks (DueDate before today, not completed/skipped). When true, no date range is required. Useful for sidestepping the NotStarted + date-range requirement."),
1210
1516
  dateRange: dateRangeEnum.optional().describe("Preset date range. Use 'Today' for today's tasks, 'Next7Days' for 'due this week', 'Last30Days' for recent activity. If 'CustomDateRange', also provide startDate and/or endDate. REQUIRED when querying NotStarted tasks unless you pass isOverdue=true."),
1211
1517
  startDate: z.string().optional().describe("Start of custom date range (YYYY-MM-DD). Used when dateRange='CustomDateRange'. Prefer specifying both startDate and endDate with a narrow window for performance — broad ranges strain the API. If startDate > endDate the API swaps them silently. Maximum allowed total range is 2 years."),
@@ -1217,29 +1523,36 @@ const ListTasksInputSchema = {
1217
1523
  savedFilter: z.string().optional().describe("Apply a user-saved filter by its code. Other filter parameters override fields from the saved filter."),
1218
1524
  includeProjected: z.boolean().optional().describe("Set true to include projected (virtual, not-yet-materialised) tasks in the results. Projected tasks are always NotStarted. Useful for 'what's coming up' queries."),
1219
1525
  includeWorkflowSteps: z.boolean().optional().describe("Set true for Agenda Mode: returns both tasks AND their workflow steps as individual rows. Each step row has its parent task's properties plus step-specific details. Useful for 'what's the next action on X' or when workflow step granularity matters."),
1220
- sortBy: sortByEnum.optional().describe("Field to sort by. Defaults to StartDate. Use 'DueDate' for 'what's due soonest' ordering. Combine with sortDesc for 'oldest/newest' ordering."),
1526
+ sortBy: sortByEnum$1.optional().describe("Field to sort by. Defaults to StartDate. Use 'DueDate' for 'what's due soonest' ordering. Combine with sortDesc for 'oldest/newest' ordering."),
1221
1527
  sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
1222
- limit: z.number().int().min(1).max(50).optional().describe("Maximum number of tasks per page. Default 10, max 50."),
1528
+ limit: z.number().int().min(0).max(50).optional().describe("Maximum number of tasks per page. Default 10, max 50. Pass 0 to return only the total count without any task data — use this for 'how many X?' questions so the API doesn't fetch a full page just to be counted."),
1223
1529
  offset: z.number().int().min(0).optional().describe("Pagination offset. Default 0.")
1224
1530
  };
1225
1531
  function formatTask(t) {
1226
1532
  const code = t.code ?? "(no code)";
1227
- return `- ${t.name ?? "(unnamed)"} (${code})${t.isOverdue ? " [OVERDUE]" : ""}${t.dueDate ? ` · due ${t.dueDate}` : ""}${t.primaryClient ? ` · ${t.primaryClient.name}` : t.clients?.[0] ? ` · ${t.clients[0].name}` : ""}${t.assignedUser?.name ? ` · ${t.assignedUser.name}` : ""}${t.status && t.status !== "NotStarted" ? ` · ${t.status}` : ""}`;
1533
+ return `- ${t.name ?? "(unnamed)"} (${code})${t.isOverdue ? " [OVERDUE]" : ""}${t.dueDate ? ` · due ${t.dueDate}` : ""}${t.primaryClient ? ` · ${t.primaryClient.name}` : t.clients?.[0] ? ` · ${t.clients[0].name}` : ""}${t.assignedUser?.name ? ` · ${t.assignedUser.name}` : ""}${t.status && t.status !== "NotStarted" ? ` · ${t.status}` : ""}${t.workflow ? ` · workflow ${t.workflowStepsComplete ?? 0}/${t.workflowSteps ?? 0}` : ""}`;
1228
1534
  }
1229
1535
  async function handleListTasks(api, args) {
1230
1536
  try {
1231
1537
  const query = args;
1232
1538
  const result = await api.listTasks(query);
1233
1539
  const items = result.data ?? [];
1540
+ const total = result.totalCount ?? items.length;
1541
+ if (args.limit === 0) {
1542
+ const desc = describeFilters$1(args);
1543
+ return { content: [{
1544
+ type: "text",
1545
+ text: desc ? `Total: ${total} task${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} task${total === 1 ? "" : "s"}.`
1546
+ }] };
1547
+ }
1234
1548
  if (items.length === 0) {
1235
- const desc = describeFilters(args);
1549
+ const desc = describeFilters$1(args);
1236
1550
  return { content: [{
1237
1551
  type: "text",
1238
1552
  text: desc ? `No tasks match ${desc}.` : "No tasks found."
1239
1553
  }] };
1240
1554
  }
1241
- const total = result.totalCount ?? items.length;
1242
- const desc = describeFilters(args);
1555
+ const desc = describeFilters$1(args);
1243
1556
  const lines = [
1244
1557
  desc ? total > items.length ? `Found ${total} tasks matching ${desc} (showing ${items.length}):` : `Found ${items.length} task${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} tasks:` : `${items.length} task${items.length === 1 ? "" : "s"}:`,
1245
1558
  "",
@@ -1263,7 +1576,7 @@ async function handleListTasks(api, args) {
1263
1576
  };
1264
1577
  }
1265
1578
  }
1266
- function describeFilters(args) {
1579
+ function describeFilters$1(args) {
1267
1580
  const parts = [];
1268
1581
  if (args.user?.length) parts.push(`user ${args.user.join(",")}`);
1269
1582
  if (args.client?.length) parts.push(`client ${args.client.join(",")}`);
@@ -1275,6 +1588,101 @@ function describeFilters(args) {
1275
1588
  return parts.join(", ");
1276
1589
  }
1277
1590
  //#endregion
1591
+ //#region ../mcp-core/src/tools/list-users.ts
1592
+ const statusEnum = z.enum([
1593
+ "Created",
1594
+ "Invited",
1595
+ "Active",
1596
+ "Disabled",
1597
+ "Declined",
1598
+ "Deleted"
1599
+ ]);
1600
+ const systemRoleEnum = z.enum([
1601
+ "Admin",
1602
+ "StandardUser",
1603
+ "Viewer"
1604
+ ]);
1605
+ const sortByEnum = z.enum([
1606
+ "LastName",
1607
+ "FirstName",
1608
+ "Email"
1609
+ ]);
1610
+ const ListUsersInputSchema = {
1611
+ search: z.string().min(3, "Search must be at least 3 characters when provided").optional().describe("Free-text search across user code, first name, last name, and email. Minimum 3 characters. Use for 'find Jane' when the name isn't in the startup roster (large teams, non-active users)."),
1612
+ status: statusEnum.optional().describe("Filter by a single user status. Use 'Active' for currently-working members, 'Invited' for pending invitations, 'Disabled' for offboarded users, 'Deleted' for removed users. Note: this is a single value, not an array."),
1613
+ systemRole: systemRoleEnum.optional().describe("Filter by system role. Admin = full access, StandardUser = normal access, Viewer = read-only. Single value."),
1614
+ isClientManager: z.boolean().optional().describe("Set true to return only users flagged as client managers; false to exclude them."),
1615
+ isPartner: z.boolean().optional().describe("Set true to return only users flagged as partners; false to exclude them. Use for 'list partners' queries."),
1616
+ isAssociate: z.boolean().optional().describe("Set true to return only users flagged as associates; false to exclude them."),
1617
+ sortBy: sortByEnum.optional().describe("Field to sort by. Defaults to LastName."),
1618
+ sortDesc: z.boolean().optional().describe("Sort in descending order. Defaults to ascending."),
1619
+ limit: z.number().int().min(0).max(50).optional().describe("Maximum number of users per page. Default 10, max 50. Pass 0 to return only the total count without any user data — use for 'how many users?' questions."),
1620
+ offset: z.number().int().min(0).optional().describe("Pagination offset. Default 0.")
1621
+ };
1622
+ function formatUser(u) {
1623
+ const code = u.code ?? "(no code)";
1624
+ const display = u.displayName ?? [u.firstName, u.lastName].filter(Boolean).join(" ") ?? u.email ?? "(no name)";
1625
+ const meta = [];
1626
+ if (u.status && u.status !== "Active") meta.push(u.status);
1627
+ if (u.email && u.email !== display) meta.push(u.email);
1628
+ if (u.isSuperAdmin) meta.push("SuperAdmin");
1629
+ return `- ${display} (${code})${meta.length > 0 ? ` — ${meta.join(" · ")}` : ""}`;
1630
+ }
1631
+ async function handleListUsers(api, args) {
1632
+ try {
1633
+ const query = args;
1634
+ const result = await api.listUsers(query);
1635
+ const items = result.data ?? [];
1636
+ const total = result.totalCount ?? items.length;
1637
+ if (args.limit === 0) {
1638
+ const desc = describeFilters(args);
1639
+ return { content: [{
1640
+ type: "text",
1641
+ text: desc ? `Total: ${total} user${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} user${total === 1 ? "" : "s"}.`
1642
+ }] };
1643
+ }
1644
+ if (items.length === 0) {
1645
+ const desc = describeFilters(args);
1646
+ return { content: [{
1647
+ type: "text",
1648
+ text: desc ? `No users match ${desc}.` : "No users found."
1649
+ }] };
1650
+ }
1651
+ const desc = describeFilters(args);
1652
+ const lines = [
1653
+ desc ? total > items.length ? `Found ${total} users matching ${desc} (showing ${items.length}):` : `Found ${items.length} user${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} users:` : `${items.length} user${items.length === 1 ? "" : "s"}:`,
1654
+ "",
1655
+ ...items.map(formatUser)
1656
+ ];
1657
+ if (result.hasMore) {
1658
+ const nextOffset = (args.offset ?? 0) + items.length;
1659
+ lines.push("", `More results available — call again with offset: ${nextOffset} to see the next page.`);
1660
+ }
1661
+ return { content: [{
1662
+ type: "text",
1663
+ text: lines.join("\n")
1664
+ }] };
1665
+ } catch (error) {
1666
+ return {
1667
+ content: [{
1668
+ type: "text",
1669
+ text: error instanceof SodiumApiError ? `Error listing users: ${error.message} (correlation: ${error.correlationId})` : `Error listing users: ${error instanceof Error ? error.message : String(error)}`
1670
+ }],
1671
+ isError: true
1672
+ };
1673
+ }
1674
+ }
1675
+ function describeFilters(args) {
1676
+ const parts = [];
1677
+ if (args.search) parts.push(`search "${args.search}"`);
1678
+ if (args.status) parts.push(`status ${args.status}`);
1679
+ if (args.systemRole) parts.push(`role ${args.systemRole}`);
1680
+ if (args.isClientManager !== void 0) parts.push(`isClientManager=${args.isClientManager}`);
1681
+ if (args.isPartner !== void 0) parts.push(`isPartner=${args.isPartner}`);
1682
+ if (args.isAssociate !== void 0) parts.push(`isAssociate=${args.isAssociate}`);
1683
+ return parts.join(", ");
1684
+ }
1685
+ //#endregion
1278
1686
  //#region ../mcp-core/src/server.ts
1279
1687
  async function buildServer(config) {
1280
1688
  const api = new SodiumApiClient(config.context);
@@ -1298,7 +1706,7 @@ async function buildServer(config) {
1298
1706
  }, () => handleGetPracticeDetails(api));
1299
1707
  server.registerTool("list_clients", {
1300
1708
  title: "List / search / filter clients",
1301
- description: "List clients with any combination of: search (code/name/internal reference, 3+ chars), status (Active/Inactive/Prospect/LostProspect), type (PrivateLimitedCompany/PublicLimitedCompany/LimitedLiabilityPartnership/Partnership/Individual/Trust/Charity/SoleTrader), manager/partner/associate user codes, service codes, a saved filter code, sort, and pagination. Use search for 'find ACME'-style queries. Use type for 'list limited companies' (pass PrivateLimitedCompany + PublicLimitedCompany). Use status: ['Active'] to exclude prospects/inactive. Returns up to 50 clients per page — paginate via offset for more. Follow up with get_client_summary for full detail on a specific client.",
1709
+ description: "List clients with any combination of: search (code/name/internal reference, 3+ chars), status (Active/Inactive/Prospect/LostProspect), type (PrivateLimitedCompany/PublicLimitedCompany/LimitedLiabilityPartnership/Partnership/Individual/Trust/Charity/SoleTrader), manager/partner/associate user codes, service codes, a saved filter code, sort, and pagination. Use search for 'find ACME'-style queries. Use type for 'list limited companies' (pass PrivateLimitedCompany + PublicLimitedCompany). Use status: ['Active'] to exclude prospects/inactive. Returns up to 50 clients per page — paginate via offset for more. For 'how many X?' questions, pass limit=0 to get just the total count without fetching any client data. Follow up with get_client_summary for full detail on a specific client.",
1302
1710
  inputSchema: ListClientsInputSchema,
1303
1711
  annotations: {
1304
1712
  readOnlyHint: true,
@@ -1318,7 +1726,7 @@ async function buildServer(config) {
1318
1726
  }, (args) => handleGetClientSummary(api, args));
1319
1727
  server.registerTool("list_tasks", {
1320
1728
  title: "List / filter tasks across the practice",
1321
- 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.",
1729
+ 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.",
1322
1730
  inputSchema: ListTasksInputSchema,
1323
1731
  annotations: {
1324
1732
  readOnlyHint: true,
@@ -1326,6 +1734,26 @@ async function buildServer(config) {
1326
1734
  openWorldHint: true
1327
1735
  }
1328
1736
  }, (args) => handleListTasks(api, args));
1737
+ server.registerTool("get_task_context", {
1738
+ title: "Get full context for a single task",
1739
+ description: "Get a consolidated view of one task by code: identity (name, code, status, overdue flag, start/due/statutory dates, time estimate, assignee), description, task-level checklist, workflow progress (groups and steps with status, assignee, and completion info — only if the task has a workflow attached), notes (pinned first, then newest first), and the parent client. Use this AFTER list_tasks identifies a task of interest, or when the user references a specific task by code. Tolerates partial failures — if notes or workflow steps can't be loaded, the task details are still returned with a note about what's missing. Only fails outright if the task itself can't be fetched (e.g. unknown code).",
1740
+ inputSchema: GetTaskContextInputSchema,
1741
+ annotations: {
1742
+ readOnlyHint: true,
1743
+ idempotentHint: true,
1744
+ openWorldHint: true
1745
+ }
1746
+ }, (args) => handleGetTaskContext(api, args));
1747
+ server.registerTool("list_users", {
1748
+ title: "List / search / filter tenant users",
1749
+ 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.",
1750
+ inputSchema: ListUsersInputSchema,
1751
+ annotations: {
1752
+ readOnlyHint: true,
1753
+ idempotentHint: true,
1754
+ openWorldHint: true
1755
+ }
1756
+ }, (args) => handleListUsers(api, args));
1329
1757
  return server;
1330
1758
  }
1331
1759
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2589",
3
+ "version": "0.1.0-beta.2592",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {