Introduction
There's a class of bug that every backend engineer knows too well: the bug you can only reproduce in a staging or production-like environment. Your local setup doesn't have the right data. You can't attach a debugger to staging. You end up either adding temporary log statements and redeploying, or writing a throwaway admin endpoint, poking around manually, and deleting it later.
This article shows a third option: embed a small, read-only diagnostic server directly in your Spring Boot application, then connect Claude Code to it. Claude can then call your business logic directly from the IDE — inspecting records, running eligibility checks, tracing through validation steps — while you describe the problem in plain English. This represents a massive leap in in-IDE debugging and observability.
The integration is built on MCP (Model Context Protocol), an open standard for connecting AI models to external tools. Spring AI provides first-class support for hosting an MCP server inside a Spring Boot application with almost no boilerplate.
By the end of this guide, you'll have:
- An MCP server running inside your Spring Boot app
- A set of custom diagnostic tools callable from Claude Code
- A workflow where you describe a bug and Claude uses your tools to find the root cause
What is MCP and Why Does It Fit Spring Boot?
MCP (Model Context Protocol) is a protocol that lets AI assistants call external tools and receive structured results. Instead of just reading code, an AI can execute functions and reason about the output.
Spring AI (the AI integration layer of the Spring ecosystem) added an MCP server module that lets you expose Spring beans as callable tools. The transport layer uses Server-Sent Events (SSE) transport over HTTP — so your MCP server is just another endpoint on your existing application. No separate process, no sidecar, no infrastructure changes.
The pattern that emerges is powerful: your existing service layer becomes the diagnostic interface. You write a thin wrapper that calls your repositories and services in read-only mode, annotate the methods, and Claude Code can invoke them from the IDE while you describe a problem in plain language.
Prerequisites
- Spring Boot 3.x (tested with 3.2+)
- Java 17 or 21
- Claude Code installed (CLI:
npm install -g @anthropic-ai/claude-code) - Maven or Gradle build
Step 1: Add the Dependency
Add the Spring AI MCP server dependency to your pom.xml:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.0.3</version>
</dependency> If you use Gradle:
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc:1.0.3' This single dependency brings in the MCP protocol handling, SSE transport, and the @Tool / @ToolParam annotations. No additional Spring AI BOM is required for the MCP server module alone, but check the Spring AI documentation for the latest version compatibility with your Spring Boot version.
Step 2: Design Your Tool Service
Create a Spring service class that holds your diagnostic tools. This is where you write the actual logic that Claude will call.
The key principle: every method in this class should be read-only and have zero side effects. No writes, no cache invalidations, no event publishing. This service is an inspection layer, not an action layer.
Here's the pattern:
package com.example.myapp.mcp;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DiagnosticToolService {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final PricingService pricingService;
public DiagnosticToolService(
OrderRepository orderRepository,
CustomerRepository customerRepository,
PricingService pricingService
) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.pricingService = pricingService;
}
@Tool(description = "Fetch full details for a customer by ID including their membership tier, active subscriptions, and last order date")
@Transactional(readOnly = true)
public String getCustomerDetails(
@ToolParam(description = "The customer's ID") Long customerId
) {
Customer customer = customerRepository.findById(customerId).orElse(null);
if (customer == null) {
return "Customer not found: " + customerId;
}
return """
Customer ID: %d
Name: %s
Tier: %s
Active Subscriptions: %s
Last Order: %s
""".formatted(
customer.getId(),
customer.getName(),
customer.getTier(),
customer.getActiveSubscriptions(),
customer.getLastOrderDate()
);
}
@Tool(description = "Run eligibility checks for a discount code against a customer and return a step-by-step audit trail explaining why the code is or isn't valid")
@Transactional(readOnly = true)
public String debugDiscountEligibility(
@ToolParam(description = "The customer ID") Long customerId,
@ToolParam(description = "The discount code to check") String discountCode
) {
StringBuilder result = new StringBuilder();
// Check 1: Does the discount code exist?
DiscountCode discount = discountRepository.findByCode(discountCode);
if (discount == null) {
result.append("[FAILED] Discount code not found: ").append(discountCode).append("\n");
return result.toString();
}
result.append("[PASSED] Discount code exists\n");
// Check 2: Is it currently active?
if (!discount.isActive()) {
result.append("[FAILED] Discount is inactive\n");
return result.toString();
}
result.append("[PASSED] Discount is active\n");
// Check 3: Is the customer in the eligible segment?
Customer customer = customerRepository.findById(customerId).orElse(null);
if (customer == null) {
result.append("[FAILED] Customer not found\n");
return result.toString();
}
if (!discount.getEligibleTiers().contains(customer.getTier())) {
result.append("[FAILED] Customer tier '").append(customer.getTier())
.append("' not in eligible tiers: ").append(discount.getEligibleTiers()).append("\n");
return result.toString();
}
result.append("[PASSED] Customer tier eligible\n");
// Check 4: Has the customer already used this discount?
long usageCount = orderRepository.countByCustomerIdAndDiscountCode(customerId, discountCode);
if (usageCount >= discount.getMaxUsagePerCustomer()) {
result.append("[FAILED] Customer has already used this discount ")
.append(usageCount).append(" time(s), limit is ")
.append(discount.getMaxUsagePerCustomer()).append("\n");
return result.toString();
}
result.append("[PASSED] Usage limit not exceeded (used ").append(usageCount).append(" time(s))\n");
result.append("\n[RESULT] Customer IS eligible for this discount.");
return result.toString();
}
} Key things to notice:
@Tool(description = ...)— The description is what Claude reads to decide whether to call this method. Write it as if you're describing the tool to a new teammate. Be specific about what it returns and what parameters mean.@ToolParam(description = ...)— Same principle for parameters. Claude uses these descriptions to understand what value to pass.- Return String — Tools should return human-readable strings. Claude reads the output and reasons about it. Structured text (with labels like [PASSED], [FAILED]) works much better than JSON objects.
@Transactional(readOnly = true)— Mark everything read-only at the database level. Understanding Spring @Transactional annotation mechanics is crucial here as this is an explicit guarantee that prevents accidental writes.- Fail fast with descriptive messages — When a check fails, return immediately with a clear reason. The step-by-step audit trail pattern (check → fail with reason → return) is more useful to Claude than a generic error.
Step 3: Register the Tools via Configuration
Create a configuration class that registers your tool service with Spring AI's MCP infrastructure:
package com.example.myapp.config;
import com.example.myapp.mcp.DiagnosticToolService;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class McpServerConfig {
@Bean
public ToolCallbackProvider diagnosticTools(DiagnosticToolService diagnosticToolService) {
return MethodToolCallbackProvider.builder()
.toolObjects(diagnosticToolService)
.build();
}
} MethodToolCallbackProvider scans the service object for methods annotated with @Tool and registers them automatically. You don't need to list them individually — adding @Tool to a new method in your service is enough to make it available.
You can register multiple service classes by chaining .toolObjects(...) calls or creating separate ToolCallbackProvider beans for different domains.
Step 4: Configure Application Properties
Add the MCP server configuration to your application.properties:
# Enable the MCP server
spring.ai.mcp.server.enabled=true
# Use SSE (Server-Sent Events) transport over HTTP
spring.ai.mcp.server.transport=sse
# Server name — this is what Claude Code will display and how you'll identify this server
spring.ai.mcp.server.name=my-app-debugger
# Version string — useful for tracking your tool set
spring.ai.mcp.server.version=1.0.0 The SSE endpoint will be available at http://localhost:{server.port}/sse by default when you run the application. No additional URL configuration is needed.
If you only want the MCP server active in certain environments (local, staging), use Spring profiles:
# application-local.properties
spring.ai.mcp.server.enabled=true
# application.properties (base)
spring.ai.mcp.server.enabled=false Step 5: Connect Claude Code
With your Spring Boot application running, configure Claude Code to connect to it.
Create or edit .claude/settings.local.json in your project root:
{
"mcpServers": {
"my-app-debugger": {
"type": "sse",
"url": "http://localhost:8080/sse"
}
}
} Replace 8080 with your actual server port and my-app-debugger with the name you set in application properties.
To verify the connection, restart Claude Code and run /mcp in the Claude Code terminal. You should see your server listed as connected with the tools enumerated.
Important: settings.local.json is for local developer configuration. Add it to .gitignore if you don't want it committed. For team-wide MCP server configuration, use settings.json (committed) instead, but be careful not to hardcode environment-specific URLs there.
Step 6: Use It
Start your Spring Boot application, open Claude Code in your project, and start a conversation. You don't need to tell Claude which tools to use — it reads the tool descriptions and decides based on your question.
Example interactions:
Scenario 1: Why isn't a discount applying?
You: Customer ID 84231 is saying their SUMMER20 discount isn't working at checkout. Can you figure out why?
Claude: Let me check that. [calls debugDiscountEligibility(84231, "SUMMER20")] The discount code exists and is active, but the check failed at step 3: the customer is on the "BASIC" tier, but SUMMER20 is only eligible for "PREMIUM" and "GOLD" tiers. The customer would need to upgrade their membership to use this code.
Scenario 2: Inspecting a customer's current state
You: What does customer 84231's account look like right now?
Claude: [calls getCustomerDetails(84231)] Customer 84231 is John Smith, on the BASIC tier since March 2024. He has 2 active subscriptions (Weekly Box, Newsletter). Last order was 3 days ago. No premium features are active.
Scenario 3: Compound investigation
You: We have 3 customers complaining they're not getting the free shipping offer. IDs are 10241, 10889, and 11002. What's going on?
Claude: [calls debugDiscountEligibility for each customer in sequence]
After checking all three:
- 10241: Fails at usage limit — they've already used free shipping twice this month (limit is 2)
- 10889: Fails at tier check — they're BASIC, offer requires PREMIUM
- 11002: Offer expired yesterday — valid until Feb 27, 2026
These are three separate issues, not a systemic bug.
Notice that in all three cases, you described the problem in plain language. This is a perfect example of Agentic AI tool execution, where Claude decides which tools to call, passes the right parameters, and synthesizes the results into an explanation. You never had to look up customer IDs in Postman or run a manual SQL query.
Design Principles for Good MCP Tools
1. One tool per question type, not one tool per table
Don't create getCustomer(), getOrders(), getDiscounts() and make Claude assemble the picture. Create debugDiscountEligibility() that answers the actual question you ask most often. Tools should mirror the diagnostic questions your team asks, not the data model.
2. The description is the interface contract
Claude decides whether to call your tool entirely based on its @Tool(description). Write descriptions that answer: What question does this tool answer? What does it return? When should someone call it?
- Bad: "Get customer data"
- Good: "Fetch a customer's full profile including tier, active subscriptions, last order date, and account flags — use this to understand a customer's current state before investigating offer or discount issues"
3. Return narrative strings, not raw data
Tools that return Customer{id=84231, tier=BASIC, ...} force Claude to parse and interpret. Tools that return labeled, human-readable text let Claude reason faster and give better explanations. Format your output the way you'd write a Slack message explaining the situation.
4. Step-by-step audit trails over boolean results
For eligibility checks, validation flows, or multi-condition logic: don't return true or false. Return every check with its result. "Passed franchise check, passed segment check, FAILED at usage limit: customer has used this 5 times, limit is 3" is infinitely more useful than "ineligible". Claude can then explain exactly which condition failed and why.
5. Enforce read-only at every layer
Use @Transactional(readOnly = true) on every tool method. Avoid injecting any service that has write operations — or if you must inject it, don't call those methods. The rule is simple: if a method changes state, it doesn't belong in your diagnostic service.
6. Handle missing data gracefully
Return a clear, descriptive string when an entity isn't found rather than throwing an exception. Claude can communicate "Customer 99999 was not found" much more helpfully than a stack trace.
Keeping It Safe
A few guardrails worth explicitly building in:
- Profile-gate the MCP server. Use
spring.ai.mcp.server.enabled=falsein production properties and true only in local/staging profiles. This ensures the diagnostic surface never accidentally exposes to production. - Don't expose credentials or PII in tool output. Filter out password hashes, payment tokens, and full PII from tool responses. Design tool output for your engineering team, not for end users.
- Log tool invocations. Even though tools are read-only, logging which tools Claude called and with which parameters gives you an audit trail, which is useful both for debugging and for understanding how Claude is using your tools.
- Avoid calling downstream services that have rate limits or costs. If your tool calls a third-party API to fulfill a diagnostic query, be aware that each Claude invocation triggers a real API call.
What to Build Next
Once you have the basic pattern working, natural extensions include:
- Multi-system correlation tools: Instead of checking eligibility in one service, build a tool that calls across multiple microservices (via Feign or RestTemplate) and returns a unified picture. Claude can then investigate cross-service bugs that would normally require switching between multiple dashboards.
- Schema-aware query tools: Expose a tool like
runNamedQuery(queryName, params)that executes pre-approved, parameterized JPQL queries by name. You get the flexibility of ad-hoc queries without exposing raw SQL. - Health check and configuration tools: Expose tools that check feature flag states, current configuration values (from database or config server), or active scheduler states. Useful for debugging "why isn't this job running?" questions.
- Deployment-aware tools: If your app has a concept of "current version" or "deployed feature set", expose it as a tool. Claude can factor deployment state into its investigation.
Full Reference: Minimal Working Implementation
For a complete picture, here's what a minimal integration looks like end-to-end:
pom.xml (one new dependency):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.0.3</version>
</dependency> DiagnosticToolService.java (your tools):
@Service
public class DiagnosticToolService {
// inject your existing repositories and services
@Tool(description = "...")
@Transactional(readOnly = true)
public String yourTool(@ToolParam(description = "...") YourParam param) {
// call your existing code, return a descriptive string
}
} McpServerConfig.java (wire it up):
@Configuration
public class McpServerConfig {
@Bean
public ToolCallbackProvider tools(DiagnosticToolService service) {
return MethodToolCallbackProvider.builder().toolObjects(service).build();
}
} application.properties (enable and name):
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.transport=sse
spring.ai.mcp.server.name=my-app-debugger
spring.ai.mcp.server.version=1.0.0 .claude/settings.local.json (connect Claude Code):
{
"mcpServers": {
"my-app-debugger": {
"type": "sse",
"url": "http://localhost:8080/sse"
}
}
} Five files. No new infrastructure. Your Spring Boot app is now a Claude Code tool.
Closing Thoughts
The MCP integration isn't about replacing your debugger. It's about making the questions you ask ten times a day — "why isn't this offer showing?", "why is this customer getting this error?", "what state is this record in?" — answerable in seconds from the IDE, a hallmark of modern AI-native IDE integrations, instead of minutes of context-switching.
The key insight is that the code to answer these questions already exists in your service layer. You've already written the eligibility checks, the validation flows, the lookup queries. MCP just gives Claude a way to call that code, so instead of writing the same diagnostic queries over and over, you describe the problem and Claude does the legwork.
Start small: pick the three questions your team asks most often when something goes wrong. Build one tool for each. Connect Claude Code. See how the debugging workflow changes.