@alexsarrell/jenkins-mcp-server 1.0.1 → 1.3.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.
@@ -1,5 +1,40 @@
1
1
  import { z } from "zod";
2
2
  import { formatJobList, formatJobDetail, ok, error, truncateText } from "../utils/formatters.js";
3
+ import { mapJenkinsParameter } from "../utils/parameter-mapper.js";
4
+ import { parseJobConfig, buildJobXml } from "../utils/job-xml.js";
5
+ import { diffJobSpecs } from "../utils/job-diff.js";
6
+ import { parameterSpec } from "../schemas/parameter.js";
7
+ const pipelineSpecSchema = z.object({
8
+ type: z.literal("pipeline"),
9
+ description: z.string().optional(),
10
+ disabled: z.boolean().optional(),
11
+ scm: z.object({
12
+ type: z.literal("git"),
13
+ url: z.string(),
14
+ branches: z.array(z.string()).optional(),
15
+ credentialsId: z.string().optional(),
16
+ jenkinsfilePath: z.string().optional(),
17
+ }),
18
+ triggers: z.object({ cron: z.string().optional() }).optional(),
19
+ parameters: z.array(parameterSpec).optional(),
20
+ buildRetention: z.object({ numToKeep: z.number().optional(), daysToKeep: z.number().optional() }).optional(),
21
+ });
22
+ const multibranchSpecSchema = z.object({
23
+ type: z.literal("multibranch"),
24
+ description: z.string().optional(),
25
+ source: z.object({
26
+ type: z.literal("git"),
27
+ url: z.string(),
28
+ credentialsId: z.string().optional(),
29
+ }),
30
+ jenkinsfilePath: z.string().optional(),
31
+ orphanedItemStrategy: z.object({ numToKeep: z.number().optional(), daysToKeep: z.number().optional() }).optional(),
32
+ });
33
+ const folderSpecSchema = z.object({
34
+ type: z.literal("folder"),
35
+ description: z.string().optional(),
36
+ });
37
+ const jobSpecSchema = z.discriminatedUnion("type", [pipelineSpecSchema, multibranchSpecSchema, folderSpecSchema]);
3
38
  export function registerJobTools(client, register, allowUnsafe) {
4
39
  // 1. getJobs
5
40
  register("getJobs", "List Jenkins jobs at root level or in a specific folder. Returns job names, status, and last build info. For multibranch pipelines, lists branches as sub-jobs.", z.object({
@@ -52,6 +87,73 @@ export function registerJobTools(client, register, allowUnsafe) {
52
87
  return handleError(e);
53
88
  }
54
89
  });
90
+ // 3b. getJobParameters
91
+ register("getJobParameters", "Get the structured parameter schema for a Jenkins job. Returns each parameter's type (string/choice/boolean/password/file/run/credentials/unknown), default, choices, and description as JSON. Use this instead of getJob when you need to know what to pass to triggerBuild.", z.object({
92
+ jobPath: z.string().describe("Full job path"),
93
+ }), async (args) => {
94
+ const jobPath = args.jobPath;
95
+ try {
96
+ const tree = "property[parameterDefinitions[name,type,description,defaultParameterValue[value],choices,projectName,credentialType,_class]]";
97
+ const data = await client.get(jobPath, "/api/json", { tree });
98
+ const job = data;
99
+ const defs = (job.property ?? []).flatMap((p) => p.parameterDefinitions ?? []);
100
+ const parameters = defs.map(mapJenkinsParameter);
101
+ return ok(JSON.stringify({ parameters }, null, 2));
102
+ }
103
+ catch (e) {
104
+ return handleError(e);
105
+ }
106
+ });
107
+ // 3c. describeJob
108
+ register("describeJob", "Get a structured view of a job's config (SCM url/branch, Jenkinsfile path, parameters, retention, cron) without parsing raw XML. Reports unrecognised XML elements via 'unknownXmlElements'. Read-only and safe.", z.object({
109
+ jobPath: z.string().describe("Full job path"),
110
+ }), async (args) => {
111
+ const jobPath = args.jobPath;
112
+ try {
113
+ const xml = await client.getRaw(jobPath, "/config.xml");
114
+ const desc = parseJobConfig(xml);
115
+ return ok(JSON.stringify(desc, null, 2));
116
+ }
117
+ catch (e) {
118
+ return handleError(e);
119
+ }
120
+ });
121
+ // 3d. previewJobConfig
122
+ register("previewJobConfig", "Generate Jenkins job XML config from a structured spec. Optionally diff against an existing job's current config (read-only — does not modify Jenkins). Use the resulting XML with updateJobConfig (gated by JENKINS_ALLOW_UNSAFE_OPERATIONS) once it looks right.", z.object({
123
+ spec: jobSpecSchema,
124
+ diffAgainstJobPath: z.string().optional().describe("If set, fetch the current config of this job and emit a structured diff."),
125
+ }), async (args) => {
126
+ const spec = args.spec;
127
+ const diffPath = args.diffAgainstJobPath;
128
+ try {
129
+ const xml = buildJobXml(spec);
130
+ const out = [`=== Generated config.xml (${spec.type}) ===`, xml];
131
+ if (diffPath) {
132
+ const currentXml = await client.getRaw(diffPath, "/config.xml");
133
+ const before = parseJobConfig(currentXml);
134
+ const after = parseJobConfig(xml);
135
+ const changes = diffJobSpecs(before, after);
136
+ out.push(`\n=== Diff against ${diffPath} ===`);
137
+ if (changes.length === 0) {
138
+ out.push("(no structural changes)");
139
+ }
140
+ else {
141
+ for (const c of changes) {
142
+ out.push(`@@ ${c.path} @@`);
143
+ out.push(`- ${c.before}`);
144
+ out.push(`+ ${c.after}`);
145
+ }
146
+ }
147
+ if (before.unknownXmlElements.length > 0) {
148
+ out.push(`\n(skipped: ${before.unknownXmlElements.join(", ")})`);
149
+ }
150
+ }
151
+ return ok(out.join("\n"));
152
+ }
153
+ catch (e) {
154
+ return handleError(e);
155
+ }
156
+ });
55
157
  // 4. updateJobConfig (unsafe — requires JENKINS_ALLOW_UNSAFE_OPERATIONS=true)
56
158
  if (allowUnsafe) {
57
159
  register("updateJobConfig", "Update a Jenkins job's XML configuration. Send the complete config.xml content. Use getJobConfig first to read the current config, modify it, then submit.", z.object({
package/dist/types.d.ts CHANGED
@@ -60,6 +60,11 @@ export interface JenkinsBuild {
60
60
  shortDescription: string;
61
61
  userName?: string;
62
62
  }>;
63
+ parameters?: Array<{
64
+ _class?: string;
65
+ name: string;
66
+ value?: string | boolean | number;
67
+ }>;
63
68
  }>;
64
69
  artifacts?: BuildArtifact[];
65
70
  changeSets?: Array<{
@@ -128,6 +133,21 @@ export interface QueueItem {
128
133
  stuck: boolean;
129
134
  blocked: boolean;
130
135
  }
136
+ export interface QueueItemDetail {
137
+ id: number;
138
+ task: {
139
+ name: string;
140
+ url: string;
141
+ };
142
+ why: string | null;
143
+ cancelled?: boolean;
144
+ executable?: {
145
+ number: number;
146
+ url: string;
147
+ };
148
+ _class?: string;
149
+ }
150
+ export type QueueItemState = "WAITING" | "BLOCKED" | "BUILDABLE" | "LEFT_QUEUE" | "CANCELLED" | "UNKNOWN";
131
151
  export interface JenkinsError {
132
152
  statusCode: number;
133
153
  message: string;
@@ -0,0 +1,10 @@
1
+ export interface JsonParameter {
2
+ name: string;
3
+ value: string | string[];
4
+ }
5
+ export interface TriggerPayload {
6
+ formData: URLSearchParams;
7
+ jsonParameters: JsonParameter[];
8
+ }
9
+ export type ParameterValue = string | string[];
10
+ export declare function buildTriggerPayload(parameters: Record<string, ParameterValue>, splitOnComma: boolean): TriggerPayload;
@@ -0,0 +1,19 @@
1
+ export function buildTriggerPayload(parameters, splitOnComma) {
2
+ const formData = new URLSearchParams();
3
+ const jsonParameters = [];
4
+ for (const [name, raw] of Object.entries(parameters)) {
5
+ const values = Array.isArray(raw)
6
+ ? raw
7
+ : splitOnComma && raw.includes(",")
8
+ ? raw.split(",").map((v) => v.trim())
9
+ : [raw];
10
+ for (const v of values) {
11
+ formData.append(name, v);
12
+ }
13
+ jsonParameters.push({
14
+ name,
15
+ value: values.length === 1 ? values[0] : values,
16
+ });
17
+ }
18
+ return { formData, jsonParameters };
19
+ }
@@ -1,4 +1,4 @@
1
- import type { JenkinsJob, JenkinsBuild, PipelineStage, QueueItem, ToolResult } from "../types.js";
1
+ import type { JenkinsJob, JenkinsBuild, PipelineStage, QueueItem, QueueItemDetail, ToolResult } from "../types.js";
2
2
  export declare function truncateText(text: string, maxBytes?: number): string;
3
3
  export declare function formatJobStatus(color: string): string;
4
4
  export declare function formatTimestamp(ts: number): string;
@@ -10,3 +10,4 @@ export declare function formatStages(stages: PipelineStage[]): string;
10
10
  export declare function formatQueue(items: QueueItem[]): string;
11
11
  export declare function ok(text: string): ToolResult;
12
12
  export declare function error(message: string, suggestion?: string): ToolResult;
13
+ export declare function formatQueueItem(item: QueueItemDetail): string;
@@ -116,6 +116,17 @@ export function formatBuild(build) {
116
116
  if (causes && causes.length > 0) {
117
117
  lines.push(`Triggered by: ${causes.join(", ")}`);
118
118
  }
119
+ // Parameters
120
+ const parametersAction = build.actions?.find((a) => a.parameters && a.parameters.length > 0);
121
+ if (parametersAction?.parameters) {
122
+ lines.push(`\nParameters (${parametersAction.parameters.length}):`);
123
+ for (const p of parametersAction.parameters) {
124
+ // Loose substring match — covers PasswordParameterValue and most plugin variants.
125
+ const isPassword = (p._class || "").toLowerCase().includes("password");
126
+ const display = isPassword ? "[hidden]" : String(p.value ?? "");
127
+ lines.push(` ${p.name} = ${display}`);
128
+ }
129
+ }
119
130
  // Artifacts
120
131
  if (build.artifacts && build.artifacts.length > 0) {
121
132
  lines.push(`\nArtifacts (${build.artifacts.length}):`);
@@ -164,3 +175,32 @@ export function error(message, suggestion) {
164
175
  text += `\nSuggestion: ${suggestion}`;
165
176
  return { content: [{ type: "text", text }], isError: true };
166
177
  }
178
+ function classifyQueueItem(item) {
179
+ if (item.cancelled)
180
+ return "CANCELLED";
181
+ const cls = item._class || "";
182
+ if (cls.endsWith("$WaitingItem"))
183
+ return "WAITING";
184
+ if (cls.endsWith("$BlockedItem"))
185
+ return "BLOCKED";
186
+ if (cls.endsWith("$BuildableItem"))
187
+ return "BUILDABLE";
188
+ if (cls.endsWith("$LeftItem"))
189
+ return "LEFT_QUEUE";
190
+ if (cls.endsWith("$CancelledItem"))
191
+ return "CANCELLED";
192
+ return "UNKNOWN";
193
+ }
194
+ export function formatQueueItem(item) {
195
+ const state = classifyQueueItem(item);
196
+ const lines = [`Queue item #${item.id}: ${state}`];
197
+ lines.push(`Task: ${item.task.name}`);
198
+ if (state === "LEFT_QUEUE" && item.executable) {
199
+ lines.push(`Build started: ${item.task.name} #${item.executable.number}`);
200
+ lines.push(`URL: ${item.executable.url}`);
201
+ }
202
+ else if (item.why) {
203
+ lines.push(`Why: ${item.why}`);
204
+ }
205
+ return lines.join("\n");
206
+ }
@@ -0,0 +1,7 @@
1
+ import type { JobDescription } from "./job-xml.js";
2
+ export interface FieldChange {
3
+ path: string;
4
+ before: string;
5
+ after: string;
6
+ }
7
+ export declare function diffJobSpecs(before: JobDescription, after: JobDescription): FieldChange[];
@@ -0,0 +1,29 @@
1
+ function flatten(obj, prefix) {
2
+ if (obj === null || obj === undefined)
3
+ return { [prefix]: "(unset)" };
4
+ if (typeof obj !== "object")
5
+ return { [prefix]: String(obj) };
6
+ if (Array.isArray(obj))
7
+ return { [prefix]: JSON.stringify(obj) };
8
+ const out = {};
9
+ for (const [k, v] of Object.entries(obj)) {
10
+ const next = prefix ? `${prefix}.${k}` : k;
11
+ Object.assign(out, flatten(v, next));
12
+ }
13
+ return out;
14
+ }
15
+ export function diffJobSpecs(before, after) {
16
+ const a = flatten(before, "");
17
+ const b = flatten(after, "");
18
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
19
+ const changes = [];
20
+ for (const k of keys) {
21
+ if (k === "rawConfigSha" || k.startsWith("unknownXmlElements"))
22
+ continue;
23
+ const left = a[k] ?? "(unset)";
24
+ const right = b[k] ?? "(unset)";
25
+ if (left !== right)
26
+ changes.push({ path: k, before: left, after: right });
27
+ }
28
+ return changes.sort((x, y) => x.path.localeCompare(y.path));
29
+ }
@@ -0,0 +1,66 @@
1
+ import type { ParameterSpec } from "../schemas/parameter.js";
2
+ export interface PipelineSpec {
3
+ type: "pipeline";
4
+ description?: string;
5
+ disabled?: boolean;
6
+ scm: {
7
+ type: "git";
8
+ url: string;
9
+ branches?: string[];
10
+ credentialsId?: string;
11
+ jenkinsfilePath?: string;
12
+ };
13
+ triggers?: {
14
+ cron?: string;
15
+ };
16
+ parameters?: ParameterSpec[];
17
+ buildRetention?: {
18
+ numToKeep?: number;
19
+ daysToKeep?: number;
20
+ };
21
+ }
22
+ export interface MultibranchSpec {
23
+ type: "multibranch";
24
+ description?: string;
25
+ source: {
26
+ type: "git";
27
+ url: string;
28
+ credentialsId?: string;
29
+ };
30
+ jenkinsfilePath?: string;
31
+ orphanedItemStrategy?: {
32
+ numToKeep?: number;
33
+ daysToKeep?: number;
34
+ };
35
+ }
36
+ export interface FolderSpec {
37
+ type: "folder";
38
+ description?: string;
39
+ }
40
+ export type JobSpec = PipelineSpec | MultibranchSpec | FolderSpec;
41
+ export interface JobDescription {
42
+ type: "pipeline" | "multibranch" | "freestyle" | "folder" | "unknown";
43
+ description?: string;
44
+ disabled: boolean;
45
+ concurrentBuilds: boolean;
46
+ scm?: {
47
+ type: "git" | "unknown";
48
+ url?: string;
49
+ branches?: string[];
50
+ credentialsId?: string;
51
+ jenkinsfilePath?: string;
52
+ };
53
+ triggers?: {
54
+ cron?: string;
55
+ scmPolling?: string;
56
+ };
57
+ parameters?: ParameterSpec[];
58
+ buildRetention?: {
59
+ numToKeep?: number;
60
+ daysToKeep?: number;
61
+ };
62
+ unknownXmlElements: string[];
63
+ rawConfigSha: string;
64
+ }
65
+ export declare function parseJobConfig(xml: string): JobDescription;
66
+ export declare function buildJobXml(spec: JobSpec): string;