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:
/chat → Non-streaming/stream-chat → StreamingWhen 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 ?
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());
}
}
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);
}
});
}
}
}
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();
}
}
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.
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
org.springframework.boot:spring-boot-starter-webspring.ai.mcp.client.type=SYNCorg.springframework.ai:spring-ai-starter-mcp-client
a. The examples below should also work with WebClientChatClient#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.