@alexsarrell/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,166 @@
1
+ const MAX_RESPONSE_BYTES = 100_000;
2
+ export function truncateText(text, maxBytes = MAX_RESPONSE_BYTES) {
3
+ if (Buffer.byteLength(text, "utf8") <= maxBytes)
4
+ return text;
5
+ // Binary search for safe cut point
6
+ let low = 0;
7
+ let high = text.length;
8
+ while (low < high) {
9
+ const mid = Math.ceil((low + high) / 2);
10
+ if (Buffer.byteLength(text.substring(0, mid), "utf8") <= maxBytes - 200) {
11
+ low = mid;
12
+ }
13
+ else {
14
+ high = mid - 1;
15
+ }
16
+ }
17
+ return text.substring(0, low) + "\n\n... [TRUNCATED - response exceeded 100KB. Use pagination or search to get specific parts.]";
18
+ }
19
+ export function formatJobStatus(color) {
20
+ const statusMap = {
21
+ blue: "SUCCESS",
22
+ red: "FAILURE",
23
+ yellow: "UNSTABLE",
24
+ grey: "NOT_BUILT",
25
+ disabled: "DISABLED",
26
+ aborted: "ABORTED",
27
+ notbuilt: "NOT_BUILT",
28
+ blue_anime: "RUNNING (was SUCCESS)",
29
+ red_anime: "RUNNING (was FAILURE)",
30
+ yellow_anime: "RUNNING (was UNSTABLE)",
31
+ grey_anime: "RUNNING",
32
+ };
33
+ if (!color)
34
+ return "UNKNOWN";
35
+ return statusMap[color] || color.toUpperCase();
36
+ }
37
+ export function formatTimestamp(ts) {
38
+ return new Date(ts).toISOString();
39
+ }
40
+ export function formatDuration(ms) {
41
+ if (ms < 1000)
42
+ return `${ms}ms`;
43
+ const seconds = Math.floor(ms / 1000);
44
+ if (seconds < 60)
45
+ return `${seconds}s`;
46
+ const minutes = Math.floor(seconds / 60);
47
+ const secs = seconds % 60;
48
+ if (minutes < 60)
49
+ return `${minutes}m ${secs}s`;
50
+ const hours = Math.floor(minutes / 60);
51
+ const mins = minutes % 60;
52
+ return `${hours}h ${mins}m`;
53
+ }
54
+ export function formatJobList(jobs) {
55
+ if (jobs.length === 0)
56
+ return "No jobs found.";
57
+ const lines = jobs.map((job) => {
58
+ const status = formatJobStatus(job.color);
59
+ const lastBuild = job.lastBuild
60
+ ? `#${job.lastBuild.number} (${job.lastBuild.result || "RUNNING"})`
61
+ : "no builds";
62
+ const desc = job.description ? ` - ${job.description}` : "";
63
+ return ` ${status} ${job.name} [${lastBuild}]${desc}`;
64
+ });
65
+ return `Jobs (${jobs.length}):\n${lines.join("\n")}`;
66
+ }
67
+ export function formatJobDetail(job) {
68
+ const lines = [];
69
+ lines.push(`Job: ${job.fullName || job.name}`);
70
+ lines.push(`Status: ${formatJobStatus(job.color)}`);
71
+ if (job.description)
72
+ lines.push(`Description: ${job.description}`);
73
+ lines.push(`Buildable: ${job.buildable ?? "unknown"}`);
74
+ lines.push(`URL: ${job.url}`);
75
+ if (job.healthReport && job.healthReport.length > 0) {
76
+ lines.push(`Health: ${job.healthReport.map((h) => `${h.score}% - ${h.description}`).join("; ")}`);
77
+ }
78
+ if (job.lastBuild) {
79
+ lines.push(`Last Build: #${job.lastBuild.number} (${job.lastBuild.result || "RUNNING"}) at ${formatTimestamp(job.lastBuild.timestamp)}`);
80
+ }
81
+ // Parameter definitions
82
+ const paramProp = job.property?.find((p) => p.parameterDefinitions && p.parameterDefinitions.length > 0);
83
+ if (paramProp?.parameterDefinitions) {
84
+ lines.push("\nParameters:");
85
+ for (const param of paramProp.parameterDefinitions) {
86
+ const def = param.defaultParameterValue?.value ?? "(no default)";
87
+ lines.push(` - ${param.name} (${param.type}): ${param.description || "no description"} [default: ${def}]`);
88
+ }
89
+ }
90
+ // Sub-jobs (for folders/multibranch)
91
+ if (job.jobs && job.jobs.length > 0) {
92
+ lines.push(`\nBranches/Sub-jobs (${job.jobs.length}):`);
93
+ for (const sub of job.jobs.slice(0, 50)) {
94
+ lines.push(` ${formatJobStatus(sub.color)} ${sub.name}`);
95
+ }
96
+ if (job.jobs.length > 50) {
97
+ lines.push(` ... and ${job.jobs.length - 50} more`);
98
+ }
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+ export function formatBuild(build) {
103
+ const lines = [];
104
+ lines.push(`Build: ${build.fullDisplayName || `#${build.number}`}`);
105
+ lines.push(`Result: ${build.building ? "RUNNING" : build.result || "UNKNOWN"}`);
106
+ lines.push(`Duration: ${formatDuration(build.duration || 0)}${build.building ? ` (estimated: ${formatDuration(build.estimatedDuration)})` : ""}`);
107
+ lines.push(`Started: ${formatTimestamp(build.timestamp)}`);
108
+ if (build.description)
109
+ lines.push(`Description: ${build.description}`);
110
+ lines.push(`URL: ${build.url}`);
111
+ // Build causes
112
+ const causes = build.actions
113
+ ?.filter((a) => a.causes)
114
+ .flatMap((a) => a.causes)
115
+ .map((c) => c.shortDescription);
116
+ if (causes && causes.length > 0) {
117
+ lines.push(`Triggered by: ${causes.join(", ")}`);
118
+ }
119
+ // Artifacts
120
+ if (build.artifacts && build.artifacts.length > 0) {
121
+ lines.push(`\nArtifacts (${build.artifacts.length}):`);
122
+ for (const a of build.artifacts) {
123
+ lines.push(` - ${a.fileName} (${a.relativePath})`);
124
+ }
125
+ }
126
+ // Changes
127
+ if (build.changeSets) {
128
+ const allChanges = build.changeSets.flatMap((cs) => cs.items);
129
+ if (allChanges.length > 0) {
130
+ lines.push(`\nChanges (${allChanges.length}):`);
131
+ for (const c of allChanges.slice(0, 20)) {
132
+ lines.push(` - ${c.commitId.substring(0, 8)} ${c.author.fullName}: ${c.msg}`);
133
+ }
134
+ }
135
+ }
136
+ return lines.join("\n");
137
+ }
138
+ export function formatStages(stages) {
139
+ if (stages.length === 0)
140
+ return "No pipeline stages found.";
141
+ const lines = ["Pipeline Stages:"];
142
+ for (const stage of stages) {
143
+ const status = stage.status === "SUCCESS" ? "OK" : stage.status;
144
+ lines.push(` ${status} ${stage.name} [${formatDuration(stage.durationMillis)}]`);
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+ export function formatQueue(items) {
149
+ if (items.length === 0)
150
+ return "Build queue is empty.";
151
+ const lines = [`Build Queue (${items.length} items):`];
152
+ for (const item of items) {
153
+ const status = item.stuck ? "STUCK" : item.blocked ? "BLOCKED" : "WAITING";
154
+ lines.push(` #${item.id} ${item.task.name} [${status}]${item.why ? ` - ${item.why}` : ""}`);
155
+ }
156
+ return lines.join("\n");
157
+ }
158
+ export function ok(text) {
159
+ return { content: [{ type: "text", text: truncateText(text) }] };
160
+ }
161
+ export function error(message, suggestion) {
162
+ let text = `Error: ${message}`;
163
+ if (suggestion)
164
+ text += `\nSuggestion: ${suggestion}`;
165
+ return { content: [{ type: "text", text }], isError: true };
166
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Converts a human-readable job path like "my-folder/my-pipeline/feature/my-branch"
3
+ * into Jenkins URL path segments like "/job/my-folder/job/my-pipeline/job/feature%2Fmy-branch".
4
+ *
5
+ * For multibranch pipelines, users provide paths as: folder/pipeline/branch
6
+ * Branch names containing "/" are encoded as %2F in a single path segment.
7
+ *
8
+ * Since we can't always know which segments are folders vs branch names without
9
+ * querying Jenkins, we treat each "/" as a job separator by default.
10
+ * Users dealing with branches containing "/" should use the special "::" separator:
11
+ * "my-folder/my-pipeline::feature/my-branch"
12
+ */
13
+ export declare function resolveJobPath(jobPath: string): string;
14
+ /**
15
+ * Builds a full Jenkins API URL from base URL, job path, and optional suffix.
16
+ */
17
+ export declare function buildJenkinsUrl(baseUrl: string, jobPath: string, suffix?: string): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Converts a human-readable job path like "my-folder/my-pipeline/feature/my-branch"
3
+ * into Jenkins URL path segments like "/job/my-folder/job/my-pipeline/job/feature%2Fmy-branch".
4
+ *
5
+ * For multibranch pipelines, users provide paths as: folder/pipeline/branch
6
+ * Branch names containing "/" are encoded as %2F in a single path segment.
7
+ *
8
+ * Since we can't always know which segments are folders vs branch names without
9
+ * querying Jenkins, we treat each "/" as a job separator by default.
10
+ * Users dealing with branches containing "/" should use the special "::" separator:
11
+ * "my-folder/my-pipeline::feature/my-branch"
12
+ */
13
+ export function resolveJobPath(jobPath) {
14
+ // Handle the :: separator for branch names with slashes
15
+ const branchSepIndex = jobPath.indexOf("::");
16
+ let segments;
17
+ if (branchSepIndex !== -1) {
18
+ const prefix = jobPath.substring(0, branchSepIndex);
19
+ const branchName = jobPath.substring(branchSepIndex + 2);
20
+ segments = [...prefix.split("/").filter(Boolean), branchName];
21
+ }
22
+ else {
23
+ segments = jobPath.split("/").filter(Boolean);
24
+ }
25
+ return segments
26
+ .map((segment) => `/job/${encodeURIComponent(segment)}`)
27
+ .join("");
28
+ }
29
+ /**
30
+ * Builds a full Jenkins API URL from base URL, job path, and optional suffix.
31
+ */
32
+ export function buildJenkinsUrl(baseUrl, jobPath, suffix = "") {
33
+ const base = baseUrl.replace(/\/+$/, "");
34
+ const resolvedPath = resolveJobPath(jobPath);
35
+ return `${base}${resolvedPath}${suffix}`;
36
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@alexsarrell/jenkins-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "Jenkins MCP server for Claude Code integration",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "jenkins-mcp-server": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "start": "node dist/index.js"
13
+ },
14
+ "type": "module",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "keywords": [
19
+ "jenkins",
20
+ "mcp",
21
+ "claude"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.27.1",
26
+ "zod": "^4.3.6"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.5.0",
30
+ "typescript": "^5.9.3"
31
+ }
32
+ }