Post

Spring AI + MCP — Exposing Tools as a Standardized Server

A hands-on guide to the Model Context Protocol (MCP) with Spring AI 2.0. Build an MCP server that exposes payment tools over SSE, then a separate AI client that discovers and invokes them — zero tool definitions on the client side.

Spring AI + MCP — Exposing Tools as a Standardized Server

The Problem

In the function calling post, tools live inside the same application as the AI model. That works fine for a single app. But what happens when:

  • Multiple AI apps need the same tools (payment lookup, user search, etc.)
  • Different teams own the tools vs. the AI layer
  • Non-Java clients (Claude Desktop, Python agents) need access to your Java tools
  • You want to add tools dynamically without redeploying the AI application

You need a standard protocol for exposing and consuming tools over the network. That’s MCP — the Model Context Protocol.


What Is MCP?

MCP is an open standard (created by Anthropic, adopted widely) that defines how AI applications discover and call tools from remote servers.

flowchart LR
    subgraph "MCP Server (your tools)"
        T1[getPaymentStatus]
        T2[getExchangeRate]
        T3[convertAmount]
    end

    subgraph "MCP Clients"
        C1[Spring AI App]
        C2[Claude Desktop]
        C3[Python Agent]
    end

    C1 -->|MCP protocol| T1
    C1 -->|MCP protocol| T2
    C2 -->|MCP protocol| T1
    C2 -->|MCP protocol| T3
    C3 -->|MCP protocol| T2

    style T1 fill:#48dbfb,color:#333
    style T2 fill:#48dbfb,color:#333
    style T3 fill:#48dbfb,color:#333

diagram

Think of it as REST for AI tools — a standard interface so any client can discover and invoke any server’s tools without custom integration.


What We Are Building

A multi-module Spring Boot project:

ModuleRolePort
mcp-serverExposes payment tools via MCP (SSE transport)8081
mcp-clientAI assistant that discovers + uses remote tools8080
sequenceDiagram
    participant User
    participant Client as MCP Client (8080)
    participant Server as MCP Server (8081)
    participant LLM as OpenAI

    Note over Client,Server: Startup: tool discovery
    Client->>Server: GET /mcp (SSE connection)
    Server-->>Client: Tool list: [getPaymentStatus, getExchangeRate, ...]

    Note over User,LLM: Runtime: user query
    User->>Client: "Check payment TXN-9042"
    Client->>LLM: Message + discovered tool schemas
    LLM-->>Client: Tool call: getPaymentStatus("TXN-9042")
    Client->>Server: MCP tool call request
    Server-->>Client: {status: COMPLETED, amount: 250...}
    Client->>LLM: Tool result
    LLM-->>Client: "Payment TXN-9042 is completed..."
    Client-->>User: Final answer

diagram

The client never defines tool schemas locally. It discovers them from the server at startup.


Prerequisites

ComponentVersion
Java21+
Spring Boot3.5+
Spring AI2.0.0
OpenAI API keyplatform.openai.com
Maven3.9+

Project Setup

Parent POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<groupId>com.anupam</groupId>
<artifactId>spring-ai-mcp</artifactId>
<packaging>pom</packaging>

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties>

<modules>
    <module>mcp-server</module>
    <module>mcp-client</module>
</modules>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Part 1: Building the MCP Server

Server Dependencies

1
2
3
4
5
6
7
8
9
10
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>
</dependencies>

That’s it. One AI dependency. The spring-ai-starter-mcp-server-webmvc auto-configuration:

  • Scans for @Tool-annotated beans
  • Generates JSON schemas for parameters
  • Registers tools with the MCP protocol handler
  • Exposes an SSE endpoint for clients to connect

Server Configuration

1
2
3
4
5
6
7
8
9
10
spring:
  ai:
    mcp:
      server:
        name: payment-tools-server
        version: 1.0.0
        description: "MCP server exposing payment lookup and exchange rate tools"

server:
  port: 8081

Define Tools with @Tool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class PaymentMcpTools {

    @Tool(description = "Get the current status and details of a payment by its transaction ID")
    public PaymentInfo getPaymentStatus(
            @ToolParam(description = "Transaction ID, e.g. TXN-9042") String transactionId) {

        PaymentInfo info = payments.get(transactionId);
        if (info == null) {
            throw new RuntimeException("Payment not found: " + transactionId);
        }
        return info;
    }

    @Tool(description = "Get the current exchange rate between two currencies. " +
            "Supported: USD, EUR, GBP, JPY, INR, CAD, AUD")
    public ExchangeRate getExchangeRate(
            @ToolParam(description = "Source currency code, e.g. USD") String from,
            @ToolParam(description = "Target currency code, e.g. EUR") String to) {

        // ... conversion logic
        return new ExchangeRate(from, to, rate, LocalDateTime.now());
    }

    @Tool(description = "Calculate the total amount in a target currency for a given payment")
    public String convertPaymentAmount(
            @ToolParam(description = "Transaction ID") String transactionId,
            @ToolParam(description = "Target currency code") String targetCurrency) {

        PaymentInfo payment = getPaymentStatus(transactionId);
        ExchangeRate rate = getExchangeRate(payment.currency(), targetCurrency);
        BigDecimal converted = payment.amount().multiply(rate.rate());
        return String.format("%s %s = %s %s", payment.amount(), payment.currency(),
                converted, targetCurrency);
    }
}

Notice: the exact same @Tool annotation from the function calling post. The only difference is the dependency — spring-ai-starter-mcp-server-webmvc instead of spring-ai-starter-model-openai. Your tool code doesn’t change.

Start the Server

1
2
cd mcp-server
./mvnw spring-boot:run

The server is now exposing tools at http://localhost:8081 via the MCP SSE transport. Any MCP client can connect and discover getPaymentStatus, getExchangeRate, and convertPaymentAmount.


Part 2: Building the MCP Client

Client Dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-client</artifactId>
    </dependency>
</dependencies>

Client Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o
          temperature: 0.3

    mcp:
      client:
        sse:
          connections:
            payment-tools:
              url: http://localhost:8081

server:
  port: 8080

The mcp.client.sse.connections section tells Spring AI where to find MCP servers. You can list multiple servers — each gets a logical name (payment-tools here).

Wire MCP Tools into ChatClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class AiConfig {

    @Bean
    public ChatClient chatClient(ChatModel chatModel, SyncMcpToolCallbackProvider mcpTools) {
        return ChatClient.builder(chatModel)
                .defaultSystem("""
                        You are a helpful payments assistant. You have access to tools
                        provided by an MCP server. Use them to look up payment status,
                        get exchange rates, and convert payment amounts.
                        """)
                .defaultTools(mcpTools)
                .build();
    }
}

SyncMcpToolCallbackProvider is auto-configured by Spring AI. It connects to all configured MCP servers, discovers their tools, and wraps them as ToolCallback instances. You just inject it and pass it to .defaultTools().

The Controller (No Tool Knowledge Needed)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("/api/v1/assistant")
public class AssistantController {

    private final ChatClient chatClient;

    public AssistantController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @PostMapping("/chat")
    public ResponseEntity<ChatResponse> chat(@Valid @RequestBody ChatRequest request) {
        String answer = chatClient.prompt()
                .user(request.message())
                .call()
                .content();

        return ResponseEntity.ok(ChatResponse.of(answer));
    }
}

The controller has zero knowledge of what tools exist. It doesn’t import PaymentInfo or ExchangeRate. Those types live on the server side only. The client just passes the MCP-provided tool callbacks to the model and lets the loop run.


Testing

1. Start both applications

1
2
3
4
5
6
7
8
# Terminal 1 — MCP Server
cd mcp-server
./mvnw spring-boot:run

# Terminal 2 — MCP Client
cd mcp-client
export OPENAI_API_KEY=sk-your-key-here
./mvnw spring-boot:run

2. Query via the client

1
2
3
curl -X POST http://localhost:8080/api/v1/assistant/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "What is the status of payment TXN-9042?"}'
1
2
3
4
{
  "answer": "Payment TXN-9042 has been completed. It was $250.00 USD sent from Alice Johnson to Bob Smith on August 20, 2026.",
  "timestamp": "2026-08-22T11:00:00"
}

3. Use a tool that chains other tools

1
2
3
curl -X POST http://localhost:8080/api/v1/assistant/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "Convert TXN-9043 amount to EUR"}'
1
2
3
4
{
  "answer": "Payment TXN-9043 is $1,200.50 USD. At the current rate of 0.92, that equals approximately €1,104.46 EUR.",
  "timestamp": "2026-08-22T11:00:05"
}

MCP vs Local Tools: When to Use Which

ScenarioUse
Tools and AI in the same appLocal @Tool (simpler, no network hop)
Multiple AI apps share the same toolsMCP server (one source of truth)
Different teams own tools vs. AI layerMCP (clean ownership boundary)
Non-Java clients need your Java toolsMCP (Claude Desktop, Python, etc.)
You want to add tools without redeploying the AI appMCP (dynamic discovery)
Low-latency requirementLocal @Tool (no network round-trip)

Combining Local and MCP Tools

You can mix them freely:

1
2
3
4
5
chatClient.prompt()
    .user(question)
    .tools(localTools, mcpTools)  // Both in one call
    .call()
    .content();

The model doesn’t distinguish between local and remote tools — they all look like ToolCallback instances.


Transport Options

MCP supports two transports:

TransportUse CaseConfig Key
SSE (Server-Sent Events)Remote servers over HTTPspring.ai.mcp.client.sse.connections
stdioLocal processes (CLI tools, scripts)spring.ai.mcp.client.stdio.connections

stdio example (connecting to an npm-based MCP server):

1
2
3
4
5
6
7
8
9
spring:
  ai:
    mcp:
      client:
        stdio:
          connections:
            filesystem:
              command: npx
              args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

Security Considerations

MCP expands the attack surface beyond a single JVM:

  1. Authenticate MCP connections — in production, add auth headers or mTLS between client and server
  2. Filter tools — use McpToolFilter to restrict which discovered tools the client exposes to the model
  3. Network boundary — keep MCP servers internal (VPC, service mesh). Don’t expose them publicly.
  4. Tool name conflicts — if two MCP servers expose a tool with the same name, use DefaultMcpToolNamePrefixGenerator to prefix them
1
2
3
4
5
6
// Only allow specific tools from the MCP server
@Bean
McpToolFilter mcpToolFilter() {
    return (serverName, toolName) ->
            Set.of("getPaymentStatus", "getExchangeRate").contains(toolName);
}

Common Problems

SymptomCauseFix
Connection refused on startupMCP server not runningStart the server first
No tools discoveredWrong URL in client configCheck spring.ai.mcp.client.sse.connections.*.url
Tools work locally but not via MCPServer not scanning @Tool beansEnsure tool class has @Component
Slow first requestSSE connection + tool discovery on first callThis is normal; subsequent calls are fast
ClassNotFoundException on clientTrying to import server-side model classesClient doesn’t need server models — results are JSON strings

Full Working Example

The complete code is available at github.com/AnupamSinha/spring-boot-examples/tree/main/06-ai-mcp.

1
2
3
4
5
6
7
8
git clone https://github.com/AnupamSinha/spring-boot-examples/tree/main/06-ai-mcp
cd spring-ai-mcp

# Terminal 1 — Start server
cd mcp-server && ../mvnw spring-boot:run

# Terminal 2 — Start client
cd mcp-client && export OPENAI_API_KEY=sk-... && ../mvnw spring-boot:run

What’s Next


References

This post is licensed under CC BY 4.0 by the author.