@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,1037 @@
1
+ """
2
+ Jenkins MCP Server Implementation
3
+
4
+ Provides MCP protocol handlers for Jenkins operations including:
5
+ - Resources (job information)
6
+ - Prompts (analysis templates)
7
+ - Tools (Jenkins operations)
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import sys
13
+ from typing import Optional
14
+
15
+ import mcp.server.stdio
16
+ import mcp.types as types
17
+ from mcp.server import NotificationOptions, Server
18
+ from mcp.server.models import InitializationOptions
19
+ from pydantic import AnyUrl
20
+
21
+ from .config import JenkinsSettings, get_default_settings
22
+ from .jenkins_client import get_jenkins_client, JenkinsConnectionError
23
+
24
+ # Configure logging
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Server instance
28
+ server = Server("jenkins-mcp-server")
29
+
30
+ # Settings storage (injected by main)
31
+ _jenkins_settings: Optional[JenkinsSettings] = None
32
+
33
+
34
+ def set_jenkins_settings(settings: JenkinsSettings) -> None:
35
+ """Set Jenkins settings for the server (called from __init__.py)"""
36
+ global _jenkins_settings
37
+ _jenkins_settings = settings
38
+
39
+
40
+ def get_settings() -> JenkinsSettings:
41
+ """Get current Jenkins settings"""
42
+ global _jenkins_settings
43
+ if _jenkins_settings is None:
44
+ _jenkins_settings = get_default_settings()
45
+ return _jenkins_settings
46
+
47
+
48
+ # ==================== Resources ====================
49
+
50
+ @server.list_resources()
51
+ async def handle_list_resources() -> list[types.Resource]:
52
+ """
53
+ List available Jenkins resources.
54
+ Each job is exposed as a resource with jenkins:// URI scheme.
55
+ """
56
+ try:
57
+ client = get_jenkins_client(get_settings())
58
+ jobs = client.get_jobs()
59
+
60
+ return [
61
+ types.Resource(
62
+ uri=AnyUrl(f"jenkins://job/{job['name']}"),
63
+ name=f"Job: {job['name']}",
64
+ description=f"Jenkins job: {job['name']} (status: {job.get('color', 'unknown')})",
65
+ mimeType="application/json",
66
+ )
67
+ for job in jobs
68
+ ]
69
+ except Exception as e:
70
+ logger.error(f"Failed to list resources: {e}")
71
+ return [
72
+ types.Resource(
73
+ uri=AnyUrl("jenkins://error"),
74
+ name="Error connecting to Jenkins",
75
+ description=f"Error: {str(e)}",
76
+ mimeType="text/plain",
77
+ )
78
+ ]
79
+
80
+
81
+ @server.read_resource()
82
+ async def handle_read_resource(uri: AnyUrl) -> str:
83
+ """Read a specific Jenkins resource by URI"""
84
+ if uri.scheme != "jenkins":
85
+ raise ValueError(f"Unsupported URI scheme: {uri.scheme}")
86
+
87
+ path = str(uri.path).lstrip("/") if uri.path else ""
88
+
89
+ if not path:
90
+ raise ValueError("Invalid Jenkins URI: missing path")
91
+
92
+ if path == "error":
93
+ return "Failed to connect to Jenkins server. Please check your configuration."
94
+
95
+ # Handle job requests
96
+ if path.startswith("job/"):
97
+ job_name = path[4:] # Remove "job/" prefix
98
+
99
+ try:
100
+ client = get_jenkins_client(get_settings())
101
+ job_info = client.get_job_info(job_name)
102
+
103
+ # Try to get last build info
104
+ last_build = job_info.get('lastBuild')
105
+ if last_build and last_build.get('number'):
106
+ build_number = last_build['number']
107
+ try:
108
+ build_info = client.get_build_info(job_name, build_number)
109
+ return json.dumps(build_info, indent=2)
110
+ except Exception as e:
111
+ logger.warning(f"Could not fetch build info: {e}")
112
+
113
+ return json.dumps(job_info, indent=2)
114
+
115
+ except Exception as e:
116
+ logger.error(f"Error reading resource {path}: {e}")
117
+ return f"Error retrieving job information: {str(e)}"
118
+
119
+ raise ValueError(f"Unknown Jenkins resource: {path}")
120
+
121
+
122
+ # ==================== Prompts ====================
123
+
124
+ @server.list_prompts()
125
+ async def handle_list_prompts() -> list[types.Prompt]:
126
+ """List available prompts for Jenkins data analysis"""
127
+ return [
128
+ types.Prompt(
129
+ name="analyze-job-status",
130
+ description="Analyze the status of Jenkins jobs",
131
+ arguments=[
132
+ types.PromptArgument(
133
+ name="detail_level",
134
+ description="Level of analysis detail (brief/detailed)",
135
+ required=False,
136
+ )
137
+ ],
138
+ ),
139
+ types.Prompt(
140
+ name="analyze-build-logs",
141
+ description="Analyze build logs for a specific job",
142
+ arguments=[
143
+ types.PromptArgument(
144
+ name="job_name",
145
+ description="Name of the Jenkins job",
146
+ required=True,
147
+ ),
148
+ types.PromptArgument(
149
+ name="build_number",
150
+ description="Build number (default: latest)",
151
+ required=False,
152
+ )
153
+ ],
154
+ )
155
+ ]
156
+
157
+
158
+ @server.get_prompt()
159
+ async def handle_get_prompt(
160
+ name: str,
161
+ arguments: dict[str, str] | None
162
+ ) -> types.GetPromptResult:
163
+ """Generate prompts for Jenkins data analysis"""
164
+ arguments = arguments or {}
165
+
166
+ if name == "analyze-job-status":
167
+ return await _prompt_analyze_job_status(arguments)
168
+ elif name == "analyze-build-logs":
169
+ return await _prompt_analyze_build_logs(arguments)
170
+ else:
171
+ raise ValueError(f"Unknown prompt: {name}")
172
+
173
+
174
+ async def _prompt_analyze_job_status(arguments: dict[str, str]) -> types.GetPromptResult:
175
+ """Generate job status analysis prompt"""
176
+ detail_level = arguments.get("detail_level", "brief")
177
+ detail_prompt = " Provide extensive analysis." if detail_level == "detailed" else ""
178
+
179
+ try:
180
+ client = get_jenkins_client(get_settings())
181
+ jobs = client.get_jobs()
182
+
183
+ jobs_text = "\n".join(
184
+ f"- {job['name']}: Status={job.get('color', 'unknown')}"
185
+ for job in jobs
186
+ )
187
+
188
+ return types.GetPromptResult(
189
+ description="Analyze Jenkins job statuses",
190
+ messages=[
191
+ types.PromptMessage(
192
+ role="user",
193
+ content=types.TextContent(
194
+ type="text",
195
+ text=(
196
+ f"Here are the current Jenkins jobs to analyze:{detail_prompt}\n\n"
197
+ f"{jobs_text}\n\n"
198
+ f"Please provide insights on the status of these jobs, identify any "
199
+ f"potential issues, and suggest next steps to maintain a healthy CI/CD environment."
200
+ ),
201
+ ),
202
+ )
203
+ ],
204
+ )
205
+ except Exception as e:
206
+ logger.error(f"Error in analyze-job-status prompt: {e}")
207
+ return types.GetPromptResult(
208
+ description="Error retrieving Jenkins jobs",
209
+ messages=[
210
+ types.PromptMessage(
211
+ role="user",
212
+ content=types.TextContent(
213
+ type="text",
214
+ text=(
215
+ f"I tried to get information about Jenkins jobs but encountered an error: {str(e)}\n\n"
216
+ f"Please help diagnose what might be wrong with my Jenkins connection or configuration."
217
+ ),
218
+ ),
219
+ )
220
+ ],
221
+ )
222
+
223
+
224
+ async def _prompt_analyze_build_logs(arguments: dict[str, str]) -> types.GetPromptResult:
225
+ """Generate build log analysis prompt"""
226
+ job_name = arguments.get("job_name")
227
+ build_number_str = arguments.get("build_number")
228
+
229
+ if not job_name:
230
+ raise ValueError("Missing required argument: job_name")
231
+
232
+ try:
233
+ client = get_jenkins_client(get_settings())
234
+ job_info = client.get_job_info(job_name)
235
+
236
+ # Determine build number
237
+ if build_number_str:
238
+ build_number = int(build_number_str)
239
+ else:
240
+ last_build = job_info.get('lastBuild')
241
+ build_number = last_build.get('number') if last_build else None
242
+
243
+ if build_number is None:
244
+ return types.GetPromptResult(
245
+ description=f"No builds found for job: {job_name}",
246
+ messages=[
247
+ types.PromptMessage(
248
+ role="user",
249
+ content=types.TextContent(
250
+ type="text",
251
+ text=(
252
+ f"I tried to analyze build logs for the Jenkins job '{job_name}', "
253
+ f"but no builds were found.\n\n"
254
+ f"Please help me understand why this job might not have any builds "
255
+ f"and suggest how to investigate."
256
+ ),
257
+ ),
258
+ )
259
+ ],
260
+ )
261
+
262
+ # Get build info and console output
263
+ build_info = client.get_build_info(job_name, build_number)
264
+ console_output = client.get_build_console_output(job_name, build_number)
265
+
266
+ # Limit console output size
267
+ max_length = 10000
268
+ if len(console_output) > max_length:
269
+ console_output = console_output[:max_length] + "\n... (output truncated)"
270
+
271
+ result = build_info.get('result', 'UNKNOWN')
272
+ duration = build_info.get('duration', 0) / 1000 # Convert ms to seconds
273
+
274
+ return types.GetPromptResult(
275
+ description=f"Analysis of build #{build_number} for job: {job_name}",
276
+ messages=[
277
+ types.PromptMessage(
278
+ role="user",
279
+ content=types.TextContent(
280
+ type="text",
281
+ text=(
282
+ f"Please analyze the following Jenkins build logs for job '{job_name}' "
283
+ f"(build #{build_number}).\n\n"
284
+ f"Build result: {result}\n"
285
+ f"Build duration: {duration:.1f} seconds\n\n"
286
+ f"Console output:\n```\n{console_output}\n```\n\n"
287
+ f"Please identify any issues, errors, or warnings in these logs. "
288
+ f"If there are problems, suggest how to fix them. "
289
+ f"If the build was successful, summarize what happened."
290
+ ),
291
+ ),
292
+ )
293
+ ],
294
+ )
295
+
296
+ except Exception as e:
297
+ logger.error(f"Error in analyze-build-logs prompt: {e}")
298
+ return types.GetPromptResult(
299
+ description="Error retrieving build information",
300
+ messages=[
301
+ types.PromptMessage(
302
+ role="user",
303
+ content=types.TextContent(
304
+ type="text",
305
+ text=(
306
+ f"I tried to analyze build logs for the Jenkins job '{job_name}' "
307
+ f"but encountered an error: {str(e)}\n\n"
308
+ f"Please help diagnose what might be wrong with my Jenkins connection, "
309
+ f"configuration, or the job itself."
310
+ ),
311
+ ),
312
+ )
313
+ ],
314
+ )
315
+
316
+
317
+ # ==================== Tools ====================
318
+
319
+ @server.list_tools()
320
+ async def handle_list_tools() -> list[types.Tool]:
321
+ """List available tools for interacting with Jenkins"""
322
+ tools = [
323
+ # Build Operations
324
+ types.Tool(
325
+ name="trigger-build",
326
+ description="Trigger a Jenkins job build with optional parameters",
327
+ inputSchema={
328
+ "type": "object",
329
+ "properties": {
330
+ "job_name": {"type": "string", "description": "Name of the Jenkins job"},
331
+ "parameters": {
332
+ "type": "object",
333
+ "description": "Build parameters (key-value pairs)",
334
+ "additionalProperties": {"type": ["string", "number", "boolean"]},
335
+ },
336
+ },
337
+ "required": ["job_name"],
338
+ },
339
+ ),
340
+ types.Tool(
341
+ name="stop-build",
342
+ description="Stop a running Jenkins build",
343
+ inputSchema={
344
+ "type": "object",
345
+ "properties": {
346
+ "job_name": {"type": "string"},
347
+ "build_number": {"type": "integer"},
348
+ },
349
+ "required": ["job_name", "build_number"],
350
+ },
351
+ ),
352
+
353
+ # Job Information
354
+ types.Tool(
355
+ name="list-jobs",
356
+ description="List all Jenkins jobs with optional filtering",
357
+ inputSchema={
358
+ "type": "object",
359
+ "properties": {
360
+ "filter": {
361
+ "type": "string",
362
+ "description": "Filter jobs by name (case-insensitive partial match)"
363
+ }
364
+ }
365
+ },
366
+ ),
367
+ types.Tool(
368
+ name="get-job-details",
369
+ description="Get detailed information about a Jenkins job",
370
+ inputSchema={
371
+ "type": "object",
372
+ "properties": {"job_name": {"type": "string"}},
373
+ "required": ["job_name"],
374
+ },
375
+ ),
376
+
377
+ # Build Information
378
+ types.Tool(
379
+ name="get-build-info",
380
+ description="Get information about a specific build",
381
+ inputSchema={
382
+ "type": "object",
383
+ "properties": {
384
+ "job_name": {"type": "string"},
385
+ "build_number": {"type": "integer"},
386
+ },
387
+ "required": ["job_name", "build_number"],
388
+ },
389
+ ),
390
+ types.Tool(
391
+ name="get-build-console",
392
+ description="Get console output from a build",
393
+ inputSchema={
394
+ "type": "object",
395
+ "properties": {
396
+ "job_name": {"type": "string"},
397
+ "build_number": {"type": "integer"},
398
+ },
399
+ "required": ["job_name", "build_number"],
400
+ },
401
+ ),
402
+ types.Tool(
403
+ name="get-last-build-number",
404
+ description="Get the last build number for a job",
405
+ inputSchema={
406
+ "type": "object",
407
+ "properties": {"job_name": {"type": "string"}},
408
+ "required": ["job_name"],
409
+ },
410
+ ),
411
+ types.Tool(
412
+ name="get-last-build-timestamp",
413
+ description="Get the timestamp of the last build",
414
+ inputSchema={
415
+ "type": "object",
416
+ "properties": {"job_name": {"type": "string"}},
417
+ "required": ["job_name"],
418
+ },
419
+ ),
420
+
421
+ # Job Management
422
+ types.Tool(
423
+ name="create-job",
424
+ description="Create a new Jenkins job with XML configuration",
425
+ inputSchema={
426
+ "type": "object",
427
+ "properties": {
428
+ "job_name": {"type": "string"},
429
+ "config_xml": {"type": "string", "description": "Job configuration XML"},
430
+ },
431
+ "required": ["job_name", "config_xml"],
432
+ },
433
+ ),
434
+ types.Tool(
435
+ name="create-job-from-copy",
436
+ description="Create a new job by copying an existing one",
437
+ inputSchema={
438
+ "type": "object",
439
+ "properties": {
440
+ "new_job_name": {"type": "string"},
441
+ "source_job_name": {"type": "string"},
442
+ },
443
+ "required": ["new_job_name", "source_job_name"],
444
+ },
445
+ ),
446
+ types.Tool(
447
+ name="create-job-from-data",
448
+ description="Create a job from structured data (auto-generated XML)",
449
+ inputSchema={
450
+ "type": "object",
451
+ "properties": {
452
+ "job_name": {"type": "string"},
453
+ "config_data": {"type": "object"},
454
+ "root_tag": {"type": "string", "default": "project"},
455
+ },
456
+ "required": ["job_name", "config_data"],
457
+ },
458
+ ),
459
+ types.Tool(
460
+ name="delete-job",
461
+ description="Delete an existing Jenkins job",
462
+ inputSchema={
463
+ "type": "object",
464
+ "properties": {"job_name": {"type": "string"}},
465
+ "required": ["job_name"],
466
+ },
467
+ ),
468
+ types.Tool(
469
+ name="enable-job",
470
+ description="Enable a disabled Jenkins job",
471
+ inputSchema={
472
+ "type": "object",
473
+ "properties": {"job_name": {"type": "string"}},
474
+ "required": ["job_name"],
475
+ },
476
+ ),
477
+ types.Tool(
478
+ name="disable-job",
479
+ description="Disable a Jenkins job",
480
+ inputSchema={
481
+ "type": "object",
482
+ "properties": {"job_name": {"type": "string"}},
483
+ "required": ["job_name"],
484
+ },
485
+ ),
486
+ types.Tool(
487
+ name="rename-job",
488
+ description="Rename an existing Jenkins job",
489
+ inputSchema={
490
+ "type": "object",
491
+ "properties": {
492
+ "job_name": {"type": "string"},
493
+ "new_name": {"type": "string"},
494
+ },
495
+ "required": ["job_name", "new_name"],
496
+ },
497
+ ),
498
+
499
+ # Job Configuration
500
+ types.Tool(
501
+ name="get-job-config",
502
+ description="Get the configuration XML for a job",
503
+ inputSchema={
504
+ "type": "object",
505
+ "properties": {"job_name": {"type": "string"}},
506
+ "required": ["job_name"],
507
+ },
508
+ ),
509
+ types.Tool(
510
+ name="update-job-config",
511
+ description="Update the configuration XML for a job",
512
+ inputSchema={
513
+ "type": "object",
514
+ "properties": {
515
+ "job_name": {"type": "string"},
516
+ "config_xml": {"type": "string"},
517
+ },
518
+ "required": ["job_name", "config_xml"],
519
+ },
520
+ ),
521
+
522
+ # System Information
523
+ types.Tool(
524
+ name="get-queue-info",
525
+ description="Get information about the Jenkins build queue",
526
+ inputSchema={"type": "object", "properties": {}},
527
+ ),
528
+ types.Tool(
529
+ name="list-nodes",
530
+ description="List all Jenkins nodes/agents",
531
+ inputSchema={"type": "object", "properties": {}},
532
+ ),
533
+ types.Tool(
534
+ name="get-node-info",
535
+ description="Get information about a specific Jenkins node",
536
+ inputSchema={
537
+ "type": "object",
538
+ "properties": {"node_name": {"type": "string"}},
539
+ "required": ["node_name"],
540
+ },
541
+ ),
542
+ ]
543
+
544
+ logger.info(f"Registered {len(tools)} Jenkins tools")
545
+ return tools
546
+
547
+
548
+ @server.call_tool()
549
+ async def handle_call_tool(
550
+ name: str,
551
+ arguments: dict | None
552
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
553
+ """Handle tool execution requests"""
554
+ arguments = arguments or {}
555
+
556
+ try:
557
+ client = get_jenkins_client(get_settings())
558
+
559
+ # Route to appropriate handler
560
+ handlers = {
561
+ # Build operations
562
+ "trigger-build": _tool_trigger_build,
563
+ "stop-build": _tool_stop_build,
564
+
565
+ # Job information
566
+ "list-jobs": _tool_list_jobs,
567
+ "get-job-details": _tool_get_job_details,
568
+
569
+ # Build information
570
+ "get-build-info": _tool_get_build_info,
571
+ "get-build-console": _tool_get_build_console,
572
+ "get-last-build-number": _tool_get_last_build_number,
573
+ "get-last-build-timestamp": _tool_get_last_build_timestamp,
574
+
575
+ # Job management
576
+ "create-job": _tool_create_job,
577
+ "create-job-from-copy": _tool_create_job_from_copy,
578
+ "create-job-from-data": _tool_create_job_from_data,
579
+ "delete-job": _tool_delete_job,
580
+ "enable-job": _tool_enable_job,
581
+ "disable-job": _tool_disable_job,
582
+ "rename-job": _tool_rename_job,
583
+
584
+ # Job configuration
585
+ "get-job-config": _tool_get_job_config,
586
+ "update-job-config": _tool_update_job_config,
587
+
588
+ # System information
589
+ "get-queue-info": _tool_get_queue_info,
590
+ "list-nodes": _tool_list_nodes,
591
+ "get-node-info": _tool_get_node_info,
592
+ }
593
+
594
+ handler = handlers.get(name)
595
+ if not handler:
596
+ raise ValueError(f"Unknown tool: {name}")
597
+
598
+ return await handler(client, arguments)
599
+
600
+ except Exception as e:
601
+ logger.error(f"Tool execution failed for {name}: {e}", exc_info=True)
602
+ return [
603
+ types.TextContent(
604
+ type="text",
605
+ text=f"Error executing {name}: {str(e)}"
606
+ )
607
+ ]
608
+
609
+
610
+ # ==================== Tool Handlers ====================
611
+
612
+ # Build Operations
613
+
614
+ async def _tool_trigger_build(client, args):
615
+ """Trigger a Jenkins build"""
616
+ job_name = args.get("job_name")
617
+ parameters = args.get("parameters", {})
618
+
619
+ if not job_name:
620
+ raise ValueError("Missing required argument: job_name")
621
+
622
+ result = client.build_job(job_name, parameters)
623
+
624
+ text = f"Successfully triggered build for job '{job_name}'.\n"
625
+ if result['queue_id']:
626
+ text += f"Queue ID: {result['queue_id']}\n"
627
+ if result['build_number']:
628
+ text += f"Build number: #{result['build_number']}\n"
629
+ if parameters:
630
+ text += f"Parameters: {json.dumps(parameters, indent=2)}"
631
+
632
+ return [types.TextContent(type="text", text=text)]
633
+
634
+
635
+ async def _tool_stop_build(client, args):
636
+ """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")
642
+
643
+ client.stop_build(job_name, build_number)
644
+
645
+ return [
646
+ types.TextContent(
647
+ type="text",
648
+ text=f"Successfully stopped build #{build_number} for job '{job_name}'."
649
+ )
650
+ ]
651
+
652
+
653
+ # Job Information
654
+
655
+ async def _tool_list_jobs(client, args):
656
+ """List all Jenkins jobs with optional filtering"""
657
+ jobs = client.get_jobs()
658
+ filter_text = args.get("filter", "").strip()
659
+
660
+ # Apply filter if provided
661
+ if filter_text:
662
+ filter_lower = filter_text.lower()
663
+ jobs = [
664
+ job for job in jobs
665
+ if filter_lower in job.get("name", "").lower()
666
+ ]
667
+
668
+ jobs_info = [
669
+ {
670
+ "name": job.get("name"),
671
+ "url": job.get("url"),
672
+ "status": job.get("color", "unknown")
673
+ }
674
+ for job in jobs
675
+ ]
676
+
677
+ # Build response message
678
+ if filter_text:
679
+ message = f"Jenkins Jobs matching '{filter_text}' ({len(jobs_info)} found):\n\n{json.dumps(jobs_info, indent=2)}"
680
+ else:
681
+ message = f"Jenkins Jobs ({len(jobs_info)} total):\n\n{json.dumps(jobs_info, indent=2)}"
682
+
683
+ return [
684
+ types.TextContent(
685
+ type="text",
686
+ text=message
687
+ )
688
+ ]
689
+
690
+
691
+ async def _tool_get_job_details(client, args):
692
+ """Get detailed job information"""
693
+ job_name = args.get("job_name")
694
+
695
+ if not job_name:
696
+ raise ValueError("Missing required argument: job_name")
697
+
698
+ job_info = client.get_job_info(job_name)
699
+
700
+ details = {
701
+ "name": job_info.get("name", job_name),
702
+ "url": job_info.get("url", ""),
703
+ "description": job_info.get("description", ""),
704
+ "buildable": job_info.get("buildable", False),
705
+ "lastBuild": job_info.get("lastBuild", {}),
706
+ "lastSuccessfulBuild": job_info.get("lastSuccessfulBuild", {}),
707
+ "lastFailedBuild": job_info.get("lastFailedBuild", {}),
708
+ }
709
+
710
+ # Add recent builds
711
+ if "builds" in job_info:
712
+ recent_builds = []
713
+ for build in job_info["builds"][:5]:
714
+ try:
715
+ build_info = client.get_build_info(job_name, build["number"])
716
+ recent_builds.append({
717
+ "number": build_info.get("number"),
718
+ "result": build_info.get("result"),
719
+ "timestamp": build_info.get("timestamp"),
720
+ "duration_seconds": build_info.get("duration", 0) / 1000,
721
+ })
722
+ except Exception as e:
723
+ logger.warning(f"Could not fetch build {build['number']}: {e}")
724
+
725
+ details["recentBuilds"] = recent_builds
726
+
727
+ # Notify of resource changes
728
+ try:
729
+ await server.request_context.session.send_resource_list_changed()
730
+ except Exception:
731
+ pass
732
+
733
+ return [
734
+ types.TextContent(
735
+ type="text",
736
+ text=f"Job details for '{job_name}':\n\n{json.dumps(details, indent=2)}"
737
+ )
738
+ ]
739
+
740
+
741
+ # Build Information
742
+
743
+ async def _tool_get_build_info(client, args):
744
+ """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")
750
+
751
+ build_info = client.get_build_info(job_name, build_number)
752
+
753
+ formatted_info = {
754
+ "number": build_info.get("number"),
755
+ "result": build_info.get("result"),
756
+ "timestamp": build_info.get("timestamp"),
757
+ "duration_seconds": build_info.get("duration", 0) / 1000,
758
+ "url": build_info.get("url"),
759
+ "building": build_info.get("building", False),
760
+ }
761
+
762
+ # Add change information if available
763
+ if "changeSet" in build_info and "items" in build_info["changeSet"]:
764
+ changes = [
765
+ {
766
+ "author": change.get("author", {}).get("fullName", "Unknown"),
767
+ "comment": change.get("comment", ""),
768
+ }
769
+ for change in build_info["changeSet"]["items"]
770
+ ]
771
+ formatted_info["changes"] = changes
772
+
773
+ return [
774
+ types.TextContent(
775
+ type="text",
776
+ text=f"Build info for {job_name} #{build_number}:\n\n{json.dumps(formatted_info, indent=2)}"
777
+ )
778
+ ]
779
+
780
+
781
+ 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")
785
+
786
+ if not job_name or build_number is None:
787
+ raise ValueError("Missing required arguments: job_name and build_number")
788
+
789
+ console_output = client.get_build_console_output(job_name, build_number)
790
+
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)"
795
+
796
+ return [
797
+ types.TextContent(
798
+ type="text",
799
+ text=f"Console output for {job_name} #{build_number}:\n\n```\n{console_output}\n```"
800
+ )
801
+ ]
802
+
803
+
804
+ async def _tool_get_last_build_number(client, args):
805
+ """Get last build number"""
806
+ job_name = args.get("job_name")
807
+ if not job_name:
808
+ raise ValueError("Missing required argument: job_name")
809
+
810
+ num = client.get_last_build_number(job_name)
811
+ return [types.TextContent(type="text", text=f"Last build number for '{job_name}': {num}")]
812
+
813
+
814
+ async def _tool_get_last_build_timestamp(client, args):
815
+ """Get last build timestamp"""
816
+ job_name = args.get("job_name")
817
+ if not job_name:
818
+ raise ValueError("Missing required argument: job_name")
819
+
820
+ ts = client.get_last_build_timestamp(job_name)
821
+ return [types.TextContent(type="text", text=f"Last build timestamp for '{job_name}': {ts}")]
822
+
823
+
824
+ # Job Management
825
+
826
+ async def _tool_create_job(client, args):
827
+ """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")
833
+
834
+ client.create_job(job_name, config_xml)
835
+ return [types.TextContent(type="text", text=f"Successfully created job '{job_name}'")]
836
+
837
+
838
+ async def _tool_create_job_from_copy(client, args):
839
+ """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")
845
+
846
+ client.create_job_from_copy(new_job_name, source_job_name)
847
+ return [types.TextContent(type="text", text=f"Successfully created job '{new_job_name}' from '{source_job_name}'")]
848
+
849
+
850
+ async def _tool_create_job_from_data(client, args):
851
+ """Create job from data"""
852
+ job_name = args.get("job_name")
853
+ config_data = args.get("config_data")
854
+ root_tag = args.get("root_tag", "project")
855
+
856
+ if not job_name or config_data is None:
857
+ raise ValueError("Missing required arguments: job_name and config_data")
858
+
859
+ client.create_job_from_dict(job_name, config_data, root_tag)
860
+ return [types.TextContent(type="text", text=f"Successfully created job '{job_name}' from data")]
861
+
862
+
863
+ async def _tool_delete_job(client, args):
864
+ """Delete a job"""
865
+ job_name = args.get("job_name")
866
+ if not job_name:
867
+ raise ValueError("Missing required argument: job_name")
868
+
869
+ client.delete_job(job_name)
870
+ return [types.TextContent(type="text", text=f"Successfully deleted job '{job_name}'")]
871
+
872
+
873
+ async def _tool_enable_job(client, args):
874
+ """Enable a job"""
875
+ job_name = args.get("job_name")
876
+ if not job_name:
877
+ raise ValueError("Missing required argument: job_name")
878
+
879
+ client.enable_job(job_name)
880
+ return [types.TextContent(type="text", text=f"Successfully enabled job '{job_name}'")]
881
+
882
+
883
+ async def _tool_disable_job(client, args):
884
+ """Disable a job"""
885
+ job_name = args.get("job_name")
886
+ if not job_name:
887
+ raise ValueError("Missing required argument: job_name")
888
+
889
+ client.disable_job(job_name)
890
+ return [types.TextContent(type="text", text=f"Successfully disabled job '{job_name}'")]
891
+
892
+
893
+ async def _tool_rename_job(client, args):
894
+ """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")
900
+
901
+ client.rename_job(job_name, new_name)
902
+ return [types.TextContent(type="text", text=f"Successfully renamed job '{job_name}' to '{new_name}'")]
903
+
904
+
905
+ # Job Configuration
906
+
907
+ async def _tool_get_job_config(client, args):
908
+ """Get job configuration"""
909
+ job_name = args.get("job_name")
910
+ if not job_name:
911
+ raise ValueError("Missing required argument: job_name")
912
+
913
+ config = client.get_job_config(job_name)
914
+ return [types.TextContent(type="text", text=config)]
915
+
916
+
917
+ async def _tool_update_job_config(client, args):
918
+ """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")
924
+
925
+ client.update_job_config(job_name, config_xml)
926
+ return [types.TextContent(type="text", text=f"Successfully updated config for job '{job_name}'")]
927
+
928
+
929
+ # System Information
930
+
931
+ async def _tool_get_queue_info(client, args):
932
+ """Get build queue information"""
933
+ queue_items = client.get_queue_info()
934
+
935
+ if not queue_items:
936
+ return [types.TextContent(type="text", text="Jenkins build queue is empty.")]
937
+
938
+ formatted_queue = [
939
+ {
940
+ "id": item.get("id"),
941
+ "job": item.get("task", {}).get("name", "Unknown"),
942
+ "inQueueSince": item.get("inQueueSince"),
943
+ "why": item.get("why", "Unknown reason"),
944
+ "blocked": item.get("blocked", False),
945
+ }
946
+ for item in queue_items
947
+ ]
948
+
949
+ return [
950
+ types.TextContent(
951
+ type="text",
952
+ text=f"Jenkins build queue ({len(formatted_queue)} items):\n\n{json.dumps(formatted_queue, indent=2)}"
953
+ )
954
+ ]
955
+
956
+
957
+ async def _tool_list_nodes(client, args):
958
+ """List all Jenkins nodes"""
959
+ nodes = client.get_nodes()
960
+
961
+ nodes_info = [
962
+ {
963
+ "name": node.get("displayName"),
964
+ "description": node.get("description", ""),
965
+ "offline": node.get("offline", False),
966
+ "executors": node.get("numExecutors", 0),
967
+ }
968
+ for node in nodes
969
+ ]
970
+
971
+ return [
972
+ types.TextContent(
973
+ type="text",
974
+ text=f"Jenkins nodes/agents ({len(nodes_info)} total):\n\n{json.dumps(nodes_info, indent=2)}"
975
+ )
976
+ ]
977
+
978
+
979
+ async def _tool_get_node_info(client, args):
980
+ """Get node information"""
981
+ node_name = args.get("node_name")
982
+
983
+ if not node_name:
984
+ raise ValueError("Missing required argument: node_name")
985
+
986
+ node_info = client.get_node_info(node_name)
987
+
988
+ formatted_info = {
989
+ "name": node_info.get("displayName"),
990
+ "description": node_info.get("description"),
991
+ "offline": node_info.get("offline", False),
992
+ "temporarilyOffline": node_info.get("temporarilyOffline", False),
993
+ "offlineCause": str(node_info.get("offlineCauseReason", "")),
994
+ "executors": node_info.get("numExecutors", 0),
995
+ }
996
+
997
+ return [
998
+ types.TextContent(
999
+ type="text",
1000
+ text=f"Information for node '{node_name}':\n\n{json.dumps(formatted_info, indent=2)}"
1001
+ )
1002
+ ]
1003
+
1004
+
1005
+ # ==================== Main Server Entry Point ====================
1006
+
1007
+ async def main():
1008
+ """Run the Jenkins MCP server"""
1009
+ try:
1010
+ # Verify settings are configured
1011
+ settings = get_settings()
1012
+ if not settings.is_configured:
1013
+ logger.error("Jenkins settings not configured!")
1014
+ sys.exit(1)
1015
+
1016
+ logger.info(f"Starting Jenkins MCP Server v1.0.0")
1017
+ logger.info(f"Connected to: {settings.url}")
1018
+
1019
+ # Run the server using stdin/stdout streams
1020
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
1021
+ await server.run(
1022
+ read_stream,
1023
+ write_stream,
1024
+ InitializationOptions(
1025
+ server_name="jenkins-mcp-server",
1026
+ server_version="1.0.0",
1027
+ capabilities=server.get_capabilities(
1028
+ notification_options=NotificationOptions(),
1029
+ experimental_capabilities={},
1030
+ ),
1031
+ ),
1032
+ )
1033
+ except KeyboardInterrupt:
1034
+ logger.info("Server stopped by user")
1035
+ except Exception as e:
1036
+ logger.error(f"Server error: {e}", exc_info=True)
1037
+ sys.exit(1)