Skip to content

Commit eb8ea08

Browse files
committed
mcp-client: introduce support for chat streaming in AuthenticationMcpTransportContextProvider
- Backfilled tests that interact with MCP through the lens of the Spring AI ChatClient. - Closes #19 Signed-off-by: Daniel Garnier-Moiroux <[email protected]>
1 parent d041b01 commit eb8ea08

File tree

11 files changed

+406
-10
lines changed

11 files changed

+406
-10
lines changed

mcp-client-security/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@
8383
<artifactId>spring-webflux</artifactId>
8484
<scope>provided</scope>
8585
</dependency>
86+
<dependency>
87+
<groupId>org.springframework.ai</groupId>
88+
<artifactId>spring-ai-model</artifactId>
89+
<scope>provided</scope>
90+
</dependency>
8691
<dependency>
8792
<groupId>jakarta.servlet</groupId>
8893
<artifactId>jakarta.servlet-api</artifactId>

mcp-client-security/src/main/java/org/springaicommunity/mcp/security/client/sync/AuthenticationMcpTransportContextProvider.java

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,120 @@
1616

1717
package org.springaicommunity.mcp.security.client.sync;
1818

19-
import io.modelcontextprotocol.common.McpTransportContext;
19+
import java.net.http.HttpClient;
2020
import java.util.HashMap;
2121
import java.util.function.Supplier;
2222

23+
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
24+
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
25+
import io.modelcontextprotocol.common.McpTransportContext;
26+
import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer;
27+
import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2HybridSyncHttpRequestCustomizer;
28+
import org.springaicommunity.mcp.security.client.sync.oauth2.webclient.McpOAuth2AuthorizationCodeExchangeFilterFunction;
29+
import org.springaicommunity.mcp.security.client.sync.oauth2.webclient.McpOAuth2HybridExchangeFilterFunction;
30+
import reactor.util.context.Context;
31+
import reactor.util.context.ContextView;
32+
33+
import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder;
2334
import org.springframework.security.core.Authentication;
2435
import org.springframework.security.core.context.SecurityContextHolder;
2536
import org.springframework.web.context.request.RequestAttributes;
2637
import org.springframework.web.context.request.RequestContextHolder;
38+
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
39+
import org.springframework.web.reactive.function.client.WebClient;
2740

2841
/**
42+
* A supplier that extracts security-related information from the "context", and make it
43+
* available to MCP clients when they send requests to MCP servers. It extracts request
44+
* attributes and the current authentication object. In Servlet application, this is
45+
* achieved with {@link SecurityContextHolder} and {@link RequestContextHolder}.
46+
* <p>
47+
* This can be used in conjunction with {@link McpSyncHttpClientRequestCustomizer} and
48+
* {@link McpAsyncHttpClientRequestCustomizer} for {@link HttpClient}-based transports,
49+
* and with {@link ExchangeFilterFunction} for {@link WebClient}-based transports.
50+
* <p>
51+
* This is usually used through a Spring AI {@code McpSyncClientCustomizer} or
52+
* {@code McpAsyncClientCustomizer}, like so:
53+
*
54+
* <pre>
55+
* &#x40;Bean
56+
* McpSyncClientCustomizer syncClientCustomizer() {
57+
* return (name, syncSpec) -> syncSpec
58+
* .transportContextProvider(
59+
* new AuthenticationMcpTransportContextProvider()
60+
* );
61+
* }
62+
* </pre>
63+
*
64+
* <p>
65+
* When using Spring's {@code ChatClient} "streaming" capabilities, you must also use
66+
* {@link #writeToReactorContext()} to make thread-locals available in the stream's
67+
* reactor context:
68+
*
69+
* <pre>
70+
* chatClientSupplier.get()
71+
* .prompt("your LLM prompt")
72+
* .stream()
73+
* .content()
74+
* .contextWrite(AuthenticationMcpTransportContextProvider.writeToReactorContext())
75+
* // ...
76+
* </pre>
77+
*
2978
* @author Daniel Garnier-Moiroux
79+
* @see OAuth2AuthorizationCodeSyncHttpRequestCustomizer
80+
* @see OAuth2HybridSyncHttpRequestCustomizer
81+
* @see McpOAuth2AuthorizationCodeExchangeFilterFunction
82+
* @see McpOAuth2HybridExchangeFilterFunction
3083
*/
3184
public class AuthenticationMcpTransportContextProvider implements Supplier<McpTransportContext> {
3285

3386
public static final String AUTHENTICATION_KEY = Authentication.class.getName();
3487

3588
public static final String REQUEST_ATTRIBUTES_KEY = RequestAttributes.class.getName();
3689

90+
public static final String REACTOR_CONTEXT_KEY = "org.springaicommunity.mcp.security.client.sync.REACTOR_CONTEXT";
91+
92+
private final boolean reactiveContextHolderAvailable;
93+
94+
public AuthenticationMcpTransportContextProvider() {
95+
boolean reactiveContextHolderAvailable = false;
96+
try {
97+
Class.forName("org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder");
98+
reactiveContextHolderAvailable = true;
99+
}
100+
catch (ClassNotFoundException ignored) {
101+
}
102+
this.reactiveContextHolderAvailable = reactiveContextHolderAvailable;
103+
}
104+
105+
/**
106+
* Helper function to write to thread-locals to the reactor context. Use it on your
107+
* reactive {@code ChatClient} operations, such as
108+
* {@code chatClient.prompt("...").stream().content()}.
109+
* <p>
110+
* Do NOT use if Reactor is not on the classpath.
111+
*/
112+
public static ContextView writeToReactorContext() {
113+
return Context.empty().put(REACTOR_CONTEXT_KEY, fromThreadLocals());
114+
}
115+
116+
/**
117+
* Read authentication and request data from thread-locals. If they are not available,
118+
* and a Spring AI {@code ToolCallReactiveContextHolder} is available on the
119+
* classpath, it will try to access the values there.
120+
*/
37121
@Override
38122
public McpTransportContext get() {
123+
var transportContext = fromThreadLocals();
124+
125+
if (this.reactiveContextHolderAvailable && transportContext == McpTransportContext.EMPTY) {
126+
transportContext = fromToolCallReactiveContextHolder();
127+
}
128+
129+
return transportContext;
130+
}
131+
132+
private static McpTransportContext fromThreadLocals() {
39133
var data = new HashMap<String, Object>();
40134

41135
var securityContext = SecurityContextHolder.getContext();
@@ -48,7 +142,19 @@ public McpTransportContext get() {
48142
data.put(REQUEST_ATTRIBUTES_KEY, requestAttributes);
49143
}
50144

145+
if (data.isEmpty()) {
146+
return McpTransportContext.EMPTY;
147+
}
148+
51149
return McpTransportContext.create(data);
52150
}
53151

152+
private static McpTransportContext fromToolCallReactiveContextHolder() {
153+
var reactorContext = ToolCallReactiveContextHolder.getContext();
154+
if (reactorContext == Context.empty()) {
155+
return McpTransportContext.EMPTY;
156+
}
157+
return reactorContext.getOrDefault(REACTOR_CONTEXT_KEY, McpTransportContext.EMPTY);
158+
}
159+
54160
}

samples/integration-tests/pom.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
<artifactId>mcp-client-security</artifactId>
4949
<version>0.0.4-SNAPSHOT</version>
5050
</dependency>
51-
51+
<dependency>
52+
<groupId>org.springframework.ai</groupId>
53+
<artifactId>spring-ai-starter-model-anthropic</artifactId>
54+
</dependency>
5255
<dependency>
5356
<groupId>org.springframework.experimental.boot</groupId>
5457
<artifactId>spring-boot-testjars</artifactId>

samples/integration-tests/src/main/java/org/springaicommunity/mcp/security/tests/McpController.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@
1616

1717
package org.springaicommunity.mcp.security.tests;
1818

19+
import java.util.Optional;
20+
1921
import io.modelcontextprotocol.spec.McpSchema;
22+
import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider;
2023

24+
import org.springframework.ai.chat.client.ChatClient;
25+
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
26+
import org.springframework.beans.factory.ObjectProvider;
27+
import org.springframework.util.function.SingletonSupplier;
2128
import org.springframework.web.bind.annotation.GetMapping;
2229
import org.springframework.web.bind.annotation.RestController;
2330

@@ -29,8 +36,15 @@ public class McpController {
2936

3037
private final InMemoryMcpClientRepository repository;
3138

32-
McpController(InMemoryMcpClientRepository repository) {
39+
// Not all tests have ChatClient support, so we wrap it in a supplier that will
40+
// only be used in LLM-powered tests.
41+
private final SingletonSupplier<ChatClient> chatClientSupplier;
42+
43+
McpController(InMemoryMcpClientRepository repository, ObjectProvider<ChatClient.Builder> chatClientBuilder,
44+
Optional<SyncMcpToolCallbackProvider> mcpTools) {
3345
this.repository = repository;
46+
this.chatClientSupplier = SingletonSupplier
47+
.of(() -> chatClientBuilder.getIfUnique().defaultToolCallbacks(mcpTools.get()).build());
3448
}
3549

3650
@GetMapping("/tool/call")
@@ -42,4 +56,24 @@ public String callTool(String clientName, String toolName) {
4256
return "Called [client: %s, tool: %s], got response [%s]".formatted(clientName, toolName, toolResponse);
4357
}
4458

59+
@GetMapping("/chat")
60+
public String chat(String question) {
61+
return chatClientSupplier.get().prompt(question).call().content();
62+
}
63+
64+
@GetMapping("/stream")
65+
public String stream(String question) {
66+
return chatClientSupplier.get()
67+
.prompt(question)
68+
.stream()
69+
.content()
70+
.contextWrite(AuthenticationMcpTransportContextProvider.writeToReactorContext())
71+
.blockLast();
72+
}
73+
74+
@GetMapping("/stream-no-context")
75+
public String streamNoContext(String question) {
76+
return chatClientSupplier.get().prompt(question).stream().content().blockLast();
77+
}
78+
4579
}

0 commit comments

Comments
 (0)