When Jenkins uses the OpenShift Login plugin as its security realm, all MCP tool calls execute as anonymous despite valid Authorization: Bearer <token> headers. The same Bearer token works correctly with the Jenkins REST API (e.g., /whoAmI/api/json).
Environment:
- Jenkins: 2.541.3
- MCP Server plugin: 0.194.v6ce5ce41a_02a_
- OpenShift Login plugin: 1.1.0.263.v2e0ddecf8b_25
REST API works:
$ curl -H "Authorization: Bearer ${TOKEN}" \
"https://jenkins-host/whoAmI/api/json"
{"anonymous":false,"authenticated":true,"name":"system:serviceaccount:..."}MCP tool calls fail:
AccessDeniedException3: anonymous is missing the Overall/Read permission
The MCP initialize handshake succeeds (HTTP 200), and the server reports tools, but every tools/call returns the anonymous error.
The MCP Endpoint class implements HttpServletFilter, which registers a wrapper filter at position 0 in PluginServletFilter's filter list. The OpenShift Login plugin's OpenShiftPermissionFilter (which processes Authorization: Bearer headers) registers at a later position. When the MCP filter handles a request, it returns true, stopping the filter chain before the OpenShift auth filter ever runs.
PluginServletFilter maintains an ordered list of filters. Requests pass through them sequentially. The ordering on the affected Jenkins instance:
0: jenkins.util.HttpServletFilter$1 <-- MCP Endpoint.handle() runs here
1: jenkins.metrics.impl.MetricsFilter
2: io.jenkins.blueocean.auth.jwt.impl.JwtAuthenticationFilter
3: io.jenkins.blueocean.ResourceCacheControl
4: org.jenkinsci.plugins.ssegateway.Endpoint$SSEListenChannelFilter
5: FilterWrapper$1 -> OpenShiftPermissionFilter <-- Bearer token auth (never reached for MCP)
6: FilterWrapper$1 -> WebPostAccessLogger
PluginServletFilter uses a CopyOnWriteArrayList and filters are appended in the order they call addFilter():
HttpServletFilter.register()runs during an@Initializerphase early in Jenkins startup, adding the wrapper at position 0.OpenShiftPermissionFilteris added viaPluginServletFilter.addFilter()fromOpenShiftOAuth2SecurityRealm.createFilter(), which runs during security realm initialization. Despite this happening during startup as well, it ends up later in the list.
The FilterWrapper$1 wrapping exists because the OpenShift Login plugin uses javax.servlet.Filter (old API) while Jenkins 2.541.3 uses jakarta.servlet.Filter. Jenkins bridges these via FilterWrapper.toJakartaFilter().
HudsonFilterruns the security chain (BasicHeaderProcessor,AnonymousAuthenticationFilter, etc.).BasicHeaderProcessoronly handlesBasicauth, notBearer.AnonymousAuthenticationFiltersets the security context to anonymous.PluginServletFilter.doFilter()iterates its filter list.- Position 0:
HttpServletFilter$1wrapper iterates allHttpServletFilterextensions.Endpoint.handle()matches/mcp-server/mcp, callshandleMessage()->prepareMcpContext()->Jenkins.getAuthentication2(), which returns anonymous. handle()returnstrue. The wrapper returns immediately without callingchain.doFilter().- Position 5:
OpenShiftPermissionFilternever runs. The Bearer token is never processed.
GET requests to /whoAmI/api/json pass through the HttpServletFilter$1 wrapper without being intercepted (the MCP endpoint only matches /mcp-server/*). The filter chain continues to position 5, where OpenShiftPermissionFilter processes the Bearer token and sets the security context. The request then reaches Stapler, which handles /whoAmI with the correct authentication.
PR #182 ("Gratuitous use of User when Authentication would suffice"), merged in version 0.172, changed prepareMcpContext() from using User.current() to Jenkins.getAuthentication2(). This is the correct approach, but it doesn't help when Jenkins.getAuthentication2() itself returns anonymous because the auth filter hasn't run yet.
The MCP Endpoint also extends CrumbExclusion and overrides process() to fully handle MCP requests. On Jenkins instances with CSRF crumbs enabled, CrumbFilter would call CrumbExclusion.process() for POST requests, which would intercept MCP requests even earlier in the filter chain (before PluginServletFilter). On the affected instance, useCrumbs is false, so CrumbFilter is a no-op and this path is not taken.
Run on the Jenkins Script Console (/script) to see the filter ordering and unwrap FilterWrapper$1 entries:
def psfField = hudson.util.PluginServletFilter.getDeclaredField("list")
psfField.setAccessible(true)
def psf = hudson.util.PluginServletFilter.getInstance(Jenkins.get().servletContext)
def filters = psfField.get(psf)
println "=== PluginServletFilter filters (unwrapped) ==="
filters.eachWithIndex { f, i ->
def className = f.getClass().getName()
def extra = ""
if (className.contains("FilterWrapper")) {
try {
f.getClass().getDeclaredFields().each { field ->
field.setAccessible(true)
def wrapped = field.get(f)
if (wrapped != null) extra = " -> ${wrapped.getClass().getName()}"
}
} catch (Exception e) {}
}
println "${i}: ${className}${extra}"
}Run on the Script Console to check if the MCP Endpoint is registered as a CrumbExclusion:
println "=== CrumbExclusion extensions ==="
hudson.security.csrf.CrumbExclusion.all().eachWithIndex { e, i ->
println "${i}: ${e.getClass().getName()}"
}Reorder the filters at runtime so OpenShiftPermissionFilter runs before HttpServletFilter$1. Run on the Jenkins Script Console, or place in $JENKINS_HOME/init.groovy.d/ to persist across restarts:
// Runtime patch: Move OpenShiftPermissionFilter before HttpServletFilter$1
// in PluginServletFilter so Bearer token auth is processed before MCP handles requests.
//
// NOTE: Does NOT survive a Jenkins restart unless placed in
// $JENKINS_HOME/init.groovy.d/
import java.util.concurrent.CopyOnWriteArrayList
def psfField = hudson.util.PluginServletFilter.getDeclaredField("list")
psfField.setAccessible(true)
def psf = hudson.util.PluginServletFilter.getInstance(Jenkins.get().servletContext)
def filters = psfField.get(psf)
// Find the OpenShift filter and the HttpServletFilter wrapper
def osFilterIndex = -1
def httpFilterIndex = -1
filters.eachWithIndex { f, i ->
def className = f.getClass().getName()
if (className.contains("FilterWrapper")) {
try {
def fields = f.getClass().getDeclaredFields()
fields.each { field ->
field.setAccessible(true)
def wrapped = field.get(f)
if (wrapped?.getClass()?.getName()?.contains("OpenShiftPermissionFilter")) {
osFilterIndex = i
}
}
} catch (Exception e) {}
}
if (className.contains("HttpServletFilter")) {
httpFilterIndex = i
}
}
if (osFilterIndex == -1) {
println "ERROR: OpenShiftPermissionFilter not found in PluginServletFilter"
return
}
if (httpFilterIndex == -1) {
println "ERROR: HttpServletFilter not found in PluginServletFilter"
return
}
if (osFilterIndex < httpFilterIndex) {
println "OpenShiftPermissionFilter (${osFilterIndex}) already runs before HttpServletFilter (${httpFilterIndex}). No change needed."
return
}
println "Before reorder:"
filters.eachWithIndex { f, i -> println " ${i}: ${f.getClass().getName()}" }
// Build a new ordered list
def newOrder = new ArrayList(filters)
def osFilter = newOrder.remove(osFilterIndex)
newOrder.add(httpFilterIndex, osFilter)
// Replace the CopyOnWriteArrayList contents atomically
def newList = new CopyOnWriteArrayList(newOrder)
psfField.set(psf, newList)
// Verify
def verifyFilters = psfField.get(psf)
println "\nAfter reorder:"
verifyFilters.eachWithIndex { f, i ->
def className = f.getClass().getName()
def extra = ""
if (className.contains("FilterWrapper")) {
try {
def fields = f.getClass().getDeclaredFields()
fields.each { field ->
field.setAccessible(true)
def wrapped = field.get(f)
if (wrapped != null) extra = " -> ${wrapped.getClass().getName()}"
}
} catch (Exception e) {}
}
println " ${i}: ${className}${extra}"
}
println "\nDone. OpenShiftPermissionFilter now runs before MCP HttpServletFilter."
println "NOTE: This change does NOT survive a Jenkins restart."The Endpoint should not fully handle requests at the HttpServletFilter stage, since this preempts authentication filters registered later in PluginServletFilter. Possible approaches:
- Use the
RootAction(Stapler) path instead -- remove theHttpServletFilterimplementation and handle MCP requests via Stapler routing, which runs after allPluginServletFilterfilters have executed. - Defer
HttpServletFilterregistration -- ensure the MCP filter is registered after auth-related filters, or add an ordering mechanism. - Let unauthenticated requests fall through -- in
handle(), ifJenkins.getAuthentication2()is anonymous and anAuthorizationheader is present, returnfalseto let the filter chain continue, then handle the request via theRootActionpath.
- jenkinsci/mcp-server-plugin#208 -- Similar symptoms (anonymous tool execution) with Basic auth. May be the same root cause or a separate thread-context propagation issue in the MCP SDK.
- jenkinsci/mcp-server-plugin#182 -- Changed
prepareMcpContext()fromUser.current()toJenkins.getAuthentication2(). Necessary but not sufficient for this issue. - jenkinsci/mcp-server-plugin#171 -- Support JWT token authentication (closed, referenced by PR #182).