@tedorigawa001/servicenow-mcp 1.0.2

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +844 -0
  3. package/dist/cli/auth.d.ts +15 -0
  4. package/dist/cli/auth.d.ts.map +1 -0
  5. package/dist/cli/auth.js +200 -0
  6. package/dist/cli/auth.js.map +1 -0
  7. package/dist/cli/config-store.d.ts +28 -0
  8. package/dist/cli/config-store.d.ts.map +1 -0
  9. package/dist/cli/config-store.js +66 -0
  10. package/dist/cli/config-store.js.map +1 -0
  11. package/dist/cli/detect-clients.d.ts +16 -0
  12. package/dist/cli/detect-clients.d.ts.map +1 -0
  13. package/dist/cli/detect-clients.js +151 -0
  14. package/dist/cli/detect-clients.js.map +1 -0
  15. package/dist/cli/index.d.ts +3 -0
  16. package/dist/cli/index.d.ts.map +1 -0
  17. package/dist/cli/index.js +135 -0
  18. package/dist/cli/index.js.map +1 -0
  19. package/dist/cli/setup.d.ts +4 -0
  20. package/dist/cli/setup.d.ts.map +1 -0
  21. package/dist/cli/setup.js +564 -0
  22. package/dist/cli/setup.js.map +1 -0
  23. package/dist/cli/writers/index.d.ts +9 -0
  24. package/dist/cli/writers/index.d.ts.map +1 -0
  25. package/dist/cli/writers/index.js +135 -0
  26. package/dist/cli/writers/index.js.map +1 -0
  27. package/dist/prompts/index.d.ts +25 -0
  28. package/dist/prompts/index.d.ts.map +1 -0
  29. package/dist/prompts/index.js +38 -0
  30. package/dist/prompts/index.js.map +1 -0
  31. package/dist/prompts/itsm.d.ts +20 -0
  32. package/dist/prompts/itsm.d.ts.map +1 -0
  33. package/dist/prompts/itsm.js +110 -0
  34. package/dist/prompts/itsm.js.map +1 -0
  35. package/dist/prompts/user-prompts.d.ts +3 -0
  36. package/dist/prompts/user-prompts.d.ts.map +1 -0
  37. package/dist/prompts/user-prompts.js +35 -0
  38. package/dist/prompts/user-prompts.js.map +1 -0
  39. package/dist/resources/index.d.ts +26 -0
  40. package/dist/resources/index.d.ts.map +1 -0
  41. package/dist/resources/index.js +99 -0
  42. package/dist/resources/index.js.map +1 -0
  43. package/dist/server-http.d.ts +41 -0
  44. package/dist/server-http.d.ts.map +1 -0
  45. package/dist/server-http.js +187 -0
  46. package/dist/server-http.js.map +1 -0
  47. package/dist/server.d.ts +54 -0
  48. package/dist/server.d.ts.map +1 -0
  49. package/dist/server.js +150 -0
  50. package/dist/server.js.map +1 -0
  51. package/dist/servicenow/client.d.ts +137 -0
  52. package/dist/servicenow/client.d.ts.map +1 -0
  53. package/dist/servicenow/client.js +855 -0
  54. package/dist/servicenow/client.js.map +1 -0
  55. package/dist/servicenow/instances.d.ts +28 -0
  56. package/dist/servicenow/instances.d.ts.map +1 -0
  57. package/dist/servicenow/instances.js +190 -0
  58. package/dist/servicenow/instances.js.map +1 -0
  59. package/dist/servicenow/types.d.ts +578 -0
  60. package/dist/servicenow/types.d.ts.map +1 -0
  61. package/dist/servicenow/types.js +3 -0
  62. package/dist/servicenow/types.js.map +1 -0
  63. package/dist/tools/agile.d.ts +225 -0
  64. package/dist/tools/agile.d.ts.map +1 -0
  65. package/dist/tools/agile.js +205 -0
  66. package/dist/tools/agile.js.map +1 -0
  67. package/dist/tools/app-studio.d.ts +139 -0
  68. package/dist/tools/app-studio.d.ts.map +1 -0
  69. package/dist/tools/app-studio.js +139 -0
  70. package/dist/tools/app-studio.js.map +1 -0
  71. package/dist/tools/atf.d.ts +144 -0
  72. package/dist/tools/atf.d.ts.map +1 -0
  73. package/dist/tools/atf.js +186 -0
  74. package/dist/tools/atf.js.map +1 -0
  75. package/dist/tools/catalog.d.ts +966 -0
  76. package/dist/tools/catalog.d.ts.map +1 -0
  77. package/dist/tools/catalog.js +640 -0
  78. package/dist/tools/catalog.js.map +1 -0
  79. package/dist/tools/change.d.ts +351 -0
  80. package/dist/tools/change.d.ts.map +1 -0
  81. package/dist/tools/change.js +214 -0
  82. package/dist/tools/change.js.map +1 -0
  83. package/dist/tools/core.d.ts +768 -0
  84. package/dist/tools/core.d.ts.map +1 -0
  85. package/dist/tools/core.js +695 -0
  86. package/dist/tools/core.js.map +1 -0
  87. package/dist/tools/csm.d.ts +403 -0
  88. package/dist/tools/csm.d.ts.map +1 -0
  89. package/dist/tools/csm.js +256 -0
  90. package/dist/tools/csm.js.map +1 -0
  91. package/dist/tools/deployment.d.ts +366 -0
  92. package/dist/tools/deployment.d.ts.map +1 -0
  93. package/dist/tools/deployment.js +181 -0
  94. package/dist/tools/deployment.js.map +1 -0
  95. package/dist/tools/devops.d.ts +236 -0
  96. package/dist/tools/devops.d.ts.map +1 -0
  97. package/dist/tools/devops.js +207 -0
  98. package/dist/tools/devops.js.map +1 -0
  99. package/dist/tools/discovery.d.ts +306 -0
  100. package/dist/tools/discovery.d.ts.map +1 -0
  101. package/dist/tools/discovery.js +357 -0
  102. package/dist/tools/discovery.js.map +1 -0
  103. package/dist/tools/flow.d.ts +496 -0
  104. package/dist/tools/flow.d.ts.map +1 -0
  105. package/dist/tools/flow.js +348 -0
  106. package/dist/tools/flow.js.map +1 -0
  107. package/dist/tools/hrsd.d.ts +790 -0
  108. package/dist/tools/hrsd.d.ts.map +1 -0
  109. package/dist/tools/hrsd.js +378 -0
  110. package/dist/tools/hrsd.js.map +1 -0
  111. package/dist/tools/incident.d.ts +259 -0
  112. package/dist/tools/incident.d.ts.map +1 -0
  113. package/dist/tools/incident.js +170 -0
  114. package/dist/tools/incident.js.map +1 -0
  115. package/dist/tools/index.d.ts +13 -0
  116. package/dist/tools/index.d.ts.map +1 -0
  117. package/dist/tools/index.js +385 -0
  118. package/dist/tools/index.js.map +1 -0
  119. package/dist/tools/integration.d.ts +940 -0
  120. package/dist/tools/integration.d.ts.map +1 -0
  121. package/dist/tools/integration.js +685 -0
  122. package/dist/tools/integration.js.map +1 -0
  123. package/dist/tools/itam.d.ts +462 -0
  124. package/dist/tools/itam.d.ts.map +1 -0
  125. package/dist/tools/itam.js +306 -0
  126. package/dist/tools/itam.js.map +1 -0
  127. package/dist/tools/knowledge.d.ts +187 -0
  128. package/dist/tools/knowledge.d.ts.map +1 -0
  129. package/dist/tools/knowledge.js +161 -0
  130. package/dist/tools/knowledge.js.map +1 -0
  131. package/dist/tools/ml.d.ts +263 -0
  132. package/dist/tools/ml.d.ts.map +1 -0
  133. package/dist/tools/ml.js +251 -0
  134. package/dist/tools/ml.js.map +1 -0
  135. package/dist/tools/mobile.d.ts +352 -0
  136. package/dist/tools/mobile.d.ts.map +1 -0
  137. package/dist/tools/mobile.js +122 -0
  138. package/dist/tools/mobile.js.map +1 -0
  139. package/dist/tools/notification.d.ts +590 -0
  140. package/dist/tools/notification.d.ts.map +1 -0
  141. package/dist/tools/notification.js +382 -0
  142. package/dist/tools/notification.js.map +1 -0
  143. package/dist/tools/now-assist.d.ts +300 -0
  144. package/dist/tools/now-assist.d.ts.map +1 -0
  145. package/dist/tools/now-assist.js +227 -0
  146. package/dist/tools/now-assist.js.map +1 -0
  147. package/dist/tools/performance.d.ts +447 -0
  148. package/dist/tools/performance.d.ts.map +1 -0
  149. package/dist/tools/performance.js +473 -0
  150. package/dist/tools/performance.js.map +1 -0
  151. package/dist/tools/portal.d.ts +530 -0
  152. package/dist/tools/portal.d.ts.map +1 -0
  153. package/dist/tools/portal.js +425 -0
  154. package/dist/tools/portal.js.map +1 -0
  155. package/dist/tools/problem.d.ts +111 -0
  156. package/dist/tools/problem.d.ts.map +1 -0
  157. package/dist/tools/problem.js +101 -0
  158. package/dist/tools/problem.js.map +1 -0
  159. package/dist/tools/reporting.d.ts +825 -0
  160. package/dist/tools/reporting.d.ts.map +1 -0
  161. package/dist/tools/reporting.js +453 -0
  162. package/dist/tools/reporting.js.map +1 -0
  163. package/dist/tools/sam.d.ts +312 -0
  164. package/dist/tools/sam.d.ts.map +1 -0
  165. package/dist/tools/sam.js +360 -0
  166. package/dist/tools/sam.js.map +1 -0
  167. package/dist/tools/schema-helpers.d.ts +119 -0
  168. package/dist/tools/schema-helpers.d.ts.map +1 -0
  169. package/dist/tools/schema-helpers.js +121 -0
  170. package/dist/tools/schema-helpers.js.map +1 -0
  171. package/dist/tools/script.d.ts +714 -0
  172. package/dist/tools/script.d.ts.map +1 -0
  173. package/dist/tools/script.js +629 -0
  174. package/dist/tools/script.js.map +1 -0
  175. package/dist/tools/security.d.ts +802 -0
  176. package/dist/tools/security.d.ts.map +1 -0
  177. package/dist/tools/security.js +426 -0
  178. package/dist/tools/security.js.map +1 -0
  179. package/dist/tools/smart-query.d.ts +84 -0
  180. package/dist/tools/smart-query.d.ts.map +1 -0
  181. package/dist/tools/smart-query.js +320 -0
  182. package/dist/tools/smart-query.js.map +1 -0
  183. package/dist/tools/sys-properties.d.ts +315 -0
  184. package/dist/tools/sys-properties.d.ts.map +1 -0
  185. package/dist/tools/sys-properties.js +416 -0
  186. package/dist/tools/sys-properties.js.map +1 -0
  187. package/dist/tools/task.d.ts +82 -0
  188. package/dist/tools/task.d.ts.map +1 -0
  189. package/dist/tools/task.js +96 -0
  190. package/dist/tools/task.js.map +1 -0
  191. package/dist/tools/updateset.d.ts +159 -0
  192. package/dist/tools/updateset.d.ts.map +1 -0
  193. package/dist/tools/updateset.js +266 -0
  194. package/dist/tools/updateset.js.map +1 -0
  195. package/dist/tools/usem-approval.d.ts +130 -0
  196. package/dist/tools/usem-approval.d.ts.map +1 -0
  197. package/dist/tools/usem-approval.js +194 -0
  198. package/dist/tools/usem-approval.js.map +1 -0
  199. package/dist/tools/usem-config.d.ts +171 -0
  200. package/dist/tools/usem-config.d.ts.map +1 -0
  201. package/dist/tools/usem-config.js +254 -0
  202. package/dist/tools/usem-config.js.map +1 -0
  203. package/dist/tools/usem-integration.d.ts +228 -0
  204. package/dist/tools/usem-integration.d.ts.map +1 -0
  205. package/dist/tools/usem-integration.js +223 -0
  206. package/dist/tools/usem-integration.js.map +1 -0
  207. package/dist/tools/usem-sla.d.ts +201 -0
  208. package/dist/tools/usem-sla.d.ts.map +1 -0
  209. package/dist/tools/usem-sla.js +322 -0
  210. package/dist/tools/usem-sla.js.map +1 -0
  211. package/dist/tools/usem.d.ts +489 -0
  212. package/dist/tools/usem.d.ts.map +1 -0
  213. package/dist/tools/usem.js +514 -0
  214. package/dist/tools/usem.js.map +1 -0
  215. package/dist/tools/user.d.ts +206 -0
  216. package/dist/tools/user.d.ts.map +1 -0
  217. package/dist/tools/user.js +163 -0
  218. package/dist/tools/user.js.map +1 -0
  219. package/dist/tools/va.d.ts +217 -0
  220. package/dist/tools/va.d.ts.map +1 -0
  221. package/dist/tools/va.js +178 -0
  222. package/dist/tools/va.js.map +1 -0
  223. package/dist/tools/workspace.d.ts +565 -0
  224. package/dist/tools/workspace.d.ts.map +1 -0
  225. package/dist/tools/workspace.js +201 -0
  226. package/dist/tools/workspace.js.map +1 -0
  227. package/dist/utils/errors.d.ts +6 -0
  228. package/dist/utils/errors.d.ts.map +1 -0
  229. package/dist/utils/errors.js +11 -0
  230. package/dist/utils/errors.js.map +1 -0
  231. package/dist/utils/logging.d.ts +7 -0
  232. package/dist/utils/logging.d.ts.map +1 -0
  233. package/dist/utils/logging.js +15 -0
  234. package/dist/utils/logging.js.map +1 -0
  235. package/dist/utils/permissions.d.ts +21 -0
  236. package/dist/utils/permissions.d.ts.map +1 -0
  237. package/dist/utils/permissions.js +54 -0
  238. package/dist/utils/permissions.js.map +1 -0
  239. package/instances.example.json +69 -0
  240. package/package.json +110 -0
@@ -0,0 +1,855 @@
1
+ import { ServiceNowError } from '../utils/errors.js';
2
+ import { logger } from '../utils/logging.js';
3
+ // ─── Input validation helpers ────────────────────────────────────────────────
4
+ /** Validate and sanitize ServiceNow table names (alphanumeric + underscores only) */
5
+ function validateTableName(table) {
6
+ if (!table || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(table)) {
7
+ throw new ServiceNowError(`Invalid table name: "${table}". Must contain only letters, numbers, and underscores.`, 'VALIDATION_ERROR');
8
+ }
9
+ return table;
10
+ }
11
+ /** Validate ServiceNow sys_id format (32-char hex string) */
12
+ function validateSysId(sysId) {
13
+ if (!sysId || !/^[0-9a-f]{32}$/i.test(sysId)) {
14
+ throw new ServiceNowError(`Invalid sys_id: "${sysId}". Must be a 32-character hex string.`, 'VALIDATION_ERROR');
15
+ }
16
+ return sysId;
17
+ }
18
+ /** Allowlist of safe GlideSystem functions permitted in javascript: query expressions */
19
+ const SAFE_GS_PATTERN = /^javascript:gs\.(getUserID|beginningOfToday|endOfToday|beginningOfYesterday|endOfYesterday|beginningOfLastMonth|endOfLastMonth|beginningOfThisMonth|endOfThisMonth|beginningOfThisQuarter|endOfThisQuarter|beginningOfThisYear|endOfThisYear|beginningOfNextMonth|endOfNextMonth|beginningOfLast7Days|endOfLast7Days|beginningOfLastYear|endOfLastYear|daysAgo|hoursAgo|minutesAgo|monthsAgo|quartersAgo|yearsAgo|now|dateGenerate)\([\d,\s'":-]*\)$/i;
20
+ /** Validate orderBy field names (column names only — no query operators) */
21
+ function validateOrderByField(field) {
22
+ if (!field || !/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(field)) {
23
+ throw new ServiceNowError(`Invalid orderBy field: "${field}". Must contain only letters, numbers, underscores, and dots.`, 'VALIDATION_ERROR');
24
+ }
25
+ return field;
26
+ }
27
+ /** Strip ServiceNow encoded-query operators from free-text search values */
28
+ function sanitizeLikeValue(value) {
29
+ // Remove ^ (clause separator) and NUL bytes to prevent encoded-query injection
30
+ return value.replace(/[\^]/g, '').replace(/\0/g, '');
31
+ }
32
+ /** Validate and sanitize ServiceNow encoded query strings */
33
+ function validateQuery(query) {
34
+ if (!query)
35
+ return query;
36
+ // Validate javascript: expressions against safe GlideSystem function allowlist
37
+ const jsMatches = query.match(/javascript:[^@^]*/gi);
38
+ if (jsMatches) {
39
+ for (const match of jsMatches) {
40
+ if (!SAFE_GS_PATTERN.test(match.trim())) {
41
+ throw new ServiceNowError(`Query contains unsafe JavaScript expression: "${match.substring(0, 60)}…". Only standard GlideSystem date/user functions are allowed.`, 'VALIDATION_ERROR');
42
+ }
43
+ }
44
+ }
45
+ // Enforce max query length
46
+ if (query.length > 4096) {
47
+ throw new ServiceNowError('Query string exceeds maximum length of 4096 characters.', 'VALIDATION_ERROR');
48
+ }
49
+ return query;
50
+ }
51
+ export class ServiceNowClient {
52
+ baseUrl;
53
+ authMode;
54
+ oauthConfig;
55
+ maxRetries;
56
+ retryDelayMs;
57
+ requestTimeoutMs;
58
+ /** For impersonation mode: user sys_id to pass in X-Sn-Impersonate */
59
+ impersonateUserSysId;
60
+ /** For per-user mode: pre-loaded token overrides service-account auth */
61
+ perUserBearerToken;
62
+ accessToken;
63
+ tokenExpiry;
64
+ constructor(config) {
65
+ this.baseUrl = config.instanceUrl.replace(/\/$/, '');
66
+ this.authMode = config.authMode || 'service-account';
67
+ this.oauthConfig = config.oauth;
68
+ // Use ?? so an explicit 0 is honored (e.g. maxRetries: 0 disables retries,
69
+ // retryDelayMs: 0 retries immediately) rather than falling back to defaults.
70
+ this.maxRetries = config.maxRetries ?? 3;
71
+ this.retryDelayMs = config.retryDelayMs ?? 1000;
72
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
73
+ this.impersonateUserSysId = config.impersonateUserSysId;
74
+ this.perUserBearerToken = config.perUserBearerToken;
75
+ }
76
+ /**
77
+ * Return a copy of this client configured to run as a specific user.
78
+ * Used for per-request user context switching without mutating the shared client.
79
+ */
80
+ withUser(options) {
81
+ const copy = Object.create(Object.getPrototypeOf(this));
82
+ Object.assign(copy, this);
83
+ if (options.sysId) {
84
+ copy.authMode = 'impersonation';
85
+ copy.impersonateUserSysId = options.sysId;
86
+ }
87
+ if (options.bearerToken) {
88
+ copy.authMode = 'per-user';
89
+ copy.perUserBearerToken = options.bearerToken;
90
+ }
91
+ return copy;
92
+ }
93
+ /**
94
+ * Authenticate with ServiceNow using OAuth.
95
+ * Automatically selects grant type:
96
+ * - client_credentials: when only clientId + clientSecret are configured (recommended)
97
+ * - password: when username + password are also provided
98
+ */
99
+ async authenticate() {
100
+ // Check if we have a valid token
101
+ if (this.accessToken && this.tokenExpiry && Date.now() < this.tokenExpiry) {
102
+ return;
103
+ }
104
+ if (!this.oauthConfig?.clientId || !this.oauthConfig?.clientSecret) {
105
+ throw new ServiceNowError('OAuth client ID and secret are required', 'AUTHENTICATION_FAILED');
106
+ }
107
+ const tokenUrl = `${this.baseUrl}/oauth_token.do`;
108
+ const usePasswordGrant = !!(this.oauthConfig.username && this.oauthConfig.password);
109
+ const bodyParams = {
110
+ grant_type: usePasswordGrant ? 'password' : 'client_credentials',
111
+ client_id: this.oauthConfig.clientId,
112
+ client_secret: this.oauthConfig.clientSecret,
113
+ };
114
+ if (usePasswordGrant) {
115
+ bodyParams.username = this.oauthConfig.username;
116
+ bodyParams.password = this.oauthConfig.password;
117
+ }
118
+ const body = new URLSearchParams(bodyParams);
119
+ const oauthController = new AbortController();
120
+ const oauthTimeout = setTimeout(() => oauthController.abort(), this.requestTimeoutMs);
121
+ try {
122
+ const response = await fetch(tokenUrl, {
123
+ method: 'POST',
124
+ signal: oauthController.signal,
125
+ headers: {
126
+ 'Content-Type': 'application/x-www-form-urlencoded',
127
+ },
128
+ body: body.toString(),
129
+ });
130
+ if (!response.ok) {
131
+ throw new ServiceNowError(`OAuth authentication failed: ${response.status} ${response.statusText}`, 'AUTHENTICATION_FAILED');
132
+ }
133
+ const tokenData = await response.json();
134
+ this.accessToken = tokenData.access_token;
135
+ // Set expiry to 90% of actual expiry time for safety margin
136
+ this.tokenExpiry = Date.now() + (tokenData.expires_in * 1000 * 0.9);
137
+ logger.debug('OAuth token acquired successfully');
138
+ }
139
+ catch (error) {
140
+ if (error instanceof ServiceNowError) {
141
+ throw error;
142
+ }
143
+ throw new ServiceNowError(`OAuth authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`, 'AUTHENTICATION_FAILED');
144
+ }
145
+ finally {
146
+ clearTimeout(oauthTimeout);
147
+ }
148
+ }
149
+ /**
150
+ * Get authorization header for requests.
151
+ * Per-user mode returns the user's own Bearer token directly.
152
+ * Impersonation and service-account modes use the configured service account.
153
+ */
154
+ getAuthHeader() {
155
+ // Per-user: use the individual user's token (highest precedence)
156
+ if (this.authMode === 'per-user' && this.perUserBearerToken) {
157
+ return `Bearer ${this.perUserBearerToken}`;
158
+ }
159
+ if (!this.accessToken) {
160
+ throw new ServiceNowError('OAuth token not available. Call authenticate() first.', 'AUTHENTICATION_FAILED');
161
+ }
162
+ return `Bearer ${this.accessToken}`;
163
+ }
164
+ /**
165
+ * Returns the X-Sn-Impersonate header value if impersonation mode is active.
166
+ * ServiceNow executes the request in the context of the named user's roles/ACLs.
167
+ */
168
+ getImpersonateHeader() {
169
+ if (this.authMode === 'impersonation' && this.impersonateUserSysId) {
170
+ return this.impersonateUserSysId;
171
+ }
172
+ return undefined;
173
+ }
174
+ /**
175
+ * Make HTTP request with retry logic
176
+ */
177
+ async request(url, options = {}) {
178
+ let lastError;
179
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
180
+ const controller = new AbortController();
181
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
182
+ try {
183
+ const extraHeaders = {};
184
+ const impersonateHeader = this.getImpersonateHeader();
185
+ if (impersonateHeader) {
186
+ extraHeaders['X-Sn-Impersonate'] = impersonateHeader;
187
+ }
188
+ const response = await fetch(url, {
189
+ ...options,
190
+ signal: controller.signal,
191
+ headers: {
192
+ 'Accept': 'application/json',
193
+ 'Content-Type': 'application/json',
194
+ 'Authorization': this.getAuthHeader(),
195
+ ...extraHeaders,
196
+ ...options.headers,
197
+ },
198
+ });
199
+ // Handle HTTP errors
200
+ if (!response.ok) {
201
+ const errorText = await response.text();
202
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
203
+ try {
204
+ const errorJson = JSON.parse(errorText);
205
+ if (errorJson.error?.message) {
206
+ errorMessage = errorJson.error.message;
207
+ }
208
+ }
209
+ catch {
210
+ // Error response wasn't JSON, use status text
211
+ }
212
+ // Map HTTP status to error codes
213
+ let errorCode = 'API_ERROR';
214
+ if (response.status === 401) {
215
+ errorCode = 'AUTHENTICATION_FAILED';
216
+ }
217
+ else if (response.status === 403) {
218
+ errorCode = 'INSUFFICIENT_PRIVILEGES';
219
+ }
220
+ else if (response.status === 404) {
221
+ errorCode = 'NOT_FOUND';
222
+ }
223
+ else if (response.status === 400) {
224
+ errorCode = 'INVALID_REQUEST';
225
+ }
226
+ throw new ServiceNowError(errorMessage, errorCode);
227
+ }
228
+ // 204 No Content (e.g. DELETE success) has no body
229
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
230
+ return undefined;
231
+ }
232
+ const data = await response.json();
233
+ return data;
234
+ }
235
+ catch (error) {
236
+ lastError = error instanceof Error ? error : new Error('Unknown error');
237
+ // Don't retry on auth errors or invalid requests
238
+ if (error instanceof ServiceNowError) {
239
+ if (['AUTHENTICATION_FAILED', 'INVALID_REQUEST', 'NOT_FOUND', 'VALIDATION_ERROR'].includes(error.code)) {
240
+ throw error;
241
+ }
242
+ }
243
+ // Retry on network errors or server errors
244
+ if (attempt < this.maxRetries) {
245
+ const delay = this.retryDelayMs * Math.pow(2, attempt); // Exponential backoff
246
+ logger.warn(`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`);
247
+ await new Promise(resolve => setTimeout(resolve, delay));
248
+ continue;
249
+ }
250
+ }
251
+ finally {
252
+ clearTimeout(timeout);
253
+ }
254
+ }
255
+ // Surface the real cause from Node.js fetch failures
256
+ if (lastError) {
257
+ const cause = lastError.cause;
258
+ if (cause) {
259
+ throw new ServiceNowError(`Failed to query records: ${cause.message}`, cause.code || 'NETWORK_ERROR');
260
+ }
261
+ throw lastError;
262
+ }
263
+ throw new Error('Request failed after retries');
264
+ }
265
+ /**
266
+ * Query records from a ServiceNow table
267
+ */
268
+ async queryRecords(params) {
269
+ // Validate inputs
270
+ validateTableName(params.table);
271
+ if (params.query)
272
+ validateQuery(params.query);
273
+ // Authenticate before making API calls
274
+ await this.authenticate();
275
+ // Build query parameters
276
+ const queryParams = new URLSearchParams();
277
+ if (params.query) {
278
+ queryParams.set('sysparm_query', params.query);
279
+ }
280
+ if (params.fields) {
281
+ queryParams.set('sysparm_fields', params.fields);
282
+ }
283
+ // Opt-in human-readable reference/choice values. Constrain to the two valid
284
+ // modes so the value can never be used for query-param injection.
285
+ if (params.display_value !== undefined && params.display_value !== false) {
286
+ queryParams.set('sysparm_display_value', params.display_value === 'all' ? 'all' : 'true');
287
+ }
288
+ if (params.limit !== undefined) {
289
+ queryParams.set('sysparm_limit', Math.min(params.limit, 1000).toString());
290
+ }
291
+ else {
292
+ queryParams.set('sysparm_limit', '10'); // Default limit
293
+ }
294
+ if (params.offset !== undefined) {
295
+ queryParams.set('sysparm_offset', params.offset.toString());
296
+ }
297
+ if (params.orderBy) {
298
+ // Handle descending sort (prefix with "-")
299
+ if (params.orderBy.startsWith('-')) {
300
+ const field = validateOrderByField(params.orderBy.substring(1));
301
+ queryParams.set('sysparm_query', params.query
302
+ ? `${params.query}^ORDERBYDESC${field}`
303
+ : `ORDERBYDESC${field}`);
304
+ }
305
+ else {
306
+ const field = validateOrderByField(params.orderBy);
307
+ queryParams.set('sysparm_query', params.query
308
+ ? `${params.query}^ORDERBY${field}`
309
+ : `ORDERBY${field}`);
310
+ }
311
+ }
312
+ const url = `${this.baseUrl}/api/now/table/${params.table}?${queryParams.toString()}`;
313
+ logger.info(`Querying ServiceNow table: ${params.table}`);
314
+ logger.debug(`Query: ${params.query || 'none'}`);
315
+ try {
316
+ const response = await this.request(url);
317
+ return {
318
+ count: response.result.length,
319
+ records: response.result,
320
+ };
321
+ }
322
+ catch (error) {
323
+ if (error instanceof ServiceNowError) {
324
+ throw error;
325
+ }
326
+ throw new ServiceNowError(`Failed to query records: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
327
+ }
328
+ }
329
+ /**
330
+ * Get table schema/structure
331
+ */
332
+ async getTableSchema(tableName) {
333
+ validateTableName(tableName);
334
+ await this.authenticate();
335
+ const url = `${this.baseUrl}/api/now/table/${tableName}?sysparm_exclude_reference_link=true&sysparm_limit=1`;
336
+ logger.info(`Getting schema for table: ${tableName}`);
337
+ try {
338
+ // Get table structure by querying with limit=1
339
+ const response = await this.request(url);
340
+ // Extract field names and types from the result
341
+ if (response.result && response.result.length > 0) {
342
+ const sample = response.result[0];
343
+ const columns = Object.keys(sample).map(key => ({
344
+ element: key,
345
+ value_sample: sample[key],
346
+ }));
347
+ return {
348
+ table: tableName,
349
+ columns,
350
+ };
351
+ }
352
+ return {
353
+ table: tableName,
354
+ columns: [],
355
+ };
356
+ }
357
+ catch (error) {
358
+ if (error instanceof ServiceNowError) {
359
+ throw error;
360
+ }
361
+ throw new ServiceNowError(`Failed to get table schema: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
362
+ }
363
+ }
364
+ /**
365
+ * Get a single record by sys_id
366
+ */
367
+ async getRecord(table, sysId, fields) {
368
+ validateTableName(table);
369
+ validateSysId(sysId);
370
+ await this.authenticate();
371
+ const queryParams = new URLSearchParams();
372
+ if (fields) {
373
+ queryParams.set('sysparm_fields', fields);
374
+ }
375
+ const url = `${this.baseUrl}/api/now/table/${table}/${sysId}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
376
+ logger.info(`Getting record from ${table}: ${sysId}`);
377
+ try {
378
+ const response = await this.request(url);
379
+ return response.result;
380
+ }
381
+ catch (error) {
382
+ if (error instanceof ServiceNowError) {
383
+ throw error;
384
+ }
385
+ throw new ServiceNowError(`Failed to get record: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
386
+ }
387
+ }
388
+ /**
389
+ * Get user details by email or username
390
+ */
391
+ async getUser(userIdentifier) {
392
+ await this.authenticate();
393
+ // Try user_name, email, or sys_id
394
+ if (/^[0-9a-f]{32}$/i.test(userIdentifier)) {
395
+ return await this.getRecord('sys_user', userIdentifier);
396
+ }
397
+ const safe = sanitizeLikeValue(userIdentifier);
398
+ const params = new URLSearchParams({ sysparm_query: `user_name=${safe}^ORemail=${safe}`, sysparm_limit: '1' });
399
+ const url = `${this.baseUrl}/api/now/table/sys_user?${params.toString()}`;
400
+ logger.info(`Looking up user: ${userIdentifier}`);
401
+ try {
402
+ const response = await this.request(url);
403
+ if (response.result.length === 0) {
404
+ throw new ServiceNowError(`User not found: ${userIdentifier}`, 'NOT_FOUND');
405
+ }
406
+ return response.result[0];
407
+ }
408
+ catch (error) {
409
+ if (error instanceof ServiceNowError) {
410
+ throw error;
411
+ }
412
+ throw new ServiceNowError(`Failed to get user: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
413
+ }
414
+ }
415
+ /**
416
+ * Get group details by name or sys_id
417
+ */
418
+ async getGroup(groupIdentifier) {
419
+ await this.authenticate();
420
+ // Check if it's a sys_id (32 hex chars) or name
421
+ const isSysId = /^[0-9a-f]{32}$/i.test(groupIdentifier);
422
+ const safe = sanitizeLikeValue(groupIdentifier);
423
+ const params = new URLSearchParams({ sysparm_query: isSysId ? `sys_id=${safe}` : `name=${safe}`, sysparm_limit: '1' });
424
+ const url = `${this.baseUrl}/api/now/table/sys_user_group?${params.toString()}`;
425
+ logger.info(`Looking up group: ${groupIdentifier}`);
426
+ try {
427
+ const response = await this.request(url);
428
+ if (response.result.length === 0) {
429
+ throw new ServiceNowError(`Group not found: ${groupIdentifier}`, 'NOT_FOUND');
430
+ }
431
+ return response.result[0];
432
+ }
433
+ catch (error) {
434
+ if (error instanceof ServiceNowError) {
435
+ throw error;
436
+ }
437
+ throw new ServiceNowError(`Failed to get group: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
438
+ }
439
+ }
440
+ /**
441
+ * Search CMDB configuration items
442
+ */
443
+ async searchCmdbCi(query, limit = 10) {
444
+ await this.authenticate();
445
+ const queryParams = new URLSearchParams();
446
+ if (query) {
447
+ queryParams.set('sysparm_query', query);
448
+ }
449
+ queryParams.set('sysparm_limit', Math.min(limit, 100).toString());
450
+ const url = `${this.baseUrl}/api/now/table/cmdb_ci?${queryParams.toString()}`;
451
+ logger.info('Searching CMDB CIs');
452
+ try {
453
+ const response = await this.request(url);
454
+ return {
455
+ count: response.result.length,
456
+ records: response.result,
457
+ };
458
+ }
459
+ catch (error) {
460
+ if (error instanceof ServiceNowError) {
461
+ throw error;
462
+ }
463
+ throw new ServiceNowError(`Failed to search CMDB CIs: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
464
+ }
465
+ }
466
+ /**
467
+ * Get a specific CMDB configuration item
468
+ */
469
+ async getCmdbCi(ciSysId, fields) {
470
+ return this.getRecord('cmdb_ci', ciSysId, fields);
471
+ }
472
+ /**
473
+ * List relationships for a CI
474
+ */
475
+ async listRelationships(ciSysId) {
476
+ await this.authenticate();
477
+ validateSysId(ciSysId);
478
+ const params = new URLSearchParams({ sysparm_query: `parent=${ciSysId}^ORchild=${ciSysId}` });
479
+ const url = `${this.baseUrl}/api/now/table/cmdb_rel_ci?${params.toString()}`;
480
+ logger.info(`Listing relationships for CI: ${ciSysId}`);
481
+ try {
482
+ const response = await this.request(url);
483
+ return {
484
+ count: response.result.length,
485
+ relationships: response.result,
486
+ };
487
+ }
488
+ catch (error) {
489
+ if (error instanceof ServiceNowError) {
490
+ throw error;
491
+ }
492
+ throw new ServiceNowError(`Failed to list relationships: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
493
+ }
494
+ }
495
+ /**
496
+ * List discovery schedules
497
+ */
498
+ async listDiscoverySchedules(activeOnly = false) {
499
+ await this.authenticate();
500
+ const query = activeOnly ? 'active=true' : '';
501
+ const url = `${this.baseUrl}/api/now/table/discovery_schedule${query ? '?sysparm_query=' + query : ''}`;
502
+ logger.info('Listing discovery schedules');
503
+ try {
504
+ const response = await this.request(url);
505
+ return {
506
+ count: response.result.length,
507
+ schedules: response.result,
508
+ };
509
+ }
510
+ catch (error) {
511
+ if (error instanceof ServiceNowError) {
512
+ throw error;
513
+ }
514
+ throw new ServiceNowError(`Failed to list discovery schedules: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
515
+ }
516
+ }
517
+ /**
518
+ * List MID servers
519
+ */
520
+ async listMidServers(activeOnly = false) {
521
+ await this.authenticate();
522
+ const query = activeOnly ? 'status=Up' : '';
523
+ const url = `${this.baseUrl}/api/now/table/ecc_agent${query ? '?sysparm_query=' + query : ''}`;
524
+ logger.info('Listing MID servers');
525
+ try {
526
+ const response = await this.request(url);
527
+ return {
528
+ count: response.result.length,
529
+ mid_servers: response.result,
530
+ };
531
+ }
532
+ catch (error) {
533
+ if (error instanceof ServiceNowError) {
534
+ throw error;
535
+ }
536
+ throw new ServiceNowError(`Failed to list MID servers: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
537
+ }
538
+ }
539
+ /**
540
+ * List active events
541
+ */
542
+ async listActiveEvents(query, limit = 10) {
543
+ await this.authenticate();
544
+ const queryParams = new URLSearchParams();
545
+ if (query) {
546
+ queryParams.set('sysparm_query', query);
547
+ }
548
+ queryParams.set('sysparm_limit', limit.toString());
549
+ const url = `${this.baseUrl}/api/now/table/em_event?${queryParams.toString()}`;
550
+ logger.info('Listing active events');
551
+ try {
552
+ const response = await this.request(url);
553
+ return {
554
+ count: response.result.length,
555
+ records: response.result,
556
+ };
557
+ }
558
+ catch (error) {
559
+ if (error instanceof ServiceNowError) {
560
+ throw error;
561
+ }
562
+ throw new ServiceNowError(`Failed to list events: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
563
+ }
564
+ }
565
+ /**
566
+ * Get CMDB health dashboard metrics
567
+ */
568
+ async cmdbHealthDashboard() {
569
+ await this.authenticate();
570
+ logger.info('Getting CMDB health metrics');
571
+ try {
572
+ // Get server metrics
573
+ const serversUrl = `${this.baseUrl}/api/now/table/cmdb_ci_server?sysparm_fields=sys_id,ip_address,os,serial_number`;
574
+ const serversResponse = await this.request(serversUrl);
575
+ const servers = serversResponse.result;
576
+ const serversWithIp = servers.filter(s => s.ip_address).length;
577
+ const serversWithOs = servers.filter(s => s.os).length;
578
+ const serversWithSerial = servers.filter(s => s.serial_number).length;
579
+ // Get network device metrics
580
+ const networkUrl = `${this.baseUrl}/api/now/table/cmdb_ci_network_adapter?sysparm_fields=sys_id,ip_address,mac_address&sysparm_limit=100`;
581
+ const networkResponse = await this.request(networkUrl);
582
+ const network = networkResponse.result;
583
+ const networkWithIp = network.filter(n => n.ip_address).length;
584
+ const networkWithMac = network.filter(n => n.mac_address).length;
585
+ return {
586
+ server_metrics: {
587
+ total: servers.length,
588
+ with_ip: serversWithIp,
589
+ with_os: serversWithOs,
590
+ with_serial: serversWithSerial,
591
+ ip_completeness: servers.length > 0 ? ((serversWithIp / servers.length) * 100).toFixed(2) : '0',
592
+ os_completeness: servers.length > 0 ? ((serversWithOs / servers.length) * 100).toFixed(2) : '0',
593
+ },
594
+ network_metrics: {
595
+ total: network.length,
596
+ with_ip: networkWithIp,
597
+ with_mac: networkWithMac,
598
+ ip_completeness: network.length > 0 ? ((networkWithIp / network.length) * 100).toFixed(2) : '0',
599
+ mac_completeness: network.length > 0 ? ((networkWithMac / network.length) * 100).toFixed(2) : '0',
600
+ },
601
+ };
602
+ }
603
+ catch (error) {
604
+ if (error instanceof ServiceNowError) {
605
+ throw error;
606
+ }
607
+ throw new ServiceNowError(`Failed to get CMDB health: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
608
+ }
609
+ }
610
+ /**
611
+ * Get service mapping summary
612
+ */
613
+ async serviceMappingSummary(serviceSysId) {
614
+ await this.authenticate();
615
+ validateSysId(serviceSysId);
616
+ logger.info(`Getting service mapping summary for: ${serviceSysId}`);
617
+ try {
618
+ // Get service details
619
+ const serviceUrl = `${this.baseUrl}/api/now/table/cmdb_ci_service/${serviceSysId}`;
620
+ const serviceResponse = await this.request(serviceUrl);
621
+ // Get related CIs — use URLSearchParams to safely encode the query
622
+ const relParams = new URLSearchParams({ sysparm_query: `parent=${serviceSysId}^ORchild=${serviceSysId}` });
623
+ const relatedUrl = `${this.baseUrl}/api/now/table/cmdb_rel_ci?${relParams.toString()}`;
624
+ const relatedResponse = await this.request(relatedUrl);
625
+ return {
626
+ service: serviceResponse.result,
627
+ related_cis_count: relatedResponse.result.length,
628
+ related_cis: relatedResponse.result,
629
+ };
630
+ }
631
+ catch (error) {
632
+ if (error instanceof ServiceNowError) {
633
+ throw error;
634
+ }
635
+ throw new ServiceNowError(`Failed to get service mapping: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
636
+ }
637
+ }
638
+ /**
639
+ * Create a change request
640
+ */
641
+ async createChangeRequest(params) {
642
+ await this.authenticate();
643
+ logger.info('Creating change request');
644
+ const url = `${this.baseUrl}/api/now/table/change_request`;
645
+ try {
646
+ const response = await this.request(url, {
647
+ method: 'POST',
648
+ body: JSON.stringify(params),
649
+ });
650
+ return response.result;
651
+ }
652
+ catch (error) {
653
+ if (error instanceof ServiceNowError) {
654
+ throw error;
655
+ }
656
+ throw new ServiceNowError(`Failed to create change request: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
657
+ }
658
+ }
659
+ /**
660
+ * Create a record in any ServiceNow table
661
+ */
662
+ async createRecord(table, data) {
663
+ validateTableName(table);
664
+ await this.authenticate();
665
+ logger.info(`Creating record in ${table}`);
666
+ const url = `${this.baseUrl}/api/now/table/${table}`;
667
+ try {
668
+ const response = await this.request(url, {
669
+ method: 'POST',
670
+ body: JSON.stringify(data),
671
+ });
672
+ return response.result;
673
+ }
674
+ catch (error) {
675
+ if (error instanceof ServiceNowError)
676
+ throw error;
677
+ throw new ServiceNowError(`Failed to create record in ${table}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'CREATE_FAILED');
678
+ }
679
+ }
680
+ /**
681
+ * Update a record in any ServiceNow table
682
+ */
683
+ async updateRecord(table, sysId, data) {
684
+ validateTableName(table);
685
+ validateSysId(sysId);
686
+ await this.authenticate();
687
+ logger.info(`Updating record ${sysId} in ${table}`);
688
+ const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`;
689
+ try {
690
+ const response = await this.request(url, {
691
+ method: 'PATCH',
692
+ body: JSON.stringify(data),
693
+ });
694
+ return response.result;
695
+ }
696
+ catch (error) {
697
+ if (error instanceof ServiceNowError)
698
+ throw error;
699
+ throw new ServiceNowError(`Failed to update record ${sysId} in ${table}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'UPDATE_FAILED');
700
+ }
701
+ }
702
+ /**
703
+ * Delete a record from any ServiceNow table
704
+ */
705
+ async deleteRecord(table, sysId) {
706
+ validateTableName(table);
707
+ validateSysId(sysId);
708
+ await this.authenticate();
709
+ logger.info(`Deleting record ${sysId} from ${table}`);
710
+ const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`;
711
+ try {
712
+ await this.request(url, { method: 'DELETE' });
713
+ }
714
+ catch (error) {
715
+ if (error instanceof ServiceNowError)
716
+ throw error;
717
+ throw new ServiceNowError(`Failed to delete record ${sysId} from ${table}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'DELETE_FAILED');
718
+ }
719
+ }
720
+ /**
721
+ * Call Now Assist / Generative AI endpoints (latest release)
722
+ */
723
+ async callNowAssist(endpoint, payload) {
724
+ if (!endpoint.startsWith('/api/')) {
725
+ throw new ServiceNowError(`Invalid endpoint: "${endpoint}". Must start with /api/.`, 'VALIDATION_ERROR');
726
+ }
727
+ await this.authenticate();
728
+ logger.info(`Calling Now Assist endpoint: ${endpoint}`);
729
+ const url = `${this.baseUrl}${endpoint}`;
730
+ try {
731
+ const response = await this.request(url, {
732
+ method: 'POST',
733
+ body: JSON.stringify(payload),
734
+ });
735
+ return response;
736
+ }
737
+ catch (error) {
738
+ if (error instanceof ServiceNowError)
739
+ throw error;
740
+ throw new ServiceNowError(`Now Assist call failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'NOW_ASSIST_ERROR');
741
+ }
742
+ }
743
+ async callApiGet(endpoint) {
744
+ if (!endpoint.startsWith('/api/')) {
745
+ throw new ServiceNowError(`Invalid endpoint: "${endpoint}". Must start with /api/.`, 'VALIDATION_ERROR');
746
+ }
747
+ await this.authenticate();
748
+ logger.info(`GET ${endpoint}`);
749
+ const url = `${this.baseUrl}${endpoint}`;
750
+ try {
751
+ return await this.request(url);
752
+ }
753
+ catch (error) {
754
+ if (error instanceof ServiceNowError)
755
+ throw error;
756
+ throw new ServiceNowError(`API GET failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'API_ERROR');
757
+ }
758
+ }
759
+ /**
760
+ * Run aggregate/stats query on a table (ServiceNow Reporting API)
761
+ */
762
+ async runAggregateQuery(table, groupBy, _aggregate = 'COUNT', query) {
763
+ await this.authenticate();
764
+ validateTableName(table);
765
+ validateOrderByField(groupBy);
766
+ const params = new URLSearchParams();
767
+ params.set('sysparm_group_by', groupBy);
768
+ if (query)
769
+ params.set('sysparm_query', query);
770
+ params.set('sysparm_count', 'true');
771
+ const url = `${this.baseUrl}/api/now/stats/${table}?${params.toString()}`;
772
+ try {
773
+ const response = await this.request(url);
774
+ return response.result;
775
+ }
776
+ catch (error) {
777
+ if (error instanceof ServiceNowError)
778
+ throw error;
779
+ throw new ServiceNowError(`Aggregate query failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'QUERY_FAILED');
780
+ }
781
+ }
782
+ /**
783
+ * Natural language search (simplified implementation)
784
+ */
785
+ async naturalLanguageSearch(query, limit = 10) {
786
+ // For now, search across incidents - in a full implementation,
787
+ // this would use NLP to determine the table and build the query
788
+ logger.info(`Natural language search: ${query}`);
789
+ const safeQuery = sanitizeLikeValue(query);
790
+ const searchQuery = `short_descriptionLIKE${safeQuery}^ORdescriptionLIKE${safeQuery}`;
791
+ return this.queryRecords({
792
+ table: 'incident',
793
+ query: searchQuery,
794
+ limit,
795
+ });
796
+ }
797
+ /**
798
+ * Upload a file attachment to a ServiceNow record via the Attachment API.
799
+ * Accepts base64-encoded content and uploads it as a multipart form.
800
+ */
801
+ async uploadAttachment(table, recordSysId, fileName, contentType, contentBase64) {
802
+ await this.authenticate();
803
+ const url = `${this.baseUrl}/api/now/attachment/file?table_name=${encodeURIComponent(table)}&table_sys_id=${encodeURIComponent(recordSysId)}&file_name=${encodeURIComponent(fileName)}`;
804
+ logger.info(`Uploading attachment "${fileName}" to ${table}:${recordSysId}`);
805
+ const attachController = new AbortController();
806
+ const attachTimeout = setTimeout(() => attachController.abort(), this.requestTimeoutMs);
807
+ try {
808
+ // Decode base64 to binary
809
+ const binary = Buffer.from(contentBase64, 'base64');
810
+ const response = await fetch(url, {
811
+ method: 'POST',
812
+ signal: attachController.signal,
813
+ headers: {
814
+ 'Content-Type': contentType,
815
+ 'Authorization': this.getAuthHeader(),
816
+ 'Accept': 'application/json',
817
+ },
818
+ body: binary,
819
+ });
820
+ if (!response.ok) {
821
+ const errorText = await response.text();
822
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
823
+ try {
824
+ const errorJson = JSON.parse(errorText);
825
+ if (errorJson.error?.message)
826
+ errorMessage = errorJson.error.message;
827
+ }
828
+ catch {
829
+ // ignore parse error
830
+ }
831
+ throw new ServiceNowError(errorMessage, 'ATTACHMENT_UPLOAD_FAILED');
832
+ }
833
+ const data = await response.json();
834
+ return data.result ?? data;
835
+ }
836
+ catch (error) {
837
+ if (error instanceof ServiceNowError)
838
+ throw error;
839
+ throw new ServiceNowError(`Failed to upload attachment: ${error instanceof Error ? error.message : 'Unknown error'}`, 'ATTACHMENT_UPLOAD_FAILED');
840
+ }
841
+ finally {
842
+ clearTimeout(attachTimeout);
843
+ }
844
+ }
845
+ /**
846
+ * Natural language update (simplified implementation)
847
+ */
848
+ async naturalLanguageUpdate(_instruction, _table) {
849
+ // This is a simplified implementation - a full version would parse
850
+ // the instruction to extract record identifier and field updates
851
+ logger.warn('Natural language update is experimental and requires manual parsing');
852
+ throw new ServiceNowError('Natural language update requires custom parsing logic - not yet implemented', 'NOT_IMPLEMENTED');
853
+ }
854
+ }
855
+ //# sourceMappingURL=client.js.map