Module 8: Production Configuration
Duration: ~45 minutes | Level: Intermediate → Advanced | Prerequisites: All previous modules. Some familiarity with Spring Boot Actuator is helpful but not required.
What You'll Learn
Building an AI application that works on your laptop is Module 4. Building one that runs reliably in production, with correct secrets management, meaningful health checks, observable metrics, and graceful degradation, is this module.
The techniques here are not AI-specific. They're standard production Spring Boot practices applied to the specific concerns of LLM and MCP integrations.
Externalising Configuration
Never put API keys, server URLs, or environment-specific settings in application.properties. That file is committed to version control. Your API key in source control is a security incident.
Spring Boot's externalized configuration stack handles this correctly:
Lowest priority → application.properties (committed to source)
→ application-{profile}.properties
→ Environment variables
→ System properties (-Dkey=value)
→ Command-line arguments (--key=value) ← Highest priority
Your application.properties contains only safe defaults:
# application.properties, committed to source control, no secrets
spring.ai.anthropic.chat.model=claude-sonnet-4-6
spring.ai.anthropic.chat.max-tokens=4096
spring.ai.mcp.client.toolcallback.enabled=true
spring.ai.mcp.server.name=order-management
spring.ai.mcp.server.version=1.0.0
Secrets come from environment variables:
# application.properties references env vars, never contains values
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
spring.ai.mcp.client.streamable-http.connections.remote-tools.url=${MCP_SERVER_URL:http://localhost:8080}
The syntax ${MCP_SERVER_URL:http://localhost:8080} sets a default (http://localhost:8080) when the env var isn't set, useful for local development.
In Production Environments
Docker/Kubernetes: Inject via env in pod specs or docker run -e. Never bake secrets into images.
AWS: Use Systems Manager Parameter Store or Secrets Manager with the Spring Cloud AWS integration.
HashiCorp Vault: Use spring-cloud-vault-config. Vault injects secrets at startup and can rotate them.
Kubernetes Secrets: Mount as environment variables or files. Use sealed secrets for encrypted storage in Git.
Profile-Specific Configuration
Use Spring profiles to separate development and production settings:
# application-dev.properties, for local development
spring.ai.mcp.client.stdio.connections.filesystem.command=npx
spring.ai.mcp.client.stdio.connections.filesystem.args=-y,@modelcontextprotocol/server-filesystem,/tmp
logging.level.org.springframework.ai=DEBUG
# application-prod.properties, for production
spring.ai.mcp.client.streamable-http.connections.remote-filesystem.url=${FILESYSTEM_MCP_URL}
logging.level.org.springframework.ai=WARN
Activate a profile with SPRING_PROFILES_ACTIVE=prod environment variable or --spring.profiles.active=prod command-line argument.
Spring Boot Actuator Integration
Add Actuator for operational endpoints:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Configure what's exposed:
# Expose health and info endpoints over HTTP
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
# Actuator on a separate port (keeps it off the public internet)
management.server.port=8081
Health Indicators
Spring AI 2.0.x ships no built-in health indicator for MCP connections, so you write a small custom one. Each configured MCP client exposes a ping() operation, which is exactly what a health check needs. Note that in Spring Boot 4 the health API moved to the org.springframework.boot.health.contributor package, out of the old actuate package:
import java.util.List;
import io.modelcontextprotocol.client.McpSyncClient;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
@Component("mcpClientHealthIndicator")
public class McpClientHealthIndicator implements HealthIndicator {
private final List<McpSyncClient> clients;
public McpClientHealthIndicator(List<McpSyncClient> clients) {
this.clients = clients;
}
@Override
public Health health() {
Health.Builder builder = Health.up();
for (McpSyncClient client : clients) {
String name = client.getClientInfo().name();
try {
client.ping();
builder.withDetail(name, "UP");
} catch (Exception ex) {
builder.down().withDetail(name, "DOWN: " + ex.getMessage());
}
}
return builder.build();
}
}
Spring Boot derives the contributor name by stripping the HealthIndicator suffix from the bean name, so this bean surfaces as mcpClient in /actuator/health.
Check health:
curl http://localhost:8081/actuator/health | python -m json.tool
Expected when healthy:
{
"status": "UP",
"components": {
"mcpClient": {
"status": "UP",
"details": {
"filesystem": "UP",
"order-server": "UP"
}
},
"diskSpace": { "status": "UP" },
"ping": { "status": "UP" }
}
}
If an MCP server goes down, the custom indicator reports DOWN when any ping fails, and the overall status becomes DOWN (or DEGRADED if you configure separate readiness and liveness probes).
Kubernetes Probes
Configure separate liveness and readiness probes, a common production pattern:
# Liveness: is the process healthy? Restart if not.
management.endpoint.health.group.liveness.include=ping,diskSpace
# Readiness: is the app ready to serve requests? Remove from load balancer if not.
management.endpoint.health.group.readiness.include=mcpClient
In your Kubernetes deployment:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
initialDelaySeconds: 10
periodSeconds: 5
If an MCP connection drops, the pod is removed from the load balancer (readiness fails) but not restarted (liveness still passes). This is correct behaviour, the application is healthy but temporarily degraded.
Micrometer Metrics
Spring AI integrates with Micrometer for AI-specific metrics. Add the Micrometer Registry for your metrics backend:
<!-- Prometheus (most common in Kubernetes) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Spring AI automatically records:
| Observation / Metric | Description |
|---|---|
spring.ai.chat.client | ChatClient call()/stream() duration and status |
gen_ai.client.operation | ChatModel (and EmbeddingModel) call duration and status |
gen_ai.client.token.usage | Input and output token counts (recorded by the ChatModel) |
spring.ai.tool | Tool call duration and status during chat model interactions |
Spring AI does not emit an MCP-specific metric. MCP client health is surfaced through the custom health indicator shown earlier, not through a dedicated Micrometer metric.
Custom Metrics for Your Tools
Instrument your tool methods directly when you need business-level metrics:
package com.themcpguy.orders;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.stereotype.Component;
@Component
public class OrderMcpTools {
private final OrderService orderService;
private final Counter orderLookups;
private final Counter orderUpdates;
private final Timer orderLookupTimer;
OrderMcpTools(OrderService orderService, MeterRegistry registry) {
this.orderService = orderService;
this.orderLookups = Counter.builder("mcp.tool.order.lookups")
.description("Number of order lookup tool calls")
.register(registry);
this.orderUpdates = Counter.builder("mcp.tool.order.updates")
.description("Number of order status update tool calls")
.register(registry);
this.orderLookupTimer = Timer.builder("mcp.tool.order.lookup.duration")
.description("Duration of order lookup tool calls")
.register(registry);
}
@McpTool(name = "get_order", description = "Look up an order by its unique order ID.")
public Order getOrder(String orderId) {
return orderLookupTimer.record(() -> {
orderLookups.increment();
return orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"Order not found: " + orderId));
});
}
@McpTool(name = "update_order_status", description = "Update the status of an order.")
public Order updateOrderStatus(String orderId, String newStatus) {
orderUpdates.increment();
return orderService.updateStatus(orderId, newStatus);
}
}
Your custom metrics appear alongside Spring AI's built-in metrics in the Prometheus scrape and your Grafana dashboards.
Retry and Resilience
LLM APIs are external services with real failure modes: rate limits, transient network errors, temporary provider outages. Build resilience into your AI calls.
Retry with Resilience4j
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot4</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
Resilience4j is not managed by the Spring Boot BOM, so pin the version explicitly. The @Retry annotation is an AspectJ aspect that Resilience4j silently skips when AspectJ is not on the classpath, so the AspectJ starter is required. In Spring Boot 4 this starter is spring-boot-starter-aspectj (the old spring-boot-starter-aop name no longer exists).
package com.themcpguy.springai.agent;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class ResilientAgentService {
private final ChatClient chatClient;
ResilientAgentService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@Retry(name = "ai-chat", fallbackMethod = "chatFallback")
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
public String chatFallback(String message, Throwable ex) {
// Return a graceful fallback when the AI service is unavailable
return "I'm temporarily unable to process your request due to a service issue. " +
"Please try again in a moment. (Error: " + ex.getMessage() + ")";
}
}
Configure retry behaviour in application.properties:
# Retry configuration for AI calls
resilience4j.retry.instances.ai-chat.max-attempts=3
resilience4j.retry.instances.ai-chat.wait-duration=2s
resilience4j.retry.instances.ai-chat.retry-exceptions=\
org.springframework.web.client.ResourceAccessException,\
java.net.ConnectException
# Exponential backoff to avoid hammering a struggling service
resilience4j.retry.instances.ai-chat.enable-exponential-backoff=true
resilience4j.retry.instances.ai-chat.exponential-backoff-multiplier=2
Timeout Configuration
Configure timeouts for both the LLM API call and MCP operations:
# Spring AI tool call timeout (how long to wait for any MCP operation to complete)
# Applies to all stdio and remote connections; no per-connection timeout exists in 2.0.x.
spring.ai.mcp.client.request-timeout=30s
AnthropicChatOptions in Spring AI 2.0.x has a built-in timeout for the HTTP call to Anthropic (default 60 seconds). Set it via a property:
# Timeout for the HTTP call to the Anthropic API (default 60s)
spring.ai.anthropic.timeout=60s
Or programmatically:
AnthropicChatOptions options = AnthropicChatOptions.builder()
.timeout(Duration.ofSeconds(60))
.build();
For lower-level HTTP tuning (interceptors, connection pool, dispatcher), the Anthropic model in 2.0.x runs on the official Anthropic SDK over OkHttp, not Spring's RestClient. Provide an AnthropicHttpClientBuilderCustomizer bean to customise the underlying OkHttp client:
@Bean
public AnthropicHttpClientBuilderCustomizer anthropicHttpCustomizer() {
return builder -> {
// customise the OkHttp-backed client builder here
};
}
Set timeouts conservatively. LLM calls for complex tasks with multiple tool rounds can take 30-60 seconds. Too-short timeouts cause false failures. Too-long timeouts mask real problems.
Logging and Observability
Structured logging makes AI applications observable. Use Logback (Spring Boot default) with JSON output for log aggregation systems:
<!-- logback-spring.xml -->
<configuration>
<springProfile name="prod">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="WARN">
<appender-ref ref="STDOUT"/>
</root>
<logger name="com.themcpguy" level="INFO"/>
</springProfile>
<springProfile name="!prod">
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<logger name="org.springframework.ai.mcp" level="DEBUG"/>
</springProfile>
</configuration>
What to Log at INFO
Your tool methods should log meaningful business events, not protocol details:
@McpTool(name = "update_order_status", description = "Update the status of an order.")
public Order updateOrderStatus(String orderId, String newStatus) {
log.info("Order status update: orderId={} newStatus={}", orderId, newStatus);
Order updated = orderService.updateStatus(orderId, newStatus);
log.info("Order updated successfully: orderId={} previousStatus={} newStatus={}",
orderId, updated.status(), newStatus);
return updated;
}
Use structured logging parameters (not string concatenation) so log aggregation tools can index the fields.
Distributed Tracing
For microservice deployments, add distributed tracing to track a request from the user through the agent, through tool calls, to the MCP server:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
Spring AI propagates trace context through ChatClient calls and tool invocations automatically. The trace includes the LLM call, each tool call, and the MCP operations, giving you end-to-end visibility into agent execution.
Resource Limits
MCP servers are processes. Be explicit about process limits in production.
Stdio servers (child processes):
- Monitor child process memory and CPU via JVM process management or your container runtime
- Set
Xmxlimits on any JVM-based MCP servers you start - Consider whether stdio servers should be external services rather than child processes at scale
Streamable HTTP connections (remote HTTP servers):
- Configure connection pool sizes
- Monitor open connection counts (streaming responses and legacy SSE clients hold persistent connections)
- Use circuit breakers (Resilience4j
@CircuitBreaker) to stop calling a failing remote server
The legacy spring.ai.mcp.client.sse.* properties still work in Spring AI 2.0 but are deprecated for removal, so new production deployments should use streamable-http.
LLM Token Budget
Every AI request costs money (or compute resources, for local models). Monitor token usage and set budget limits:
@Bean
public ChatClientCustomizer tokenBudgetCustomizer(TokenBudgetAdvisor tokenBudgetAdvisor) {
return builder -> builder.defaultAdvisors(
tokenBudgetAdvisor // Custom CallAdvisor you implement to enforce a token budget
);
}
TokenBudgetAdvisor is a class you write yourself. Implement CallAdvisor (org.springframework.ai.chat.client.advisor.api.CallAdvisor), call the chain, then inspect ChatResponse.getMetadata().getUsage() from the returned ChatClientResponse and alert or throttle when usage exceeds your thresholds. Do not confuse it with Spring AI's built-in SafeGuardAdvisor, which is a sensitive-word content filter and requires a List<String> of sensitive words in its constructor.
Deployment Checklist
Before going to production, verify:
- API keys loaded from environment variables (not
application.properties) - Spring profile configured for production (
SPRING_PROFILES_ACTIVE=prod) - Actuator endpoints on a separate port, not publicly accessible
- Health checks configured and responding correctly
- Micrometer metrics registered and visible in your monitoring system
- Retry logic configured for LLM calls
- Timeouts set for all external calls (LLM API, MCP servers)
- Logging configured with structured JSON output
- MCP server processes (for stdio) have appropriate resource limits
- At least one E2E smoke test passing against the production configuration
- Fallback responses implemented for when the AI service is unavailable
Congratulations
You've completed the Spring AI + MCP Integration course. You can now:
- Build Spring Boot applications that consume MCP servers as AI tool providers
- Expose Spring services as MCP tools that any MCP-compatible client can use
- Test AI-integrated applications reliably without burning API credits
- Ship production-grade AI features with proper observability and resilience
The companion code repository at projects/mcp-spring-ai-course/ contains complete, compilable implementations for each module. The next course in the series, Building Remote MCP Servers, covers HTTP transport in depth, OAuth authentication, and running MCP servers at scale. It is coming soon.
Further Reading:
- Spring AI Reference Documentation
- Spring AI MCP Integration Guide
- MCP Specification, Protocol Lifecycle
- Resilience4j Getting Started Guide
- Micrometer Reference
- Embabel, a JVM agent framework layered on top of Spring AI that both consumes MCP servers as agent tools and exposes its own agents as MCP tools. As of July 2026,
1.0.0targets Spring Boot 3.5.x and Spring AI 1.1.x, one generation behind this course.