@wilnertech/halopsa-mcp-server 1.4.0 → 1.6.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/README.md +8 -1
- package/dist/api/client.d.ts +51 -2
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +124 -9
- package/dist/api/client.js.map +1 -1
- package/dist/api/errors.d.ts +16 -3
- package/dist/api/errors.d.ts.map +1 -1
- package/dist/api/errors.js +107 -30
- package/dist/api/errors.js.map +1 -1
- package/dist/cache/memory-cache.d.ts +1 -0
- package/dist/cache/memory-cache.d.ts.map +1 -1
- package/dist/cache/memory-cache.js +6 -0
- package/dist/cache/memory-cache.js.map +1 -1
- package/dist/cache/prewarm.d.ts +1 -0
- package/dist/cache/prewarm.d.ts.map +1 -1
- package/dist/cache/prewarm.js +8 -0
- package/dist/cache/prewarm.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/tools/batch-operations.js +10 -10
- package/dist/tools/batch-operations.js.map +1 -1
- package/dist/tools/budgets.d.ts.map +1 -1
- package/dist/tools/budgets.js +7 -4
- package/dist/tools/budgets.js.map +1 -1
- package/dist/tools/milestones.d.ts.map +1 -1
- package/dist/tools/milestones.js +12 -9
- package/dist/tools/milestones.js.map +1 -1
- package/dist/tools/registrations.d.ts +8 -3
- package/dist/tools/registrations.d.ts.map +1 -1
- package/dist/tools/registrations.js +21 -12
- package/dist/tools/registrations.js.map +1 -1
- package/dist/tools/ticket-actions.d.ts +46 -0
- package/dist/tools/ticket-actions.d.ts.map +1 -1
- package/dist/tools/ticket-actions.js +236 -32
- package/dist/tools/ticket-actions.js.map +1 -1
- package/dist/tools/ticket-reference-data.d.ts +70 -1
- package/dist/tools/ticket-reference-data.d.ts.map +1 -1
- package/dist/tools/ticket-reference-data.js +340 -11
- package/dist/tools/ticket-reference-data.js.map +1 -1
- package/dist/tools/tickets.d.ts +53 -0
- package/dist/tools/tickets.d.ts.map +1 -1
- package/dist/tools/tickets.js +394 -22
- package/dist/tools/tickets.js.map +1 -1
- package/dist/types/tickets.d.ts +127 -0
- package/dist/types/tickets.d.ts.map +1 -1
- package/dist/utils/formatter.d.ts +18 -0
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +108 -22
- package/dist/utils/formatter.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/tickets.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* or pass `format_options.format = 'detailed'` / `full: true`.
|
|
16
16
|
*/
|
|
17
17
|
import { z } from 'zod';
|
|
18
|
-
import { formatResponse, stripWriteResponse } from '../utils/formatter.js';
|
|
19
|
-
import { TTL } from '../cache/memory-cache.js';
|
|
18
|
+
import { formatResponse, stripWriteResponse, withCompactDefaults } from '../utils/formatter.js';
|
|
19
|
+
import { TTL, generateCacheKey } from '../cache/memory-cache.js';
|
|
20
20
|
import { FormatOptionsSchema } from '../schemas/common.js';
|
|
21
21
|
import { linkTicketToMilestone } from './milestones.js';
|
|
22
|
+
import { getOutcomesForTicketType } from './ticket-reference-data.js';
|
|
22
23
|
// =============================================================================
|
|
23
24
|
// Schemas
|
|
24
25
|
// =============================================================================
|
|
@@ -44,11 +45,11 @@ export const ListTicketsArgsSchema = z.object({
|
|
|
44
45
|
.describe('Filter by site/location ID'),
|
|
45
46
|
user_id: z.number().int().optional()
|
|
46
47
|
.describe('Filter by end-user ID'),
|
|
47
|
-
agent_id: z.number().int().optional()
|
|
48
|
+
agent_id: z.number().int().positive().optional()
|
|
48
49
|
.describe('Filter by assigned agent ID (single int). Use agent for multiple.'),
|
|
49
50
|
agent: IntOrIntArray.optional()
|
|
50
51
|
.describe('Filter by one or more agent IDs (int or array of int, serialised comma-separated). Swagger param: agent (array of int).'),
|
|
51
|
-
team_id: z.number().int().optional()
|
|
52
|
+
team_id: z.number().int().positive().optional()
|
|
52
53
|
.describe('Filter by team ID'),
|
|
53
54
|
tickettype_id: IntOrIntArray.optional()
|
|
54
55
|
.describe('Filter by ticket type ID(s) (int or array of int, serialised comma-separated).'),
|
|
@@ -109,9 +110,9 @@ export const CreateTicketArgsSchema = z.object({
|
|
|
109
110
|
.describe('Initial status ID — see list_halopsa_ticket_statuses'),
|
|
110
111
|
source: z.number().int().optional()
|
|
111
112
|
.describe('Source enum (tenant-specific integer). Halo auto-sets source=3 (API) when omitted.'),
|
|
112
|
-
agent_id: z.number().int().optional()
|
|
113
|
+
agent_id: z.number().int().positive().optional()
|
|
113
114
|
.describe('Assigned agent'),
|
|
114
|
-
team_id: z.number().int().optional()
|
|
115
|
+
team_id: z.number().int().positive().optional()
|
|
115
116
|
.describe('Assigned team'),
|
|
116
117
|
site_id: z.number().int().optional()
|
|
117
118
|
.describe('Site/location'),
|
|
@@ -131,6 +132,11 @@ export const CreateTicketArgsSchema = z.object({
|
|
|
131
132
|
.describe('Estimated business days. Companion to estimate; pass either or neither — Halo auto-computes both from startdate/targetdate when both omitted.'),
|
|
132
133
|
milestone_id: z.number().int().optional()
|
|
133
134
|
.describe('Link this ticket to an existing milestone on its parent project ticket. milestone_id orchestrates: the MCP transparently translates this to a parent-project milestones[].tickets[] update. Single milestone per task — setting milestone_id automatically unlinks from any previous milestone. Pass 0 to unlink entirely. Find milestone ids via list_halopsa_ticket_milestones on the parent ticket.'),
|
|
135
|
+
// Bug 9: Asset linking fields.
|
|
136
|
+
asset_id: z.number().int().optional()
|
|
137
|
+
.describe('Single asset to link to this ticket. For multiple, use asset_ids.'),
|
|
138
|
+
asset_ids: z.array(z.number().int()).optional()
|
|
139
|
+
.describe('Multiple asset IDs to link to this ticket. Halo will populate the ticket.assets[] array. If both asset_id and asset_ids are set, they are combined.'),
|
|
134
140
|
format_options: FormatOptionsSchema,
|
|
135
141
|
});
|
|
136
142
|
export const UpdateTicketArgsSchema = z.object({
|
|
@@ -143,8 +149,8 @@ export const UpdateTicketArgsSchema = z.object({
|
|
|
143
149
|
status_id: z.number().int().optional(),
|
|
144
150
|
priority_id: z.number().int().optional(),
|
|
145
151
|
category_1: z.string().optional(),
|
|
146
|
-
agent_id: z.number().int().optional(),
|
|
147
|
-
team_id: z.number().int().optional(),
|
|
152
|
+
agent_id: z.number().int().positive().optional(),
|
|
153
|
+
team_id: z.number().int().positive().optional(),
|
|
148
154
|
site_id: z.number().int().optional(),
|
|
149
155
|
user_id: z.number().int().optional(),
|
|
150
156
|
parent_id: z.number().int().optional()
|
|
@@ -162,19 +168,34 @@ export const UpdateTicketArgsSchema = z.object({
|
|
|
162
168
|
.describe('Estimated business days. Companion to estimate; pass either or neither — Halo auto-computes both from startdate/targetdate when both omitted.'),
|
|
163
169
|
milestone_id: z.number().int().optional()
|
|
164
170
|
.describe('Link this ticket to an existing milestone on its parent project ticket. milestone_id orchestrates: the MCP transparently translates this to a parent-project milestones[].tickets[] update. Single milestone per task — setting milestone_id automatically unlinks from any previous milestone. Pass 0 to unlink entirely. Find milestone ids via list_halopsa_ticket_milestones on the parent ticket.'),
|
|
171
|
+
// Bug 9: Asset linking fields.
|
|
172
|
+
asset_id: z.number().int().optional()
|
|
173
|
+
.describe('Single asset to link to this ticket. For multiple, use asset_ids.'),
|
|
174
|
+
asset_ids: z.array(z.number().int()).optional()
|
|
175
|
+
.describe('Multiple asset IDs to link to this ticket. Halo will populate the ticket.assets[] array. If both asset_id and asset_ids are set, they are combined.'),
|
|
165
176
|
format_options: FormatOptionsSchema,
|
|
166
177
|
});
|
|
167
178
|
export const CloseTicketArgsSchema = z.object({
|
|
168
179
|
ticket_id: z.number().int()
|
|
169
180
|
.describe('HaloPSA ticket ID to close'),
|
|
170
181
|
resolution_note: z.string().optional()
|
|
171
|
-
.describe('Optional final action note added before status change'),
|
|
182
|
+
.describe('Optional final action note added before status change. When provided, a ticket action is posted before the status flip. Requires an outcome_id — supply resolution_outcome_id explicitly, or the MCP will auto-resolve the first is_resolution outcome for this tickettype.'),
|
|
183
|
+
resolution_outcome_id: z.number().int().positive().optional()
|
|
184
|
+
.describe('Outcome ID to use for the resolution note action. When omitted but resolution_note is set, the MCP auto-resolves the first outcome with is_resolution=true for the ticket\'s tickettype. Pass explicitly when the auto-resolve picks the wrong outcome.'),
|
|
172
185
|
});
|
|
173
186
|
export const DeleteTicketArgsSchema = z.object({
|
|
174
187
|
ticket_id: z.number().int().describe('HaloPSA ticket ID to delete'),
|
|
175
188
|
reason: z.string().min(5).describe('REQUIRED audit-trail reason. To bypass safety blocks, the reason MUST include the substring "OVERRIDE" (case-sensitive) AND confirm_destructive must be true.'),
|
|
176
189
|
confirm_destructive: z.boolean().optional().default(false).describe('When safety pre-flight checks would block the delete, set true AND include "OVERRIDE" in `reason` to proceed. Default false.'),
|
|
177
190
|
});
|
|
191
|
+
// Bug 8: Schema for validate_halopsa_create_ticket.
|
|
192
|
+
export const ValidateCreateTicketArgsSchema = z.object({
|
|
193
|
+
tickettype_id: z.number().int()
|
|
194
|
+
.describe('Ticket type ID to validate against — see list_halopsa_tickettypes'),
|
|
195
|
+
fields: z.record(z.string(), z.unknown())
|
|
196
|
+
.describe('Proposed ticket payload as a key-value map. Keys should be field names (e.g. summary, client_id, customfields).'),
|
|
197
|
+
format_options: FormatOptionsSchema,
|
|
198
|
+
});
|
|
178
199
|
// =============================================================================
|
|
179
200
|
// Helpers
|
|
180
201
|
// =============================================================================
|
|
@@ -251,6 +272,24 @@ async function resolveClosedStatusId(client) {
|
|
|
251
272
|
`(received ${items.length} statuses; "Closed" matches: ${exactClosed.length}, "Completed" matches: ${exactCompleted.length}) ` +
|
|
252
273
|
'Pass status_id explicitly via update_halopsa_ticket.');
|
|
253
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Build the Halo assets[] array payload from asset_id / asset_ids args.
|
|
277
|
+
* Halo Faults.assets[] accepts elements with at minimum { id: number }.
|
|
278
|
+
* Returns undefined when neither arg is set (no-op).
|
|
279
|
+
*/
|
|
280
|
+
function buildAssetsPayload(asset_id, asset_ids) {
|
|
281
|
+
if (asset_id === undefined && asset_ids === undefined) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
const idSet = [];
|
|
285
|
+
if (asset_ids !== undefined) {
|
|
286
|
+
idSet.push(...asset_ids);
|
|
287
|
+
}
|
|
288
|
+
if (asset_id !== undefined && !idSet.includes(asset_id)) {
|
|
289
|
+
idSet.push(asset_id);
|
|
290
|
+
}
|
|
291
|
+
return idSet.map((id) => ({ id }));
|
|
292
|
+
}
|
|
254
293
|
// =============================================================================
|
|
255
294
|
// Implementations
|
|
256
295
|
// =============================================================================
|
|
@@ -298,7 +337,10 @@ export async function listTickets(client, args) {
|
|
|
298
337
|
if (args.page_no !== undefined)
|
|
299
338
|
params.page_no = args.page_no;
|
|
300
339
|
const formatOpts = resolveListFormat(args.format_options);
|
|
301
|
-
|
|
340
|
+
// B3 fix: use generateCacheKey so ALL filter params (agent_id, tickettype_id,
|
|
341
|
+
// date_from, open_only, etc.) participate in the cache key. The previous
|
|
342
|
+
// hand-rolled key silently ignored those params, causing cache collisions.
|
|
343
|
+
const cacheKey = generateCacheKey('tickets', params);
|
|
302
344
|
const response = await client.getCached('/Tickets', params, {
|
|
303
345
|
enabled: true,
|
|
304
346
|
ttl: TTL.TICKET_LIST,
|
|
@@ -364,14 +406,22 @@ export async function createTicket(client, args) {
|
|
|
364
406
|
// milestone_id is NOT sent to Halo as a direct field — writing it to the
|
|
365
407
|
// child record is a no-op for Project Task tickets (tickettype_id=20).
|
|
366
408
|
// Milestone membership is orchestrated via linkTicketToMilestone below.
|
|
409
|
+
// Bug 9: Asset linking. Build assets[] from asset_id / asset_ids args.
|
|
410
|
+
// Halo Faults.assets[] accepts elements with at minimum { id: number }.
|
|
411
|
+
const assetsPayload = buildAssetsPayload(args.asset_id, args.asset_ids);
|
|
412
|
+
if (assetsPayload !== undefined) {
|
|
413
|
+
payload.assets = assetsPayload;
|
|
414
|
+
}
|
|
367
415
|
const created = unwrapWriteResponse(await client.post('/Tickets', [payload]));
|
|
368
416
|
client.invalidateCache('tickets:*');
|
|
369
417
|
// Bug 1: Orchestrate milestone linkage via parent project if requested.
|
|
370
418
|
if (args.milestone_id !== undefined) {
|
|
371
419
|
await linkTicketToMilestone(client, created.id, args.milestone_id, args.parent_id);
|
|
372
420
|
}
|
|
373
|
-
|
|
374
|
-
|
|
421
|
+
// Bug 5: Pass full format_options so fields/omit_empty flow through to formatResponse.
|
|
422
|
+
const formatOpts = args.format_options ?? { format: 'compact' };
|
|
423
|
+
const format = formatOpts.format ?? 'compact';
|
|
424
|
+
return formatResponse(stripWriteResponse(created, format, 'ticket'), formatOpts);
|
|
375
425
|
}
|
|
376
426
|
export async function updateTicket(client, args) {
|
|
377
427
|
const partial = {};
|
|
@@ -412,6 +462,11 @@ export async function updateTicket(client, args) {
|
|
|
412
462
|
// milestone_id is NOT sent to Halo as a direct field — writing it to the
|
|
413
463
|
// child record is a no-op for Project Task tickets (tickettype_id=20).
|
|
414
464
|
// Milestone membership is orchestrated via linkTicketToMilestone below.
|
|
465
|
+
// Bug 9: Asset linking. Build assets[] from asset_id / asset_ids args.
|
|
466
|
+
const assetsPayload = buildAssetsPayload(args.asset_id, args.asset_ids);
|
|
467
|
+
if (assetsPayload !== undefined) {
|
|
468
|
+
partial.assets = assetsPayload;
|
|
469
|
+
}
|
|
415
470
|
// Determine which fields count for the "at least one" check. We include
|
|
416
471
|
// milestone_id in the check so that a milestone-only update is valid.
|
|
417
472
|
const fieldCount = Object.keys(partial).length + (args.milestone_id !== undefined ? 1 : 0);
|
|
@@ -421,8 +476,30 @@ export async function updateTicket(client, args) {
|
|
|
421
476
|
message: 'At least one field must be provided for update',
|
|
422
477
|
}, null, 2);
|
|
423
478
|
}
|
|
424
|
-
// Halo POST /Tickets
|
|
425
|
-
//
|
|
479
|
+
// Halo POST /Tickets serves both create AND update (Halo has no PATCH/PUT
|
|
480
|
+
// anywhere — verified 2026-05-12 against the authoritative OpenAPI 3.0.1 spec:
|
|
481
|
+
// zero PATCH operations across all 952 paths; zero PUT operations). The swagger
|
|
482
|
+
// does not document whether absent fields are preserved or cleared on update.
|
|
483
|
+
// The Faults schema has no `required[]` constraint (984 nullable properties),
|
|
484
|
+
// and no description on POST /Tickets.
|
|
485
|
+
//
|
|
486
|
+
// Two sources of truth conflict:
|
|
487
|
+
// - .claude/api-docs/halopsa.md:494 (curated): "partial updates supported".
|
|
488
|
+
// - This codebase (observed, Bug 3): Halo's scheduler recomputes startdate/
|
|
489
|
+
// targetdate on estimate changes even when the same values are resent
|
|
490
|
+
// (see Bug 3 below). That recomputation is irrefutable evidence that
|
|
491
|
+
// POST /Tickets is NOT a pure PATCH model — at minimum, server-side
|
|
492
|
+
// computed fields drift on partial-body update.
|
|
493
|
+
//
|
|
494
|
+
// The safe choice: keep the read-before-write merge. The downside is request
|
|
495
|
+
// size (20-60 KB per update, since Faults has 984 properties many of which
|
|
496
|
+
// are populated). If a future iteration wants to reduce request size, the
|
|
497
|
+
// migration would need per-field empirical testing on a non-production
|
|
498
|
+
// tenant — start with leaf scalars (summary, status_id), avoid arrays/objects
|
|
499
|
+
// (customfields, assets, actions, attachments), and never drop a field whose
|
|
500
|
+
// absence could trigger a billing-relevant default.
|
|
501
|
+
//
|
|
502
|
+
// Reference: thoughts/shared/research/2026-05-12_halopsa-mcp-v16-deferred-items-swagger-research.md
|
|
426
503
|
const existing = await client.get(`/Tickets/${args.ticket_id}`, { includedetails: true });
|
|
427
504
|
// Bug 3 — schedule drift on estimate updates:
|
|
428
505
|
// Halo's scheduler treats estimate/estimatedays changes as a recompute
|
|
@@ -462,8 +539,10 @@ export async function updateTicket(client, args) {
|
|
|
462
539
|
if (args.milestone_id !== undefined) {
|
|
463
540
|
await linkTicketToMilestone(client, args.ticket_id, args.milestone_id, args.parent_id);
|
|
464
541
|
}
|
|
465
|
-
|
|
466
|
-
|
|
542
|
+
// Bug 5: Pass full format_options so fields/omit_empty flow through to formatResponse.
|
|
543
|
+
const formatOpts = args.format_options ?? { format: 'compact' };
|
|
544
|
+
const format = formatOpts.format ?? 'compact';
|
|
545
|
+
return formatResponse(stripWriteResponse(response, format, 'ticket'), formatOpts);
|
|
467
546
|
}
|
|
468
547
|
/**
|
|
469
548
|
* Permanently delete a HaloPSA ticket by ID after running four safety
|
|
@@ -589,11 +668,122 @@ export async function closeTicket(client, args) {
|
|
|
589
668
|
// If a resolution note was supplied, post it as a final action first so
|
|
590
669
|
// the close-out is auditable.
|
|
591
670
|
if (args.resolution_note) {
|
|
592
|
-
|
|
671
|
+
// Resolve the outcome_id to use for the resolution note action.
|
|
672
|
+
let outcomeId;
|
|
673
|
+
if (args.resolution_outcome_id !== undefined) {
|
|
674
|
+
// Explicit override — use directly.
|
|
675
|
+
outcomeId = args.resolution_outcome_id;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Auto-resolve: fetch the ticket to get tickettype_id, then find the
|
|
679
|
+
// first is_resolution outcome for that tickettype (cached 1h).
|
|
680
|
+
// Use the same keyPrefix as getTicket so a prior get_halopsa_ticket call
|
|
681
|
+
// seeds this cache hit — avoids a second Halo round-trip in the typical
|
|
682
|
+
// "get then close" workflow. updateTicket invalidates ticket:${id}* so
|
|
683
|
+
// the unified key cannot become stale.
|
|
684
|
+
const ticketForClose = await client.getCached(`/Tickets/${args.ticket_id}`, { includedetails: true }, {
|
|
685
|
+
enabled: true,
|
|
686
|
+
ttl: TTL.TICKET_LIST,
|
|
687
|
+
keyPrefix: `ticket:${args.ticket_id}`,
|
|
688
|
+
});
|
|
689
|
+
const tickettypeId = ticketForClose.tickettype_id;
|
|
690
|
+
const outcomeList = await getOutcomesForTicketType(client, tickettypeId);
|
|
691
|
+
// Find the first outcome with is_resolution===true, sorted by id ascending.
|
|
692
|
+
const resolutionOutcomes = outcomeList
|
|
693
|
+
.filter((o) => o.is_resolution === true)
|
|
694
|
+
.sort((a, b) => a.id - b.id);
|
|
695
|
+
if (resolutionOutcomes.length === 0) {
|
|
696
|
+
// S5 fix: sort outcomes by sequence ascending (fallback to id) before slicing,
|
|
697
|
+
// matching buildOutcomeDiscoveryRefusal behavior.
|
|
698
|
+
const sortedOutcomes = outcomeList.slice().sort((a, b) => {
|
|
699
|
+
const seqA = a.sequence ?? 999999;
|
|
700
|
+
const seqB = b.sequence ?? 999999;
|
|
701
|
+
return seqA !== seqB ? seqA - seqB : a.id - b.id;
|
|
702
|
+
});
|
|
703
|
+
return JSON.stringify({
|
|
704
|
+
error: true,
|
|
705
|
+
message: `No outcome with is_resolution=true found for tickettype_id=${tickettypeId}. ` +
|
|
706
|
+
'Pass resolution_outcome_id explicitly or close without resolution_note.',
|
|
707
|
+
tickettype_id: tickettypeId,
|
|
708
|
+
available_outcomes: sortedOutcomes.slice(0, 5).map((o) => ({ id: o.id, name: o.name })),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
outcomeId = resolutionOutcomes[0].id;
|
|
712
|
+
}
|
|
713
|
+
// B2 fix: wrap the action POST in try/catch for structured failure handling.
|
|
714
|
+
// If the POST throws, return a structured failure instead of propagating the
|
|
715
|
+
// exception. If the POST returns an unexpected shape (no id in response),
|
|
716
|
+
// treat as failure with a clear message.
|
|
717
|
+
let actionId;
|
|
718
|
+
try {
|
|
719
|
+
const actionRaw = await client.post('/Actions', [{
|
|
720
|
+
ticket_id: args.ticket_id,
|
|
721
|
+
note: args.resolution_note,
|
|
722
|
+
outcome_id: outcomeId,
|
|
723
|
+
}]);
|
|
724
|
+
const actionObj = Array.isArray(actionRaw) ? actionRaw[0] : actionRaw;
|
|
725
|
+
if (actionObj === null ||
|
|
726
|
+
typeof actionObj !== 'object' ||
|
|
727
|
+
!('id' in actionObj)) {
|
|
728
|
+
return JSON.stringify({
|
|
729
|
+
closed: false,
|
|
730
|
+
action_created: false,
|
|
731
|
+
ticket_id: args.ticket_id,
|
|
732
|
+
message: 'Resolution-note action POST returned an unexpected shape (no id in response); ticket NOT closed.',
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
actionId = actionObj['id'];
|
|
736
|
+
}
|
|
737
|
+
catch (actionErr) {
|
|
738
|
+
const raw = actionErr instanceof Error ? actionErr.message : String(actionErr);
|
|
739
|
+
const truncated = raw.length > 500 ? raw.slice(0, 500) + '…' : raw;
|
|
740
|
+
return JSON.stringify({
|
|
741
|
+
closed: false,
|
|
742
|
+
action_created: false,
|
|
593
743
|
ticket_id: args.ticket_id,
|
|
594
|
-
|
|
595
|
-
|
|
744
|
+
halo_error: truncated,
|
|
745
|
+
message: 'Resolution-note action POST failed; ticket NOT closed.',
|
|
746
|
+
});
|
|
747
|
+
}
|
|
596
748
|
client.invalidateCache(`ticket-actions:${args.ticket_id}*`);
|
|
749
|
+
// B2 fix: if the status flip (updateTicket) throws, return a partial-failure
|
|
750
|
+
// result that tells the caller the action was posted but status was not changed.
|
|
751
|
+
let closedStatusId;
|
|
752
|
+
try {
|
|
753
|
+
closedStatusId = await resolveClosedStatusId(client);
|
|
754
|
+
}
|
|
755
|
+
catch (statusErr) {
|
|
756
|
+
const raw = statusErr instanceof Error ? statusErr.message : String(statusErr);
|
|
757
|
+
const truncated = raw.length > 500 ? raw.slice(0, 500) + '…' : raw;
|
|
758
|
+
return JSON.stringify({
|
|
759
|
+
closed: false,
|
|
760
|
+
action_created: true,
|
|
761
|
+
ticket_id: args.ticket_id,
|
|
762
|
+
action_id: actionId,
|
|
763
|
+
halo_error: truncated,
|
|
764
|
+
message: `Resolution note was posted (action id=${actionId}) but status flip failed; ticket left in original state. ` +
|
|
765
|
+
'Retry close_halopsa_ticket without resolution_note to flip status.',
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
return await updateTicket(client, {
|
|
770
|
+
ticket_id: args.ticket_id,
|
|
771
|
+
status_id: closedStatusId,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
catch (updateErr) {
|
|
775
|
+
const raw = updateErr instanceof Error ? updateErr.message : String(updateErr);
|
|
776
|
+
const truncated = raw.length > 500 ? raw.slice(0, 500) + '…' : raw;
|
|
777
|
+
return JSON.stringify({
|
|
778
|
+
closed: false,
|
|
779
|
+
action_created: true,
|
|
780
|
+
ticket_id: args.ticket_id,
|
|
781
|
+
action_id: actionId,
|
|
782
|
+
halo_error: truncated,
|
|
783
|
+
message: `Resolution note was posted (action id=${actionId}) but status flip failed; ticket left in original state. ` +
|
|
784
|
+
'Retry close_halopsa_ticket without resolution_note to flip status.',
|
|
785
|
+
});
|
|
786
|
+
}
|
|
597
787
|
}
|
|
598
788
|
const closedStatusId = await resolveClosedStatusId(client);
|
|
599
789
|
return updateTicket(client, {
|
|
@@ -602,6 +792,182 @@ export async function closeTicket(client, args) {
|
|
|
602
792
|
});
|
|
603
793
|
}
|
|
604
794
|
// =============================================================================
|
|
795
|
+
// Bug 8 / Bug 5: validate_halopsa_create_ticket implementation
|
|
796
|
+
// =============================================================================
|
|
797
|
+
/**
|
|
798
|
+
* Default compact fields for validate_halopsa_create_ticket (Bug 3 wiring).
|
|
799
|
+
* Ensures compact responses return the key validation fields rather than the
|
|
800
|
+
* legacy compactItem() heuristic which strips unrecognised shapes.
|
|
801
|
+
*/
|
|
802
|
+
const VALIDATE_COMPACT_DEFAULTS = [
|
|
803
|
+
'valid',
|
|
804
|
+
'missing_required',
|
|
805
|
+
'unknown_fields',
|
|
806
|
+
'required_fields',
|
|
807
|
+
'tickettype_id',
|
|
808
|
+
'tickettype_name',
|
|
809
|
+
];
|
|
810
|
+
/**
|
|
811
|
+
* Map a Halo-internal field name to the equivalent MCP parameter name.
|
|
812
|
+
*
|
|
813
|
+
* Halo uses `category2`..`category6` internally while the MCP exposes
|
|
814
|
+
* `category_1`..`category_5`. Custom fields (CF* names) are not mapped to a
|
|
815
|
+
* standard param — they are returned in a separate `required_customfields`
|
|
816
|
+
* array. All other names are returned unchanged.
|
|
817
|
+
*
|
|
818
|
+
* Returns null for custom fields (CF* prefix) — callers handle these
|
|
819
|
+
* separately.
|
|
820
|
+
*/
|
|
821
|
+
export function mcpParamForHaloField(haloName) {
|
|
822
|
+
const categoryMap = {
|
|
823
|
+
category2: 'category_1',
|
|
824
|
+
category3: 'category_2',
|
|
825
|
+
category4: 'category_3',
|
|
826
|
+
category5: 'category_4',
|
|
827
|
+
category6: 'category_5',
|
|
828
|
+
};
|
|
829
|
+
if (haloName in categoryMap) {
|
|
830
|
+
return categoryMap[haloName];
|
|
831
|
+
}
|
|
832
|
+
// Custom fields (CF prefix): signal to caller that this is a CF, not a param
|
|
833
|
+
if (/^CF/i.test(haloName)) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
return haloName;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Recursively walk a Halo TicketType fields[] tree (which can nest groups),
|
|
840
|
+
* accumulating required standard fields and required custom fields.
|
|
841
|
+
*
|
|
842
|
+
* Mutation: adds to `requiredFields` and `requiredCustomFields` arrays.
|
|
843
|
+
* `allSchemaNames` accumulates every field name seen (for "unknown" detection).
|
|
844
|
+
*/
|
|
845
|
+
function walkSchemaFields(entries, requiredFields, requiredCustomFields, allSchemaNames) {
|
|
846
|
+
for (const f of entries) {
|
|
847
|
+
// Recurse into group nesting (either shape)
|
|
848
|
+
if (f.group?.fields && f.group.fields.length > 0) {
|
|
849
|
+
walkSchemaFields(f.group.fields, requiredFields, requiredCustomFields, allSchemaNames);
|
|
850
|
+
}
|
|
851
|
+
if (f.fields && f.fields.length > 0) {
|
|
852
|
+
walkSchemaFields(f.fields, requiredFields, requiredCustomFields, allSchemaNames);
|
|
853
|
+
}
|
|
854
|
+
// Field info is on fieldinfo sub-object OR directly on the field entry
|
|
855
|
+
const info = f.fieldinfo ?? f;
|
|
856
|
+
const haloName = info.name ?? '';
|
|
857
|
+
const label = info.label ?? haloName;
|
|
858
|
+
const isCustom = info.custom ?? false;
|
|
859
|
+
if (!haloName)
|
|
860
|
+
continue;
|
|
861
|
+
// Track all halo-internal names AND their MCP equivalents for known-field set
|
|
862
|
+
allSchemaNames.add(haloName);
|
|
863
|
+
const mcpName = mcpParamForHaloField(haloName);
|
|
864
|
+
if (mcpName !== null) {
|
|
865
|
+
allSchemaNames.add(mcpName);
|
|
866
|
+
}
|
|
867
|
+
// A field is required if mandatory===true OR technew===3.
|
|
868
|
+
// Skip display_type=3 (read-only) and display_type=4 (hidden) — these are
|
|
869
|
+
// server-computed and cannot be provided by callers.
|
|
870
|
+
const dt = info.display_type;
|
|
871
|
+
const isReadOnly = dt === 3 || dt === 4;
|
|
872
|
+
const isRequired = !isReadOnly && (info.mandatory === true || info.technew === 3);
|
|
873
|
+
if (!isRequired)
|
|
874
|
+
continue;
|
|
875
|
+
if (isCustom || mcpName === null) {
|
|
876
|
+
// Custom field (CF*): append to required_customfields
|
|
877
|
+
const cfId = info.id ?? 0;
|
|
878
|
+
requiredCustomFields.push({ id: cfId, name: haloName });
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
// Standard field — use the MCP-translated name
|
|
882
|
+
requiredFields.push({ name: mcpName, label, custom: false });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Pure-local validation of a proposed ticket payload against the cached
|
|
888
|
+
* tickettype schema. No write is performed — this is a dry-run helper.
|
|
889
|
+
*
|
|
890
|
+
* Required-field detection logic:
|
|
891
|
+
* Halo's TicketType details response includes a `fields[]` array where each
|
|
892
|
+
* entry has a `fieldinfo` sub-object. The `mandatory` flag on `fieldinfo`
|
|
893
|
+
* indicates a required field. Additionally, `technew` on `fieldinfo` where
|
|
894
|
+
* the value equals 3 indicates "required for tech/API create". When either
|
|
895
|
+
* condition is true, the field is counted as required.
|
|
896
|
+
*
|
|
897
|
+
* Fields nested inside `group.fields[]` or `fields[]` (both group-nesting
|
|
898
|
+
* shapes Halo uses across versions) are also walked — not just top-level.
|
|
899
|
+
*
|
|
900
|
+
* Halo-internal category field names (category2..category6) are translated
|
|
901
|
+
* to MCP equivalents (category_1..category_5). Custom fields (CF* prefix)
|
|
902
|
+
* are reported in a separate `required_customfields` array.
|
|
903
|
+
*
|
|
904
|
+
* Standard ticket fields (summary, client_id, tickettype_id) are always
|
|
905
|
+
* required regardless of the tickettype schema.
|
|
906
|
+
*/
|
|
907
|
+
export async function validateCreateTicket(client, args) {
|
|
908
|
+
// Fetch the tickettype with full field details.
|
|
909
|
+
const tickettype = await client.getCached(`/TicketType/${args.tickettype_id}`, { includedetails: true }, {
|
|
910
|
+
enabled: true,
|
|
911
|
+
ttl: TTL.TICKET_TYPES,
|
|
912
|
+
keyPrefix: `tickettype:${args.tickettype_id}:details`,
|
|
913
|
+
});
|
|
914
|
+
// Standard fields always required on create.
|
|
915
|
+
const STANDARD_REQUIRED = ['summary', 'client_id', 'tickettype_id'];
|
|
916
|
+
// Build the list of required fields from the tickettype schema.
|
|
917
|
+
const schemaFields = Array.isArray(tickettype.fields)
|
|
918
|
+
? tickettype.fields
|
|
919
|
+
: [];
|
|
920
|
+
const requiredFromSchema = [];
|
|
921
|
+
const requiredCustomFields = [];
|
|
922
|
+
const allSchemaNames = new Set();
|
|
923
|
+
// Recursively walk field tree (handles nested groups)
|
|
924
|
+
walkSchemaFields(schemaFields, requiredFromSchema, requiredCustomFields, allSchemaNames);
|
|
925
|
+
// Combine standard required fields with schema-derived required fields,
|
|
926
|
+
// deduplicating by name.
|
|
927
|
+
const allRequiredFields = [
|
|
928
|
+
...STANDARD_REQUIRED.map((n) => ({ name: n, label: n, custom: false })),
|
|
929
|
+
...requiredFromSchema.filter((f) => !STANDARD_REQUIRED.includes(f.name)),
|
|
930
|
+
];
|
|
931
|
+
const providedKeys = new Set(Object.keys(args.fields));
|
|
932
|
+
// Missing: required fields not in the provided payload.
|
|
933
|
+
const missingRequired = allRequiredFields
|
|
934
|
+
.filter((f) => !providedKeys.has(f.name))
|
|
935
|
+
.map((f) => f.name);
|
|
936
|
+
// Unknown: provided fields not in the standard set and not in the schema.
|
|
937
|
+
// Include both Halo-internal names and MCP equivalents in the known set
|
|
938
|
+
// so callers passing either form are not flagged.
|
|
939
|
+
const knownFieldNames = new Set([
|
|
940
|
+
...STANDARD_REQUIRED,
|
|
941
|
+
...allSchemaNames,
|
|
942
|
+
// Common optional standard fields that are always valid.
|
|
943
|
+
'details_html', 'category_1', 'category_2', 'category_3', 'category_4', 'category_5',
|
|
944
|
+
'priority_id', 'status_id', 'source',
|
|
945
|
+
'agent_id', 'team_id', 'site_id', 'user_id', 'parent_id',
|
|
946
|
+
'dateoccurred', 'startdate', 'customfields', 'estimate', 'estimatedays',
|
|
947
|
+
'milestone_id', 'asset_id', 'asset_ids', 'assets',
|
|
948
|
+
// Halo-internal category names always valid
|
|
949
|
+
'category2', 'category3', 'category4', 'category5', 'category6',
|
|
950
|
+
]);
|
|
951
|
+
const unknownFields = [...providedKeys].filter((k) => !knownFieldNames.has(k));
|
|
952
|
+
const valid = missingRequired.length === 0 && unknownFields.length === 0;
|
|
953
|
+
const result = {
|
|
954
|
+
valid,
|
|
955
|
+
missing_required: missingRequired,
|
|
956
|
+
unknown_fields: unknownFields,
|
|
957
|
+
required_fields: allRequiredFields,
|
|
958
|
+
tickettype_id: args.tickettype_id,
|
|
959
|
+
tickettype_name: tickettype.name ?? '',
|
|
960
|
+
};
|
|
961
|
+
// Only include required_customfields when there are any, to keep compact
|
|
962
|
+
// responses lean for ticket types with no custom field requirements.
|
|
963
|
+
if (requiredCustomFields.length > 0) {
|
|
964
|
+
result['required_customfields'] = requiredCustomFields;
|
|
965
|
+
}
|
|
966
|
+
// Bug 3: wire compact defaults so compact mode returns the key validation fields.
|
|
967
|
+
const formatOpts = withCompactDefaults(args.format_options ?? { format: 'standard' }, VALIDATE_COMPACT_DEFAULTS);
|
|
968
|
+
return formatResponse(result, formatOpts);
|
|
969
|
+
}
|
|
970
|
+
// =============================================================================
|
|
605
971
|
// Tool Definitions
|
|
606
972
|
// =============================================================================
|
|
607
973
|
export const listTicketsTool = {
|
|
@@ -624,19 +990,19 @@ export const searchTicketsTool = {
|
|
|
624
990
|
};
|
|
625
991
|
export const createTicketTool = {
|
|
626
992
|
name: 'create_halopsa_ticket',
|
|
627
|
-
description: 'Create a new HaloPSA ticket. Required: summary, tickettype_id, client_id.
|
|
993
|
+
description: 'Create a new HaloPSA ticket. Required: summary, tickettype_id, client_id. Invalidates ticket cache. Estimate auto-computed from startdate->targetdate (5x8 business hrs) if omitted; pass `estimate` to override. Pass asset_id/asset_ids to link assets on creation.',
|
|
628
994
|
schema: CreateTicketArgsSchema,
|
|
629
995
|
handler: createTicket,
|
|
630
996
|
};
|
|
631
997
|
export const updateTicketTool = {
|
|
632
998
|
name: 'update_halopsa_ticket',
|
|
633
|
-
description: 'Update an existing HaloPSA ticket. Performs read-before-write merge (
|
|
999
|
+
description: 'Update an existing HaloPSA ticket. Performs read-before-write merge (REPLACE not PATCH). Update by explicit id only. Invalidates ticket cache. Pass asset_id/asset_ids to link assets on update.',
|
|
634
1000
|
schema: UpdateTicketArgsSchema,
|
|
635
1001
|
handler: updateTicket,
|
|
636
1002
|
};
|
|
637
1003
|
export const closeTicketTool = {
|
|
638
1004
|
name: 'close_halopsa_ticket',
|
|
639
|
-
description: 'Close a HaloPSA ticket. Resolves the closed-status id dynamically via /Status (cached). Optionally posts a resolution note as a final action before status change.',
|
|
1005
|
+
description: 'Close a HaloPSA ticket. Resolves the closed-status id dynamically via /Status (cached). Optionally posts a resolution note as a final action before status change. When resolution_note is supplied, an outcome_id is required by Halo — provide resolution_outcome_id explicitly or the MCP auto-resolves the first is_resolution outcome for the tickettype (cached 1h).',
|
|
640
1006
|
schema: CloseTicketArgsSchema,
|
|
641
1007
|
handler: closeTicket,
|
|
642
1008
|
};
|
|
@@ -646,4 +1012,10 @@ export const deleteTicketTool = {
|
|
|
646
1012
|
schema: DeleteTicketArgsSchema,
|
|
647
1013
|
handler: deleteTicket,
|
|
648
1014
|
};
|
|
1015
|
+
export const validateCreateTicketTool = {
|
|
1016
|
+
name: 'validate_halopsa_create_ticket',
|
|
1017
|
+
description: 'Pure local dry-run validation of a proposed ticket payload against the cached tickettype schema. No Halo write is performed. Returns {valid, missing_required[], unknown_fields[], required_fields[]} so callers can catch missing mandatory fields before calling create_halopsa_ticket. Fetches tickettype via GET /TicketType/{id}?includedetails=true (cached 1h). Use list_halopsa_tickettypes to look up tickettype_id values. v1.6: recurses into nested group.fields[] and translates Halo-internal category2..6 to MCP category_1..5 field names; required-customfield ids surfaced separately when present.',
|
|
1018
|
+
schema: ValidateCreateTicketArgsSchema,
|
|
1019
|
+
handler: validateCreateTicket,
|
|
1020
|
+
};
|
|
649
1021
|
//# sourceMappingURL=tickets.js.map
|