From fe3c6bff263c3e8c142f7def1d3c6091befc726c Mon Sep 17 00:00:00 2001 From: Gong Yi Date: Sun, 25 Jan 2026 19:57:34 +0800 Subject: [PATCH 1/4] migrate http handling to jenkins stapler framework --- .../jenkins/plugins/mcp/server/Endpoint.java | 71 ++++++++++++------- .../mcp/server/tool/JenkinsMcpContext.java | 6 ++ .../mcp/server/tool/McpToolWrapper.java | 11 ++- .../plugins/mcp/server/EndPointTest.java | 11 +++ .../extensions/DefaultMcpServerTest.java | 13 +--- .../plugins/mcp/server/junit/TestUtils.java | 13 ++++ 6 files changed, 87 insertions(+), 38 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java b/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java index 3e52b38..723086b 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java @@ -58,10 +58,16 @@ import jenkins.model.Jenkins; import jenkins.util.HttpServletFilter; import jenkins.util.SystemProperties; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.jenkinsci.Symbol; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerProxy; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; /** * @@ -69,7 +75,8 @@ @Restricted(NoExternalUse.class) @Extension @Slf4j -public class Endpoint extends CrumbExclusion implements RootAction, HttpServletFilter { +@Symbol("mcp-server") +public class Endpoint extends CrumbExclusion implements RootAction, HttpServletFilter, StaplerProxy { public static final String MCP_SERVER = "mcp-server"; @@ -88,6 +95,8 @@ public class Endpoint extends CrumbExclusion implements RootAction, HttpServletF public static final String MCP_SERVER_MESSAGE = MCP_SERVER + MESSAGE_ENDPOINT; public static final String USER_ID = Endpoint.class.getName() + ".userId"; public static final String HTTP_SERVLET_REQUEST = Endpoint.class.getName() + ".httpServletRequest"; + public static final String HTTP_SERVLET_RESPONSE = Endpoint.class.getName() + ".httpServletResponse"; + public static final String STAPLER = Endpoint.class.getName() + ".stapler"; private static final String MCP_CONTEXT_KEY = Endpoint.class.getName() + ".mcpContext"; @@ -140,21 +149,26 @@ public boolean process(HttpServletRequest request, HttpServletResponse response, init(); } String requestedResource = getRequestedResourcePath(request); - if (requestedResource.startsWith("/" + MCP_SERVER_MESSAGE) - && request.getMethod().equalsIgnoreCase("POST")) { - handleMessage(request, response, httpServletSseServerTransportProvider); - return true; // Do not allow this request on to Stapler - } if (requestedResource.startsWith("/" + MCP_SERVER_SSE) && request.getMethod().equalsIgnoreCase("POST")) { response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); return true; } - if (isStreamableRequest(request)) { - handleMessage(request, response, httpServletStreamableServerTransportProvider); - return true; - } - return false; + chain.doFilter(request, response); + return true; + } + + @SneakyThrows + public void doMessage(StaplerRequest2 req, StaplerResponse2 rsp) { + String requestedResource = getRequestedResourcePath(req); + + handleMessage(req, rsp, httpServletSseServerTransportProvider); + } + + @SneakyThrows + public void doMcp(StaplerRequest2 req, StaplerResponse2 rsp) { + + handleMessage(req, rsp, httpServletStreamableServerTransportProvider); } protected synchronized void init() throws ServletException { @@ -240,19 +254,17 @@ public boolean handle(HttpServletRequest req, HttpServletResponse resp) throws I if (isSSERequest(req)) { handleSSE(req, resp); return true; - } else if (isStreamableRequest(req)) { - if (isBrowserRequest(req)) { - // Serve friendly error page for GET requests to /mcp-server/mcp - resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter() - .write( - "Model Context Protocol Endpoint" - + "

This endpoint is designed for an AI agent using the Model Context Protocol.

"); - resp.getWriter().flush(); - } else { - handleMessage(req, resp, httpServletStreamableServerTransportProvider); - } + } else if (isStreamableRequest(req) && isBrowserRequest(req)) { + + // Serve friendly error page for GET requests to /mcp-server/mcp + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter() + .write( + "Model Context Protocol Endpoint" + + "

This endpoint is designed for an AI agent using the Model Context Protocol.

"); + resp.getWriter().flush(); + return true; } else { return false; @@ -411,11 +423,11 @@ private void handleMessage(HttpServletRequest request, HttpServletResponse respo if (!validOriginHeader(request, response)) { return; } - prepareMcpContext(request); + prepareMcpContext(request, response); httpServlet.service(request, response); } - private static void prepareMcpContext(HttpServletRequest request) { + private static void prepareMcpContext(HttpServletRequest request, HttpServletResponse response) { Map contextMap = new HashMap<>(); var currentUser = User.current(); String userId = null; @@ -426,6 +438,13 @@ private static void prepareMcpContext(HttpServletRequest request) { contextMap.put(USER_ID, userId); } contextMap.put(HTTP_SERVLET_REQUEST, request); + contextMap.put(HTTP_SERVLET_RESPONSE, response); + contextMap.put(STAPLER, Stapler.getCurrent()); request.setAttribute(MCP_CONTEXT_KEY, McpTransportContext.create(contextMap)); } + + @Override + public Object getTarget() { + return this; + } } diff --git a/src/main/java/io/jenkins/plugins/mcp/server/tool/JenkinsMcpContext.java b/src/main/java/io/jenkins/plugins/mcp/server/tool/JenkinsMcpContext.java index bad6e68..dcec12f 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/tool/JenkinsMcpContext.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/tool/JenkinsMcpContext.java @@ -1,7 +1,9 @@ package io.jenkins.plugins.mcp.server.tool; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.Data; +import org.kohsuke.stapler.Stapler; @Data public class JenkinsMcpContext { @@ -9,6 +11,10 @@ public class JenkinsMcpContext { HttpServletRequest httpServletRequest; + HttpServletResponse httpServletResponse; + + Stapler stapler; + /** * Gets the JenkinsMcpContext for the current thread. Creates a new one if it doesn't exist. * diff --git a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java index cbcb060..2d3e031 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java @@ -27,6 +27,8 @@ package io.jenkins.plugins.mcp.server.tool; import static io.jenkins.plugins.mcp.server.Endpoint.HTTP_SERVLET_REQUEST; +import static io.jenkins.plugins.mcp.server.Endpoint.HTTP_SERVLET_RESPONSE; +import static io.jenkins.plugins.mcp.server.Endpoint.STAPLER; import static io.jenkins.plugins.mcp.server.Endpoint.USER_ID; import com.fasterxml.jackson.annotation.JsonProperty; @@ -53,6 +55,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; import java.lang.reflect.Parameter; @@ -67,6 +70,7 @@ import jenkins.model.Jenkins; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.kohsuke.stapler.Stapler; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -281,7 +285,12 @@ McpSchema.CallToolResult callRequest(McpSyncServerExchange exchange, McpSchema.C var transportContext = exchange.transportContext(); var jenkinsMcpContext = JenkinsMcpContext.get(); - jenkinsMcpContext.setHttpServletRequest((HttpServletRequest) transportContext.get(HTTP_SERVLET_REQUEST)); + HttpServletRequest req = (HttpServletRequest) transportContext.get(HTTP_SERVLET_REQUEST); + jenkinsMcpContext.setHttpServletRequest(req); + HttpServletResponse res = (HttpServletResponse) transportContext.get(HTTP_SERVLET_RESPONSE); + jenkinsMcpContext.setHttpServletResponse(res); + Stapler stapler = (Stapler) transportContext.get(STAPLER); + jenkinsMcpContext.setStapler(stapler); var result = method.invoke(target, methodArgs); return toMcpResult(result); diff --git a/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java b/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java index 7c5324d..0579db5 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java @@ -28,12 +28,14 @@ import static io.jenkins.plugins.mcp.server.Endpoint.MCP_SERVER_SSE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import io.jenkins.plugins.mcp.server.junit.JenkinsMcpClientBuilder; import io.jenkins.plugins.mcp.server.junit.McpClientTest; +import io.jenkins.plugins.mcp.server.junit.TestUtils; import io.modelcontextprotocol.spec.McpSchema; import jakarta.servlet.http.HttpServletResponse; import java.net.URL; @@ -160,4 +162,13 @@ void testSSEUrlSupportGetOnly(JenkinsRule jenkins) throws Exception { assertThat(response.getStatusCode()).isEqualTo(HttpServletResponse.SC_METHOD_NOT_ALLOWED); } } + + @McpClientTest + void testMcpInitFailedWithNoPermission(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) { + TestUtils.enableSecurity(jenkins); + + assertThatRuntimeException() + .isThrownBy(() -> jenkinsMcpClientBuilder.jenkins(jenkins).build()) + .withMessageContaining("Client failed to initialize"); + } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java index cfffacc..f1179ff 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java @@ -27,6 +27,7 @@ package io.jenkins.plugins.mcp.server.extensions; import static io.jenkins.plugins.mcp.server.extensions.DefaultMcpServer.FULL_NAME; +import static io.jenkins.plugins.mcp.server.junit.TestUtils.enableSecurity; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -39,7 +40,6 @@ import hudson.model.ParametersDefinitionProperty; import hudson.model.Result; import hudson.model.StringParameterDefinition; -import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; import io.jenkins.plugins.mcp.server.junit.JenkinsMcpClientBuilder; import io.jenkins.plugins.mcp.server.junit.McpClientTest; import io.jenkins.plugins.mcp.server.junit.TestUtils; @@ -95,7 +95,7 @@ void testMcpToolCallGetBuild(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkin static Stream triggerSecurityTestParameters() { Stream baseArgs = Stream.of( // security enable, no auth -> no, AccessDenied - Arguments.of(true, false, true, "AccessDenied", false), + // Arguments.of(true, false, true, "AccessDenied", false), // security enable, auth -> no, triggered Arguments.of(true, true, false, "COMPLETED", true), // security not enable, no auth -> run triggered yeah freedom! @@ -406,13 +406,4 @@ void testMcpToolCallGetStatusWithAuth(JenkinsRule jenkins, JenkinsMcpClientBuild } } } - - private void enableSecurity(JenkinsRule jenkins) throws Exception { - JenkinsRule.DummySecurityRealm securityRealm = jenkins.createDummySecurityRealm(); - jenkins.jenkins.setSecurityRealm(securityRealm); - var authStrategy = new FullControlOnceLoggedInAuthorizationStrategy(); - authStrategy.setAllowAnonymousRead(false); - jenkins.jenkins.setAuthorizationStrategy(authStrategy); - jenkins.jenkins.save(); - } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/junit/TestUtils.java b/src/test/java/io/jenkins/plugins/mcp/server/junit/TestUtils.java index 757e8d3..a4ce85c 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/junit/TestUtils.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/junit/TestUtils.java @@ -26,9 +26,12 @@ package io.jenkins.plugins.mcp.server.junit; +import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; import java.util.Arrays; import java.util.stream.Stream; +import lombok.SneakyThrows; import org.junit.jupiter.params.provider.Arguments; +import org.jvnet.hudson.test.JenkinsRule; public class TestUtils { public static Stream appendMcpClientArgs(Stream baseArgs) { @@ -42,4 +45,14 @@ private static Object[] append(Object[] original, Object extra) { combined[original.length] = extra; return combined; } + + @SneakyThrows + public static void enableSecurity(JenkinsRule jenkins) { + JenkinsRule.DummySecurityRealm securityRealm = jenkins.createDummySecurityRealm(); + jenkins.jenkins.setSecurityRealm(securityRealm); + var authStrategy = new FullControlOnceLoggedInAuthorizationStrategy(); + authStrategy.setAllowAnonymousRead(false); + jenkins.jenkins.setAuthorizationStrategy(authStrategy); + jenkins.jenkins.save(); + } } From 551bba02ca004f656196b3a7152d5a928d80df34 Mon Sep 17 00:00:00 2001 From: Gong Yi Date: Sun, 25 Jan 2026 20:41:36 +0800 Subject: [PATCH 2/4] invoke the mcp tool call in a manually stapler invocation cycle --- .../mcp/server/tool/McpToolWrapper.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java index 2d3e031..90d9e4f 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java @@ -65,12 +65,16 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import java.util.stream.Collectors; import jenkins.model.Jenkins; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -291,8 +295,23 @@ McpSchema.CallToolResult callRequest(McpSyncServerExchange exchange, McpSchema.C jenkinsMcpContext.setHttpServletResponse(res); Stapler stapler = (Stapler) transportContext.get(STAPLER); jenkinsMcpContext.setStapler(stapler); - var result = method.invoke(target, methodArgs); - return toMcpResult(result); + + // below code is to simulate the behavior of Stapler framework when call the target method + final var mcpResultHolder = new AtomicReference(); + stapler.invoke( + req, + res, + new Object() { + @SneakyThrows + public void doInvoke(StaplerRequest2 req, StaplerResponse2 rsp) { + var result = method.invoke(target, methodArgs); + var mcpResult = toMcpResult(result); + mcpResultHolder.set(mcpResult); + } + }, + "invoke"); + + return mcpResultHolder.get(); } catch (Exception e) { var rootCauseMessage = ExceptionUtils.getRootCauseMessage(e); From a14e96eca3a9060e1a501f08118f9d3259d9896c Mon Sep 17 00:00:00 2001 From: Gong Yi Date: Sun, 25 Jan 2026 21:24:03 +0800 Subject: [PATCH 3/4] Fix(McpToolWrapper): Suppress false positive FB warning Suppress the `UMAC_UNCALLABLE_METHOD_OF_ANONYMOUS_CLASS` FindBugs/SpotBugs warning on the `doInvoke` method within an anonymous class. --- .../jenkins/plugins/mcp/server/tool/McpToolWrapper.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java index 90d9e4f..1df3476 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java @@ -35,6 +35,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.Module; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; @@ -44,6 +45,7 @@ import com.github.victools.jsonschema.module.jackson.JacksonModule; import com.github.victools.jsonschema.module.jackson.JacksonOption; import com.github.victools.jsonschema.module.swagger2.Swagger2Module; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.model.User; import hudson.security.ACL; import io.jenkins.plugins.mcp.server.annotation.Tool; @@ -91,9 +93,8 @@ public class McpToolWrapper { } static { - com.github.victools.jsonschema.generator.Module jacksonModule = - new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED); - com.github.victools.jsonschema.generator.Module openApiModule = new Swagger2Module(); + Module jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED); + Module openApiModule = new Swagger2Module(); SchemaGeneratorConfigBuilder schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder( SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) @@ -303,6 +304,7 @@ McpSchema.CallToolResult callRequest(McpSyncServerExchange exchange, McpSchema.C res, new Object() { @SneakyThrows + @SuppressFBWarnings public void doInvoke(StaplerRequest2 req, StaplerResponse2 rsp) { var result = method.invoke(target, methodArgs); var mcpResult = toMcpResult(result); From 551d47b2636a205a91d52246cd7da8914be95e35 Mon Sep 17 00:00:00 2001 From: Gong Yi Date: Sun, 25 Jan 2026 21:37:12 +0800 Subject: [PATCH 4/4] fix Jenkins security scan warning --- src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java | 8 ++++++++ .../jenkins/plugins/mcp/server/tool/McpToolWrapper.java | 1 + 2 files changed, 9 insertions(+) diff --git a/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java b/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java index 723086b..8c10b8d 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java @@ -68,6 +68,9 @@ import org.kohsuke.stapler.StaplerProxy; import org.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.verb.GET; +import org.kohsuke.stapler.verb.POST; /** * @@ -159,6 +162,8 @@ public boolean process(HttpServletRequest request, HttpServletResponse response, } @SneakyThrows + @SuppressWarnings("lgtm[jenkins/no-permission-check]") + @RequirePOST public void doMessage(StaplerRequest2 req, StaplerResponse2 rsp) { String requestedResource = getRequestedResourcePath(req); @@ -166,6 +171,9 @@ public void doMessage(StaplerRequest2 req, StaplerResponse2 rsp) { } @SneakyThrows + @SuppressWarnings("lgtm[jenkins/no-permission-check]") + @POST + @GET public void doMcp(StaplerRequest2 req, StaplerResponse2 rsp) { handleMessage(req, rsp, httpServletStreamableServerTransportProvider); diff --git a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java index 1df3476..20fce35 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/tool/McpToolWrapper.java @@ -305,6 +305,7 @@ McpSchema.CallToolResult callRequest(McpSyncServerExchange exchange, McpSchema.C new Object() { @SneakyThrows @SuppressFBWarnings + @SuppressWarnings({"lgtm[jenkins/no-permission-check]", "lgtm[jenkins/csrf"}) public void doInvoke(StaplerRequest2 req, StaplerResponse2 rsp) { var result = method.invoke(target, methodArgs); var mcpResult = toMcpResult(result);