@rishibhushan/jenkins-mcp-server 1.0.7 → 1.1.1

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.
@@ -7,9 +7,11 @@ Provides MCP protocol handlers for Jenkins operations including:
7
7
  - Tools (Jenkins operations)
8
8
  """
9
9
 
10
+ import asyncio
10
11
  import json
11
12
  import logging
12
13
  import sys
14
+ import time
13
15
  from typing import Optional
14
16
 
15
17
  import mcp.server.stdio
@@ -18,8 +20,10 @@ from mcp.server import NotificationOptions, Server
18
20
  from mcp.server.models import InitializationOptions
19
21
  from pydantic import AnyUrl
20
22
 
23
+ from .cache import get_cache_manager
21
24
  from .config import JenkinsSettings, get_default_settings
22
- from .jenkins_client import get_jenkins_client, JenkinsConnectionError
25
+ from .jenkins_client import get_jenkins_client
26
+ from .metrics import get_metrics_collector, record_tool_execution
23
27
 
24
28
  # Configure logging
25
29
  logger = logging.getLogger(__name__)
@@ -30,11 +34,17 @@ server = Server("jenkins-mcp-server")
30
34
  # Settings storage (injected by main)
31
35
  _jenkins_settings: Optional[JenkinsSettings] = None
32
36
 
37
+ # Client connection cache (Quick Win #3)
38
+ _jenkins_client_cache = None
39
+ _client_cache_lock = asyncio.Lock()
40
+
33
41
 
34
42
  def set_jenkins_settings(settings: JenkinsSettings) -> None:
35
43
  """Set Jenkins settings for the server (called from __init__.py)"""
36
- global _jenkins_settings
44
+ global _jenkins_settings, _jenkins_client_cache
37
45
  _jenkins_settings = settings
46
+ # Clear cache when settings change
47
+ _jenkins_client_cache = None
38
48
 
39
49
 
40
50
  def get_settings() -> JenkinsSettings:
@@ -45,6 +55,64 @@ def get_settings() -> JenkinsSettings:
45
55
  return _jenkins_settings
46
56
 
47
57
 
58
+ async def get_cached_jenkins_client(settings: JenkinsSettings):
59
+ """
60
+ Get or create cached Jenkins client (Quick Win #3: Client Caching)
61
+ Reuses the same client connection across tool calls for better performance.
62
+ """
63
+ global _jenkins_client_cache
64
+
65
+ async with _client_cache_lock:
66
+ if _jenkins_client_cache is None:
67
+ logger.info("Creating new Jenkins client connection")
68
+ _jenkins_client_cache = get_jenkins_client(settings)
69
+ return _jenkins_client_cache
70
+
71
+
72
+ # Input Validation Helpers (Quick Win #4)
73
+
74
+ def validate_job_name(job_name: any) -> str:
75
+ """Validate job name parameter"""
76
+ if not job_name:
77
+ raise ValueError("Missing required argument: job_name")
78
+ if not isinstance(job_name, str):
79
+ raise ValueError(f"job_name must be a string, got {type(job_name).__name__}")
80
+ if not job_name.strip():
81
+ raise ValueError("job_name cannot be empty or whitespace")
82
+ return job_name.strip()
83
+
84
+
85
+ def validate_build_number(build_number: any) -> int:
86
+ """Validate build number parameter"""
87
+ if build_number is None:
88
+ raise ValueError("Missing required argument: build_number")
89
+
90
+ try:
91
+ num = int(build_number)
92
+ except (ValueError, TypeError):
93
+ raise ValueError(f"build_number must be an integer, got: {build_number}")
94
+
95
+ if num < 0:
96
+ raise ValueError(f"build_number must be non-negative, got: {num}")
97
+
98
+ return num
99
+
100
+
101
+ def validate_config_xml(config_xml: any) -> str:
102
+ """Validate XML configuration parameter"""
103
+ if not config_xml:
104
+ raise ValueError("Missing required argument: config_xml")
105
+ if not isinstance(config_xml, str):
106
+ raise ValueError(f"config_xml must be a string, got {type(config_xml).__name__}")
107
+
108
+ # Basic XML validation
109
+ xml_str = config_xml.strip()
110
+ if not xml_str.startswith('<'):
111
+ raise ValueError("config_xml must be valid XML (should start with '<')")
112
+
113
+ return xml_str
114
+
115
+
48
116
  # ==================== Resources ====================
49
117
 
50
118
  @server.list_resources()
@@ -314,6 +382,91 @@ async def _prompt_analyze_build_logs(arguments: dict[str, str]) -> types.GetProm
314
382
  )
315
383
 
316
384
 
385
+ async def _tool_trigger_multiple_builds(client, args):
386
+ """Trigger builds for multiple jobs at once"""
387
+ job_names = args.get("job_names", [])
388
+ parameters = args.get("parameters", {})
389
+ wait_for_start = args.get("wait_for_start", False)
390
+
391
+ # Validation
392
+ if not job_names:
393
+ raise ValueError("No job names provided")
394
+
395
+ if not isinstance(job_names, list):
396
+ raise ValueError("job_names must be an array")
397
+
398
+ if len(job_names) > 20:
399
+ raise ValueError("Maximum 20 jobs can be triggered at once")
400
+
401
+ # Validate each job name
402
+ validated_jobs = []
403
+ for job_name in job_names:
404
+ try:
405
+ validated = validate_job_name(job_name)
406
+ validated_jobs.append(validated)
407
+ except ValueError as e:
408
+ return [types.TextContent(
409
+ type="text",
410
+ text=f"āŒ Invalid job name '{job_name}': {str(e)}"
411
+ )]
412
+
413
+ # Validate parameters if provided
414
+ if parameters and not isinstance(parameters, dict):
415
+ raise ValueError(f"parameters must be a dictionary, got {type(parameters).__name__}")
416
+
417
+ # Trigger all builds
418
+ results = []
419
+ for job_name in validated_jobs:
420
+ try:
421
+ result = client.build_job(
422
+ job_name,
423
+ parameters,
424
+ wait_for_start=wait_for_start,
425
+ timeout=10 # Shorter timeout for batch
426
+ )
427
+
428
+ results.append({
429
+ "job": job_name,
430
+ "status": "triggered",
431
+ "queue_id": result.get('queue_id'),
432
+ "build_number": result.get('build_number') if wait_for_start else None
433
+ })
434
+
435
+ logger.info(f"Triggered build for {job_name}")
436
+
437
+ except Exception as e:
438
+ results.append({
439
+ "job": job_name,
440
+ "status": "failed",
441
+ "error": str(e)
442
+ })
443
+ logger.error(f"Failed to trigger {job_name}: {e}")
444
+
445
+ # Build summary
446
+ successful = [r for r in results if r["status"] == "triggered"]
447
+ failed = [r for r in results if r["status"] == "failed"]
448
+
449
+ summary = {
450
+ "total": len(job_names),
451
+ "successful": len(successful),
452
+ "failed": len(failed),
453
+ "results": results
454
+ }
455
+
456
+ # Invalidate cache since jobs were triggered
457
+ cache_manager = get_cache_manager()
458
+ await cache_manager.invalidate_pattern("jobs_list:")
459
+
460
+ emoji = "āœ…" if len(failed) == 0 else "āš ļø"
461
+ message = f"{emoji} Batch Build Trigger Complete\n\n"
462
+ message += f"Total Jobs: {len(job_names)}\n"
463
+ message += f"Successful: {len(successful)}\n"
464
+ message += f"Failed: {len(failed)}\n\n"
465
+ message += f"Details:\n{json.dumps(results, indent=2)}"
466
+
467
+ return [types.TextContent(type="text", text=message)]
468
+
469
+
317
470
  # ==================== Tools ====================
318
471
 
319
472
  @server.list_tools()
@@ -343,8 +496,14 @@ async def handle_list_tools() -> list[types.Tool]:
343
496
  inputSchema={
344
497
  "type": "object",
345
498
  "properties": {
346
- "job_name": {"type": "string"},
347
- "build_number": {"type": "integer"},
499
+ "job_name": {
500
+ "type": "string",
501
+ "description": "Name of the Jenkins job"
502
+ },
503
+ "build_number": {
504
+ "type": "integer",
505
+ "description": "Build number to stop"
506
+ },
348
507
  },
349
508
  "required": ["job_name", "build_number"],
350
509
  },
@@ -353,13 +512,18 @@ async def handle_list_tools() -> list[types.Tool]:
353
512
  # Job Information
354
513
  types.Tool(
355
514
  name="list-jobs",
356
- description="List all Jenkins jobs with optional filtering",
515
+ description="List all Jenkins jobs with optional filtering and caching",
357
516
  inputSchema={
358
517
  "type": "object",
359
518
  "properties": {
360
519
  "filter": {
361
520
  "type": "string",
362
521
  "description": "Filter jobs by name (case-insensitive partial match)"
522
+ },
523
+ "use_cache": {
524
+ "type": "boolean",
525
+ "description": "Use cached results if available (default: true)",
526
+ "default": True
363
527
  }
364
528
  }
365
529
  },
@@ -369,7 +533,19 @@ async def handle_list_tools() -> list[types.Tool]:
369
533
  description="Get detailed information about a Jenkins job",
370
534
  inputSchema={
371
535
  "type": "object",
372
- "properties": {"job_name": {"type": "string"}},
536
+ "properties": {
537
+ "job_name": {
538
+ "type": "string",
539
+ "description": "Name of the Jenkins job"
540
+ },
541
+ "max_recent_builds": {
542
+ "type": "integer",
543
+ "description": "Maximum number of recent builds to fetch (0-10, default: 3). Set to 0 to skip build history.",
544
+ "default": 3,
545
+ "minimum": 0,
546
+ "maximum": 10
547
+ }
548
+ },
373
549
  "required": ["job_name"],
374
550
  },
375
551
  ),
@@ -381,8 +557,14 @@ async def handle_list_tools() -> list[types.Tool]:
381
557
  inputSchema={
382
558
  "type": "object",
383
559
  "properties": {
384
- "job_name": {"type": "string"},
385
- "build_number": {"type": "integer"},
560
+ "job_name": {
561
+ "type": "string",
562
+ "description": "Name of the Jenkins job"
563
+ },
564
+ "build_number": {
565
+ "type": "integer",
566
+ "description": "Build number to get information about"
567
+ },
386
568
  },
387
569
  "required": ["job_name", "build_number"],
388
570
  },
@@ -393,8 +575,26 @@ async def handle_list_tools() -> list[types.Tool]:
393
575
  inputSchema={
394
576
  "type": "object",
395
577
  "properties": {
396
- "job_name": {"type": "string"},
397
- "build_number": {"type": "integer"},
578
+ "job_name": {
579
+ "type": "string",
580
+ "description": "Name of the Jenkins job"
581
+ },
582
+ "build_number": {
583
+ "type": "integer",
584
+ "description": "Build number to get console output from"
585
+ },
586
+ "max_lines": {
587
+ "type": "integer",
588
+ "description": "Maximum number of lines to return (default: 1000, max: 10000)",
589
+ "default": 1000,
590
+ "minimum": 10,
591
+ "maximum": 10000
592
+ },
593
+ "tail_only": {
594
+ "type": "boolean",
595
+ "description": "If true, return last N lines instead of first N lines (default: false)",
596
+ "default": False
597
+ },
398
598
  },
399
599
  "required": ["job_name", "build_number"],
400
600
  },
@@ -404,7 +604,12 @@ async def handle_list_tools() -> list[types.Tool]:
404
604
  description="Get the last build number for a job",
405
605
  inputSchema={
406
606
  "type": "object",
407
- "properties": {"job_name": {"type": "string"}},
607
+ "properties": {
608
+ "job_name": {
609
+ "type": "string",
610
+ "description": "Name of the Jenkins job"
611
+ }
612
+ },
408
613
  "required": ["job_name"],
409
614
  },
410
615
  ),
@@ -413,7 +618,12 @@ async def handle_list_tools() -> list[types.Tool]:
413
618
  description="Get the timestamp of the last build",
414
619
  inputSchema={
415
620
  "type": "object",
416
- "properties": {"job_name": {"type": "string"}},
621
+ "properties": {
622
+ "job_name": {
623
+ "type": "string",
624
+ "description": "Name of the Jenkins job"
625
+ }
626
+ },
417
627
  "required": ["job_name"],
418
628
  },
419
629
  ),
@@ -425,8 +635,14 @@ async def handle_list_tools() -> list[types.Tool]:
425
635
  inputSchema={
426
636
  "type": "object",
427
637
  "properties": {
428
- "job_name": {"type": "string"},
429
- "config_xml": {"type": "string", "description": "Job configuration XML"},
638
+ "job_name": {
639
+ "type": "string",
640
+ "description": "Name for the new Jenkins job"
641
+ },
642
+ "config_xml": {
643
+ "type": "string",
644
+ "description": "Job configuration in XML format"
645
+ },
430
646
  },
431
647
  "required": ["job_name", "config_xml"],
432
648
  },
@@ -437,8 +653,14 @@ async def handle_list_tools() -> list[types.Tool]:
437
653
  inputSchema={
438
654
  "type": "object",
439
655
  "properties": {
440
- "new_job_name": {"type": "string"},
441
- "source_job_name": {"type": "string"},
656
+ "new_job_name": {
657
+ "type": "string",
658
+ "description": "Name for the new job to be created"
659
+ },
660
+ "source_job_name": {
661
+ "type": "string",
662
+ "description": "Name of the existing job to copy from"
663
+ },
442
664
  },
443
665
  "required": ["new_job_name", "source_job_name"],
444
666
  },
@@ -449,9 +671,19 @@ async def handle_list_tools() -> list[types.Tool]:
449
671
  inputSchema={
450
672
  "type": "object",
451
673
  "properties": {
452
- "job_name": {"type": "string"},
453
- "config_data": {"type": "object"},
454
- "root_tag": {"type": "string", "default": "project"},
674
+ "job_name": {
675
+ "type": "string",
676
+ "description": "Name for the new Jenkins job"
677
+ },
678
+ "config_data": {
679
+ "type": "object",
680
+ "description": "Job configuration as structured data (will be converted to XML)"
681
+ },
682
+ "root_tag": {
683
+ "type": "string",
684
+ "default": "project",
685
+ "description": "Root XML tag for the configuration (default: 'project')"
686
+ },
455
687
  },
456
688
  "required": ["job_name", "config_data"],
457
689
  },
@@ -461,7 +693,12 @@ async def handle_list_tools() -> list[types.Tool]:
461
693
  description="Delete an existing Jenkins job",
462
694
  inputSchema={
463
695
  "type": "object",
464
- "properties": {"job_name": {"type": "string"}},
696
+ "properties": {
697
+ "job_name": {
698
+ "type": "string",
699
+ "description": "Name of the Jenkins job to delete"
700
+ }
701
+ },
465
702
  "required": ["job_name"],
466
703
  },
467
704
  ),
@@ -470,7 +707,12 @@ async def handle_list_tools() -> list[types.Tool]:
470
707
  description="Enable a disabled Jenkins job",
471
708
  inputSchema={
472
709
  "type": "object",
473
- "properties": {"job_name": {"type": "string"}},
710
+ "properties": {
711
+ "job_name": {
712
+ "type": "string",
713
+ "description": "Name of the Jenkins job to enable"
714
+ }
715
+ },
474
716
  "required": ["job_name"],
475
717
  },
476
718
  ),
@@ -479,7 +721,12 @@ async def handle_list_tools() -> list[types.Tool]:
479
721
  description="Disable a Jenkins job",
480
722
  inputSchema={
481
723
  "type": "object",
482
- "properties": {"job_name": {"type": "string"}},
724
+ "properties": {
725
+ "job_name": {
726
+ "type": "string",
727
+ "description": "Name of the Jenkins job to disable"
728
+ }
729
+ },
483
730
  "required": ["job_name"],
484
731
  },
485
732
  ),
@@ -489,8 +736,14 @@ async def handle_list_tools() -> list[types.Tool]:
489
736
  inputSchema={
490
737
  "type": "object",
491
738
  "properties": {
492
- "job_name": {"type": "string"},
493
- "new_name": {"type": "string"},
739
+ "job_name": {
740
+ "type": "string",
741
+ "description": "Current name of the Jenkins job"
742
+ },
743
+ "new_name": {
744
+ "type": "string",
745
+ "description": "New name for the Jenkins job"
746
+ },
494
747
  },
495
748
  "required": ["job_name", "new_name"],
496
749
  },
@@ -502,7 +755,12 @@ async def handle_list_tools() -> list[types.Tool]:
502
755
  description="Get the configuration XML for a job",
503
756
  inputSchema={
504
757
  "type": "object",
505
- "properties": {"job_name": {"type": "string"}},
758
+ "properties": {
759
+ "job_name": {
760
+ "type": "string",
761
+ "description": "Name of the Jenkins job"
762
+ }
763
+ },
506
764
  "required": ["job_name"],
507
765
  },
508
766
  ),
@@ -512,8 +770,14 @@ async def handle_list_tools() -> list[types.Tool]:
512
770
  inputSchema={
513
771
  "type": "object",
514
772
  "properties": {
515
- "job_name": {"type": "string"},
516
- "config_xml": {"type": "string"},
773
+ "job_name": {
774
+ "type": "string",
775
+ "description": "Name of the Jenkins job to update"
776
+ },
777
+ "config_xml": {
778
+ "type": "string",
779
+ "description": "New configuration in XML format"
780
+ },
517
781
  },
518
782
  "required": ["job_name", "config_xml"],
519
783
  },
@@ -535,10 +799,102 @@ async def handle_list_tools() -> list[types.Tool]:
535
799
  description="Get information about a specific Jenkins node",
536
800
  inputSchema={
537
801
  "type": "object",
538
- "properties": {"node_name": {"type": "string"}},
802
+ "properties": {
803
+ "node_name": {
804
+ "type": "string",
805
+ "description": "Name of the Jenkins node/agent"
806
+ }
807
+ },
539
808
  "required": ["node_name"],
540
809
  },
541
810
  ),
811
+ types.Tool(
812
+ name="trigger-multiple-builds",
813
+ description="Trigger builds for multiple jobs at once",
814
+ inputSchema={
815
+ "type": "object",
816
+ "properties": {
817
+ "job_names": {
818
+ "type": "array",
819
+ "items": {"type": "string"},
820
+ "description": "List of job names to trigger",
821
+ "minItems": 1,
822
+ "maxItems": 20
823
+ },
824
+ "parameters": {
825
+ "type": "object",
826
+ "description": "Common parameters for all builds (optional)",
827
+ "additionalProperties": {"type": ["string", "number", "boolean"]},
828
+ },
829
+ "wait_for_start": {
830
+ "type": "boolean",
831
+ "description": "Wait for all builds to start (default: false)",
832
+ "default": False
833
+ }
834
+ },
835
+ "required": ["job_names"],
836
+ },
837
+ ),
838
+
839
+ # Cache tools
840
+ types.Tool(
841
+ name="get-cache-stats",
842
+ description="Get cache statistics and information",
843
+ inputSchema={"type": "object", "properties": {}},
844
+ ),
845
+ types.Tool(
846
+ name="clear-cache",
847
+ description="Clear all cached data",
848
+ inputSchema={"type": "object", "properties": {}},
849
+ ),
850
+
851
+ # Health Check (Quick Win #1)
852
+ types.Tool(
853
+ name="health-check",
854
+ description="Check Jenkins server health and connection status. Useful for troubleshooting connectivity issues.",
855
+ inputSchema={"type": "object", "properties": {}},
856
+ ),
857
+
858
+ # Get metrics
859
+ types.Tool(
860
+ name="get-metrics",
861
+ description="Get usage metrics and performance statistics",
862
+ inputSchema={
863
+ "type": "object",
864
+ "properties": {
865
+ "tool_name": {
866
+ "type": "string",
867
+ "description": "Specific tool name (optional, returns all if not specified)"
868
+ }
869
+ }
870
+ },
871
+ ),
872
+
873
+ types.Tool(
874
+ name="configure-webhook",
875
+ description="Configure webhook notifications for Jenkins events (requires Jenkins plugin)",
876
+ inputSchema={
877
+ "type": "object",
878
+ "properties": {
879
+ "job_name": {
880
+ "type": "string",
881
+ "description": "Job to configure webhook for"
882
+ },
883
+ "webhook_url": {
884
+ "type": "string",
885
+ "description": "URL to receive webhook notifications"
886
+ },
887
+ "events": {
888
+ "type": "array",
889
+ "items": {
890
+ "enum": ["build_started", "build_completed", "build_failed", "build_success"]
891
+ },
892
+ "description": "Events to trigger webhook"
893
+ }
894
+ },
895
+ "required": ["job_name", "webhook_url", "events"]
896
+ },
897
+ ),
542
898
  ]
543
899
 
544
900
  logger.info(f"Registered {len(tools)} Jenkins tools")
@@ -550,11 +906,15 @@ async def handle_call_tool(
550
906
  name: str,
551
907
  arguments: dict | None
552
908
  ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
553
- """Handle tool execution requests"""
909
+ """Handle tool execution requests with improved error handling and metrics tracking"""
554
910
  arguments = arguments or {}
911
+ start_time = time.time()
912
+ success = False
913
+ error_message = None
555
914
 
556
915
  try:
557
- client = get_jenkins_client(get_settings())
916
+ # Use cached client for better performance
917
+ client = await get_cached_jenkins_client(get_settings())
558
918
 
559
919
  # Route to appropriate handler
560
920
  handlers = {
@@ -589,22 +949,163 @@ async def handle_call_tool(
589
949
  "get-queue-info": _tool_get_queue_info,
590
950
  "list-nodes": _tool_list_nodes,
591
951
  "get-node-info": _tool_get_node_info,
952
+
953
+ # Health check (Quick Win #1)
954
+ "health-check": _tool_health_check,
955
+
956
+ # NEW: Medium Priority
957
+ "trigger-multiple-builds": _tool_trigger_multiple_builds,
958
+ "get-cache-stats": _tool_get_cache_stats,
959
+ "clear-cache": _tool_clear_cache,
960
+
961
+ # NEW: Low Priority
962
+ "get-metrics": _tool_get_metrics,
963
+ "configure-webhook": _tool_configure_webhook,
592
964
  }
593
965
 
594
966
  handler = handlers.get(name)
595
967
  if not handler:
596
968
  raise ValueError(f"Unknown tool: {name}")
597
969
 
598
- return await handler(client, arguments)
970
+ result = await handler(client, arguments)
971
+ success = True
972
+ return result
599
973
 
600
- except Exception as e:
601
- logger.error(f"Tool execution failed for {name}: {e}", exc_info=True)
974
+ # Better error messages with troubleshooting steps (Quick Win #2)
975
+ except ValueError as e:
976
+ # Validation errors - user's fault
977
+ logger.warning(f"Validation error in {name}: {e}")
602
978
  return [
603
979
  types.TextContent(
604
980
  type="text",
605
- text=f"Error executing {name}: {str(e)}"
981
+ text=f"āŒ Invalid input for {name}: {str(e)}\n\n"
982
+ f"šŸ’” Please check the parameter values and try again."
606
983
  )
607
984
  ]
985
+ except ImportError:
986
+ # Missing requests library
987
+ import_error = (
988
+ f"āš ļø Missing required library 'requests'.\n\n"
989
+ f"To fix this, run:\n"
990
+ f"pip install requests"
991
+ )
992
+ logger.error(f"Import error in {name}: requests library not found")
993
+ return [types.TextContent(type="text", text=import_error)]
994
+ except Exception as e:
995
+ # Check for common requests exceptions
996
+ error_type = type(e).__name__
997
+ error_message = str(e)
998
+
999
+ # Timeout errors
1000
+ if 'timeout' in error_message.lower() or error_type == 'Timeout':
1001
+ logger.error(f"Timeout error in {name}: {e}")
1002
+ return [
1003
+ types.TextContent(
1004
+ type="text",
1005
+ text=f"ā±ļø Timeout connecting to Jenkins.\n\n"
1006
+ f"Troubleshooting steps:\n"
1007
+ f"1. Check Jenkins server is running\n"
1008
+ f"2. Verify URL is correct: {get_settings().url}\n"
1009
+ f"3. Ensure network/VPN connection is active\n"
1010
+ f"4. Check firewall settings\n\n"
1011
+ f"Error: {str(e)}"
1012
+ )
1013
+ ]
1014
+
1015
+ # Connection errors
1016
+ elif 'connection' in error_message.lower() or error_type in ['ConnectionError', 'ConnectionRefusedError']:
1017
+ logger.error(f"Connection error in {name}: {e}")
1018
+ return [
1019
+ types.TextContent(
1020
+ type="text",
1021
+ text=f"šŸ”Œ Cannot connect to Jenkins at {get_settings().url}\n\n"
1022
+ f"Troubleshooting steps:\n"
1023
+ f"1. Verify Jenkins server is accessible\n"
1024
+ f"2. Check port is correct (usually 8080)\n"
1025
+ f"3. Ensure firewall allows connection\n"
1026
+ f"4. Test with: curl {get_settings().url}/api/json\n\n"
1027
+ f"Error: {str(e)}"
1028
+ )
1029
+ ]
1030
+
1031
+ # Authentication errors (401)
1032
+ elif '401' in error_message or 'unauthorized' in error_message.lower():
1033
+ logger.error(f"Authentication error in {name}: {e}")
1034
+ return [
1035
+ types.TextContent(
1036
+ type="text",
1037
+ text=f"šŸ” Authentication failed.\n\n"
1038
+ f"Troubleshooting steps:\n"
1039
+ f"1. Verify username is correct: {get_settings().username}\n"
1040
+ f"2. Check API token is valid (not expired)\n"
1041
+ f"3. Generate new token in Jenkins:\n"
1042
+ f" - Go to Jenkins → Your Name → Configure\n"
1043
+ f" - Click 'Add new Token' under API Token section\n"
1044
+ f"4. Update .env file with new token\n\n"
1045
+ f"Error: {str(e)}"
1046
+ )
1047
+ ]
1048
+
1049
+ # Permission errors (403)
1050
+ elif '403' in error_message or 'forbidden' in error_message.lower():
1051
+ logger.error(f"Permission error in {name}: {e}")
1052
+ return [
1053
+ types.TextContent(
1054
+ type="text",
1055
+ text=f"🚫 Permission denied.\n\n"
1056
+ f"Troubleshooting steps:\n"
1057
+ f"1. Check user has permission to access Jenkins\n"
1058
+ f"2. Verify user has permission for this operation\n"
1059
+ f"3. Contact Jenkins admin to grant necessary permissions\n\n"
1060
+ f"User: {get_settings().username}\n"
1061
+ f"Operation: {name}\n"
1062
+ f"Error: {str(e)}"
1063
+ )
1064
+ ]
1065
+
1066
+ # Not found errors (404)
1067
+ elif '404' in error_message or 'not found' in error_message.lower():
1068
+ logger.error(f"Not found error in {name}: {e}")
1069
+ return [
1070
+ types.TextContent(
1071
+ type="text",
1072
+ text=f"āŒ Resource not found.\n\n"
1073
+ f"Troubleshooting steps:\n"
1074
+ f"1. Check job/resource name is correct (case-sensitive)\n"
1075
+ f"2. Verify resource exists in Jenkins\n"
1076
+ f"3. Ensure user has permission to view the resource\n"
1077
+ f"4. Try listing all jobs with 'list-jobs' tool\n\n"
1078
+ f"Error: {str(e)}"
1079
+ )
1080
+ ]
1081
+
1082
+ # Generic error with some context
1083
+ else:
1084
+ logger.error(f"Tool execution failed for {name}: {e}", exc_info=True)
1085
+ return [
1086
+ types.TextContent(
1087
+ type="text",
1088
+ text=f"āŒ Error executing {name}\n\n"
1089
+ f"Error type: {error_type}\n"
1090
+ f"Error message: {str(e)}\n\n"
1091
+ f"šŸ’” Troubleshooting tips:\n"
1092
+ f"1. Run 'health-check' tool to verify connection\n"
1093
+ f"2. Check Jenkins logs for more details\n"
1094
+ f"3. Verify all parameters are correct\n"
1095
+ f"4. Try the operation manually in Jenkins UI"
1096
+ )
1097
+ ]
1098
+
1099
+ finally:
1100
+ # Record metrics
1101
+ execution_time_ms = (time.time() - start_time) * 1000
1102
+ await record_tool_execution(
1103
+ tool_name=name,
1104
+ execution_time_ms=execution_time_ms,
1105
+ success=success,
1106
+ error_message=error_message,
1107
+ args={"tool": name} # Don't log full args for privacy
1108
+ )
608
1109
 
609
1110
 
610
1111
  # ==================== Tool Handlers ====================
@@ -613,11 +1114,12 @@ async def handle_call_tool(
613
1114
 
614
1115
  async def _tool_trigger_build(client, args):
615
1116
  """Trigger a Jenkins build"""
616
- job_name = args.get("job_name")
1117
+ # Input validation (Quick Win #4)
1118
+ job_name = validate_job_name(args.get("job_name"))
617
1119
  parameters = args.get("parameters", {})
618
1120
 
619
- if not job_name:
620
- raise ValueError("Missing required argument: job_name")
1121
+ if parameters and not isinstance(parameters, dict):
1122
+ raise ValueError(f"parameters must be a dictionary, got {type(parameters).__name__}")
621
1123
 
622
1124
  result = client.build_job(job_name, parameters)
623
1125
 
@@ -634,11 +1136,9 @@ async def _tool_trigger_build(client, args):
634
1136
 
635
1137
  async def _tool_stop_build(client, args):
636
1138
  """Stop a running build"""
637
- job_name = args.get("job_name")
638
- build_number = args.get("build_number")
639
-
640
- if not job_name or build_number is None:
641
- raise ValueError("Missing required arguments: job_name and build_number")
1139
+ # Input validation (Quick Win #4)
1140
+ job_name = validate_job_name(args.get("job_name"))
1141
+ build_number = validate_build_number(args.get("build_number"))
642
1142
 
643
1143
  client.stop_build(job_name, build_number)
644
1144
 
@@ -653,9 +1153,26 @@ async def _tool_stop_build(client, args):
653
1153
  # Job Information
654
1154
 
655
1155
  async def _tool_list_jobs(client, args):
656
- """List all Jenkins jobs with optional filtering"""
657
- jobs = client.get_jobs()
1156
+ """List all Jenkins jobs with optional filtering and caching"""
658
1157
  filter_text = args.get("filter", "").strip()
1158
+ use_cache = args.get("use_cache", True) # cache control
1159
+
1160
+ # Try cache first (if enabled)
1161
+ cache_key = f"jobs_list:{filter_text or 'all'}"
1162
+ if use_cache:
1163
+ cache_manager = get_cache_manager()
1164
+ cached_jobs = await cache_manager.get(cache_key)
1165
+ if cached_jobs is not None:
1166
+ logger.debug(f"Using cached job list ({len(cached_jobs)} jobs)")
1167
+ return [
1168
+ types.TextContent(
1169
+ type="text",
1170
+ text=f"Jenkins Jobs (cached) ({len(cached_jobs)} total):\n\n{json.dumps(cached_jobs, indent=2)}"
1171
+ )
1172
+ ]
1173
+
1174
+ # Fetch from Jenkins
1175
+ jobs = client.get_jobs()
659
1176
 
660
1177
  # Apply filter if provided
661
1178
  if filter_text:
@@ -674,6 +1191,10 @@ async def _tool_list_jobs(client, args):
674
1191
  for job in jobs
675
1192
  ]
676
1193
 
1194
+ # Cache the result
1195
+ if use_cache:
1196
+ await cache_manager.set(cache_key, jobs_info, ttl_seconds=30)
1197
+
677
1198
  # Build response message
678
1199
  if filter_text:
679
1200
  message = f"Jenkins Jobs matching '{filter_text}' ({len(jobs_info)} found):\n\n{json.dumps(jobs_info, indent=2)}"
@@ -690,10 +1211,19 @@ async def _tool_list_jobs(client, args):
690
1211
 
691
1212
  async def _tool_get_job_details(client, args):
692
1213
  """Get detailed job information"""
693
- job_name = args.get("job_name")
1214
+ # Input validation
1215
+ job_name = validate_job_name(args.get("job_name"))
694
1216
 
695
- if not job_name:
696
- raise ValueError("Missing required argument: job_name")
1217
+ # Configurable number of recent builds to fetch (Critical Issue #3)
1218
+ max_recent_builds = args.get("max_recent_builds", 3)
1219
+ try:
1220
+ max_recent_builds = int(max_recent_builds)
1221
+ if max_recent_builds < 0:
1222
+ max_recent_builds = 0
1223
+ elif max_recent_builds > 10:
1224
+ max_recent_builds = 10 # Cap at 10 to prevent excessive API calls
1225
+ except (ValueError, TypeError):
1226
+ max_recent_builds = 3 # Default to 3
697
1227
 
698
1228
  job_info = client.get_job_info(job_name)
699
1229
 
@@ -707,10 +1237,14 @@ async def _tool_get_job_details(client, args):
707
1237
  "lastFailedBuild": job_info.get("lastFailedBuild", {}),
708
1238
  }
709
1239
 
710
- # Add recent builds
711
- if "builds" in job_info:
1240
+ # Add recent builds (optimized to reduce API calls)
1241
+ if max_recent_builds > 0 and "builds" in job_info:
712
1242
  recent_builds = []
713
- for build in job_info["builds"][:5]:
1243
+ builds_to_fetch = job_info["builds"][:max_recent_builds]
1244
+
1245
+ logger.info(f"Fetching {len(builds_to_fetch)} recent builds for '{job_name}'")
1246
+
1247
+ for build in builds_to_fetch:
714
1248
  try:
715
1249
  build_info = client.get_build_info(job_name, build["number"])
716
1250
  recent_builds.append({
@@ -723,6 +1257,7 @@ async def _tool_get_job_details(client, args):
723
1257
  logger.warning(f"Could not fetch build {build['number']}: {e}")
724
1258
 
725
1259
  details["recentBuilds"] = recent_builds
1260
+ details["recentBuildsCount"] = len(recent_builds)
726
1261
 
727
1262
  # Notify of resource changes
728
1263
  try:
@@ -742,11 +1277,9 @@ async def _tool_get_job_details(client, args):
742
1277
 
743
1278
  async def _tool_get_build_info(client, args):
744
1279
  """Get build information"""
745
- job_name = args.get("job_name")
746
- build_number = args.get("build_number")
747
-
748
- if not job_name or build_number is None:
749
- raise ValueError("Missing required arguments: job_name and build_number")
1280
+ # Input validation (Quick Win #4)
1281
+ job_name = validate_job_name(args.get("job_name"))
1282
+ build_number = validate_build_number(args.get("build_number"))
750
1283
 
751
1284
  build_info = client.get_build_info(job_name, build_number)
752
1285
 
@@ -779,33 +1312,78 @@ async def _tool_get_build_info(client, args):
779
1312
 
780
1313
 
781
1314
  async def _tool_get_build_console(client, args):
782
- """Get build console output"""
783
- job_name = args.get("job_name")
784
- build_number = args.get("build_number")
1315
+ """Get build console output with improved truncation (High Priority Issue #5)"""
1316
+ # Input validation (Quick Win #4)
1317
+ job_name = validate_job_name(args.get("job_name"))
1318
+ build_number = validate_build_number(args.get("build_number"))
785
1319
 
786
- if not job_name or build_number is None:
787
- raise ValueError("Missing required arguments: job_name and build_number")
1320
+ # Get configurable parameters with defaults from settings
1321
+ settings = get_settings()
1322
+ max_lines = args.get("max_lines", settings.console_max_lines)
1323
+ tail_only = args.get("tail_only", False)
788
1324
 
1325
+ # Validate max_lines
1326
+ try:
1327
+ max_lines = int(max_lines)
1328
+ if max_lines < 10:
1329
+ max_lines = 10
1330
+ elif max_lines > 10000:
1331
+ max_lines = 10000
1332
+ except (ValueError, TypeError):
1333
+ max_lines = settings.console_max_lines
1334
+
1335
+ # Validate tail_only
1336
+ if not isinstance(tail_only, bool):
1337
+ tail_only = str(tail_only).lower() in ('true', '1', 'yes')
1338
+
1339
+ # Get console output
789
1340
  console_output = client.get_build_console_output(job_name, build_number)
790
1341
 
791
- # Limit output size
792
- max_length = 10000
793
- if len(console_output) > max_length:
794
- console_output = console_output[:max_length] + "\n... (output truncated)"
1342
+ # Split into lines for better handling
1343
+ all_lines = console_output.split('\n')
1344
+ total_lines = len(all_lines)
1345
+
1346
+ # Determine what to show
1347
+ prefix = ""
1348
+ if total_lines <= max_lines:
1349
+ # No truncation needed
1350
+ output_lines = all_lines
1351
+ prefix = f"[Complete output: {total_lines} lines]\n\n"
1352
+ elif tail_only:
1353
+ # Show last N lines
1354
+ output_lines = all_lines[-max_lines:]
1355
+ truncated_lines = total_lines - max_lines
1356
+ prefix = f"[Showing last {max_lines} of {total_lines} lines - {truncated_lines} earlier lines omitted]\n\n"
1357
+ else:
1358
+ # Show first N lines
1359
+ output_lines = all_lines[:max_lines]
1360
+ truncated_lines = total_lines - max_lines
1361
+ prefix = f"[Showing first {max_lines} of {total_lines} lines - {truncated_lines} later lines truncated]\n\n"
1362
+
1363
+ # Reconstruct output
1364
+ final_output = '\n'.join(output_lines)
1365
+
1366
+ # Add helpful note if truncated
1367
+ if total_lines > max_lines:
1368
+ if tail_only:
1369
+ suffix = f"\n\nšŸ’” Tip: Use max_lines parameter to see more lines (current: {max_lines}, max: 10000)"
1370
+ else:
1371
+ suffix = f"\n\nšŸ’” Tip: Set tail_only=true to see last {max_lines} lines, or increase max_lines (current: {max_lines}, max: 10000)"
1372
+ else:
1373
+ suffix = ""
795
1374
 
796
1375
  return [
797
1376
  types.TextContent(
798
1377
  type="text",
799
- text=f"Console output for {job_name} #{build_number}:\n\n```\n{console_output}\n```"
1378
+ text=f"{prefix}Console output for {job_name} #{build_number}:\n\n```\n{final_output}\n```{suffix}"
800
1379
  )
801
1380
  ]
802
1381
 
803
1382
 
804
1383
  async def _tool_get_last_build_number(client, args):
805
1384
  """Get last build number"""
806
- job_name = args.get("job_name")
807
- if not job_name:
808
- raise ValueError("Missing required argument: job_name")
1385
+ # Input validation
1386
+ job_name = validate_job_name(args.get("job_name"))
809
1387
 
810
1388
  num = client.get_last_build_number(job_name)
811
1389
  return [types.TextContent(type="text", text=f"Last build number for '{job_name}': {num}")]
@@ -813,9 +1391,8 @@ async def _tool_get_last_build_number(client, args):
813
1391
 
814
1392
  async def _tool_get_last_build_timestamp(client, args):
815
1393
  """Get last build timestamp"""
816
- job_name = args.get("job_name")
817
- if not job_name:
818
- raise ValueError("Missing required argument: job_name")
1394
+ # Input validation
1395
+ job_name = validate_job_name(args.get("job_name"))
819
1396
 
820
1397
  ts = client.get_last_build_timestamp(job_name)
821
1398
  return [types.TextContent(type="text", text=f"Last build timestamp for '{job_name}': {ts}")]
@@ -825,11 +1402,9 @@ async def _tool_get_last_build_timestamp(client, args):
825
1402
 
826
1403
  async def _tool_create_job(client, args):
827
1404
  """Create a new job"""
828
- job_name = args.get("job_name")
829
- config_xml = args.get("config_xml")
830
-
831
- if not job_name or not config_xml:
832
- raise ValueError("Missing required arguments: job_name and config_xml")
1405
+ # Input validation (Quick Win #4)
1406
+ job_name = validate_job_name(args.get("job_name"))
1407
+ config_xml = validate_config_xml(args.get("config_xml"))
833
1408
 
834
1409
  client.create_job(job_name, config_xml)
835
1410
  return [types.TextContent(type="text", text=f"Successfully created job '{job_name}'")]
@@ -837,11 +1412,9 @@ async def _tool_create_job(client, args):
837
1412
 
838
1413
  async def _tool_create_job_from_copy(client, args):
839
1414
  """Create job from copy"""
840
- new_job_name = args.get("new_job_name")
841
- source_job_name = args.get("source_job_name")
842
-
843
- if not new_job_name or not source_job_name:
844
- raise ValueError("Missing required arguments: new_job_name and source_job_name")
1415
+ # Input validation
1416
+ new_job_name = validate_job_name(args.get("new_job_name"))
1417
+ source_job_name = validate_job_name(args.get("source_job_name"))
845
1418
 
846
1419
  client.create_job_from_copy(new_job_name, source_job_name)
847
1420
  return [types.TextContent(type="text", text=f"Successfully created job '{new_job_name}' from '{source_job_name}'")]
@@ -849,12 +1422,15 @@ async def _tool_create_job_from_copy(client, args):
849
1422
 
850
1423
  async def _tool_create_job_from_data(client, args):
851
1424
  """Create job from data"""
852
- job_name = args.get("job_name")
1425
+ # Input validation
1426
+ job_name = validate_job_name(args.get("job_name"))
853
1427
  config_data = args.get("config_data")
854
1428
  root_tag = args.get("root_tag", "project")
855
1429
 
856
- if not job_name or config_data is None:
857
- raise ValueError("Missing required arguments: job_name and config_data")
1430
+ if config_data is None:
1431
+ raise ValueError("Missing required argument: config_data")
1432
+ if not isinstance(config_data, dict):
1433
+ raise ValueError(f"config_data must be a dictionary, got {type(config_data).__name__}")
858
1434
 
859
1435
  client.create_job_from_dict(job_name, config_data, root_tag)
860
1436
  return [types.TextContent(type="text", text=f"Successfully created job '{job_name}' from data")]
@@ -862,9 +1438,8 @@ async def _tool_create_job_from_data(client, args):
862
1438
 
863
1439
  async def _tool_delete_job(client, args):
864
1440
  """Delete a job"""
865
- job_name = args.get("job_name")
866
- if not job_name:
867
- raise ValueError("Missing required argument: job_name")
1441
+ # Input validation
1442
+ job_name = validate_job_name(args.get("job_name"))
868
1443
 
869
1444
  client.delete_job(job_name)
870
1445
  return [types.TextContent(type="text", text=f"Successfully deleted job '{job_name}'")]
@@ -872,9 +1447,8 @@ async def _tool_delete_job(client, args):
872
1447
 
873
1448
  async def _tool_enable_job(client, args):
874
1449
  """Enable a job"""
875
- job_name = args.get("job_name")
876
- if not job_name:
877
- raise ValueError("Missing required argument: job_name")
1450
+ # Input validation
1451
+ job_name = validate_job_name(args.get("job_name"))
878
1452
 
879
1453
  client.enable_job(job_name)
880
1454
  return [types.TextContent(type="text", text=f"Successfully enabled job '{job_name}'")]
@@ -882,9 +1456,8 @@ async def _tool_enable_job(client, args):
882
1456
 
883
1457
  async def _tool_disable_job(client, args):
884
1458
  """Disable a job"""
885
- job_name = args.get("job_name")
886
- if not job_name:
887
- raise ValueError("Missing required argument: job_name")
1459
+ # Input validation
1460
+ job_name = validate_job_name(args.get("job_name"))
888
1461
 
889
1462
  client.disable_job(job_name)
890
1463
  return [types.TextContent(type="text", text=f"Successfully disabled job '{job_name}'")]
@@ -892,11 +1465,9 @@ async def _tool_disable_job(client, args):
892
1465
 
893
1466
  async def _tool_rename_job(client, args):
894
1467
  """Rename a job"""
895
- job_name = args.get("job_name")
896
- new_name = args.get("new_name")
897
-
898
- if not job_name or not new_name:
899
- raise ValueError("Missing required arguments: job_name and new_name")
1468
+ # Input validation
1469
+ job_name = validate_job_name(args.get("job_name"))
1470
+ new_name = validate_job_name(args.get("new_name"))
900
1471
 
901
1472
  client.rename_job(job_name, new_name)
902
1473
  return [types.TextContent(type="text", text=f"Successfully renamed job '{job_name}' to '{new_name}'")]
@@ -906,9 +1477,8 @@ async def _tool_rename_job(client, args):
906
1477
 
907
1478
  async def _tool_get_job_config(client, args):
908
1479
  """Get job configuration"""
909
- job_name = args.get("job_name")
910
- if not job_name:
911
- raise ValueError("Missing required argument: job_name")
1480
+ # Input validation
1481
+ job_name = validate_job_name(args.get("job_name"))
912
1482
 
913
1483
  config = client.get_job_config(job_name)
914
1484
  return [types.TextContent(type="text", text=config)]
@@ -916,11 +1486,9 @@ async def _tool_get_job_config(client, args):
916
1486
 
917
1487
  async def _tool_update_job_config(client, args):
918
1488
  """Update job configuration"""
919
- job_name = args.get("job_name")
920
- config_xml = args.get("config_xml")
921
-
922
- if not job_name or not config_xml:
923
- raise ValueError("Missing required arguments: job_name and config_xml")
1489
+ # Input validation
1490
+ job_name = validate_job_name(args.get("job_name"))
1491
+ config_xml = validate_config_xml(args.get("config_xml"))
924
1492
 
925
1493
  client.update_job_config(job_name, config_xml)
926
1494
  return [types.TextContent(type="text", text=f"Successfully updated config for job '{job_name}'")]
@@ -978,10 +1546,13 @@ async def _tool_list_nodes(client, args):
978
1546
 
979
1547
  async def _tool_get_node_info(client, args):
980
1548
  """Get node information"""
1549
+ # Input validation
981
1550
  node_name = args.get("node_name")
982
-
983
1551
  if not node_name:
984
1552
  raise ValueError("Missing required argument: node_name")
1553
+ if not isinstance(node_name, str):
1554
+ raise ValueError(f"node_name must be a string, got {type(node_name).__name__}")
1555
+ node_name = node_name.strip()
985
1556
 
986
1557
  node_info = client.get_node_info(node_name)
987
1558
 
@@ -1002,6 +1573,342 @@ async def _tool_get_node_info(client, args):
1002
1573
  ]
1003
1574
 
1004
1575
 
1576
+ async def _tool_get_cache_stats(client, args):
1577
+ """Get cache statistics"""
1578
+ cache_manager = get_cache_manager()
1579
+ stats = cache_manager.get_stats()
1580
+ cache_info = await cache_manager.get_cache_info()
1581
+
1582
+ report = f"""
1583
+ šŸ“Š Cache Statistics
1584
+
1585
+ ═══════════════════════════════════════
1586
+ OVERVIEW
1587
+ ═══════════════════════════════════════
1588
+ Cache Size: {stats['size']} entries
1589
+ Total Requests: {stats['total_requests']}
1590
+ Cache Hits: {stats['hits']}
1591
+ Cache Misses: {stats['misses']}
1592
+ Hit Rate: {stats['hit_rate_percent']}%
1593
+ Evictions: {stats['evictions']}
1594
+
1595
+ ═══════════════════════════════════════
1596
+ CACHED ENTRIES
1597
+ ═══════════════════════════════════════
1598
+ """
1599
+
1600
+ for entry in cache_info['entries']:
1601
+ status = "āœ… Valid" if not entry['is_expired'] else "āŒ Expired"
1602
+ report += f"\n{entry['key']}\n"
1603
+ report += f" Status: {status}\n"
1604
+ report += f" Age: {entry['age_seconds']}s\n"
1605
+ report += f" TTL: {entry['ttl_seconds']}s\n"
1606
+ report += f" Expires in: {entry['expires_in_seconds']}s\n"
1607
+
1608
+ return [types.TextContent(type="text", text=report.strip())]
1609
+
1610
+
1611
+ async def _tool_clear_cache(client, args):
1612
+ """Clear all cached data"""
1613
+ cache_manager = get_cache_manager()
1614
+ cleared = await cache_manager.clear()
1615
+
1616
+ return [types.TextContent(
1617
+ type="text",
1618
+ text=f"āœ… Cache cleared: {cleared} entries removed"
1619
+ )]
1620
+
1621
+
1622
+ # Health Check Tool (Quick Win #1)
1623
+
1624
+ async def _tool_health_check(client, args):
1625
+ """
1626
+ Check Jenkins server health and connection status.
1627
+ Provides detailed diagnostics for troubleshooting.
1628
+ """
1629
+ checks = {
1630
+ "server_reachable": False,
1631
+ "authentication_valid": False,
1632
+ "api_responsive": False,
1633
+ "server_version": None,
1634
+ "server_url": get_settings().url,
1635
+ "username": get_settings().username,
1636
+ "response_time_ms": None,
1637
+ "timestamp": None
1638
+ }
1639
+
1640
+ status_emoji = "āŒ"
1641
+ status_text = "Unhealthy"
1642
+ error_details = None
1643
+
1644
+ try:
1645
+ import datetime
1646
+ start_time = time.time()
1647
+ checks["timestamp"] = datetime.datetime.now().isoformat()
1648
+
1649
+ # Test 1: Basic connectivity
1650
+ try:
1651
+ # Try to get user info (tests auth + connectivity)
1652
+ user_info = client.get_whoami()
1653
+ checks["server_reachable"] = True
1654
+ checks["authentication_valid"] = True
1655
+
1656
+ # Get version info
1657
+ try:
1658
+ version = client.get_version()
1659
+ checks["server_version"] = version
1660
+ checks["api_responsive"] = True
1661
+ except Exception as ve:
1662
+ logger.warning(f"Could not get version: {ve}")
1663
+ checks["api_responsive"] = False
1664
+
1665
+ # Calculate response time
1666
+ elapsed_ms = (time.time() - start_time) * 1000
1667
+ checks["response_time_ms"] = round(elapsed_ms, 2)
1668
+
1669
+ # Determine overall status
1670
+ if checks["api_responsive"]:
1671
+ status_emoji = "āœ…"
1672
+ status_text = "Healthy"
1673
+ if elapsed_ms > 2000:
1674
+ status_text = "Healthy (Slow)"
1675
+ status_emoji = "āš ļø"
1676
+ elif checks["authentication_valid"]:
1677
+ status_emoji = "āš ļø"
1678
+ status_text = "Partially Healthy (API issues)"
1679
+
1680
+ except Exception as conn_error:
1681
+ error_details = str(conn_error)
1682
+ error_type = type(conn_error).__name__
1683
+
1684
+ # Classify the error
1685
+ if 'timeout' in error_details.lower():
1686
+ status_text = "Timeout - Server not responding"
1687
+ checks["server_reachable"] = False
1688
+ elif '401' in error_details or 'unauthorized' in error_details.lower():
1689
+ status_text = "Authentication Failed"
1690
+ checks["server_reachable"] = True
1691
+ checks["authentication_valid"] = False
1692
+ elif 'connection' in error_details.lower():
1693
+ status_text = "Connection Failed - Server unreachable"
1694
+ checks["server_reachable"] = False
1695
+ else:
1696
+ status_text = f"Error: {error_type}"
1697
+
1698
+ except Exception as e:
1699
+ error_details = str(e)
1700
+ status_text = f"Health check failed: {type(e).__name__}"
1701
+ logger.error(f"Health check error: {e}", exc_info=True)
1702
+
1703
+ # Build detailed report
1704
+ report = f"""
1705
+ {status_emoji} Jenkins Health Check: {status_text}
1706
+
1707
+ ═══════════════════════════════════════
1708
+ CONNECTION STATUS
1709
+ ═══════════════════════════════════════
1710
+ Server URL: {checks['server_url']}
1711
+ Username: {checks['username']}
1712
+ Server Reachable: {'āœ… Yes' if checks['server_reachable'] else 'āŒ No'}
1713
+ Authentication: {'āœ… Valid' if checks['authentication_valid'] else 'āŒ Failed'}
1714
+ API Responsive: {'āœ… Yes' if checks['api_responsive'] else 'āŒ No'}
1715
+
1716
+ ═══════════════════════════════════════
1717
+ SERVER DETAILS
1718
+ ═══════════════════════════════════════
1719
+ Jenkins Version: {checks['server_version'] or 'Unknown'}
1720
+ Response Time: {checks['response_time_ms']}ms
1721
+ Checked At: {checks['timestamp']}
1722
+ """
1723
+
1724
+ if error_details:
1725
+ report += f"""
1726
+ ═══════════════════════════════════════
1727
+ ERROR DETAILS
1728
+ ═══════════════════════════════════════
1729
+ {error_details}
1730
+ """
1731
+
1732
+ # Add troubleshooting tips if unhealthy
1733
+ if status_emoji == "āŒ":
1734
+ report += """
1735
+ ═══════════════════════════════════════
1736
+ TROUBLESHOOTING STEPS
1737
+ ═══════════════════════════════════════
1738
+ """
1739
+ if not checks['server_reachable']:
1740
+ report += """
1741
+ šŸ”Œ Server Not Reachable:
1742
+ 1. Verify Jenkins is running
1743
+ 2. Check the URL is correct
1744
+ 3. Test with: curl {url}/api/json
1745
+ 4. Check firewall/VPN settings
1746
+ 5. Verify network connectivity
1747
+ """.format(url=checks['server_url'])
1748
+
1749
+ if checks['server_reachable'] and not checks['authentication_valid']:
1750
+ report += """
1751
+ šŸ” Authentication Failed:
1752
+ 1. Verify username is correct
1753
+ 2. Check API token is valid
1754
+ 3. Generate new token:
1755
+ - Jenkins → Your Name → Configure
1756
+ - API Token section → Add new Token
1757
+ 4. Update .env file with new token
1758
+ """
1759
+
1760
+ if checks['server_reachable'] and checks['authentication_valid'] and not checks['api_responsive']:
1761
+ report += """
1762
+ āš ļø API Not Responsive:
1763
+ 1. Check Jenkins server logs
1764
+ 2. Verify Jenkins is not overloaded
1765
+ 3. Check for Jenkins plugin issues
1766
+ 4. Restart Jenkins if needed
1767
+ """
1768
+
1769
+ report += "\nšŸ’” Tip: Run this health-check regularly to monitor your Jenkins connection."
1770
+
1771
+ return [types.TextContent(type="text", text=report.strip())]
1772
+
1773
+
1774
+ async def _tool_get_metrics(client, args):
1775
+ """Get usage metrics"""
1776
+ tool_name = args.get("tool_name")
1777
+
1778
+ metrics_collector = get_metrics_collector()
1779
+
1780
+ if tool_name:
1781
+ # Get specific tool metrics
1782
+ stats = await metrics_collector.get_tool_stats(tool_name)
1783
+
1784
+ report = f"""
1785
+ šŸ“Š Metrics for '{tool_name}'
1786
+
1787
+ {json.dumps(stats, indent=2)}
1788
+ """
1789
+ else:
1790
+ # Get overall summary
1791
+ summary = await metrics_collector.get_summary()
1792
+ tool_stats = await metrics_collector.get_tool_stats()
1793
+
1794
+ report = f"""
1795
+ šŸ“Š Jenkins MCP Server Metrics
1796
+
1797
+ ═══════════════════════════════════════
1798
+ SUMMARY
1799
+ ═══════════════════════════════════════
1800
+ Uptime: {summary['uptime_human']}
1801
+ Total Executions: {summary['total_executions']}
1802
+ Successful: {summary['successful_executions']}
1803
+ Failed: {summary['failed_executions']}
1804
+ Success Rate: {summary['success_rate_percent']}%
1805
+ Avg Execution Time: {summary['avg_execution_time_ms']}ms
1806
+ Unique Tools Used: {summary['unique_tools_used']}
1807
+ Most Used Tool: {summary['most_used_tool']}
1808
+ Slowest Tool: {summary['slowest_tool']}
1809
+
1810
+ ═══════════════════════════════════════
1811
+ PER-TOOL STATISTICS
1812
+ ═══════════════════════════════════════
1813
+ {json.dumps(tool_stats, indent=2)}
1814
+ """
1815
+
1816
+ return [types.TextContent(type="text", text=report.strip())]
1817
+
1818
+
1819
+ async def _tool_configure_webhook(client, args):
1820
+ """Configure webhook for Jenkins job"""
1821
+ job_name = validate_job_name(args.get("job_name"))
1822
+ webhook_url = args.get("webhook_url")
1823
+ events = args.get("events", [])
1824
+
1825
+ if not webhook_url:
1826
+ raise ValueError("webhook_url is required")
1827
+
1828
+ if not events:
1829
+ raise ValueError("At least one event must be specified")
1830
+
1831
+ # Get current job config
1832
+ config_xml = client.get_job_config(job_name)
1833
+
1834
+ # Add webhook notification (this is simplified - actual implementation
1835
+ # depends on Jenkins plugin configuration)
1836
+ import xml.etree.ElementTree as ET
1837
+
1838
+ try:
1839
+ root = ET.fromstring(config_xml)
1840
+
1841
+ # Add or update properties section
1842
+ properties = root.find('properties')
1843
+ if properties is None:
1844
+ properties = ET.SubElement(root, 'properties')
1845
+
1846
+ # Add webhook trigger configuration
1847
+ # (Actual XML structure depends on the webhook plugin used)
1848
+ webhook_config = f"""
1849
+ <!-- Webhook Configuration -->
1850
+ <!-- Events: {', '.join(events)} -->
1851
+ <!-- URL: {webhook_url} -->
1852
+ """
1853
+
1854
+ # Note: This is a placeholder. Real implementation would need
1855
+ # to configure the actual webhook plugin XML structure
1856
+
1857
+ updated_xml = ET.tostring(root, encoding='unicode')
1858
+
1859
+ # Update job
1860
+ client.update_job_config(job_name, updated_xml)
1861
+
1862
+ return [types.TextContent(
1863
+ type="text",
1864
+ text=f"āœ… Webhook configured for '{job_name}'\n\n"
1865
+ f"URL: {webhook_url}\n"
1866
+ f"Events: {', '.join(events)}\n\n"
1867
+ f"āš ļø Note: Requires Generic Webhook Trigger plugin in Jenkins"
1868
+ )]
1869
+
1870
+ except Exception as e:
1871
+ return [types.TextContent(
1872
+ type="text",
1873
+ text=f"āŒ Failed to configure webhook: {str(e)}\n\n"
1874
+ f"Make sure the Generic Webhook Trigger plugin is installed in Jenkins."
1875
+ )]
1876
+
1877
+
1878
+ # Note: MCP protocol may not support streaming yet. This is prepared for future use.
1879
+ # Example: For trigger-multiple-builds
1880
+ async def _tool_trigger_multiple_builds_with_progress(client, args):
1881
+ """Trigger builds with progress updates"""
1882
+ job_names = args.get("job_names", [])
1883
+
1884
+ # Initial message
1885
+ yield types.TextContent(
1886
+ type="text",
1887
+ text=f"šŸš€ Starting batch build trigger for {len(job_names)} jobs..."
1888
+ )
1889
+
1890
+ results = []
1891
+ for i, job_name in enumerate(job_names, 1):
1892
+ # Progress update
1893
+ yield types.TextContent(
1894
+ type="text",
1895
+ text=f"ā³ [{i}/{len(job_names)}] Triggering {job_name}..."
1896
+ )
1897
+
1898
+ try:
1899
+ result = client.build_job(job_name)
1900
+ results.append({"job": job_name, "status": "success"})
1901
+ except Exception as e:
1902
+ results.append({"job": job_name, "status": "failed", "error": str(e)})
1903
+
1904
+ # Final summary
1905
+ successful = len([r for r in results if r["status"] == "success"])
1906
+ yield types.TextContent(
1907
+ type="text",
1908
+ text=f"āœ… Complete: {successful}/{len(job_names)} builds triggered successfully"
1909
+ )
1910
+
1911
+
1005
1912
  # ==================== Main Server Entry Point ====================
1006
1913
 
1007
1914
  async def main():
@@ -1034,4 +1941,4 @@ async def main():
1034
1941
  logger.info("Server stopped by user")
1035
1942
  except Exception as e:
1036
1943
  logger.error(f"Server error: {e}", exc_info=True)
1037
- sys.exit(1)
1944
+ sys.exit(1)