javaspringproject-reactormodel-context-protocolspring-ai

Spring MCP Client do not access to HTTP Headers from McpTransportContext in stream mode


I'm trying to implement a Spring MCP Client based on the spring-ai 1.1.0-SNAPSHOT library.

The minimal project source code is available in this public GitHub repository: https://github.com/robynico/spring-mcp-client-stream.

The Spring controller exposes 2 endpoints:

When calling /stream-chat, the ServletContext is not available from McpTransportContext despite implementing AuthenticationMcpTransportContextProvider as recommended in: https://github.com/spring-ai-community/mcp-security

Question:
How to propagate HTTP headers in a thread-safe way to the MCP tool execution context when using streaming with reactor ?


Controller

ChatController.java

@RestController
@Validated
public class ChatController {

    public static final String HTTP_HEADER_TENANT = "Tenant";

    private static final Logger logger = LoggerFactory.getLogger(ChatController.class);

    private final AgentService agentService;

    public ChatController(AgentService agentService) {
        this.agentService = agentService;
    }

    @PostMapping(value = "/chat")
    public String chat(@RequestBody @Valid ChatRequest request) {
        return agentService.chat(request.getPrompt());
    }

    @PostMapping(value = "/stream-chat")
    public Flux<String> streamChat(@RequestBody @Valid ChatRequest request, @Headers HttpHeaders headers) {
        var requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        logger.info("Tenant {}", requestAttributes.getRequest().getHeader(HTTP_HEADER_TENANT));
        return agentService.streamChat(request.getPrompt());
    }

}

ContextProvider & RequestCustomizer

McpConfiguration.java

@Configuration
public class McpConfiguration implements WebMvcConfigurer {

    @Bean
    McpSyncClientCustomizer syncClientCustomizer() {
        return (name, syncSpec) -> syncSpec.transportContextProvider(new AuthenticationMcpTransportContextProvider());
    }

    @Bean
    McpSyncHttpClientRequestCustomizer requestCustomizer() {
        return new CustomMcpSyncRequestCustomizer();
    }

}

AuthenticationMcpTransportContextProvider.java

public class AuthenticationMcpTransportContextProvider implements Supplier<McpTransportContext> {

    private static final Logger logger = LoggerFactory.getLogger(AuthenticationMcpTransportContextProvider.class);

    public static final String HTTP_HEADERS_KEY = "httpHeaders";

    @Override
    public McpTransportContext get() {
        var data = new HashMap<String, Object>();
        RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
        if (previousAttributes instanceof ServletRequestAttributes) {
            HttpServletRequest request = ((ServletRequestAttributes) previousAttributes).getRequest();
            Map<String, String> headers = new HashMap<>();
            List.of(HttpHeaders.AUTHORIZATION, HTTP_HEADER_TENANT).forEach(headerName -> {
                String value = request.getHeader(headerName);
                if (value != null) {
                    headers.put(headerName, value);
                }
            });
            data.put(HTTP_HEADERS_KEY, headers);
        }
        logger.info("Headers found in RequestContextHolder: {}", data);
        return McpTransportContext.create(data);
    }

}

CustomMcpSyncRequestCustomizer.java

class CustomMcpSyncRequestCustomizer implements McpSyncHttpClientRequestCustomizer {

    private static final Logger logger = LoggerFactory.getLogger(CustomMcpSyncRequestCustomizer.class);

    @Override
    public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
            McpTransportContext context) {
        var headers = context.get(AuthenticationMcpTransportContextProvider.HTTP_HEADERS_KEY);
        logger.info("Headers found in McpTransportContext: {}", headers);

        if (headers instanceof Map<?, ?> headerMap) {
            List.of(HttpHeaders.AUTHORIZATION, HTTP_HEADER_TENANT).forEach(headerName -> {
                var value = headerMap.get(headerName);
                if (value instanceof String stringValue) {
                    builder.header(headerName, stringValue);
                }
            });
        }
    }

}

ChatClient

AgentService.java

@Component
public class AgentService {

    private final ChatClient chatClient;

    public AgentService(ChatClient.Builder chatClientBuilder, ToolCallbackProvider mcpToolProvider) {
        var options = BedrockChatOptions.builder()
            .model("anthropic.claude-3-5-sonnet-20240620-v1:0")
            .temperature(0.6)
            .maxTokens(3000)
            .build();
        chatClient = chatClientBuilder.defaultOptions(options).defaultToolCallbacks(mcpToolProvider).build();
    }

    public Flux<String> streamChat(String prompt) {
        return chatClient.prompt().user(userMessage -> userMessage.text(prompt)).stream().content();
    }

    public String chat(String prompt) {
        return chatClient.prompt().user(userMessage -> userMessage.text(prompt)).call().content();
    }

}

Test

Curl:

curl -X POST http://localhost:8080/stream-chat \
-H "Content-Type: application/json" \
-H "Tenant: A " \
-d '{"prompt": "do an echo toto"}'

Observed Logs:

[nio-8080-exec-3] c.c.a.m.c.controller.ChatController      : Tenant A
[yEventLoop-1-14] o.s.a.b.converse.BedrockProxyChatModel   : Completed streaming response.
[oundedElastic-2] uthenticationMcpTransportContextProvider : Headers found in RequestContextHolder: {}
[oundedElastic-2] c.a.m.c.c.CustomMcpSyncRequestCustomizer : Headers found in McpTransportContext: null
[yEventLoop-1-14] o.s.a.b.converse.BedrockProxyChatModel   : Completed streaming response.

Solution

  • Edit 2025-11-13

    This is now supported in MCP-security version 0.0.4

    You now need to add this to your streaming calls:

      chatClient
        .prompt("<your prompt>")
        .stream()
        .content()
        // ... any streaming operation ...
        .contextWrite(
          AuthenticationMcpTransportContextProvider.writeToReactorContext()
        );
    

    See the documentation for further instructions to configure your project.


    The problem is losing thread-locals when switching to a Reactor context. Whenever you call .stream(), this will be executed by Reactor. Reactor has its own way of propagating information, the Context API.

    But the Spring AI Tool API is blocking, and therefore does not have access to the Context API. So you need to jump through a few hoops to get this to work.

    Assumptions on the project

    1. Using Spring MVC, with org.springframework.boot:spring-boot-starter-web
    2. Using MCP sync clients spring.ai.mcp.client.type=SYNC
    3. Using HttpClient-based MCP clients, with org.springframework.ai:spring-ai-starter-mcp-client a. The examples below should also work with WebClient
    4. And, as mentioned earlier, using ChatClient#stream()

    To get information into the context of the SyncMcpToolCallback, you first have to add it to the Reactive context of the streaming call:

    var response = chatClient.prompt("What's the weather in Paris?")
            .toolCallbacks(this.mcpToolCallbacks)
            .stream()
            .content()
            .contextWrite(ctx -> ctx.put("attributes", RequestContextHolder.getRequestAttributes())
                    .put("authentication", SecurityContextHolder.getContext().getAuthentication())
                    .put("custom-header", "custom-header-value))
           // ...
    

    In here, I'm only putting request attributes and authentication, but you could add anything value you want. This will be stored in a thread local in ToolCallReactiveContextHolder and will be accessible in the client's transportContextProvider.

    So update your client to fetch those values, and put them in an McpTransportContext, like so:

    @Bean
    McpSyncClientCustomizer syncClientCustomizer() {
        return (name, syncSpec) -> syncSpec.transportContextProvider(() -> {
            var ctx = ToolCallReactiveContextHolder.getContext();
            var data = new HashMap<String, Object>();
            // used by OAuth2AuthorizationCodeSyncHttpRequestCustomizer
            // you may not need it
            if (ctx.hasKey("authentication")) {
                var authentication = ctx.get("authentication");
                data.put(AuthenticationMcpTransportContextProvider.AUTHENTICATION_KEY, authentication);
            }
            // used by OAuth2AuthorizationCodeSyncHttpRequestCustomizer
            // you may not need it
            if (ctx.hasKey("attributes")) {
                var requestAttributes = ctx.get("attributes");
                data.put(AuthenticationMcpTransportContextProvider.REQUEST_ATTRIBUTES_KEY, requestAttributes);
            }
            if (ctx.hasKey("custom-header")) {
                var headerValue = ctx.get("custom-header");
                data.put("custom-header", headerValue);
            }
    
            return McpTransportContext.create(data);
        });
    }
    

    And with this, you can write a custom McpSyncHttpRequestCustomizer to extract that data and put it on the request:

    @Bean
    McpSyncHttpClientRequestCustomizer requestCustomizer() {
        return (builder, method, endpoint, body, context) -> {
            // Note: we are not using the request attributes or authentication here
            // But it's 100% possible
            String headerValue = context.get("custom-header").toString();
            builder.header("x-custom-header", headerValue);
        };
    }
    

    A note on ASYNC clients:

    If you're using only the streaming API, you may want to consider using the ASYNC mode for your MCP client. In that case, all the information will pass through the Reactor context. Like the example above, you add the context to your request:

    var response = chatClient.prompt("What's the weather in Paris?")
            .toolCallbacks(this.mcpToolCallbacks)
            .stream()
            .content()
            .contextWrite(ctx -> ctx.put("my-header", "some value"))
           // ...
    

    Then, provide an McpAsyncHttpClientRequestCustomizer:

    @Bean
    McpAsyncHttpClientRequestCustomizer asyncRequestCustomizer() {
        return (builder, method, endpoint, body, context) -> Mono.deferContextual(ctx -> {
            String headerValue = ctx.get("my-header");
            return Mono.just(builder.header("x-custom-header", headerValue));
        });
    }
    

    Since it has access to the Reactor context, no need for a TransportContextProvider.