@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 CHANGED
@@ -1,30 +1,22 @@
1
- # 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
 
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
- ## Installation
9
+ ## Quick Start
10
10
 
11
- ```bash
12
- npm install
13
- npm run build
14
- ```
15
-
16
- ## Configuration
17
-
18
- ### Claude Code
19
-
20
- Add to your `.claude/settings.json`:
11
+ Add to your `~/.claude.json`:
21
12
 
22
13
  ```json
23
14
  {
24
15
  "mcpServers": {
25
16
  "jenkins": {
26
- "command": "node",
27
- "args": ["/path/to/jenkins-mcp/dist/index.js"],
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
- ### Environment Variables
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 (User > Configure > 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
- ## Tools (18)
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** List jobs in a folder with status
50
- - **getJob** Job details, parameters, health, branches
51
- - **getJobConfig** Get job XML configuration (config.xml)
52
- - **updateJobConfig** Update job XML configuration
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** Trigger builds with optional parameters
56
- - **getBuild** Build details (status, duration, changes)
57
- - **getBuildLog** Console output with tail/pagination
58
- - **stopBuild** Abort a running build
59
- - **getBuildArtifacts** List build artifacts
60
- - **getBuildTestResults** Test results with failure details
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** Stage overview (names, status, duration)
64
- - **getStageLog** Log for a specific pipeline stage
65
- - **getPipelineScript** Get Jenkinsfile from replay page
66
- - **replayBuild** Replay build with optional script changes
67
- - **restartFromStage** Restart pipeline from a specific stage
68
-
69
- ### Discovery
70
- - **searchBuildLogs** Search logs across recent builds
71
- - **getQueue** View the build queue
72
- - **enableDisableJob** Enable or disable a job
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: "1.0.0",
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);
@@ -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));