Skip to content

Commit 43d4140

Browse files
Merge pull request #18734 from venkat1701/BAEL-9365
[BAEL-9365] MCP Authorization with Spring AI and OAuth2
2 parents 5b5290e + 16e0f26 commit 43d4140

File tree

19 files changed

+833
-0
lines changed

19 files changed

+833
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>org.springframework.boot</groupId>
7+
<artifactId>spring-boot-starter-parent</artifactId>
8+
<version>3.5.4</version>
9+
<relativePath/>
10+
</parent>
11+
<groupId>com.baeldung.mcp</groupId>
12+
<artifactId>mcp-client-oauth2</artifactId>
13+
<version>0.0.1-SNAPSHOT</version>
14+
<name>mcp-client-oauth2</name>
15+
<description>mcp-client-oauth2</description>
16+
17+
<properties>
18+
<java.version>17</java.version>
19+
<spring-ai.version>1.0.0</spring-ai.version>
20+
<spring-boot.starter.test>3.5.4</spring-boot.starter.test>
21+
</properties>
22+
23+
<dependencies>
24+
<dependency>
25+
<groupId>org.springframework.ai</groupId>
26+
<artifactId>spring-ai-starter-model-anthropic</artifactId>
27+
</dependency>
28+
<dependency>
29+
<groupId>org.springframework.ai</groupId>
30+
<artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
31+
</dependency>
32+
<dependency>
33+
<groupId>org.springframework.boot</groupId>
34+
<artifactId>spring-boot-starter-test</artifactId>
35+
<version>${spring-boot.starter.test}</version>
36+
</dependency>
37+
<dependency>
38+
<groupId>org.springframework.boot</groupId>
39+
<artifactId>spring-boot-starter-web</artifactId>
40+
</dependency>
41+
<dependency>
42+
<groupId>org.springframework.boot</groupId>
43+
<artifactId>spring-boot-starter-oauth2-client</artifactId>
44+
</dependency>
45+
</dependencies>
46+
47+
<dependencyManagement>
48+
<dependencies>
49+
<dependency>
50+
<groupId>org.springframework.ai</groupId>
51+
<artifactId>spring-ai-bom</artifactId>
52+
<version>${spring-ai.version}</version>
53+
<type>pom</type>
54+
<scope>import</scope>
55+
</dependency>
56+
</dependencies>
57+
</dependencyManagement>
58+
59+
<build>
60+
<plugins>
61+
<plugin>
62+
<groupId>org.springframework.boot</groupId>
63+
<artifactId>spring-boot-maven-plugin</artifactId>
64+
</plugin>
65+
</plugins>
66+
</build>
67+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.baeldung.mcp.mcpclientoauth2;
2+
3+
import org.springframework.ai.chat.client.ChatClient;
4+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
5+
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestParam;
8+
import org.springframework.web.bind.annotation.RestController;
9+
10+
@RestController
11+
public class CalculatorController {
12+
13+
private final ChatClient chatClient;
14+
15+
public CalculatorController(ChatClient chatClient) {
16+
this.chatClient = chatClient;
17+
}
18+
19+
@GetMapping("/calculate")
20+
public String calculate(@RequestParam String expression, @RegisteredOAuth2AuthorizedClient("authserver") OAuth2AuthorizedClient authorizedClient) {
21+
22+
String prompt = String.format("Please calculate the following mathematical expression using the available calculator tools: %s", expression);
23+
24+
return chatClient.prompt()
25+
.user(prompt)
26+
.call()
27+
.content();
28+
}
29+
30+
@GetMapping("/")
31+
public String home() {
32+
return """
33+
<html>
34+
<body>
35+
<h1>MCP Calculator with OAuth2</h1>
36+
<p>Try these examples:</p>
37+
<ul>
38+
<li><a href="/calculate?expression=5 + 3">/calculate?expression=5 + 3</a></li>
39+
<li><a href="/calculate?expression=10 - 4">/calculate?expression=10 - 4</a></li>
40+
<li><a href="/calculate?expression=6 * 7">/calculate?expression=6 * 7</a></li>
41+
<li><a href="/calculate?expression=15 / 3">/calculate?expression=15 / 3</a></li>
42+
</ul>
43+
<p>Note: You'll be redirected to login if not authenticated.</p>
44+
</body>
45+
</html>
46+
""";
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.baeldung.mcp.mcpclientoauth2;
2+
3+
import java.util.List;
4+
5+
import org.springframework.ai.chat.client.ChatClient;
6+
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
7+
import org.springframework.boot.SpringApplication;
8+
import org.springframework.boot.autoconfigure.SpringBootApplication;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.security.config.Customizer;
11+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
12+
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
13+
import org.springframework.security.web.SecurityFilterChain;
14+
import org.springframework.web.reactive.function.client.WebClient;
15+
16+
import io.modelcontextprotocol.client.McpSyncClient;
17+
18+
@SpringBootApplication
19+
public class McpClientOauth2Application {
20+
21+
public static void main(String[] args) {
22+
SpringApplication.run(McpClientOauth2Application.class, args);
23+
}
24+
25+
@Bean
26+
ChatClient chatClient(ChatClient.Builder chatClientBuilder, List<McpSyncClient> mcpClients) {
27+
return chatClientBuilder.defaultToolCallbacks(new SyncMcpToolCallbackProvider(mcpClients))
28+
.build();
29+
}
30+
31+
@Bean
32+
WebClient.Builder webClientBuilder(McpSyncClientExchangeFilterFunction filterFunction) {
33+
return WebClient.builder()
34+
.apply(filterFunction.configuration());
35+
}
36+
37+
@Bean
38+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
39+
return http.authorizeHttpRequests(auth -> auth.anyRequest()
40+
.permitAll())
41+
.oauth2Client(Customizer.withDefaults())
42+
.csrf(CsrfConfigurer::disable)
43+
.build();
44+
}
45+
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.baeldung.mcp.mcpclientoauth2;
2+
3+
import java.util.function.Consumer;
4+
5+
import org.springframework.security.authentication.AnonymousAuthenticationToken;
6+
import org.springframework.security.core.authority.AuthorityUtils;
7+
import org.springframework.security.oauth2.client.ClientCredentialsOAuth2AuthorizedClientProvider;
8+
import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
9+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
10+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
11+
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
12+
import org.springframework.stereotype.Component;
13+
import org.springframework.web.context.request.RequestContextHolder;
14+
import org.springframework.web.context.request.ServletRequestAttributes;
15+
import org.springframework.web.reactive.function.client.ClientRequest;
16+
import org.springframework.web.reactive.function.client.ClientResponse;
17+
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
18+
import org.springframework.web.reactive.function.client.ExchangeFunction;
19+
import org.springframework.web.reactive.function.client.WebClient;
20+
21+
import reactor.core.publisher.Mono;
22+
23+
@Component
24+
public class McpSyncClientExchangeFilterFunction implements ExchangeFilterFunction {
25+
26+
private final ClientCredentialsOAuth2AuthorizedClientProvider clientCredentialTokenProvider = new ClientCredentialsOAuth2AuthorizedClientProvider();
27+
28+
private final ServletOAuth2AuthorizedClientExchangeFilterFunction delegate;
29+
30+
private final ClientRegistrationRepository clientRegistrationRepository;
31+
32+
private static final String AUTHORIZATION_CODE_CLIENT_REGISTRATION_ID = "authserver";
33+
34+
private static final String CLIENT_CREDENTIALS_CLIENT_REGISTRATION_ID = "authserver-client-credentials";
35+
36+
public McpSyncClientExchangeFilterFunction(OAuth2AuthorizedClientManager clientManager, ClientRegistrationRepository clientRegistrationRepository) {
37+
this.delegate = new ServletOAuth2AuthorizedClientExchangeFilterFunction(clientManager);
38+
this.delegate.setDefaultClientRegistrationId(AUTHORIZATION_CODE_CLIENT_REGISTRATION_ID);
39+
this.clientRegistrationRepository = clientRegistrationRepository;
40+
}
41+
42+
@Override
43+
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
44+
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes) {
45+
return this.delegate.filter(request, next);
46+
} else {
47+
var accessToken = getClientCredentialsAccessToken();
48+
var requestWithToken = ClientRequest.from(request)
49+
.headers(headers -> headers.setBearerAuth(accessToken))
50+
.build();
51+
return next.exchange(requestWithToken);
52+
}
53+
}
54+
55+
private String getClientCredentialsAccessToken() {
56+
var clientRegistration = this.clientRegistrationRepository.findByRegistrationId(CLIENT_CREDENTIALS_CLIENT_REGISTRATION_ID);
57+
58+
var authRequest = OAuth2AuthorizationContext.withClientRegistration(clientRegistration)
59+
.principal(new AnonymousAuthenticationToken("client-credentials-client", "client-credentials-client",
60+
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")))
61+
.build();
62+
return this.clientCredentialTokenProvider.authorize(authRequest)
63+
.getAccessToken()
64+
.getTokenValue();
65+
}
66+
67+
public Consumer<WebClient.Builder> configuration() {
68+
return builder -> builder.defaultRequest(this.delegate.defaultRequest())
69+
.filter(this);
70+
}
71+
72+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
spring.application.name=mcp-client-oauth2
2+
3+
server.port=8080
4+
5+
spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8090
6+
spring.ai.mcp.client.type=SYNC
7+
8+
spring.security.oauth2.client.provider.authserver.issuer-uri=http://localhost:9000
9+
10+
# OAuth2 Client for User-Initiated Requests (Authorization Code Grant)
11+
spring.security.oauth2.client.registration.authserver.client-id=mcp-client
12+
spring.security.oauth2.client.registration.authserver.client-secret=mcp-secret
13+
spring.security.oauth2.client.registration.authserver.authorization-grant-type=authorization_code
14+
spring.security.oauth2.client.registration.authserver.provider=authserver
15+
spring.security.oauth2.client.registration.authserver.scope=openid,profile,mcp.read,mcp.write
16+
spring.security.oauth2.client.registration.authserver.redirect-uri={baseUrl}/authorize/oauth2/code/{registrationId}
17+
18+
# OAuth2 Client for Machine-to-Machine Requests (Client Credentials Grant)
19+
spring.security.oauth2.client.registration.authserver-client-credentials.client-id=mcp-client
20+
spring.security.oauth2.client.registration.authserver-client-credentials.client-secret=mcp-secret
21+
spring.security.oauth2.client.registration.authserver-client-credentials.authorization-grant-type=client_credentials
22+
spring.security.oauth2.client.registration.authserver-client-credentials.provider=authserver
23+
spring.security.oauth2.client.registration.authserver-client-credentials.scope=mcp.read,mcp.write
24+
25+
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
26+
27+
# Logging Configuration
28+
logging.level.com.baeldung.mcp=DEBUG
29+
logging.level.org.springframework.security.oauth2=INFO
30+
logging.level.org.springframework.ai.mcp=DEBUG
31+
logging.level.org.springframework.web.reactive.function.client=INFO
32+
logging.level.io.modelcontextprotocol=INFO
33+
34+
# Spring Boot Configuration
35+
spring.main.lazy-initialization=false
36+
spring.task.execution.pool.core-size=4
37+
spring.task.execution.pool.max-size=8
38+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.baeldung.mcp.mcpclientoauth2;
2+
3+
import static org.mockito.ArgumentMatchers.anyString;
4+
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.when;
6+
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.mockito.Mockito;
10+
import org.springframework.ai.chat.client.ChatClient;
11+
import org.springframework.test.web.servlet.MockMvc;
12+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
13+
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
14+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
15+
16+
public class CalculatorControllerUnitTest {
17+
18+
private MockMvc mockMvc;
19+
private ChatClient chatClient;
20+
21+
@BeforeEach
22+
void setUp() {
23+
chatClient = mock(ChatClient.class, Mockito.RETURNS_DEEP_STUBS);
24+
CalculatorController controller = new CalculatorController(chatClient);
25+
mockMvc = MockMvcBuilders.standaloneSetup(controller)
26+
.build();
27+
}
28+
29+
@Test
30+
void givenValidExpression_whenCalculateEndpointCalled_thenReturnsExpectedResult() throws Exception {
31+
when(chatClient.prompt()
32+
.user(anyString())
33+
.call()
34+
.content()).thenReturn("42");
35+
mockMvc.perform(MockMvcRequestBuilders.get("/calculate")
36+
.param("expression", "40 + 2"))
37+
.andExpect(MockMvcResultMatchers.status()
38+
.isOk())
39+
.andExpect(MockMvcResultMatchers.content()
40+
.string("42"));
41+
}
42+
43+
@Test
44+
void givenHomeRequest_whenHomeEndpointCalled_thenReturnsHtmlWithTitle() throws Exception {
45+
mockMvc.perform(MockMvcRequestBuilders.get("/"))
46+
.andExpect(MockMvcResultMatchers.status()
47+
.isOk())
48+
.andExpect(MockMvcResultMatchers.content()
49+
.string(org.hamcrest.Matchers.containsString("MCP Calculator with OAuth2")));
50+
}
51+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
5+
http://maven.apache.org/xsd/maven-4.0.0.xsd">
6+
<modelVersion>4.0.0</modelVersion>
7+
8+
<parent>
9+
<groupId>org.springframework.boot</groupId>
10+
<artifactId>spring-boot-starter-parent</artifactId>
11+
<version>3.5.4</version>
12+
<relativePath/>
13+
</parent>
14+
15+
<groupId>com.baeldung.mcp</groupId>
16+
<artifactId>mcp-server-oauth2</artifactId>
17+
<version>1.0.0</version>
18+
<name>mcp-server-oauth2</name>
19+
20+
<properties>
21+
<classmate.version>1.7.0</classmate.version>
22+
<spring-ai.version>1.0.0-M7</spring-ai.version>
23+
<java.version>17</java.version>
24+
<spring-ai.version>1.0.0</spring-ai.version>
25+
<junit-version>5.10.2</junit-version>
26+
</properties>
27+
28+
<dependencies>
29+
<dependency>
30+
<groupId>com.fasterxml</groupId>
31+
<artifactId>classmate</artifactId>
32+
<version>${classmate.version}</version>
33+
</dependency>
34+
<dependency>
35+
<groupId>org.springframework.ai</groupId>
36+
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
37+
<version>${spring-ai.version}</version>
38+
</dependency>
39+
40+
<dependency>
41+
<groupId>org.springframework.boot</groupId>
42+
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
43+
</dependency>
44+
<dependency>
45+
<groupId>org.junit.jupiter</groupId>
46+
<artifactId>junit-jupiter</artifactId>
47+
<version>${junit-version}</version>
48+
<scope>test</scope>
49+
</dependency>
50+
</dependencies>
51+
52+
<build>
53+
<plugins>
54+
<plugin>
55+
<groupId>org.springframework.boot</groupId>
56+
<artifactId>spring-boot-maven-plugin</artifactId>
57+
</plugin>
58+
</plugins>
59+
</build>
60+
61+
</project>

0 commit comments

Comments
 (0)