-
Notifications
You must be signed in to change notification settings - Fork 25
Implement getBuildArtifacts tool and optimize getBuild response #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7bf1d0d
994b9f4
18063d1
7b83969
3a2d044
2cb1163
9f195f2
e10e41e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") | ||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will need annotations = @Tool.Annotations(destructiveHint = false) |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||
} | ||
}) | ||
.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()) { | ||
return new BuildArtifactResponse(false, 0L, "Artifact file does not exist: " + artifactPath); | ||
} | ||
|
||
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) { | ||
log.warn("Could not skip to offset {}, only skipped {}", offset, skipped); | ||
} | ||
|
||
// Read up to limit bytes | ||
byte[] buffer = new byte[limit]; | ||
int bytesRead = is.read(buffer); | ||
|
||
if (bytesRead <= 0) { | ||
return new BuildArtifactResponse(false, fileSize, ""); | ||
} | ||
|
||
// Convert to string (assuming text content) | ||
String content = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} | ||
|
||
public record BuildArtifactResponse(boolean hasMoreContent, long totalSize, String content) {} | ||
} |
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 |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need of this comment here. |
||
// for top-level lists while maintaining proper JSON structure | ||
resultBuilder.addTextContent(toJson(result)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment.
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