@alexsarrell/jenkins-mcp-server 1.0.0 → 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 +84 -41
- package/dist/index.js +9 -3
- 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.d.ts +1 -1
- package/dist/tools/jobs.js +112 -8
- package/dist/tools/pipeline.d.ts +1 -1
- package/dist/tools/pipeline.js +36 -34
- 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/README.md
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @alexsarrell/jenkins-mcp-server v1.3.0
|
|
2
2
|
|
|
3
|
-
A custom MCP (Model Context Protocol) server for Jenkins integration with Claude Code. Provides
|
|
3
|
+
A custom MCP (Model Context Protocol) server for Jenkins integration with Claude Code. Provides 22 tools (20 default + 2 unsafe) for comprehensive Jenkins management including pipeline replay, stage-level logs, structured config reads, parameter introspection, and rich log search.
|
|
4
4
|
|
|
5
5
|
## Why?
|
|
6
6
|
|
|
7
|
-
The official Jenkins MCP plugin has timeout issues (30s timeouts). This standalone server connects directly to Jenkins REST API and works reliably.
|
|
7
|
+
The official Jenkins MCP plugin has timeout issues (30s timeouts). This standalone server connects directly to Jenkins REST API via stdio transport and works reliably.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Quick Start
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
npm install
|
|
13
|
-
npm run build
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Configuration
|
|
17
|
-
|
|
18
|
-
### Claude Code
|
|
19
|
-
|
|
20
|
-
Add to your `.claude/settings.json`:
|
|
11
|
+
Add to your `~/.claude.json`:
|
|
21
12
|
|
|
22
13
|
```json
|
|
23
14
|
{
|
|
24
15
|
"mcpServers": {
|
|
25
16
|
"jenkins": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
17
|
+
"type": "stdio",
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "@alexsarrell/jenkins-mcp-server"],
|
|
28
20
|
"env": {
|
|
29
21
|
"JENKINS_URL": "https://your-jenkins.example.com",
|
|
30
22
|
"JENKINS_USER": "your-username",
|
|
@@ -35,41 +27,73 @@ Add to your `.claude/settings.json`:
|
|
|
35
27
|
}
|
|
36
28
|
```
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
Restart Claude Code and the `jenkins` MCP server will be available.
|
|
31
|
+
|
|
32
|
+
### Getting Your API Token
|
|
33
|
+
|
|
34
|
+
1. Log in to Jenkins
|
|
35
|
+
2. Click your username (top right) -> Configure
|
|
36
|
+
3. API Token -> Add new Token -> Generate
|
|
37
|
+
4. Copy the token
|
|
38
|
+
|
|
39
|
+
## Environment Variables
|
|
39
40
|
|
|
40
41
|
| Variable | Required | Description |
|
|
41
42
|
|----------|----------|-------------|
|
|
42
43
|
| `JENKINS_URL` | Yes | Jenkins server URL |
|
|
43
44
|
| `JENKINS_USER` | Yes | Jenkins username |
|
|
44
|
-
| `JENKINS_API_TOKEN` | Yes | Jenkins API token
|
|
45
|
+
| `JENKINS_API_TOKEN` | Yes | Jenkins API token |
|
|
46
|
+
| `JENKINS_ALLOW_UNSAFE_OPERATIONS` | No | Set to `true` to enable unsafe tools (`replayBuild`, `updateJobConfig`) |
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
### Unsafe Operations
|
|
49
|
+
|
|
50
|
+
By default, tools that can execute arbitrary code or modify job configurations are **disabled**. This includes:
|
|
51
|
+
|
|
52
|
+
- **replayBuild** — replays a build with arbitrary Groovy/Pipeline script
|
|
53
|
+
- **updateJobConfig** — overwrites job XML configuration
|
|
54
|
+
|
|
55
|
+
To enable them, add the env variable to your MCP config:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
"env": {
|
|
59
|
+
"JENKINS_URL": "...",
|
|
60
|
+
"JENKINS_USER": "...",
|
|
61
|
+
"JENKINS_API_TOKEN": "...",
|
|
62
|
+
"JENKINS_ALLOW_UNSAFE_OPERATIONS": "true"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Tools (20 default + 2 unsafe)
|
|
47
67
|
|
|
48
68
|
### Job Management
|
|
49
|
-
- **getJobs**
|
|
50
|
-
- **getJob**
|
|
51
|
-
- **getJobConfig**
|
|
52
|
-
- **
|
|
69
|
+
- **getJobs** - List jobs in a folder with status summary
|
|
70
|
+
- **getJob** - Job details, parameters, health, branches (for multibranch pipelines)
|
|
71
|
+
- **getJobConfig** - Get job XML configuration (config.xml)
|
|
72
|
+
- **getJobParameters** - Structured `ParameterSpec[]` for the job (types, choices, defaults). Use this before `triggerBuild` to know what to pass.
|
|
73
|
+
- **describeJob** - Structured read of `config.xml` (SCM url/branch, Jenkinsfile path, parameters, cron, retention) without parsing raw XML.
|
|
74
|
+
- **previewJobConfig** - Generate a `config.xml` from a structured spec (pipeline / multibranch / folder), optionally diffed against an existing job. Read-only.
|
|
75
|
+
- **updateJobConfig** - Update job XML configuration
|
|
53
76
|
|
|
54
77
|
### Build Operations
|
|
55
|
-
- **triggerBuild**
|
|
56
|
-
- **getBuild**
|
|
57
|
-
- **getBuildLog**
|
|
58
|
-
- **stopBuild**
|
|
59
|
-
- **getBuildArtifacts**
|
|
60
|
-
- **getBuildTestResults**
|
|
78
|
+
- **triggerBuild** - Trigger builds with optional parameters (string or string[] values for multi-value parameters)
|
|
79
|
+
- **getBuild** - Build details (status, duration, parameters, artifacts, changes); use `include` to control returned sections
|
|
80
|
+
- **getBuildLog** - Console output with tail / byte-pagination / grep modes (regex + before/after context)
|
|
81
|
+
- **stopBuild** - Abort a running build
|
|
82
|
+
- **getBuildArtifacts** - List build artifacts
|
|
83
|
+
- **getBuildTestResults** - Test results with failure details
|
|
61
84
|
|
|
62
85
|
### Pipeline
|
|
63
|
-
- **getPipelineStages**
|
|
64
|
-
- **getStageLog**
|
|
65
|
-
- **getPipelineScript**
|
|
66
|
-
- **replayBuild**
|
|
67
|
-
- **restartFromStage**
|
|
68
|
-
|
|
69
|
-
### Discovery
|
|
70
|
-
- **searchBuildLogs**
|
|
71
|
-
- **getQueue**
|
|
72
|
-
- **
|
|
86
|
+
- **getPipelineStages** - Stage overview (names, status, duration)
|
|
87
|
+
- **getStageLog** - Log for a specific pipeline stage
|
|
88
|
+
- **getPipelineScript** - Get Jenkinsfile content from replay page
|
|
89
|
+
- **replayBuild** - Replay a build with optional script modifications
|
|
90
|
+
- **restartFromStage** - Restart pipeline from a specific stage
|
|
91
|
+
|
|
92
|
+
### Discovery & Utilities
|
|
93
|
+
- **searchBuildLogs** - Grep across build logs (regex, before/after context, `onlyResults` filter, progressive read)
|
|
94
|
+
- **getQueue** - View the build queue
|
|
95
|
+
- **getQueueItem** - Map a queue item ID (returned by `triggerBuild`) to the build that started, or its current waiting/cancelled state
|
|
96
|
+
- **enableDisableJob** - Enable or disable a job
|
|
73
97
|
|
|
74
98
|
## Multibranch Pipelines
|
|
75
99
|
|
|
@@ -80,10 +104,29 @@ For branches with slashes in the name, use `::` separator:
|
|
|
80
104
|
my-pipeline::feature/my-branch
|
|
81
105
|
```
|
|
82
106
|
|
|
107
|
+
## Multi-value parameters
|
|
108
|
+
|
|
109
|
+
`triggerBuild` accepts each parameter value as either a string or a string array. The string form is **never** split on commas — passing `"hello, world"` sends one value verbatim. For multi-select / `ExtendedChoiceParameter (PT_CHECKBOX)` parameters, pass an array:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
triggerBuild({ jobPath: "x", parameters: { TAGS: ["alpha", "beta", "gamma"] } });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A legacy `splitOnComma: true` flag preserves the pre-1.3 behaviour (split string values on commas) — deprecated, will be removed in v2.0.
|
|
116
|
+
|
|
117
|
+
## Log search and grep
|
|
118
|
+
|
|
119
|
+
Both `getBuildLog` and `searchBuildLogs` accept a `pattern` (with optional `regex`) and `before`/`after` context lines. `getBuildLog` switches to grep mode when `pattern` is set; otherwise it returns a tail. `searchBuildLogs` streams logs via Jenkins' progressive-text API and stops early once `maxMatchesPerBuild` is reached, and an `onlyResults: ["FAILURE"]` filter prunes builds before fetching their logs.
|
|
120
|
+
|
|
83
121
|
## Notes
|
|
84
122
|
|
|
85
123
|
- **Replay** uses Jenkins' form-based replay endpoint (not a clean REST API)
|
|
86
124
|
- **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
|
|
125
|
+
- **Config editing** works with raw XML — use `getJobConfig` (or `describeJob` for a structured subset) to read, modify, then `updateJobConfig`. For new configs from a spec, use `previewJobConfig` then paste into `updateJobConfig`.
|
|
126
|
+
- Build logs are capped at 100KB per response; use grep mode or pagination for larger logs
|
|
89
127
|
- CSRF crumbs are handled automatically
|
|
128
|
+
- Authentication uses HTTP Basic Auth with user API token
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
2
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
7
|
import { JenkinsClient } from "./jenkins-client.js";
|
|
@@ -6,6 +9,7 @@ import { registerJobTools } from "./tools/jobs.js";
|
|
|
6
9
|
import { registerBuildTools } from "./tools/builds.js";
|
|
7
10
|
import { registerPipelineTools } from "./tools/pipeline.js";
|
|
8
11
|
import { registerDiscoveryTools } from "./tools/discovery.js";
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
9
13
|
// Validate environment variables
|
|
10
14
|
const JENKINS_URL = process.env.JENKINS_URL;
|
|
11
15
|
const JENKINS_USER = process.env.JENKINS_USER;
|
|
@@ -17,6 +21,7 @@ if (!JENKINS_URL || !JENKINS_USER || !JENKINS_API_TOKEN) {
|
|
|
17
21
|
" JENKINS_API_TOKEN - Jenkins API token (generate at User > Configure > API Token)");
|
|
18
22
|
process.exit(1);
|
|
19
23
|
}
|
|
24
|
+
const ALLOW_UNSAFE = process.env.JENKINS_ALLOW_UNSAFE_OPERATIONS === "true";
|
|
20
25
|
// Create Jenkins client
|
|
21
26
|
const client = new JenkinsClient({
|
|
22
27
|
url: JENKINS_URL,
|
|
@@ -26,7 +31,7 @@ const client = new JenkinsClient({
|
|
|
26
31
|
// Create MCP server
|
|
27
32
|
const server = new McpServer({
|
|
28
33
|
name: "jenkins-mcp-server",
|
|
29
|
-
version:
|
|
34
|
+
version: pkg.version,
|
|
30
35
|
});
|
|
31
36
|
// Tool registration helper that wraps the McpServer.tool() API
|
|
32
37
|
function register(name, description, schema, handler) {
|
|
@@ -47,9 +52,9 @@ function register(name, description, schema, handler) {
|
|
|
47
52
|
});
|
|
48
53
|
}
|
|
49
54
|
// Register all tools
|
|
50
|
-
registerJobTools(client, register);
|
|
55
|
+
registerJobTools(client, register, ALLOW_UNSAFE);
|
|
51
56
|
registerBuildTools(client, register);
|
|
52
|
-
registerPipelineTools(client, register);
|
|
57
|
+
registerPipelineTools(client, register, ALLOW_UNSAFE);
|
|
53
58
|
registerDiscoveryTools(client, register);
|
|
54
59
|
// Start server
|
|
55
60
|
async function main() {
|
|
@@ -58,6 +63,7 @@ async function main() {
|
|
|
58
63
|
console.error("Jenkins MCP server started successfully.");
|
|
59
64
|
console.error(`Connected to: ${JENKINS_URL}`);
|
|
60
65
|
console.error(`User: ${JENKINS_USER}`);
|
|
66
|
+
console.error(`Unsafe operations: ${ALLOW_UNSAFE ? "enabled" : "disabled"}`);
|
|
61
67
|
}
|
|
62
68
|
main().catch((e) => {
|
|
63
69
|
console.error("Failed to start Jenkins MCP server:", e);
|
package/dist/jenkins-client.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export declare class JenkinsClient {
|
|
|
10
10
|
private throwJenkinsError;
|
|
11
11
|
get(jobPath: string, suffix: string, params?: Record<string, string>): Promise<unknown>;
|
|
12
12
|
getRaw(jobPath: string, suffix: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Stream a Jenkins console log chunk-by-chunk via /logText/progressiveText.
|
|
15
|
+
* Yields chunks until the log ends. Caller can stop iteration early.
|
|
16
|
+
* Falls back gracefully — if the endpoint returns 404, the caller should retry with /consoleText.
|
|
17
|
+
*/
|
|
18
|
+
getProgressiveText(jobPath: string, buildNumber: number | "lastBuild"): AsyncGenerator<string, void, void>;
|
|
13
19
|
getAbsolute(path: string, params?: Record<string, string>): Promise<unknown>;
|
|
14
20
|
getRawAbsolute(path: string): Promise<string>;
|
|
15
21
|
post(jobPath: string, suffix: string, body?: string, contentType?: string): Promise<{
|
package/dist/jenkins-client.js
CHANGED
|
@@ -108,6 +108,34 @@ export class JenkinsClient {
|
|
|
108
108
|
}
|
|
109
109
|
return resp.text();
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Stream a Jenkins console log chunk-by-chunk via /logText/progressiveText.
|
|
113
|
+
* Yields chunks until the log ends. Caller can stop iteration early.
|
|
114
|
+
* Falls back gracefully — if the endpoint returns 404, the caller should retry with /consoleText.
|
|
115
|
+
*/
|
|
116
|
+
async *getProgressiveText(jobPath, buildNumber) {
|
|
117
|
+
let start = 0;
|
|
118
|
+
const url = buildJenkinsUrl(this.config.url, jobPath, `/${buildNumber}/logText/progressiveText`);
|
|
119
|
+
while (true) {
|
|
120
|
+
const u = new URL(url);
|
|
121
|
+
u.searchParams.set("start", String(start));
|
|
122
|
+
const resp = await fetch(u.toString(), { headers: this.getHeaders() });
|
|
123
|
+
if (!resp.ok) {
|
|
124
|
+
await this.throwJenkinsError(resp);
|
|
125
|
+
}
|
|
126
|
+
const text = await resp.text();
|
|
127
|
+
if (text.length > 0)
|
|
128
|
+
yield text;
|
|
129
|
+
const moreData = resp.headers.get("X-More-Data");
|
|
130
|
+
const sizeHeader = resp.headers.get("X-Text-Size");
|
|
131
|
+
if (moreData !== "true")
|
|
132
|
+
return;
|
|
133
|
+
const newStart = sizeHeader ? Number(sizeHeader) : start + text.length;
|
|
134
|
+
if (newStart <= start)
|
|
135
|
+
return; // protect against infinite loop
|
|
136
|
+
start = newStart;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
111
139
|
async getAbsolute(path, params) {
|
|
112
140
|
const url = new URL(`${this.config.url}${path}`);
|
|
113
141
|
if (params) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const parameterSpec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
3
|
+
type: z.ZodLiteral<"string">;
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
default: z.ZodOptional<z.ZodString>;
|
|
6
|
+
description: z.ZodOptional<z.ZodString>;
|
|
7
|
+
trim: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
9
|
+
type: z.ZodLiteral<"text">;
|
|
10
|
+
name: z.ZodString;
|
|
11
|
+
default: z.ZodOptional<z.ZodString>;
|
|
12
|
+
description: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
type: z.ZodLiteral<"boolean">;
|
|
15
|
+
name: z.ZodString;
|
|
16
|
+
default: z.ZodOptional<z.ZodBoolean>;
|
|
17
|
+
description: z.ZodOptional<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
19
|
+
type: z.ZodLiteral<"choice">;
|
|
20
|
+
name: z.ZodString;
|
|
21
|
+
choices: z.ZodArray<z.ZodString>;
|
|
22
|
+
default: z.ZodOptional<z.ZodString>;
|
|
23
|
+
description: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
25
|
+
type: z.ZodLiteral<"password">;
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
description: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
29
|
+
type: z.ZodLiteral<"file">;
|
|
30
|
+
name: z.ZodString;
|
|
31
|
+
description: z.ZodOptional<z.ZodString>;
|
|
32
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
33
|
+
type: z.ZodLiteral<"run">;
|
|
34
|
+
name: z.ZodString;
|
|
35
|
+
projectName: z.ZodOptional<z.ZodString>;
|
|
36
|
+
description: z.ZodOptional<z.ZodString>;
|
|
37
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
38
|
+
type: z.ZodLiteral<"credentials">;
|
|
39
|
+
name: z.ZodString;
|
|
40
|
+
credentialType: z.ZodOptional<z.ZodString>;
|
|
41
|
+
description: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
43
|
+
type: z.ZodLiteral<"unknown">;
|
|
44
|
+
name: z.ZodString;
|
|
45
|
+
rawType: z.ZodString;
|
|
46
|
+
description: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>], "type">;
|
|
48
|
+
export type ParameterSpec = z.infer<typeof parameterSpec>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const parameterSpec = z.discriminatedUnion("type", [
|
|
3
|
+
z.object({
|
|
4
|
+
type: z.literal("string"),
|
|
5
|
+
name: z.string(),
|
|
6
|
+
default: z.string().optional(),
|
|
7
|
+
description: z.string().optional(),
|
|
8
|
+
trim: z.boolean().optional(),
|
|
9
|
+
}),
|
|
10
|
+
z.object({
|
|
11
|
+
type: z.literal("text"),
|
|
12
|
+
name: z.string(),
|
|
13
|
+
default: z.string().optional(),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
}),
|
|
16
|
+
z.object({
|
|
17
|
+
type: z.literal("boolean"),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
default: z.boolean().optional(),
|
|
20
|
+
description: z.string().optional(),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
type: z.literal("choice"),
|
|
24
|
+
name: z.string(),
|
|
25
|
+
choices: z.array(z.string()).min(1),
|
|
26
|
+
default: z.string().optional(),
|
|
27
|
+
description: z.string().optional(),
|
|
28
|
+
}),
|
|
29
|
+
z.object({
|
|
30
|
+
type: z.literal("password"),
|
|
31
|
+
name: z.string(),
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
z.object({
|
|
35
|
+
type: z.literal("file"),
|
|
36
|
+
name: z.string(),
|
|
37
|
+
description: z.string().optional(),
|
|
38
|
+
}),
|
|
39
|
+
z.object({
|
|
40
|
+
type: z.literal("run"),
|
|
41
|
+
name: z.string(),
|
|
42
|
+
projectName: z.string().optional(),
|
|
43
|
+
description: z.string().optional(),
|
|
44
|
+
}),
|
|
45
|
+
z.object({
|
|
46
|
+
type: z.literal("credentials"),
|
|
47
|
+
name: z.string(),
|
|
48
|
+
credentialType: z.string().optional(),
|
|
49
|
+
description: z.string().optional(),
|
|
50
|
+
}),
|
|
51
|
+
z.object({
|
|
52
|
+
type: z.literal("unknown"),
|
|
53
|
+
name: z.string(),
|
|
54
|
+
rawType: z.string(),
|
|
55
|
+
description: z.string().optional(),
|
|
56
|
+
}),
|
|
57
|
+
]);
|
package/dist/tools/builds.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatBuild, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
|
+
import { buildTriggerPayload } from "../utils/build-payload.js";
|
|
4
|
+
import { grepLog } from "../utils/log-grep.js";
|
|
3
5
|
export function registerBuildTools(client, register) {
|
|
6
|
+
const DEFAULT_GET_BUILD_INCLUDE = ["causes", "parameters", "artifacts", "changes"];
|
|
4
7
|
// 5. triggerBuild
|
|
5
8
|
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
9
|
jobPath: z.string().describe("Full job path (e.g., 'my-folder/my-job' or 'pipeline/main')"),
|
|
7
|
-
parameters: z.record(z.string(), z.string()).
|
|
10
|
+
parameters: z.record(z.string(), z.union([z.string(), z.array(z.string()).nonempty()])).optional()
|
|
11
|
+
.describe("Build parameters. String values are submitted as-is (no comma splitting). Use string[] for multi-value parameters (ExtendedChoiceParameter, multi-select)."),
|
|
12
|
+
splitOnComma: z.boolean().optional().default(false)
|
|
13
|
+
.describe("[DEPRECATED] Legacy behaviour: split comma-bearing string values into multi-value submissions. Will be removed in v2.0. Prefer string[] values instead."),
|
|
8
14
|
}), async (args) => {
|
|
9
15
|
const jobPath = args.jobPath;
|
|
10
16
|
const parameters = args.parameters;
|
|
11
17
|
try {
|
|
12
18
|
let result;
|
|
19
|
+
const splitOnComma = args.splitOnComma ?? false;
|
|
13
20
|
if (parameters && Object.keys(parameters).length > 0) {
|
|
14
|
-
const formData =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// Also set individual params for compatibility
|
|
18
|
-
for (const [key, value] of Object.entries(parameters)) {
|
|
19
|
-
formData.set(key, value);
|
|
20
|
-
}
|
|
21
|
+
const { formData, jsonParameters } = buildTriggerPayload(parameters, splitOnComma);
|
|
22
|
+
// jsonParameters is reserved for the v1.3 FILE/CREDENTIALS payload; not sent today.
|
|
23
|
+
void jsonParameters;
|
|
21
24
|
result = await client.postForm(jobPath, "/buildWithParameters", formData);
|
|
22
25
|
}
|
|
23
26
|
else {
|
|
@@ -38,16 +41,32 @@ export function registerBuildTools(client, register) {
|
|
|
38
41
|
}
|
|
39
42
|
});
|
|
40
43
|
// 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({
|
|
44
|
+
register("getBuild", "Get detailed information about a specific build including status, duration, trigger cause, parameters, artifacts, and changes. Defaults to the last build if no number specified. Use 'include' to control which optional sections are returned.", z.object({
|
|
42
45
|
jobPath: z.string().describe("Full job path"),
|
|
43
46
|
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
47
|
+
include: z.array(z.enum(["artifacts", "changes", "causes", "parameters"])).optional()
|
|
48
|
+
.describe(`Sections to include. Default: ${JSON.stringify(DEFAULT_GET_BUILD_INCLUDE)}`),
|
|
44
49
|
}), async (args) => {
|
|
45
50
|
const jobPath = args.jobPath;
|
|
46
51
|
const buildNumber = args.buildNumber;
|
|
52
|
+
const include = args.include ?? [...DEFAULT_GET_BUILD_INCLUDE];
|
|
47
53
|
const num = buildNumber ?? "lastBuild";
|
|
48
54
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
55
|
+
const treeFields = [
|
|
56
|
+
"number,url,result,building,duration,estimatedDuration,timestamp,displayName,description,fullDisplayName",
|
|
57
|
+
];
|
|
58
|
+
const actionFields = [];
|
|
59
|
+
if (include.includes("causes"))
|
|
60
|
+
actionFields.push("causes[shortDescription,userName]");
|
|
61
|
+
if (include.includes("parameters"))
|
|
62
|
+
actionFields.push("parameters[name,value,_class]");
|
|
63
|
+
if (actionFields.length > 0)
|
|
64
|
+
treeFields.push(`actions[${actionFields.join(",")}]`);
|
|
65
|
+
if (include.includes("artifacts"))
|
|
66
|
+
treeFields.push("artifacts[displayPath,fileName,relativePath]");
|
|
67
|
+
if (include.includes("changes"))
|
|
68
|
+
treeFields.push("changeSets[items[msg,author[fullName],commitId]]");
|
|
69
|
+
const data = await client.get(jobPath, `/${num}/api/json`, { tree: treeFields.join(",") });
|
|
51
70
|
return ok(formatBuild(data));
|
|
52
71
|
}
|
|
53
72
|
catch (e) {
|
|
@@ -55,37 +74,65 @@ export function registerBuildTools(client, register) {
|
|
|
55
74
|
}
|
|
56
75
|
});
|
|
57
76
|
// 7. getBuildLog
|
|
58
|
-
register("getBuildLog", "Get console output of a Jenkins build.
|
|
77
|
+
register("getBuildLog", "Get console output of a Jenkins build. Three modes: (a) tail (default — returns last `maxLines`), (b) byte-offset pagination via `startByte`, (c) grep mode if `pattern` is set (returns matches with `before`/`after` context lines). Modes are mutually exclusive — if `pattern` is set, tail/startByte are ignored.", z.object({
|
|
59
78
|
jobPath: z.string().describe("Full job path"),
|
|
60
79
|
buildNumber: z.number().optional().describe("Build number (default: last build)"),
|
|
61
|
-
maxLines: z.number().optional().default(200).describe("Maximum lines to return
|
|
62
|
-
startByte: z.number().optional().describe("Byte offset to start from
|
|
80
|
+
maxLines: z.number().optional().default(200).describe("[Tail mode] Maximum lines to return"),
|
|
81
|
+
startByte: z.number().optional().describe("[Pagination mode] Byte offset to start from"),
|
|
82
|
+
pattern: z.string().optional().describe("[Grep mode] Search pattern. Switches the tool to grep mode when set."),
|
|
83
|
+
regex: z.boolean().optional().default(false).describe("[Grep mode] Treat pattern as regex (default: case-insensitive substring)"),
|
|
84
|
+
before: z.number().optional().default(0).describe("[Grep mode] Lines of context before each match"),
|
|
85
|
+
after: z.number().optional().default(0).describe("[Grep mode] Lines of context after each match"),
|
|
86
|
+
maxMatches: z.number().optional().default(50).describe("[Grep mode] Stop after this many matches"),
|
|
63
87
|
}), async (args) => {
|
|
64
88
|
const jobPath = args.jobPath;
|
|
65
89
|
const buildNumber = args.buildNumber;
|
|
66
90
|
const maxLines = args.maxLines || 200;
|
|
67
91
|
const startByte = args.startByte;
|
|
92
|
+
const pattern = args.pattern;
|
|
93
|
+
const regex = args.regex ?? false;
|
|
94
|
+
const before = args.before ?? 0;
|
|
95
|
+
const after = args.after ?? 0;
|
|
96
|
+
const maxMatches = args.maxMatches ?? 50;
|
|
68
97
|
const num = buildNumber ?? "lastBuild";
|
|
69
98
|
try {
|
|
99
|
+
// Grep mode
|
|
100
|
+
if (pattern !== undefined) {
|
|
101
|
+
const text = await client.getRaw(jobPath, `/${num}/consoleText`);
|
|
102
|
+
let result;
|
|
103
|
+
try {
|
|
104
|
+
result = grepLog(text, { pattern, regex, before, after, maxMatches });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
108
|
+
return error(msg);
|
|
109
|
+
}
|
|
110
|
+
const header = [
|
|
111
|
+
`--- Build Log Search: pattern="${pattern}" (regex=${regex}, before=${before}, after=${after}) ---`,
|
|
112
|
+
`Total matches: ${result.matches.length}${result.truncated ? ` (truncated at maxMatches=${maxMatches})` : ""}`,
|
|
113
|
+
"",
|
|
114
|
+
];
|
|
115
|
+
const blocks = result.matches.map((m, idx) => {
|
|
116
|
+
const ctxLines = m.context.map((c) => `${String(c.lineNumber).padStart(6)}: ${c.text}`);
|
|
117
|
+
return `=== match #${idx + 1} (line ${m.lineNumber}) ===\n${ctxLines.join("\n")}`;
|
|
118
|
+
});
|
|
119
|
+
return ok(truncateText(header.join("\n") + blocks.join("\n\n")));
|
|
120
|
+
}
|
|
121
|
+
// Pagination mode
|
|
70
122
|
if (startByte !== undefined) {
|
|
71
|
-
// Progressive log with byte offset
|
|
72
123
|
const url = `/${num}/logText/progressiveText`;
|
|
73
124
|
const data = await client.get(jobPath, url, { start: String(startByte) });
|
|
74
125
|
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
126
|
return ok(truncateText(text));
|
|
78
127
|
}
|
|
79
|
-
//
|
|
128
|
+
// Tail mode (default)
|
|
80
129
|
const text = await client.getRaw(jobPath, `/${num}/consoleText`);
|
|
81
130
|
const lines = text.split("\n");
|
|
82
131
|
const totalLines = lines.length;
|
|
83
132
|
let output;
|
|
84
133
|
let hasMore = false;
|
|
85
134
|
if (lines.length > maxLines) {
|
|
86
|
-
|
|
87
|
-
const start = lines.length - maxLines;
|
|
88
|
-
output = lines.slice(start).join("\n");
|
|
135
|
+
output = lines.slice(lines.length - maxLines).join("\n");
|
|
89
136
|
hasMore = true;
|
|
90
137
|
}
|
|
91
138
|
else {
|
|
@@ -95,7 +142,7 @@ export function registerBuildTools(client, register) {
|
|
|
95
142
|
`--- Build Log (${totalLines} total lines, showing last ${Math.min(maxLines, totalLines)}) ---`,
|
|
96
143
|
];
|
|
97
144
|
if (hasMore) {
|
|
98
|
-
meta.push(`[Has more content. ${totalLines - maxLines} earlier lines not shown. Increase maxLines or use
|
|
145
|
+
meta.push(`[Has more content. ${totalLines - maxLines} earlier lines not shown. Increase maxLines, use startByte, or use pattern for grep mode.]`);
|
|
99
146
|
}
|
|
100
147
|
meta.push("");
|
|
101
148
|
return ok(truncateText(meta.join("\n") + output));
|