@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.
- package/README.md +7 -0
- package/dist/api/client.d.ts +39 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +50 -0
- package/dist/api/client.js.map +1 -1
- package/dist/api/errors.d.ts.map +1 -1
- package/dist/api/errors.js +1 -2
- package/dist/api/errors.js.map +1 -1
- package/dist/cache/memory-cache.d.ts.map +1 -1
- package/dist/cache/memory-cache.js +5 -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/registrations.d.ts +5 -1
- package/dist/tools/registrations.d.ts.map +1 -1
- package/dist/tools/registrations.js +28 -3
- package/dist/tools/registrations.js.map +1 -1
- package/dist/tools/ticket-actions.d.ts +1 -18
- package/dist/tools/ticket-actions.d.ts.map +1 -1
- package/dist/tools/ticket-actions.js +244 -53
- package/dist/tools/ticket-actions.js.map +1 -1
- package/dist/tools/ticket-reference-data.d.ts +28 -0
- package/dist/tools/ticket-reference-data.d.ts.map +1 -1
- package/dist/tools/ticket-reference-data.js +218 -20
- package/dist/tools/ticket-reference-data.js.map +1 -1
- package/dist/tools/tickets.d.ts +20 -0
- package/dist/tools/tickets.d.ts.map +1 -1
- package/dist/tools/tickets.js +275 -38
- package/dist/tools/tickets.js.map +1 -1
- package/dist/types/tickets.d.ts +64 -5
- 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
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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":"AAAA
|
|
1
|
+
{"version":3,"file":"registrations.js","sourceRoot":"","sources":["../../src/tools/registrations.ts"],"names":[],"mappings":"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"}
|
|
@@ -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;
|
|
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".
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
131
|
+
let tickettypeNameFallback;
|
|
104
132
|
try {
|
|
105
133
|
const ticket = await client.get(`/Tickets/${ticketId}`, { includedetails: true });
|
|
106
134
|
tickettypeId = ticket.tickettype_id;
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
|
|
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('
|
|
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
|
|
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 (
|
|
186
|
-
payload.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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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.
|
|
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
|
|
219
|
-
if (OUTCOME_ERROR_RE.test(
|
|
220
|
-
const refusal = await buildOutcomeDiscoveryRefusal(client, args.ticket_id,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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: '
|
|
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
|
};
|