@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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# @alexsarrell/jenkins-mcp-server
|
|
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
|
|
|
@@ -63,18 +63,21 @@ To enable them, add the env variable to your MCP config:
|
|
|
63
63
|
}
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
## Tools (
|
|
66
|
+
## Tools (20 default + 2 unsafe)
|
|
67
67
|
|
|
68
68
|
### Job Management
|
|
69
69
|
- **getJobs** - List jobs in a folder with status summary
|
|
70
70
|
- **getJob** - Job details, parameters, health, branches (for multibranch pipelines)
|
|
71
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.
|
|
72
75
|
- **updateJobConfig** - Update job XML configuration
|
|
73
76
|
|
|
74
77
|
### Build Operations
|
|
75
|
-
- **triggerBuild** - Trigger builds with optional parameters (
|
|
76
|
-
- **getBuild** - Build details (status, duration, changes)
|
|
77
|
-
- **getBuildLog** - Console output with tail/pagination
|
|
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)
|
|
78
81
|
- **stopBuild** - Abort a running build
|
|
79
82
|
- **getBuildArtifacts** - List build artifacts
|
|
80
83
|
- **getBuildTestResults** - Test results with failure details
|
|
@@ -87,8 +90,9 @@ To enable them, add the env variable to your MCP config:
|
|
|
87
90
|
- **restartFromStage** - Restart pipeline from a specific stage
|
|
88
91
|
|
|
89
92
|
### Discovery & Utilities
|
|
90
|
-
- **searchBuildLogs** - Grep across build logs
|
|
93
|
+
- **searchBuildLogs** - Grep across build logs (regex, before/after context, `onlyResults` filter, progressive read)
|
|
91
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
|
|
92
96
|
- **enableDisableJob** - Enable or disable a job
|
|
93
97
|
|
|
94
98
|
## Multibranch Pipelines
|
|
@@ -100,12 +104,26 @@ For branches with slashes in the name, use `::` separator:
|
|
|
100
104
|
my-pipeline::feature/my-branch
|
|
101
105
|
```
|
|
102
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
|
+
|
|
103
121
|
## Notes
|
|
104
122
|
|
|
105
123
|
- **Replay** uses Jenkins' form-based replay endpoint (not a clean REST API)
|
|
106
124
|
- **Restart from Stage** requires the Declarative Pipeline plugin
|
|
107
|
-
- **Config editing** works with raw XML
|
|
108
|
-
- 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
|
|
109
127
|
- CSRF crumbs are handled automatically
|
|
110
128
|
- Authentication uses HTTP Basic Auth with user API token
|
|
111
129
|
|
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;
|
|
@@ -27,7 +31,7 @@ const client = new JenkinsClient({
|
|
|
27
31
|
// Create MCP server
|
|
28
32
|
const server = new McpServer({
|
|
29
33
|
name: "jenkins-mcp-server",
|
|
30
|
-
version:
|
|
34
|
+
version: pkg.version,
|
|
31
35
|
});
|
|
32
36
|
// Tool registration helper that wraps the McpServer.tool() API
|
|
33
37
|
function register(name, description, schema, handler) {
|
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));
|
package/dist/tools/discovery.js
CHANGED
|
@@ -1,51 +1,71 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatQueue, ok, error } from "../utils/formatters.js";
|
|
2
|
+
import { formatQueue, formatQueueItem, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
3
|
export function registerDiscoveryTools(client, register) {
|
|
4
|
-
// 16. searchBuildLogs
|
|
5
|
-
register("searchBuildLogs", "Search build logs for a
|
|
4
|
+
// 16. searchBuildLogs (v2)
|
|
5
|
+
register("searchBuildLogs", "Search build logs for a pattern across recent builds. Supports regex, context lines, result filter, and progressive read for large logs. Streams the log and stops early once maxMatchesPerBuild is hit per build.", z.object({
|
|
6
6
|
jobPath: z.string().describe("Full job path"),
|
|
7
|
-
pattern: z.string().describe("
|
|
8
|
-
buildNumber: z.number().optional().describe("Search a specific build
|
|
9
|
-
lastN: z.number().optional().default(5).describe("Number of recent builds to search (
|
|
7
|
+
pattern: z.string().describe("Pattern to search for"),
|
|
8
|
+
buildNumber: z.number().optional().describe("Search a specific build only. Overrides lastN/onlyResults."),
|
|
9
|
+
lastN: z.number().optional().default(5).describe("Number of recent builds to search (max 20)"),
|
|
10
|
+
regex: z.boolean().optional().default(false).describe("Treat pattern as regex (default: case-insensitive substring)"),
|
|
11
|
+
before: z.number().optional().default(0).describe("Lines of context before each match"),
|
|
12
|
+
after: z.number().optional().default(0).describe("Lines of context after each match"),
|
|
13
|
+
maxMatchesPerBuild: z.number().optional().default(10).describe("Stop scanning a build after this many matches"),
|
|
14
|
+
onlyResults: z.array(z.enum(["SUCCESS", "FAILURE", "UNSTABLE", "ABORTED", "NOT_BUILT"])).optional()
|
|
15
|
+
.describe("Filter builds by result before searching. Default: search all."),
|
|
10
16
|
}), async (args) => {
|
|
11
17
|
const jobPath = args.jobPath;
|
|
12
18
|
const pattern = args.pattern;
|
|
13
19
|
const buildNumber = args.buildNumber;
|
|
14
20
|
const lastN = Math.min(args.lastN || 5, 20);
|
|
21
|
+
const regex = args.regex ?? false;
|
|
22
|
+
const before = args.before ?? 0;
|
|
23
|
+
const after = args.after ?? 0;
|
|
24
|
+
const maxMatchesPerBuild = args.maxMatchesPerBuild ?? 10;
|
|
25
|
+
const onlyResults = args.onlyResults;
|
|
15
26
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
results.push(...matches.map((m) => ` L${m.line}: ${m.text}`));
|
|
27
|
+
const matcher = (() => {
|
|
28
|
+
try {
|
|
29
|
+
return regex ? new RegExp(pattern) : null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
throw new Error(`Invalid regex: ${msg}`);
|
|
24
34
|
}
|
|
35
|
+
})();
|
|
36
|
+
const isMatch = (line) => matcher ? matcher.test(line) : line.toLowerCase().includes(pattern.toLowerCase());
|
|
37
|
+
// Determine which builds to search.
|
|
38
|
+
let buildsToSearch;
|
|
39
|
+
if (buildNumber !== undefined) {
|
|
40
|
+
buildsToSearch = [buildNumber];
|
|
25
41
|
}
|
|
26
42
|
else {
|
|
27
|
-
// Get recent builds
|
|
28
43
|
const data = await client.get(jobPath, "/api/json", {
|
|
29
|
-
tree:
|
|
44
|
+
tree: `builds[number,result]{0,${lastN}}`,
|
|
30
45
|
});
|
|
31
46
|
const jobData = data;
|
|
32
|
-
const builds = jobData.builds
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
const builds = jobData.builds ?? [];
|
|
48
|
+
buildsToSearch = builds
|
|
49
|
+
.filter((b) => !onlyResults || (b.result !== null && onlyResults.includes(b.result)))
|
|
50
|
+
.map((b) => b.number);
|
|
51
|
+
}
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const num of buildsToSearch) {
|
|
54
|
+
const matches = await searchOneBuild(client, jobPath, num, isMatch, before, after, maxMatchesPerBuild);
|
|
55
|
+
if (matches.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
results.push(`Build #${num} (${matches.length}${matches.length >= maxMatchesPerBuild ? "+" : ""} matches):`);
|
|
58
|
+
for (const m of matches.slice(0, maxMatchesPerBuild)) {
|
|
59
|
+
for (const c of m.context) {
|
|
60
|
+
results.push(` ${String(c.lineNumber).padStart(6)}: ${c.text}`);
|
|
42
61
|
}
|
|
62
|
+
results.push("");
|
|
43
63
|
}
|
|
44
64
|
}
|
|
45
65
|
if (results.length === 0) {
|
|
46
|
-
return ok(`No matches
|
|
66
|
+
return ok(`No matches for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${buildsToSearch.length} build(s)`}.`);
|
|
47
67
|
}
|
|
48
|
-
return ok(
|
|
68
|
+
return ok(truncateText(results.join("\n")));
|
|
49
69
|
}
|
|
50
70
|
catch (e) {
|
|
51
71
|
return handleError(e);
|
|
@@ -64,7 +84,20 @@ export function registerDiscoveryTools(client, register) {
|
|
|
64
84
|
return handleError(e);
|
|
65
85
|
}
|
|
66
86
|
});
|
|
67
|
-
// 18.
|
|
87
|
+
// 18. getQueueItem
|
|
88
|
+
register("getQueueItem", "Get the state of a specific queue item by ID. Use the queue ID returned from triggerBuild ('Queue item: #N') to find which build was started — this bridges queue → build.", z.object({
|
|
89
|
+
queueId: z.number().int().describe("Queue item ID"),
|
|
90
|
+
}), async (args) => {
|
|
91
|
+
const queueId = args.queueId;
|
|
92
|
+
try {
|
|
93
|
+
const data = await client.getAbsolute(`/queue/item/${queueId}/api/json`);
|
|
94
|
+
return ok(formatQueueItem(data));
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return handleError(e);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
// 19. enableDisableJob
|
|
68
101
|
register("enableDisableJob", "Enable or disable a Jenkins job. Disabled jobs cannot be triggered.", z.object({
|
|
69
102
|
jobPath: z.string().describe("Full job path"),
|
|
70
103
|
enabled: z.boolean().describe("true to enable, false to disable"),
|
|
@@ -81,24 +114,101 @@ export function registerDiscoveryTools(client, register) {
|
|
|
81
114
|
}
|
|
82
115
|
});
|
|
83
116
|
}
|
|
84
|
-
async function
|
|
117
|
+
async function searchOneBuild(client, jobPath, buildNumber, isMatch, before, after, maxMatches) {
|
|
118
|
+
const matches = [];
|
|
119
|
+
let lineNumber = 0;
|
|
120
|
+
// Sliding context buffer of recent lines.
|
|
121
|
+
const tail = [];
|
|
122
|
+
// Pending matches awaiting `after` lines.
|
|
123
|
+
const pending = [];
|
|
124
|
+
let leftover = "";
|
|
85
125
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
for await (const chunk of client.getProgressiveText(jobPath, buildNumber)) {
|
|
127
|
+
const data = leftover + chunk;
|
|
128
|
+
const lines = data.split("\n");
|
|
129
|
+
// The last fragment may be a partial line — keep it for the next chunk.
|
|
130
|
+
leftover = lines.pop() ?? "";
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
lineNumber++;
|
|
133
|
+
// Drain pending after-context.
|
|
134
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
135
|
+
const p = pending[i];
|
|
136
|
+
p.context.push({ lineNumber, text: line });
|
|
137
|
+
p.collected++;
|
|
138
|
+
if (p.collected >= after) {
|
|
139
|
+
matches.push({ lineNumber: p.lineNumber, context: p.context });
|
|
140
|
+
pending.splice(i, 1);
|
|
141
|
+
if (matches.length >= maxMatches)
|
|
142
|
+
return matches;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (isMatch(line)) {
|
|
146
|
+
const ctx = [];
|
|
147
|
+
// Take last `before` lines from tail.
|
|
148
|
+
const startIdx = Math.max(0, tail.length - before);
|
|
149
|
+
for (let j = startIdx; j < tail.length; j++) {
|
|
150
|
+
ctx.push({ lineNumber: lineNumber - (tail.length - j), text: tail[j] });
|
|
151
|
+
}
|
|
152
|
+
ctx.push({ lineNumber, text: line });
|
|
153
|
+
if (after === 0) {
|
|
154
|
+
matches.push({ lineNumber, context: ctx });
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
pending.push({ lineNumber, collected: 0, context: ctx });
|
|
158
|
+
}
|
|
159
|
+
if (matches.length >= maxMatches)
|
|
160
|
+
return matches;
|
|
161
|
+
}
|
|
162
|
+
tail.push(line);
|
|
163
|
+
if (tail.length > before)
|
|
164
|
+
tail.shift();
|
|
95
165
|
}
|
|
96
166
|
}
|
|
97
|
-
return matches;
|
|
98
167
|
}
|
|
99
168
|
catch {
|
|
100
|
-
|
|
169
|
+
// Fallback to /consoleText (e.g., progressiveText not available).
|
|
170
|
+
const text = await client.getRaw(jobPath, `/${buildNumber}/consoleText`);
|
|
171
|
+
return fallbackSearch(text, isMatch, before, after, maxMatches);
|
|
172
|
+
}
|
|
173
|
+
// Process trailing fragment if log doesn't end with \n.
|
|
174
|
+
if (leftover.length > 0) {
|
|
175
|
+
lineNumber++;
|
|
176
|
+
for (const p of pending) {
|
|
177
|
+
p.context.push({ lineNumber, text: leftover });
|
|
178
|
+
}
|
|
179
|
+
if (isMatch(leftover)) {
|
|
180
|
+
const ctx = [];
|
|
181
|
+
const startIdx = Math.max(0, tail.length - before);
|
|
182
|
+
for (let j = startIdx; j < tail.length; j++) {
|
|
183
|
+
ctx.push({ lineNumber: lineNumber - (tail.length - j), text: tail[j] });
|
|
184
|
+
}
|
|
185
|
+
ctx.push({ lineNumber, text: leftover });
|
|
186
|
+
matches.push({ lineNumber, context: ctx });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Final flush: emit pending matches with whatever after-context we got.
|
|
190
|
+
for (const p of pending) {
|
|
191
|
+
matches.push({ lineNumber: p.lineNumber, context: p.context });
|
|
192
|
+
}
|
|
193
|
+
return matches.slice(0, maxMatches);
|
|
194
|
+
}
|
|
195
|
+
function fallbackSearch(text, isMatch, before, after, maxMatches) {
|
|
196
|
+
const lines = text.split("\n");
|
|
197
|
+
const matches = [];
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
if (!isMatch(lines[i]))
|
|
200
|
+
continue;
|
|
201
|
+
const ctx = [];
|
|
202
|
+
const start = Math.max(0, i - before);
|
|
203
|
+
const end = Math.min(lines.length - 1, i + after);
|
|
204
|
+
for (let j = start; j <= end; j++) {
|
|
205
|
+
ctx.push({ lineNumber: j + 1, text: lines[j] });
|
|
206
|
+
}
|
|
207
|
+
matches.push({ lineNumber: i + 1, context: ctx });
|
|
208
|
+
if (matches.length >= maxMatches)
|
|
209
|
+
return matches;
|
|
101
210
|
}
|
|
211
|
+
return matches;
|
|
102
212
|
}
|
|
103
213
|
function handleError(e) {
|
|
104
214
|
if (e && typeof e === "object" && "errorCode" in e) {
|