@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.
- package/README.md +27 -9
- package/dist/index.js +5 -1
- package/dist/jenkins-client.d.ts +6 -0
- package/dist/jenkins-client.js +28 -0
- package/dist/schemas/parameter.d.ts +48 -0
- package/dist/schemas/parameter.js +57 -0
- package/dist/tools/builds.js +69 -22
- package/dist/tools/discovery.js +151 -41
- package/dist/tools/jobs.js +102 -0
- package/dist/types.d.ts +20 -0
- package/dist/utils/build-payload.d.ts +10 -0
- package/dist/utils/build-payload.js +19 -0
- package/dist/utils/formatters.d.ts +2 -1
- package/dist/utils/formatters.js +40 -0
- package/dist/utils/job-diff.d.ts +7 -0
- package/dist/utils/job-diff.js +29 -0
- package/dist/utils/job-xml.d.ts +66 -0
- package/dist/utils/job-xml.js +415 -0
- package/dist/utils/log-grep.d.ts +20 -0
- package/dist/utils/log-grep.js +43 -0
- package/dist/utils/parameter-mapper.d.ts +13 -0
- package/dist/utils/parameter-mapper.js +70 -0
- package/package.json +13 -3
package/dist/tools/jobs.js
CHANGED
|
@@ -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;
|
package/dist/utils/formatters.js
CHANGED
|
@@ -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,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;
|