@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.
- package/README.md +89 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +65 -0
- package/dist/jenkins-client.d.ts +29 -0
- package/dist/jenkins-client.js +212 -0
- package/dist/tools/builds.d.ts +4 -0
- package/dist/tools/builds.js +207 -0
- package/dist/tools/discovery.d.ts +4 -0
- package/dist/tools/discovery.js +110 -0
- package/dist/tools/jobs.d.ts +4 -0
- package/dist/tools/jobs.js +78 -0
- package/dist/tools/pipeline.d.ts +4 -0
- package/dist/tools/pipeline.js +208 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.js +1 -0
- package/dist/utils/formatters.d.ts +12 -0
- package/dist/utils/formatters.js +166 -0
- package/dist/utils/path-resolver.d.ts +17 -0
- package/dist/utils/path-resolver.js +36 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Jenkins MCP Server
|
|
2
|
+
|
|
3
|
+
A custom MCP (Model Context Protocol) server for Jenkins integration with Claude Code. Provides 18 tools for comprehensive Jenkins management including pipeline replay, stage-level logs, job configuration editing, and multibranch pipeline support.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
The official Jenkins MCP plugin has timeout issues (30s timeouts). This standalone server connects directly to Jenkins REST API and works reliably.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
npm run build
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
### Claude Code
|
|
19
|
+
|
|
20
|
+
Add to your `.claude/settings.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"jenkins": {
|
|
26
|
+
"command": "node",
|
|
27
|
+
"args": ["/path/to/jenkins-mcp/dist/index.js"],
|
|
28
|
+
"env": {
|
|
29
|
+
"JENKINS_URL": "https://your-jenkins.example.com",
|
|
30
|
+
"JENKINS_USER": "your-username",
|
|
31
|
+
"JENKINS_API_TOKEN": "your-api-token"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Environment Variables
|
|
39
|
+
|
|
40
|
+
| Variable | Required | Description |
|
|
41
|
+
|----------|----------|-------------|
|
|
42
|
+
| `JENKINS_URL` | Yes | Jenkins server URL |
|
|
43
|
+
| `JENKINS_USER` | Yes | Jenkins username |
|
|
44
|
+
| `JENKINS_API_TOKEN` | Yes | Jenkins API token (User > Configure > API Token) |
|
|
45
|
+
|
|
46
|
+
## Tools (18)
|
|
47
|
+
|
|
48
|
+
### Job Management
|
|
49
|
+
- **getJobs** — List jobs in a folder with status
|
|
50
|
+
- **getJob** — Job details, parameters, health, branches
|
|
51
|
+
- **getJobConfig** — Get job XML configuration (config.xml)
|
|
52
|
+
- **updateJobConfig** — Update job XML configuration
|
|
53
|
+
|
|
54
|
+
### Build Operations
|
|
55
|
+
- **triggerBuild** — Trigger builds with optional parameters
|
|
56
|
+
- **getBuild** — Build details (status, duration, changes)
|
|
57
|
+
- **getBuildLog** — Console output with tail/pagination
|
|
58
|
+
- **stopBuild** — Abort a running build
|
|
59
|
+
- **getBuildArtifacts** — List build artifacts
|
|
60
|
+
- **getBuildTestResults** — Test results with failure details
|
|
61
|
+
|
|
62
|
+
### Pipeline
|
|
63
|
+
- **getPipelineStages** — Stage overview (names, status, duration)
|
|
64
|
+
- **getStageLog** — Log for a specific pipeline stage
|
|
65
|
+
- **getPipelineScript** — Get Jenkinsfile from replay page
|
|
66
|
+
- **replayBuild** — Replay build with optional script changes
|
|
67
|
+
- **restartFromStage** — Restart pipeline from a specific stage
|
|
68
|
+
|
|
69
|
+
### Discovery
|
|
70
|
+
- **searchBuildLogs** — Search logs across recent builds
|
|
71
|
+
- **getQueue** — View the build queue
|
|
72
|
+
- **enableDisableJob** — Enable or disable a job
|
|
73
|
+
|
|
74
|
+
## Multibranch Pipelines
|
|
75
|
+
|
|
76
|
+
Job paths use `/` as separator: `folder/pipeline/branch`
|
|
77
|
+
|
|
78
|
+
For branches with slashes in the name, use `::` separator:
|
|
79
|
+
```
|
|
80
|
+
my-pipeline::feature/my-branch
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- **Replay** uses Jenkins' form-based replay endpoint (not a clean REST API)
|
|
86
|
+
- **Restart from Stage** requires the Declarative Pipeline plugin
|
|
87
|
+
- **Config editing** works with raw XML — use getJobConfig to read, modify, then updateJobConfig
|
|
88
|
+
- Build logs are capped at 100KB per response; use pagination for larger logs
|
|
89
|
+
- CSRF crumbs are handled automatically
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { JenkinsClient } from "./jenkins-client.js";
|
|
5
|
+
import { registerJobTools } from "./tools/jobs.js";
|
|
6
|
+
import { registerBuildTools } from "./tools/builds.js";
|
|
7
|
+
import { registerPipelineTools } from "./tools/pipeline.js";
|
|
8
|
+
import { registerDiscoveryTools } from "./tools/discovery.js";
|
|
9
|
+
// Validate environment variables
|
|
10
|
+
const JENKINS_URL = process.env.JENKINS_URL;
|
|
11
|
+
const JENKINS_USER = process.env.JENKINS_USER;
|
|
12
|
+
const JENKINS_API_TOKEN = process.env.JENKINS_API_TOKEN;
|
|
13
|
+
if (!JENKINS_URL || !JENKINS_USER || !JENKINS_API_TOKEN) {
|
|
14
|
+
console.error("Missing required environment variables. Please set:\n" +
|
|
15
|
+
" JENKINS_URL - Jenkins server URL (e.g., https://jenkins.example.com)\n" +
|
|
16
|
+
" JENKINS_USER - Jenkins username\n" +
|
|
17
|
+
" JENKINS_API_TOKEN - Jenkins API token (generate at User > Configure > API Token)");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
// Create Jenkins client
|
|
21
|
+
const client = new JenkinsClient({
|
|
22
|
+
url: JENKINS_URL,
|
|
23
|
+
user: JENKINS_USER,
|
|
24
|
+
token: JENKINS_API_TOKEN,
|
|
25
|
+
});
|
|
26
|
+
// Create MCP server
|
|
27
|
+
const server = new McpServer({
|
|
28
|
+
name: "jenkins-mcp-server",
|
|
29
|
+
version: "1.0.0",
|
|
30
|
+
});
|
|
31
|
+
// Tool registration helper that wraps the McpServer.tool() API
|
|
32
|
+
function register(name, description, schema, handler) {
|
|
33
|
+
// The MCP SDK expects a ZodRawShape (object with zod fields), not a ZodObject
|
|
34
|
+
// We need to extract the shape from our z.object() schemas
|
|
35
|
+
const zodObject = schema;
|
|
36
|
+
server.tool(name, description, zodObject.shape, async (args) => {
|
|
37
|
+
try {
|
|
38
|
+
return await handler(args);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: `Unexpected error: ${msg}` }],
|
|
44
|
+
isError: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Register all tools
|
|
50
|
+
registerJobTools(client, register);
|
|
51
|
+
registerBuildTools(client, register);
|
|
52
|
+
registerPipelineTools(client, register);
|
|
53
|
+
registerDiscoveryTools(client, register);
|
|
54
|
+
// Start server
|
|
55
|
+
async function main() {
|
|
56
|
+
const transport = new StdioServerTransport();
|
|
57
|
+
await server.connect(transport);
|
|
58
|
+
console.error("Jenkins MCP server started successfully.");
|
|
59
|
+
console.error(`Connected to: ${JENKINS_URL}`);
|
|
60
|
+
console.error(`User: ${JENKINS_USER}`);
|
|
61
|
+
}
|
|
62
|
+
main().catch((e) => {
|
|
63
|
+
console.error("Failed to start Jenkins MCP server:", e);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JenkinsConfig } from "./types.js";
|
|
2
|
+
export declare class JenkinsClient {
|
|
3
|
+
private config;
|
|
4
|
+
private authHeader;
|
|
5
|
+
private crumb;
|
|
6
|
+
constructor(config: JenkinsConfig);
|
|
7
|
+
private fetchCrumb;
|
|
8
|
+
private getHeaders;
|
|
9
|
+
private handleResponse;
|
|
10
|
+
private throwJenkinsError;
|
|
11
|
+
get(jobPath: string, suffix: string, params?: Record<string, string>): Promise<unknown>;
|
|
12
|
+
getRaw(jobPath: string, suffix: string): Promise<string>;
|
|
13
|
+
getAbsolute(path: string, params?: Record<string, string>): Promise<unknown>;
|
|
14
|
+
getRawAbsolute(path: string): Promise<string>;
|
|
15
|
+
post(jobPath: string, suffix: string, body?: string, contentType?: string): Promise<{
|
|
16
|
+
response: Response;
|
|
17
|
+
data: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
postForm(jobPath: string, suffix: string, formData: URLSearchParams): Promise<{
|
|
20
|
+
response: Response;
|
|
21
|
+
data: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
postAbsolute(path: string, body?: string, contentType?: string): Promise<{
|
|
24
|
+
response: Response;
|
|
25
|
+
data: unknown;
|
|
26
|
+
}>;
|
|
27
|
+
private safeParseBody;
|
|
28
|
+
get baseUrl(): string;
|
|
29
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { buildJenkinsUrl } from "./utils/path-resolver.js";
|
|
2
|
+
export class JenkinsClient {
|
|
3
|
+
config;
|
|
4
|
+
authHeader;
|
|
5
|
+
crumb = null;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = {
|
|
8
|
+
...config,
|
|
9
|
+
url: config.url.replace(/\/+$/, ""),
|
|
10
|
+
};
|
|
11
|
+
this.authHeader =
|
|
12
|
+
"Basic " +
|
|
13
|
+
Buffer.from(`${config.user}:${config.token}`).toString("base64");
|
|
14
|
+
}
|
|
15
|
+
async fetchCrumb() {
|
|
16
|
+
try {
|
|
17
|
+
const resp = await fetch(`${this.config.url}/crumbIssuer/api/json`, {
|
|
18
|
+
headers: { Authorization: this.authHeader },
|
|
19
|
+
});
|
|
20
|
+
if (resp.ok) {
|
|
21
|
+
const data = (await resp.json());
|
|
22
|
+
this.crumb = {
|
|
23
|
+
field: data.crumbRequestField,
|
|
24
|
+
value: data.crumb,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// If crumb issuer is not available (404), CSRF is disabled — that's fine
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Crumb fetch failed — proceed without it
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
getHeaders(extra = {}) {
|
|
34
|
+
const headers = {
|
|
35
|
+
Authorization: this.authHeader,
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
if (this.crumb) {
|
|
39
|
+
headers[this.crumb.field] = this.crumb.value;
|
|
40
|
+
}
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
43
|
+
async handleResponse(resp) {
|
|
44
|
+
if (resp.ok) {
|
|
45
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
46
|
+
if (contentType.includes("json")) {
|
|
47
|
+
return resp.json();
|
|
48
|
+
}
|
|
49
|
+
return resp.text();
|
|
50
|
+
}
|
|
51
|
+
await this.throwJenkinsError(resp);
|
|
52
|
+
}
|
|
53
|
+
async throwJenkinsError(resp) {
|
|
54
|
+
let message;
|
|
55
|
+
try {
|
|
56
|
+
message = await resp.text();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
message = resp.statusText;
|
|
60
|
+
}
|
|
61
|
+
let errorCode;
|
|
62
|
+
switch (resp.status) {
|
|
63
|
+
case 401:
|
|
64
|
+
errorCode = "AUTH_FAILED";
|
|
65
|
+
message =
|
|
66
|
+
"Authentication failed. Check JENKINS_USER and JENKINS_API_TOKEN.";
|
|
67
|
+
break;
|
|
68
|
+
case 403:
|
|
69
|
+
errorCode = "FORBIDDEN";
|
|
70
|
+
message = `Access denied. The user may lack permissions for this operation.`;
|
|
71
|
+
break;
|
|
72
|
+
case 404:
|
|
73
|
+
errorCode = "NOT_FOUND";
|
|
74
|
+
message = `Resource not found. Check that the job path is correct.`;
|
|
75
|
+
break;
|
|
76
|
+
case 500:
|
|
77
|
+
errorCode = "SERVER_ERROR";
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
errorCode = "HTTP_ERROR";
|
|
81
|
+
}
|
|
82
|
+
const error = {
|
|
83
|
+
statusCode: resp.status,
|
|
84
|
+
message,
|
|
85
|
+
errorCode,
|
|
86
|
+
};
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
async get(jobPath, suffix, params) {
|
|
90
|
+
const url = new URL(buildJenkinsUrl(this.config.url, jobPath, suffix));
|
|
91
|
+
if (params) {
|
|
92
|
+
for (const [key, value] of Object.entries(params)) {
|
|
93
|
+
url.searchParams.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const resp = await fetch(url.toString(), {
|
|
97
|
+
headers: this.getHeaders(),
|
|
98
|
+
});
|
|
99
|
+
return this.handleResponse(resp);
|
|
100
|
+
}
|
|
101
|
+
async getRaw(jobPath, suffix) {
|
|
102
|
+
const url = buildJenkinsUrl(this.config.url, jobPath, suffix);
|
|
103
|
+
const resp = await fetch(url, {
|
|
104
|
+
headers: this.getHeaders(),
|
|
105
|
+
});
|
|
106
|
+
if (!resp.ok) {
|
|
107
|
+
await this.throwJenkinsError(resp);
|
|
108
|
+
}
|
|
109
|
+
return resp.text();
|
|
110
|
+
}
|
|
111
|
+
async getAbsolute(path, params) {
|
|
112
|
+
const url = new URL(`${this.config.url}${path}`);
|
|
113
|
+
if (params) {
|
|
114
|
+
for (const [key, value] of Object.entries(params)) {
|
|
115
|
+
url.searchParams.set(key, value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const resp = await fetch(url.toString(), {
|
|
119
|
+
headers: this.getHeaders(),
|
|
120
|
+
});
|
|
121
|
+
return this.handleResponse(resp);
|
|
122
|
+
}
|
|
123
|
+
async getRawAbsolute(path) {
|
|
124
|
+
const url = `${this.config.url}${path}`;
|
|
125
|
+
const resp = await fetch(url, {
|
|
126
|
+
headers: this.getHeaders(),
|
|
127
|
+
});
|
|
128
|
+
if (!resp.ok) {
|
|
129
|
+
await this.throwJenkinsError(resp);
|
|
130
|
+
}
|
|
131
|
+
return resp.text();
|
|
132
|
+
}
|
|
133
|
+
async post(jobPath, suffix, body, contentType) {
|
|
134
|
+
if (!this.crumb) {
|
|
135
|
+
await this.fetchCrumb();
|
|
136
|
+
}
|
|
137
|
+
const url = buildJenkinsUrl(this.config.url, jobPath, suffix);
|
|
138
|
+
const headers = this.getHeaders(contentType ? { "Content-Type": contentType } : {});
|
|
139
|
+
const resp = await fetch(url, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers,
|
|
142
|
+
body: body ?? undefined,
|
|
143
|
+
});
|
|
144
|
+
// If we get a 403 and have a crumb, try refreshing it
|
|
145
|
+
if (resp.status === 403 && this.crumb) {
|
|
146
|
+
this.crumb = null;
|
|
147
|
+
await this.fetchCrumb();
|
|
148
|
+
const retryHeaders = this.getHeaders(contentType ? { "Content-Type": contentType } : {});
|
|
149
|
+
const retryResp = await fetch(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: retryHeaders,
|
|
152
|
+
body: body ?? undefined,
|
|
153
|
+
});
|
|
154
|
+
if (!retryResp.ok) {
|
|
155
|
+
await this.throwJenkinsError(retryResp);
|
|
156
|
+
}
|
|
157
|
+
return { response: retryResp, data: await this.safeParseBody(retryResp) };
|
|
158
|
+
}
|
|
159
|
+
if (!resp.ok) {
|
|
160
|
+
await this.throwJenkinsError(resp);
|
|
161
|
+
}
|
|
162
|
+
return { response: resp, data: await this.safeParseBody(resp) };
|
|
163
|
+
}
|
|
164
|
+
async postForm(jobPath, suffix, formData) {
|
|
165
|
+
return this.post(jobPath, suffix, formData.toString(), "application/x-www-form-urlencoded");
|
|
166
|
+
}
|
|
167
|
+
async postAbsolute(path, body, contentType) {
|
|
168
|
+
if (!this.crumb) {
|
|
169
|
+
await this.fetchCrumb();
|
|
170
|
+
}
|
|
171
|
+
const url = `${this.config.url}${path}`;
|
|
172
|
+
const headers = this.getHeaders(contentType ? { "Content-Type": contentType } : {});
|
|
173
|
+
const resp = await fetch(url, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers,
|
|
176
|
+
body: body ?? undefined,
|
|
177
|
+
});
|
|
178
|
+
if (resp.status === 403 && this.crumb) {
|
|
179
|
+
this.crumb = null;
|
|
180
|
+
await this.fetchCrumb();
|
|
181
|
+
const retryHeaders = this.getHeaders(contentType ? { "Content-Type": contentType } : {});
|
|
182
|
+
const retryResp = await fetch(url, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: retryHeaders,
|
|
185
|
+
body: body ?? undefined,
|
|
186
|
+
});
|
|
187
|
+
if (!retryResp.ok) {
|
|
188
|
+
await this.throwJenkinsError(retryResp);
|
|
189
|
+
}
|
|
190
|
+
return { response: retryResp, data: await this.safeParseBody(retryResp) };
|
|
191
|
+
}
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
await this.throwJenkinsError(resp);
|
|
194
|
+
}
|
|
195
|
+
return { response: resp, data: await this.safeParseBody(resp) };
|
|
196
|
+
}
|
|
197
|
+
async safeParseBody(resp) {
|
|
198
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
199
|
+
try {
|
|
200
|
+
if (contentType.includes("json")) {
|
|
201
|
+
return await resp.json();
|
|
202
|
+
}
|
|
203
|
+
return await resp.text();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
get baseUrl() {
|
|
210
|
+
return this.config.url;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { JenkinsClient } from "../jenkins-client.js";
|
|
3
|
+
import type { ToolResult } from "../types.js";
|
|
4
|
+
export declare function registerBuildTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void): void;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatBuild, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
|
+
export function registerBuildTools(client, register) {
|
|
4
|
+
// 5. triggerBuild
|
|
5
|
+
register("triggerBuild", "Trigger a new build for a Jenkins job. Supports parameterized builds. For multibranch pipelines, trigger on a specific branch. Returns the queue item URL for tracking.", z.object({
|
|
6
|
+
jobPath: z.string().describe("Full job path (e.g., 'my-folder/my-job' or 'pipeline/main')"),
|
|
7
|
+
parameters: z.record(z.string(), z.string()).optional().describe("Build parameters as key-value pairs (e.g., {\"BRANCH\": \"main\", \"DEPLOY\": \"true\"})"),
|
|
8
|
+
}), async (args) => {
|
|
9
|
+
const jobPath = args.jobPath;
|
|
10
|
+
const parameters = args.parameters;
|
|
11
|
+
try {
|
|
12
|
+
let result;
|
|
13
|
+
if (parameters && Object.keys(parameters).length > 0) {
|
|
14
|
+
const formData = new URLSearchParams();
|
|
15
|
+
const json = JSON.stringify({ parameter: Object.entries(parameters).map(([name, value]) => ({ name, value })) });
|
|
16
|
+
formData.set("json", json);
|
|
17
|
+
// Also set individual params for compatibility
|
|
18
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
19
|
+
formData.set(key, value);
|
|
20
|
+
}
|
|
21
|
+
result = await client.postForm(jobPath, "/buildWithParameters", formData);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
result = await client.post(jobPath, "/build");
|
|
25
|
+
}
|
|
26
|
+
const location = result.response.headers.get("location");
|
|
27
|
+
if (location) {
|
|
28
|
+
// Extract queue ID from location header
|
|
29
|
+
const match = location.match(/\/queue\/item\/(\d+)/);
|
|
30
|
+
if (match) {
|
|
31
|
+
return ok(`Build triggered successfully.\nQueue item: #${match[1]}\nTrack at: ${location}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return ok(`Build triggered successfully for ${jobPath}.`);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return handleError(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
// 6. getBuild
|
|
41
|
+
register("getBuild", "Get detailed information about a specific build including status, duration, trigger cause, artifacts, and changes. Defaults to the last build if no number specified.", z.object({
|
|
42
|
+
jobPath: z.string().describe("Full job path"),
|
|
43
|
+
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
44
|
+
}), async (args) => {
|
|
45
|
+
const jobPath = args.jobPath;
|
|
46
|
+
const buildNumber = args.buildNumber;
|
|
47
|
+
const num = buildNumber ?? "lastBuild";
|
|
48
|
+
try {
|
|
49
|
+
const tree = "number,url,result,building,duration,estimatedDuration,timestamp,displayName,description,fullDisplayName,actions[causes[shortDescription,userName]],artifacts[displayPath,fileName,relativePath],changeSets[items[msg,author[fullName],commitId]]";
|
|
50
|
+
const data = await client.get(jobPath, `/${num}/api/json`, { tree });
|
|
51
|
+
return ok(formatBuild(data));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return handleError(e);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// 7. getBuildLog
|
|
58
|
+
register("getBuildLog", "Get console output of a Jenkins build. By default returns the last 200 lines (tail). Use 'maxLines' to control output size. For large logs, use 'startByte' for byte-offset pagination. Returns hasMore flag and nextStart for follow-up calls.", z.object({
|
|
59
|
+
jobPath: z.string().describe("Full job path"),
|
|
60
|
+
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
61
|
+
maxLines: z.number().optional().default(200).describe("Maximum lines to return (default: 200)"),
|
|
62
|
+
startByte: z.number().optional().describe("Byte offset to start from (for pagination). Use nextStart from previous response."),
|
|
63
|
+
}), async (args) => {
|
|
64
|
+
const jobPath = args.jobPath;
|
|
65
|
+
const buildNumber = args.buildNumber;
|
|
66
|
+
const maxLines = args.maxLines || 200;
|
|
67
|
+
const startByte = args.startByte;
|
|
68
|
+
const num = buildNumber ?? "lastBuild";
|
|
69
|
+
try {
|
|
70
|
+
if (startByte !== undefined) {
|
|
71
|
+
// Progressive log with byte offset
|
|
72
|
+
const url = `/${num}/logText/progressiveText`;
|
|
73
|
+
const data = await client.get(jobPath, url, { start: String(startByte) });
|
|
74
|
+
const text = typeof data === "string" ? data : String(data);
|
|
75
|
+
// Note: progressiveText returns X-Text-Size and X-More-Data headers
|
|
76
|
+
// but we can't easily access them through our client. Return what we have.
|
|
77
|
+
return ok(truncateText(text));
|
|
78
|
+
}
|
|
79
|
+
// Full console text, then tail
|
|
80
|
+
const text = await client.getRaw(jobPath, `/${num}/consoleText`);
|
|
81
|
+
const lines = text.split("\n");
|
|
82
|
+
const totalLines = lines.length;
|
|
83
|
+
let output;
|
|
84
|
+
let hasMore = false;
|
|
85
|
+
if (lines.length > maxLines) {
|
|
86
|
+
// Tail from end
|
|
87
|
+
const start = lines.length - maxLines;
|
|
88
|
+
output = lines.slice(start).join("\n");
|
|
89
|
+
hasMore = true;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
output = text;
|
|
93
|
+
}
|
|
94
|
+
const meta = [
|
|
95
|
+
`--- Build Log (${totalLines} total lines, showing last ${Math.min(maxLines, totalLines)}) ---`,
|
|
96
|
+
];
|
|
97
|
+
if (hasMore) {
|
|
98
|
+
meta.push(`[Has more content. ${totalLines - maxLines} earlier lines not shown. Increase maxLines or use startByte for full log.]`);
|
|
99
|
+
}
|
|
100
|
+
meta.push("");
|
|
101
|
+
return ok(truncateText(meta.join("\n") + output));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return handleError(e);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
// 8. stopBuild
|
|
108
|
+
register("stopBuild", "Abort/stop a running Jenkins build.", z.object({
|
|
109
|
+
jobPath: z.string().describe("Full job path"),
|
|
110
|
+
buildNumber: z.number().describe("Build number to stop"),
|
|
111
|
+
}), async (args) => {
|
|
112
|
+
const jobPath = args.jobPath;
|
|
113
|
+
const buildNumber = args.buildNumber;
|
|
114
|
+
try {
|
|
115
|
+
await client.post(jobPath, `/${buildNumber}/stop`);
|
|
116
|
+
return ok(`Build #${buildNumber} stop signal sent for ${jobPath}.`);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return handleError(e);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// 9. getBuildArtifacts
|
|
123
|
+
register("getBuildArtifacts", "List build artifacts with their file names and download paths.", z.object({
|
|
124
|
+
jobPath: z.string().describe("Full job path"),
|
|
125
|
+
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
126
|
+
}), async (args) => {
|
|
127
|
+
const jobPath = args.jobPath;
|
|
128
|
+
const buildNumber = args.buildNumber;
|
|
129
|
+
const num = buildNumber ?? "lastBuild";
|
|
130
|
+
try {
|
|
131
|
+
const data = await client.get(jobPath, `/${num}/api/json`, {
|
|
132
|
+
tree: "number,url,artifacts[displayPath,fileName,relativePath]",
|
|
133
|
+
});
|
|
134
|
+
const build = data;
|
|
135
|
+
if (!build.artifacts || build.artifacts.length === 0) {
|
|
136
|
+
return ok(`No artifacts found for build #${build.number}.`);
|
|
137
|
+
}
|
|
138
|
+
const lines = [`Artifacts for build #${build.number} (${build.artifacts.length}):`];
|
|
139
|
+
for (const a of build.artifacts) {
|
|
140
|
+
lines.push(` - ${a.fileName} (${a.relativePath})`);
|
|
141
|
+
lines.push(` Download: ${build.url}artifact/${a.relativePath}`);
|
|
142
|
+
}
|
|
143
|
+
return ok(lines.join("\n"));
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return handleError(e);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
// 10. getBuildTestResults
|
|
150
|
+
register("getBuildTestResults", "Get test results for a build. By default shows only failures. Set onlyFailures=false to see all tests.", z.object({
|
|
151
|
+
jobPath: z.string().describe("Full job path"),
|
|
152
|
+
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
153
|
+
onlyFailures: z.boolean().optional().default(true).describe("Show only failing tests (default: true)"),
|
|
154
|
+
}), async (args) => {
|
|
155
|
+
const jobPath = args.jobPath;
|
|
156
|
+
const buildNumber = args.buildNumber;
|
|
157
|
+
const onlyFailures = args.onlyFailures ?? true;
|
|
158
|
+
const num = buildNumber ?? "lastBuild";
|
|
159
|
+
try {
|
|
160
|
+
const data = await client.get(jobPath, `/${num}/testReport/api/json`, {
|
|
161
|
+
tree: "failCount,passCount,skipCount,totalCount,suites[name,cases[className,name,status,duration,errorDetails,errorStackTrace]]",
|
|
162
|
+
});
|
|
163
|
+
const results = data;
|
|
164
|
+
const lines = [
|
|
165
|
+
`Test Results for build #${num}:`,
|
|
166
|
+
` Total: ${results.totalCount} Passed: ${results.passCount} Failed: ${results.failCount} Skipped: ${results.skipCount}`,
|
|
167
|
+
];
|
|
168
|
+
if (results.suites) {
|
|
169
|
+
const allCases = results.suites.flatMap((s) => s.cases);
|
|
170
|
+
const filtered = onlyFailures
|
|
171
|
+
? allCases.filter((c) => c.status === "FAILED" || c.status === "REGRESSION")
|
|
172
|
+
: allCases;
|
|
173
|
+
if (filtered.length > 0) {
|
|
174
|
+
lines.push(`\n${onlyFailures ? "Failed" : "All"} Tests (${filtered.length}):`);
|
|
175
|
+
for (const tc of filtered.slice(0, 100)) {
|
|
176
|
+
lines.push(` ${tc.status} ${tc.className}.${tc.name} (${tc.duration.toFixed(2)}s)`);
|
|
177
|
+
if (tc.errorDetails) {
|
|
178
|
+
lines.push(` Error: ${tc.errorDetails.substring(0, 500)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (filtered.length > 100) {
|
|
182
|
+
lines.push(` ... and ${filtered.length - 100} more`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (onlyFailures) {
|
|
186
|
+
lines.push("\nNo test failures.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ok(lines.join("\n"));
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
// 404 often means no test results
|
|
193
|
+
if (e && typeof e === "object" && "statusCode" in e && e.statusCode === 404) {
|
|
194
|
+
return ok(`No test results found for build #${num}. The build may not have any test reports.`);
|
|
195
|
+
}
|
|
196
|
+
return handleError(e);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function handleError(e) {
|
|
201
|
+
if (e && typeof e === "object" && "errorCode" in e) {
|
|
202
|
+
const je = e;
|
|
203
|
+
return error(`[${je.errorCode}] ${je.message}`);
|
|
204
|
+
}
|
|
205
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
206
|
+
return error(msg);
|
|
207
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { JenkinsClient } from "../jenkins-client.js";
|
|
3
|
+
import type { ToolResult } from "../types.js";
|
|
4
|
+
export declare function registerDiscoveryTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void): void;
|