Security Context Not Propagated When Calling Tools in Spring AI MCP Server

I’m integrating a Spring Boot application with the Spring AI MCP Server (spring-ai-starter-mcp-server-webmvc). I’m testing it using another Spring Boot application as the client, leveraging the spring-ai-starter-mcp-client dependency. The client is configured to send requests with a bearer token, which works fine for listing tools:

java

McpSyncClient client = McpClientFactory.getInstance();
client.listTools();

However, when invoking a tool with:

java

client.callTool(new McpSchema.CallToolRequest(toolName, params));

I encounter the error: “Security Context is invalid”. The issue seems to be that the MCP server delegates tool execution to a boundedElastic thread, which does not propagate the original request’s security context. The security context is required at the tool level to determine parameters like organizationId.

What I’ve Tried:

  • DelegatingSecurityContextAsyncTaskExecutor (didn’t propagate)
  • DelegatingSecurityContextTaskExecutor (didn’t propagate)
  • SecurityContextHolder.MODE_INHERITABLETHREADLOCAL (propagates, but a mess for thread pool)
  • Swapping sdk’s WebMvcSseServerTransportProvider with a custom provider: so I can manually set the context in the new thread, but just copying the sdk’s class still changed the behavior and failed to even get the toolList, let alone modifying it.

Below is basically how I’d enforce use of my custom provider

@Bean
@Primary
public McpServerTransportProvider customWebMvcSseServerTransportProvider() {
    return new CustomWebMvcSseServerTransportProvider(objectMapper, "/mcp/message");
}

sdk’s provider method that leads to the boundedElastic thread:

public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
    return Mono.fromRunnable(() -> {
        try {
            String jsonText = WebMvcSseServerTransportProvider.this.objectMapper.writeValueAsString(message);
            this.sseBuilder.id(this.sessionId).event("message").data(jsonText);
            WebMvcSseServerTransportProvider.logger.debug("Message sent to session {}", this.sessionId);
        } catch (Exception var3) {
            Exception e = var3;
            WebMvcSseServerTransportProvider.logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
            this.sseBuilder.error(e);
        }

    });
}

Question:

How can I ensure the security context is properly propagated to the boundedElastic thread when calling tools in the Spring AI MCP Server? Is there a recommended configuration or workaround for this issue?

Dependencies I am using:

Server:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0-SNAPSHOT</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>

Client:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0-SNAPSHOT</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-client</artifactId>
    </dependency>

You’re correct — the problem is that Spring Security’s SecurityContext is not propagated into Reactor’s boundedElastic threads by default, because it’s stored in a ThreadLocal, and boundedElastic uses a different thread than the HTTP request thread.

Straight Answer:

To ensure the SecurityContext is available inside tool execution in the MCP server, you must manually capture and restore it in reactive contexts, or switch to using Spring Security’s reactive context propagation features.


Solution: Use ReactorContext to propagate SecurityContext

Since the Spring AI MCP Server is using Project Reactor (e.g. Mono.fromRunnable()), the correct way to propagate the security context is by putting it into the Reactor context.

Step-by-step:

  1. Capture the SecurityContext early in the request (i.e. controller or filter layer):
SecurityContext securityContext = SecurityContextHolder.getContext();
  1. Attach it to the Mono context before switching threads:

When you need to execute code in boundedElastic, pass the context explicitly:

return Mono.deferContextual(ctx -> {
    SecurityContext context = ctx.get(SecurityContext.class);
    SecurityContextHolder.setContext(context);
    try {
        // your logic here
    } finally {
        SecurityContextHolder.clearContext();
    }
}).subscribeOn(Schedulers.boundedElastic())
  .contextWrite(Context.of(SecurityContext.class, securityContext));

Where to Apply This

Since you’re dealing with Spring AI MCP Server internals and possibly overriding sendMessage(...), you’ll need to wrap any tool invocation code that requires the SecurityContext like this.

You can do this wrapping inside your custom tool executor, or a custom server transport class if you choose to go that route.


Alternative: Global Filter to Attach SecurityContext to Reactor Context

If tool execution happens downstream of a WebMVC controller, you can add a OncePerRequestFilter that adds the SecurityContext to Reactor’s context:

@Component
public class SecurityContextReactorBridgeFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        SecurityContext context = SecurityContextHolder.getContext();
        filterChain.doFilter(request, response);
        // Optionally bridge to Reactor context here if needed in custom async logic
    }
}

However, this doesn’t help once you’re inside async boundaries like Mono.fromRunnable() — you’ll still need to do manual propagation there.


Recap:

  • Problem: SecurityContext is lost when using boundedElastic
  • Cause: It uses different threads and ThreadLocal doesn’t cross over
  • Fix: Use Mono.deferContextual and .contextWrite(...) to manually pass the SecurityContext into the reactive chain

Let me know if you’d like a reusable utility method to wrap this behavior, or how to integrate this specifically into your tool logic.