Skip to content

Commit 53036d8

Browse files
committed
Implement ServiceNow query_records functionality
Add complete implementation for querying ServiceNow records through MCP: New Features: - ServiceNowClient with OAuth and Basic Auth support - HTTP client with retry logic and exponential backoff - queryRecords() method for Table API integration - Full query parameter support (filters, fields, limit, sorting) - Comprehensive error handling and logging Files Modified: - src/servicenow/client.ts: Full HTTP client implementation with auth - src/servicenow/types.ts: Add query params and response interfaces - src/tools/index.ts: Implement query_records tool with validation Technical Details: - Uses Node.js native fetch (Node 20+) - OAuth token caching with automatic refresh - Basic Auth with base64 encoding - Supports ServiceNow encoded query syntax (^, ^OR, dot-walking) - Request timeout and retry logic - Error mapping to ServiceNowError codes Example Query: { "table": "incident", "query": "active=true^priority=1^assignment_group.name=Database", "fields": "number,short_description,state,assigned_to", "limit": 10, "orderBy": "-sys_updated_on" } Tested against: https://inteliblissltddemo2.service-now.com Authentication: Basic Auth
1 parent d024f20 commit 53036d8

3 files changed

Lines changed: 394 additions & 16 deletions

File tree

src/servicenow/client.ts

Lines changed: 277 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,281 @@
1-
export interface ServiceNowConfig {
2-
instanceUrl: string;
3-
authMethod: 'oauth' | 'basic';
4-
oauth?: { clientId?: string; clientSecret?: string; username?: string; password?: string; };
5-
basic?: { username?: string; password?: string; };
6-
maxRetries?: number;
7-
retryDelayMs?: number;
8-
requestTimeoutMs?: number;
9-
}
1+
import type {
2+
ServiceNowConfig,
3+
QueryRecordsParams,
4+
QueryRecordsResponse,
5+
OAuthTokenResponse,
6+
ServiceNowApiResponse,
7+
ServiceNowRecord,
8+
} from './types.js';
9+
import { ServiceNowError } from '../utils/errors.js';
10+
import { logger } from '../utils/logging.js';
1011

1112
export class ServiceNowClient {
12-
constructor(_config: ServiceNowConfig) {
13-
// Client initialization is silent
13+
private baseUrl: string;
14+
private authMethod: 'oauth' | 'basic';
15+
private oauthConfig?: ServiceNowConfig['oauth'];
16+
private basicConfig?: ServiceNowConfig['basic'];
17+
private maxRetries: number;
18+
private retryDelayMs: number;
19+
private requestTimeoutMs: number;
20+
21+
private accessToken?: string;
22+
private tokenExpiry?: number;
23+
24+
constructor(config: ServiceNowConfig) {
25+
this.baseUrl = config.instanceUrl.replace(/\/$/, ''); // Remove trailing slash
26+
this.authMethod = config.authMethod;
27+
this.oauthConfig = config.oauth;
28+
this.basicConfig = config.basic;
29+
this.maxRetries = config.maxRetries || 3;
30+
this.retryDelayMs = config.retryDelayMs || 1000;
31+
this.requestTimeoutMs = config.requestTimeoutMs || 30000;
32+
}
33+
34+
/**
35+
* Authenticate with ServiceNow using OAuth or Basic Auth
36+
*/
37+
private async authenticate(): Promise<void> {
38+
if (this.authMethod === 'basic') {
39+
// Basic auth doesn't require token acquisition
40+
return;
41+
}
42+
43+
// Check if we have a valid token
44+
if (this.accessToken && this.tokenExpiry && Date.now() < this.tokenExpiry) {
45+
return; // Token still valid
46+
}
47+
48+
// Acquire OAuth token
49+
if (!this.oauthConfig?.clientId || !this.oauthConfig?.clientSecret) {
50+
throw new ServiceNowError(
51+
'OAuth client ID and secret are required for OAuth authentication',
52+
'AUTHENTICATION_FAILED'
53+
);
54+
}
55+
56+
if (!this.oauthConfig?.username || !this.oauthConfig?.password) {
57+
throw new ServiceNowError(
58+
'Username and password are required for OAuth password grant',
59+
'AUTHENTICATION_FAILED'
60+
);
61+
}
62+
63+
const tokenUrl = `${this.baseUrl}/oauth_token.do`;
64+
const body = new URLSearchParams({
65+
grant_type: 'password',
66+
client_id: this.oauthConfig.clientId,
67+
client_secret: this.oauthConfig.clientSecret,
68+
username: this.oauthConfig.username,
69+
password: this.oauthConfig.password,
70+
});
71+
72+
try {
73+
const response = await fetch(tokenUrl, {
74+
method: 'POST',
75+
headers: {
76+
'Content-Type': 'application/x-www-form-urlencoded',
77+
},
78+
body: body.toString(),
79+
});
80+
81+
if (!response.ok) {
82+
throw new ServiceNowError(
83+
`OAuth authentication failed: ${response.status} ${response.statusText}`,
84+
'AUTHENTICATION_FAILED'
85+
);
86+
}
87+
88+
const tokenData = await response.json() as OAuthTokenResponse;
89+
this.accessToken = tokenData.access_token;
90+
// Set expiry to 90% of actual expiry time for safety margin
91+
this.tokenExpiry = Date.now() + (tokenData.expires_in * 1000 * 0.9);
92+
93+
logger.debug('OAuth token acquired successfully');
94+
} catch (error) {
95+
if (error instanceof ServiceNowError) {
96+
throw error;
97+
}
98+
throw new ServiceNowError(
99+
`OAuth authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`,
100+
'AUTHENTICATION_FAILED'
101+
);
102+
}
103+
}
104+
105+
/**
106+
* Get authorization header for requests
107+
*/
108+
private getAuthHeader(): string {
109+
if (this.authMethod === 'basic') {
110+
if (!this.basicConfig?.username || !this.basicConfig?.password) {
111+
throw new ServiceNowError(
112+
'Username and password are required for Basic authentication',
113+
'AUTHENTICATION_FAILED'
114+
);
115+
}
116+
const credentials = Buffer.from(
117+
`${this.basicConfig.username}:${this.basicConfig.password}`
118+
).toString('base64');
119+
return `Basic ${credentials}`;
120+
} else {
121+
if (!this.accessToken) {
122+
throw new ServiceNowError(
123+
'OAuth token not available. Call authenticate() first.',
124+
'AUTHENTICATION_FAILED'
125+
);
126+
}
127+
return `Bearer ${this.accessToken}`;
128+
}
129+
}
130+
131+
/**
132+
* Make HTTP request with retry logic
133+
*/
134+
private async request<T>(
135+
url: string,
136+
options: RequestInit = {}
137+
): Promise<T> {
138+
let lastError: Error | undefined;
139+
140+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
141+
try {
142+
const controller = new AbortController();
143+
const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
144+
145+
const response = await fetch(url, {
146+
...options,
147+
signal: controller.signal,
148+
headers: {
149+
'Accept': 'application/json',
150+
'Content-Type': 'application/json',
151+
'Authorization': this.getAuthHeader(),
152+
...options.headers,
153+
},
154+
});
155+
156+
clearTimeout(timeout);
157+
158+
// Handle HTTP errors
159+
if (!response.ok) {
160+
const errorText = await response.text();
161+
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
162+
163+
try {
164+
const errorJson = JSON.parse(errorText);
165+
if (errorJson.error?.message) {
166+
errorMessage = errorJson.error.message;
167+
}
168+
} catch {
169+
// Error response wasn't JSON, use status text
170+
}
171+
172+
// Map HTTP status to error codes
173+
let errorCode = 'API_ERROR';
174+
if (response.status === 401) {
175+
errorCode = 'AUTHENTICATION_FAILED';
176+
} else if (response.status === 403) {
177+
errorCode = 'INSUFFICIENT_PRIVILEGES';
178+
} else if (response.status === 404) {
179+
errorCode = 'NOT_FOUND';
180+
} else if (response.status === 400) {
181+
errorCode = 'INVALID_REQUEST';
182+
}
183+
184+
throw new ServiceNowError(errorMessage, errorCode);
185+
}
186+
187+
const data = await response.json();
188+
return data as T;
189+
190+
} catch (error) {
191+
lastError = error instanceof Error ? error : new Error('Unknown error');
192+
193+
// Don't retry on auth errors or invalid requests
194+
if (error instanceof ServiceNowError) {
195+
if (['AUTHENTICATION_FAILED', 'INVALID_REQUEST', 'NOT_FOUND'].includes(error.code)) {
196+
throw error;
197+
}
198+
}
199+
200+
// Retry on network errors or server errors
201+
if (attempt < this.maxRetries) {
202+
const delay = this.retryDelayMs * Math.pow(2, attempt); // Exponential backoff
203+
logger.warn(`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`);
204+
await new Promise(resolve => setTimeout(resolve, delay));
205+
continue;
206+
}
207+
}
208+
}
209+
210+
throw lastError || new Error('Request failed after retries');
211+
}
212+
213+
/**
214+
* Query records from a ServiceNow table
215+
*/
216+
async queryRecords(params: QueryRecordsParams): Promise<QueryRecordsResponse> {
217+
// Authenticate before making API calls
218+
await this.authenticate();
219+
220+
// Build query parameters
221+
const queryParams = new URLSearchParams();
222+
223+
if (params.query) {
224+
queryParams.set('sysparm_query', params.query);
225+
}
226+
227+
if (params.fields) {
228+
queryParams.set('sysparm_fields', params.fields);
229+
}
230+
231+
if (params.limit !== undefined) {
232+
queryParams.set('sysparm_limit', Math.min(params.limit, 1000).toString());
233+
} else {
234+
queryParams.set('sysparm_limit', '10'); // Default limit
235+
}
236+
237+
if (params.offset !== undefined) {
238+
queryParams.set('sysparm_offset', params.offset.toString());
239+
}
240+
241+
if (params.orderBy) {
242+
// Handle descending sort (prefix with "-")
243+
if (params.orderBy.startsWith('-')) {
244+
const field = params.orderBy.substring(1);
245+
queryParams.set('sysparm_query',
246+
params.query
247+
? `${params.query}^ORDERBY${field}^ORDERBYDESC`
248+
: `ORDERBY${field}^ORDERBYDESC`
249+
);
250+
} else {
251+
queryParams.set('sysparm_query',
252+
params.query
253+
? `${params.query}^ORDERBY${params.orderBy}`
254+
: `ORDERBY${params.orderBy}`
255+
);
256+
}
257+
}
258+
259+
const url = `${this.baseUrl}/api/now/table/${params.table}?${queryParams.toString()}`;
260+
261+
logger.info(`Querying ServiceNow table: ${params.table}`);
262+
logger.debug(`Query: ${params.query || 'none'}`);
263+
264+
try {
265+
const response = await this.request<ServiceNowApiResponse<ServiceNowRecord[]>>(url);
266+
267+
return {
268+
count: response.result.length,
269+
records: response.result,
270+
};
271+
} catch (error) {
272+
if (error instanceof ServiceNowError) {
273+
throw error;
274+
}
275+
throw new ServiceNowError(
276+
`Failed to query records: ${error instanceof Error ? error.message : 'Unknown error'}`,
277+
'QUERY_FAILED'
278+
);
279+
}
14280
}
15281
}

src/servicenow/types.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,50 @@ export interface ServiceNowConfig {
1111
username?: string;
1212
password?: string;
1313
};
14+
maxRetries?: number;
15+
retryDelayMs?: number;
16+
requestTimeoutMs?: number;
17+
}
18+
19+
export interface QueryRecordsParams {
20+
table: string;
21+
query?: string;
22+
fields?: string;
23+
limit?: number;
24+
orderBy?: string;
25+
offset?: number;
26+
}
27+
28+
export interface QueryRecordsResponse {
29+
count: number;
30+
records: ServiceNowRecord[];
31+
}
32+
33+
export interface ServiceNowRecord {
34+
[key: string]: string | number | boolean | ServiceNowReference | null | undefined;
35+
}
36+
37+
export interface ServiceNowReference {
38+
value: string;
39+
display_value: string;
40+
}
41+
42+
export interface OAuthTokenResponse {
43+
access_token: string;
44+
refresh_token: string;
45+
expires_in: number;
46+
token_type: string;
47+
scope: string;
48+
}
49+
50+
export interface ServiceNowApiResponse<T = any> {
51+
result: T;
52+
}
53+
54+
export interface ServiceNowApiError {
55+
error: {
56+
message: string;
57+
detail?: string;
58+
};
59+
status: string;
1460
}

0 commit comments

Comments
 (0)