Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,38 @@ The plugin provides the following built-in tools for interacting with Jenkins:
- `updateBuild`: Update build display name and/or description.
- `getBuildLog`: Retrieve log lines with pagination for a specific build or the last build.
- `searchBuildLog`: Search for log lines matching a pattern (string or regex) in build logs.
- `getBuildArtifact`: Get the content of a specific build artifact with pagination support.
This tool retrieves the actual content of an artifact file as text with information about whether there is more content to retrieve:

```json
{
"jobFullName": "my-job",
"buildNumber": 123,
"artifactPath": "logs/build.log",
"offset": 0,
"limit": 1024
}
```
Note on Artifact Content Reading:
- Use `artifactPath` to specify the relative path of the artifact from the artifacts root.
- Use `offset` and `limit` parameters for pagination when reading large artifact files.
- Returns content as text with metadata about file size and whether more content is available.
- Ideal for reading log files, configuration files, or other text-based artifacts.

- `getBuildArtifacts`: Retrieve build artifacts for a specific build or the last build of a Jenkins job.
This tool supports artifact filtering and exclusion patterns. You can provide parameters to control which artifacts are returned:

```json
{
"jobFullName": "my-job",
"buildNumber": 123,
"excludePatterns": ["*.tmp", "logs/*"]
}
```
Note on Artifact Filtering:
- Use `excludePatterns` to filter out unwanted artifacts using glob patterns.
- Artifacts are returned with their relative paths, file sizes, and download URLs.
- Large artifact lists are automatically paginated for performance.

#### SCM Integration
- `getJobScm`: Retrieve SCM configurations of a Jenkins job.
Expand Down Expand Up @@ -303,7 +335,7 @@ public class MyCustomMcpExtension implements McpServerExtension {

The MCP Server Plugin handles various result types with the following approach:

- **List Results**: Each element in the list is converted to a separate text content item in the response.
- **List Results**: Lists are serialized as proper JSON arrays within a single text content item in the response.
- **Single Objects**: The entire object is converted into a single text content item.

For serialization to text content:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
*
* The MIT License
*
* Copyright (c) 2025, Derek Taubert.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/

package io.jenkins.plugins.mcp.server.extensions;

import static io.jenkins.plugins.mcp.server.extensions.util.JenkinsUtil.getBuildByNumberOrLast;

import hudson.Extension;
import hudson.model.Run;
import io.jenkins.plugins.mcp.server.McpServerExtension;
import io.jenkins.plugins.mcp.server.annotation.Tool;
import io.jenkins.plugins.mcp.server.annotation.ToolParam;
import jakarta.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import jenkins.util.VirtualFile;
import lombok.extern.slf4j.Slf4j;

@Extension
@Slf4j
public class BuildArtifactsExtension implements McpServerExtension {

/*
* Suppression Rationale:
* - "rawtypes": Jenkins' Run.Artifact is a raw type in Jenkins' legacy API
* - "unchecked": Run.getArtifacts() returns raw List requiring unchecked conversion
*/
@SuppressWarnings({"rawtypes", "unchecked"})
@Tool(description = "Get the artifacts for a specific build or the last build of a Jenkins job")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will need annotations = @Tool.Annotations(destructiveHint = false)
as it's true per default

public List<Run.Artifact> getBuildArtifacts(
@ToolParam(description = "Job full name of the Jenkins job (e.g., 'folder/job-name')") String jobFullName,
@Nullable
@ToolParam(
description = "Build number (optional, if not provided, returns the last build)",
required = false)
Integer buildNumber) {
return getBuildByNumberOrLast(jobFullName, buildNumber)
.map(Run::getArtifacts)
.orElse(List.of());
}

@Tool(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will need annotations = @Tool.Annotations(destructiveHint = false)
as it's true per default

description =
"Get the content of a specific build artifact with pagination support. Returns the artifact content as text with information about whether there is more content to retrieve.")
public BuildArtifactResponse getBuildArtifact(
@ToolParam(description = "Job full name of the Jenkins job (e.g., 'folder/job-name')") String jobFullName,
@Nullable
@ToolParam(
description = "Build number (optional, if not provided, returns the last build)",
required = false)
Integer buildNumber,
@ToolParam(description = "Relative path of the artifact from the artifacts root") String artifactPath,
@Nullable
@ToolParam(
description =
"The byte offset to start reading from (optional, if not provided, starts from the beginning)",
required = false)
Long offset,
@Nullable
@ToolParam(
description =
"The maximum number of bytes to read (optional, if not provided, reads up to 64KB)",
required = false)
Integer limit) {

if (offset == null || offset < 0) {
offset = 0L;
}
if (limit == null || limit <= 0) {
limit = 65536; // 64KB default
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be better having a configuration (e.g SystemProperties) as users may want to configure more or less.

}

// Cap the limit to prevent excessive memory usage
final int maxLimit = 1048576; // 1MB max
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here. should be configurable

if (limit > maxLimit) {
log.warn("Limit {} is too large, using the default max limit {}", limit, maxLimit);
limit = maxLimit;
}

final long offsetF = offset;
final int limitF = limit;

return getBuildByNumberOrLast(jobFullName, buildNumber)
.map(build -> {
try {
return getArtifactContent(build, artifactPath, offsetF, limitF);
} catch (Exception e) {
log.error(
"Error reading artifact {} for job {} build {}",
artifactPath,
jobFullName,
buildNumber,
e);
return new BuildArtifactResponse(false, 0L, "Error reading artifact: " + e.getMessage());

Check warning on line 119 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 112-119 are not covered by tests
}
})
.orElse(new BuildArtifactResponse(false, 0L, "Build not found"));
}

/*
* Suppression Rationale:
* - "rawtypes": Jenkins' Run and Run.Artifact use raw types in legacy API
*/
@SuppressWarnings("rawtypes")
private BuildArtifactResponse getArtifactContent(Run run, String artifactPath, long offset, int limit)
throws IOException {
log.trace(
"getArtifactContent for run {}/{} called with artifact {}, offset {}, limit {}",
run.getParent().getName(),
run.getDisplayName(),
artifactPath,
offset,
limit);

// Find the artifact
Run.Artifact artifact = null;
for (Object a : run.getArtifacts()) {
Run.Artifact runArtifact = (Run.Artifact) a;
if (runArtifact.relativePath.equals(artifactPath)) {
artifact = runArtifact;
break;
}
}

if (artifact == null) {
return new BuildArtifactResponse(false, 0L, "Artifact not found: " + artifactPath);
}

// Get the artifact file through the artifact manager
VirtualFile artifactFile = run.getArtifactManager().root().child(artifactPath);
if (!artifactFile.exists()) {

Check warning on line 156 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 156 is only partially covered, one branch is missing
return new BuildArtifactResponse(false, 0L, "Artifact file does not exist: " + artifactPath);

Check warning on line 157 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 157 is not covered by tests
}

long fileSize = artifactFile.length();
if (offset >= fileSize) {
return new BuildArtifactResponse(false, fileSize, "");
}

// Read the content with offset and limit
try (InputStream is = artifactFile.open()) {
// Skip to the offset
long skipped = is.skip(offset);
if (skipped != offset) {

Check warning on line 169 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 169 is only partially covered, one branch is missing
log.warn("Could not skip to offset {}, only skipped {}", offset, skipped);

Check warning on line 170 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 170 is not covered by tests
}

// Read up to limit bytes
byte[] buffer = new byte[limit];
int bytesRead = is.read(buffer);

if (bytesRead <= 0) {

Check warning on line 177 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 177 is only partially covered, one branch is missing
return new BuildArtifactResponse(false, fileSize, "");

Check warning on line 178 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 178 is not covered by tests
}

// Convert to string (assuming text content)
String content = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about binaries artifacts? (we can attach jar, war etc....)


// Check if there's more content
boolean hasMoreContent = (offset + bytesRead) < fileSize;

return new BuildArtifactResponse(hasMoreContent, fileSize, content);
}

Check warning on line 188 in src/main/java/io/jenkins/plugins/mcp/server/extensions/BuildArtifactsExtension.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 188 is not covered by tests
}

public record BuildArtifactResponse(boolean hasMoreContent, long totalSize, String content) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import hudson.model.Run;
import org.kohsuke.stapler.export.ExportedBean;

public class JenkinsExportedBeanSerializerModifier extends BeanSerializerModifier {
Expand All @@ -39,6 +40,10 @@ public JsonSerializer<?> modifySerializer(
SerializationConfig config, BeanDescription beanDesc, JsonSerializer<?> serializer) {

if (beanDesc.getClassAnnotations().has(ExportedBean.class)) {
// Use custom serializer for Run objects to exclude artifacts
if (Run.class.isAssignableFrom(beanDesc.getBeanClass())) {
return new RunWithoutArtifactsSerializer();
}
return new JenkinsExportedBeanSerializer();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
*
* The MIT License
*
* Copyright (c) 2025, Derek Taubert.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/

package io.jenkins.plugins.mcp.server.jackson;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import hudson.model.Run;
import java.io.IOException;
import java.io.StringWriter;
import org.kohsuke.stapler.export.DataWriter;
import org.kohsuke.stapler.export.ExportConfig;
import org.kohsuke.stapler.export.Flavor;
import org.kohsuke.stapler.export.Model;
import org.kohsuke.stapler.export.ModelBuilder;
import org.kohsuke.stapler.export.TreePruner;

/**
* Custom serializer for Run objects that excludes the artifacts field to reduce payload size.
* This is used by the getBuild tool to provide build information without the potentially large artifacts array.
* Use the getBuildArtifacts tool to retrieve artifacts separately.
*
* This implementation uses Jenkins' TreePruner to filter out the artifacts field during serialization,
* which is more efficient and robust than post-processing approaches. The pruner ensures that artifacts
* are never serialized in the first place, saving both memory and processing time.
*/
public class RunWithoutArtifactsSerializer extends JsonSerializer<Run> {
private static final ModelBuilder MODEL_BUILDER = new ModelBuilder();

// TreePruner that excludes only the "artifacts" field
private static final TreePruner ARTIFACTS_PRUNER = new TreePruner() {
@Override
public TreePruner accept(Object node, org.kohsuke.stapler.export.Property prop) {
return "artifacts".equals(prop.name) ? null : TreePruner.DEFAULT;
}
};

@Override
public void serialize(Run value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
StringWriter sw = new StringWriter();
ExportConfig config = new ExportConfig().withFlavor(Flavor.JSON);
DataWriter dw = Flavor.JSON.createDataWriter(value, sw, config);
Model model = MODEL_BUILDER.get(value.getClass());

// Use TreePruner to exclude only the artifacts field during serialization
model.writeTo(value, ARTIFACTS_PRUNER, dw);

gen.writeRawValue(sw.toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,9 @@ McpSchema.CallToolResult toMcpResult(Object result) {
try {

var resultBuilder = McpSchema.CallToolResult.builder().isError(false);
if (result instanceof List listResult) {
for (var item : listResult) {
resultBuilder.addTextContent(toJson(item));
}
} else {
resultBuilder.addTextContent(toJson(result));
}
// Serialize all results the same way - this fixes the JSON concatenation issue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need of this comment here.
it;s more a PR comment :)

// for top-level lists while maintaining proper JSON structure
resultBuilder.addTextContent(toJson(result));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great. it's going to the direction of #82

return resultBuilder.build();
} catch (IOException e) {
throw new RuntimeException(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ void testMcpToolCallSimpleJson(JenkinsRule jenkins, JenkinsMcpClientBuilder jenk
"getBuildScm",
"getBuildChangeSets",
"getStatus",
"getTestResults");
"getTestResults",
"getBuildArtifacts",
"getBuildArtifact");

var sayHelloTool = tools.tools().stream()
.filter(tool -> "sayHello".equals(tool.name()))
Expand Down
Loading