@sodiumhq/mcp-pm 0.1.0-beta.2593 → 0.1.0-beta.2596
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/dist/index.js +350 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,10 +47,23 @@ Then ask: *"give me a summary of my practice"*.
|
|
|
47
47
|
|
|
48
48
|
## What it can do today
|
|
49
49
|
|
|
50
|
+
**Practice**
|
|
50
51
|
- **`get_practice_details`** — consolidated practice overview (counts, connections, settings)
|
|
52
|
+
|
|
53
|
+
**Clients**
|
|
51
54
|
- **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
|
|
52
|
-
- **`get_client_summary`** — one-call composite
|
|
53
|
-
|
|
55
|
+
- **`get_client_summary`** — one-call composite for a single client: identity + contacts + active services with pricing + business details (company number, VAT, UTR, trading address) + key statutory dates (year-end, accounts due, VAT return due, confirmation statement due) + overdue tasks + tasks due in next 7 days
|
|
56
|
+
|
|
57
|
+
**Tasks**
|
|
58
|
+
- **`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?"
|
|
59
|
+
- **`get_task_context`** — one-call composite for a single task: details + notes + workflow steps + primary client + category
|
|
60
|
+
|
|
61
|
+
**Proposals and engagements**
|
|
62
|
+
- **`list_proposals`** and **`list_engagement_letters`** — aliased tools for the same underlying engagement pipeline (pre-acceptance vs signed). Filter by status, client, date range.
|
|
63
|
+
- **`get_proposal_summary`** and **`get_engagement_letter_summary`** — aliased drill-ins: identity + client + value breakdown + services with pricing + PDFs + email history + acceptance record
|
|
64
|
+
|
|
65
|
+
**Team**
|
|
66
|
+
- **`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?"
|
|
54
67
|
|
|
55
68
|
More tools land iteratively as the beta progresses.
|
|
56
69
|
|
package/dist/index.js
CHANGED
|
@@ -719,6 +719,57 @@ const getClient = (options) => (options.client ?? client).get({
|
|
|
719
719
|
...options
|
|
720
720
|
});
|
|
721
721
|
/**
|
|
722
|
+
* List Engagements
|
|
723
|
+
*
|
|
724
|
+
* Lists all Engagements for the given tenant.
|
|
725
|
+
*/
|
|
726
|
+
const listEngagements = (options) => (options.client ?? client).get({
|
|
727
|
+
security: [{
|
|
728
|
+
name: "x-api-key",
|
|
729
|
+
type: "apiKey"
|
|
730
|
+
}, {
|
|
731
|
+
scheme: "bearer",
|
|
732
|
+
type: "http"
|
|
733
|
+
}],
|
|
734
|
+
url: "/tenants/{tenant}/engagements",
|
|
735
|
+
...options
|
|
736
|
+
});
|
|
737
|
+
/**
|
|
738
|
+
* Get Engagement
|
|
739
|
+
*
|
|
740
|
+
* Gets a Engagement for the specified tenant.
|
|
741
|
+
*/
|
|
742
|
+
const getEngagement = (options) => (options.client ?? client).get({
|
|
743
|
+
security: [{
|
|
744
|
+
name: "x-api-key",
|
|
745
|
+
type: "apiKey"
|
|
746
|
+
}, {
|
|
747
|
+
scheme: "bearer",
|
|
748
|
+
type: "http"
|
|
749
|
+
}],
|
|
750
|
+
url: "/tenants/{tenant}/engagements/{code}",
|
|
751
|
+
...options
|
|
752
|
+
});
|
|
753
|
+
/**
|
|
754
|
+
* Get Engagement Emails
|
|
755
|
+
*
|
|
756
|
+
* Gets all emails that have been sent for a specific engagement.
|
|
757
|
+
*
|
|
758
|
+
* Returns a list of emails with their message IDs and sent dates.
|
|
759
|
+
* The list is ordered by most recent first.
|
|
760
|
+
*/
|
|
761
|
+
const getEngagementEmails = (options) => (options.client ?? client).get({
|
|
762
|
+
security: [{
|
|
763
|
+
name: "x-api-key",
|
|
764
|
+
type: "apiKey"
|
|
765
|
+
}, {
|
|
766
|
+
scheme: "bearer",
|
|
767
|
+
type: "http"
|
|
768
|
+
}],
|
|
769
|
+
url: "/tenants/{tenant}/engagements/{code}/email",
|
|
770
|
+
...options
|
|
771
|
+
});
|
|
772
|
+
/**
|
|
722
773
|
* Get Practice Details
|
|
723
774
|
*
|
|
724
775
|
* Returns the practice details for the specified tenant
|
|
@@ -1072,6 +1123,44 @@ var SodiumApiClient = class {
|
|
|
1072
1123
|
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `list notes for task ${taskCode}`);
|
|
1073
1124
|
return data;
|
|
1074
1125
|
}
|
|
1126
|
+
async listEngagements(query = {}) {
|
|
1127
|
+
const correlationId = randomUUID();
|
|
1128
|
+
const { data, error, response } = await listEngagements({
|
|
1129
|
+
path: { tenant: this.ctx.tenant },
|
|
1130
|
+
query: {
|
|
1131
|
+
...query,
|
|
1132
|
+
limit: query.limit ?? 10,
|
|
1133
|
+
offset: query.offset ?? 0
|
|
1134
|
+
},
|
|
1135
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1136
|
+
});
|
|
1137
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, "list engagements");
|
|
1138
|
+
return data;
|
|
1139
|
+
}
|
|
1140
|
+
async getEngagement(code) {
|
|
1141
|
+
const correlationId = randomUUID();
|
|
1142
|
+
const { data, error, response } = await getEngagement({
|
|
1143
|
+
path: {
|
|
1144
|
+
tenant: this.ctx.tenant,
|
|
1145
|
+
code
|
|
1146
|
+
},
|
|
1147
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1148
|
+
});
|
|
1149
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get engagement ${code}`);
|
|
1150
|
+
return data;
|
|
1151
|
+
}
|
|
1152
|
+
async getEngagementEmails(code) {
|
|
1153
|
+
const correlationId = randomUUID();
|
|
1154
|
+
const { data, error, response } = await getEngagementEmails({
|
|
1155
|
+
path: {
|
|
1156
|
+
tenant: this.ctx.tenant,
|
|
1157
|
+
code
|
|
1158
|
+
},
|
|
1159
|
+
headers: { "X-Correlation-Id": correlationId }
|
|
1160
|
+
});
|
|
1161
|
+
if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get emails for engagement ${code}`);
|
|
1162
|
+
return data;
|
|
1163
|
+
}
|
|
1075
1164
|
async getTaskWorkflowGroups(taskCode) {
|
|
1076
1165
|
const correlationId = randomUUID();
|
|
1077
1166
|
const { data, error, response } = await getTaskWorkflowGroups({
|
|
@@ -1137,7 +1226,7 @@ async function buildInstructions(api) {
|
|
|
1137
1226
|
}
|
|
1138
1227
|
//#endregion
|
|
1139
1228
|
//#region ../mcp-core/src/tools/get-practice-details.ts
|
|
1140
|
-
function format$
|
|
1229
|
+
function format$3(tenant, practice) {
|
|
1141
1230
|
const lines = [];
|
|
1142
1231
|
lines.push(`Practice: ${practice.name}`);
|
|
1143
1232
|
lines.push(`Tenant: ${tenant.name} (${tenant.code})`);
|
|
@@ -1171,7 +1260,7 @@ async function handleGetPracticeDetails(api) {
|
|
|
1171
1260
|
const [tenant, practice] = await Promise.all([api.getTenantDetails(), api.getPracticeDetails()]);
|
|
1172
1261
|
return { content: [{
|
|
1173
1262
|
type: "text",
|
|
1174
|
-
text: format$
|
|
1263
|
+
text: format$3(tenant, practice)
|
|
1175
1264
|
}] };
|
|
1176
1265
|
} catch (error) {
|
|
1177
1266
|
return {
|
|
@@ -1229,20 +1318,20 @@ async function handleListClients(api, args) {
|
|
|
1229
1318
|
const items = result.data ?? [];
|
|
1230
1319
|
const total = result.totalCount ?? items.length;
|
|
1231
1320
|
if (args.limit === 0) {
|
|
1232
|
-
const desc = describeFilters$
|
|
1321
|
+
const desc = describeFilters$3(args);
|
|
1233
1322
|
return { content: [{
|
|
1234
1323
|
type: "text",
|
|
1235
1324
|
text: desc ? `Total: ${total} client${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} client${total === 1 ? "" : "s"}.`
|
|
1236
1325
|
}] };
|
|
1237
1326
|
}
|
|
1238
1327
|
if (items.length === 0) {
|
|
1239
|
-
const desc = describeFilters$
|
|
1328
|
+
const desc = describeFilters$3(args);
|
|
1240
1329
|
return { content: [{
|
|
1241
1330
|
type: "text",
|
|
1242
1331
|
text: desc ? `No clients match ${desc}.` : "No clients found."
|
|
1243
1332
|
}] };
|
|
1244
1333
|
}
|
|
1245
|
-
const desc = describeFilters$
|
|
1334
|
+
const desc = describeFilters$3(args);
|
|
1246
1335
|
const lines = [
|
|
1247
1336
|
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"}:`,
|
|
1248
1337
|
"",
|
|
@@ -1266,7 +1355,7 @@ async function handleListClients(api, args) {
|
|
|
1266
1355
|
};
|
|
1267
1356
|
}
|
|
1268
1357
|
}
|
|
1269
|
-
function describeFilters$
|
|
1358
|
+
function describeFilters$3(args) {
|
|
1270
1359
|
const parts = [];
|
|
1271
1360
|
if (args.search) parts.push(`search "${args.search}"`);
|
|
1272
1361
|
if (args.status?.length) parts.push(`status ${args.status.join("/")}`);
|
|
@@ -1312,7 +1401,7 @@ async function handleGetClientSummary(api, { code }) {
|
|
|
1312
1401
|
}
|
|
1313
1402
|
return { content: [{
|
|
1314
1403
|
type: "text",
|
|
1315
|
-
text: format$
|
|
1404
|
+
text: format$2({
|
|
1316
1405
|
client: clientResult.value,
|
|
1317
1406
|
contacts: extract(contactsResult),
|
|
1318
1407
|
services: extract(servicesResult),
|
|
@@ -1335,7 +1424,7 @@ function extract(result) {
|
|
|
1335
1424
|
if (result.status !== "fulfilled") return [];
|
|
1336
1425
|
return result.value.data ?? [];
|
|
1337
1426
|
}
|
|
1338
|
-
function format$
|
|
1427
|
+
function format$2(input) {
|
|
1339
1428
|
const { client, contacts, services, businessDetails, clientDates, overdueTasks, upcomingTasks, gaps } = input;
|
|
1340
1429
|
const lines = [];
|
|
1341
1430
|
const name = client.name ?? "(no name)";
|
|
@@ -1435,6 +1524,101 @@ function humanizeDateType(type) {
|
|
|
1435
1524
|
}).join(" ");
|
|
1436
1525
|
}
|
|
1437
1526
|
//#endregion
|
|
1527
|
+
//#region ../mcp-core/src/tools/get-engagement-summary.ts
|
|
1528
|
+
const GetEngagementSummaryInputSchema = { code: z.string().min(1, "Engagement code is required").describe("REQUIRED: The engagement code (identifier, e.g. 'ENG-123'). This is the unique code of a specific engagement — NOT a description like 'the unsent proposal' or 'ACME's proposal'. If the user describes an engagement by attribute (status, client, date) rather than by code, you MUST call list_proposals or list_engagement_letters first (with the appropriate filters) to find the engagement's code, then pass that code here.") };
|
|
1529
|
+
async function handleGetEngagementSummary(api, { code }) {
|
|
1530
|
+
const [engagementResult, emailsResult] = await Promise.allSettled([api.getEngagement(code), api.getEngagementEmails(code)]);
|
|
1531
|
+
if (engagementResult.status === "rejected") {
|
|
1532
|
+
const err = engagementResult.reason;
|
|
1533
|
+
return {
|
|
1534
|
+
content: [{
|
|
1535
|
+
type: "text",
|
|
1536
|
+
text: err instanceof SodiumApiError ? `Error getting engagement: ${err.message} (correlation: ${err.correlationId})` : `Error getting engagement: ${err instanceof Error ? err.message : String(err)}`
|
|
1537
|
+
}],
|
|
1538
|
+
isError: true
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
const engagement = engagementResult.value;
|
|
1542
|
+
return { content: [{
|
|
1543
|
+
type: "text",
|
|
1544
|
+
text: format$1({
|
|
1545
|
+
engagement,
|
|
1546
|
+
emails: emailsResult.status === "fulfilled" ? emailsResult.value : [],
|
|
1547
|
+
gaps: emailsResult.status === "rejected" ? ["email history"] : []
|
|
1548
|
+
})
|
|
1549
|
+
}] };
|
|
1550
|
+
}
|
|
1551
|
+
function format$1(input) {
|
|
1552
|
+
const { engagement: e, emails, gaps } = input;
|
|
1553
|
+
const lines = [];
|
|
1554
|
+
const code = e.code ?? "(no code)";
|
|
1555
|
+
const typeLabel = e.typeName ?? e.type ?? "";
|
|
1556
|
+
lines.push(`Engagement: ${code}${typeLabel ? ` (${typeLabel})` : ""}`);
|
|
1557
|
+
if (e.status) lines.push(`Status: ${e.status}`);
|
|
1558
|
+
if (e.date) lines.push(`Date: ${e.date}`);
|
|
1559
|
+
if (e.client) lines.push(`Client: ${e.client.name ?? "(no name)"} (${e.client.code ?? "?"})`);
|
|
1560
|
+
const recipientName = [e.recipientFirstName, e.recipientLastName].filter(Boolean).join(" ");
|
|
1561
|
+
if (recipientName || e.recipientEmail) {
|
|
1562
|
+
const email = e.recipientEmail ? ` <${e.recipientEmail}>` : "";
|
|
1563
|
+
lines.push(`Recipient: ${recipientName || "(no name)"}${email}`);
|
|
1564
|
+
}
|
|
1565
|
+
if (e.lastViewed) lines.push(`Last viewed by client: ${e.lastViewed}`);
|
|
1566
|
+
const valueLines = formatValues(e);
|
|
1567
|
+
if (valueLines.length > 0) lines.push("", "--- Value ---", ...valueLines);
|
|
1568
|
+
const services = e.proposalServices ?? [];
|
|
1569
|
+
if (services.length > 0) {
|
|
1570
|
+
lines.push("", `--- Services (${services.length}) ---`);
|
|
1571
|
+
for (const s of services) lines.push(`- ${formatService(s)}`);
|
|
1572
|
+
}
|
|
1573
|
+
if (e.acceptance && e.status === "Accepted") {
|
|
1574
|
+
lines.push("", "--- Acceptance ---");
|
|
1575
|
+
if (e.acceptance.acceptedDate) lines.push(`Accepted on: ${e.acceptance.acceptedDate}`);
|
|
1576
|
+
if (e.acceptance.manuallyAccepted) lines.push("Accepted manually (recorded by practice, not via portal)");
|
|
1577
|
+
if (e.acceptance.acceptedIpAddress && !e.acceptance.manuallyAccepted) lines.push(`Client IP: ${e.acceptance.acceptedIpAddress}`);
|
|
1578
|
+
}
|
|
1579
|
+
const pdfs = [];
|
|
1580
|
+
if (e.hasProposalPdf) pdfs.push("proposal PDF uploaded");
|
|
1581
|
+
if (e.hasLofEPdf) pdfs.push("letter of engagement PDF uploaded");
|
|
1582
|
+
if (pdfs.length === 0) pdfs.push("no PDFs uploaded yet");
|
|
1583
|
+
lines.push("", `--- Documents ---`, ...pdfs.map((p) => `- ${p}`));
|
|
1584
|
+
if (emails.length > 0) {
|
|
1585
|
+
const sorted = [...emails].sort((a, b) => {
|
|
1586
|
+
const da = a.sentDate ?? "";
|
|
1587
|
+
return (b.sentDate ?? "").localeCompare(da);
|
|
1588
|
+
});
|
|
1589
|
+
lines.push("", `--- Email history (${emails.length}) ---`);
|
|
1590
|
+
for (const em of sorted.slice(0, 10)) lines.push(formatEmail(em));
|
|
1591
|
+
if (emails.length > 10) lines.push(`... and ${emails.length - 10} more`);
|
|
1592
|
+
}
|
|
1593
|
+
if (e.link) lines.push("", `Acceptance link: ${e.link}`);
|
|
1594
|
+
if (gaps.length > 0) lines.push("", `Note: could not load ${gaps.join(", ")} (partial failure). Retry for a complete picture.`);
|
|
1595
|
+
return lines.join("\n");
|
|
1596
|
+
}
|
|
1597
|
+
function formatValues(e) {
|
|
1598
|
+
const out = [];
|
|
1599
|
+
if (e.annualTotal && e.annualTotal > 0) out.push(`Annual: £${formatMoney$1(e.annualTotal)}`);
|
|
1600
|
+
if (e.quarterlyTotal && e.quarterlyTotal > 0) out.push(`Quarterly: £${formatMoney$1(e.quarterlyTotal)}`);
|
|
1601
|
+
if (e.monthlyTotal && e.monthlyTotal > 0) out.push(`Monthly: £${formatMoney$1(e.monthlyTotal)}`);
|
|
1602
|
+
if (e.oneOffTotal && e.oneOffTotal > 0) out.push(`One-off: £${formatMoney$1(e.oneOffTotal)}`);
|
|
1603
|
+
if (e.totalValue && e.totalValue > 0) out.push(`Total contracted value: £${formatMoney$1(e.totalValue)}`);
|
|
1604
|
+
return out;
|
|
1605
|
+
}
|
|
1606
|
+
function formatService(s) {
|
|
1607
|
+
const name = s.billableService?.name ?? "(unnamed)";
|
|
1608
|
+
const freq = s.billingFrequency ? ` ${s.billingFrequency}` : "";
|
|
1609
|
+
if (s.effectivePrice !== void 0 && s.effectivePrice !== null) return `${name} — £${formatMoney$1(s.effectivePrice)}${freq}`;
|
|
1610
|
+
return freq ? `${name} —${freq}` : name;
|
|
1611
|
+
}
|
|
1612
|
+
function formatEmail(em) {
|
|
1613
|
+
return `- ${em.sentDate ?? "(no date)"}: ${em.subject ?? "(no subject)"}${em.status ? ` · ${em.status}` : ""}${em.toRecipients?.length ? ` → ${em.toRecipients.join(", ")}` : ""}`;
|
|
1614
|
+
}
|
|
1615
|
+
function formatMoney$1(n) {
|
|
1616
|
+
return n.toLocaleString("en-GB", {
|
|
1617
|
+
minimumFractionDigits: 2,
|
|
1618
|
+
maximumFractionDigits: 2
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
//#endregion
|
|
1438
1622
|
//#region ../mcp-core/src/tools/get-task-context.ts
|
|
1439
1623
|
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.") };
|
|
1440
1624
|
async function handleGetTaskContext(api, { code }) {
|
|
@@ -1574,6 +1758,124 @@ function sumBy(arr, fn) {
|
|
|
1574
1758
|
return arr.reduce((acc, cur) => acc + fn(cur), 0);
|
|
1575
1759
|
}
|
|
1576
1760
|
//#endregion
|
|
1761
|
+
//#region ../mcp-core/src/tools/list-engagements.ts
|
|
1762
|
+
const EngagementStatusEnum = z.enum([
|
|
1763
|
+
"Unsent",
|
|
1764
|
+
"Sent",
|
|
1765
|
+
"Viewed",
|
|
1766
|
+
"Accepted",
|
|
1767
|
+
"Rejected"
|
|
1768
|
+
]);
|
|
1769
|
+
const EngagementSortFieldEnum = z.enum([
|
|
1770
|
+
"Client",
|
|
1771
|
+
"Code",
|
|
1772
|
+
"Date",
|
|
1773
|
+
"Status",
|
|
1774
|
+
"NumberOfServices",
|
|
1775
|
+
"TotalValue"
|
|
1776
|
+
]);
|
|
1777
|
+
const DateRangeEnum = z.enum([
|
|
1778
|
+
"Today",
|
|
1779
|
+
"ThisWeek",
|
|
1780
|
+
"NextWeek",
|
|
1781
|
+
"Last7Days",
|
|
1782
|
+
"Next7Days",
|
|
1783
|
+
"Last30Days",
|
|
1784
|
+
"Next30Days",
|
|
1785
|
+
"PreviousMonth",
|
|
1786
|
+
"ThisMonth",
|
|
1787
|
+
"NextMonth",
|
|
1788
|
+
"PreviousQuarter",
|
|
1789
|
+
"ThisQuarter",
|
|
1790
|
+
"NextQuarter",
|
|
1791
|
+
"PreviousYear",
|
|
1792
|
+
"ThisYear",
|
|
1793
|
+
"NextYear",
|
|
1794
|
+
"YearToDate",
|
|
1795
|
+
"PreviousFinancialYear",
|
|
1796
|
+
"ThisFinancialYear",
|
|
1797
|
+
"NextFinancialYear",
|
|
1798
|
+
"FinancialYearToDate",
|
|
1799
|
+
"CustomDateRange"
|
|
1800
|
+
]);
|
|
1801
|
+
const ListEngagementsInputSchema = {
|
|
1802
|
+
status: EngagementStatusEnum.optional().describe("Filter by engagement status. Unsent = draft, not yet sent. Sent = delivered, awaiting client action. Viewed = client opened but not yet responded. Accepted = client signed / agreed. Rejected = client declined. For 'proposals awaiting acceptance' use status=Sent (or run twice: Sent then Viewed). For 'signed letters of engagement' use status=Accepted. Omit to see every status."),
|
|
1803
|
+
search: z.string().min(3, "Search must be at least 3 characters").optional().describe("Search across engagement code, client name, and client code. Minimum 3 characters. Use for 'ACME's engagement' / 'proposal for Smith & Co' queries where you know the client name but not the engagement code."),
|
|
1804
|
+
dateRange: DateRangeEnum.optional().describe("Preset date range filtering by engagement date. Use Today / ThisWeek / Last7Days / Last30Days / ThisMonth / ThisQuarter / ThisYear etc. Use CustomDateRange with dateFrom+dateTo for arbitrary bounds. Omit to include engagements from any date."),
|
|
1805
|
+
dateFrom: z.string().optional().describe("Inclusive start date (YYYY-MM-DD). ONLY valid when dateRange=CustomDateRange — ignored otherwise."),
|
|
1806
|
+
dateTo: z.string().optional().describe("Inclusive end date (YYYY-MM-DD). ONLY valid when dateRange=CustomDateRange — ignored otherwise."),
|
|
1807
|
+
sortBy: EngagementSortFieldEnum.optional().describe("Field to sort by. Use Date for 'most recent proposals' (with sortDesc=true), TotalValue for 'highest-value engagements', Status for grouping by pipeline stage. Default server-side."),
|
|
1808
|
+
sortDesc: z.boolean().optional().describe("Sort descending when true, ascending when false/omitted. Pair with sortBy=Date and sortDesc=true for 'most recent first'."),
|
|
1809
|
+
limit: z.number().int().min(0).max(50).optional().describe("Max records to return (default 10, max 50). Pass limit=0 for 'how many proposals are awaiting acceptance?' / 'how many engagements did we send this month?' — returns just the total count without fetching any engagement data."),
|
|
1810
|
+
offset: z.number().int().min(0).optional().describe("Skip this many records. Use with limit for pagination.")
|
|
1811
|
+
};
|
|
1812
|
+
async function handleListEngagements(api, args) {
|
|
1813
|
+
try {
|
|
1814
|
+
const query = args;
|
|
1815
|
+
const result = await api.listEngagements(query);
|
|
1816
|
+
const items = result.data ?? [];
|
|
1817
|
+
const total = result.totalCount ?? items.length;
|
|
1818
|
+
if (args.limit === 0) {
|
|
1819
|
+
const desc = describeFilters$2(args);
|
|
1820
|
+
return { content: [{
|
|
1821
|
+
type: "text",
|
|
1822
|
+
text: desc ? `Total: ${total} engagement${total === 1 ? "" : "s"} matching ${desc}.` : `Total: ${total} engagement${total === 1 ? "" : "s"}.`
|
|
1823
|
+
}] };
|
|
1824
|
+
}
|
|
1825
|
+
if (items.length === 0) {
|
|
1826
|
+
const desc = describeFilters$2(args);
|
|
1827
|
+
return { content: [{
|
|
1828
|
+
type: "text",
|
|
1829
|
+
text: desc ? `No engagements match ${desc}.` : "No engagements found."
|
|
1830
|
+
}] };
|
|
1831
|
+
}
|
|
1832
|
+
const desc = describeFilters$2(args);
|
|
1833
|
+
const lines = [
|
|
1834
|
+
desc ? total > items.length ? `Found ${total} engagements matching ${desc} (showing ${items.length}):` : `Found ${items.length} engagement${items.length === 1 ? "" : "s"} matching ${desc}:` : total > items.length ? `Showing ${items.length} of ${total} engagements:` : `${items.length} engagement${items.length === 1 ? "" : "s"}:`,
|
|
1835
|
+
"",
|
|
1836
|
+
...items.map(formatEngagement)
|
|
1837
|
+
];
|
|
1838
|
+
if (result.hasMore) {
|
|
1839
|
+
const nextOffset = (args.offset ?? 0) + items.length;
|
|
1840
|
+
lines.push("", `More results available — call again with offset: ${nextOffset} to see the next page.`);
|
|
1841
|
+
}
|
|
1842
|
+
return { content: [{
|
|
1843
|
+
type: "text",
|
|
1844
|
+
text: lines.join("\n")
|
|
1845
|
+
}] };
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
return {
|
|
1848
|
+
content: [{
|
|
1849
|
+
type: "text",
|
|
1850
|
+
text: error instanceof SodiumApiError ? `Error listing engagements: ${error.message} (correlation: ${error.correlationId})` : `Error listing engagements: ${error instanceof Error ? error.message : String(error)}`
|
|
1851
|
+
}],
|
|
1852
|
+
isError: true
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function formatEngagement(e) {
|
|
1857
|
+
return `- ${e.code ?? "(no code)"} · ${e.status ?? "?"} · ${e.client ? `${e.client.name ?? "(no name)"} (${e.client.code ?? "?"})` : "(no client)"}${e.date ? ` · ${e.date}` : ""}${formatAnnualValue(e)}${e.numberOfServices !== void 0 && e.numberOfServices !== null ? ` · ${e.numberOfServices} service${e.numberOfServices === 1 ? "" : "s"}` : ""}`;
|
|
1858
|
+
}
|
|
1859
|
+
function formatAnnualValue(e) {
|
|
1860
|
+
if (e.annualTotal && e.annualTotal > 0) return ` · annual £${formatMoney(e.annualTotal)}`;
|
|
1861
|
+
if (e.totalValue && e.totalValue > 0) return ` · total £${formatMoney(e.totalValue)}`;
|
|
1862
|
+
return "";
|
|
1863
|
+
}
|
|
1864
|
+
function formatMoney(n) {
|
|
1865
|
+
return n.toLocaleString("en-GB", {
|
|
1866
|
+
minimumFractionDigits: 2,
|
|
1867
|
+
maximumFractionDigits: 2
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
function describeFilters$2(args) {
|
|
1871
|
+
const parts = [];
|
|
1872
|
+
if (args.status) parts.push(`status ${args.status}`);
|
|
1873
|
+
if (args.search) parts.push(`search "${args.search}"`);
|
|
1874
|
+
if (args.dateRange) parts.push(`date ${args.dateRange}${args.dateRange === "CustomDateRange" && args.dateFrom && args.dateTo ? ` ${args.dateFrom} to ${args.dateTo}` : ""}`);
|
|
1875
|
+
if (args.sortBy) parts.push(`sort ${args.sortBy}${args.sortDesc ? " desc" : ""}`);
|
|
1876
|
+
return parts.join(", ");
|
|
1877
|
+
}
|
|
1878
|
+
//#endregion
|
|
1577
1879
|
//#region ../mcp-core/src/tools/list-tasks.ts
|
|
1578
1880
|
const statusEnum$1 = z.enum([
|
|
1579
1881
|
"NotStarted",
|
|
@@ -1851,6 +2153,46 @@ async function buildServer(config) {
|
|
|
1851
2153
|
openWorldHint: true
|
|
1852
2154
|
}
|
|
1853
2155
|
}, (args) => handleGetTaskContext(api, args));
|
|
2156
|
+
server.registerTool("list_proposals", {
|
|
2157
|
+
title: "List / filter proposals",
|
|
2158
|
+
description: "List proposals (pre-acceptance engagement documents) with filters: status (Unsent/Sent/Viewed/Accepted/Rejected), search (engagement code / client name / client code, 3+ chars), preset date range, sort, pagination. Use for 'proposals awaiting acceptance' (status=Sent, or run twice for Sent + Viewed), 'proposals sent to ACME' (search), 'proposals sent this month' (dateRange=ThisMonth). Returns the same underlying data as list_engagement_letters — both aliases back the same Engagement entity; the status filter is what distinguishes a proposal (Unsent/Sent/Viewed/Rejected) from a signed letter of engagement (Accepted). For 'how many X?' questions, pass limit=0 for count-only. Follow up with get_proposal_summary for full detail on one engagement.",
|
|
2159
|
+
inputSchema: ListEngagementsInputSchema,
|
|
2160
|
+
annotations: {
|
|
2161
|
+
readOnlyHint: true,
|
|
2162
|
+
idempotentHint: true,
|
|
2163
|
+
openWorldHint: true
|
|
2164
|
+
}
|
|
2165
|
+
}, (args) => handleListEngagements(api, args));
|
|
2166
|
+
server.registerTool("list_engagement_letters", {
|
|
2167
|
+
title: "List / filter letters of engagement",
|
|
2168
|
+
description: "List letters of engagement (signed/accepted or in-flight engagement documents) with filters: status (Unsent/Sent/Viewed/Accepted/Rejected), search (engagement code / client name / client code, 3+ chars), preset date range, sort, pagination. Use for 'live letters of engagement' or 'signed engagements' (status=Accepted), 'letter of engagement for ACME' (search), 'engagements signed this quarter' (status=Accepted + dateRange=ThisQuarter). Returns the same underlying data as list_proposals — both aliases back the same Engagement entity; the status filter narrows the pipeline stage. For 'how many X?' questions, pass limit=0 for count-only. Follow up with get_engagement_letter_summary for full detail on one engagement.",
|
|
2169
|
+
inputSchema: ListEngagementsInputSchema,
|
|
2170
|
+
annotations: {
|
|
2171
|
+
readOnlyHint: true,
|
|
2172
|
+
idempotentHint: true,
|
|
2173
|
+
openWorldHint: true
|
|
2174
|
+
}
|
|
2175
|
+
}, (args) => handleListEngagements(api, args));
|
|
2176
|
+
server.registerTool("get_proposal_summary", {
|
|
2177
|
+
title: "Get full detail of one proposal",
|
|
2178
|
+
description: "Get a consolidated view of a single proposal by engagement code: identity (code, status, type, date), client, recipient, value breakdown (annual / quarterly / monthly / one-off / total), the proposed services with pricing, acceptance record (if accepted), PDF availability (proposal + letter of engagement), email history, and the acceptance link. REQUIRES a concrete engagement code as input — do NOT call this tool unless you already have the code. If the user describes an engagement by attribute — e.g. 'the unsent proposal', 'the proposal for ACME', 'our most recent proposal', 'the rejected one' — you MUST call list_proposals FIRST with the matching filters to look up the code, then call this tool with that code. Returns identical data to get_engagement_letter_summary — both aliases back the same Engagement entity. Tolerates partial failures (email history may be missing with a note); only fails hard if the engagement itself can't be fetched.",
|
|
2179
|
+
inputSchema: GetEngagementSummaryInputSchema,
|
|
2180
|
+
annotations: {
|
|
2181
|
+
readOnlyHint: true,
|
|
2182
|
+
idempotentHint: true,
|
|
2183
|
+
openWorldHint: true
|
|
2184
|
+
}
|
|
2185
|
+
}, (args) => handleGetEngagementSummary(api, args));
|
|
2186
|
+
server.registerTool("get_engagement_letter_summary", {
|
|
2187
|
+
title: "Get full detail of one letter of engagement",
|
|
2188
|
+
description: "Get a consolidated view of a single letter of engagement by code: identity (code, status, type, date), client, recipient, value breakdown (annual / quarterly / monthly / one-off / total), services included with pricing, acceptance record (accepted date, whether accepted via portal or recorded manually), PDF availability (proposal + letter of engagement), email history, and the acceptance link. REQUIRES a concrete engagement code as input — do NOT call this tool unless you already have the code. If the user describes the engagement by attribute — e.g. 'ACME's letter of engagement', 'the most recent signed engagement', 'the live engagement for Smith & Co' — you MUST call list_engagement_letters FIRST with the matching filters to look up the code, then call this tool with that code. Returns identical data to get_proposal_summary — both aliases back the same Engagement entity. Tolerates partial failures (email history may be missing with a note); only fails hard if the engagement itself can't be fetched.",
|
|
2189
|
+
inputSchema: GetEngagementSummaryInputSchema,
|
|
2190
|
+
annotations: {
|
|
2191
|
+
readOnlyHint: true,
|
|
2192
|
+
idempotentHint: true,
|
|
2193
|
+
openWorldHint: true
|
|
2194
|
+
}
|
|
2195
|
+
}, (args) => handleGetEngagementSummary(api, args));
|
|
1854
2196
|
server.registerTool("list_users", {
|
|
1855
2197
|
title: "List / search / filter tenant users",
|
|
1856
2198
|
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.",
|