Skip to content
Draft
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
79 changes: 53 additions & 26 deletions src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,28 @@
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;

/**
*
*/
@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";

Expand All @@ -88,6 +98,8 @@
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";

Expand Down Expand Up @@ -140,21 +152,31 @@
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 {
Expand Down Expand Up @@ -240,19 +262,17 @@
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(
"<html><head><title>Model Context Protocol Endpoint</title></head>"
+ "<body><h2>This endpoint is designed for an AI agent using the Model Context Protocol.</h2></body></html>");
resp.getWriter().flush();
} else {
handleMessage(req, resp, httpServletStreamableServerTransportProvider);
}
} else if (isStreamableRequest(req) && isBrowserRequest(req)) {

Check warning on line 265 in src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 265 is only partially covered, one branch is missing

// 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(
"<html><head><title>Model Context Protocol Endpoint</title></head>"
+ "<body><h2>This endpoint is designed for an AI agent using the Model Context Protocol.</h2></body></html>");
resp.getWriter().flush();

return true;
} else {
return false;
Expand Down Expand Up @@ -411,11 +431,11 @@
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<String, Object> contextMap = new HashMap<>();
var currentUser = User.current();
String userId = null;
Expand All @@ -426,6 +446,13 @@
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;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
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 {
private static final ThreadLocal<JenkinsMcpContext> CONTEXT = ThreadLocal.withInitial(JenkinsMcpContext::new);

HttpServletRequest httpServletRequest;

HttpServletResponse httpServletResponse;

Stapler stapler;

/**
* Gets the JenkinsMcpContext for the current thread. Creates a new one if it doesn't exist.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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<McpSchema.CallToolResult>();
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);
Expand Down
11 changes: 11 additions & 0 deletions src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -95,7 +95,7 @@ void testMcpToolCallGetBuild(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkin
static Stream<Arguments> triggerSecurityTestParameters() {
Stream<Arguments> 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!
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arguments> appendMcpClientArgs(Stream<Arguments> baseArgs) {
Expand All @@ -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();
}
}
Loading