Module 9: Production Deployment
Duration: ~35 minutes | Level: Advanced | Prerequisites: Module 8: Security
When Does "Production" Apply?
A stdio server running on a developer's machine is already "production", for that developer. The challenges arrive when:
- Multiple users need to share a server
- Uptime matters (the server can't just be "run this JAR when you need it")
- Credentials must be managed centrally, not distributed to every client
- Scaling is necessary
For these scenarios, an HTTP transport is the right choice. The current MCP standard (introduced in spec revision 2025-03-26) is the Streamable HTTP transport, served on a single /mcp endpoint; in the Java SDK this is HttpServletStreamableServerTransportProvider. For brand new deployments, prefer it. The older HTTP+SSE transport (separate /sse and message endpoints, HttpServletSseServerTransportProvider) is deprecated in both the spec and the SDK, but many existing MCP clients still support it, so it remains a pragmatic choice for interoperability with older hosts.
The runnable companion server in projects/mcp-java-sdk/production-server/ wires HttpServletSseServerTransportProvider (the HTTP+SSE provider), because it is the transport a broad range of existing clients can still connect to. The wiring pattern below (a transport bean plus a ServletRegistrationBean) is identical for the Streamable HTTP provider: swap the provider type and register it on the single /mcp endpoint instead of the /sse and /mcp/message pair. The lesson code matches the companion server so you can run exactly what you read.
This module covers deploying an HTTP-based MCP server.
From plain Java to a Spring Boot wrapper
Modules 1-8 of this course built MCP servers as plain Java applications: a main() method that constructs a StdioServerTransportProvider, builds the server, and blocks until stdin closes. For local stdio servers that's the simplest path and you don't need anything more.
Production MCP servers (multi-user, HTTP, lifecycle-managed) benefit from running inside Spring Boot. Spring Boot brings:
- HTTP server (Tomcat/Netty) lifecycle handling
- Configuration management (
application.yml, profiles, externalised secrets) - Health endpoints, metrics, and tracing wiring
- Dependency injection so your services aren't manually wired
The migration from plain-Java to Spring Boot is small:
- Add
spring-boot-starter-webto yourpom.xmlalongside the MCP SDK dependency. - Add
@SpringBootApplicationto a new class with amain()that callsSpringApplication.run(...). - Replace your old
main()'s server construction with a@Beanthat builds and returns theMcpSyncServer(orMcpAsyncServer). Spring will instantiate it during context startup. The companion server returns anMcpSyncServerbecause itsOrderToolsare synchronous; pick the type that matches your tool specifications. - Stop calling
Thread.currentThread().join(): Spring Boot's HTTP server keeps the JVM alive. - Convert your tool/resource construction from inline lambdas in
main()to a@Componentbean that exposes each tool as aSyncToolSpecification(the same builder pattern from Module 3), registered on theMcpSyncServerbean from step 3. If you take the Spring AI starter fast path below instead, you annotate methods with@McpTooland the annotation scanner registers them for you, with no registration bean to write (the@McpToolmodel from the Spring AI course Module 6).
The fast path: rather than wire the raw Java MCP SDK into Spring yourself, use the Spring AI MCP server starter (spring-ai-starter-mcp-server-webmvc), which auto-wires the HTTP transport, exposes the standard endpoints, and lets you expose @McpTool-annotated beans. The Spring AI course covers that integration in detail.
Switching to HTTP Transport
If you want to wire the raw Java SDK HTTP transport without Spring AI's starter (perhaps because you need fine-grained control over the transport configuration), use HttpServletSseServerTransportProvider directly. The code below is exactly what the companion production-server runs.
@SpringBootApplication
public class ProductionMcpApplication {
public static void main(String[] args) {
SpringApplication.run(ProductionMcpApplication.class, args);
}
}
The transport provider is itself an HttpServlet, so it needs three beans: the transport itself, a ServletRegistrationBean that routes HTTP traffic to it, and the McpSyncServer that registers your tools. OrderTools is a @Component that exposes each tool as a SyncToolSpecification (the same builder pattern from Module 3); getOrderSpec() and updateOrderStatusSpec() return those specs.
@Configuration
public class McpServerConfig {
@Bean
public McpJsonMapper mcpJsonMapper() {
return new JacksonMcpJsonMapper(new ObjectMapper());
}
@Bean
public HttpServletSseServerTransportProvider mcpTransportProvider(
McpJsonMapper jsonMapper,
@Value("${app.mcp.sse-endpoint:/sse}") String sseEndpoint,
@Value("${app.mcp.message-endpoint:/mcp/message}") String messageEndpoint) {
return HttpServletSseServerTransportProvider.builder()
.jsonMapper(jsonMapper)
.sseEndpoint(sseEndpoint)
.messageEndpoint(messageEndpoint)
.build();
}
// Register the transport provider as a servlet on both endpoints so Spring's
// Tomcat routes incoming HTTP traffic to it. Without this, the bean exists but
// never sees a request.
@Bean
public ServletRegistrationBean<Servlet> mcpServletRegistration(
HttpServletSseServerTransportProvider transport,
@Value("${app.mcp.sse-endpoint:/sse}") String sseEndpoint,
@Value("${app.mcp.message-endpoint:/mcp/message}") String messageEndpoint) {
ServletRegistrationBean<Servlet> registration = new ServletRegistrationBean<>(
transport, sseEndpoint, messageEndpoint);
registration.setName("mcp-transport");
registration.setAsyncSupported(true);
return registration;
}
@Bean
public McpSyncServer mcpServer(
HttpServletSseServerTransportProvider transport,
OrderTools orderTools,
@Value("${spring.application.name:production-server}") String name,
@Value("${app.version:1.0.0}") String version) {
return McpServer.sync(transport)
.serverInfo(name, version)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(orderTools.getOrderSpec(), orderTools.updateOrderStatusSpec())
.build();
}
}
The mcpJsonMapper bean constructs its own Jackson 2 ObjectMapper rather than injecting Spring's. Spring Boot 4 auto-configures Jackson 3 (tools.jackson) and no longer exposes a com.fasterxml.jackson.databind.ObjectMapper bean, while mcp-json-jackson2 needs a Jackson 2 mapper, so injecting the Boot-managed mapper fails at startup. (Alternatively, switch the dependency to mcp-json-jackson3 and inject Boot 4's tools.jackson ObjectMapper instead.)
The MCP server registers its SSE endpoint at /sse and accepts POST requests at /mcp/message. In the raw Java SDK only the SSE endpoint has a default (/sse); the message endpoint is mandatory, you must set it or build() throws IllegalStateException("MessageEndpoint must be set"), so /mcp/message is the value chosen above rather than an SDK default. Both values match the Spring AI 2.0.x starter's HTTP+SSE defaults, and both are configurable. The ServletRegistrationBean is load-bearing: without it the transport bean is constructed but Tomcat never routes requests to it, so the server appears up while every MCP call hangs.
Externalise Configuration
Never hardcode credentials. Use environment variables or Spring's @Value:
@Configuration
public class DatabaseConfig {
@Value("${DB_URL}")
private String dbUrl;
@Value("${DB_USER}")
private String dbUser;
@Value("${DB_PASSWORD}")
private String dbPassword;
@Bean
public DataSource dataSource() {
var config = new HikariConfig();
config.setJdbcUrl(dbUrl);
config.setUsername(dbUser);
config.setPassword(dbPassword);
config.setMaximumPoolSize(10);
config.setConnectionTimeout(5000);
return new HikariDataSource(config);
}
}
# application.yml, defaults; override with env vars in production
server:
port: 8080
spring:
application:
name: acme-mcp-server
Dockerfile
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY target/acme-mcp-server-*.jar app.jar
# Run as non-root
RUN addgroup -S mcp && adduser -S mcp -G mcp
USER mcp
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "app.jar"]
docker build -t acme-mcp-server:1.0 .
docker run -d \
--name acme-mcp-server \
-p 8080:8080 \
-e DB_URL=jdbc:postgresql://db:5432/acme \
-e DB_USER=mcp_reader \
-e DB_PASSWORD=secret \
acme-mcp-server:1.0
Health Checks
Add a health endpoint so orchestrators (Kubernetes, load balancers) can verify the server is ready:
@RestController
public class HealthController {
private final DataSource dataSource;
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
try (var conn = dataSource.getConnection()) {
conn.createStatement().execute("SELECT 1");
return ResponseEntity.ok(Map.of("status", "UP", "db", "UP"));
} catch (Exception e) {
return ResponseEntity.status(503)
.body(Map.of("status", "DOWN", "db", "DOWN", "error", e.getMessage()));
}
}
}
Connecting Remote Clients to Your HTTP Server
When using HTTP transport, clients connect differently from stdio:
Claude Desktop:
Claude Desktop's claude_desktop_config.json only launches local stdio servers via command/args. A "url" entry is silently ignored. Connect a remote HTTP server one of two ways:
-
Settings > Connectors > Add custom connector, then paste your server URL (for example
https://mcp.acme.com/mcpfor Streamable HTTP, or the/sseendpoint for legacy SSE). This path uses OAuth for authentication. -
Bridge through the
mcp-remotestdio adapter in the config file, which also lets you send an API key header:
{
"mcpServers": {
"acme-server": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.acme.com/sse",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer your-api-key"
}
}
}
}
The header value lives in env because Claude Desktop on Windows (and Cursor) mangle args entries that contain spaces.
Cursor / other clients: Check the client documentation for HTTP server configuration, the format varies.
Monitoring
At minimum, track:
- Tool call latency: how long each tool takes; alert on P99 > 5s
- Error rate,
isError: truerate per tool; spikes indicate problems - Connection count: active MCP sessions
- External dependency health: database connections, API availability
With Spring Boot Actuator + Micrometer + Prometheus:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Then instrument your tool handlers:
private final MeterRegistry meterRegistry;
(exchange, request) -> {
var timer = Timer.start(meterRegistry);
try {
var result = doSearch(request.arguments());
timer.stop(meterRegistry.timer("mcp.tool", "name", "search_customers", "status", "success"));
return result;
} catch (Exception e) {
timer.stop(meterRegistry.timer("mcp.tool", "name", "search_customers", "status", "error"));
return CallToolResult.builder().isError(true).addTextContent(e.getMessage()).build();
}
}
Companion code
A complete runnable production server, using HttpServletSseServerTransportProvider directly (the raw SDK path described above), is in projects/mcp-java-sdk/production-server/. It exposes get_order and update_order_status tools, registers the transport as a Spring servlet, ships an OrderService plus Micrometer-instrumented OrderTools, and exposes /actuator/health and /actuator/prometheus. Build and run with mvn -B -pl production-server spring-boot:run.
Congratulations
You've completed the Building MCP Servers in Java course.
You can now:
- Build production-quality MCP servers with Tools, Resources, and Prompts
- Handle errors correctly and defensively
- Write unit and integration tests
- Secure your servers against common vulnerabilities
- Deploy to production with Docker and Spring Boot
The next frontier: Spring AI, which integrates MCP client functionality into AI-enabled Spring applications, letting you build the AI agent that uses your MCP server. Continue with the Spring AI + MCP Integration course.
For architectural patterns once you're building real systems, the MCP Architecture Patterns course covers server composition, multi-tenancy, caching, versioning, and resilience. The Securing Production MCP course drills deeper into the threat model and OAuth flows. Both are coming soon. And browse the blog for shorter takes on practical patterns.