@rishibhushan/jenkins-mcp-server 1.0.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.
@@ -0,0 +1,516 @@
1
+ """
2
+ Jenkins MCP Server Client Module
3
+
4
+ Provides a clean interface to Jenkins API operations with automatic fallback
5
+ between python-jenkins library and direct REST API calls.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import xml.etree.ElementTree as ET
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import jenkins
14
+ import requests
15
+ import urllib3
16
+ from requests.auth import HTTPBasicAuth
17
+
18
+ from .config import JenkinsSettings, get_default_settings
19
+
20
+ # Disable SSL warnings
21
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
22
+
23
+ # Configure logging
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class JenkinsConnectionError(Exception):
28
+ """Raised when unable to connect to Jenkins"""
29
+ pass
30
+
31
+
32
+ class JenkinsClient:
33
+ """
34
+ Client for interacting with Jenkins API.
35
+
36
+ Supports both python-jenkins library and direct REST API calls
37
+ with automatic fallback for reliability.
38
+ """
39
+
40
+ def __init__(self, settings: Optional[JenkinsSettings] = None):
41
+ """
42
+ Initialize Jenkins client.
43
+
44
+ Args:
45
+ settings: JenkinsSettings instance. If None, uses default settings.
46
+
47
+ Raises:
48
+ JenkinsConnectionError: If unable to connect to Jenkins
49
+ ValueError: If required settings are missing
50
+ """
51
+ self.settings = settings or get_default_settings()
52
+
53
+ # Validate required settings
54
+ if not self.settings.is_configured:
55
+ raise ValueError(
56
+ "Jenkins settings incomplete. Required: url, username, and (token or password)"
57
+ )
58
+
59
+ self.base_url = self.settings.url
60
+ username, auth_value = self.settings.get_credentials()
61
+ self.auth = HTTPBasicAuth(username, auth_value)
62
+
63
+ # Cache for python-jenkins server instance
64
+ self._server: Optional[jenkins.Jenkins] = None
65
+
66
+ # Test connection
67
+ self._test_connection()
68
+
69
+ def _test_connection(self) -> None:
70
+ """Test connection to Jenkins server"""
71
+ try:
72
+ response = requests.get(
73
+ f"{self.base_url}/api/json",
74
+ auth=self.auth,
75
+ verify=False,
76
+ timeout=10
77
+ )
78
+ response.raise_for_status()
79
+
80
+ data = response.json()
81
+ logger.info(f"Connected to Jenkins: {self.base_url}")
82
+ logger.info(f"Jenkins version: {data.get('_class', 'unknown')}")
83
+ logger.debug(f"Found {len(data.get('jobs', []))} jobs")
84
+
85
+ except requests.RequestException as e:
86
+ raise JenkinsConnectionError(
87
+ f"Failed to connect to Jenkins at {self.base_url}: {e}"
88
+ ) from e
89
+
90
+ @property
91
+ def server(self) -> jenkins.Jenkins:
92
+ """Get or create python-jenkins server instance (lazy initialization)"""
93
+ if self._server is None:
94
+ username, password = self.settings.get_credentials()
95
+ self._server = jenkins.Jenkins(
96
+ self.base_url,
97
+ username=username,
98
+ password=password
99
+ )
100
+ return self._server
101
+
102
+ def _api_call(self, method: str, endpoint: str, **kwargs) -> requests.Response:
103
+ """
104
+ Make a direct REST API call to Jenkins.
105
+
106
+ Args:
107
+ method: HTTP method (GET, POST, etc.)
108
+ endpoint: API endpoint (e.g., '/job/myjob/api/json')
109
+ **kwargs: Additional arguments for requests
110
+
111
+ Returns:
112
+ requests.Response object
113
+ """
114
+ url = f"{self.base_url}{endpoint}"
115
+ kwargs.setdefault('auth', self.auth)
116
+ kwargs.setdefault('verify', False)
117
+ kwargs.setdefault('timeout', 30)
118
+
119
+ response = requests.request(method, url, **kwargs)
120
+ response.raise_for_status()
121
+ return response
122
+
123
+ # ==================== Job Information ====================
124
+
125
+ def get_jobs(self) -> List[Dict[str, Any]]:
126
+ """Get list of all Jenkins jobs"""
127
+ try:
128
+ return self.server.get_jobs()
129
+ except Exception as e:
130
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
131
+ response = self._api_call('GET', '/api/json')
132
+ return response.json().get('jobs', [])
133
+
134
+ def get_job_info(self, job_name: str) -> Dict[str, Any]:
135
+ """Get detailed information about a specific job"""
136
+ try:
137
+ return self.server.get_job_info(job_name)
138
+ except Exception as e:
139
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
140
+ response = self._api_call('GET', f'/job/{job_name}/api/json')
141
+ return response.json()
142
+
143
+ def get_last_build_number(self, job_name: str) -> Optional[int]:
144
+ """Get the last build number for a job"""
145
+ try:
146
+ info = self.get_job_info(job_name)
147
+
148
+ # Try lastBuild first
149
+ if info.get('lastBuild') and 'number' in info['lastBuild']:
150
+ return int(info['lastBuild']['number'])
151
+
152
+ # Fall back to lastCompletedBuild
153
+ if info.get('lastCompletedBuild') and 'number' in info['lastCompletedBuild']:
154
+ return int(info['lastCompletedBuild']['number'])
155
+
156
+ return None
157
+ except Exception as e:
158
+ logger.error(f"Error getting last build number for {job_name}: {e}")
159
+ return None
160
+
161
+ def get_last_build_timestamp(self, job_name: str) -> Optional[int]:
162
+ """Get timestamp (ms since epoch) of the last build"""
163
+ try:
164
+ last_num = self.get_last_build_number(job_name)
165
+ if last_num is None:
166
+ return None
167
+
168
+ build_info = self.get_build_info(job_name, last_num)
169
+ return build_info.get('timestamp')
170
+ except Exception as e:
171
+ logger.error(f"Error getting last build timestamp for {job_name}: {e}")
172
+ return None
173
+
174
+ # ==================== Build Information ====================
175
+
176
+ def get_build_info(self, job_name: str, build_number: int) -> Dict[str, Any]:
177
+ """Get information about a specific build"""
178
+ try:
179
+ return self.server.get_build_info(job_name, build_number)
180
+ except Exception as e:
181
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
182
+ response = self._api_call('GET', f'/job/{job_name}/{build_number}/api/json')
183
+ return response.json()
184
+
185
+ def get_build_console_output(self, job_name: str, build_number: int) -> str:
186
+ """Get console output from a build (alias for get_build_log)"""
187
+ return self.get_build_log(job_name, build_number)
188
+
189
+ def get_build_log(self, job_name: str, build_number: int) -> str:
190
+ """Get console log output from a build"""
191
+ try:
192
+ return self.server.get_build_console_output(job_name, build_number)
193
+ except Exception as e:
194
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
195
+ response = self._api_call('GET', f'/job/{job_name}/{build_number}/consoleText')
196
+ return response.text
197
+
198
+ # ==================== Build Operations ====================
199
+
200
+ def build_job(
201
+ self,
202
+ job_name: str,
203
+ parameters: Optional[Dict[str, Any]] = None,
204
+ wait_for_start: bool = True,
205
+ timeout: int = 30,
206
+ poll_interval: float = 1.0
207
+ ) -> Dict[str, Optional[int]]:
208
+ """
209
+ Trigger a build and optionally wait for it to start.
210
+
211
+ Args:
212
+ job_name: Name of the Jenkins job
213
+ parameters: Optional build parameters
214
+ wait_for_start: Wait for build to start and return build number
215
+ timeout: Maximum seconds to wait for build start
216
+ poll_interval: Seconds between polling attempts
217
+
218
+ Returns:
219
+ Dict with 'queue_id' and 'build_number' (if wait_for_start=True)
220
+ """
221
+ try:
222
+ # Trigger build using python-jenkins
223
+ queue_id = self.server.build_job(job_name, parameters or {})
224
+ queue_id = int(queue_id) if queue_id else None
225
+
226
+ build_number = None
227
+ if wait_for_start and queue_id:
228
+ build_number = self._wait_for_build_start(
229
+ queue_id, timeout, poll_interval
230
+ )
231
+
232
+ return {"queue_id": queue_id, "build_number": build_number}
233
+
234
+ except Exception as e:
235
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
236
+ return self._build_job_rest(
237
+ job_name, parameters, wait_for_start, timeout, poll_interval
238
+ )
239
+
240
+ def _build_job_rest(
241
+ self,
242
+ job_name: str,
243
+ parameters: Optional[Dict[str, Any]],
244
+ wait_for_start: bool,
245
+ timeout: int,
246
+ poll_interval: float
247
+ ) -> Dict[str, Optional[int]]:
248
+ """Build job using REST API (fallback method)"""
249
+ endpoint = f'/job/{job_name}/buildWithParameters' if parameters else f'/job/{job_name}/build'
250
+
251
+ response = self._api_call('POST', endpoint, params=parameters)
252
+
253
+ # Extract queue ID from Location header
254
+ location = response.headers.get('Location', '')
255
+ queue_id = self._extract_queue_id(location)
256
+
257
+ build_number = None
258
+ if wait_for_start and queue_id:
259
+ build_number = self._wait_for_build_start(queue_id, timeout, poll_interval)
260
+
261
+ return {"queue_id": queue_id, "build_number": build_number}
262
+
263
+ def _wait_for_build_start(
264
+ self,
265
+ queue_id: int,
266
+ timeout: int,
267
+ poll_interval: float
268
+ ) -> Optional[int]:
269
+ """Wait for a queued build to start and return its build number"""
270
+ elapsed = 0.0
271
+
272
+ while elapsed < timeout:
273
+ try:
274
+ # Try python-jenkins first
275
+ item = self.server.get_queue_item(queue_id)
276
+ if item and item.get('executable'):
277
+ return int(item['executable']['number'])
278
+ except Exception:
279
+ # Fall back to REST API
280
+ try:
281
+ response = self._api_call('GET', f'/queue/item/{queue_id}/api/json')
282
+ item = response.json()
283
+ if item.get('executable'):
284
+ return int(item['executable']['number'])
285
+ except Exception:
286
+ pass
287
+
288
+ time.sleep(poll_interval)
289
+ elapsed += poll_interval
290
+
291
+ logger.warning(f"Timeout waiting for build {queue_id} to start")
292
+ return None
293
+
294
+ @staticmethod
295
+ def _extract_queue_id(location: str) -> Optional[int]:
296
+ """Extract queue ID from Jenkins Location header"""
297
+ if not location:
298
+ return None
299
+
300
+ parts = location.rstrip('/').split('/')
301
+ for part in reversed(parts):
302
+ if part.isdigit():
303
+ return int(part)
304
+ return None
305
+
306
+ def stop_build(self, job_name: str, build_number: int) -> None:
307
+ """Stop a running build"""
308
+ self._api_call('POST', f'/job/{job_name}/{build_number}/stop')
309
+ logger.info(f"Stopped build {job_name} #{build_number}")
310
+
311
+ # ==================== Job Management ====================
312
+
313
+ def create_job(self, job_name: str, config_xml: str) -> bool:
314
+ """Create a new Jenkins job with XML configuration"""
315
+ try:
316
+ self.server.create_job(job_name, config_xml)
317
+ self.server.reconfig_job(job_name, config_xml)
318
+ logger.info(f"Created job: {job_name}")
319
+ return True
320
+ except Exception as e:
321
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
322
+ self._api_call(
323
+ 'POST',
324
+ '/createItem',
325
+ params={'name': job_name},
326
+ data=config_xml,
327
+ headers={'Content-Type': 'application/xml'}
328
+ )
329
+ logger.info(f"Created job: {job_name}")
330
+ return True
331
+
332
+ def create_job_from_copy(self, new_job_name: str, source_job_name: str) -> bool:
333
+ """Create a new job by copying an existing one"""
334
+ # Get source config
335
+ config_xml = self.get_job_config(source_job_name)
336
+
337
+ # Update job references in XML
338
+ config_xml = self._update_job_references(config_xml, source_job_name, new_job_name)
339
+
340
+ # Create new job
341
+ return self.create_job(new_job_name, config_xml)
342
+
343
+ @staticmethod
344
+ def _update_job_references(config_xml: str, old_name: str, new_name: str) -> str:
345
+ """Update job name references in XML configuration"""
346
+ try:
347
+ root = ET.fromstring(config_xml)
348
+
349
+ # Update projectName and projectFullName elements
350
+ for elem in root.iter():
351
+ if elem.tag in ['projectName', 'projectFullName'] and elem.text == old_name:
352
+ elem.text = new_name
353
+
354
+ return ET.tostring(root, encoding='unicode', method='xml')
355
+ except Exception as e:
356
+ logger.warning(f"Failed to update job references in XML: {e}")
357
+ return config_xml
358
+
359
+ def create_job_from_dict(
360
+ self,
361
+ job_name: str,
362
+ config_data: Dict[str, Any],
363
+ root_tag: str = 'project'
364
+ ) -> bool:
365
+ """
366
+ Create a job from a dictionary (simplified XML generation).
367
+
368
+ Note: For complex configurations, use create_job() with full XML
369
+ or create_job_from_copy() instead.
370
+ """
371
+ config_xml = self._dict_to_xml(root_tag, config_data)
372
+ return self.create_job(job_name, config_xml)
373
+
374
+ @staticmethod
375
+ def _dict_to_xml(root_tag: str, data: Dict[str, Any]) -> str:
376
+ """Convert dictionary to XML (basic implementation)"""
377
+
378
+ def build_elem(parent: ET.Element, obj: Any) -> None:
379
+ if isinstance(obj, dict):
380
+ for key, value in obj.items():
381
+ child = ET.SubElement(parent, key)
382
+ build_elem(child, value)
383
+ elif isinstance(obj, list):
384
+ for item in obj:
385
+ item_el = ET.SubElement(parent, 'item')
386
+ build_elem(item_el, item)
387
+ else:
388
+ parent.text = str(obj)
389
+
390
+ root = ET.Element(root_tag)
391
+ build_elem(root, data)
392
+ return ET.tostring(root, encoding='unicode')
393
+
394
+ def delete_job(self, job_name: str) -> bool:
395
+ """Delete an existing job"""
396
+ try:
397
+ self.server.delete_job(job_name)
398
+ logger.info(f"Deleted job: {job_name}")
399
+ return True
400
+ except Exception as e:
401
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
402
+ self._api_call('POST', f'/job/{job_name}/doDelete')
403
+ logger.info(f"Deleted job: {job_name}")
404
+ return True
405
+
406
+ def enable_job(self, job_name: str) -> bool:
407
+ """Enable a disabled job"""
408
+ self._api_call('POST', f'/job/{job_name}/enable')
409
+ logger.info(f"Enabled job: {job_name}")
410
+ return True
411
+
412
+ def disable_job(self, job_name: str) -> bool:
413
+ """Disable a job"""
414
+ self._api_call('POST', f'/job/{job_name}/disable')
415
+ logger.info(f"Disabled job: {job_name}")
416
+ return True
417
+
418
+ def rename_job(self, job_name: str, new_name: str) -> bool:
419
+ """Rename a job"""
420
+ self._api_call(
421
+ 'POST',
422
+ f'/job/{job_name}/doRename',
423
+ params={'newName': new_name}
424
+ )
425
+ logger.info(f"Renamed job: {job_name} -> {new_name}")
426
+ return True
427
+
428
+ # ==================== Job Configuration ====================
429
+
430
+ def get_job_config(self, job_name: str) -> str:
431
+ """Get job configuration XML"""
432
+ try:
433
+ return self.server.get_job_config(job_name)
434
+ except Exception as e:
435
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
436
+ response = self._api_call('GET', f'/job/{job_name}/config.xml')
437
+ return response.text
438
+
439
+ def update_job_config(self, job_name: str, config_xml: str) -> bool:
440
+ """Update job configuration XML"""
441
+ try:
442
+ self.server.reconfig_job(job_name, config_xml)
443
+ logger.info(f"Updated config for job: {job_name}")
444
+ return True
445
+ except Exception as e:
446
+ logger.debug(f"python-jenkins failed, using REST API: {e}")
447
+ self._api_call(
448
+ 'POST',
449
+ f'/job/{job_name}/config.xml',
450
+ data=config_xml,
451
+ headers={'Content-Type': 'application/xml'}
452
+ )
453
+ logger.info(f"Updated config for job: {job_name}")
454
+ return True
455
+
456
+ # ==================== Queue & Node Information ====================
457
+
458
+ def get_queue_info(self) -> List[Dict[str, Any]]:
459
+ """Get information about the build queue"""
460
+ try:
461
+ response = self._api_call('GET', '/queue/api/json')
462
+ return response.json().get('items', [])
463
+ except Exception as e:
464
+ logger.error(f"Error getting queue info: {e}")
465
+ return []
466
+
467
+ def get_nodes(self) -> List[Dict[str, Any]]:
468
+ """Get list of all Jenkins nodes"""
469
+ try:
470
+ response = self._api_call('GET', '/computer/api/json')
471
+ return response.json().get('computer', [])
472
+ except Exception as e:
473
+ logger.error(f"Error getting nodes: {e}")
474
+ return []
475
+
476
+ def get_node_info(self, node_name: str) -> Dict[str, Any]:
477
+ """Get information about a specific node"""
478
+ response = self._api_call('GET', f'/computer/{node_name}/api/json')
479
+ return response.json()
480
+
481
+
482
+ # ==================== Client Factory ====================
483
+
484
+ _default_client: Optional[JenkinsClient] = None
485
+
486
+
487
+ def get_jenkins_client(settings: Optional[JenkinsSettings] = None) -> JenkinsClient:
488
+ """
489
+ Get Jenkins client instance.
490
+
491
+ Args:
492
+ settings: Optional JenkinsSettings. If None, uses default settings.
493
+
494
+ Returns:
495
+ JenkinsClient instance
496
+
497
+ Note: If no settings provided, returns a cached default client (singleton).
498
+ If settings are provided, always returns a new client instance.
499
+ """
500
+ global _default_client
501
+
502
+ if settings is not None:
503
+ # Always create new client with custom settings
504
+ return JenkinsClient(settings)
505
+
506
+ # Use cached default client
507
+ if _default_client is None:
508
+ _default_client = JenkinsClient()
509
+
510
+ return _default_client
511
+
512
+
513
+ def reset_default_client() -> None:
514
+ """Reset the default client (useful for testing or reconfiguration)"""
515
+ global _default_client
516
+ _default_client = None