@rishibhushan/jenkins-mcp-server 1.0.6 → 1.1.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.
@@ -14,19 +14,27 @@ from . import server
14
14
 
15
15
 
16
16
  def setup_logging(verbose: bool = False) -> None:
17
- """Configure logging for the application"""
18
- level = logging.DEBUG if verbose else logging.INFO
19
-
20
- logging.basicConfig(
21
- level=level,
22
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
23
- stream=sys.stderr
17
+ """Configure structured logging"""
18
+ import structlog
19
+
20
+ # Configure structlog
21
+ structlog.configure(
22
+ processors=[
23
+ structlog.stdlib.filter_by_level,
24
+ structlog.stdlib.add_logger_name,
25
+ structlog.stdlib.add_log_level,
26
+ structlog.stdlib.PositionalArgumentsFormatter(),
27
+ structlog.processors.TimeStamper(fmt="iso"),
28
+ structlog.processors.StackInfoRenderer(),
29
+ structlog.processors.format_exc_info,
30
+ structlog.processors.UnicodeDecoder(),
31
+ structlog.processors.JSONRenderer() if not verbose else structlog.dev.ConsoleRenderer()
32
+ ],
33
+ context_class=dict,
34
+ logger_factory=structlog.stdlib.LoggerFactory(),
35
+ cache_logger_on_first_use=True,
24
36
  )
25
37
 
26
- # Reduce noise from libraries
27
- logging.getLogger('urllib3').setLevel(logging.WARNING)
28
- logging.getLogger('requests').setLevel(logging.WARNING)
29
-
30
38
 
31
39
  def main():
32
40
  """
@@ -0,0 +1,310 @@
1
+ """
2
+ Caching Module for Jenkins MCP Server
3
+
4
+ Provides TTL-based caching for frequently accessed Jenkins data
5
+ to reduce API calls and improve performance.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import time
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta
13
+ from typing import Any, Callable, Dict, Optional, TypeVar
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ T = TypeVar('T')
18
+
19
+
20
+ @dataclass
21
+ class CachedData:
22
+ """Container for cached data with expiration"""
23
+ data: Any
24
+ cached_at: float
25
+ ttl_seconds: int
26
+ key: str
27
+
28
+ def is_expired(self) -> bool:
29
+ """Check if cached data has expired"""
30
+ age = time.time() - self.cached_at
31
+ return age >= self.ttl_seconds
32
+
33
+ def age_seconds(self) -> float:
34
+ """Get age of cached data in seconds"""
35
+ return time.time() - self.cached_at
36
+
37
+ def time_until_expiry(self) -> float:
38
+ """Get seconds until expiry (negative if expired)"""
39
+ return self.ttl_seconds - self.age_seconds()
40
+
41
+
42
+ class CacheManager:
43
+ """
44
+ Thread-safe cache manager with TTL support.
45
+
46
+ Features:
47
+ - TTL-based expiration
48
+ - Thread-safe operations with async locks
49
+ - Cache statistics
50
+ - Automatic cleanup of expired entries
51
+ """
52
+
53
+ def __init__(self):
54
+ """Initialize cache manager"""
55
+ self._cache: Dict[str, CachedData] = {}
56
+ self._lock = asyncio.Lock()
57
+
58
+ # Statistics
59
+ self._hits = 0
60
+ self._misses = 0
61
+ self._evictions = 0
62
+
63
+ logger.info("Cache manager initialized")
64
+
65
+ async def get(self, key: str) -> Optional[Any]:
66
+ """
67
+ Get cached data if available and not expired.
68
+
69
+ Args:
70
+ key: Cache key
71
+
72
+ Returns:
73
+ Cached data if available and valid, None otherwise
74
+ """
75
+ async with self._lock:
76
+ if key not in self._cache:
77
+ self._misses += 1
78
+ logger.debug(f"Cache miss: {key}")
79
+ return None
80
+
81
+ cached = self._cache[key]
82
+
83
+ if cached.is_expired():
84
+ # Remove expired entry
85
+ del self._cache[key]
86
+ self._evictions += 1
87
+ self._misses += 1
88
+ logger.debug(f"Cache expired: {key} (age: {cached.age_seconds():.1f}s)")
89
+ return None
90
+
91
+ self._hits += 1
92
+ logger.debug(f"Cache hit: {key} (age: {cached.age_seconds():.1f}s, ttl: {cached.ttl_seconds}s)")
93
+ return cached.data
94
+
95
+ async def set(self, key: str, data: Any, ttl_seconds: int = 30) -> None:
96
+ """
97
+ Store data in cache with TTL.
98
+
99
+ Args:
100
+ key: Cache key
101
+ data: Data to cache
102
+ ttl_seconds: Time-to-live in seconds
103
+ """
104
+ async with self._lock:
105
+ self._cache[key] = CachedData(
106
+ data=data,
107
+ cached_at=time.time(),
108
+ ttl_seconds=ttl_seconds,
109
+ key=key
110
+ )
111
+ logger.debug(f"Cached: {key} (ttl: {ttl_seconds}s)")
112
+
113
+ async def invalidate(self, key: str) -> bool:
114
+ """
115
+ Invalidate (remove) a specific cache entry.
116
+
117
+ Args:
118
+ key: Cache key to invalidate
119
+
120
+ Returns:
121
+ True if key was in cache, False otherwise
122
+ """
123
+ async with self._lock:
124
+ if key in self._cache:
125
+ del self._cache[key]
126
+ self._evictions += 1
127
+ logger.debug(f"Invalidated: {key}")
128
+ return True
129
+ return False
130
+
131
+ async def invalidate_pattern(self, pattern: str) -> int:
132
+ """
133
+ Invalidate all cache entries matching a pattern.
134
+
135
+ Args:
136
+ pattern: String pattern to match (substring match)
137
+
138
+ Returns:
139
+ Number of entries invalidated
140
+ """
141
+ async with self._lock:
142
+ keys_to_remove = [
143
+ key for key in self._cache.keys()
144
+ if pattern in key
145
+ ]
146
+
147
+ for key in keys_to_remove:
148
+ del self._cache[key]
149
+ self._evictions += 1
150
+
151
+ if keys_to_remove:
152
+ logger.debug(f"Invalidated {len(keys_to_remove)} entries matching '{pattern}'")
153
+
154
+ return len(keys_to_remove)
155
+
156
+ async def clear(self) -> int:
157
+ """
158
+ Clear all cached data.
159
+
160
+ Returns:
161
+ Number of entries cleared
162
+ """
163
+ async with self._lock:
164
+ count = len(self._cache)
165
+ self._cache.clear()
166
+ self._evictions += count
167
+ logger.info(f"Cache cleared: {count} entries removed")
168
+ return count
169
+
170
+ async def cleanup_expired(self) -> int:
171
+ """
172
+ Remove all expired entries from cache.
173
+
174
+ Returns:
175
+ Number of expired entries removed
176
+ """
177
+ async with self._lock:
178
+ expired_keys = [
179
+ key for key, cached in self._cache.items()
180
+ if cached.is_expired()
181
+ ]
182
+
183
+ for key in expired_keys:
184
+ del self._cache[key]
185
+ self._evictions += 1
186
+
187
+ if expired_keys:
188
+ logger.debug(f"Cleaned up {len(expired_keys)} expired entries")
189
+
190
+ return len(expired_keys)
191
+
192
+ async def get_or_fetch(
193
+ self,
194
+ key: str,
195
+ fetch_func: Callable[[], Any],
196
+ ttl_seconds: int = 30
197
+ ) -> Any:
198
+ """
199
+ Get cached data or fetch and cache if not available.
200
+
201
+ Args:
202
+ key: Cache key
203
+ fetch_func: Function to call to fetch data if not cached
204
+ ttl_seconds: TTL for newly cached data
205
+
206
+ Returns:
207
+ Cached or freshly fetched data
208
+ """
209
+ # Try cache first
210
+ cached_data = await self.get(key)
211
+ if cached_data is not None:
212
+ return cached_data
213
+
214
+ # Fetch and cache
215
+ data = fetch_func()
216
+ await self.set(key, data, ttl_seconds)
217
+ return data
218
+
219
+ def get_stats(self) -> Dict[str, Any]:
220
+ """
221
+ Get cache statistics.
222
+
223
+ Returns:
224
+ Dictionary with cache statistics
225
+ """
226
+ total_requests = self._hits + self._misses
227
+ hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
228
+
229
+ return {
230
+ "size": len(self._cache),
231
+ "hits": self._hits,
232
+ "misses": self._misses,
233
+ "evictions": self._evictions,
234
+ "total_requests": total_requests,
235
+ "hit_rate_percent": round(hit_rate, 2)
236
+ }
237
+
238
+ def reset_stats(self) -> None:
239
+ """Reset cache statistics"""
240
+ self._hits = 0
241
+ self._misses = 0
242
+ self._evictions = 0
243
+ logger.debug("Cache statistics reset")
244
+
245
+ async def get_all_keys(self) -> list[str]:
246
+ """Get all cache keys"""
247
+ async with self._lock:
248
+ return list(self._cache.keys())
249
+
250
+ async def get_cache_info(self) -> Dict[str, Any]:
251
+ """
252
+ Get detailed cache information.
253
+
254
+ Returns:
255
+ Dictionary with cache details including per-entry info
256
+ """
257
+ async with self._lock:
258
+ entries = []
259
+ for key, cached in self._cache.items():
260
+ entries.append({
261
+ "key": key,
262
+ "age_seconds": round(cached.age_seconds(), 2),
263
+ "ttl_seconds": cached.ttl_seconds,
264
+ "expires_in_seconds": round(cached.time_until_expiry(), 2),
265
+ "is_expired": cached.is_expired(),
266
+ "cached_at": datetime.fromtimestamp(cached.cached_at).isoformat()
267
+ })
268
+
269
+ return {
270
+ "stats": self.get_stats(),
271
+ "entries": entries
272
+ }
273
+
274
+
275
+ # Global cache manager instance
276
+ _cache_manager: Optional[CacheManager] = None
277
+
278
+
279
+ def get_cache_manager() -> CacheManager:
280
+ """Get or create the global cache manager instance"""
281
+ global _cache_manager
282
+ if _cache_manager is None:
283
+ _cache_manager = CacheManager()
284
+ return _cache_manager
285
+
286
+
287
+ # Convenience functions
288
+ async def get_cached(key: str) -> Optional[Any]:
289
+ """Get cached data"""
290
+ return await get_cache_manager().get(key)
291
+
292
+
293
+ async def set_cached(key: str, data: Any, ttl_seconds: int = 30) -> None:
294
+ """Set cached data"""
295
+ await get_cache_manager().set(key, data, ttl_seconds)
296
+
297
+
298
+ async def invalidate_cache(key: str) -> bool:
299
+ """Invalidate cache entry"""
300
+ return await get_cache_manager().invalidate(key)
301
+
302
+
303
+ async def clear_cache() -> int:
304
+ """Clear all cache"""
305
+ return await get_cache_manager().clear()
306
+
307
+
308
+ async def get_cache_stats() -> Dict[str, Any]:
309
+ """Get cache statistics"""
310
+ return get_cache_manager().get_stats()
@@ -5,6 +5,8 @@ Handles loading Jenkins connection settings from multiple sources:
5
5
  1. VS Code settings.json (highest priority)
6
6
  2. Environment variables / .env file
7
7
  3. Direct instantiation with parameters
8
+
9
+ Enhanced with timeout configuration support.
8
10
  """
9
11
 
10
12
  import json
@@ -50,6 +52,50 @@ class JenkinsSettings(BaseSettings):
50
52
  description="Jenkins API token (preferred over password)"
51
53
  )
52
54
 
55
+ # Timeout settings (High Priority Issue #4)
56
+ timeout: int = Field(
57
+ default=30,
58
+ description="Default timeout for Jenkins API calls in seconds",
59
+ ge=5,
60
+ le=300
61
+ )
62
+
63
+ connect_timeout: int = Field(
64
+ default=10,
65
+ description="Connection timeout in seconds",
66
+ ge=2,
67
+ le=60
68
+ )
69
+
70
+ read_timeout: int = Field(
71
+ default=30,
72
+ description="Read timeout for API responses in seconds",
73
+ ge=5,
74
+ le=300
75
+ )
76
+
77
+ # Retry settings
78
+ max_retries: int = Field(
79
+ default=3,
80
+ description="Maximum number of retry attempts for failed requests",
81
+ ge=0,
82
+ le=10
83
+ )
84
+
85
+ # Console output settings (High Priority Issue #5)
86
+ console_max_lines: int = Field(
87
+ default=1000,
88
+ description="Default maximum lines to return from console output",
89
+ ge=10,
90
+ le=50000
91
+ )
92
+
93
+ # SSL verification
94
+ verify_ssl: bool = Field(
95
+ default=True,
96
+ description="Whether to verify SSL certificates"
97
+ )
98
+
53
99
  model_config = SettingsConfigDict(
54
100
  env_prefix="JENKINS_",
55
101
  env_file_encoding="utf-8",
@@ -92,6 +138,12 @@ class JenkinsSettings(BaseSettings):
92
138
  logger.info("Jenkins Configuration:")
93
139
  logger.info(f" URL: {self.url or 'Not configured'}")
94
140
  logger.info(f" Username: {self.username or 'Not configured'}")
141
+ logger.info(f" Timeout: {self.timeout}s")
142
+ logger.info(f" Connect Timeout: {self.connect_timeout}s")
143
+ logger.info(f" Read Timeout: {self.read_timeout}s")
144
+ logger.info(f" Max Retries: {self.max_retries}")
145
+ logger.info(f" Console Max Lines: {self.console_max_lines}")
146
+ logger.info(f" Verify SSL: {self.verify_ssl}")
95
147
 
96
148
  if hide_sensitive:
97
149
  logger.info(f" Authentication: {self.auth_method}")
@@ -244,7 +296,8 @@ def load_settings(
244
296
  print(f"=== VS Code settings loaded: {vscode_settings is not None} ===", file=sys.stderr, flush=True)
245
297
  if vscode_settings:
246
298
  # Merge VS Code settings into our settings object
247
- for key in ['url', 'username', 'password', 'token']:
299
+ for key in ['url', 'username', 'password', 'token', 'timeout', 'connect_timeout',
300
+ 'read_timeout', 'max_retries', 'console_max_lines', 'verify_ssl']:
248
301
  vscode_value = vscode_settings.get(key)
249
302
  if vscode_value is not None:
250
303
  setattr(settings, key, vscode_value)
@@ -3,6 +3,8 @@ Jenkins MCP Server Client Module
3
3
 
4
4
  Provides a clean interface to Jenkins API operations with automatic fallback
5
5
  between python-jenkins library and direct REST API calls.
6
+
7
+ Enhanced with configurable timeout support.
6
8
  """
7
9
 
8
10
  import logging
@@ -34,7 +36,8 @@ class JenkinsClient:
34
36
  Client for interacting with Jenkins API.
35
37
 
36
38
  Supports both python-jenkins library and direct REST API calls
37
- with automatic fallback for reliability.
39
+ with automatic fallback for reliability. Enhanced with configurable
40
+ timeout support.
38
41
  """
39
42
 
40
43
  def __init__(self, settings: Optional[JenkinsSettings] = None):
@@ -60,6 +63,12 @@ class JenkinsClient:
60
63
  username, auth_value = self.settings.get_credentials()
61
64
  self.auth = HTTPBasicAuth(username, auth_value)
62
65
 
66
+ # Store timeout settings (High Priority Issue #4)
67
+ self.timeout = self.settings.timeout
68
+ self.connect_timeout = self.settings.connect_timeout
69
+ self.read_timeout = self.settings.read_timeout
70
+ self.verify_ssl = self.settings.verify_ssl
71
+
63
72
  # Cache for python-jenkins server instance
64
73
  self._server: Optional[jenkins.Jenkins] = None
65
74
 
@@ -67,14 +76,14 @@ class JenkinsClient:
67
76
  self._test_connection()
68
77
 
69
78
  def _test_connection(self) -> None:
70
- """Test connection to Jenkins server (with quick timeout for MCP)"""
79
+ """Test connection to Jenkins server (with configurable timeout)"""
71
80
  try:
72
- # Quick connection test with short timeout for MCP compatibility
81
+ # Quick connection test with configured timeout for MCP compatibility
73
82
  response = requests.get(
74
83
  f"{self.base_url}/api/json",
75
84
  auth=self.auth,
76
- verify=False,
77
- timeout=3 # Short timeout for responsiveness
85
+ verify=self.verify_ssl if self.verify_ssl else False,
86
+ timeout=self.connect_timeout # Use configured connect timeout
78
87
  )
79
88
  response.raise_for_status()
80
89
 
@@ -83,7 +92,7 @@ class JenkinsClient:
83
92
  logger.debug(f"Jenkins version: {data.get('_class', 'unknown')}")
84
93
 
85
94
  except requests.Timeout:
86
- logger.warning(f"Connection to Jenkins timed out (server may be slow)")
95
+ logger.warning(f"Connection to Jenkins timed out after {self.connect_timeout}s (server may be slow)")
87
96
  # Don't fail - let actual operations fail if there's a real problem
88
97
  except requests.RequestException as e:
89
98
  logger.warning(f"Could not verify Jenkins connection: {e}")
@@ -91,19 +100,20 @@ class JenkinsClient:
91
100
 
92
101
  @property
93
102
  def server(self) -> jenkins.Jenkins:
94
- """Get or create python-jenkins server instance (lazy initialization)"""
103
+ """Get or create python-jenkins server instance (lazy initialization with timeout)"""
95
104
  if self._server is None:
96
105
  username, password = self.settings.get_credentials()
97
106
  self._server = jenkins.Jenkins(
98
107
  self.base_url,
99
108
  username=username,
100
- password=password
109
+ password=password,
110
+ timeout=self.timeout # Use configured timeout
101
111
  )
102
112
  return self._server
103
113
 
104
114
  def _api_call(self, method: str, endpoint: str, **kwargs) -> requests.Response:
105
115
  """
106
- Make a direct REST API call to Jenkins.
116
+ Make a direct REST API call to Jenkins with configured timeout.
107
117
 
108
118
  Args:
109
119
  method: HTTP method (GET, POST, etc.)
@@ -115,8 +125,11 @@ class JenkinsClient:
115
125
  """
116
126
  url = f"{self.base_url}{endpoint}"
117
127
  kwargs.setdefault('auth', self.auth)
118
- kwargs.setdefault('verify', False)
119
- kwargs.setdefault('timeout', 30)
128
+ kwargs.setdefault('verify', self.verify_ssl if self.verify_ssl else False)
129
+
130
+ # Use configured timeout (can be overridden per call)
131
+ if 'timeout' not in kwargs:
132
+ kwargs['timeout'] = (self.connect_timeout, self.read_timeout)
120
133
 
121
134
  response = requests.request(method, url, **kwargs)
122
135
  response.raise_for_status()
@@ -220,82 +233,46 @@ class JenkinsClient:
220
233
  Returns:
221
234
  Dict with 'queue_id' and 'build_number' (if wait_for_start=True)
222
235
  """
223
- try:
224
- # Trigger build using python-jenkins
225
- queue_id = self.server.build_job(job_name, parameters or {})
226
- queue_id = int(queue_id) if queue_id else None
227
-
228
- build_number = None
229
- if wait_for_start and queue_id:
230
- build_number = self._wait_for_build_start(
231
- queue_id, timeout, poll_interval
232
- )
233
-
234
- return {"queue_id": queue_id, "build_number": build_number}
236
+ # Get the last build number before triggering
237
+ last_build_num = self.get_last_build_number(job_name) or 0
235
238
 
236
- except Exception as e:
237
- logger.debug(f"python-jenkins failed, using REST API: {e}")
238
- return self._build_job_rest(
239
- job_name, parameters, wait_for_start, timeout, poll_interval
239
+ # Trigger the build
240
+ if parameters:
241
+ self._api_call(
242
+ 'POST',
243
+ f'/job/{job_name}/buildWithParameters',
244
+ params=parameters
240
245
  )
246
+ else:
247
+ self._api_call('POST', f'/job/{job_name}/build')
241
248
 
242
- def _build_job_rest(
243
- self,
244
- job_name: str,
245
- parameters: Optional[Dict[str, Any]],
246
- wait_for_start: bool,
247
- timeout: int,
248
- poll_interval: float
249
- ) -> Dict[str, Optional[int]]:
250
- """Build job using REST API (fallback method)"""
251
- endpoint = f'/job/{job_name}/buildWithParameters' if parameters else f'/job/{job_name}/build'
252
-
253
- response = self._api_call('POST', endpoint, params=parameters)
249
+ # Get queue ID from response
250
+ queue_id = self._extract_queue_id_from_location(
251
+ f'/job/{job_name}/build'
252
+ )
254
253
 
255
- # Extract queue ID from Location header
256
- location = response.headers.get('Location', '')
257
- queue_id = self._extract_queue_id(location)
254
+ result = {
255
+ 'queue_id': queue_id,
256
+ 'build_number': None
257
+ }
258
258
 
259
- build_number = None
260
- if wait_for_start and queue_id:
261
- build_number = self._wait_for_build_start(queue_id, timeout, poll_interval)
259
+ # Optionally wait for build to start
260
+ if wait_for_start:
261
+ start_time = time.time()
262
+ while time.time() - start_time < timeout:
263
+ time.sleep(poll_interval)
262
264
 
263
- return {"queue_id": queue_id, "build_number": build_number}
265
+ # Check if a new build has started
266
+ current_build_num = self.get_last_build_number(job_name)
267
+ if current_build_num and current_build_num > last_build_num:
268
+ result['build_number'] = current_build_num
269
+ logger.info(f"Build {job_name} #{current_build_num} started")
270
+ break
264
271
 
265
- def _wait_for_build_start(
266
- self,
267
- queue_id: int,
268
- timeout: int,
269
- poll_interval: float
270
- ) -> Optional[int]:
271
- """Wait for a queued build to start and return its build number"""
272
- elapsed = 0.0
273
-
274
- while elapsed < timeout:
275
- try:
276
- # Try python-jenkins first
277
- item = self.server.get_queue_item(queue_id)
278
- if item and item.get('executable'):
279
- return int(item['executable']['number'])
280
- except Exception:
281
- # Fall back to REST API
282
- try:
283
- response = self._api_call('GET', f'/queue/item/{queue_id}/api/json')
284
- item = response.json()
285
- if item.get('executable'):
286
- return int(item['executable']['number'])
287
- except Exception:
288
- pass
289
-
290
- time.sleep(poll_interval)
291
- elapsed += poll_interval
292
-
293
- logger.warning(f"Timeout waiting for build {queue_id} to start")
294
- return None
272
+ return result
295
273
 
296
- @staticmethod
297
- def _extract_queue_id(location: str) -> Optional[int]:
298
- """Extract queue ID from Jenkins Location header"""
274
+ def _extract_queue_id_from_location(self, location: str) -> Optional[int]:
275
+ """Extract queue ID from Location header"""
299
276
  if not location:
300
277
  return None
301
278
 
@@ -480,6 +457,18 @@ class JenkinsClient:
480
457
  response = self._api_call('GET', f'/computer/{node_name}/api/json')
481
458
  return response.json()
482
459
 
460
+ # ==================== Additional Helper Methods ====================
461
+
462
+ def get_whoami(self) -> Dict[str, Any]:
463
+ """Get information about the current authenticated user"""
464
+ response = self._api_call('GET', '/me/api/json')
465
+ return response.json()
466
+
467
+ def get_version(self) -> str:
468
+ """Get Jenkins version"""
469
+ response = self._api_call('GET', '/api/json')
470
+ return response.headers.get('X-Jenkins', 'Unknown')
471
+
483
472
 
484
473
  # ==================== Client Factory ====================
485
474
 
@@ -515,4 +504,4 @@ def get_jenkins_client(settings: Optional[JenkinsSettings] = None) -> JenkinsCli
515
504
  def reset_default_client() -> None:
516
505
  """Reset the default client (useful for testing or reconfiguration)"""
517
506
  global _default_client
518
- _default_client = None
507
+ _default_client = None