Learning MCP Client & Server with Spring AI + Claude
A small two-module learning project that shows how the Model Context Protocol (MCP) works end to end with Spring AI, Java 25, and Anthropic Claude.
The two apps are deliberately separate processes: the server is a pure tool provider and the client is the only place an LLM (Claude) is ever invoked. That separation is the whole point of MCP - a tool provider doesn't need to know or care which LLM (or how many different LLM apps) ends up calling it.
1. Architecture
graph LR
User(["Person / curl"])
subgraph "Springboot-mcp-client (port 8080)"
Controller["ChatController<br/>/api/chat, /api/tools"]
ChatClient["Spring AI ChatClient"]
MCPClient["MCP Client<br/>(spring-ai-starter-mcp-client)"]
end
Claude[("Anthropic Claude API<br/>claude-sonnet-5")]
subgraph "Springboot-mcp-server (port 8081)"
MCPServer["MCP Server<br/>(Streamable-HTTP, /mcp)"]
Calc["CalculatorTools<br/>add / subtract / multiply / divide"]
Weather["WeatherTools<br/>getCurrentWeather"]
end
User -- "1. HTTP POST" --> Controller
Controller -- "2. prompt(message)" --> ChatClient
ChatClient -- "3. tool definitions + prompt" --> Claude
Claude -- "4. tool_use request" --> ChatClient
ChatClient -- "5. invoke tool" --> MCPClient
MCPClient -- "6. MCP call (HTTP)" --> MCPServer
MCPServer --> Calc
MCPServer --> Weather
MCPServer -- "7. tool result" --> MCPClient
MCPClient -- "8. tool_result" --> ChatClient
ChatClient -- "9. final prompt" --> Claude
Claude -- "10. final answer" --> ChatClient
ChatClient -- "11. response text" --> Controller
Controller -- "12. HTTP response" --> User
Key idea: the MCP server never talks to Claude, and Claude never talks to the MCP server directly. The MCP client sits in the middle and is the only component that talks to both sides.
Request/response sequence
sequenceDiagram
actor U as User
participant C as ChatController (client app)
participant CC as ChatClient
participant A as Claude (Anthropic API)
participant M as MCP Client
participant S as MCP Server (tools app)
U->>C: POST /api/chat {"message": "what is 12 * 7"}
C->>CC: prompt(message).toolCallbacks(mcpTools)
CC->>A: messages.create(prompt, tools=[add, subtract, multiply, divide, getCurrentWeather])
A-->>CC: stop_reason=tool_use, tool=multiply(a=12,b=7)
CC->>M: execute multiply(12, 7)
M->>S: MCP tools/call "multiply"
S-->>M: result = 84
M-->>CC: tool_result = 84
CC->>A: send tool_result back
A-->>CC: stop_reason=end_turn, text="12 * 7 = 84"
CC-->>C: content()
C-->>U: "12 * 7 = 84"
2. How it works, step by step
- The server starts first. On boot, Spring AI's annotation scanner finds
every
@Componentmethod annotated with@McpTool(seeCalculatorToolsandWeatherTools) and registers them as MCP tools. It exposes them over the Streamable-HTTP MCP transport athttp://localhost:8081/mcp. - The client starts second. On boot,
spring-ai-starter-mcp-clientconnects tohttp://localhost:8081/mcp, performs the MCP handshake, and discovers the tools the server just registered. It exposes them as a SpringToolCallbackProviderbean. - A user calls the client's REST API (
/api/chat). TheChatControllerbuilds a one-offChatClient, attaches the MCP tool callbacks to the prompt, and sends it to Claude. - Claude decides on its own whether it needs a tool to answer. If it
does, Spring AI intercepts Claude's
tool_useresponse, calls the tool through the MCP client (which forwards the call to the MCP server over HTTP), and sends the tool's result back to Claude automatically. - Claude produces the final answer, which the controller returns as plain text.
Nothing here is hardcoded to "calculator" or "weather" - if you add a new
@McpTool method on the server and restart it, the client picks it up on its
next connection with no client-side code changes.
2.1 Following a request in the logs
Every hop in the diagram above logs a line, tagged so you can grep for it and see exactly where a request went - straight to Claude, or out to a tool on the MCP server:
For example, asking What is 12 times 7? prints this on the client
console:
[CLIENT] Received chat request: "What is 12 times 7?"
[CLIENT] Sending prompt to Claude with 5 MCP tool(s) available
-> Claude is calling MCP tool 'multiply' over the MCP server, input: {"a":12,"b":7}
<- MCP tool 'multiply' returned (43 ms): 84
[CLAUDE] Final answer: "12 times 7 is 84."
...and this on the server console, at the same moment:
[SERVER] Tool 'multiply' invoked with a=12, b=7
[SERVER] Tool 'multiply' result: 84
Asking something Claude can answer on its own (Say hello in French) only
ever prints the [CLIENT] / [CLAUDE] lines on the client - no ->/<-
tool lines, and nothing at all on the server - which is itself the signal
that no tool was needed.
3. APIs
3.1 MCP Server - Springboot-mcp-server (port 8081)
This app does not expose a hand-written REST API. It speaks the MCP protocol itself over Streamable-HTTP:
Tools registered on this endpoint:
3.2 MCP Client - Springboot-mcp-client (port 8080)
This app exposes plain REST endpoints for people/tools to talk to:
⚠️ Why
/api/chatisPOSTwith a JSON body, notGETwith a query parameter. A GET query string followsapplication/x-www-form-urlencodeddecoding rules, where a literal+means space, not a plus sign -?message=2 + 3silently arrives at Claude as"2 3"with no operator at all, so it never calls theadd/multiply/etc. tool you'd expect. A JSON body has no such ambiguity, so any text - math expressions, URLs,&,#,%- is safe to send as-is.
Example calls:
# Confirm the client sees the server's tools
curl "http://localhost:8080/api/tools"
# -> ["add","subtract","multiply","divide","getCurrentWeather"]
# Ask Claude something that requires a tool call
curl -X POST "http://localhost:8080/api/chat" \
-H "Content-Type: application/json" \
-d '{"message":"What is 2 + 3?"}'
# -> "2 + 3 is 5."
# Ask Claude something that requires the weather tool
curl -X POST "http://localhost:8080/api/chat" \
-H "Content-Type: application/json" \
-d '{"message":"What'\''s the weather in Pune?"}'
# -> "It's 28C and partly cloudy in Pune."
# Ask Claude something that needs no tool at all
curl -X POST "http://localhost:8080/api/chat" \
-H "Content-Type: application/json" \
-d '{"message":"Say hello in French"}'
# -> "Bonjour !"
4. Configuration reference
4.1 Server - Springboot-mcp-server/src/main/resources/application.properties
No Anthropic/Claude dependency or API key is needed here - this app never calls an LLM.
4.2 Client - Springboot-mcp-client/src/main/resources/application.properties
5. How to run this app
Prerequisites
- Java 25
- An Anthropic API key (only needed by the client)
Steps
1. Start the MCP server first (it must be up before the client connects):
cd Springboot-mcp-server
./mvnw spring-boot:run
Wait for a log line like Registered tools: 5 - that confirms all the
@McpTool methods were found.
2. Set your Anthropic API key, then start the client in a second terminal:
export ANTHROPIC_API_KEY=sk-ant-...
cd Springboot-mcp-client
./mvnw spring-boot:run
3. Try it out:
curl "http://localhost:8080/api/tools"
curl -X POST "http://localhost:8080/api/chat" \
-H "Content-Type: application/json" \
-d '{"message":"What is 144 divided by 12?"}'
If you stop and restart just the server (e.g. after adding a new
@McpTool method), restart the client too so it re-runs the MCP handshake
and picks up the new tool.
6. Project layout
Springboot-mcp-server/ (outer folder - this README)
├── Springboot-mcp-server/ MCP server app (port 8081)
│ └── src/main/java/com/rajeshkawali/
│ ├── SpringbootMcpServerApplication.java
│ └── tools/
│ ├── CalculatorTools.java
│ └── WeatherTools.java
└── Springboot-mcp-client/ MCP client app (port 8080)
└── src/main/java/com/rajeshkawali/
├── SpringbootMcpClientApplication.java
└── controller/
└── ChatController.java
Author: Rajesh_Kawali