@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.
Files changed (51) hide show
  1. package/README.md +8 -1
  2. package/dist/api/client.d.ts +51 -2
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +124 -9
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/api/errors.d.ts +16 -3
  7. package/dist/api/errors.d.ts.map +1 -1
  8. package/dist/api/errors.js +107 -30
  9. package/dist/api/errors.js.map +1 -1
  10. package/dist/cache/memory-cache.d.ts +1 -0
  11. package/dist/cache/memory-cache.d.ts.map +1 -1
  12. package/dist/cache/memory-cache.js +6 -0
  13. package/dist/cache/memory-cache.js.map +1 -1
  14. package/dist/cache/prewarm.d.ts +1 -0
  15. package/dist/cache/prewarm.d.ts.map +1 -1
  16. package/dist/cache/prewarm.js +8 -0
  17. package/dist/cache/prewarm.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +12 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/tools/batch-operations.js +10 -10
  22. package/dist/tools/batch-operations.js.map +1 -1
  23. package/dist/tools/budgets.d.ts.map +1 -1
  24. package/dist/tools/budgets.js +7 -4
  25. package/dist/tools/budgets.js.map +1 -1
  26. package/dist/tools/milestones.d.ts.map +1 -1
  27. package/dist/tools/milestones.js +12 -9
  28. package/dist/tools/milestones.js.map +1 -1
  29. package/dist/tools/registrations.d.ts +8 -3
  30. package/dist/tools/registrations.d.ts.map +1 -1
  31. package/dist/tools/registrations.js +21 -12
  32. package/dist/tools/registrations.js.map +1 -1
  33. package/dist/tools/ticket-actions.d.ts +46 -0
  34. package/dist/tools/ticket-actions.d.ts.map +1 -1
  35. package/dist/tools/ticket-actions.js +236 -32
  36. package/dist/tools/ticket-actions.js.map +1 -1
  37. package/dist/tools/ticket-reference-data.d.ts +70 -1
  38. package/dist/tools/ticket-reference-data.d.ts.map +1 -1
  39. package/dist/tools/ticket-reference-data.js +340 -11
  40. package/dist/tools/ticket-reference-data.js.map +1 -1
  41. package/dist/tools/tickets.d.ts +53 -0
  42. package/dist/tools/tickets.d.ts.map +1 -1
  43. package/dist/tools/tickets.js +394 -22
  44. package/dist/tools/tickets.js.map +1 -1
  45. package/dist/types/tickets.d.ts +127 -0
  46. package/dist/types/tickets.d.ts.map +1 -1
  47. package/dist/utils/formatter.d.ts +18 -0
  48. package/dist/utils/formatter.d.ts.map +1 -1
  49. package/dist/utils/formatter.js +108 -22
  50. package/dist/utils/formatter.js.map +1 -1
  51. package/package.json +1 -1
@@ -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
- const cacheKey = `tickets:client_${args.client_id ?? 'all'}:status_${args.status_id ?? 'all'}`;
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
- const format = args.format_options?.format ?? 'compact';
374
- return formatResponse(stripWriteResponse(created, format, 'ticket'), { format });
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 is REPLACE not PATCH read-before-write merge to
425
- // preserve fields the caller didn't touch (mirrors updateAsset).
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
- const format = args.format_options?.format ?? 'compact';
466
- return formatResponse(stripWriteResponse(response, format, 'ticket'), { format });
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
- await client.post('/Actions', [{
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
- note: args.resolution_note,
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. Body is sent as POST /Tickets with array wrapper [{ ... }] (HaloPSA convention). Invalidates ticket cache. Estimate auto-computed from startdate->targetdate (5x8 business hrs) if omitted; pass `estimate` to override.',
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 (Halo POST is REPLACE not PATCH). Update by explicit id only no force/dedup flags (tickets are append-only events). Invalidates ticket cache.',
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