Module 6: Exposing Spring Beans as MCP Tools
Duration: ~50 minutes | Level: Intermediate | Prerequisites: Module 5 complete. Familiarity with Spring Service beans and REST/HTTP concepts.
What You'll Build
An order management MCP server. A Spring Boot application that:
- Contains a real business service (
OrderService) that processes orders - Exposes that service as MCP tools via
@McpToolannotations - Runs as a Streamable HTTP MCP server (the Spring AI 2.0 default transport) that any MCP client can connect to
- Can be used from Claude Desktop, from another Spring AI application, or from any MCP-compatible client
This is the other side of the MCP story. Modules 2 and 3-5 showed you how to consume MCP servers. This module shows you how to produce them, turning your existing Spring application into a standard MCP tool provider.
Why Expose Spring Beans as MCP Tools?
You probably have existing Spring services. They contain business logic that took years to build and validate. That logic is tested, deployed, and running in production.
Now imagine you want Claude to be able to look up orders, check inventory, send notifications, or query your reporting database. Without MCP, you'd build a custom LLM integration: define tools manually (in JSON schema), wire up the tool-call handlers, and integrate with the model's specific function-calling API.
With Spring AI's @McpTool annotation, you annotate existing service methods and Spring AI generates the JSON schema, handles the tool call dispatch, and exposes everything via the MCP protocol. Existing code. Standard protocol. No custom LLM integration.
Any MCP-compatible AI client can then use your services, not just your own Spring application, but Claude Desktop, Cursor, any other MCP client. Your Spring services become a universal tool provider.
The MCP Server Starters
Spring AI provides three MCP server starters: a base spring-ai-starter-mcp-server that uses STDIO transport (no web stack), plus two that expose the server over HTTP. This module uses the HTTP variants, so we focus on those two:
| Starter | Transport | Use Case |
|---|---|---|
spring-ai-starter-mcp-server-webmvc | HTTP (synchronous) | Traditional Spring MVC apps, most common choice |
spring-ai-starter-mcp-server-webflux | HTTP (reactive) | Spring WebFlux apps, when you need reactive streams throughout |
Both expose the same MCP protocol. Use webmvc unless your application is already reactive (WebFlux). In Spring AI 2.0 the HTTP server defaults to the Streamable HTTP transport (spring.ai.mcp.server.protocol defaults to STREAMABLE); the legacy HTTP+SSE transport is now opt-in via spring.ai.mcp.server.protocol=SSE and is deprecated in the underlying SDK. The protocol is identical regardless of which starter you use.
Build the Order Management Server
Dependencies
<dependencies>
<!-- Spring Web (required for webmvc variant) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MCP Server, exposes this application as an MCP server over HTTP (Streamable HTTP by default) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
</dependencies>
Note: no spring-ai-starter-model-* dependency. This application is the MCP server. It doesn't call any LLM, it exposes functionality to LLMs. No API key needed.
The Business Domain
(For brevity, the two records are shown together; in practice each is its own file or nested inside a holder class.)
package com.themcpguy.orders;
public record OrderItem(
String productId,
String productName,
int quantity,
double unitPrice
) {}
public record Order(
String orderId,
String status,
String customerId,
List<OrderItem> items,
double totalAmount,
String createdAt,
String lastUpdated
) {}
The Service
package com.themcpguy.orders;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class OrderService {
// In-memory store, replace with JPA repository in real applications
private final Map<String, Order> orders = new ConcurrentHashMap<>();
public OrderService() {
// Seed with sample data
seedSampleData();
}
public Optional<Order> findById(String orderId) {
return Optional.ofNullable(orders.get(orderId));
}
public List<Order> findByCustomerId(String customerId) {
return orders.values().stream()
.filter(o -> o.customerId().equals(customerId))
.sorted(Comparator.comparing(Order::createdAt).reversed())
.toList();
}
public List<Order> findByStatus(String status) {
return orders.values().stream()
.filter(o -> o.status().equalsIgnoreCase(status))
.toList();
}
public Order updateStatus(String orderId, String newStatus) {
Order existing = orders.get(orderId);
if (existing == null) {
throw new IllegalArgumentException("Order not found: " + orderId);
}
Order updated = new Order(
existing.orderId(),
newStatus.toUpperCase(),
existing.customerId(),
existing.items(),
existing.totalAmount(),
existing.createdAt(),
Instant.now().toString()
);
orders.put(orderId, updated);
return updated;
}
/** Create a new order. Used by tests and any tool that exposes an "add order" capability. */
public Order placeOrder(String customerId, List<OrderItem> items) {
String orderId = "ORD-" + (10000 + orders.size() + 1);
double total = items.stream()
.mapToDouble(item -> item.quantity() * item.unitPrice())
.sum();
String now = Instant.now().toString();
Order order = new Order(orderId, "PENDING", customerId, items, total, now, now);
orders.put(orderId, order);
return order;
}
private void seedSampleData() {
// Add a few sample orders
var items = List.of(
new OrderItem("PROD-001", "Wireless Keyboard", 1, 89.99),
new OrderItem("PROD-002", "USB-C Hub", 2, 45.00)
);
orders.put("ORD-10001", new Order(
"ORD-10001", "SHIPPED", "CUST-42",
items, 179.99, "2026-05-10T09:00:00Z", "2026-05-12T14:30:00Z"
));
orders.put("ORD-10002", new Order(
"ORD-10002", "PENDING", "CUST-17",
List.of(new OrderItem("PROD-003", "Mechanical Switch Kit", 1, 34.99)),
34.99, "2026-05-15T16:45:00Z", "2026-05-15T16:45:00Z"
));
}
}
The store is a ConcurrentHashMap because an HTTP MCP server can execute tool calls from several client sessions at the same time.
The MCP Tool Methods
This is where the magic happens. Spring AI's annotation-based MCP server support means the @McpTool annotation turns regular service methods into MCP tools, and the MCP annotation scanner registers them automatically.
package com.themcpguy.orders;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class OrderMcpTools {
private final OrderService orderService;
OrderMcpTools(OrderService orderService) {
this.orderService = orderService;
}
@McpTool(name = "get_order", description = """
Look up an order by its unique order ID.
Returns full order details including status, customer, items, and total.
Order IDs follow the format ORD-XXXXX (e.g., ORD-10001).
""")
public Order getOrder(
@McpToolParam(description = "The order ID in format ORD-XXXXX") String orderId) {
validateOrderIdFormat(orderId);
return orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"Order %s not found. Verify the order ID and try again.".formatted(orderId)));
}
@McpTool(name = "get_customer_orders", description = """
List all orders for a specific customer.
Returns orders sorted by creation date, newest first.
Customer IDs follow the format CUST-XX (e.g., CUST-42).
""")
public List<Order> getCustomerOrders(
@McpToolParam(description = "The customer ID in format CUST-XX") String customerId) {
return orderService.findByCustomerId(customerId);
}
@McpTool(name = "get_orders_by_status", description = """
List all orders with a specific status.
Valid statuses: PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED.
""")
public List<Order> getOrdersByStatus(
@McpToolParam(description = "Order status: PENDING, PROCESSING, SHIPPED, DELIVERED, or CANCELLED")
String status) {
return orderService.findByStatus(status);
}
@McpTool(name = "update_order_status", description = """
Update the status of an order.
Use this to mark orders as PROCESSING, SHIPPED, DELIVERED, or CANCELLED.
Requires the order ID and the new status value.
""")
public Order updateOrderStatus(
@McpToolParam(description = "The order ID to update") String orderId,
@McpToolParam(description = "The new status: PROCESSING, SHIPPED, DELIVERED, or CANCELLED")
String newStatus) {
validateOrderIdFormat(orderId);
return orderService.updateStatus(orderId, newStatus);
}
// Reject malformed IDs before hitting the service, with a hint the model can act on.
private void validateOrderIdFormat(String orderId) {
if (orderId == null || !orderId.matches("ORD-\\d+")) {
throw new IllegalArgumentException(
"Invalid order ID format: '%s'. Expected ORD-XXXXX (e.g., ORD-10001)."
.formatted(orderId));
}
}
}
What @McpTool does:
Spring AI reads the @McpTool annotation at startup and generates a JSON schema for the method. The schema is derived from:
- The
namefield, becomes the tool's name in MCP (use snake_case, the MCP convention; omit it and the name is derived from the method name) - The
descriptionfield, becomes the tool's description in MCP - The parameter types, become the JSON schema types
- The
@McpToolParamdescriptions, become the parameter descriptions in the schema
The return type is serialised to JSON automatically. For your Order record, Spring AI uses Jackson to produce the JSON representation.
Annotation-based MCP servers (@McpTool, @McpResource, @McpPrompt, now under org.springframework.ai.mcp.annotation) arrived in Spring AI 1.1 and carry forward unchanged in Spring AI 2.0. On Spring AI 1.0 you exposed tools with the generic @Tool annotation plus a MethodToolCallbackProvider bean. @Tool still works, but @McpTool is the MCP-native approach this course uses.
Registration: nothing to wire
With @McpTool there is no registration bean to write. The MCP annotation scanner (on by default; toggle with spring.ai.mcp.server.annotation-scanner.enabled) discovers every @McpTool-annotated bean, turns each method into a tool specification, and the spring-ai-starter-mcp-server-webmvc autoconfiguration exposes them over the MCP endpoint.
On Spring AI 1.0 this step was explicit: you declared a ToolCallbackProvider bean built with MethodToolCallbackProvider.builder().toolObjects(orderMcpTools).build(). With @McpTool that boilerplate is gone.
Application Properties
# Server identity, shown during MCP capability negotiation
spring.ai.mcp.server.name=order-management
spring.ai.mcp.server.version=1.0.0
# The HTTP port the MCP server listens on
server.port=8080
The Application Class
package com.themcpguy.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OrderManagementMcpServer {
public static void main(String[] args) {
SpringApplication.run(OrderManagementMcpServer.class, args);
}
}
Run and Connect
Start the server:
mvn spring-boot:run
The server starts and listens on http://localhost:8080. With the Spring AI 2.0 default (spring.ai.mcp.server.protocol=STREAMABLE), the MCP endpoint is the Streamable HTTP endpoint at:
http://localhost:8080/mcp
If you opt back into the legacy transport with spring.ai.mcp.server.protocol=SSE, the server instead exposes the deprecated HTTP+SSE endpoint at http://localhost:8080/sse.
Connect from Another Spring AI Application
In your client application's application.yml, point the client at the server:
spring:
ai:
mcp:
client:
streamable-http:
connections:
order-server:
url: http://localhost:8080
Spring AI's MCP client connects to the server, completes the handshake, and discovers the four order management tools. This is the same streamable-http: connections block the companion multi-server module uses to reach the order server, so the agent you build in Module 5 talks to this server with no extra wiring.
The streamable-http: client block above targets the server's default Streamable HTTP endpoint (/mcp), which is exactly what Spring AI 2.0 exposes out of the box, so the client and server line up with no protocol override on either end. If you instead pin the server to the legacy transport with spring.ai.mcp.server.protocol=SSE, switch the client to an sse: connections block so both ends speak the same transport. The client and server transports must always match.
Connect from Claude Desktop
Claude Desktop supports remote HTTP MCP servers via its configuration file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS; %APPDATA%\Claude\claude_desktop_config.json on Windows). Anthropic does not ship an official Linux build of Claude Desktop; Linux users should use Claude Code (the CLI) or one of the other MCP-aware clients. The exact config schema for remote servers has evolved over Claude Desktop releases; verify against the official MCP remote-server quickstart for the current shape:
{
"mcpServers": {
"order-management": {
"command": "npx",
"args": ["mcp-remote", "http://localhost:8080/mcp"]
}
}
}
Claude Desktop launches config-file servers over stdio, so a remote HTTP server is connected through the mcp-remote bridge (which proxies the remote endpoint over stdio). Point it at the server's Streamable HTTP endpoint (/mcp); if the server is running the legacy SSE transport instead, use /sse. On paid plans you can instead add it natively via the Custom Connectors UI (Settings, Connectors, Add custom connector). Restart Claude Desktop. The order management tools appear in the tools list. You can now ask Claude Desktop: "Show me all pending orders" and it will call get_orders_by_status on your Spring Boot application.
The Full-Stack Picture
You now have two patterns working:
Your Spring Boot AI agent (from Modules 4-5) can connect to your Spring Boot MCP server (this module) as an MCP client. The same server serves both Claude Desktop and your own agents. Standard protocol, universal compatibility.
Schema Generation and Control
Spring AI generates JSON schemas from your method signatures automatically. For most types, this works without configuration:
| Java Type | JSON Schema Type |
|---|---|
String | string |
int, Integer, long | integer |
double, Double | number |
boolean, Boolean | boolean |
List<T> | array |
| Record / POJO | object (properties from fields) |
enum | string with enum values |
For custom control, override the schema with a Jackson annotation or customise the ToolCallbackProvider builder.
Handling Optional Parameters
@McpTool(description = "Search orders with optional filters")
public List<Order> searchOrders(
@McpToolParam(description = "Customer ID (optional)", required = false)
String customerId,
@McpToolParam(description = "Status filter (optional)", required = false)
String status,
@McpToolParam(description = "Maximum results to return (default 20)")
int limit) {
// implementation
}
required = false in @McpToolParam marks the parameter as optional in the generated schema.
Error Handling in Tool Methods
When a tool method throws a checked or unchecked exception, Spring AI catches it and returns an MCP error result. The calling model receives the error message and can reason about it.
Best practice: throw IllegalArgumentException for invalid input (the model can recover by correcting the argument) and let runtime exceptions propagate for genuine server errors.
@McpTool(description = "Get order details")
public Order getOrder(String orderId) {
if (!orderId.matches("ORD-\\d+")) {
throw new IllegalArgumentException(
"Invalid order ID format: '%s'. Expected ORD-XXXXX (e.g., ORD-10001)."
.formatted(orderId));
}
return orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"Order '%s' does not exist.".formatted(orderId)));
}
Include the correction hint in the error message. The model will see it and can correct its next attempt automatically.
Next: Module 7: Testing. Test your MCP-integrated applications without burning API credits on every test run.