@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.
- package/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/dist/api/client.d.ts +85 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +297 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +60 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +188 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/cache/memory-cache.d.ts +89 -0
- package/dist/cache/memory-cache.d.ts.map +1 -0
- package/dist/cache/memory-cache.js +175 -0
- package/dist/cache/memory-cache.js.map +1 -0
- package/dist/cache/prewarm.d.ts +12 -0
- package/dist/cache/prewarm.d.ts.map +1 -0
- package/dist/cache/prewarm.js +55 -0
- package/dist/cache/prewarm.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/common.d.ts +212 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +127 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/tools/assets.d.ts +482 -0
- package/dist/tools/assets.d.ts.map +1 -0
- package/dist/tools/assets.js +732 -0
- package/dist/tools/assets.js.map +1 -0
- package/dist/tools/batch-operations.d.ts +125 -0
- package/dist/tools/batch-operations.d.ts.map +1 -0
- package/dist/tools/batch-operations.js +207 -0
- package/dist/tools/batch-operations.js.map +1 -0
- package/dist/tools/clients.d.ts +145 -0
- package/dist/tools/clients.d.ts.map +1 -0
- package/dist/tools/clients.js +148 -0
- package/dist/tools/clients.js.map +1 -0
- package/dist/tools/reference-data.d.ts +118 -0
- package/dist/tools/reference-data.d.ts.map +1 -0
- package/dist/tools/reference-data.js +103 -0
- package/dist/tools/reference-data.js.map +1 -0
- package/dist/tools/registrations.d.ts +7 -0
- package/dist/tools/registrations.d.ts.map +1 -0
- package/dist/tools/registrations.js +61 -0
- package/dist/tools/registrations.js.map +1 -0
- package/dist/tools/registry.d.ts +67 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +71 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/sites.d.ts +188 -0
- package/dist/tools/sites.d.ts.map +1 -0
- package/dist/tools/sites.js +258 -0
- package/dist/tools/sites.js.map +1 -0
- package/dist/tools/users.d.ts +317 -0
- package/dist/tools/users.d.ts.map +1 -0
- package/dist/tools/users.js +489 -0
- package/dist/tools/users.js.map +1 -0
- package/dist/types/halopsa.d.ts +212 -0
- package/dist/types/halopsa.d.ts.map +1 -0
- package/dist/types/halopsa.js +8 -0
- package/dist/types/halopsa.js.map +1 -0
- package/dist/utils/formatter.d.ts +18 -0
- package/dist/utils/formatter.d.ts.map +1 -0
- package/dist/utils/formatter.js +178 -0
- package/dist/utils/formatter.js.map +1 -0
- package/dist/utils/similarity.d.ts +25 -0
- package/dist/utils/similarity.d.ts.map +1 -0
- package/dist/utils/similarity.js +90 -0
- package/dist/utils/similarity.js.map +1 -0
- package/dist/utils/zod-to-schema.d.ts +29 -0
- package/dist/utils/zod-to-schema.d.ts.map +1 -0
- package/dist/utils/zod-to-schema.js +182 -0
- package/dist/utils/zod-to-schema.js.map +1 -0
- 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
|