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
111 changes: 109 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,122 @@ The following system properties can be used to configure the MCP Server plugin:

#### Origin header validation

The MCP specification mark as `MUST` validate the `Origin` header of incoming requests.
The MCP specification mark as `MUST` validate the `Origin` header of incoming requests.
By default, the MCP Server plugin does not enforce this validation to facilitate usage by AI Agent not providing the header.
You can enable different levels of validation, if the header is available with the request you can enforce his validation using
You can enable different levels of validation, if the header is available with the request you can enforce his validation using
the system property `io.jenkins.plugins.mcp.server.Endpoint.requireOriginMatch=true`
When enforcing the validation, the header value must match the configured Jenkins root url.

If receiving the header is mandatory the system property `io.jenkins.plugins.mcp.server.Endpoint.requireOriginHeader=true`
will make it mandatory as well.

### Connection Resilience

The MCP Server plugin includes several features to improve connection reliability:

#### Keep-Alive Messages

The server sends periodic keep-alive messages to detect broken connections. By default, keep-alive messages are sent every 30 seconds.

You can configure this interval with the system property:
```
io.jenkins.plugins.mcp.server.Endpoint.keepAliveInterval=30
```

Set to `0` to disable keep-alive messages (not recommended).

#### Health Endpoint

A lightweight MCP-specific health endpoint is available for connection monitoring at:
```
<jenkins-url>/mcp-health
```

This endpoint:
- Returns MCP server status and active connection counts
- Requires no authentication for maximum accessibility
- Returns immediately without MCP protocol overhead
- Returns HTTP 200 when healthy, HTTP 503 during shutdown
- Includes `Retry-After` header during shutdown

Response format:
```json
{
"mcpServerStatus": "ok",
"activeConnections": 5,
"shuttingDown": false,
"timestamp": "2025-01-28T10:30:00Z"
}
```

**Recommended client usage:**
- Poll the health endpoint periodically (e.g., every 10-30 seconds)
- When the endpoint returns 503 or becomes unreachable, prepare for reconnection
- Use the `Retry-After` header value when available

#### Metrics Endpoint

A metrics endpoint is available for monitoring connection statistics at:
```
<jenkins-url>/mcp-server/metrics
```

This endpoint requires authentication (standard Jenkins permissions) and provides:
```json
{
"sseConnectionsTotal": 42,
"sseConnectionsActive": 3,
"streamableRequestsTotal": 150,
"connectionErrorsTotal": 2,
"uptimeSeconds": 3600,
"startTime": "2025-01-28T10:00:00Z"
}
```

#### Graceful Shutdown

When Jenkins shuts down, the health endpoint will return `503 Service Unavailable` with a brief grace period before full termination. This allows clients to detect the shutdown and prepare for reconnection.

#### Transport Recommendation

For better connection reliability, we recommend using **Streamable HTTP** (`/mcp-server/mcp`) instead of **SSE** (`/mcp-server/sse`). Streamable HTTP handles connection issues more gracefully and is the preferred transport for most MCP clients.

#### Production Deployment

When deploying behind a reverse proxy or in production environments, configure these timeout settings to prevent premature connection drops:

**Jenkins/Jetty Configuration**

Jenkins uses Winstone (embedded Jetty) which defaults `httpKeepAliveTimeout` to 30 seconds. Since MCP keep-alive pings are also sent every 30 seconds, this creates a race condition where Jetty may close the connection before the next ping arrives.

Add this argument to your Jenkins startup command:
```
--httpKeepAliveTimeout=600000
```

For Docker deployments, add to your docker-compose.yml:
```yaml
services:
jenkins:
image: jenkins/jenkins:lts
command: ["--httpKeepAliveTimeout=600000"]
```

**Reverse Proxy Configuration (Nginx)**

For Nginx, extend timeouts for MCP endpoints:
```nginx
location ~ ^/(mcp-server|mcp-health)/ {
proxy_pass http://jenkins;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
```

#### Transport Endpoints

The MCP Server plugin provides three transport endpoints, all enabled by default:
Expand Down
97 changes: 93 additions & 4 deletions src/main/java/io/jenkins/plugins/mcp/server/Endpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,33 @@
private static final String MESSAGE_ENDPOINT = "/message";

public static final String MCP_SERVER_MESSAGE = MCP_SERVER + MESSAGE_ENDPOINT;

/**
* The endpoint path for health checks
*/
public static final String HEALTH_ENDPOINT = "/health";

public static final String MCP_SERVER_HEALTH = MCP_SERVER + HEALTH_ENDPOINT;

/**
* The endpoint path for metrics
*/
public static final String METRICS_ENDPOINT = "/metrics";

public static final String MCP_SERVER_METRICS = MCP_SERVER + METRICS_ENDPOINT;
public static final String USER_ID = Endpoint.class.getName() + ".userId";
public static final String HTTP_SERVLET_REQUEST = Endpoint.class.getName() + ".httpServletRequest";

private static final String MCP_CONTEXT_KEY = Endpoint.class.getName() + ".mcpContext";

/**
* The interval in seconds for sending keep-alive messages to the client.
* Default is 0 seconds (so disabled per default), can be overridden by setting the system property
* it's not static final on purpose to allow dynamic configuration via script console.
* Default is 30 seconds, can be overridden by setting the system property.
* Set to 0 to disable keep-alive messages.
* It's not static final on purpose to allow dynamic configuration via script console.
*/
private static int keepAliveInterval =
SystemProperties.getInteger(Endpoint.class.getName() + ".keepAliveInterval", 0);
SystemProperties.getInteger(Endpoint.class.getName() + ".keepAliveInterval", 30);

/**
* Whether to require the Origin header in requests. Default is false, can be overridden by setting the system
Expand Down Expand Up @@ -180,6 +195,7 @@
if (!initialized) {
init();
}

// Handle stateless endpoint
if (isStatelessRequest(request)) {
if (DISABLE_MCP_STATELESS) {
Expand All @@ -190,6 +206,9 @@
return true;
}

// Note: Health endpoint is now handled by HealthEndpoint UnprotectedRootAction at /mcp-health
// Metrics endpoint is handled in handle() method below after Stapler applies auth

// Handle SSE message endpoint
if (requestedResource.startsWith("/" + MCP_SERVER_MESSAGE)
&& request.getMethod().equalsIgnoreCase("POST")) {
Expand All @@ -214,6 +233,7 @@
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Streamable endpoint is disabled");
return true;
}
McpConnectionMetrics.recordStreamableRequest();
handleMessage(request, response, httpServletStreamableServerTransportProvider);
return true;
}
Expand Down Expand Up @@ -331,6 +351,12 @@
.prompts(prompts)
.resources(resources)
.build();

initialized = true;
log.info(
"MCP Server initialized with {} tools, keep-alive interval: {} seconds",
allTools.size(),
keepAliveInterval);
}

private void initStateless(
Expand Down Expand Up @@ -426,6 +452,14 @@
init();
}

// Note: health endpoint is handled by HealthEndpoint UnprotectedRootAction at /mcp-health

// Handle metrics endpoint (authentication checked in handler)
if (isMetricsRequest(req)) {
handleMetrics(req, resp);
return true;
}

// Handle stateless endpoint
if (isStatelessRequest(req)) {
if (DISABLE_MCP_STATELESS) {
Expand Down Expand Up @@ -459,6 +493,7 @@
if (isBrowserRequest(req)) {
serveBrowserPage(resp);
} else {
McpConnectionMetrics.recordStreamableRequest();
handleMessage(req, resp, httpServletStreamableServerTransportProvider);
}
return true;
Expand Down Expand Up @@ -626,16 +661,43 @@
|| (request.getMethod().equalsIgnoreCase("POST")));
}

private boolean isMetricsRequest(HttpServletRequest request) {
String requestedResource = getRequestedResourcePath(request);
return requestedResource.equals("/" + MCP_SERVER_METRICS)
&& request.getMethod().equalsIgnoreCase("GET");

Check warning on line 667 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 667 is only partially covered, one branch is missing
}

private void handleMetrics(HttpServletRequest request, HttpServletResponse response) throws IOException {
McpConnectionMetrics.handleMetricsRequest(response);
}

private void handleSSE(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
httpServletSseServerTransportProvider.service(request, response);
String clientInfo = getClientInfo(request);
log.info("SSE connection started from {}", clientInfo);
McpConnectionMetrics.recordSseConnectionStart();
try {
httpServletSseServerTransportProvider.service(request, response);
} catch (IOException e) {
log.warn("SSE connection error from {}: {}", clientInfo, e.getMessage());
McpConnectionMetrics.recordConnectionError();
throw e;

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 681-684 are not covered by tests
} finally {
McpConnectionMetrics.recordSseConnectionEnd();
log.info("SSE connection ended from {}", clientInfo);
}
}

private void handleMessage(HttpServletRequest request, HttpServletResponse response, HttpServlet httpServlet)
throws IOException, ServletException {
if (!validOriginHeader(request, response)) {
String clientInfo = getClientInfo(request);
log.warn("MCP message rejected due to invalid origin from {}", clientInfo);
return;
}
if (log.isDebugEnabled()) {

Check warning on line 698 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 698 is only partially covered, one branch is missing
log.debug("MCP message received from {}", getClientInfo(request));

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 699 is not covered by tests
}
prepareMcpContext(request);
httpServlet.service(request, response);
}
Expand All @@ -662,4 +724,31 @@
contextMap.put(HTTP_SERVLET_REQUEST, request);
request.setAttribute(MCP_CONTEXT_KEY, McpTransportContext.create(contextMap));
}

/**
* Extracts client identification information from an HTTP request for logging purposes.
*
* @param request the HTTP request
* @return a string containing client IP, X-Forwarded-For header (if present), and User-Agent
*/
private static String getClientInfo(HttpServletRequest request) {
StringBuilder info = new StringBuilder();
info.append("ip=").append(request.getRemoteAddr());

String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isEmpty()) {

Check warning on line 739 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 739 is only partially covered, 3 branches are missing
info.append(", forwarded-for=").append(forwardedFor);

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 740 is not covered by tests
}

String userAgent = request.getHeader("User-Agent");
if (userAgent != null && !userAgent.isEmpty()) {

Check warning on line 744 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 744 is only partially covered, 2 branches are missing
// Truncate long user agents for readability
if (userAgent.length() > 100) {

Check warning on line 746 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 746 is only partially covered, one branch is missing
userAgent = userAgent.substring(0, 100) + "...";

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 747 is not covered by tests
}
info.append(", user-agent=").append(userAgent);
}

return info.toString();
}
}
Loading
Loading