Bug description

I'm in a situation where I have authentication with OIDC (so an access token basically).

When I set authentication required on the /sse and /mcp/** endpoint, then the client side only connect when I provide the correct access token. This is ok.

Even when the client send a call to a tool, the authentication is needed, but inside the executed code, I cannot access the authentication.

SecurityContextHolder.getContext().getAuthentication() and ReactiveSecurityContextHolder.getContext() return null.

Long story short, I cannot control data ownership so this is bad, and my MCP server also execute some api call that need to be authenticated, and I use the oauth2ClientRequestInterceptor to do token exchange so this also fail. ( the MCP core even with SYNC option goes through reactive code and the original servlet thread is put on hold, giving the execution to a 'boundedElactic' thread )

Environment

Spring MVC ( springboot 3.4 ) Spring AI 1.0.0-M6 ( spring-ai-mcp-server-webmvc-spring-boot-starter ) spring-boot-starter-oauth2-resource-server (for oauth2 authentication )

Steps to reproduce

Enable authentication on an application, create a Tool that just return the authenticated user.

Expected behavior

I expect to be able to find the security context when a tool is called from MCP

Minimal Complete Reproducible example

Enable authentication on a springboot with MCP server activated, Create a Tool

@Tool(description="Get your name")
    public String getYourName() {
        return SecurityContextHolder.getContext().getAuthentication().getName();
    }

call the tool. It should answer your sub and not null.

Comment From: jochenchrist

Same issue here. This is a major blocker for any remote server application that relies on authentication.

Comment From: emdzej

I had similar issue, and had a quick look at the implementation (for 1.0.0-M6 and respective dependency versions)

For this to work, the mono chain needs to be preserved through out the entire chain. Having a brief look, it seems that the implementation of the io.modelcontextprotocol.spec.DefaultMcpSession is not right for webflux.

Seems it's not spring-ai issue.

After changing the implementation in io.modelcontextprotocol.spec.DefaultMcpSession slighlty:

public DefaultMcpSession(Duration requestTimeout, McpTransport transport,
                          Map<String, RequestHandler<?>> requestHandlers, Map<String, NotificationHandler> notificationHandlers) {

        Assert.notNull(requestTimeout, "The requstTimeout can not be null");
        Assert.notNull(transport, "The transport can not be null");
        Assert.notNull(requestHandlers, "The requestHandlers can not be null");
        Assert.notNull(notificationHandlers, "The notificationHandlers can not be null");

        this.requestTimeout = requestTimeout;
        this.transport = transport;
        this.requestHandlers.putAll(requestHandlers);
        this.notificationHandlers.putAll(notificationHandlers);

        // TODO: consider mono.transformDeferredContextual where the Context contains
        // the
        // Observation associated with the individual message - it can be used to
        // create child Observation and emit it together with the message to the
        // consumer
        this.connection = this.transport.connect(
                mono -> mono.flatMap(message -> {
                    if (message instanceof McpSchema.JSONRPCResponse response) {
                        logger.debug("Received Response: {}", response);
                        var sink = pendingResponses.remove(response.id());
                        if (sink == null) {
                            logger.warn("Unexpected response for unkown id {}", response.id());
                        }
                        else {
                            sink.success(response);
                        }
                        return Mono.just(message);
                    }
                    else if (message instanceof McpSchema.JSONRPCRequest request) {
                        logger.debug("Received request: {}", request);
                        return handleIncomingRequest(request).flatMap(transport::sendMessage).onErrorResume(
                                error -> {
                                    var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
                                            null, new McpSchema.JSONRPCResponse.JSONRPCError(
                                            McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), null));
                                    return transport.sendMessage(errorResponse);
                                }).thenReturn(message);
                    }
                    else if (message instanceof McpSchema.JSONRPCNotification notification) {
                        logger.debug("Received notification: {}", notification);
                        return handleIncomingNotification(notification).thenReturn(message);
                    }
                    return Mono.just(message);
                })

        ).subscribe();
    }

SecurityContext is preserved.

The main issue in the original implementation is the subscription to the handler that is being done instead of chaining the response with the request:

else if (message instanceof McpSchema.JSONRPCRequest request) {
                logger.debug("Received request: {}", request);
                handleIncomingRequest(request).subscribe(response -> transport.sendMessage(response).subscribe(),
                        error -> {
                            var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
                                    null, new McpSchema.JSONRPCResponse.JSONRPCError(
                                            McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), null));
                            transport.sendMessage(errorResponse).subscribe();
                        });
            }

Comment From: emdzej

@GregoireW please also note that despite fixing the issue as above, the @Tool registration will not work, since it's not correctly handled in asynchronous scenario. I did not dig too deep tho.

I had to create registrations by hand


    @Bean
    public List<McpServerFeatures.AsyncToolRegistration> tools(TaskService taskService,
                                                               ObjectMapper objectMapper) {

        return List.of(
                new McpServerFeatures.AsyncToolRegistration(
                        new McpSchema.Tool("tasks", "tasks", "{\n" +
                                "  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n" +
                                "  \"$id\": \"file://schemas/simple.schema.json\",\n" +
                                "  \"title\": \"simplified data\",\n" +
                                "  \"description\": \"simple\",\n" +
                                "  \"type\": \"object\",\n" +
                                "  \"properties\": {\n" +
                                "    }\n" +
                                "  }}"),
                        (request) -> {
                            return taskService.getCurrentUserTasks()
                                    .map(task -> {
                                        try {
                                            return new McpSchema.TextContent(
                                                    objectMapper.writeValueAsString(task)
                                            );
                                        } catch (JsonProcessingException e) {
                                            throw new RuntimeException(e);
                                        }
                                    })
                                    .cast(McpSchema.Content.class)
                                    .collectList()
                                    .flatMap(i -> Mono.just(new McpSchema.CallToolResult(i, false)));
                        }
                )
        );
    }

Comment From: GregoireW

@emdzej Thanks for the heads up and for drilling a little bit more on that (I did not took a deep dive in this subject and did something else for my use case) Next time I will know what to check

Comment From: emdzej

Looks like this has been fixed in mcp java-sdk 0.8.x https://github.com/modelcontextprotocol/java-sdk/blob/0.8.x/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java

So... hopefully with next release will be covered :)

Comment From: GregoireW

Wonderful news!

Comment From: los-ko

Doesnt seem to be working on snapshot yet, even tho it has mcp java-sdk 0.8.x.

Comment From: emdzej

@los-ko have you tried with the tool annotation or manual registration? If no other changes were made the annotation will not work to the best of my knowledge, since it's not reactive.

Comment From: los-ko

We have only tried with the tool annotation.

Comment From: zz-zhi54

Hi, if I don't use Security, is there any other way for me to get the current header or specified parameters? similar #2757

Comment From: djcrabhat

Was surprised by this today, too. In my dreams, "@AuthenticationPrincipal" could work just like it does on a controller, giving me access to the JWT that the Spring Security stack has already negotiated for me. But even getting the Authentication directly from the SecurityContext is a no-go.

Comment From: eric-eldard

Update: Don't use – see the conversation below regarding the McpServerSession being one-per-LLM-connection and not one-per-user. This code only works when you've only got one user.

Seems I'm experiencing this issue too. I'm running an McpSyncServer, but it just delegates everything to McpAsyncServer (creating a new thread, then blocking on the result), and the security context is fumbled in the handoff. Needed a quick workaround until this gets patched, so...father forgive me for this one...

@Getter
public class AuthAwareMcpServerSession extends McpServerSession
{
    private final Authentication authentication;

    public AuthAwareMcpServerSession(Authentication authentication, String id, Duration requestTimeout,
                                     McpServerTransport transport, InitRequestHandler initHandler,
                                     InitNotificationHandler initNotificationHandler,
                                     Map<String, RequestHandler<?>> requestHandlers,
                                     Map<String, NotificationHandler> notificationHandlers
    )
    {
        super(id, requestTimeout, transport, initHandler, initNotificationHandler, requestHandlers, notificationHandlers);
        this.authentication = authentication;
    }
}
@Configuration
public class McpConfig
{
    @Bean
    public ToolCallbackProvider tools(...)
    {
        return ToolCallbackProvider.from(ToolCallbacks.from(...));
    }

    @Bean
    public WebMvcSseServerTransportProvider mcpServerTransportProvider(McpServerProperties serverProperties,
                                                                       ObjectProvider<ObjectMapper> objectMapperProvider)
    {
        return new WebMvcSseServerTransportProvider(
            objectMapperProvider.getIfAvailable(ObjectMapper::new),
            serverProperties.getBaseUrl(),
            serverProperties.getSseMessageEndpoint(),
            serverProperties.getSseEndpoint()
        )
        {
            @Override
            public void setSessionFactory(McpServerSession.Factory sessionFactory)
            {
                McpServerSession template = sessionFactory.create(null);

                super.setSessionFactory(transport -> new AuthAwareMcpServerSession(
                    SecurityContextHolder.getContext().getAuthentication(), // auth goes in
                    UUID.randomUUID().toString(),
                    ReflectionUtils.getFieldValue(template, "requestTimeout", Duration.class), // avert your eyes
                    transport,
                    ReflectionUtils.getFieldValue(template, "initRequestHandler", McpServerSession.InitRequestHandler.class),
                    Mono::empty,
                    ReflectionUtils.getFieldValue(template, "requestHandlers", Map.class),
                    ReflectionUtils.getFieldValue(template, "notificationHandlers", Map.class)
                ));
            }
        };
    }

    @Bean
    public RouterFunction<ServerResponse> mvcMcpRouterFunction(WebMvcSseServerTransportProvider transportProvider)
    {
        return transportProvider.getRouterFunction();
    }
}
@Slf4j
@Aspect
@Component
public class ToolAuthAdvice
{
    @Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
    public Object wrapToolInvocation(ProceedingJoinPoint joinPoint) throws Throwable
    {
        ToolContext toolContext = ReflectionUtils.getArgValue(joinPoint, ToolContext.class); // it's magic
        if (toolContext == null)
        {
            log.debug("No context for method annotated with @Tool; executing tool without authentication");
            return joinPoint.proceed();
        }

        McpSyncServerExchange syncExchange = (McpSyncServerExchange) toolContext.getContext().get("exchange");
        Preconditions.checkNotNull(syncExchange, "No sync exchange found in tool context");

        McpAsyncServerExchange asyncExchange = ReflectionUtils.getFieldValue(syncExchange, "exchange", McpAsyncServerExchange.class);
        Preconditions.checkNotNull(asyncExchange, "No async exchange found in sync exchange");

        McpServerSession session = ReflectionUtils.getFieldValue(asyncExchange, "session", McpServerSession.class);
        Preconditions.checkNotNull(session, "No session found in async exchange");

        if (session instanceof AuthAwareMcpServerSession authAwareSession)
        {
            try
            {
                Authentication authentication = authAwareSession.getAuthentication() // auth comes out

                // You may wish to revalidate the authentication here, for this request, since it was added at session creation

                SecurityContextHolder.getContext().setAuthentication(authentication)
                return joinPoint.proceed();
            }
            finally
            {
                SecurityContextHolder.clearContext();
            }
        }
        else
        {
            throw new IllegalArgumentException("Cannot proceed; session is not auth-aware");
        }
    }
}

If this was helpful to you, great! If not, I was never here.

Comment From: mmengLong

~~https://stackoverflow.com/questions/42341635/spring-security-current-user-in-thread~~ ~~@PostConstruct~~ ~~void setGlobalSecurityContext() {~~ ~~SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);~~ ~~}~~

~~works well for WebMVC~~ can not clean info in sub thread

Comment From: coderliguoqing

@mmengLong Oh, that's great, it doesn't seem to have anything to do with SpringAi MCP, it's just that we don't know enough about the capabilities of Spring Security's SecurityContextHolder support to solve the problem perfectly; thk.

Comment From: eric-eldard

I believe SecurityContextHolder.MODE_INHERITABLETHREADLOCAL is the nuclear option. I can confirm it works, but it will carry your security context over anytime a new thread is spawned, anywhere in your app. This may not be desired, and could actually open vulnerabilities for inattentive developers. IMHO, the right solution is to provide an option in Spring AI MCP that allows the security context to be carried over from a request to an async response thread.

Comment From: coderliguoqing

Yes, if the global is enabled SecurityContextHolder.MODE_INHERITABLETHREADLOCAL it will cause some global problems; Spring AI MCP can provide a more suitable selection; However, we are only here to provide ideas for solving the problem, for example, you can block the global problems that you mentioned in the following ways:

public class AuthorizationHolder {
    private static final InheritableThreadLocal<String> holder = new InheritableThreadLocal<>();

    public static void set(String token) {
        holder.set(token);
    }

    public static String get() {
        return holder.get();
    }

    public static void clear() {
        holder.remove();
    }
}

Comment From: ls-rein-martha

Seems I'm experiencing this issue too. I'm running an McpSyncServer, but it just delegates everything to McpAsyncServer (creating a new thread, then blocking on the result), and the security context is fumbled in the handoff. Needed a quick workaround until this gets patched, so...father forgive me for this one...

@eric-eldard It's "beautiful". Stuck for hours on this rabbit hole, your workaround helped me. Will put it in "dontseethisbeautifulcode" package. Thanks a lot.

Comment From: ls-rein-martha

Seems I'm experiencing this issue too. I'm running an McpSyncServer, but it just delegates everything to McpAsyncServer (creating a new thread, then blocking on the result), and the security context is fumbled in the handoff. Needed a quick workaround until this gets patched, so...father forgive me for this one...

@eric-eldard It's "beautiful". Stuck for hours on this rabbit hole, your workaround helped me. Will put it in "dontseethisbeautifulcode" package. Thanks a lot.

Ok, I misunderstood the session there, just to clarify if anyone read the above. I thought that the MCP session is the end user session somehow (which obviously not, since that is not the design it intend for). So to clarify it is the mcp-client session.

Thus, the above will not work, since a single session of mcp-client (host) -> mcp-server can be used by multiple end users concurrently.

So the comment that were mentioned:

You may wish to revalidate the authentication here

Not only revalidate, when it reaches there, the auth actually might belong to another user authentication when there are concurrent users connected, and there is no way to know that since it lost in the thread change.

So well.. looks like we need to wait for the actual patch. Unless I misunderstood the above, or someone has better solutions? 👀

Comment From: coderliguoqing

Yes, the problems mentioned above are real, because

private static final InheritableThreadLocal holder = new InheritableThreadLocal<>(); 

Replication is only made when the parent thread enters the child thread for the first time, that is, if there is a subsequent request to reuse the child thread, then his authorization information will be the permission information of the person who entered the child thread for the first time; Before officially deal with this, there is another way to deal with it:

  • the MCP tool uses the boundedElastic thread pool, which is the recommended scheduler in Reactor for performing blocking I/O or time-consuming operations.
  • A hook needs to be registered at application startup, which "decorates" all tasks submitted to the boundedElastic scheduler for context delivery and cleanup.
@Configuration
public class ReactorContextPropagationConfig {

    @PostConstruct
    public void init() {
        // This is the key for propagating context to Reactor's boundedElastic scheduler
        Function<Runnable, Runnable> decorator = runnable -> {
            // This part is executed on the *submitting* thread (e.g. web request thread)
            String token = McpUserHolder.get();
            return () -> {
                // This part is executed on the *worker* thread (e.g. boundedElastic-1)
                try {
                    McpUserHolder.set(token);
                    runnable.run();
                } finally {
                    log.info("[{}] [Reactor Hook] McpUserHolder clean", Thread.currentThread().getName());
                    McpUserHolder.clear();
                }
            };
        };

        Schedulers.onScheduleHook("McpBoundedElasticHook", decorator);
    }
}




**Comment From: ls-rein-martha**

> Before officially deal with this, there is another way to deal with it:
> 
> * the MCP tool uses the boundedElastic thread pool, which is the recommended scheduler in Reactor for performing blocking I/O or time-consuming operations.
> * A hook needs to be registered at application startup, which "decorates" all tasks submitted to the boundedElastic scheduler for context delivery and cleanup.
> 
> @Configuration
> public class ReactorContextPropagationConfig {
> 
>     @PostConstruct
>     public void init() {
>         // This is the key for propagating context to Reactor's boundedElastic scheduler
>         Function<Runnable, Runnable> decorator = runnable -> {
>             // This part is executed on the *submitting* thread (e.g. web request thread)
>             String token = McpUserHolder.get();
>             return () -> {
>                 // This part is executed on the *worker* thread (e.g. boundedElastic-1)
>                 try {
>                     McpUserHolder.set(token);
>                     runnable.run();
>                 } finally {
>                     log.info("[{}] [Reactor Hook] McpUserHolder clean", Thread.currentThread().getName());
>                     McpUserHolder.clear();
>                 }
>             };
>         };
> 
>         Schedulers.onScheduleHook("McpBoundedElasticHook", decorator);
>     }
> }

@coderliguoqing 
Could you help explain further on your workaround?
I assume this is when the sec inherit strat below used?

SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);


And care to elaborate what is the `McpUserHolder` contain?


Update:
- After some thought on the snippet, I think you mean to not use MODE_INHERITABLETHREADLOCAL
- then handle  the auth retrieval (from tool/whatever) based on that `McpUserHolder` instead

?

**Comment From: coderliguoqing**

> > Before officially deal with this, there is another way to deal with it:
> > 
> > * the MCP tool uses the boundedElastic thread pool, which is the recommended scheduler in Reactor for performing blocking I/O or time-consuming operations.
> > * A hook needs to be registered at application startup, which "decorates" all tasks submitted to the boundedElastic scheduler for context delivery and cleanup.
> > 
> > [@configuration](https://github.com/configuration)
> > public class ReactorContextPropagationConfig {
> > ```
> > @PostConstruct
> > public void init() {
> >     // This is the key for propagating context to Reactor's boundedElastic scheduler
> >     Function<Runnable, Runnable> decorator = runnable -> {
> >         // This part is executed on the *submitting* thread (e.g. web request thread)
> >         String token = McpUserHolder.get();
> >         return () -> {
> >             // This part is executed on the *worker* thread (e.g. boundedElastic-1)
> >             try {
> >                 McpUserHolder.set(token);
> >                 runnable.run();
> >             } finally {
> >                 log.info("[{}] [Reactor Hook] McpUserHolder clean", Thread.currentThread().getName());
> >                 McpUserHolder.clear();
> >             }
> >         };
> >     };
> > 
> >     Schedulers.onScheduleHook("McpBoundedElasticHook", decorator);
> > }
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > }
> 
> [@coderliguoqing](https://github.com/coderliguoqing) Could you help explain further on your workaround? I assume this is when the sec inherit strat below used?
> 
> ```
> SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
> ```
> 
> And care to elaborate what is the `McpUserHolder` contain?
> 
> Update:
> 
> * After some thought on the snippet, I think you mean to not use MODE_INHERITABLETHREADLOCAL
> * then handle  the auth retrieval (from tool/whatever) based on that `McpUserHolder` instead
> 
> ?

As mentioned in my comment above, it is clear that this model has an impact on the overall situation, and there are problems with this approach, and what I really need to do is what I described above;
The following table describes the implementation of the McpUserHolder class

```java 
public class McpUserHolder {
    private static final InheritableThreadLocal<String> holder = new InheritableThreadLocal<>();

    public static void set(String token) {
        holder.set(token);
    }

    public static String get() {
        return holder.get();
    }

    public static void clear() {
        holder.remove();
    }
}

Comment From: venkybirla

I am also facing the same issue(Authentication lost in tool execution) and also i am not using spring in my server. Could you please help me to find a solution for it. I dont see any solution approved here. Thanks in advance. Also curious to know , already many MCP servers out there in market, how they resolved it ? are they using different language or sdk for it?

Comment From: ls-rein-martha

I am also facing the same issue(Authentication lost in tool execution) and also i am not using spring in my server. Could you please help me to find a solution for it. I dont see any solution approved here. Thanks in advance.

You still can use the Schedulers.onScheduleHook workaround since that is the project reactor, not Spring. You just need to put it before and clean up after, like what @coderliguoqing said. I am not a project-reactor expert, but I believe it is a solid solution.

Also curious to know , already many MCP servers out there in market, how they resolved it ? are they using different language or sdk for it?

No idea on this, but this issue is SDK language specific, so it probably not the case for others.

Comment From: sp1986sp

I also tried to solve the problem of sending the headers and threadlocal variables in this git repo. Please check it is a working code: https://github.com/sp1986sp/mcp-client-server-azure

Comment From: ls-rein-martha

I also tried to solve the problem of sending the headers and threadlocal variables in this git repo. Please check it is a working code: https://github.com/sp1986sp/mcp-client-server-azure

@sp1986sp cool thank you, I indeed plan to put similar approach.

NB: Not to be nosy, but your sequence diagram is incorrect. Assuming Client is your MCP Host (that have MCP Client) and Server is the MCP Server, then LLM as in your Azure OpenAI will not directly communicate with MCP Server and call the tool in it, it is not at SkyNet level ~yet~

Comment From: sp1986sp

@ls-rein-martha Thank you for pointing out I have updated the sequence diagram

Comment From: jackdpeterson

Thanks for already creating this issue. I'll put in the general, "yep, I'm also experiencing issues with lack of authentication within the reactive implementation".

The logging indicating that one can authenticate at a global level but the security context isn't propagated is ... frustrating to work with since this is so foundational to building any app that does anything that depends on a specific user be known to run said operations.

I'm definitely looking forward to this being fixed and just being able to rely on injecting in an @AuthenticationPrincipal or accessing from the reactive context.

I suppose the other thing that's really confusing is why the reactive endpoint doesn't work with returning Mono<T> or Flux<T> but must be the synchronous implementation within the scope of a Tool is also confusing. But that's another issue for another time.

Comment From: shlomiken

https://stackoverflow.com/questions/42341635/spring-security-current-user-in-thread

@PostConstruct void setGlobalSecurityContext() { SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); }

works well for WebMVC

this is so bad !! - i tried this one but since this server also expose REST API , there was a mess in user authentications. also i think its bad you never know which user is going to be used, cause the parent thread could be anyone creating the first thread in the pool.

Comment From: shlomiken

The only reasonable solution chat GBT found , after putting hours into this

@Configuration
public class ReactorSecurityPropagationConfig {

    private static final String HOOK_KEY = "security-context-propagation";

    @PostConstruct
    public void registerSecurityContextHook() {
        /*
         * Schedulers.onScheduleHook(key, decorator)
         * decorates every Runnable that Reactor puts on any Scheduler
         * (boundedElastic, parallel, single, etc.).
         */
        Schedulers.onScheduleHook(HOOK_KEY, original -> {
            // Capture the context from the *submitting* thread (the HTTP one)
            SecurityContext captured = SecurityContextHolder.getContext();
            /*
             * Wrap the original task so that, when it runs on another thread,
             * the SecurityContext is first set, then cleared afterward.
             * DelegatingSecurityContextRunnable already does this safely.
             */
            return new DelegatingSecurityContextRunnable(original, captured);
        });
    }
}

Comment From: pingteck

I also tried to solve the problem of sending the headers and threadlocal variables in this git repo. Please check it is a working code: https://github.com/sp1986sp/mcp-client-server-azure

@sp1986sp I could be wrong here, but it seems like your task executor is not being used. You'll need to override Schedulers.Factory.newBoundedElastic cause tool execution is being subscribed on Schedulers.boundedElastic()

Comment From: mikegron

The only reasonable solution chat GBT found , after putting hours into this

``` @Configuration public class ReactorSecurityPropagationConfig {

```

This seems to work with a setup including MVC and MCP starter together to pass the context, but we'll check further if it does something funny!

Comment From: kuercan

For anyone else looking for a quick workaround, changing the SecurityContextHolder strategy can resolve the issue in some scenarios. By setting the strategy to MODE_INHERITABLETHREADLOCAL, the security context from the parent thread can be passed down to child threads spawned for tool execution.

You can apply this globally by adding the following configuration to your application:

import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration
import org.springframework.security.core.context.SecurityContextHolder

@Configuration
class SecurityConfig {

    @PostConstruct
    fun init() {
        // Set SecurityContext to use inheritable thread-local storage.
        // This allows child threads to inherit the SecurityContext from their parent.
        SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)
    }
}

Comment From: SightStudio

@kuercan

Do not use SecurityContextHolder.MODE_INHERITABLETHREADLOCAL

We had a serious issue that user A used user B’s auth token because of that approach

Rather Using this

which is @coderliguoqing proposed here

@Configuration
@Slf4j
public class ReactorElasticBoundConfig {

    @PostConstruct
    public void init() {
        Function<Runnable, Runnable> decorator = runnable -> {
            Jwt token = new AccessTokenExtractor(SecurityContextHolder.getContext())
                    .extractAccessToken();

            return () -> {
                try {
                    McpUserHolder.set(token.getTokenValue());
                    runnable.run();
                } finally {
                    log.info("[{}] [Reactor Hook] McpUserHolder clean", 
                             Thread.currentThread().getName());
                    McpUserHolder.clear();
                }
            };
        };

        Schedulers.onScheduleHook("McpBoundedElasticHook", decorator);
    }
}


@RequiredArgsConstructor
public class AccessTokenExtractor {

    private final SecurityContext securityContext;

    public Jwt extractAccessToken() {
        Authentication authentication = securityContext.getAuthentication();
        if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
            throw new IllegalStateException("Authentication is not a JwtAuthenticationToken");
        }
        return jwtAuth.getToken();
    }
}

and clear in tool either

@Tool(
    name = "my-info",
    description = "checkMyInfo"
) 
public MyInfoResponse getMyInfo() {
    try {
        String token = McpUserHolder.get();

        // .... logic
    } finally {
        McpUserHolder.clear();
    }
}

In our Spring MVC-based MCP, we forgot to call McpUserHolder.clear() after tool execution — and some tools started leaking sockets.

The number of open file descriptors kept going up.

Image

Comment From: SightStudio

@tzolov @markpollack

(comment I wrote above)

I solved this by using Spring Security in Spring AI to make the auth token accessible inside the tool execution. As far as I know, this is the only way that works — but I’m wondering, is there a “correct” solution for this? It’s a pretty important issue, and I think it would help everyone if someone could provide a clear best practice.

Comment From: mollyegibson

I was able to get the security context propagated by adding this to my config:

  @PostConstruct
  public void init() {
    Function<Runnable, Runnable> decorator =
        runnable -> {
          Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
          return () -> {
            try {
              SecurityContextHolder.getContext().setAuthentication(authentication);
              runnable.run();
            } finally {
              SecurityContextHolder.clearContext();
            }
          };
        };

    Schedulers.onScheduleHook("McpBoundedElasticHook", decorator);
  }

Should I be worried about the security context leaking across requests? I want to be sure I'm not introducing a security hole into my MCP server.