@countfinancial/cli 0.1.6 → 0.1.8
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/dist/partner-mcp/config.js +1 -1
- package/dist/partner-mcp/helpers/mcpKnowledge.helper.d.ts +19 -0
- package/dist/partner-mcp/helpers/mcpKnowledge.helper.js +293 -0
- package/dist/partner-mcp/helpers/mcpKnowledge.helper.js.map +1 -0
- package/dist/partner-mcp/helpers/mcpPayloadValidation.helper.d.ts +42 -0
- package/dist/partner-mcp/helpers/mcpPayloadValidation.helper.js +741 -0
- package/dist/partner-mcp/helpers/mcpPayloadValidation.helper.js.map +1 -0
- package/dist/partner-mcp/helpers/mcpPlaybooks.helper.d.ts +26 -0
- package/dist/partner-mcp/helpers/mcpPlaybooks.helper.js +210 -0
- package/dist/partner-mcp/helpers/mcpPlaybooks.helper.js.map +1 -0
- package/dist/partner-mcp/helpers/mcpRecoveryHint.helper.d.ts +22 -0
- package/dist/partner-mcp/helpers/mcpRecoveryHint.helper.js +203 -0
- package/dist/partner-mcp/helpers/mcpRecoveryHint.helper.js.map +1 -0
- package/dist/partner-mcp/helpers/mcpReferenceResolution.helper.d.ts +52 -0
- package/dist/partner-mcp/helpers/mcpReferenceResolution.helper.js +343 -0
- package/dist/partner-mcp/helpers/mcpReferenceResolution.helper.js.map +1 -0
- package/dist/partner-mcp/helpers/partnerErrorSanitization.helper.d.ts +18 -0
- package/dist/partner-mcp/helpers/partnerErrorSanitization.helper.js +63 -0
- package/dist/partner-mcp/helpers/partnerErrorSanitization.helper.js.map +1 -0
- package/dist/partner-mcp/partnerApiClient.d.ts +4 -0
- package/dist/partner-mcp/partnerApiClient.js +38 -1
- package/dist/partner-mcp/partnerApiClient.js.map +1 -1
- package/dist/partner-mcp/tools/definitions.js +233 -12
- package/dist/partner-mcp/tools/definitions.js.map +1 -1
- package/dist/partner-mcp/tools/registerTools.js +107 -23
- package/dist/partner-mcp/tools/registerTools.js.map +1 -1
- package/dist/partner-mcp/tools/schemas.d.ts +40 -0
- package/dist/partner-mcp/tools/schemas.js +55 -0
- package/dist/partner-mcp/tools/schemas.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import { getToolDefinition } from '../tools/definitions.js';
|
|
2
|
+
import { buildMcpRecoveryHint } from './mcpRecoveryHint.helper.js';
|
|
3
|
+
/** Keep in sync with `MAX_PARTNER_BULK_BATCH_SIZE` in `src/app/helpers/partnerBulk.helper.ts`. */
|
|
4
|
+
const MAX_PARTNER_BULK_BATCH_SIZE = 100;
|
|
5
|
+
const QUERY_VALIDATION_READ_TOOLS = new Set(['COUNT_list_bills']);
|
|
6
|
+
const LOCAL_GUIDANCE_TOOLS = new Set([
|
|
7
|
+
'COUNT_auth_status',
|
|
8
|
+
'COUNT_describe_endpoint',
|
|
9
|
+
'COUNT_knowledge',
|
|
10
|
+
'COUNT_playbooks',
|
|
11
|
+
'COUNT_resolve_references',
|
|
12
|
+
'COUNT_validate_payload',
|
|
13
|
+
'COUNT_refresh_access_token',
|
|
14
|
+
]);
|
|
15
|
+
const BULK_TOOL_BODY_FIELDS = {
|
|
16
|
+
COUNT_bulk_create_transactions: { bodyField: 'transactions', itemLabel: 'transaction' },
|
|
17
|
+
COUNT_bulk_create_customers: { bodyField: 'customers', itemLabel: 'customer' },
|
|
18
|
+
COUNT_bulk_update_customers: { bodyField: 'customers', itemLabel: 'customer' },
|
|
19
|
+
COUNT_bulk_create_journal_entries: {
|
|
20
|
+
bodyField: 'journalEntries',
|
|
21
|
+
itemLabel: 'journal entry',
|
|
22
|
+
itemPluralLabel: 'journal entries',
|
|
23
|
+
},
|
|
24
|
+
COUNT_bulk_update_budget_cells: {
|
|
25
|
+
bodyField: 'updates',
|
|
26
|
+
itemLabel: 'budget cell update',
|
|
27
|
+
itemPluralLabel: 'budget cell updates',
|
|
28
|
+
},
|
|
29
|
+
COUNT_update_budget_cells: {
|
|
30
|
+
bodyField: 'updates',
|
|
31
|
+
itemLabel: 'budget cell update',
|
|
32
|
+
itemPluralLabel: 'budget cell updates',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const BILL_LEGACY_BODY_FIELDS = [
|
|
36
|
+
{ bodyField: 'vendorId', uuidField: 'vendorUuid', entityLabel: 'from list_vendors' },
|
|
37
|
+
{ bodyField: 'projectId', uuidField: 'projectUuid', entityLabel: 'from list_projects' },
|
|
38
|
+
{ bodyField: 'tags', uuidField: 'tagUuids', entityLabel: 'from list_tags' },
|
|
39
|
+
];
|
|
40
|
+
const BILL_LIST_LEGACY_QUERY_FIELDS = [
|
|
41
|
+
{ queryField: 'vendors', uuidQueryField: 'vendorUuids' },
|
|
42
|
+
{ queryField: 'projects', uuidQueryField: 'projectUuids' },
|
|
43
|
+
];
|
|
44
|
+
const UUID_FIELD_PATTERN = /^(.*)(Uuid|Uuids)$|^accUuid$|^id$|^uuid$|^categoryAccountUuid$|^categoryAccountUuids$|^accountUuid$/;
|
|
45
|
+
export function validateMcpPayload(params) {
|
|
46
|
+
const { toolName, body, query, verifyReferences = false, client } = params;
|
|
47
|
+
const issues = [];
|
|
48
|
+
const toolDefinition = getToolDefinition({ toolName });
|
|
49
|
+
if (!toolDefinition) {
|
|
50
|
+
issues.push({
|
|
51
|
+
path: 'toolName',
|
|
52
|
+
code: 'invalid_tool',
|
|
53
|
+
message: `Unknown tool "${toolName}". Use COUNT_describe_endpoint to list valid tool names.`,
|
|
54
|
+
});
|
|
55
|
+
return buildValidationResult({ toolName, issues });
|
|
56
|
+
}
|
|
57
|
+
if (LOCAL_GUIDANCE_TOOLS.has(toolName)) {
|
|
58
|
+
issues.push({
|
|
59
|
+
path: 'toolName',
|
|
60
|
+
code: 'invalid_tool',
|
|
61
|
+
message: `COUNT_validate_payload targets mutation tools only. "${toolName}" is read-only or a guidance tool.`,
|
|
62
|
+
});
|
|
63
|
+
return buildValidationResult({ toolName, issues });
|
|
64
|
+
}
|
|
65
|
+
if (toolDefinition.readOnly && !QUERY_VALIDATION_READ_TOOLS.has(toolName)) {
|
|
66
|
+
issues.push({
|
|
67
|
+
path: 'toolName',
|
|
68
|
+
code: 'invalid_tool',
|
|
69
|
+
message: `COUNT_validate_payload targets mutation tools only. "${toolName}" is read-only or a guidance tool.`,
|
|
70
|
+
});
|
|
71
|
+
return buildValidationResult({ toolName, issues });
|
|
72
|
+
}
|
|
73
|
+
validateBulkEnvelope({ toolName, body, issues });
|
|
74
|
+
validateLegacyBillListQuery({ toolName, query, issues });
|
|
75
|
+
validateLegacyBillBodyFields({ toolName, body, issues });
|
|
76
|
+
validateRequiredFieldsForTool({ toolName, body, issues });
|
|
77
|
+
void verifyReferences;
|
|
78
|
+
void client;
|
|
79
|
+
return buildValidationResult({ toolName, issues });
|
|
80
|
+
}
|
|
81
|
+
function buildValidationResult(params) {
|
|
82
|
+
const { toolName, issues } = params;
|
|
83
|
+
const suggestedPlaybook = toolName === 'COUNT_bulk_update_budget_cells' || toolName === 'COUNT_update_budget_cells'
|
|
84
|
+
? 'budget_import'
|
|
85
|
+
: BULK_TOOL_BODY_FIELDS[toolName]
|
|
86
|
+
? 'migration_import'
|
|
87
|
+
: undefined;
|
|
88
|
+
const firstIssueMessage = issues[0]?.message;
|
|
89
|
+
const recoveryHint = firstIssueMessage
|
|
90
|
+
? buildMcpRecoveryHint({ message: firstIssueMessage, toolName })
|
|
91
|
+
: undefined;
|
|
92
|
+
return {
|
|
93
|
+
valid: issues.length === 0,
|
|
94
|
+
toolName,
|
|
95
|
+
issueCount: issues.length,
|
|
96
|
+
issues,
|
|
97
|
+
suggestedPlaybook,
|
|
98
|
+
_mcpRecoveryHint: recoveryHint,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function validateBulkEnvelope(params) {
|
|
102
|
+
const { toolName, body, issues } = params;
|
|
103
|
+
const bulkRule = BULK_TOOL_BODY_FIELDS[toolName];
|
|
104
|
+
if (!bulkRule || !body)
|
|
105
|
+
return;
|
|
106
|
+
const items = body[bulkRule.bodyField];
|
|
107
|
+
const itemPluralLabel = bulkRule.itemPluralLabel ?? `${bulkRule.itemLabel}s`;
|
|
108
|
+
if (!Array.isArray(items)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
path: `body.${bulkRule.bodyField}`,
|
|
111
|
+
code: 'bulk_envelope',
|
|
112
|
+
message: `\`${bulkRule.bodyField}\` must be a non-empty array of ${itemPluralLabel}.`,
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (items.length === 0) {
|
|
117
|
+
issues.push({
|
|
118
|
+
path: `body.${bulkRule.bodyField}`,
|
|
119
|
+
code: 'bulk_envelope',
|
|
120
|
+
message: `\`${bulkRule.bodyField}\` must contain at least one ${bulkRule.itemLabel}.`,
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (items.length > MAX_PARTNER_BULK_BATCH_SIZE) {
|
|
125
|
+
issues.push({
|
|
126
|
+
path: `body.${bulkRule.bodyField}`,
|
|
127
|
+
code: 'bulk_envelope',
|
|
128
|
+
message: `Bulk requests are capped at ${MAX_PARTNER_BULK_BATCH_SIZE} ${itemPluralLabel} per call (received ${items.length}). Split the import into multiple calls.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function validateLegacyBillListQuery(params) {
|
|
133
|
+
const { toolName, query, issues } = params;
|
|
134
|
+
if (toolName !== 'COUNT_list_bills' || !query)
|
|
135
|
+
return;
|
|
136
|
+
for (const legacyRule of BILL_LIST_LEGACY_QUERY_FIELDS) {
|
|
137
|
+
const legacyValue = query[legacyRule.queryField];
|
|
138
|
+
if (legacyValue === undefined || legacyValue === null || String(legacyValue).trim() === '')
|
|
139
|
+
continue;
|
|
140
|
+
issues.push({
|
|
141
|
+
path: `query.${legacyRule.queryField}`,
|
|
142
|
+
code: 'legacy_numeric_field',
|
|
143
|
+
message: `Use \`${legacyRule.uuidQueryField}\`; numeric \`${legacyRule.queryField}\` is not accepted on partner bill list requests.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function validateLegacyBillBodyFields(params) {
|
|
148
|
+
const { toolName, body, issues } = params;
|
|
149
|
+
if (!body || (toolName !== 'COUNT_create_bill' && toolName !== 'COUNT_update_bill'))
|
|
150
|
+
return;
|
|
151
|
+
for (const legacyRule of BILL_LEGACY_BODY_FIELDS) {
|
|
152
|
+
if (!Object.prototype.hasOwnProperty.call(body, legacyRule.bodyField))
|
|
153
|
+
continue;
|
|
154
|
+
if (Object.prototype.hasOwnProperty.call(body, legacyRule.uuidField))
|
|
155
|
+
continue;
|
|
156
|
+
const legacyBodyValue = body[legacyRule.bodyField];
|
|
157
|
+
if (legacyRule.bodyField === 'tags' &&
|
|
158
|
+
Array.isArray(legacyBodyValue) &&
|
|
159
|
+
legacyBodyValue.length === 0) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
issues.push({
|
|
163
|
+
path: `body.${legacyRule.bodyField}`,
|
|
164
|
+
code: 'legacy_numeric_field',
|
|
165
|
+
message: `Use \`${legacyRule.uuidField}\` (${legacyRule.entityLabel}); numeric \`${legacyRule.bodyField}\` is not accepted on partner bill requests.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function validateRequiredFieldsForTool(params) {
|
|
170
|
+
const { toolName, body, issues } = params;
|
|
171
|
+
if (!body)
|
|
172
|
+
return;
|
|
173
|
+
if (toolName === 'COUNT_bulk_create_transactions') {
|
|
174
|
+
validateBulkTransactionRows({ body, issues });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (toolName === 'COUNT_bulk_create_customers') {
|
|
178
|
+
validateBulkCustomerRows({ body, issues });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (toolName === 'COUNT_bulk_update_customers') {
|
|
182
|
+
validateBulkUpdateCustomerRows({ body, issues });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (toolName === 'COUNT_bulk_create_journal_entries') {
|
|
186
|
+
validateBulkJournalEntryRows({ body, issues });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (toolName === 'COUNT_bulk_update_budget_cells' || toolName === 'COUNT_update_budget_cells') {
|
|
190
|
+
validateBulkBudgetCellRows({ body, issues });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (toolName === 'COUNT_create_bill') {
|
|
194
|
+
validateCreateBillBody({ body, issues });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function validateBulkTransactionRows(params) {
|
|
198
|
+
const { body, issues } = params;
|
|
199
|
+
const transactions = body.transactions;
|
|
200
|
+
if (!Array.isArray(transactions))
|
|
201
|
+
return;
|
|
202
|
+
transactions.forEach((_transaction, index) => {
|
|
203
|
+
if (!_transaction || typeof _transaction !== 'object') {
|
|
204
|
+
issues.push({
|
|
205
|
+
path: `body.transactions[${index}]`,
|
|
206
|
+
code: 'missing_required',
|
|
207
|
+
message: 'Each transaction row must be an object.',
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const transactionObject = _transaction;
|
|
212
|
+
if (!hasNonEmptyString(transactionObject.accUuid)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path: `body.transactions[${index}].accUuid`,
|
|
215
|
+
code: 'missing_required',
|
|
216
|
+
message: 'Required field accUuid (bank account UUID from list_accounts) is missing.',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (transactionObject.amount === undefined || transactionObject.amount === null) {
|
|
220
|
+
issues.push({
|
|
221
|
+
path: `body.transactions[${index}].amount`,
|
|
222
|
+
code: 'missing_required',
|
|
223
|
+
message: 'Required field amount is missing.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (!hasNonEmptyString(transactionObject.postedDate) && !hasNonEmptyString(transactionObject.date)) {
|
|
227
|
+
issues.push({
|
|
228
|
+
path: `body.transactions[${index}].postedDate`,
|
|
229
|
+
code: 'missing_required',
|
|
230
|
+
message: 'Required field postedDate or date (YYYY-MM-DD) is missing.',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (Object.prototype.hasOwnProperty.call(transactionObject, 'vendorId') && !transactionObject.vendorUuid) {
|
|
234
|
+
issues.push({
|
|
235
|
+
path: `body.transactions[${index}].vendorId`,
|
|
236
|
+
code: 'legacy_numeric_field',
|
|
237
|
+
message: 'Use vendorUuid from list_vendors instead of numeric vendorId.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function validateBulkCustomerRows(params) {
|
|
243
|
+
const { body, issues } = params;
|
|
244
|
+
const customers = body.customers;
|
|
245
|
+
if (!Array.isArray(customers))
|
|
246
|
+
return;
|
|
247
|
+
customers.forEach((_customer, index) => {
|
|
248
|
+
if (!_customer || typeof _customer !== 'object') {
|
|
249
|
+
issues.push({
|
|
250
|
+
path: `body.customers[${index}]`,
|
|
251
|
+
code: 'missing_required',
|
|
252
|
+
message: 'Each customer row must be an object.',
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const customerObject = _customer;
|
|
257
|
+
if (!hasNonEmptyString(customerObject.customer)) {
|
|
258
|
+
issues.push({
|
|
259
|
+
path: `body.customers[${index}].customer`,
|
|
260
|
+
code: 'missing_required',
|
|
261
|
+
message: 'Required field customer (display name) is missing.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function validateBulkUpdateCustomerRows(params) {
|
|
267
|
+
const { body, issues } = params;
|
|
268
|
+
const customers = body.customers;
|
|
269
|
+
if (!Array.isArray(customers))
|
|
270
|
+
return;
|
|
271
|
+
customers.forEach((_customer, index) => {
|
|
272
|
+
if (!_customer || typeof _customer !== 'object') {
|
|
273
|
+
issues.push({
|
|
274
|
+
path: `body.customers[${index}]`,
|
|
275
|
+
code: 'missing_required',
|
|
276
|
+
message: 'Each customer row must be an object.',
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const customerObject = _customer;
|
|
281
|
+
if (!hasNonEmptyString(customerObject.uuid)) {
|
|
282
|
+
issues.push({
|
|
283
|
+
path: `body.customers[${index}].uuid`,
|
|
284
|
+
code: 'missing_required',
|
|
285
|
+
message: 'Each customer update row requires uuid (external customer id from list_customers).',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function validateBulkJournalEntryRows(params) {
|
|
291
|
+
const { body, issues } = params;
|
|
292
|
+
const journalEntries = body.journalEntries;
|
|
293
|
+
if (!Array.isArray(journalEntries))
|
|
294
|
+
return;
|
|
295
|
+
journalEntries.forEach((_journalEntry, index) => {
|
|
296
|
+
if (!_journalEntry || typeof _journalEntry !== 'object') {
|
|
297
|
+
issues.push({
|
|
298
|
+
path: `body.journalEntries[${index}]`,
|
|
299
|
+
code: 'missing_required',
|
|
300
|
+
message: 'Each journal entry row must be an object.',
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const journalEntryObject = _journalEntry;
|
|
305
|
+
if (!hasNonEmptyString(journalEntryObject.descriptionEntry)) {
|
|
306
|
+
issues.push({
|
|
307
|
+
path: `body.journalEntries[${index}].descriptionEntry`,
|
|
308
|
+
code: 'missing_required',
|
|
309
|
+
message: 'Required field descriptionEntry is missing.',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!hasNonEmptyString(journalEntryObject.date)) {
|
|
313
|
+
issues.push({
|
|
314
|
+
path: `body.journalEntries[${index}].date`,
|
|
315
|
+
code: 'missing_required',
|
|
316
|
+
message: 'Required field date (YYYY-MM-DD) is missing.',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (!Array.isArray(journalEntryObject.lines) || journalEntryObject.lines.length === 0) {
|
|
320
|
+
issues.push({
|
|
321
|
+
path: `body.journalEntries[${index}].lines`,
|
|
322
|
+
code: 'missing_required',
|
|
323
|
+
message: 'Required field lines must be a non-empty array.',
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
journalEntryObject.lines.forEach((_line, lineIndex) => {
|
|
328
|
+
if (!_line || typeof _line !== 'object') {
|
|
329
|
+
issues.push({
|
|
330
|
+
path: `body.journalEntries[${index}].lines[${lineIndex}]`,
|
|
331
|
+
code: 'missing_required',
|
|
332
|
+
message: 'Each journal line must be an object.',
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const lineObject = _line;
|
|
337
|
+
if (!hasNonEmptyString(lineObject.accountUuid)) {
|
|
338
|
+
issues.push({
|
|
339
|
+
path: `body.journalEntries[${index}].lines[${lineIndex}].accountUuid`,
|
|
340
|
+
code: 'missing_required',
|
|
341
|
+
message: 'Each line requires accountUuid from list_accounts.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function validateBulkBudgetCellRows(params) {
|
|
348
|
+
const { body, issues } = params;
|
|
349
|
+
const updates = body.updates;
|
|
350
|
+
if (!Array.isArray(updates))
|
|
351
|
+
return;
|
|
352
|
+
updates.forEach((_update, index) => {
|
|
353
|
+
if (!_update || typeof _update !== 'object') {
|
|
354
|
+
issues.push({
|
|
355
|
+
path: `body.updates[${index}]`,
|
|
356
|
+
code: 'missing_required',
|
|
357
|
+
message: 'Each budget cell update row must be an object.',
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const updateObject = _update;
|
|
362
|
+
if (Object.prototype.hasOwnProperty.call(updateObject, 'accountId') &&
|
|
363
|
+
!Object.prototype.hasOwnProperty.call(updateObject, 'accountUuid')) {
|
|
364
|
+
issues.push({
|
|
365
|
+
path: `body.updates[${index}].accountId`,
|
|
366
|
+
code: 'legacy_numeric_field',
|
|
367
|
+
message: 'Use accountUuid from list_accounts; numeric accountId is not accepted on budget cell updates.',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (!hasNonEmptyString(updateObject.accountUuid)) {
|
|
371
|
+
issues.push({
|
|
372
|
+
path: `body.updates[${index}].accountUuid`,
|
|
373
|
+
code: 'missing_required',
|
|
374
|
+
message: 'Required field accountUuid (P&L account UUID from list_accounts) is missing.',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (!hasNonEmptyString(updateObject.periodStart)) {
|
|
378
|
+
issues.push({
|
|
379
|
+
path: `body.updates[${index}].periodStart`,
|
|
380
|
+
code: 'missing_required',
|
|
381
|
+
message: 'Required field periodStart (YYYY-MM-DD budget column) is missing.',
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (updateObject.amount === undefined || updateObject.amount === null) {
|
|
385
|
+
issues.push({
|
|
386
|
+
path: `body.updates[${index}].amount`,
|
|
387
|
+
code: 'missing_required',
|
|
388
|
+
message: 'Required field amount is missing.',
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const parsedAmount = typeof updateObject.amount === 'number'
|
|
393
|
+
? updateObject.amount
|
|
394
|
+
: parseFloat(`${updateObject.amount}`.trim());
|
|
395
|
+
if (!Number.isFinite(parsedAmount)) {
|
|
396
|
+
issues.push({
|
|
397
|
+
path: `body.updates[${index}].amount`,
|
|
398
|
+
code: 'missing_required',
|
|
399
|
+
message: 'Required field amount must be a valid number.',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function validateCreateBillBody(params) {
|
|
405
|
+
const { body, issues } = params;
|
|
406
|
+
if (!hasNonEmptyString(body.vendorUuid)) {
|
|
407
|
+
issues.push({
|
|
408
|
+
path: 'body.vendorUuid',
|
|
409
|
+
code: 'missing_required',
|
|
410
|
+
message: 'Required field vendorUuid (from list_vendors) is missing.',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (!hasNonEmptyString(body.date)) {
|
|
414
|
+
issues.push({
|
|
415
|
+
path: 'body.date',
|
|
416
|
+
code: 'missing_required',
|
|
417
|
+
message: 'Required field date (YYYY-MM-DD) is missing.',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (!hasNonEmptyString(body.dueDate)) {
|
|
421
|
+
issues.push({
|
|
422
|
+
path: 'body.dueDate',
|
|
423
|
+
code: 'missing_required',
|
|
424
|
+
message: 'Required field dueDate (YYYY-MM-DD) is missing.',
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (!Array.isArray(body.lineItems) || body.lineItems.length === 0) {
|
|
428
|
+
issues.push({
|
|
429
|
+
path: 'body.lineItems',
|
|
430
|
+
code: 'missing_required',
|
|
431
|
+
message: 'Required field lineItems must be a non-empty array.',
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
body.lineItems.forEach((_lineItem, index) => {
|
|
436
|
+
if (!_lineItem || typeof _lineItem !== 'object') {
|
|
437
|
+
issues.push({
|
|
438
|
+
path: `body.lineItems[${index}]`,
|
|
439
|
+
code: 'missing_required',
|
|
440
|
+
message: 'Each line item must be an object.',
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const lineItemObject = _lineItem;
|
|
445
|
+
if (!hasNonEmptyString(lineItemObject.categoryAccountUuid)) {
|
|
446
|
+
issues.push({
|
|
447
|
+
path: `body.lineItems[${index}].categoryAccountUuid`,
|
|
448
|
+
code: 'missing_required',
|
|
449
|
+
message: 'Each line item requires categoryAccountUuid from list_accounts.',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (Object.prototype.hasOwnProperty.call(lineItemObject, 'categoryAccountId')) {
|
|
453
|
+
issues.push({
|
|
454
|
+
path: `body.lineItems[${index}].categoryAccountId`,
|
|
455
|
+
code: 'legacy_numeric_field',
|
|
456
|
+
message: 'Use categoryAccountUuid from list_accounts instead of categoryAccountId.',
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (!hasRequiredNumericField(lineItemObject.quantity)) {
|
|
460
|
+
issues.push({
|
|
461
|
+
path: `body.lineItems[${index}].quantity`,
|
|
462
|
+
code: 'missing_required',
|
|
463
|
+
message: 'Each line item requires quantity.',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (!hasRequiredNumericField(lineItemObject.price)) {
|
|
467
|
+
issues.push({
|
|
468
|
+
path: `body.lineItems[${index}].price`,
|
|
469
|
+
code: 'missing_required',
|
|
470
|
+
message: 'Each line item requires price.',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function hasRequiredNumericField(value) {
|
|
476
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
function hasNonEmptyString(value) {
|
|
485
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
486
|
+
}
|
|
487
|
+
/** Walk a payload tree and collect string values on UUID-shaped field names for optional existence checks. */
|
|
488
|
+
export function collectUuidReferencesFromPayload(params) {
|
|
489
|
+
const references = [];
|
|
490
|
+
if (params.body) {
|
|
491
|
+
walkValueForUuidReferences({ value: params.body, currentPath: 'body', references });
|
|
492
|
+
}
|
|
493
|
+
if (params.query) {
|
|
494
|
+
walkValueForUuidReferences({ value: params.query, currentPath: 'query', references });
|
|
495
|
+
}
|
|
496
|
+
return references;
|
|
497
|
+
}
|
|
498
|
+
function walkValueForUuidReferences(params) {
|
|
499
|
+
const { value, currentPath, references } = params;
|
|
500
|
+
if (Array.isArray(value)) {
|
|
501
|
+
value.forEach((_item, index) => {
|
|
502
|
+
walkValueForUuidReferences({ value: _item, currentPath: `${currentPath}[${index}]`, references });
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!value || typeof value !== 'object')
|
|
507
|
+
return;
|
|
508
|
+
const valueObject = value;
|
|
509
|
+
for (const [fieldName, fieldValue] of Object.entries(valueObject)) {
|
|
510
|
+
const fieldPath = `${currentPath}.${fieldName}`;
|
|
511
|
+
if (typeof fieldValue === 'string' && fieldValue.trim() !== '' && UUID_FIELD_PATTERN.test(fieldName)) {
|
|
512
|
+
if (fieldName.endsWith('Uuids') && fieldValue.includes(',')) {
|
|
513
|
+
fieldValue.split(',').forEach((_uuidSegment, index) => {
|
|
514
|
+
const trimmedUuidSegment = _uuidSegment.trim();
|
|
515
|
+
if (trimmedUuidSegment !== '') {
|
|
516
|
+
references.push({ path: `${fieldPath}[${index}]`, uuid: trimmedUuidSegment });
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
references.push({ path: fieldPath, uuid: fieldValue.trim() });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if (Array.isArray(fieldValue) && fieldName.endsWith('Uuids')) {
|
|
525
|
+
fieldValue.forEach((_uuidValue, index) => {
|
|
526
|
+
if (typeof _uuidValue === 'string' && _uuidValue.trim() !== '') {
|
|
527
|
+
references.push({ path: `${fieldPath}[${index}]`, uuid: _uuidValue.trim() });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
walkValueForUuidReferences({ value: fieldValue, currentPath: fieldPath, references });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function canonicalizeUuidForPartnerLookup(uuid) {
|
|
537
|
+
return uuid.trim().toLowerCase();
|
|
538
|
+
}
|
|
539
|
+
function classifyUuidReferenceFieldPath(params) {
|
|
540
|
+
const { fieldPath } = params;
|
|
541
|
+
if (fieldPath.includes('customerUuid') || /\.customers\[\d+\]\.uuid$/.test(fieldPath)) {
|
|
542
|
+
return 'customer';
|
|
543
|
+
}
|
|
544
|
+
if (fieldPath.includes('projectUuid')) {
|
|
545
|
+
return 'project';
|
|
546
|
+
}
|
|
547
|
+
if (fieldPath.includes('tagUuid')) {
|
|
548
|
+
return 'tag';
|
|
549
|
+
}
|
|
550
|
+
if (fieldPath.includes('vendorUuid')) {
|
|
551
|
+
return 'vendor';
|
|
552
|
+
}
|
|
553
|
+
if (fieldPath.includes('accUuid') ||
|
|
554
|
+
fieldPath.includes('categoryAccountUuid') ||
|
|
555
|
+
fieldPath.includes('accountUuid')) {
|
|
556
|
+
return 'account';
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
function extractPartnerListRecords(params) {
|
|
561
|
+
const { response, recordsKey } = params;
|
|
562
|
+
if (!response || typeof response !== 'object') {
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
const responseObject = response;
|
|
566
|
+
const directArray = responseObject[recordsKey];
|
|
567
|
+
if (Array.isArray(directArray)) {
|
|
568
|
+
return directArray;
|
|
569
|
+
}
|
|
570
|
+
const dataObject = responseObject.data;
|
|
571
|
+
if (dataObject && typeof dataObject === 'object') {
|
|
572
|
+
const nestedArray = dataObject[recordsKey];
|
|
573
|
+
if (Array.isArray(nestedArray)) {
|
|
574
|
+
return nestedArray;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
const PARTNER_REFERENCE_LIST_PAGE_LIMIT = 5000;
|
|
580
|
+
const PARTNER_REFERENCE_LIST_MAX_PAGES = 200;
|
|
581
|
+
function extractPartnerListTotalRecords(params) {
|
|
582
|
+
const { response } = params;
|
|
583
|
+
if (!response || typeof response !== 'object') {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
const responseObject = response;
|
|
587
|
+
if (typeof responseObject.totalRecords === 'number' && Number.isFinite(responseObject.totalRecords)) {
|
|
588
|
+
return responseObject.totalRecords;
|
|
589
|
+
}
|
|
590
|
+
const dataObject = responseObject.data;
|
|
591
|
+
if (dataObject && typeof dataObject === 'object') {
|
|
592
|
+
const nestedTotalRecords = dataObject.totalRecords;
|
|
593
|
+
if (typeof nestedTotalRecords === 'number' && Number.isFinite(nestedTotalRecords)) {
|
|
594
|
+
return nestedTotalRecords;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
async function loadPartnerRecordUuidSet(params) {
|
|
600
|
+
const { client, path, recordsKey, uuidFieldNames } = params;
|
|
601
|
+
const uuidSet = new Set();
|
|
602
|
+
let page = 1;
|
|
603
|
+
let totalRecords;
|
|
604
|
+
let fetchedRecordCount = 0;
|
|
605
|
+
while (page <= PARTNER_REFERENCE_LIST_MAX_PAGES) {
|
|
606
|
+
const response = await client.request({
|
|
607
|
+
method: 'GET',
|
|
608
|
+
path,
|
|
609
|
+
query: { page, limit: PARTNER_REFERENCE_LIST_PAGE_LIMIT },
|
|
610
|
+
requiresUserAuth: true,
|
|
611
|
+
});
|
|
612
|
+
if (totalRecords === undefined) {
|
|
613
|
+
totalRecords = extractPartnerListTotalRecords({ response });
|
|
614
|
+
}
|
|
615
|
+
const pageRecords = extractPartnerListRecords({ response, recordsKey });
|
|
616
|
+
for (const rawRecord of pageRecords) {
|
|
617
|
+
if (!rawRecord || typeof rawRecord !== 'object') {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const recordObject = rawRecord;
|
|
621
|
+
for (const uuidFieldName of uuidFieldNames) {
|
|
622
|
+
const fieldValue = recordObject[uuidFieldName];
|
|
623
|
+
if (typeof fieldValue === 'string' && fieldValue.trim() !== '') {
|
|
624
|
+
uuidSet.add(canonicalizeUuidForPartnerLookup(fieldValue));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
fetchedRecordCount += pageRecords.length;
|
|
629
|
+
if (pageRecords.length === 0) {
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
if (pageRecords.length < PARTNER_REFERENCE_LIST_PAGE_LIMIT) {
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
if (totalRecords !== undefined && fetchedRecordCount >= totalRecords) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
page += 1;
|
|
639
|
+
}
|
|
640
|
+
return uuidSet;
|
|
641
|
+
}
|
|
642
|
+
async function probePartnerResourceByUuid(params) {
|
|
643
|
+
const { client, pathTemplate, uuid } = params;
|
|
644
|
+
const canonicalUuid = canonicalizeUuidForPartnerLookup(uuid);
|
|
645
|
+
try {
|
|
646
|
+
await client.request({
|
|
647
|
+
method: 'GET',
|
|
648
|
+
path: pathTemplate.replace('{uuid}', encodeURIComponent(canonicalUuid)),
|
|
649
|
+
requiresUserAuth: true,
|
|
650
|
+
});
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Path-aware UUID existence checks using partner routes that actually exist. */
|
|
658
|
+
export async function verifyUuidReferencesExist(params) {
|
|
659
|
+
const { client, references } = params;
|
|
660
|
+
const issues = [];
|
|
661
|
+
const seenReferenceKeys = new Set();
|
|
662
|
+
const needsVendorList = references.some((_reference) => classifyUuidReferenceFieldPath({ fieldPath: _reference.path }) === 'vendor');
|
|
663
|
+
const needsAccountList = references.some((_reference) => classifyUuidReferenceFieldPath({ fieldPath: _reference.path }) === 'account');
|
|
664
|
+
const [vendorUuidSet, accountUuidSet] = await Promise.all([
|
|
665
|
+
needsVendorList
|
|
666
|
+
? loadPartnerRecordUuidSet({
|
|
667
|
+
client,
|
|
668
|
+
path: '/partners/vendors',
|
|
669
|
+
recordsKey: 'vendors',
|
|
670
|
+
uuidFieldNames: ['id', 'uuid'],
|
|
671
|
+
})
|
|
672
|
+
: Promise.resolve(new Set()),
|
|
673
|
+
needsAccountList
|
|
674
|
+
? loadPartnerRecordUuidSet({
|
|
675
|
+
client,
|
|
676
|
+
path: '/partners/chart-of-accounts',
|
|
677
|
+
recordsKey: 'accounts',
|
|
678
|
+
uuidFieldNames: ['id', 'uuid', 'accUuid', 'accountUuid'],
|
|
679
|
+
})
|
|
680
|
+
: Promise.resolve(new Set()),
|
|
681
|
+
]);
|
|
682
|
+
for (const reference of references) {
|
|
683
|
+
const referenceKey = `${reference.path}:${reference.uuid}`;
|
|
684
|
+
if (seenReferenceKeys.has(referenceKey)) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
seenReferenceKeys.add(referenceKey);
|
|
688
|
+
const verificationKind = classifyUuidReferenceFieldPath({ fieldPath: reference.path });
|
|
689
|
+
if (!verificationKind) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
const canonicalUuid = canonicalizeUuidForPartnerLookup(reference.uuid);
|
|
693
|
+
let exists = false;
|
|
694
|
+
if (verificationKind === 'customer') {
|
|
695
|
+
exists = await probePartnerResourceByUuid({
|
|
696
|
+
client,
|
|
697
|
+
pathTemplate: '/partners/customers/{uuid}',
|
|
698
|
+
uuid: reference.uuid,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
else if (verificationKind === 'project') {
|
|
702
|
+
exists = await probePartnerResourceByUuid({
|
|
703
|
+
client,
|
|
704
|
+
pathTemplate: '/partners/projects/{uuid}',
|
|
705
|
+
uuid: reference.uuid,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
else if (verificationKind === 'tag') {
|
|
709
|
+
exists = await probePartnerResourceByUuid({
|
|
710
|
+
client,
|
|
711
|
+
pathTemplate: '/partners/tags/{uuid}',
|
|
712
|
+
uuid: reference.uuid,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
else if (verificationKind === 'vendor') {
|
|
716
|
+
exists = vendorUuidSet.has(canonicalUuid);
|
|
717
|
+
}
|
|
718
|
+
else if (verificationKind === 'account') {
|
|
719
|
+
exists = accountUuidSet.has(canonicalUuid);
|
|
720
|
+
}
|
|
721
|
+
if (!exists) {
|
|
722
|
+
issues.push({
|
|
723
|
+
path: reference.path,
|
|
724
|
+
code: 'reference_not_found',
|
|
725
|
+
message: `UUID "${reference.uuid}" was not found in this workspace. Use COUNT_resolve_references or the matching list/get tool.`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return issues;
|
|
730
|
+
}
|
|
731
|
+
export async function validateMcpPayloadAsync(params) {
|
|
732
|
+
const baseResult = validateMcpPayload(params);
|
|
733
|
+
if (!params.verifyReferences || !params.client || baseResult.issues.length > 0) {
|
|
734
|
+
return baseResult;
|
|
735
|
+
}
|
|
736
|
+
const uuidReferences = collectUuidReferencesFromPayload({ body: params.body, query: params.query });
|
|
737
|
+
const referenceIssues = await verifyUuidReferencesExist({ client: params.client, references: uuidReferences });
|
|
738
|
+
const combinedIssues = [...baseResult.issues, ...referenceIssues];
|
|
739
|
+
return buildValidationResult({ toolName: params.toolName, issues: combinedIssues });
|
|
740
|
+
}
|
|
741
|
+
//# sourceMappingURL=mcpPayloadValidation.helper.js.map
|