How can I pass the API key from the MCP client to the MCP server in Spring AI without including it in the prompt? I tried using Tool Context, but it was not working. If this issue can be solved with Tool Context, can anyone share how to do that? Maybe my implementation was wrong.
MCP Client code where I want to pass API Key from MCP Client to MCP Server:
chatClient
.prompt()
.system("""
System prompt.
""")
.user(requestDto.prompt())
.toolCallbacks(toolCallbackProvider.getToolCallbacks())
.toolContext(Map.of("apiKey", "test_key"))
.call()
.content()
MCP Server where I want to read the API code sent from the client:
@Tool(name = "tool name", description = "tool description")
public List<Data> getData(@ToolParam(description = "des") TimeRange timeRange,
ToolContext toolContext) {
}
In the above toolContext
I am not able to get API key I send from client.
There was only an "exchange" key in the toolContext
Map context. This is why I reached the conclusion that tool context was not working as I was not able to see the key value in the map I passed from the client.
This is the config I am using in the client:
@Configuration
public class MCPConfig {
@Bean
public SyncMcpToolCallbackProvider toolCallbackProvider(List<McpSyncClient> clients) {
return new SyncMcpToolCallbackProvider(clients);
}
}
In the MCP server code:
@Tool(name = "tool name", description = "tool description")
public List<Data> getData(@ToolParam(description = "des") PlayBackState playBackState,
ToolContext toolContext) {
String apiKey = toolContext.getContext().get("apiKey").toString();
}
Here I am getting nullpointerexception
as .get("apiKey")
is returning null
.
Ideally it should have returned the value passed from the MCP Client; "test_key"
is the value that should have been present.
Below is the log I am getting on the above MCP server tool call:
2025-06-22T20:09:21.463+05:30 TRACE 17000 --- [app-mcp-server] [nio-8081-exec-3] o.s.w.s.f.support.RouterFunctionMapping : Mapped to io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$$Lambda/0x000001ba36662f38@b76b7d8
2025-06-22T20:09:21.463+05:30 DEBUG 17000 --- [app-mcp-server] [nio-8081-exec-3] io.modelcontextprotocol.spec.McpSchema : Received JSON message: {"jsonrpc":"2.0","method":"tools/call","id":"355f2a07-8","params":{"name":"playback_controller","arguments":{"playBackState":"PAUSE"}}}
2025-06-22T20:09:21.463+05:30 DEBUG 17000 --- [app-mcp-server] [nio-8081-exec-3] i.m.spec.McpServerSession : Received request: JSONRPCRequest[jsonrpc=2.0, method=tools/call, id=355f2a07-8, params={name=playback_controller, arguments={playBackState=PAUSE}}]
2025-06-22T20:09:21.463+05:30 DEBUG 17000 --- [app-mcp-server] [oundedElastic-1] o.s.ai.tool.method.MethodToolCallback : Starting execution of tool: playback_controller
2025-06-22T20:09:59.228+05:30 DEBUG 17000 --- [app-mcp-server] [nio-8081-Poller] org.apache.tomcat.util.net.NioEndpoint : timeout completed: keys processed=2; now=1750603199227; nextExpiration=1750603161517; keyCount=0; hasEvents=false; eval=false
2025-06-22T20:09:59.231+05:30 DEBUG 17000 --- [app-mcp-server] [oundedElastic-1] i.m.s.t.WebMvcSseServerTransportProvider : Message sent to session a3545270-ab97-4ede-882f-2bdccb36097a
2025-06-22T20:09:59.232+05:30 TRACE 17000 --- [app-mcp-server] [nio-8081-exec-3] o.s.web.servlet.DispatcherServlet : No view rendering, null ModelAndView returned.
2025-06-22T20:09:59.232+05:30 DEBUG 17000 --- [app-mcp-server] [nio-8081-exec-3] o.s.web.servlet.DispatcherServlet : Completed 200 OK, headers={}
2025-06-22T20:09:59.232+05:30 TRACE 17000 --- [app-mcp-server] [nio-8081-exec-3] o.s.b.w.s.f.OrderedRequestContextFilter : Cleared thread-bound request context: org.apache.catalina.connector.RequestFacade@4cb31c2e
2025-06-22T20:09:59.232+05:30 DEBUG 17000 --- [app-mcp-server] [nio-8081-exec-3] o.a.coyote.http11.Http11InputBuffer : Before fill(): parsingHeader: [true], parsingRequestLine: [true], parsingRequestLinePhase: [0], parsingRequestLineStart: [0], byteBuffer.position(): [0], byteBuffer.limit(): [0], end: [351]
When I looked into SyncMcpToolCallback
class in package org.springframework.ai.mcp
:
public String call(String toolArguments, ToolContext toolContext) {
return this.call(toolArguments);
}
ToolContext is not used. So, from my understanding, toolContext
is not passed over the network in MCP Client.
Can this be the reason? If it is, why is that done like that?
This is the response I get in the MCP client:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 30000ms in 'Mono.deferContextual ⇢ at io.modelcontextprotocol.spec.McpClientSession.sendRequest(McpClientSession.java:233)' (and no fallback has been configured)",
"instance": "/chat"
}
I worked around this by providing a WebClient.Builder bean with an `ExchangeFilterFunction` that adds context-related headers to each request. On the server side, I intercept those requests and use `ThreadLocal` appropriately, ensuring it’s cleaned up at the end of processing.
\> For the transport layer, you must use the **WebFlux-based client** (either SYNC or ASYNC mode) to ensure these headers propagate correctly.
\> This approach is designed for a Spring MVC architecture (one thread per request). For non-blocking/reactive architectures, you should use Reactor’s `Context instead of `ThreadLocal`.
at MCP Server side
A holder for the data (in my case, the userId
). Use these static methods to access the context within your tool calls.
public class CustomVarHolder {
private static final ThreadLocal<String> userId=new ThreadLocal<>();
public static void setUserId(String uString){
userId.set(uString);
}
public static String getUserId(){
return userId.get();
}
public static void clear(){
userId.remove();
}
}
Interceptor for the request:
@Component
public class CustomHandlerInterceptor implements HandlerInterceptor{
public static final String USER_HEADER_NAME = "X-User-Header";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String userId=request.getHeader(USER_HEADER_NAME);
if(userId!=null){
CustomVarHolder.setUserId(userId);
}
else{
System.out.println("no header found");
}
return true;
}
//Clear the value in the context holder
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception{
CustomVarHolder.clear();
System.out.println("thread local cleared");
}
}
Configuring the interceptor
@Configuration
public class CustomWebMVCConfigurer implements WebMvcConfigurer{
private final CustomHandlerInterceptor interceptor;
CustomWebMVCConfigurer(CustomHandlerInterceptor interceptor){
this.interceptor=interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor).addPathPatterns("/**");
System.out.println("interceptor is added");
}
}
at MCP Client side:
Configuring a Webclient builder bean to inject to WebFluxSseClientTransport
@Configuration
public class CustomUserHeader {
private static ThreadLocal<String> userHeader = new ThreadLocal<>();
public static final String USER_HEADER_NAME = "X-User-Header";
public static void setUserHeader(String user) {
userHeader.set(user);
}
public static String getUserHeader() {
return userHeader.get();
}
public static void clear() {
userHeader.remove();
}
@Bean
@Primary
public WebClient.Builder webClientBuilder() {
System.out.println("Using WebClient from builder: ");
return WebClient.builder().filter(filterFunction());
}
private ExchangeFilterFunction filterFunction(){
return ExchangeFilterFunction.ofRequestProcessor(req->{
String user = CustomUserHeader.getUserHeader();
System.out.println("from filter function "+user);
if(user != null) {
ClientRequest modifiedClientRequest = ClientRequest.from(req).headers(
h->{
h.add(USER_HEADER_NAME, user);
}
).build();
return Mono.just(
modifiedClientRequest
);
}
System.out.println("user id is null from filter function");
return Mono.just(req);
});
}
}
creating and handling context
@GetMapping("chat")
public String getMethodName(@RequestParam(value = "message",defaultValue = "tell a joke") String param,@RequestParam(name = "user",defaultValue = "12323")String userId) {
try{
//setting the value to be send as context
CustomUserHeader.setUserHeader(userId);
ToolCallingChatOptions options =ToolCallingChatOptions.builder().toolCallbacks(syncMcpToolCallbackProvider.getToolCallbacks()).internalToolExecutionEnabled(true).build();
Prompt prompt = Prompt.builder().chatOptions(options).messages(List.of(UserMessage.builder().text(param).build())).build();
return chatClient.prompt(prompt).system("give direct answers nothing more nothing less").call().content();
}
finally{
//must clear the context value to prevent reusing the value in re use of thread
CustomUserHeader.clear();
}
}
a