Skip to content

Instantly share code, notes, and snippets.

@dustymabe
Created August 13, 2026 15:48
Show Gist options
  • Select an option

  • Save dustymabe/8d4739ef8672c3eb8b7c8950c7de1438 to your computer and use it in GitHub Desktop.

Select an option

Save dustymabe/8d4739ef8672c3eb8b7c8950c7de1438 to your computer and use it in GitHub Desktop.
20260813_jenkins-mcp-openshift-auth.md

MCP Server Plugin + OpenShift Login: Full Analysis of Anonymous Tool Execution Bug

Problem

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

Symptoms

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.

Root Cause

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.

The Filter Chain

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

Why This Ordering Occurs

PluginServletFilter uses a CopyOnWriteArrayList and filters are appended in the order they call addFilter():

  • HttpServletFilter.register() runs during an @Initializer phase early in Jenkins startup, adding the wrapper at position 0.
  • OpenShiftPermissionFilter is added via PluginServletFilter.addFilter() from OpenShiftOAuth2SecurityRealm.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().

What Happens on an MCP POST Request

  1. HudsonFilter runs the security chain (BasicHeaderProcessor, AnonymousAuthenticationFilter, etc.). BasicHeaderProcessor only handles Basic auth, not Bearer. AnonymousAuthenticationFilter sets the security context to anonymous.
  2. PluginServletFilter.doFilter() iterates its filter list.
  3. Position 0: HttpServletFilter$1 wrapper iterates all HttpServletFilter extensions. Endpoint.handle() matches /mcp-server/mcp, calls handleMessage() -> prepareMcpContext() -> Jenkins.getAuthentication2(), which returns anonymous.
  4. handle() returns true. The wrapper returns immediately without calling chain.doFilter().
  5. Position 5: OpenShiftPermissionFilter never runs. The Bearer token is never processed.

Why REST API Works

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 is Necessary but Not Sufficient

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.

Note on CrumbExclusion

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.

Diagnostic Scripts

Inspect PluginServletFilter Ordering

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}"
}

List CrumbExclusion Extensions

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()}"
}

Workaround

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."

Suggested Fix

The Endpoint should not fully handle requests at the HttpServletFilter stage, since this preempts authentication filters registered later in PluginServletFilter. Possible approaches:

  1. Use the RootAction (Stapler) path instead -- remove the HttpServletFilter implementation and handle MCP requests via Stapler routing, which runs after all PluginServletFilter filters have executed.
  2. Defer HttpServletFilter registration -- ensure the MCP filter is registered after auth-related filters, or add an ordering mechanism.
  3. Let unauthenticated requests fall through -- in handle(), if Jenkins.getAuthentication2() is anonymous and an Authorization header is present, return false to let the filter chain continue, then handle the request via the RootAction path.

Related Issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment