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 3e52b380..8c10b8df 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,19 @@
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;
+import org.kohsuke.stapler.interceptor.RequirePOST;
+import org.kohsuke.stapler.verb.GET;
+import org.kohsuke.stapler.verb.POST;
/**
*
@@ -69,7 +78,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 +98,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 +152,31 @@ 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
+ @SuppressWarnings("lgtm[jenkins/no-permission-check]")
+ @RequirePOST
+ public void doMessage(StaplerRequest2 req, StaplerResponse2 rsp) {
+ String requestedResource = getRequestedResourcePath(req);
+
+ handleMessage(req, rsp, httpServletSseServerTransportProvider);
+ }
+
+ @SneakyThrows
+ @SuppressWarnings("lgtm[jenkins/no-permission-check]")
+ @POST
+ @GET
+ public void doMcp(StaplerRequest2 req, StaplerResponse2 rsp) {
+
+ handleMessage(req, rsp, httpServletStreamableServerTransportProvider);
}
protected synchronized void init() throws ServletException {
@@ -240,19 +262,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 +431,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 +446,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 bad6e686..dcec12ff 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 cbcb060d..20fce35e 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,12 +27,15 @@
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;
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;
@@ -42,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;
@@ -53,6 +57,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;
@@ -62,11 +67,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;
@@ -83,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)
@@ -281,9 +290,31 @@ McpSchema.CallToolResult callRequest(McpSyncServerExchange exchange, McpSchema.C
var transportContext = exchange.transportContext();
var jenkinsMcpContext = JenkinsMcpContext.get();
- jenkinsMcpContext.setHttpServletRequest((HttpServletRequest) transportContext.get(HTTP_SERVLET_REQUEST));
- var result = method.invoke(target, methodArgs);
- return toMcpResult(result);
+ 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);
+
+ // 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
+ @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);
+ mcpResultHolder.set(mcpResult);
+ }
+ },
+ "invoke");
+
+ return mcpResultHolder.get();
} catch (Exception e) {
var rootCauseMessage = ExceptionUtils.getRootCauseMessage(e);
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 7c5324da..0579db56 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 cfffacc4..f1179ff4 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 757e8d35..a4ce85cf 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();
+ }
}