← Back to list

Building a Scalable Enterprise MCP Server with Spring Boot AI

A production-grade Model Context Protocol server using Java 21 virtual threads, SSE streaming, dynamic tool registration, and a centralized…

Vijayakumar Easwaran · 2026-04-23 02:13 · 2 claps · 13.3 min read
#mcp-server #mcp-registry #scalability #sse-stream #mcp-client
Open on Medium ↗
Wiki topics: AGT · AI Agents 🎬 · Film & Television

Building a Scalable Enterprise MCP Server with Spring Boot AI

A production-grade Model Context Protocol server using Java 21 virtual threads, SSE streaming, dynamic tool registration, and a centralized MCP registry — built for enterprise teams.

Enterprise AI Engineering · Spring Boot AI Series · April 2026 · ⏱ 18 min read

Introduction

The Model Context Protocol (MCP) is quickly becoming the standard interface between AI models and enterprise tools. Think of it as a universal adapter — Claude (or any MCP-compatible AI) connects to your MCP server, which exposes tools and resources, and the AI can then act on your internal systems in a secure, governed way.

In this post, we’ll build a production-grade MCP server from scratch using Spring Boot 3.4 + Spring AI and Java 21, featuring:

  • SSE (Server-Sent Events) transport for seamless persistent connections
  • Dynamic tool and resource registration
  • Multi-tenant tool scoping with RBAC
  • A self-registering MCP Registry for discoverability
  • Virtual threads (Java 21) for massive I/O concurrency

“MCP turns your enterprise systems into an AI-native API surface — without rewriting anything. The server is the glue, and Spring Boot is the best glue in the Java ecosystem.”

Architecture Overview

┌─────────────┐     SSE      ┌────────────────────────────────────┐
│  Claude AI  │─────────────►│        Spring Boot MCP Server      │
│  MCP Client │◄─────────────│                                    │
└─────────────┘  streaming   │  ┌──────────────────────────────┐  │
                             │  │   SSE Transport Layer        │  │
                             │  ├──────────────────────────────┤  │
                             │  │   Tool Registry              │  │
                             │  ├──────────────────────────────┤  │
                             │  │   Resource Manager           │  │
                             │  ├──────────────────────────────┤  │
                             │  │   Auth / RBAC Filter         │  │
                             │  └──────────────────────────────┘  │
                             │  Java 21 Virtual Threads 🚀        │
                             └──────────────┬─────────────────────┘
                                            │ tool dispatch
                          ┌─────────────────┼─────────────────┐
                          │                 │                 │
                          ▼                 ▼                 ▼
                   ┌────────────┐   ┌────────────┐   ┌─────────────┐
                   │ DB Tool    │   │ REST Tool  │   │ Kafka Tool  │
                   │ JPA/JDBC   │   │ WebClient  │   │  Events     │
                   └────────────┘   └────────────┘   └─────────────┘
                          │                                   │
                          ▼                                   ▼
                   ┌────────────┐                   ┌─────────────┐
                   │ File Res.  │                   │ MCP Registry│
                   │ S3/Local   │                   │ Discovery   │
                   └────────────┘                   │ Heartbeat   │
                                                    └─────────────┘

Key design goals:

  • The MCP server is stateless — scale horizontally behind a load balancer
  • Each SSE connection runs on a Java 21 virtual thread (near-zero overhead)
  • All tool calls pass through an RBAC filter before execution
  • On startup, the server self-registers with a central MCP Registry

Why Java 21? Virtual Threads are a Game Changer

Traditional Spring Boot servers use platform threads. Each SSE connection holds a thread open for its lifetime. With 10,000 concurrent Claude sessions, you’d need 10,000 platform threads — each consuming ~1MB of stack. That’s 10GB just in threads.

Java 21 Virtual Threads (Project Loom) solves this. A virtual thread is lightweight — thousands can run on a handful of carrier threads. SSE connections become essentially free.

Java 17 vs Java 21 Comparison

Feature Java 17 (LTS) Java 21 (LTS) Impact for MCP Virtual Threads Preview only ✅ GA (stable) Critical — SSE concurrency Structured Concurrency ❌ ✅ Preview Cleaner async tool chaining Pattern Matching (switch) Partial ✅ Full GA Cleaner resource routing Record Patterns ❌ ✅ GA Concise tool result destructuring String Templates ❌ Preview Cleaner tool descriptions Enterprise Support ✅ Wide adoption ✅ Growing fast Both viable; 21 preferred

✅ Recommendation: Use Java 21. Virtual threads alone justify the upgrade for an MCP server — they allow tens of thousands of simultaneous SSE connections without the memory overhead of platform threads. Java 21 is an LTS release with enterprise support from all major vendors.

Project Setup — Maven Dependencies

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.0</version>
</parent>
<properties>
    <java.version>21</java.version>
</properties>
<dependencies>
    <!-- Spring AI MCP Server -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
    </dependency>
    <!-- WebFlux for SSE (reactive SSE transport) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <!-- Security for OAuth2 / JWT -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <!-- Actuator + Micrometer for observability -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- Redis for caching + dedup -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

SSE Transport — Seamless Persistent Connections

MCP uses SSE as its transport layer. The client (Claude) opens a long-lived HTTP connection; the server pushes JSON-RPC messages over it. Spring AI’s MCP starter handles the SSE lifecycle automatically — you just configure the endpoint.

application.yml

spring:
  threads:
    virtual:
      enabled: true   # Enables virtual threads globally — Java 21+

ai:
    mcp:
      server:
        enabled: true
        name: enterprise-mcp-server
        version: 1.0.0
        transport: SSE              # Server-Sent Events transport
        sse-endpoint: /mcp/sse
        message-endpoint: /mcp/message
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.company.internal/oauth2/issuer
mcp:
  registry:
    url: https://mcp-registry.company.internal
  server:
    public-url: https://enterprise-mcp.company.internal

SSE Connection Lifecycle

Client (Claude)           Auth Filter          SSE Handler         Tool Dispatch
      │                        │                     │                     │
      │── GET /mcp/sse ───────►│                     │                     │
      │                        │── Validate JWT ────►│                     │
      │                        │                     │─Register session -► │
      │                        │                     │                     │
      │◄══════════════════════════ SSE stream open ════════════════════════│
      │                        │                     │                     │
      │── tools/call ─────────────────────────────────────────────────────►│
      │◄══════════════════════════ stream result as SSE event ═════════════│
      │                        │                     │                     │
      │   (connection stays open · virtual thread per session)             │

Main Application Entry Point

// EnterpriseMcpServerApplication.java
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class EnterpriseMcpServerApplication {

public static void main(String[] args) {
        SpringApplication.run(EnterpriseMcpServerApplication.class, args);
    }
    /**
     * Enable Java 21 Virtual Threads for the async executor.
     * Each SSE session and tool execution gets a lightweight virtual thread.
     */
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
        executor.setVirtualThreads(true);  // Java 21 🚀
        return executor;
    }
}

Defining MCP Tools — @Tool Annotation

Spring AI MCP uses the @Tool annotation to expose methods as callable tools. Every tool gets a name, description, and JSON Schema generated automatically from its parameters. Claude uses the description field to decide when to call the tool — write it like documentation for an AI, not a human.

Example 1 — Database Query Tool

// DatabaseQueryTool.java
@Component
public class DatabaseQueryTool {
private final EmployeeRepository employeeRepo;
    private final AuditLogger auditLogger;
    @Tool(
        name = "get_employee_details",
        description = """
            Fetch an employee's details by their ID or email.
            Returns name, department, role, location and manager.
            Use this when the user asks about a specific employee.
            """
    )
    public EmployeeDetails getEmployeeDetails(
            @ToolParam(description = "Employee ID or corporate email address")
            String identifier) {
        auditLogger.log("get_employee_details", identifier);
        return employeeRepo
            .findByIdOrEmail(identifier)
            .map(EmployeeDetails::from)
            .orElseThrow(() -> new ToolExecutionException(
                "Employee not found: " + identifier));
    }
    @Tool(
        name = "list_team_members",
        description = "List all members of a team or department by name"
    )
    public List<EmployeeSummary> listTeamMembers(
            @ToolParam(description = "Team or department name")
            String teamName,
            @ToolParam(description = "Include inactive members (default: false)", required = false)
            Boolean includeInactive) {
        boolean showAll = Boolean.TRUE.equals(includeInactive);
        return employeeRepo.findByDepartment(teamName, showAll)
                           .stream()
                           .map(EmployeeSummary::from)
                           .toList();
    }
}

Example 2 — REST API Tool with WebClient

// JiraIntegrationTool.java
@Component
public class JiraIntegrationTool {
private final WebClient jiraClient;
    @Tool(
        name = "create_jira_ticket",
        description = "Create a Jira ticket in the specified project with a title, description and priority"
    )
    public JiraTicketResponse createTicket(
            @ToolParam(description = "Jira project key e.g. ENG, INFRA, DATA")  String projectKey,
            @ToolParam(description = "Short, descriptive ticket title")           String title,
            @ToolParam(description = "Detailed description of the issue or task") String description,
            @ToolParam(description = "Priority: LOW, MEDIUM, HIGH, CRITICAL")     String priority) {
        var payload = Map.of(
            "fields", Map.of(
                "project",     Map.of("key", projectKey),
                "summary",     title,
                "description", description,
                "priority",    Map.of("name", priority),
                "issuetype",   Map.of("name", "Task")
            )
        );
        return jiraClient.post()
            .uri("/rest/api/3/issue")
            .bodyValue(payload)
            .retrieve()
            .bodyToMono(JiraTicketResponse.class)
            .block();  // safe - virtual thread, not blocking a carrier thread
    }
}

Example 3 — DevOps Tool with Audit + Idempotency

// DeploymentTool.java
@Component
public class DeploymentTool {
private final KubernetesClient k8sClient;
    private final RedisTemplate<String, String> redis;
    @Tool(
        name = "deploy_service",
        description = """
            Deploy a service to a Kubernetes namespace.
            Specify the service name, image tag and target environment.
            Only available to users with role: devops-engineer or sre.
            """
    )
    public DeploymentResult deployService(
            @ToolParam(description = "Service name e.g. payment-service")       String serviceName,
            @ToolParam(description = "Docker image tag e.g. v1.4.2")            String imageTag,
            @ToolParam(description = "Target environment: staging or production") String environment) {
        // Idempotency key - prevent double-deploy from duplicate tool calls
        String idempotencyKey = "deploy:%s:%s:%s".formatted(serviceName, imageTag, environment);
        if (Boolean.TRUE.equals(redis.hasKey(idempotencyKey))) {
            return DeploymentResult.alreadyDeployed(serviceName, imageTag);
        }
        DeploymentResult result = k8sClient.deploy(serviceName, imageTag, environment);
        // Cache for 5 minutes to guard against duplicate calls
        redis.opsForValue().set(idempotencyKey, "deployed", Duration.ofMinutes(5));
        return result;
    }
}

Exposing MCP Resources

Beyond tools (actions), MCP also supports Resources — read-only data sources that Claude can access to enrich its context. Think runbooks, configuration files, documentation pages, or API specs.

// EnterpriseResourceProvider.java
@Component
public class EnterpriseResourceProvider implements McpResourceProvider {
private final ConfluenceClient confluenceClient;
    private final S3Client s3;
    @Override
    public List<McpResource> listResources() {
        return List.of(
            McpResource.of(
                "confluence://runbooks",
                "Engineering Runbooks",
                "All operational runbooks from Confluence",
                "text/markdown"
            ),
            McpResource.of(
                "s3://company-docs/api-specs",
                "API Specifications",
                "OpenAPI specs for all internal services",
                "application/json"
            ),
            McpResource.of(
                "confluence://architecture-decisions",
                "Architecture Decision Records",
                "ADRs documenting key system design decisions",
                "text/markdown"
            )
        );
    }
    @Override
    public McpResourceContent readResource(String uri) {
        return switch (uri) {
            case String u when u.startsWith("confluence://") -> fetchFromConfluence(u);
            case String u when u.startsWith("s3://")         -> fetchFromS3(u);
            default -> throw new ResourceNotFoundException(uri);
        };
    }
    private McpResourceContent fetchFromConfluence(String uri) {
        String pageTitle = uri.replace("confluence://", "");
        String markdown  = confluenceClient.getPageAsMarkdown(pageTitle);
        return McpResourceContent.text(uri, markdown);
    }
    private McpResourceContent fetchFromS3(String uri) {
        String[] parts  = uri.replace("s3://", "").split("/", 2);
        String   bucket = parts[0];
        String   key    = parts[1];
        String   content = s3.getObjectAsString(bucket, key);
        return McpResourceContent.text(uri, content);
    }
}

MCP Registry — Service Discovery for AI Tools

The MCP Registry is a centralized catalog that all MCP servers register with on startup. When Claude or another AI agent initializes, it queries the registry to discover what servers exist and what tools they expose — without hardcoding URLs.

Why a Registry? In a large enterprise, you may have dozens of MCP servers — one for HR, one for DevOps, one for Finance. Without a registry, every AI agent needs to know every server’s URL. The registry solves this with service discovery, exactly like Eureka/Consul does for microservices.

Architecture

MCP Server (HR)     ──register──►
MCP Server (DevOps) ──register──►  MCP Registry  ◄── Claude / AI Agent
MCP Server (Finance)──register──►  (Spring Boot)      "which servers exist?"
                                        │               "what tools do they have?"
                                   PostgreSQL            "which server handles finance?"
                                   (server catalog)

Registry Data Model

// McpServerRegistration.java
public record McpServerRegistration(
    String       serverId,       // "hr-mcp-server"
    String       name,
    String       version,
    String       sseEndpoint,    // "https://hr-mcp.internal/mcp/sse"
    List<String> teams,          // ["hr-team", "people-ops"]
    List<ToolMeta> tools,
    Instant      registeredAt,
    Instant      lastHeartbeat,
    ServerStatus status          // ACTIVE, DEGRADED, OFFLINE
) {}

public record ToolMeta(
    String       name,
    String       description,
    List<String> requiredRoles   // ["hr-admin", "manager"]
) {}

Self-Registration on Startup

// McpRegistryClient.java
@Component
@Slf4j
public class McpRegistryClient implements ApplicationRunner {
private final WebClient     registryClient;
    private final McpToolScanner toolScanner;
    @Value("${mcp.registry.url}")
    private String registryUrl;
    @Value("${mcp.server.public-url}")
    private String publicUrl;
    @Override
    public void run(ApplicationArguments args) {
        var registration = new McpServerRegistration(
            "enterprise-mcp-server",
            "Enterprise MCP Server",
            "1.0.0",
            publicUrl + "/mcp/sse",
            List.of("all-teams"),
            toolScanner.discoverTools(),   // scans @Tool beans at runtime
            Instant.now(),
            Instant.now(),
            ServerStatus.ACTIVE
        );
        registryClient.post()
            .uri("/registry/servers")
            .bodyValue(registration)
            .retrieve()
            .toBodilessEntity()
            .subscribe(
                r  -> log.info("✅ Registered with MCP Registry at {}", registryUrl),
                ex -> log.error("❌ Registry registration failed: {}", ex.getMessage())
            );
        startHeartbeat();
    }
    @Scheduled(fixedDelay = 30_000)  // heartbeat every 30 seconds
    public void startHeartbeat() {
        registryClient.put()
            .uri("/registry/servers/enterprise-mcp-server/heartbeat")
            .retrieve()
            .toBodilessEntity()
            .subscribe(
                r  -> log.debug("💓 Heartbeat sent"),
                ex -> log.warn("⚠️ Heartbeat failed: {}", ex.getMessage())
            );
    }
}

Registry Server — Tool Discovery API

// McpRegistryController.java
@RestController
@RequestMapping("/registry")
public class McpRegistryController { private final McpRegistryService registry;
    /** Register or update a server */
    @PostMapping("/servers")
    public ResponseEntity<Void> register(@RequestBody McpServerRegistration reg) {
        registry.register(reg);
        return ResponseEntity.ok().build();
    }
    /** Discover all servers accessible to a team */
    @GetMapping("/servers")
    public List<McpServerRegistration> discover(
            @RequestParam("team") String team,
            @RequestParam(value = "status", defaultValue = "ACTIVE") String status) {
        return registry.findByTeamAndStatus(team, ServerStatus.valueOf(status));
    }
    /** Search tools by keyword across all registered servers */
    @GetMapping("/tools")
    public List<ToolWithServer> searchTools(@RequestParam String query) {
        return registry.searchTools(query);
    }
    /** Health heartbeat from registered servers */
    @PutMapping("/servers/{id}/heartbeat")
    public void heartbeat(@PathVariable String id) {
        registry.updateHeartbeat(id);
    }
    /** Deregister on graceful shutdown */
    @DeleteMapping("/servers/{id}")
    public void deregister(@PathVariable String id) {
        registry.deregister(id);
    }
}

McpToolScanner — Auto-Discover @Tool Beans

// McpToolScanner.java
@Component
public class McpToolScanner {
private final ApplicationContext context;
    /**
     * Scans all Spring beans for @Tool annotated methods at runtime.
     * Returns metadata for registry registration.
     */
    public List<ToolMeta> discoverTools() {
        return context.getBeansWithAnnotation(Component.class)
            .values()
            .stream()
            .flatMap(bean -> Arrays.stream(bean.getClass().getMethods()))
            .filter(method -> method.isAnnotationPresent(Tool.class))
            .map(method -> {
                Tool annotation = method.getAnnotation(Tool.class);
                return new ToolMeta(
                    annotation.name(),
                    annotation.description(),
                    resolveRequiredRoles(method)
                );
            })
            .toList();
    }
    private List<String> resolveRequiredRoles(Method method) {
        RequiresRole rolesAnnotation = method.getAnnotation(RequiresRole.class);
        return rolesAnnotation != null
            ? List.of(rolesAnnotation.value())
            : List.of();
    }
}

RBAC — Role-Scoped Tool Access

Not all tools should be accessible to all users. A junior developer shouldn’t be able to trigger production deployments. RBAC is enforced as a filter in the MCP tool dispatch pipeline — before the tool method is ever invoked.

// McpToolAuthorizationFilter.java
@Component
@Order(1)
public class McpToolAuthorizationFilter implements McpToolFilter {
private final ToolPermissionRepository permissions;
    private final AuditLogger auditLogger;
    @Override
    public void beforeToolExecution(
            McpToolContext context,
            String toolName,
            Map<String, Object> args) {
        UserPrincipal user    = context.getPrincipal();
        Set<String> required  = permissions.getRequiredRoles(toolName);
        boolean authorized = required.isEmpty()
            || user.getRoles().stream().anyMatch(required::contains);
        if (!authorized) {
            // Log the denied attempt
            auditLogger.recordDenied(user, toolName, args);
            throw new McpAccessDeniedException(
                "User '%s' with roles %s cannot execute tool '%s' (requires: %s)"
                    .formatted(user.getUsername(), user.getRoles(), toolName, required)
            );
        }
        // Log every authorized tool call
        auditLogger.record(AuditEvent.toolCall(user, toolName, args));
    }
}

Custom @RequiresRole Annotation

// Annotate tools directly with role requirements
@Tool(
    name = "deploy_service",
    description = "Deploy a service to Kubernetes"
)
@RequiresRole({"devops-engineer", "sre"})
public DeploymentResult deployService(String serviceName, String imageTag, String env) {
    // ...
}

Tool Permission Configuration (YAML)

# tool-permissions.yml — loaded at startup
tool-permissions:
  get_employee_details:   []                         # all authenticated users
  list_team_members:      []
  deploy_service:         [devops-engineer, sre]     # restricted
  create_jira_ticket:     [developer, manager]
  approve_expense:        [manager, finance-admin]
  run_production_query:   [dba, data-engineer]
  delete_resource:        [admin]

Observability — Traces, Metrics, Audit Log

// McpMetricsConfig.java
@Configuration
public class McpMetricsConfig {
@Bean
    public ObservationHandler<McpToolObservation> mcpToolObservationHandler(
            MeterRegistry registry) {
        return observation -> {
            String tool = observation.getToolName();
            String team = observation.getTeam();
            // Count total invocations per tool + team
            Counter.builder("mcp.tool.calls")
                .tag("tool", tool)
                .tag("team", team)
                .register(registry)
                .increment();
            // Track execution latency
            Timer.builder("mcp.tool.latency")
                .tag("tool", tool)
                .register(registry)
                .record(observation.getDuration(), TimeUnit.MILLISECONDS);
            // Track errors separately for alerting
            if (observation.hasError()) {
                Counter.builder("mcp.tool.errors")
                    .tag("tool", tool)
                    .tag("error", observation.getErrorType())
                    .register(registry)
                    .increment();
            }
        };
    }
}

Audit Log Schema

// AuditEvent.java
public record AuditEvent(
    String    eventId,        // UUID
    String    userId,
    String    userEmail,
    Set<String> userRoles,
    String    toolName,
    Map<String, Object> args, // sanitized — no secrets
    Instant   timestamp,
    Duration  executionTime,
    String    outcome,        // SUCCESS, DENIED, ERROR
    String    errorMessage
) {
    public static AuditEvent toolCall(UserPrincipal user, String tool, Map<String, Object> args) {
        return new AuditEvent(
            UUID.randomUUID().toString(),
            user.getId(), user.getEmail(), user.getRoles(),
            tool, sanitize(args),
            Instant.now(), null, "IN_PROGRESS", null
        );
    }
}

Docker + Kubernetes Deployment

Dockerfile

# Dockerfile
FROM eclipse-temurin:21-jre-alpine AS runtime

WORKDIR /app
COPY target/enterprise-mcp-server.jar app.jar
# Enable virtual threads JVM flags
ENV JAVA_OPTS="-XX:+UseZGC -Xmx512m --enable-preview"
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Kubernetes Deployment

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: enterprise-mcp-server
  namespace: ai-platform
spec:
  replicas: 3
  selector:
    matchLabels:
      app: enterprise-mcp-server
  template:
    spec:
      containers:
        - name: mcp-server
          image: company/enterprise-mcp-server:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: production
            - name: MCP_REGISTRY_URL
              value: https://mcp-registry.company.internal
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10

---
# Horizontal Pod Autoscaler - scale on SSE connection count
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
  namespace: ai-platform
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: enterprise-mcp-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Pods
      pods:
        metric:
          name: mcp_sse_active_connections
        target:
          type: AverageValue
          averageValue: "500"   # scale up when avg > 500 SSE conns per pod

Phased Rollout Plan

Phase 1 — Foundation (Week 1–2)

Goal: Get Claude talking to your first tool over SSE.

  • Spring Boot MCP server with SSE transport configured
  • 5–10 core tools (DB query, Jira, Confluence search)
  • JWT authentication filter
  • Basic audit logging to a database table
  • Local Docker Compose setup for development

Success metric: Claude successfully calls get_employee_details and returns real data.

Phase 2 — Governance (Week 3–4)

Goal: Make it safe for enterprise use.

  • RBAC filter with @RequiresRole annotation
  • Per-team tool scoping
  • Rate limiting per user and per tool (Bucket4j)
  • Dead-letter handling for failed tool calls
  • Micrometer metrics dashboard (Grafana)

Success metric: Unauthorized tool call is blocked, logged, and alerted.

Phase 3 — Registry (Week 5–6)

Goal: Multi-server discoverability.

  • Deploy MCP Registry service (separate Spring Boot app)
  • All MCP servers self-register on startup
  • 30-second heartbeat + automatic OFFLINE marking
  • Claude discovers available servers via registry at session start

Success metric: Second MCP server (e.g., DevOps tools) appears in registry without touching the HR server.

Phase 4 — Scale (Week 7–8)

Goal: Production-grade reliability.

  • Kubernetes deployment with HPA
  • Redis cache for read-only tool responses (TTL per tool type)
  • OpenTelemetry distributed tracing (trace AI → MCP → tool → DB)
  • Chaos testing — verify graceful degradation when tools fail

Success metric: 1,000 concurrent SSE connections with < 50ms p99 tool dispatch latency.

Phase 5 — Self-Serve (Month 2+)

Goal: Every team can publish their own tools.

  • Internal @McpTool SDK — teams drop a JAR dependency and annotate methods
  • Tool marketplace UI — browse all tools across all servers
  • Cost attribution per team per tool call
  • Auto-generated documentation portal from @Tool descriptions

Success metric: HR team ships a new tool without opening a ticket to the platform team.

Complete Tech Stack

Layer Technology Why Runtime Java 21 + Virtual Threads SSE concurrency at massive scale Framework Spring Boot 3.4 + Spring AI MCP server starter, mature ecosystem Transport SSE via WebFlux Persistent, real-time streaming to Claude Auth Spring Security + OAuth2/JWT Enterprise SSO integration (Okta, LDAP) Registry Spring Boot service + PostgreSQL Tool discovery, heartbeat, catalog Cache Redis (Spring Cache) Tool response caching, idempotency keys Observability Micrometer + OpenTelemetry Traces, metrics, audit log Container Docker + Kubernetes (EKS/GKE) Horizontal scaling, health checks Secrets HashiCorp Vault / AWS Secrets Manager Tool credentials at runtime Build Maven 3.9 + GraalVM (optional) Fast startup with native image

Key Principles Summary

┌─────────────────────────────────────────────────────────┐
│              Enterprise MCP Server Principles            │
├─────────────────────────────────────────────────────────┤
│  1. STATELESS      Scale horizontally. No session state  │
│                    on the server — all state in Redis.   │
│                                                          │
│  2. IDEMPOTENT     Every tool call is safe to retry.     │
│                    Use idempotency keys in Redis.        │
│                                                          │
│  3. AUDITED        Every tool call logged with user,     │
│                    args (sanitized), outcome, duration.  │
│                                                          │
│  4. GOVERNED       RBAC before every tool execution.     │
│                    Deny by default, allow by role.       │
│                                                          │
│  5. OBSERVABLE     Metrics + traces on every tool call.  │
│                    Alert on errors, latency, rate spikes.│
│                                                          │
│  6. DISCOVERABLE   Self-register with MCP Registry.      │
│                    Clients find servers dynamically.     │
└─────────────────────────────────────────────────────────┘

Conclusion

Building an enterprise MCP server isn’t just about writing @Tool methods. The SSE transport ensures Claude maintains a seamless persistent connection. Java 21 virtual threads make that scalable to thousands of concurrent sessions without tuning thread pools. The MCP Registry turns a single server into a discoverable, self-managing platform.

Most importantly, treating the MCP server as a platform product — with a registry, RBAC governance, observability, and a developer SDK — transforms it from a prototype into infrastructure your entire enterprise can build AI capabilities on top of.

Start with one SSE endpoint and three tools. Add the registry in week two. By month two, every team in your org can publish their own tools without touching your core server.

References

Enterprise AI Engineering · Scalable MCP Server with Spring Boot AI · April 2026 All code targets Spring Boot 3.4+, Spring AI 1.0+, and Java 21. The MCP specification is evolving — check official docs for the latest API changes.


메타데이터
post_id
de7dd68bd2cd
slug
building-a-scalable-enterprise-mcp-server-with-spring-boot-ai-de7dd68bd2cd
url
https://medium.com/@easwaranvijayakumar/building-a-scalable-enterprise-mcp-server-with-spring-boot-ai-de7dd68bd2cd
canonical_url
https://medium.com/@easwaranvijayakumar/building-a-scalable-enterprise-mcp-server-with-spring-boot-ai-de7dd68bd2cd
author_url
https://medium.com/@easwaranvijayakumar
status
ok
fetched_at
2026-06-23 17:05:31