@wilnertech/halopsa-mcp-server 1.5.0 → 1.7.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 (43) hide show
  1. package/README.md +7 -0
  2. package/dist/api/client.d.ts +39 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +50 -0
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/api/errors.d.ts.map +1 -1
  7. package/dist/api/errors.js +1 -2
  8. package/dist/api/errors.js.map +1 -1
  9. package/dist/cache/memory-cache.d.ts.map +1 -1
  10. package/dist/cache/memory-cache.js +5 -0
  11. package/dist/cache/memory-cache.js.map +1 -1
  12. package/dist/cache/prewarm.d.ts +1 -0
  13. package/dist/cache/prewarm.d.ts.map +1 -1
  14. package/dist/cache/prewarm.js +8 -0
  15. package/dist/cache/prewarm.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +12 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/tools/batch-operations.js +10 -10
  20. package/dist/tools/batch-operations.js.map +1 -1
  21. package/dist/tools/registrations.d.ts +5 -1
  22. package/dist/tools/registrations.d.ts.map +1 -1
  23. package/dist/tools/registrations.js +28 -3
  24. package/dist/tools/registrations.js.map +1 -1
  25. package/dist/tools/ticket-actions.d.ts +1 -18
  26. package/dist/tools/ticket-actions.d.ts.map +1 -1
  27. package/dist/tools/ticket-actions.js +244 -53
  28. package/dist/tools/ticket-actions.js.map +1 -1
  29. package/dist/tools/ticket-reference-data.d.ts +28 -0
  30. package/dist/tools/ticket-reference-data.d.ts.map +1 -1
  31. package/dist/tools/ticket-reference-data.js +218 -20
  32. package/dist/tools/ticket-reference-data.js.map +1 -1
  33. package/dist/tools/tickets.d.ts +20 -0
  34. package/dist/tools/tickets.d.ts.map +1 -1
  35. package/dist/tools/tickets.js +275 -38
  36. package/dist/tools/tickets.js.map +1 -1
  37. package/dist/types/tickets.d.ts +64 -5
  38. package/dist/types/tickets.d.ts.map +1 -1
  39. package/dist/utils/formatter.d.ts +18 -0
  40. package/dist/utils/formatter.d.ts.map +1 -1
  41. package/dist/utils/formatter.js +108 -22
  42. package/dist/utils/formatter.js.map +1 -1
  43. package/package.json +1 -1
@@ -5,14 +5,19 @@
5
5
  * Tool inventory (71 total):
6
6
  * Assets (11), Users (7), Sites (4), Clients (3),
7
7
  * Reference data (4), Ticket reference data (6 — incl. outcomes),
8
- * Tickets (8 — incl. validateCreateTicket), Ticket actions (4 — incl. SLA hold/release),
8
+ * Tickets (8 — incl. validateCreateTicket; getTicketsBatch counted under Batch),
9
+ * Ticket actions (4 — incl. SLA hold/release),
9
10
  * Ticket custom fields (2),
10
11
  * Batch operations (5 — assets, users, sites, tickets, bulk-update),
11
12
  * Ticket links (1 — read-side only),
12
13
  * Workflows (6 — list/get/list-steps/create/update/delete),
13
14
  * Ticket milestones (5 — list/set/add/remove + get-current),
14
15
  * Budgets (5 — list/create/delete BudgetType, get/set per-ticket)
16
+ *
17
+ * COUNT BREAKDOWN: 11+7+4+3+4+6+8+4+2+5+1+6+5+5 = 71
18
+ * The `allTools` array is the authoritative source; verify with allTools.length.
15
19
  */
20
+ import { z } from 'zod';
16
21
  // Asset tools (11)
17
22
  import { listAssetsTool, getAssetTool, searchAssetsBySerialTool, searchAssetsByHostnameTool, createAssetTool, updateAssetTool, deleteAssetTool, listAssetCustomFieldsTool, getAssetFieldSchemaTool, findAssetMatchTool, scanAssetDuplicatesTool, } from './assets.js';
18
23
  // User tools (7)
@@ -25,7 +30,7 @@ import { listClientsTool, getClientTool, searchClientsTool, } from './clients.js
25
30
  import { listAssetTypesTool, listAssetStatusesTool, listContactTypesTool, listAgentsTool, } from './reference-data.js';
26
31
  // Ticket reference data tools (6)
27
32
  import { listTicketTypesTool, getTicketTypeTool, listTicketStatusesTool, listTicketPrioritiesTool, listTicketCategoriesTool, listTicketOutcomesTool, } from './ticket-reference-data.js';
28
- // Ticket tools (9 here; getTicketsBatchTool is the 10th and lives in batch-operations.ts)
33
+ // Ticket tools (8 getTicketsBatchTool lives in batch-operations.ts and is counted there)
29
34
  import { listTicketsTool, getTicketTool, searchTicketsTool, createTicketTool, updateTicketTool, closeTicketTool, deleteTicketTool, validateCreateTicketTool, } from './tickets.js';
30
35
  // Ticket action tools (4)
31
36
  import { addTicketActionTool, listTicketActionsTool, holdTicketSlaTool, releaseTicketSlaTool, } from './ticket-actions.js';
@@ -83,7 +88,7 @@ const allTools = [
83
88
  listTicketPrioritiesTool,
84
89
  listTicketCategoriesTool,
85
90
  listTicketOutcomesTool,
86
- // Tickets (9)
91
+ // Tickets (8 — getTicketsBatchTool counted under Batch operations below)
87
92
  listTicketsTool,
88
93
  getTicketTool,
89
94
  searchTicketsTool,
@@ -128,9 +133,29 @@ const allTools = [
128
133
  getTicketBudgetTool,
129
134
  setTicketBudgetTool,
130
135
  ];
136
+ /**
137
+ * Diagnostic meta-tool — returns the names of every tool currently reachable
138
+ * in the registry. Use to verify the server\'s exposed surface, or to confirm
139
+ * a registration succeeded when a tool call returns "not found".
140
+ */
141
+ function createListLoadedToolsMeta(registry) {
142
+ return {
143
+ name: 'list_halopsa_loaded_tools',
144
+ description: 'List every HaloPSA tool currently reachable in the registry. Diagnostic helper for verifying the server\'s exposed surface — useful when a tool call returns "not found" and you need to confirm whether registration succeeded.',
145
+ schema: z.object({}),
146
+ handler: async () => {
147
+ const tools = registry.getToolSchemas().map((t) => t.name).sort();
148
+ return JSON.stringify({
149
+ total: tools.length,
150
+ tools,
151
+ });
152
+ },
153
+ };
154
+ }
131
155
  export function registerAllTools(registry) {
132
156
  for (const tool of allTools) {
133
157
  registry.registerZod(tool);
134
158
  }
159
+ registry.registerZod(createListLoadedToolsMeta(registry));
135
160
  }
136
161
  //# sourceMappingURL=registrations.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"registrations.js","sourceRoot":"","sources":["../../src/tools/registrations.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"registrations.js","sourceRoot":"","sources":["../../src/tools/registrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,mBAAmB;AACnB,OAAO,EACL,cAAc,EACd,YAAY,EACZ,wBAAwB,EACxB,0BAA0B,EAC1B,eAAe,EACf,eAAe,EACf,eAAe,EACf,yBAAyB,EACzB,uBAAuB,EACvB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,aAAa,CAAC;AAErB,iBAAiB;AACjB,OAAO,EACL,aAAa,EACb,WAAW,EACX,sBAAsB,EACtB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,YAAY,CAAC;AAEpB,iBAAiB;AACjB,OAAO,EACL,aAAa,EACb,WAAW,EACX,cAAc,EACd,iBAAiB,GAClB,MAAM,YAAY,CAAC;AAEpB,mBAAmB;AACnB,OAAO,EACL,eAAe,EACf,aAAa,EACb,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAEtB,2BAA2B;AAC3B,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,EACpB,cAAc,GACf,MAAM,qBAAqB,CAAC;AAE7B,kCAAkC;AAClC,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,sBAAsB,EACtB,wBAAwB,EACxB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,4BAA4B,CAAC;AAEpC,2FAA2F;AAC3F,OAAO,EACL,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,cAAc,CAAC;AAEtB,0BAA0B;AAC1B,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,gCAAgC;AAChC,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,2BAA2B,CAAC;AAEnC,6BAA6B;AAC7B,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,uBAAuB,CAAC;AAE/B,yBAAyB;AACzB,OAAO,EACL,qBAAqB,GACtB,MAAM,mBAAmB,CAAC;AAE3B,qBAAqB;AACrB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AAExB,6BAA6B;AAC7B,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACvB,sBAAsB,EACtB,yBAAyB,EACzB,uBAAuB,GACxB,MAAM,iBAAiB,CAAC;AAEzB,mBAAmB;AACnB,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,MAAM,QAAQ,GAAG;IACf,cAAc;IACd,cAAc;IACd,YAAY;IACZ,wBAAwB;IACxB,0BAA0B;IAC1B,eAAe;IACf,eAAe;IACf,eAAe;IACf,yBAAyB;IACzB,uBAAuB;IACvB,kBAAkB;IAClB,uBAAuB;IACvB,YAAY;IACZ,aAAa;IACb,WAAW;IACX,sBAAsB;IACtB,cAAc;IACd,cAAc;IACd,iBAAiB;IACjB,sBAAsB;IACtB,YAAY;IACZ,aAAa;IACb,WAAW;IACX,cAAc;IACd,iBAAiB;IACjB,cAAc;IACd,eAAe;IACf,aAAa;IACb,iBAAiB;IACjB,qBAAqB;IACrB,kBAAkB;IAClB,qBAAqB;IACrB,oBAAoB;IACpB,cAAc;IACd,4BAA4B;IAC5B,mBAAmB;IACnB,iBAAiB;IACjB,sBAAsB;IACtB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,yEAAyE;IACzE,eAAe;IACf,aAAa;IACb,iBAAiB;IACjB,gBAAgB;IAChB,gBAAgB;IAChB,eAAe;IACf,gBAAgB;IAChB,wBAAwB;IACxB,qBAAqB;IACrB,mBAAmB;IACnB,qBAAqB;IACrB,iBAAiB;IACjB,oBAAoB;IACpB,2BAA2B;IAC3B,0BAA0B;IAC1B,wBAAwB;IACxB,uBAAuB;IACvB,kBAAkB;IAClB,iBAAiB;IACjB,iBAAiB;IACjB,mBAAmB;IACnB,qBAAqB;IACrB,mBAAmB;IACnB,qBAAqB;IACrB,gBAAgB;IAChB,iBAAiB;IACjB,eAAe;IACf,qBAAqB;IACrB,kBAAkB;IAClB,kBAAkB;IAClB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,uBAAuB;IACvB,sBAAsB;IACtB,yBAAyB;IACzB,uBAAuB;IACvB,cAAc;IACd,mBAAmB;IACnB,oBAAoB;IACpB,oBAAoB;IACpB,mBAAmB;IACnB,mBAAmB;CACpB,CAAC;AAEF;;;;GAIG;AACH,SAAS,yBAAyB,CAAC,QAAsB;IACvD,OAAO;QACL,IAAI,EAAE,2BAA2B;QACjC,WAAW,EAAE,kOAAkO;QAC/O,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;QACpB,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,MAAM,KAAK,GAAG,QAAQ,CAAC,cAAc,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;YAClE,OAAO,IAAI,CAAC,SAAS,CAAC;gBACpB,KAAK,EAAE,KAAK,CAAC,MAAM;gBACnB,KAAK;aACN,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,QAAsB;IACrD,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC;IACD,QAAQ,CAAC,WAAW,CAAC,yBAAyB,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC5D,CAAC"}
@@ -17,6 +17,7 @@ export declare const AddTicketActionArgsSchema: z.ZodObject<{
17
17
  ticket_id: z.ZodNumber;
18
18
  note: z.ZodString;
19
19
  outcome_id: z.ZodOptional<z.ZodNumber>;
20
+ outcome_name: z.ZodOptional<z.ZodString>;
20
21
  email_reply: z.ZodOptional<z.ZodBoolean>;
21
22
  hiddenfromuser: z.ZodOptional<z.ZodBoolean>;
22
23
  timetaken: z.ZodOptional<z.ZodNumber>;
@@ -52,27 +53,9 @@ export declare const ListTicketActionsArgsSchema: z.ZodObject<{
52
53
  export declare const HoldTicketSlaArgsSchema: z.ZodObject<{
53
54
  ticket_id: z.ZodNumber;
54
55
  reason: z.ZodOptional<z.ZodString>;
55
- format_options: z.ZodOptional<z.ZodObject<{
56
- format: z.ZodOptional<z.ZodEnum<{
57
- compact: "compact";
58
- standard: "standard";
59
- detailed: "detailed";
60
- }>>;
61
- fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
62
- omit_empty: z.ZodOptional<z.ZodBoolean>;
63
- }, z.core.$strip>>;
64
56
  }, z.core.$strip>;
65
57
  export declare const ReleaseTicketSlaArgsSchema: z.ZodObject<{
66
58
  ticket_id: z.ZodNumber;
67
- format_options: z.ZodOptional<z.ZodObject<{
68
- format: z.ZodOptional<z.ZodEnum<{
69
- compact: "compact";
70
- standard: "standard";
71
- detailed: "detailed";
72
- }>>;
73
- fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
74
- omit_empty: z.ZodOptional<z.ZodBoolean>;
75
- }, z.core.$strip>>;
76
59
  }, z.core.$strip>;
77
60
  export declare function addTicketAction(client: HaloPSAAPIClient, args: z.infer<typeof AddTicketActionArgsSchema>): Promise<string>;
78
61
  export declare function listTicketActions(client: HaloPSAAPIClient, args: z.infer<typeof ListTicketActionsArgsSchema>): Promise<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"ticket-actions.d.ts","sourceRoot":"","sources":["../../src/tools/ticket-actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAevD,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;iBAyBpC,CAAC;AAEH,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;iBAQtC,CAAC;AAEH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;iBAMlC,CAAC;AAEH,eAAO,MAAM,0BAA0B;;;;;;;;;;;iBAIrC,CAAC;AA2IH,wBAAsB,eAAe,CACnC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,GAC9C,OAAO,CAAC,MAAM,CAAC,CAsDjB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,GAChD,OAAO,CAAC,MAAM,CAAC,CA0BjB;AAaD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,GAC5C,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAED,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAMD,eAAO,MAAM,mBAAmB,EAAE,iBAKjC,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,iBAKnC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAC;AAEF,eAAO,MAAM,oBAAoB,EAAE,iBAKlC,CAAC"}
1
+ {"version":3,"file":"ticket-actions.d.ts","sourceRoot":"","sources":["../../src/tools/ticket-actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAgBvD,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;iBA8BpC,CAAC;AAEH,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;iBAQtC,CAAC;AAMH,eAAO,MAAM,uBAAuB;;;iBAKlC,CAAC;AAEH,eAAO,MAAM,0BAA0B;;iBAGrC,CAAC;AA0MH,wBAAsB,eAAe,CACnC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,GAC9C,OAAO,CAAC,MAAM,CAAC,CAmKjB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,GAChD,OAAO,CAAC,MAAM,CAAC,CAiCjB;AAaD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,GAC5C,OAAO,CAAC,MAAM,CAAC,CAuBjB;AAED,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,GAC/C,OAAO,CAAC,MAAM,CAAC,CAoBjB;AAMD,eAAO,MAAM,mBAAmB,EAAE,iBAKjC,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,iBAKnC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAC;AAEF,eAAO,MAAM,oBAAoB,EAAE,iBAKlC,CAAC"}
@@ -12,9 +12,10 @@
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { HaloPSAError } from '../api/errors.js';
15
- import { formatResponse, stripWriteResponse } from '../utils/formatter.js';
15
+ import { formatResponse, stripWriteResponse, withCompactDefaults } from '../utils/formatter.js';
16
16
  import { TTL } from '../cache/memory-cache.js';
17
17
  import { FormatOptionsSchema } from '../schemas/common.js';
18
+ import { getOutcomesForTicketType, getOutcomeByName } from './ticket-reference-data.js';
18
19
  // =============================================================================
19
20
  // Schemas
20
21
  // =============================================================================
@@ -24,20 +25,25 @@ export const AddTicketActionArgsSchema = z.object({
24
25
  note: z.string().min(1)
25
26
  .describe('Note body (plain text or HTML)'),
26
27
  outcome_id: z.number().int().optional()
27
- .describe('Outcome enum ID (Public Note, Private Note, Email Reply, etc.). Halo REQUIRES this on most tenants — without it, POST /Actions returns 400 "An Outcome must be entered for this Action". Look up valid outcomes for your agent in Halo admin Configuration Tickets Outcomes.'),
28
+ .describe('Outcome enum ID (Public Note, Private Note, Email Reply, etc.). Halo REQUIRES this on most tenants — without it, POST /Actions returns 400 "An Outcome must be entered for this Action". Use list_halopsa_ticket_outcomes with the same tickettype_id to discover the IDs. Either outcome_id OR outcome_name must be supplied; if both are supplied, outcome_id wins.'),
29
+ outcome_name: z.string().optional()
30
+ .describe('Outcome name as an alternative to outcome_id (e.g. "Private Note", "Email Reply"). Case-insensitive exact match against the global outcomes list (cached 1h). Either outcome_id OR outcome_name must be supplied; if both, outcome_id wins.'),
28
31
  email_reply: z.boolean().optional()
29
32
  .describe('If true, sends the note as an email reply to the ticket requester'),
30
33
  hiddenfromuser: z.boolean().optional()
31
34
  .describe('If true, hides the action from the customer portal'),
32
35
  // Time-entry fields (Bug 1 — billing)
33
- timetaken: z.number().optional()
34
- .describe('Hours worked on this action (decimal). Drives ticket.timetaken rollup and billing.'),
35
- chargehours: z.number().optional()
36
- .describe('Chargeable hours (may differ from timetaken if some time is non-chargeable).'),
36
+ timetaken: z.number().min(0).optional()
37
+ // Hours must be non-negative wire-key fix in v1.6.0 activated live billing path.
38
+ .describe('Hours worked on this action (decimal, >= 0). Drives ticket.timetaken rollup and billing.'),
39
+ chargehours: z.number().min(0).optional()
40
+ // Hours must be non-negative — wire-key fix in v1.6.0 activated live billing path.
41
+ .describe('Chargeable hours (>= 0; may differ from timetaken if some time is non-chargeable).'),
37
42
  nonchargeable: z.boolean().optional()
38
43
  .describe('True to mark this action non-billable. Default false.'),
39
- chargerate: z.number().optional()
40
- .describe('Override hourly rate. Defaults to the ticket\'s contract rate.'),
44
+ chargerate: z.number().int().min(0).optional()
45
+ // non-negative integer FK per v1.6.0 billing-guard principle
46
+ .describe('Override hourly rate (non-negative integer FK). Defaults to the ticket\'s contract rate.'),
41
47
  start_datetime: z.string().optional()
42
48
  .describe('ISO 8601 when work started (e.g., 2026-05-12T09:00:00Z). If both start/end supplied, timetaken can be derived.'),
43
49
  end_datetime: z.string().optional()
@@ -53,65 +59,86 @@ export const ListTicketActionsArgsSchema = z.object({
53
59
  .describe('Page number (1-indexed)'),
54
60
  format_options: FormatOptionsSchema,
55
61
  });
62
+ // R3 Finding 4: format_options dropped — both SLA tools return fixed-shape
63
+ // sentinel objects (held/released boolean + ticket_id + note). Accepting
64
+ // format_options without honoring it was a broken contract; the shape is
65
+ // authoritative and small, so a single pretty-printed JSON output is correct.
56
66
  export const HoldTicketSlaArgsSchema = z.object({
57
67
  ticket_id: z.number().int()
58
68
  .describe('HaloPSA ticket ID whose SLA clock should be held'),
59
69
  reason: z.string().optional()
60
70
  .describe('Note to attach to the hold action. Defaults to "SLA hold — waiting on customer".'),
61
- format_options: FormatOptionsSchema,
62
71
  });
63
72
  export const ReleaseTicketSlaArgsSchema = z.object({
64
73
  ticket_id: z.number().int()
65
74
  .describe('HaloPSA ticket ID whose SLA clock should be released'),
66
- format_options: FormatOptionsSchema,
67
75
  });
68
76
  // =============================================================================
69
77
  // Helpers
70
78
  // =============================================================================
71
79
  const ACTION_PREVIEW_LEN = 200;
80
+ /**
81
+ * Default compact fields for add_halopsa_ticket_action (Bug 3 wiring).
82
+ * Matches the shape that compactAction() produces plus standard billing fields.
83
+ *
84
+ * Note: Halo's Actions schema does not declare a "date work performed" field
85
+ * separately from datetime (action submission). The field `actiondate` is NOT
86
+ * declared on components.schemas.Actions in halopsa-swagger.json — it appears
87
+ * only on components.schemas.Outgoingemail. Future v1.7+ work item: live-tenant
88
+ * probe to confirm whether datetime suffices for work-performed semantics.
89
+ */
90
+ const ACTION_COMPACT_DEFAULTS = [
91
+ 'id', 'ticket_id', 'outcome_id', 'outcome', 'timetaken', 'actionchargehours', 'chargerate',
92
+ 'who', 'datetime', 'note_preview',
93
+ ];
72
94
  /**
73
95
  * Regex to detect Halo 400 messages that indicate a missing or invalid
74
96
  * outcome_id — covers both "An Outcome must be entered for this Action"
75
97
  * and "You do not have access to this Action at the moment."
76
98
  */
77
99
  const OUTCOME_ERROR_RE = /Outcome must be entered|access to this Action/i;
78
- /**
79
- * Scan an arbitrary object for any key whose name contains "outcome"
80
- * (case-insensitive) and whose value is an array. Returns that array,
81
- * or an empty array if nothing is found.
82
- */
83
- function extractOutcomeHint(obj) {
84
- for (const key of Object.keys(obj)) {
85
- if (/outcome/i.test(key)) {
86
- const val = obj[key];
87
- if (Array.isArray(val)) {
88
- return val;
89
- }
90
- }
91
- }
92
- return [];
93
- }
94
100
  /**
95
101
  * When POST /Actions returns a 400 matching the outcome-required pattern,
96
102
  * fetch the parent ticket and its tickettype config, then return a structured
97
103
  * "refusal" object (not a thrown error) so the caller sees discovery info.
104
+ *
105
+ * Bug 4 enrichments:
106
+ * - tickettype_name resolved from cached /TicketType list (not from ticket.tickettype_name
107
+ * which is often absent); falls back to ticket.tickettype_name only if cache misses.
108
+ * - valid_outcomes_hint populated via getOutcomesForTicketType() helper (sorted by
109
+ * sequence then id, sliced to first 10), not via fragile extractOutcomeHint regex.
110
+ * - If any secondary lookup fails, discovery_warning documents what failed; the refusal
111
+ * is always returned (never re-thrown).
112
+ */
113
+ /**
114
+ * Clamp a halo error message to 500 characters for inclusion in refusal objects.
115
+ * 500 chars matches the bodyExcerpt budget in errors.ts and gives enough context
116
+ * to diagnose most API errors without bloating token output.
117
+ * If truncated, appends the ellipsis character and sets halo_error_truncated: true
118
+ * on the result object. Returns the (possibly clamped) message string.
98
119
  */
120
+ function clampHaloError(msg, result) {
121
+ const MAX = 500;
122
+ if (msg.length <= MAX)
123
+ return msg;
124
+ result['halo_error_truncated'] = true;
125
+ return msg.slice(0, MAX) + '…';
126
+ }
99
127
  async function buildOutcomeDiscoveryRefusal(client, ticketId, haloError) {
100
128
  const discoveryWarnings = [];
101
129
  // --- Step 1: fetch the parent ticket ---
102
130
  let tickettypeId;
103
- let tickettypeName;
131
+ let tickettypeNameFallback;
104
132
  try {
105
133
  const ticket = await client.get(`/Tickets/${ticketId}`, { includedetails: true });
106
134
  tickettypeId = ticket.tickettype_id;
107
- tickettypeName = ticket.tickettype_name;
135
+ tickettypeNameFallback = ticket.tickettype_name;
108
136
  }
109
137
  catch (innerErr) {
110
138
  const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
111
139
  discoveryWarnings.push('ticket lookup failed: ' + msg);
112
- return {
140
+ const earlyResult = {
113
141
  action_created: false,
114
- halo_error: haloError,
115
142
  diagnosis: 'outcome_id is required for this tickettype and was either missing or invalid',
116
143
  tickettype_id: null,
117
144
  tickettype_name: null,
@@ -120,29 +147,62 @@ async function buildOutcomeDiscoveryRefusal(client, ticketId, haloError) {
120
147
  remediation: 'Either retry with a valid outcome_id (check Halo admin: Configuration > Tickets > Outcomes), ' +
121
148
  'or fall back to update_halopsa_ticket to set details_html / status_id without going through the Actions surface.',
122
149
  };
150
+ earlyResult['halo_error'] = clampHaloError(haloError, earlyResult);
151
+ return earlyResult;
152
+ }
153
+ // --- Step 2: resolve tickettype_name from cached /TicketType list (Bug 4) ---
154
+ // ticket.tickettype_name is often absent from the detail response; the global
155
+ // tickettypes list (cached 1h) is more reliable.
156
+ let tickettypeName = null;
157
+ if (tickettypeId !== undefined) {
158
+ try {
159
+ const ttList = await client.getCached('/TicketType', { count: 500 }, {
160
+ enabled: true,
161
+ ttl: TTL.TICKET_TYPES,
162
+ keyPrefix: 'tickettypes:all',
163
+ });
164
+ const ttItems = Array.isArray(ttList)
165
+ ? ttList
166
+ : (ttList.tickettypes ?? []);
167
+ const found = ttItems.find((t) => t.id === tickettypeId);
168
+ tickettypeName = found?.name ?? tickettypeNameFallback ?? null;
169
+ }
170
+ catch (innerErr) {
171
+ const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
172
+ discoveryWarnings.push('tickettypes list lookup failed: ' + msg);
173
+ // Fall back to the field on the ticket (often absent)
174
+ tickettypeName = tickettypeNameFallback ?? null;
175
+ }
123
176
  }
124
- // --- Step 2: fetch the tickettype config ---
177
+ // --- Step 3: fetch valid outcomes via shared helper (Bug 4) ---
178
+ // Sort by sequence (ascending), then by id (ascending) as tiebreaker.
179
+ // Slice to first 10 for response compactness.
125
180
  let validOutcomesHint = [];
126
181
  if (tickettypeId !== undefined) {
127
182
  try {
128
- const ttConfig = await client.get(`/TicketType/${tickettypeId}`, { includedetails: true });
129
- validOutcomesHint = extractOutcomeHint(ttConfig);
183
+ const outcomes = await getOutcomesForTicketType(client, tickettypeId);
184
+ const sorted = outcomes.slice().sort((a, b) => {
185
+ const seqA = a.sequence ?? 999999;
186
+ const seqB = b.sequence ?? 999999;
187
+ return seqA !== seqB ? seqA - seqB : a.id - b.id;
188
+ });
189
+ validOutcomesHint = sorted.slice(0, 10).map((o) => ({ id: o.id, name: o.name }));
130
190
  }
131
191
  catch (innerErr) {
132
192
  const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
133
- discoveryWarnings.push('tickettype lookup failed: ' + msg);
193
+ discoveryWarnings.push('outcomes lookup failed: ' + msg);
134
194
  }
135
195
  }
136
196
  const result = {
137
197
  action_created: false,
138
- halo_error: haloError,
139
198
  diagnosis: 'outcome_id is required for this tickettype and was either missing or invalid',
140
199
  tickettype_id: tickettypeId ?? null,
141
- tickettype_name: tickettypeName ?? null,
200
+ tickettype_name: tickettypeName,
142
201
  valid_outcomes_hint: validOutcomesHint,
143
202
  remediation: 'Either retry with a valid outcome_id from the list above, ' +
144
203
  'or fall back to update_halopsa_ticket to set details_html / status_id without going through the Actions surface.',
145
204
  };
205
+ result['halo_error'] = clampHaloError(haloError, result);
146
206
  if (discoveryWarnings.length > 0) {
147
207
  result['discovery_warning'] = discoveryWarnings.join('; ');
148
208
  }
@@ -160,6 +220,13 @@ function compactAction(action) {
160
220
  who: action.who,
161
221
  datetime: action.datetime,
162
222
  outcome: action.outcome,
223
+ outcome_id: action.outcome_id,
224
+ // Q5 billing fields — must be carried through the compact projection so the
225
+ // caller can verify what hours/rate landed on the action (POST /Actions is
226
+ // at-most-once; the compact response is the only audit signal on success).
227
+ timetaken: action.timetaken,
228
+ actionchargehours: action.actionchargehours,
229
+ chargerate: action.chargerate,
163
230
  note_preview: note.length > ACTION_PREVIEW_LEN
164
231
  ? note.slice(0, ACTION_PREVIEW_LEN) + '…'
165
232
  : note,
@@ -178,25 +245,73 @@ function unwrapActionList(response) {
178
245
  // Implementations
179
246
  // =============================================================================
180
247
  export async function addTicketAction(client, args) {
248
+ // Resolve outcome_name → outcome_id when the caller passed a name only.
249
+ // outcome_id wins when both are supplied so the explicit ID never gets
250
+ // accidentally overridden by a name lookup.
251
+ //
252
+ // Phase 1.4 swagger: getOutcomeByName (ticket-reference-data.ts:642-660)
253
+ // matches against the raw `TOutcome.outcome` property — the canonical
254
+ // name field in halopsa-swagger.json. The compactor remaps it to `name`
255
+ // for the response shape, but the lookup uses the swagger-declared
256
+ // field. See tools/phase1-audit.sh item 1.4.
257
+ let effectiveOutcomeId = args.outcome_id;
258
+ let resolvedOutcomeName;
259
+ if (effectiveOutcomeId === undefined && args.outcome_name !== undefined) {
260
+ const resolved = await getOutcomeByName(client, args.outcome_name);
261
+ if (resolved === null) {
262
+ // Surface the same structured-refusal shape used for outcome-required 400s
263
+ // so the caller sees a discovery hint rather than a bare error.
264
+ const refusal = {
265
+ action_created: false,
266
+ diagnosis: `outcome_name "${args.outcome_name}" did not match any outcome (case-insensitive). The outcomes list is cached 1h — if the name was recently added, expect up to 1h cache-staleness.`,
267
+ outcome_name_requested: args.outcome_name,
268
+ remediation: 'Call list_halopsa_ticket_outcomes (optionally with tickettype_id) to discover valid names and IDs.',
269
+ };
270
+ return JSON.stringify(refusal);
271
+ }
272
+ effectiveOutcomeId = resolved.id;
273
+ resolvedOutcomeName = resolved.name;
274
+ }
181
275
  const payload = {
182
276
  ticket_id: args.ticket_id,
183
277
  note: args.note,
184
278
  };
185
- if (args.outcome_id !== undefined)
186
- payload.outcome_id = args.outcome_id;
279
+ if (effectiveOutcomeId !== undefined)
280
+ payload.outcome_id = effectiveOutcomeId;
187
281
  if (args.email_reply !== undefined)
188
282
  payload.email_reply = args.email_reply;
189
283
  if (args.hiddenfromuser !== undefined)
190
284
  payload.hiddenfromuser = args.hiddenfromuser;
191
- // Time-entry fields passed verbatim using Halo's canonical field names.
192
- // timetaken and chargehours are confirmed in the swagger Actions schema.
193
- // nonchargeable, chargerate, start_datetime, end_datetime are part of the
194
- // 508-property schema but not captured in the IPA swagger slice; they are
195
- // accepted by empirical observation on live tenants.
285
+ // Time-entry fields. Caller-facing input names are mapped to Halo's documented
286
+ // Actions property names below. Verified 2026-05-12 against
287
+ // components.schemas.Actions in halopsa-swagger.json (508 properties):
288
+ // timetaken → timetaken (in Actions schema, swagger-confirmed)
289
+ // chargehours → actionchargehours (Actions uses `actionchargehours`,
290
+ // NOT `chargehours`; `chargehours` is
291
+ // on Faults/Faults_List (ticket-level
292
+ // rollup) — NOT on Actions schema)
293
+ // chargerate → chargerate (in Actions schema, int32 FK)
294
+ // nonchargeable → nonchargeable (NOT in swagger; zero occurrences in
295
+ // halopsa-swagger.json; accepted by
296
+ // empirical observation on live tenants.
297
+ // Documented Actions field is
298
+ // actionnonchargehours: number (not
299
+ // boolean). v1.8+ TODO: live-tenant
300
+ // probe before renaming.)
301
+ // start_datetime → start_datetime (NOT in swagger; observation only)
302
+ // end_datetime → end_datetime (NOT in swagger; observation only)
196
303
  if (args.timetaken !== undefined)
197
304
  payload.timetaken = args.timetaken;
305
+ // Map the caller-facing `chargehours` input to Halo's documented Actions
306
+ // property `actionchargehours`. `chargehours` is NOT declared on Actions —
307
+ // it is a ticket-level rollup field on components.schemas.Faults and
308
+ // components.schemas.Faults_List only. Sending `chargehours` on POST /Actions
309
+ // would be silently dropped by Halo's deserializer (unknown DataMember).
198
310
  if (args.chargehours !== undefined)
199
- payload.chargehours = args.chargehours;
311
+ payload.actionchargehours = args.chargehours;
312
+ // nonchargeable: undocumented on Actions schema in halopsa-swagger.json. Tenant-accepted
313
+ // alias observed empirically. Documented field is actionnonchargehours: number (not boolean).
314
+ // v1.8+ TODO: live-tenant probe before renaming.
200
315
  if (args.nonchargeable !== undefined)
201
316
  payload.nonchargeable = args.nonchargeable;
202
317
  if (args.chargerate !== undefined)
@@ -215,9 +330,10 @@ export async function addTicketAction(client, args) {
215
330
  // When Halo returns 400 for a missing or invalid outcome_id, surface a
216
331
  // structured refusal with discovery info instead of re-throwing the raw
217
332
  // error. This lets the caller retry with a valid outcome_id.
218
- const haloMessage = error instanceof HaloPSAError ? error.message : String(error);
219
- if (OUTCOME_ERROR_RE.test(haloMessage)) {
220
- const refusal = await buildOutcomeDiscoveryRefusal(client, args.ticket_id, haloMessage);
333
+ const rawHaloMessage = error instanceof HaloPSAError ? error.message : String(error);
334
+ if (OUTCOME_ERROR_RE.test(rawHaloMessage)) {
335
+ const refusal = await buildOutcomeDiscoveryRefusal(client, args.ticket_id, rawHaloMessage);
336
+ // v1.7+ TODO: route through formatResponse() or apply scrubCredentials() before stringify. Low current risk: refusal fields are id+name+error_msg+remediation; CREDENTIAL_KEY_RE matches JSON keys, and Halo 400 messages on outcome failures empirically don't contain credential strings. Documented in 2026-05-12_review-protocol-validation.md.
221
337
  return JSON.stringify(refusal);
222
338
  }
223
339
  throw error;
@@ -225,8 +341,69 @@ export async function addTicketAction(client, args) {
225
341
  const created = Array.isArray(raw) ? raw[0] : raw;
226
342
  client.invalidateCache(`ticket-actions:${args.ticket_id}*`);
227
343
  client.invalidateCache(`ticket:${args.ticket_id}*`);
344
+ // Halo's POST /Actions response can be sparse — some tenants return only
345
+ // `{id: N}` (where `id` is the per-ticket sequence number, NOT a global ID).
346
+ // To keep the audit signal meaningful, synthesize a fallback object from the
347
+ // request payload and let real response fields override it. The resulting
348
+ // `id` (sequence-within-ticket) is preserved as the canonical id;
349
+ // `ticket_id` is also added so the response is self-describing.
350
+ //
351
+ // Phase 1.5 swagger: halopsa-swagger.json declares POST /Actions returns
352
+ // 201 with the full Actions schema (508 properties). Empirically tenants
353
+ // return a sparse body; this merge is the documented-vs-observed delta
354
+ // workaround. See tools/phase1-audit.sh item 1.5.
355
+ const note = args.note ?? '';
356
+ const synthesized = {
357
+ ticket_id: args.ticket_id,
358
+ note,
359
+ };
360
+ if (effectiveOutcomeId !== undefined)
361
+ synthesized.outcome_id = effectiveOutcomeId;
362
+ if (resolvedOutcomeName !== undefined)
363
+ synthesized.outcome = resolvedOutcomeName;
364
+ if (args.hiddenfromuser !== undefined)
365
+ synthesized.hiddenfromuser = args.hiddenfromuser;
366
+ if (args.timetaken !== undefined)
367
+ synthesized.timetaken = args.timetaken;
368
+ if (args.chargehours !== undefined)
369
+ synthesized.actionchargehours = args.chargehours;
370
+ if (args.chargerate !== undefined)
371
+ synthesized.chargerate = args.chargerate;
372
+ // R4 Finding 4.13: start_datetime / end_datetime are NOT declared in
373
+ // components.schemas.Actions in halopsa-swagger.json (verified 2026-05-12 via
374
+ // tools/phase1-audit.sh and `python3` swagger probe). Included in the
375
+ // synthesized object as request-payload echoes for audit-trail completeness —
376
+ // the caller sees back what they sent even if Halo's deserializer silently
377
+ // drops them. Empirically accepted by some tenants. If the API drops them,
378
+ // the merged response simply omits them rather than showing wrong data.
379
+ if (args.start_datetime !== undefined)
380
+ synthesized.start_datetime = args.start_datetime;
381
+ if (args.end_datetime !== undefined)
382
+ synthesized.end_datetime = args.end_datetime;
383
+ // Merge: request-side fallbacks first, then real response fields. Halo's `id`
384
+ // (sequence-within-ticket) is the only field guaranteed to be present.
385
+ //
386
+ // R1 Finding 1.5: drop `null`/`undefined`/`""` values from `created` before
387
+ // spreading so we never let Halo's response *erase* a synthesized fallback
388
+ // (e.g., resolvedOutcomeName) by overwriting it with null. Real values from
389
+ // Halo still win because non-empty values pass the filter.
390
+ const createdScrubbed = {};
391
+ for (const [k, v] of Object.entries(created)) {
392
+ if (v !== null && v !== undefined && v !== '')
393
+ createdScrubbed[k] = v;
394
+ }
395
+ const merged = {
396
+ ...synthesized,
397
+ ...createdScrubbed,
398
+ };
228
399
  const format = args.format_options?.format ?? 'compact';
229
- return formatResponse(stripWriteResponse(created, format, 'action'), { format });
400
+ // Apply compactAction() on the compact path so note_preview is synthesized
401
+ // from the merged note text (so the response is meaningful even when Halo
402
+ // returned only {id: N}). For non-compact formats, return the full merged
403
+ // object so callers see every field they sent plus everything Halo returned.
404
+ const compacted = format === 'compact' ? compactAction(merged) : merged;
405
+ const formatOpts = withCompactDefaults(args.format_options ?? { format: 'compact' }, ACTION_COMPACT_DEFAULTS);
406
+ return formatResponse(stripWriteResponse(compacted, format, 'action'), formatOpts);
230
407
  }
231
408
  export async function listTicketActions(client, args) {
232
409
  const params = { ticket_id: args.ticket_id };
@@ -234,7 +411,7 @@ export async function listTicketActions(client, args) {
234
411
  params.count = args.count;
235
412
  if (args.page_no !== undefined)
236
413
  params.page_no = args.page_no;
237
- const formatOpts = args.format_options || { format: 'compact' };
414
+ const baseFormatOpts = args.format_options || { format: 'compact' };
238
415
  const response = await client.getCached('/Actions', params, {
239
416
  enabled: true,
240
417
  ttl: TTL.TICKET_ACTION_LIST,
@@ -243,9 +420,15 @@ export async function listTicketActions(client, args) {
243
420
  const { actions, record_count } = unwrapActionList(response);
244
421
  // Compact mode: substitute the preview-shaped objects so the response
245
422
  // never echoes back full HTML bodies on a journal listing.
246
- const items = formatOpts.format === 'compact'
423
+ const items = baseFormatOpts.format === 'compact'
247
424
  ? actions.map(compactAction)
248
425
  : actions;
426
+ // Wire per-tool compact defaults so the compact projection guarantees
427
+ // {id, ticket_id, outcome_id, outcome, who, datetime, note_preview} even when
428
+ // some are null on the Halo response. Without this the legacy compactItem()
429
+ // heuristic would not recognize an action shape and could strip fields back
430
+ // to {id} on tenants that return sparse action records.
431
+ const formatOpts = withCompactDefaults(baseFormatOpts, ACTION_COMPACT_DEFAULTS);
249
432
  return formatResponse(items, formatOpts, { record_count });
250
433
  }
251
434
  // =============================================================================
@@ -276,6 +459,10 @@ export async function holdTicketSla(client, args) {
276
459
  client.invalidateCache(`ticket:${args.ticket_id}*`);
277
460
  client.invalidateCache('tickets:*');
278
461
  const note = args.reason ?? 'SLA hold — waiting on customer';
462
+ // v1.7+ TODO: route through formatResponse() or apply scrubCredentials() before stringify.
463
+ // Low current risk: SLA hold/release response object fields are held+ticket_id+reason+note (audit prose);
464
+ // CREDENTIAL_KEY_RE matches JSON keys, and Halo 200 responses on onhold updates empirically
465
+ // don't contain credential strings. Documented in 2026-05-12_review-protocol-validation.md.
279
466
  return JSON.stringify({
280
467
  held: true,
281
468
  ticket_id: args.ticket_id,
@@ -289,6 +476,10 @@ export async function releaseTicketSla(client, args) {
289
476
  await client.post('/Tickets', [payload]);
290
477
  client.invalidateCache(`ticket:${args.ticket_id}*`);
291
478
  client.invalidateCache('tickets:*');
479
+ // v1.7+ TODO: route through formatResponse() or apply scrubCredentials() before stringify.
480
+ // Low current risk: SLA hold/release response object fields are released+ticket_id+note (audit prose);
481
+ // CREDENTIAL_KEY_RE matches JSON keys, and Halo 200 responses on onhold updates empirically
482
+ // don't contain credential strings. Documented in 2026-05-12_review-protocol-validation.md.
292
483
  return JSON.stringify({
293
484
  released: true,
294
485
  ticket_id: args.ticket_id,
@@ -300,13 +491,13 @@ export async function releaseTicketSla(client, args) {
300
491
  // =============================================================================
301
492
  export const addTicketActionTool = {
302
493
  name: 'add_halopsa_ticket_action',
303
- description: 'Append an action (note, email reply, status change journal entry, time entry) to a HaloPSA ticket. POST /Actions with array body wrapper. Supports time-entry billing fields: timetaken, chargehours, nonchargeable, chargerate, start_datetime, end_datetime. Invalidates ticket and action caches.',
494
+ description: 'Append an action (note, email reply, status change, time entry) to a HaloPSA ticket. Specify outcome by outcome_id OR outcome_name (case-insensitive, resolved via cached outcomes list). Supports billing fields: timetaken, chargehours, nonchargeable, chargerate, start_datetime, end_datetime. Note: `id` in the response is the per-ticket sequence number, NOT a global action ID.',
304
495
  schema: AddTicketActionArgsSchema,
305
496
  handler: addTicketAction,
306
497
  };
307
498
  export const listTicketActionsTool = {
308
499
  name: 'list_halopsa_ticket_actions',
309
- description: 'List actions/journal entries on a HaloPSA ticket. Compact response includes a 200-char note_preview; pass format_options.format="detailed" for full notes. Cached 1min.',
500
+ description: 'List actions/journal entries on a HaloPSA ticket. Compact response guarantees {id, ticket_id, outcome_id, outcome, timetaken, actionchargehours, chargerate, who, datetime, note_preview (≤200 chars)} — null-filled when absent. Pass format_options.format="detailed" for full notes and all fields. Cached 1min.',
310
501
  schema: ListTicketActionsArgsSchema,
311
502
  handler: listTicketActions,
312
503
  };
@@ -318,7 +509,7 @@ export const holdTicketSlaTool = {
318
509
  };
319
510
  export const releaseTicketSlaTool = {
320
511
  name: 'release_halopsa_ticket_sla',
321
- description: 'Release a HaloPSA ticket SLA hold (work resumed). Discovers the SLA-Release outcome at runtime via GET /Outcome name-match (/sla.{0,3}release/i) or HALO_MCP_SLA_RELEASE_OUTCOME_ID env var. Halo sets ticket.onhold=false server-side as a side-effect. Outcome is cached per-process after first resolution.',
512
+ description: 'Releases the SLA hold on a HaloPSA ticket. Performs read-before-write merge to set onhold=false on the ticket. Direct write no outcome lookup, no env var. Invalidates ticket and ticket_actions caches.',
322
513
  schema: ReleaseTicketSlaArgsSchema,
323
514
  handler: releaseTicketSla,
324
515
  };