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