Baeldung

Java, Spring and Web Development tutorials

 

Securing Spring AI MCP Servers With OAuth2
2025-07-11 05:48 UTC by Kostiantyn Ivanov

 1. Introduction

MCP (Model Context Protocol) is an open standard, introduced by Anthropic, designed to let AI models interact with external tools, data sources, and services in a structured way. An MCP server is a lightweight backend application that exposes specific capabilities via the MCP interface, such as accessing files, querying databases, or calling APIs.

To make MCP servers production-ready, we may consider separating them into independent applications. This helps us scale and maintain them individually. However, because these servers may handle sensitive tasks, we need to secure their endpoints and limit access to trusted clients.

That’s where OAuth2 comes in. OAuth2 is a well-known protocol for secure, token-based access delegation to APIs. Instead of managing user credentials directly, our MCP server trusts validated access tokens issued by a central authorization server. We can use OAuth2 to grant or restrict client applications’ access to specific MCP capabilities based on scopes and roles.

In this tutorial, we’ll learn how to secure an MCP server using OAuth2 in a Spring AI application.

2. Dependencies

First, let’s add the Spring AI MCP server dependency that we’ll use to obtain the HTTP and SSE transport and core MCP support:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId>
</dependency>

Now, let’s add the OAuth Authorization server dependency. We’ll use it to issue the OAuth2 access tokens:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
    <version>3.3.3</version>
</dependency>

Finally, let’s add Spring Security’s resource-server dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    <version>3.4.2</version>
</dependency>

With this dependency, we’ll ensure our MCP endpoints reject invalid or missing Bearer tokens.

3. Creating a Stocks Info MCP Server

Now, let’s implement a simple MCP server. Inside it, we’ll have a tool that returns stock prices for a provided symbol.

Let’s create a StockInformationHolder class:

public class StockInformationHolder {
    @Tool(description = "Get stock price for a company symbol")
    public String getStockPrice(@ToolParam String symbol) {
        if ("AAPL".equalsIgnoreCase(symbol)) {
            return "AAPL: $150.00";
        } else if ("GOOGL".equalsIgnoreCase(symbol)) {
            return "GOOGL: $2800.00";
        } else {
            return symbol + ": Data not available";
        }
    }
}

Here, we have the getStockPrice() method that we use to return the stock prices for known companies and the default response for the symbols we don’t know about. The method is marked by the @Tool annotation, so it’ll be used to build the tool definition. Additionally, we’ve marked the symbol parameter by @ToolParam annotation to ensure it’ll be considered during the tool definition-building process.

Next, let’s create the McpServerConfiguration class:

@Configuration
public class McpServerConfiguration {
    @Bean
    public ToolCallbackProvider stockTools() {
        return MethodToolCallbackProvider
          .builder()
          .toolObjects(new StockInformationHolder())
          .build();
    }
}

Here, we’ve provided the ToolCallbackProvider bean. We build it by attaching the StockInformationHolder class. Now, we already have a ready-to-use MCP server and can start our application and open the SSE connection by calling the GET /sse endpoint. To send messages to our MCP server, let’s use the POST /mcp/message endpoint with the JSON body:

{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/call",
  "params": {
    "name": "getStockPrice",
    "arguments": {
      "arg0": "AAPL"
    }
  }
}

We’ve specified “tools/call” for the method, indicating that we want to invoke our tool’s functionality. In the params object, we’re sending parameters to the specified method, including the tool name, which defaults to the annotated method name, along with the arguments map.

4. Adding the Security Configuration

Now, let’s secure our MCP server. First, we’ll configure our authorization server using the application.yml file:

spring:
  security:
    oauth2:
      authorizationserver:
        client:
          oidc-client:
            registration:
              client-id: mcp-client
              client-secret: "{noop}secret"
              client-authentication-methods: client_secret_basic
              authorization-grant-types: client_credentials

We’ve specified the unique identifier for the client application requesting tokens. For the shared secret, we used {noop}secret, which is suitable only for demo purposes. The {noop} prefix tells Spring not to hash the secret, making it useful for testing scenarios.

Next, let’s create the McpServerSecurityConfiguration class:

@Configuration
@EnableWebSecurity
public class McpServerSecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
          .authorizeHttpRequests(auth -> auth
            .requestMatchers("/mcp/**").authenticated()
            .requestMatchers("/sse").authenticated()
            .anyRequest().permitAll())
          .with(OAuth2AuthorizationServerConfigurer.authorizationServer(), Customizer.withDefaults())
          .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
          .csrf(CsrfConfigurer::disable)
          .cors(Customizer.withDefaults())
          .build();
    }
}

Here, we permitted all the authorized requests to the /mcp and /sse endpoints. All the other endpoints will remain open. This approach simplifies access to the authentication endpoint. However, in a live application, we would restrict access more carefully.

We use the authorizationServer() and oauth2ResourceServer() methods to configure the application. This setup indicates that the application provides access token endpoints. It also acts as a resource server, validating incoming requests using JWT tokens.

5. Test the Secured MCP Server

Now, we need to test our secured MCP server. Let’s create the McpServerOAuth2LiveTest class:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class McpServerOAuth2LiveTest {
    private static final Logger log = LoggerFactory.getLogger(McpServerOAuth2LiveTest.class);
    @LocalServerPort
    private int port;
    private WebClient webClient;
    @BeforeEach
    void setup() {
        webClient = WebClient.create("http://localhost:" + port);
    }
}

We start our application on a random port and initialize the WebClient. Then, let’s call the /sse endpoint to open a server-sent events connection:

Flux<String> eventStream = webClient.get()
  .uri("/sse")
  .header("Authorization", obtainAccessToken())
  .accept(MediaType.TEXT_EVENT_STREAM)
  .retrieve()
  .bodyToFlux(String.class);
eventStream.subscribe(
    data -> {
        log.info("Response received: {}", data);
        if (!isRequestMessage(data)) {
            assertThat(data).containsSequence("AAPL", "$150");
        }
    },
    error -> log.error("Stream error: {}", error.getMessage()),
    () -> log.info("Stream completed")
);

We’ve asserted that the response messages contain the expected data. Next, let’s send a request to the /mcp/message endpoint:

Flux<String> sendMessage = webClient.post()
  .uri("/mcp/message")
  .header("Authorization", obtainAccessToken())
  .contentType(MediaType.APPLICATION_JSON)
  .accept(MediaType.TEXT_EVENT_STREAM)
  .bodyValue("""
     {
         "jsonrpc": "2.0",
         "id": "1",
         "method": "tools/call",
         "params": {
             "name": "getStockPrice",
             "arguments": {
                 "arg0": "AAPL"
             }
         }
     }
     """)
  .retrieve()
  .bodyToFlux(String.class);

We send a request to retrieve the stock price for AAPL. Both requests include the Authorization header. Now, let’s implement the method to obtain the access token:

public String obtainAccessToken() {
    String clientId = "mcp-client";
    String clientSecret = "secret";
    String basicToken = Base64.getEncoder()
      .encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
    return "Bearer " + webClient.post()
      .uri("/oauth2/token")
      .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
      .header(HttpHeaders.AUTHORIZATION, "Basic " + basicToken)
      .body(BodyInserters.fromFormData("grant_type", "client_credentials"))
      .retrieve()
      .bodyToMono(JsonNode.class)
      .map(node -> node.get("access_token").asText())
      .block(Duration.ofSeconds(5));
}

After execution, we can see that the response data was successfully received. This confirms that we’ve passed the security filters.

6. Conclusion

In this tutorial, we’ve secured our MCP server using OAuth2 in a Spring AI application. To protect key MCP endpoints, OAuth2 was integrated seamlessly through Spring Boot. Moreover, this setup is flexible and can be extended further. For instance, we could introduce role- and scope-based access control to restrict specific tools or actions to certain clients.

In a production environment, we might integrate with a full-featured identity provider like Keycloak or Okta. Besides that, we could enhance our tokens with custom claims or scopes to control access to individual tools within the MCP platform.

As always, the code is available over on GitHub.

The post Securing Spring AI MCP Servers With OAuth2 first appeared on Baeldung.
       

 

Content mobilized by FeedBlitz RSS Services, the premium FeedBurner alternative.