@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 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 18 tools for comprehensive Jenkins management including pipeline replay, stage-level logs, job configuration editing, and multibranch pipeline support.
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 (16 default + 2 unsafe)
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 (supports parameterized builds)
76
- - **getBuild** - Build details (status, duration, changes)
77
- - **getBuildLog** - Console output with tail/pagination support
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 for a pattern
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 - use `getJobConfig` to read, modify, then `updateJobConfig`
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: "1.0.0",
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) {
@@ -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<{
@@ -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
+ ]);
@@ -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()).optional().describe("Build parameters as key-value pairs (e.g., {\"BRANCH\": \"main\", \"DEPLOY\": \"true\"})"),
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 = 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
+ 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 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 });
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. 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({
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 (default: 200)"),
62
- startByte: z.number().optional().describe("Byte offset to start from (for pagination). Use nextStart from previous response."),
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
- // Full console text, then tail
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
- // Tail from end
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 startByte for full log.]`);
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));
@@ -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 text pattern across recent builds. Like grep for Jenkins logs. Searches the last N builds (default: 5) and returns matching lines with context.", z.object({
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("Text pattern to search for (case-insensitive substring match)"),
8
- buildNumber: z.number().optional().describe("Search a specific build number only"),
9
- lastN: z.number().optional().default(5).describe("Number of recent builds to search (default: 5, max: 20)"),
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 patternLower = pattern.toLowerCase();
17
- const results = [];
18
- if (buildNumber) {
19
- // Search specific build
20
- const matches = await searchBuildLog(client, jobPath, buildNumber, patternLower);
21
- if (matches.length > 0) {
22
- results.push(`Build #${buildNumber} (${matches.length} matches):`);
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: "builds[number]{0," + lastN + "}",
44
+ tree: `builds[number,result]{0,${lastN}}`,
30
45
  });
31
46
  const jobData = data;
32
- const builds = jobData.builds || [];
33
- for (const build of builds) {
34
- const matches = await searchBuildLog(client, jobPath, build.number, patternLower);
35
- if (matches.length > 0) {
36
- results.push(`Build #${build.number} (${matches.length} matches):`);
37
- results.push(...matches.slice(0, 20).map((m) => ` L${m.line}: ${m.text}`));
38
- if (matches.length > 20) {
39
- results.push(` ... and ${matches.length - 20} more matches`);
40
- }
41
- results.push("");
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 found for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${lastN} builds`}.`);
66
+ return ok(`No matches for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${buildsToSearch.length} build(s)`}.`);
47
67
  }
48
- return ok(`Search results for "${pattern}":\n\n${results.join("\n")}`);
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. enableDisableJob
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 searchBuildLog(client, jobPath, buildNumber, patternLower) {
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 logText = await client.getRaw(jobPath, `/${buildNumber}/consoleText`);
87
- const lines = logText.split("\n");
88
- const matches = [];
89
- for (let i = 0; i < lines.length; i++) {
90
- if (lines[i].toLowerCase().includes(patternLower)) {
91
- matches.push({
92
- line: i + 1,
93
- text: lines[i].substring(0, 200), // Truncate long lines
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
- return [];
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) {