Building an MCP Server in Spring Boot (Step by Step)
In the previous post, I explained what MCP is and why it matters. Now let's build one. I'll take my payment-service, a regular Spring Boot app with PostgreSQL and Kafka, and add an MCP server to it in about 30 minutes.
By the end, the service will expose three tools that any AI agent can discover and call: getPaymentStatus, getRefundRate, and getFraudRiskScore.
Starting Point
My payment-service already has these Spring beans:
@Service
public class PaymentService {
public Optional<Payment> findByTransactionId(String txId) { ... }
public long count() { ... }
public long countByStatus(PaymentStatus status) { ... }
public List<Payment> findByStatusOrderByCreatedAtDesc(PaymentStatus status) { ... }
}
@Service
public class FraudValidationService {
public String calculateFraudScore(double amount, String clientType, int hour,
int orderCount, double successRate, int totalItems) { ... }
}
These are existing business logic methods with real database queries. The goal is to expose them via MCP without changing their implementation.
Step 1: Add the Dependency
implementation 'io.modelcontextprotocol.sdk:mcp:0.9.0'
That's the only new dependency. The MCP SDK is lightweight and has no transitive dependencies that conflict with Spring Boot.
Step 2: Set Up the SSE Transport
MCP needs an HTTP transport for communication. The SDK provides an SSE-based transport that works as a servlet:
@Configuration
public class PaymentMcpConfig {
@Bean
public HttpServletSseServerTransportProvider mcpTransport() {
return HttpServletSseServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
.messageEndpoint("/mcp/message")
.build();
}
@Bean
public ServletRegistrationBean<HttpServletSseServerTransportProvider> mcpServlet(
HttpServletSseServerTransportProvider transport) {
return new ServletRegistrationBean<>(transport, "/sse", "/mcp/message");
}
}
Two endpoints are registered. /sse is the SSE connection endpoint where clients connect. /mcp/message is where JSON-RPC messages are sent. The objectMapper handles serialization.
Step 3: Define Your Tools
Each tool has four components: a name, a description (this is what the LLM reads to decide when to use it), a JSON schema for parameters, and a handler function.
Here's the payment status tool:
private SyncToolSpecification getPaymentStatus(PaymentService paymentService) {
return tool(
"getPaymentStatus",
"Returns the current payment status for a given transaction. " +
"Use to verify whether a payment was processed, pending, or refunded.",
"""
{
"type": "object",
"properties": {
"transactionId": {
"type": "string",
"description": "Transaction ID associated with the saga"
}
},
"required": ["transactionId"]
}
""",
args -> {
String txId = (String) args.get("transactionId");
return paymentService.findByTransactionId(txId)
.map(p -> "status=" + p.getStatus()
+ " | totalAmount=" + p.getTotalAmount()
+ " | totalItems=" + p.getTotalItems())
.orElse("No payment found for transactionId=" + txId);
}
);
}
The description matters more than you'd expect. The LLM uses it to decide when to call this tool. A vague description like "gets payment info" leads to the agent calling it at wrong times. A precise description like "returns payment status for a given transaction, use to verify whether processed, pending, or refunded" gives the LLM the context it needs.
The handler is a Function<Map<String, Object>, String>. It receives the arguments as a map, calls your existing business logic, and returns a string. The return value goes back to the LLM as context for generating its response.
Step 4: Build the MCP Server
Wire everything together:
@Bean
public McpSyncServer mcpServer(
HttpServletSseServerTransportProvider transport,
PaymentService paymentService,
FraudValidationService fraudService) {
return McpServer.sync(transport)
.serverInfo("payment-mcp", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(
getPaymentStatus(paymentService),
getRefundRate(paymentService),
getFraudRiskScore(fraudService)
)
.build();
}
The serverInfo is what the client sees when it connects. The capabilities declaration tells the client this server supports tools. The .tools(...) call registers all your tool specifications.
A Helper to Reduce Boilerplate
I use a small helper method to avoid repeating the SyncToolSpecification construction:
private SyncToolSpecification tool(String name,
String description,
String schema,
Function<Map<String, Object>, String> handler) {
return new SyncToolSpecification(
new McpSchema.Tool(name, description, schema),
(exchange, args) -> success(handler.apply(args))
);
}
private CallToolResult success(String text) {
return CallToolResult.builder()
.content(List.of(new TextContent(text)))
.isError(false)
.build();
}
Every tool follows the same pattern: receive args, call business logic, return text. The helper keeps each tool definition focused on its own logic.
The Full Config for All 4 Services
I repeated this pattern for each microservice. The tools map directly to existing service methods:
order-service (MongoDB):
.tools(
getOrderById(orderRepository),
getLastEventByOrder(eventRepository),
listRecentEvents(eventRepository)
)
inventory-service (PostgreSQL):
.tools(
getStockByProduct(inventoryService),
getLowStockAlert(inventoryService),
checkReservationExists(orderInventoryRepository)
)
product-validation-service (PostgreSQL):
.tools(
checkProductExists(productValidationService),
checkValidationExists(productValidationService),
listCatalog(productValidationService)
)
Each config class follows the same structure. Transport bean, servlet registration, server bean with tools. Copy the pattern, change the tools.
Writing Good Tool Descriptions
After building all 12 tools, I noticed a pattern in which descriptions work well with LLMs and which ones cause problems.
Bad description: "Gets stock." The LLM doesn't know when to call this vs the payment tool.
Good description: "Returns the current available stock quantity for a given product code. Use this tool to check whether a product has sufficient inventory before processing a saga order."
The trick is to include two things: what the tool returns and when to use it. The "use this tool to..." part gives the LLM decision criteria.
For parameters, be explicit about valid values:
{
"productCode": {
"type": "string",
"description": "Product code: COMIC_BOOKS, BOOKS, MOVIES, MUSIC"
}
}
Listing the valid values in the description prevents the LLM from inventing codes like "COMICS" or "BOOK".
What's Next
The servers are running. In the next post, I'll show the client side: how to connect to multiple MCP servers from a single LangChain4j agent and let the LLM pick the right tools at runtime.
The repo: github.com/pedrop3/saga-orchestration
Top comments (0)