@wilnertech/halopsa-mcp-server 1.0.0

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 (76) hide show
  1. package/.env.example +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +270 -0
  4. package/dist/api/client.d.ts +85 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +297 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +60 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +188 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/cache/memory-cache.d.ts +89 -0
  13. package/dist/cache/memory-cache.d.ts.map +1 -0
  14. package/dist/cache/memory-cache.js +175 -0
  15. package/dist/cache/memory-cache.js.map +1 -0
  16. package/dist/cache/prewarm.d.ts +12 -0
  17. package/dist/cache/prewarm.d.ts.map +1 -0
  18. package/dist/cache/prewarm.js +55 -0
  19. package/dist/cache/prewarm.js.map +1 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +141 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/schemas/common.d.ts +212 -0
  25. package/dist/schemas/common.d.ts.map +1 -0
  26. package/dist/schemas/common.js +127 -0
  27. package/dist/schemas/common.js.map +1 -0
  28. package/dist/tools/assets.d.ts +482 -0
  29. package/dist/tools/assets.d.ts.map +1 -0
  30. package/dist/tools/assets.js +732 -0
  31. package/dist/tools/assets.js.map +1 -0
  32. package/dist/tools/batch-operations.d.ts +125 -0
  33. package/dist/tools/batch-operations.d.ts.map +1 -0
  34. package/dist/tools/batch-operations.js +207 -0
  35. package/dist/tools/batch-operations.js.map +1 -0
  36. package/dist/tools/clients.d.ts +145 -0
  37. package/dist/tools/clients.d.ts.map +1 -0
  38. package/dist/tools/clients.js +148 -0
  39. package/dist/tools/clients.js.map +1 -0
  40. package/dist/tools/reference-data.d.ts +118 -0
  41. package/dist/tools/reference-data.d.ts.map +1 -0
  42. package/dist/tools/reference-data.js +103 -0
  43. package/dist/tools/reference-data.js.map +1 -0
  44. package/dist/tools/registrations.d.ts +7 -0
  45. package/dist/tools/registrations.d.ts.map +1 -0
  46. package/dist/tools/registrations.js +61 -0
  47. package/dist/tools/registrations.js.map +1 -0
  48. package/dist/tools/registry.d.ts +67 -0
  49. package/dist/tools/registry.d.ts.map +1 -0
  50. package/dist/tools/registry.js +71 -0
  51. package/dist/tools/registry.js.map +1 -0
  52. package/dist/tools/sites.d.ts +188 -0
  53. package/dist/tools/sites.d.ts.map +1 -0
  54. package/dist/tools/sites.js +258 -0
  55. package/dist/tools/sites.js.map +1 -0
  56. package/dist/tools/users.d.ts +317 -0
  57. package/dist/tools/users.d.ts.map +1 -0
  58. package/dist/tools/users.js +489 -0
  59. package/dist/tools/users.js.map +1 -0
  60. package/dist/types/halopsa.d.ts +212 -0
  61. package/dist/types/halopsa.d.ts.map +1 -0
  62. package/dist/types/halopsa.js +8 -0
  63. package/dist/types/halopsa.js.map +1 -0
  64. package/dist/utils/formatter.d.ts +18 -0
  65. package/dist/utils/formatter.d.ts.map +1 -0
  66. package/dist/utils/formatter.js +178 -0
  67. package/dist/utils/formatter.js.map +1 -0
  68. package/dist/utils/similarity.d.ts +25 -0
  69. package/dist/utils/similarity.d.ts.map +1 -0
  70. package/dist/utils/similarity.js +90 -0
  71. package/dist/utils/similarity.js.map +1 -0
  72. package/dist/utils/zod-to-schema.d.ts +29 -0
  73. package/dist/utils/zod-to-schema.d.ts.map +1 -0
  74. package/dist/utils/zod-to-schema.js +182 -0
  75. package/dist/utils/zod-to-schema.js.map +1 -0
  76. package/package.json +61 -0
@@ -0,0 +1,732 @@
1
+ /**
2
+ * HaloPSA Asset tools with token optimization
3
+ * Provides CRUD operations for assets with caching, deduplication, and custom field inspection
4
+ *
5
+ * CRITICAL: Assets are the primary billing entity - accuracy is essential
6
+ * - Only ACTIVE assets (status_id=1) count toward billing
7
+ * - Duplicates cause billing errors = revenue loss
8
+ */
9
+ import { z } from 'zod';
10
+ import { formatResponse } from '../utils/formatter.js';
11
+ import { TTL } from '../cache/memory-cache.js';
12
+ import { FormatOptionsSchema, ConfidenceThresholds } from '../schemas/common.js';
13
+ // =============================================================================
14
+ // Zod Schemas for Input Validation
15
+ // =============================================================================
16
+ /**
17
+ * List assets schema
18
+ */
19
+ export const ListAssetsArgsSchema = z.object({
20
+ client_id: z.number().int().optional()
21
+ .describe('Filter by client ID (REQUIRED for multi-tenant environments)'),
22
+ site_id: z.number().int().optional()
23
+ .describe('Filter by site/location ID'),
24
+ assettype_id: z.number().int().optional()
25
+ .describe('Filter by asset type ID'),
26
+ status_id: z.number().int().optional()
27
+ .describe('Filter by status (1=Active, 2=Inactive, 3=Retired, 4=Deleted)'),
28
+ search: z.string().optional()
29
+ .describe('Search term (searches across multiple fields)'),
30
+ count: z.number().int().min(1).max(100).optional()
31
+ .describe('Number of results per page (max 100, default 50)'),
32
+ page_no: z.number().int().min(1).optional()
33
+ .describe('Page number (1-indexed, default 1)'),
34
+ includeactive: z.boolean().optional()
35
+ .describe('Include active assets (default true)'),
36
+ includeinactive: z.boolean().optional()
37
+ .describe('Include inactive assets (default false)'),
38
+ format_options: FormatOptionsSchema,
39
+ });
40
+ /**
41
+ * Get single asset schema
42
+ */
43
+ export const GetAssetArgsSchema = z.object({
44
+ asset_id: z.number().int()
45
+ .describe('HaloPSA asset ID'),
46
+ format_options: FormatOptionsSchema,
47
+ });
48
+ /**
49
+ * Search assets by serial number schema
50
+ */
51
+ export const SearchAssetsBySerialArgsSchema = z.object({
52
+ serial_number: z.string().min(1)
53
+ .describe('Serial number to search for (key_field)'),
54
+ client_id: z.number().int().optional()
55
+ .describe('Limit search to specific client'),
56
+ format_options: FormatOptionsSchema,
57
+ });
58
+ /**
59
+ * Search assets by hostname schema
60
+ */
61
+ export const SearchAssetsByHostnameArgsSchema = z.object({
62
+ hostname: z.string().min(1)
63
+ .describe('Hostname to search for (key_field2)'),
64
+ client_id: z.number().int().optional()
65
+ .describe('Limit search to specific client'),
66
+ format_options: FormatOptionsSchema,
67
+ });
68
+ /**
69
+ * Create asset schema
70
+ */
71
+ export const CreateAssetArgsSchema = z.object({
72
+ client_id: z.number().int()
73
+ .describe('Client ID to create asset under'),
74
+ site_id: z.number().int().optional()
75
+ .describe('Site/location ID'),
76
+ assettype_id: z.number().int()
77
+ .describe('Asset type ID'),
78
+ inventory_number: z.string().optional()
79
+ .describe('Asset tag/identifier'),
80
+ key_field: z.string().optional()
81
+ .describe('Serial number (PRIMARY deduplication field)'),
82
+ key_field2: z.string().optional()
83
+ .describe('Hostname (SECONDARY deduplication field)'),
84
+ status_id: z.number().int().optional()
85
+ .describe('Status ID (1=Active, 2=Inactive, 3=Retired, 4=Deleted)'),
86
+ warranty_end: z.string().optional()
87
+ .describe('Warranty expiration date (ISO 8601)'),
88
+ notes: z.string().optional()
89
+ .describe('Additional notes'),
90
+ fields: z.array(z.object({
91
+ id: z.number().int(),
92
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
93
+ })).optional()
94
+ .describe('Custom fields array'),
95
+ });
96
+ /**
97
+ * Update asset schema
98
+ */
99
+ export const UpdateAssetArgsSchema = z.object({
100
+ asset_id: z.number().int()
101
+ .describe('HaloPSA asset ID to update'),
102
+ site_id: z.number().int().optional()
103
+ .describe('Site/location ID'),
104
+ assettype_id: z.number().int().optional()
105
+ .describe('Asset type ID'),
106
+ inventory_number: z.string().optional()
107
+ .describe('Asset tag/identifier'),
108
+ key_field: z.string().optional()
109
+ .describe('Serial number'),
110
+ key_field2: z.string().optional()
111
+ .describe('Hostname'),
112
+ status_id: z.number().int().optional()
113
+ .describe('Status ID'),
114
+ warranty_end: z.string().optional()
115
+ .describe('Warranty expiration date (ISO 8601)'),
116
+ last_seen: z.string().optional()
117
+ .describe('Last seen date (ISO 8601)'),
118
+ notes: z.string().optional()
119
+ .describe('Additional notes'),
120
+ fields: z.array(z.object({
121
+ id: z.number().int(),
122
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
123
+ })).optional()
124
+ .describe('Custom fields array'),
125
+ });
126
+ /**
127
+ * Delete asset schema
128
+ */
129
+ export const DeleteAssetArgsSchema = z.object({
130
+ asset_id: z.number().int()
131
+ .describe('HaloPSA asset ID to delete'),
132
+ });
133
+ /**
134
+ * List asset custom fields schema (for inspection)
135
+ */
136
+ export const ListAssetCustomFieldsArgsSchema = z.object({
137
+ asset_id: z.number().int()
138
+ .describe('HaloPSA asset ID to inspect custom fields'),
139
+ format_options: FormatOptionsSchema,
140
+ });
141
+ /**
142
+ * Get asset field schema schema
143
+ */
144
+ export const GetAssetFieldSchemaArgsSchema = z.object({
145
+ client_id: z.number().int().optional()
146
+ .describe('Limit to specific client'),
147
+ sample_size: z.number().int().min(1).max(100).optional()
148
+ .describe('Number of assets to analyze (default 50)'),
149
+ format_options: FormatOptionsSchema,
150
+ });
151
+ /**
152
+ * Find asset match schema (deduplication)
153
+ */
154
+ export const FindAssetMatchArgsSchema = z.object({
155
+ serial_number: z.string().optional()
156
+ .describe('Serial number to search for (primary match field)'),
157
+ hostname: z.string().optional()
158
+ .describe('Hostname to search for (secondary match field)'),
159
+ client_id: z.number().int()
160
+ .describe('Client ID to scope search'),
161
+ });
162
+ /**
163
+ * Scan asset duplicates schema
164
+ */
165
+ export const ScanAssetDuplicatesArgsSchema = z.object({
166
+ client_id: z.number().int().optional()
167
+ .describe('Limit scan to specific client'),
168
+ scan_field: z.enum(['serial_number', 'hostname', 'both']).optional()
169
+ .describe('Field to scan for duplicates (default: both)'),
170
+ format_options: FormatOptionsSchema,
171
+ });
172
+ // =============================================================================
173
+ // Tool Implementations
174
+ // =============================================================================
175
+ /**
176
+ * List assets with optional filtering and token optimization
177
+ * Uses 1-minute cache TTL (billing-critical data)
178
+ */
179
+ export async function listAssets(client, args) {
180
+ const params = {};
181
+ // Build query parameters
182
+ if (args.client_id !== undefined) {
183
+ params.client_id = args.client_id;
184
+ }
185
+ if (args.site_id !== undefined) {
186
+ params.site_id = args.site_id;
187
+ }
188
+ if (args.assettype_id !== undefined) {
189
+ params.assettype_id = args.assettype_id;
190
+ }
191
+ if (args.status_id !== undefined) {
192
+ params.status_id = args.status_id;
193
+ }
194
+ if (args.search !== undefined) {
195
+ params.search = args.search;
196
+ }
197
+ if (args.count !== undefined) {
198
+ params.count = args.count;
199
+ }
200
+ if (args.page_no !== undefined) {
201
+ params.page_no = args.page_no;
202
+ }
203
+ if (args.includeactive !== undefined) {
204
+ params.includeactive = args.includeactive;
205
+ }
206
+ if (args.includeinactive !== undefined) {
207
+ params.includeinactive = args.includeinactive;
208
+ }
209
+ const formatOptions = args.format_options || {};
210
+ // Generate cache key based on filters
211
+ const cacheKey = `assets:client_${args.client_id || 'all'}:site_${args.site_id || 'all'}`;
212
+ const response = await client.getCached('/Asset', params, {
213
+ enabled: true,
214
+ ttl: TTL.ASSET_LIST,
215
+ keyPrefix: cacheKey,
216
+ });
217
+ return formatResponse(response.assets, formatOptions, { record_count: response.record_count, page_count: response.page_count });
218
+ }
219
+ /**
220
+ * Get a single asset by ID with full details including custom fields
221
+ */
222
+ export async function getAsset(client, args) {
223
+ const formatOptions = args.format_options || {};
224
+ const response = await client.get(`/Asset/${args.asset_id}`);
225
+ return formatResponse(response, formatOptions);
226
+ }
227
+ /**
228
+ * Search assets by serial number
229
+ * Returns warning if multiple found (potential duplicates)
230
+ */
231
+ export async function searchAssetsBySerial(client, args) {
232
+ const params = {
233
+ search: args.serial_number,
234
+ count: 100,
235
+ };
236
+ if (args.client_id !== undefined) {
237
+ params.client_id = args.client_id;
238
+ }
239
+ const formatOptions = args.format_options || {};
240
+ // No caching for search results
241
+ const response = await client.get('/Asset', params);
242
+ // Filter to exact serial matches
243
+ const exactMatches = response.assets.filter((asset) => asset.key_field?.toLowerCase() === args.serial_number.toLowerCase());
244
+ if (exactMatches.length === 0) {
245
+ return JSON.stringify({
246
+ serial_number: args.serial_number,
247
+ matches: [],
248
+ count: 0,
249
+ message: 'No assets found with this serial number',
250
+ });
251
+ }
252
+ // Warning if duplicates found
253
+ const warning = exactMatches.length > 1
254
+ ? `WARNING: Multiple assets found with serial number ${args.serial_number}. Potential duplicates detected.`
255
+ : undefined;
256
+ const result = {
257
+ serial_number: args.serial_number,
258
+ matches: JSON.parse(formatResponse(exactMatches, formatOptions)),
259
+ count: exactMatches.length,
260
+ ...(warning && { warning }),
261
+ };
262
+ return JSON.stringify(result, null, 2);
263
+ }
264
+ /**
265
+ * Search assets by hostname
266
+ * Returns warning if multiple found (potential duplicates)
267
+ */
268
+ export async function searchAssetsByHostname(client, args) {
269
+ const params = {
270
+ search: args.hostname,
271
+ count: 100,
272
+ };
273
+ if (args.client_id !== undefined) {
274
+ params.client_id = args.client_id;
275
+ }
276
+ const formatOptions = args.format_options || {};
277
+ const response = await client.get('/Asset', params);
278
+ // Filter to exact hostname matches
279
+ const exactMatches = response.assets.filter((asset) => asset.key_field2?.toLowerCase() === args.hostname.toLowerCase());
280
+ if (exactMatches.length === 0) {
281
+ return JSON.stringify({
282
+ hostname: args.hostname,
283
+ matches: [],
284
+ count: 0,
285
+ message: 'No assets found with this hostname',
286
+ });
287
+ }
288
+ const warning = exactMatches.length > 1
289
+ ? `WARNING: Multiple assets found with hostname ${args.hostname}. Potential duplicates detected.`
290
+ : undefined;
291
+ const result = {
292
+ hostname: args.hostname,
293
+ matches: JSON.parse(formatResponse(exactMatches, formatOptions)),
294
+ count: exactMatches.length,
295
+ ...(warning && { warning }),
296
+ };
297
+ return JSON.stringify(result, null, 2);
298
+ }
299
+ /**
300
+ * Create a new asset
301
+ * Invalidates asset cache on success
302
+ */
303
+ export async function createAsset(client, args) {
304
+ const assetData = {
305
+ client_id: args.client_id,
306
+ assettype_id: args.assettype_id,
307
+ };
308
+ // Add optional fields
309
+ if (args.site_id !== undefined) {
310
+ assetData.site_id = args.site_id;
311
+ }
312
+ if (args.inventory_number !== undefined) {
313
+ assetData.inventory_number = args.inventory_number;
314
+ }
315
+ if (args.key_field !== undefined) {
316
+ assetData.key_field = args.key_field;
317
+ }
318
+ if (args.key_field2 !== undefined) {
319
+ assetData.key_field2 = args.key_field2;
320
+ }
321
+ if (args.status_id !== undefined) {
322
+ assetData.status_id = args.status_id;
323
+ }
324
+ if (args.warranty_end !== undefined) {
325
+ assetData.warranty_end = args.warranty_end;
326
+ }
327
+ if (args.notes !== undefined) {
328
+ assetData.notes = args.notes;
329
+ }
330
+ if (args.fields !== undefined) {
331
+ assetData.fields = args.fields;
332
+ }
333
+ const response = await client.post('/Asset', assetData);
334
+ // Invalidate asset caches
335
+ client.invalidateCache(`assets:client_${args.client_id}*`);
336
+ client.invalidateCache('assets:client_all*');
337
+ return formatResponse(response, { format: 'detailed' });
338
+ }
339
+ /**
340
+ * Update an existing asset
341
+ * Invalidates asset cache on success
342
+ */
343
+ export async function updateAsset(client, args) {
344
+ const assetData = {};
345
+ // Build update payload with only provided fields
346
+ if (args.site_id !== undefined) {
347
+ assetData.site_id = args.site_id;
348
+ }
349
+ if (args.assettype_id !== undefined) {
350
+ assetData.assettype_id = args.assettype_id;
351
+ }
352
+ if (args.inventory_number !== undefined) {
353
+ assetData.inventory_number = args.inventory_number;
354
+ }
355
+ if (args.key_field !== undefined) {
356
+ assetData.key_field = args.key_field;
357
+ }
358
+ if (args.key_field2 !== undefined) {
359
+ assetData.key_field2 = args.key_field2;
360
+ }
361
+ if (args.status_id !== undefined) {
362
+ assetData.status_id = args.status_id;
363
+ }
364
+ if (args.warranty_end !== undefined) {
365
+ assetData.warranty_end = args.warranty_end;
366
+ }
367
+ if (args.last_seen !== undefined) {
368
+ assetData.last_seen = args.last_seen;
369
+ }
370
+ if (args.notes !== undefined) {
371
+ assetData.notes = args.notes;
372
+ }
373
+ if (args.fields !== undefined) {
374
+ assetData.fields = args.fields;
375
+ }
376
+ if (Object.keys(assetData).length === 0) {
377
+ return JSON.stringify({
378
+ error: true,
379
+ message: 'At least one field must be provided for update',
380
+ }, null, 2);
381
+ }
382
+ // HaloPSA uses POST for updates
383
+ const response = await client.post(`/Asset/${args.asset_id}`, assetData);
384
+ // Invalidate asset caches
385
+ client.invalidateCache('assets:*');
386
+ return formatResponse(response, { format: 'detailed' });
387
+ }
388
+ /**
389
+ * Delete an asset (soft delete - sets status to Deleted)
390
+ * Invalidates asset cache on success
391
+ */
392
+ export async function deleteAsset(client, args) {
393
+ await client.delete(`/Asset/${args.asset_id}`);
394
+ // Invalidate asset caches
395
+ client.invalidateCache('assets:*');
396
+ return JSON.stringify({
397
+ success: true,
398
+ message: `Asset ${args.asset_id} deleted successfully`,
399
+ deleted_id: args.asset_id,
400
+ }, null, 2);
401
+ }
402
+ /**
403
+ * List custom fields for a specific asset
404
+ * Useful for inspecting custom field structure before updates
405
+ */
406
+ export async function listAssetCustomFields(client, args) {
407
+ const formatOptions = args.format_options || {};
408
+ const asset = await client.get(`/Asset/${args.asset_id}`);
409
+ const customFields = asset.fields || [];
410
+ const result = {
411
+ asset_id: args.asset_id,
412
+ asset_name: asset.key_field2 || asset.inventory_number || `Asset ${asset.id}`,
413
+ custom_field_count: customFields.length,
414
+ custom_fields: customFields,
415
+ };
416
+ if (formatOptions.format === 'detailed') {
417
+ return JSON.stringify(result, null, 2);
418
+ }
419
+ return JSON.stringify(result);
420
+ }
421
+ /**
422
+ * Analyze custom field schema across multiple assets
423
+ * Returns field definitions with sample values and value types
424
+ */
425
+ export async function getAssetFieldSchema(client, args) {
426
+ const sampleSize = args.sample_size || 50;
427
+ const formatOptions = args.format_options || {};
428
+ const params = {
429
+ count: sampleSize,
430
+ includeactive: true,
431
+ includeinactive: true,
432
+ };
433
+ if (args.client_id !== undefined) {
434
+ params.client_id = args.client_id;
435
+ }
436
+ const response = await client.get('/Asset', params);
437
+ // Analyze custom fields across all assets
438
+ const fieldMap = new Map();
439
+ for (const asset of response.assets) {
440
+ const fields = asset.fields || [];
441
+ for (const field of fields) {
442
+ const existing = fieldMap.get(field.id);
443
+ if (existing) {
444
+ // Update sample values and usage count
445
+ if (!existing.sample_values.includes(field.value) && existing.sample_values.length < 5) {
446
+ existing.sample_values.push(field.value);
447
+ }
448
+ existing.usage_count++;
449
+ // Determine value type
450
+ const valueType = getValueType(field.value);
451
+ if (existing.value_type !== valueType && existing.value_type !== 'mixed') {
452
+ existing.value_type = 'mixed';
453
+ }
454
+ }
455
+ else {
456
+ fieldMap.set(field.id, {
457
+ field_id: field.id,
458
+ name: field.name,
459
+ sample_values: [field.value],
460
+ value_type: getValueType(field.value),
461
+ usage_count: 1,
462
+ });
463
+ }
464
+ }
465
+ }
466
+ const fieldSchemas = Array.from(fieldMap.values()).sort((a, b) => a.field_id - b.field_id);
467
+ const result = {
468
+ client_id: args.client_id || 'all',
469
+ assets_analyzed: response.assets.length,
470
+ unique_fields: fieldSchemas.length,
471
+ field_schemas: fieldSchemas,
472
+ };
473
+ if (formatOptions.format === 'detailed') {
474
+ return JSON.stringify(result, null, 2);
475
+ }
476
+ return JSON.stringify(result);
477
+ }
478
+ /**
479
+ * Find matching asset using deduplication algorithm
480
+ * Returns confidence scoring and action recommendation
481
+ *
482
+ * Confidence Thresholds:
483
+ * - 100%: Exact serial match -> Auto-update
484
+ * - 85%: Hostname match only -> Manual review
485
+ * - 0%: No match -> Create new
486
+ */
487
+ export async function findAssetMatch(client, args) {
488
+ // Get all assets for client with caching
489
+ const response = await client.getCached('/Asset', {
490
+ client_id: args.client_id,
491
+ count: 1000,
492
+ includeactive: true,
493
+ includeinactive: true,
494
+ }, {
495
+ enabled: true,
496
+ ttl: TTL.ASSET_LIST,
497
+ keyPrefix: `assets:client_${args.client_id}`,
498
+ });
499
+ if (!response.assets || response.assets.length === 0) {
500
+ return JSON.stringify({
501
+ match: null,
502
+ confidence: 0,
503
+ matchField: 'none',
504
+ action: 'CreateNew',
505
+ }, null, 2);
506
+ }
507
+ // 1. Exact serial number match (100% confidence)
508
+ if (args.serial_number) {
509
+ const serialMatch = response.assets.find((asset) => asset.key_field?.toLowerCase() === args.serial_number?.toLowerCase());
510
+ if (serialMatch) {
511
+ return JSON.stringify({
512
+ match: serialMatch,
513
+ confidence: ConfidenceThresholds.ASSET_SERIAL_EXACT,
514
+ matchField: 'serial_number (exact)',
515
+ action: 'Update',
516
+ }, null, 2);
517
+ }
518
+ }
519
+ // 2. Hostname match only (85% confidence - requires manual review)
520
+ if (args.hostname) {
521
+ const hostnameMatches = response.assets.filter((asset) => asset.key_field2?.toLowerCase() === args.hostname?.toLowerCase());
522
+ if (hostnameMatches.length === 1) {
523
+ return JSON.stringify({
524
+ match: hostnameMatches[0],
525
+ confidence: ConfidenceThresholds.ASSET_HOSTNAME_ONLY,
526
+ matchField: 'hostname (serial mismatch)',
527
+ action: 'ManualReview',
528
+ warning: 'Hostname match found but serial number does not match. Manual review required.',
529
+ }, null, 2);
530
+ }
531
+ if (hostnameMatches.length > 1) {
532
+ return JSON.stringify({
533
+ match: null,
534
+ confidence: 0.5,
535
+ matchField: 'hostname (multiple)',
536
+ action: 'ManualReview',
537
+ warning: `Multiple assets found with hostname ${args.hostname}. Duplicates detected.`,
538
+ matches: hostnameMatches,
539
+ }, null, 2);
540
+ }
541
+ }
542
+ // 3. No match found - create new
543
+ return JSON.stringify({
544
+ match: null,
545
+ confidence: 0,
546
+ matchField: 'none',
547
+ action: 'CreateNew',
548
+ }, null, 2);
549
+ }
550
+ /**
551
+ * Scan for duplicate assets
552
+ * Returns groups of duplicate entities with confidence scores
553
+ */
554
+ export async function scanAssetDuplicates(client, args) {
555
+ const scanField = args.scan_field || 'both';
556
+ const formatOptions = args.format_options || {};
557
+ const params = {
558
+ count: 1000,
559
+ includeactive: true,
560
+ includeinactive: true,
561
+ };
562
+ if (args.client_id !== undefined) {
563
+ params.client_id = args.client_id;
564
+ }
565
+ const response = await client.getCached('/Asset', params, {
566
+ enabled: true,
567
+ ttl: TTL.ASSET_LIST,
568
+ keyPrefix: args.client_id ? `assets:client_${args.client_id}` : 'assets:client_all',
569
+ });
570
+ const duplicates = [];
571
+ // Scan by serial number
572
+ if (scanField === 'serial_number' || scanField === 'both') {
573
+ const serialToAssets = new Map();
574
+ for (const asset of response.assets) {
575
+ if (asset.key_field) {
576
+ const normalizedSerial = asset.key_field.toLowerCase();
577
+ const existing = serialToAssets.get(normalizedSerial) || [];
578
+ existing.push(asset);
579
+ serialToAssets.set(normalizedSerial, existing);
580
+ }
581
+ }
582
+ for (const [serial, assets] of serialToAssets.entries()) {
583
+ if (assets.length > 1) {
584
+ duplicates.push({
585
+ type: 'asset',
586
+ matchField: 'serial_number',
587
+ matchValue: serial,
588
+ confidence: 'High', // Exact serial match = high confidence
589
+ entity_count: assets.length,
590
+ entities: assets.map((a) => ({
591
+ id: a.id,
592
+ name: a.inventory_number || `Asset ${a.id}`,
593
+ serial_number: a.key_field,
594
+ hostname: a.key_field2,
595
+ })),
596
+ });
597
+ }
598
+ }
599
+ }
600
+ // Scan by hostname
601
+ if (scanField === 'hostname' || scanField === 'both') {
602
+ const hostnameToAssets = new Map();
603
+ for (const asset of response.assets) {
604
+ if (asset.key_field2) {
605
+ const normalizedHostname = asset.key_field2.toLowerCase();
606
+ const existing = hostnameToAssets.get(normalizedHostname) || [];
607
+ existing.push(asset);
608
+ hostnameToAssets.set(normalizedHostname, existing);
609
+ }
610
+ }
611
+ for (const [hostname, assets] of hostnameToAssets.entries()) {
612
+ if (assets.length > 1) {
613
+ // Check if these are already captured by serial number scan
614
+ const serialNumbers = new Set(assets.map((a) => a.key_field?.toLowerCase()).filter(Boolean));
615
+ const isNewDuplicate = serialNumbers.size > 1; // Different serials = new duplicate group
616
+ if (isNewDuplicate) {
617
+ duplicates.push({
618
+ type: 'asset',
619
+ matchField: 'hostname',
620
+ matchValue: hostname,
621
+ confidence: 'Medium', // Hostname only = medium confidence
622
+ entity_count: assets.length,
623
+ entities: assets.map((a) => ({
624
+ id: a.id,
625
+ name: a.inventory_number || `Asset ${a.id}`,
626
+ serial_number: a.key_field,
627
+ hostname: a.key_field2,
628
+ })),
629
+ });
630
+ }
631
+ }
632
+ }
633
+ }
634
+ const result = {
635
+ client_id: args.client_id || 'all',
636
+ scan_field: scanField,
637
+ assets_scanned: response.assets.length,
638
+ duplicates_found: duplicates.length,
639
+ duplicates,
640
+ };
641
+ if (formatOptions.format === 'detailed') {
642
+ return JSON.stringify(result, null, 2);
643
+ }
644
+ return JSON.stringify(result);
645
+ }
646
+ // =============================================================================
647
+ // Helper Functions
648
+ // =============================================================================
649
+ /**
650
+ * Determine the type of a custom field value
651
+ */
652
+ function getValueType(value) {
653
+ if (value === null)
654
+ return 'null';
655
+ if (typeof value === 'string')
656
+ return 'string';
657
+ if (typeof value === 'number')
658
+ return 'number';
659
+ if (typeof value === 'boolean')
660
+ return 'boolean';
661
+ return 'mixed';
662
+ }
663
+ // =============================================================================
664
+ // Tool Definitions (for ToolRegistry)
665
+ // =============================================================================
666
+ export const listAssetsTool = {
667
+ name: 'list_halopsa_assets',
668
+ description: 'List HaloPSA assets with optional filtering, caching (1min TTL), and token optimization. CRITICAL: Only status_id=1 (Active) assets count for billing.',
669
+ schema: ListAssetsArgsSchema,
670
+ handler: listAssets,
671
+ };
672
+ export const getAssetTool = {
673
+ name: 'get_halopsa_asset',
674
+ description: 'Get a single HaloPSA asset by ID with full details including custom fields (fields[] array)',
675
+ schema: GetAssetArgsSchema,
676
+ handler: getAsset,
677
+ };
678
+ export const searchAssetsBySerialTool = {
679
+ name: 'search_halopsa_assets_by_serial',
680
+ description: 'Search HaloPSA assets by serial number (key_field). Returns warning if multiple found (potential duplicates).',
681
+ schema: SearchAssetsBySerialArgsSchema,
682
+ handler: searchAssetsBySerial,
683
+ };
684
+ export const searchAssetsByHostnameTool = {
685
+ name: 'search_halopsa_assets_by_hostname',
686
+ description: 'Search HaloPSA assets by hostname (key_field2). Returns warning if multiple found.',
687
+ schema: SearchAssetsByHostnameArgsSchema,
688
+ handler: searchAssetsByHostname,
689
+ };
690
+ export const createAssetTool = {
691
+ name: 'create_halopsa_asset',
692
+ description: 'Create a new HaloPSA asset. Invalidates asset cache. IMPORTANT: Use find_halopsa_asset_match first to prevent duplicates.',
693
+ schema: CreateAssetArgsSchema,
694
+ handler: createAsset,
695
+ };
696
+ export const updateAssetTool = {
697
+ name: 'update_halopsa_asset',
698
+ description: 'Update an existing HaloPSA asset. Invalidates asset cache. Uses POST (HaloPSA convention).',
699
+ schema: UpdateAssetArgsSchema,
700
+ handler: updateAsset,
701
+ };
702
+ export const deleteAssetTool = {
703
+ name: 'delete_halopsa_asset',
704
+ description: 'Delete a HaloPSA asset. Invalidates asset cache.',
705
+ schema: DeleteAssetArgsSchema,
706
+ handler: deleteAsset,
707
+ };
708
+ export const listAssetCustomFieldsTool = {
709
+ name: 'list_halopsa_asset_custom_fields',
710
+ description: 'List custom fields for a specific asset. Useful for inspecting field structure before updates.',
711
+ schema: ListAssetCustomFieldsArgsSchema,
712
+ handler: listAssetCustomFields,
713
+ };
714
+ export const getAssetFieldSchemaTool = {
715
+ name: 'get_halopsa_asset_field_schema',
716
+ description: 'Analyze custom field definitions across multiple assets. Returns field IDs, names, sample values, and value types.',
717
+ schema: GetAssetFieldSchemaArgsSchema,
718
+ handler: getAssetFieldSchema,
719
+ };
720
+ export const findAssetMatchTool = {
721
+ name: 'find_halopsa_asset_match',
722
+ description: 'Find matching asset using deduplication algorithm with confidence scoring. Returns action recommendation (Update/CreateNew/ManualReview). CRITICAL for billing accuracy.',
723
+ schema: FindAssetMatchArgsSchema,
724
+ handler: findAssetMatch,
725
+ };
726
+ export const scanAssetDuplicatesTool = {
727
+ name: 'scan_halopsa_asset_duplicates',
728
+ description: 'Scan for duplicate assets. Returns groups with confidence scores. Run before billing cycles.',
729
+ schema: ScanAssetDuplicatesArgsSchema,
730
+ handler: scanAssetDuplicates,
731
+ };
732
+ //# sourceMappingURL=assets.js.map