Build the Kotlin MCP operations server: tools as a secured boundary
Checkpoint tag: chapter-07-mcp-server — an MCP client can list and call get_service_status, list_recent_incidents, and get_incident; the server has no write capability yet.
What will be built
mcp-operations-server goes from empty shell to a real MCP server: Kotlin, Spring AI’s spring-ai-starter-mcp-server-webmvc with protocol=STREAMABLE, three read-only tools backed by a typed RestClient into operations-simulator, structured error results for every downstream failure mode, and a Contract-test suite that pins the simulator’s wire format.
Why it matters
An MCP server is not “tools with extra steps” — it is a capability boundary in its own process. Local @Tool methods execute inside the agent’s JVM with the agent’s privileges; a remote tool executes behind a separate security context, its own rate limits, its own audit sink, and its own deploy lifecycle. That separation is what lets Chapter 9 put ops:incident:write on a service boundary rather than on a prompt. When you hear “just give the model a function to call,” this chapter is the argument for why the function lives across a network hop.
Concepts explained
MCP primitives. Tools are model-invocable functions (what we build). Resources are addressable read-only data (ops://services/catalog) — context the client pulls. Prompts are reusable prompt templates the server can vend. Misusing them — a tool that returns documents, a prompt that mutates — confuses clients; each primitive has a semantic job.
Transports. stdio is for same-machine subprocesses (no network auth story). The old HTTP+SSE transport is deprecated in MCP SDK 2.0. Streamable HTTP is a single endpoint handling POST (requests) and GET (server->client stream). STATELESS is a Spring AI protocol variant that drops session tracking entirely — appropriate here since our tools carry all context per-call. We use STREAMABLE with deliberately stateless tool semantics; switching to protocol=STATELESS later is a config change, not a redesign.
Annotation scanning. With the starter on the classpath, @McpTool methods are discovered automatically (spring.ai.mcp.server.annotation-scanner.enabled=true by default), JSON schemas are generated from parameter types, and @McpToolParam(required=…) marks optionality. Generated schema is a description for the model — validation still happens in the method body.
Security warning (upstream docs state this plainly): the HTTP MCP endpoint is unauthenticated JSON-RPC out of the box. We run it on localhost/internal networking now and add the OAuth boundary in Chapter 9 — exposed before that chapter, this service would let anyone list and call every tool. Do not deploy intermediate checkpoints outside a private network.
Files added or changed
mcp-operations-server/src/main/resources/application.ymlmcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/ config/HttpClientConfig.kt simulator/SimulatorClient.kt tools/OperationsTools.kt errors/ToolError.ktmcp-operations-server/src/test/kotlin/...Complete code
server: port: 8081
spring: application: name: mcp-operations-server main: web-application-type: servlet ai: mcp: server: name: ops-operations-server version: "1.0.0" protocol: STREAMABLE # STATELESS is the alternative; SSE is deprecated in MCP SDK 2.0 instructions: "Read-only operational status and incident tools. Writes require approval upstream." request-timeout: 15s
simulator: base-url: ${SIMULATOR_URL:http://localhost:8082} connect-timeout: 2s read-timeout: 10sKotlin first: every file opens with the escaped package — `in` is a keyword.
package `in`.o612.eng.opsagent.mcp.config
import org.springframework.beans.factory.annotation.Valueimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.http.client.SimpleClientHttpRequestFactoryimport org.springframework.web.client.RestClientimport java.time.Duration
@Configurationclass HttpClientConfig {
@Bean fun simulatorRestClient( @Value("\${simulator.base-url}") baseUrl: String, @Value("\${simulator.connect-timeout}") connectTimeout: Duration, @Value("\${simulator.read-timeout}") readTimeout: Duration, ): RestClient { val factory = SimpleClientHttpRequestFactory().apply { setConnectTimeout(connectTimeout) setReadTimeout(readTimeout) } return RestClient.builder() .baseUrl(baseUrl) .requestFactory(factory) .build() }}SimulatorClient — typed calls over the contract types from Chapter 2; the tenant travels as the simulator’s X-Tenant-Id contract header:
package `in`.o612.eng.opsagent.mcp.simulator
import `in`.o612.eng.opsagent.contracts.incident.IncidentDetailimport `in`.o612.eng.opsagent.contracts.incident.IncidentSummaryimport org.springframework.core.ParameterizedTypeReferenceimport org.springframework.stereotype.Componentimport org.springframework.web.client.RestClient
@Componentclass SimulatorClient(private val http: RestClient) {
fun serviceStatus(tenant: String, serviceId: String): ServiceStatusPayload = http.get() .uri("/sim/v1/services/{id}/health", serviceId) .header("X-Tenant-Id", tenant) .retrieve() .body(ServiceStatusPayload::class.java) ?: throw SimulatorException("empty body for service $serviceId")
fun listIncidents(tenant: String, serviceId: String?, severity: String?, limit: Int): List<IncidentSummary> = http.get() .uri { u -> val b = u.path("/sim/v1/incidents").queryParam("limit", limit) // Kotlin can't pass String? into queryParam's vararg — set conditionally if (serviceId != null) b.queryParam("serviceId", serviceId) if (severity != null) b.queryParam("severity", severity) b.build() } .header("X-Tenant-Id", tenant) .retrieve() .body(object : ParameterizedTypeReference<List<IncidentSummary>>() {}) ?: emptyList()
fun incident(tenant: String, incidentId: String): IncidentDetail = http.get() .uri("/sim/v1/incidents/{id}", incidentId) .header("X-Tenant-Id", tenant) .retrieve() .body(IncidentDetail::class.java) ?: throw SimulatorException("incident $incidentId not found")
class SimulatorException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) data class ServiceStatusPayload( val serviceId: String, val health: String, val lastDeployment: String?, val checkedAt: String, )}The tools class. Each method validates its arguments again — schema generation describes intent to the model; it is not a validator:
package `in`.o612.eng.opsagent.mcp.tools
import `in`.o612.eng.opsagent.mcp.errors.ToolErrorimport `in`.o612.eng.opsagent.mcp.simulator.SimulatorClientimport org.springframework.ai.mcp.annotation.McpToolimport org.springframework.ai.mcp.annotation.McpToolParamimport org.springframework.stereotype.Component
@Componentclass OperationsTools(private val simulator: SimulatorClient) {
@McpTool( name = "get_service_status", description = "Get the current health of a service and its most recent deployment", generateOutputSchema = true, ) fun getServiceStatus( @McpToolParam(description = "Service identifier, e.g. payment-gateway", required = true) serviceId: String, @McpToolParam(description = "Tenant that owns the service", required = true) tenant: String, ): Any = guard { require(serviceId.matches(Regex("[a-z0-9][a-z0-9-]{1,63}"))) { "invalid serviceId" } simulator.serviceStatus(tenant, serviceId) }
@McpTool( name = "list_recent_incidents", description = "List recent incidents, optionally filtered by service and severity", generateOutputSchema = true, ) fun listRecentIncidents( @McpToolParam(description = "Tenant scope", required = true) tenant: String, @McpToolParam(description = "Optional service filter") serviceId: String? = null, @McpToolParam(description = "SEV1..SEV4") severity: String? = null, @McpToolParam(description = "Max results, 1..100") limit: Int = 20, ): Any = guard { require(limit in 1..100) { "limit out of range" } require(severity == null || severity in setOf("SEV1", "SEV2", "SEV3", "SEV4")) { "invalid severity" } simulator.listIncidents(tenant, serviceId, severity, limit) }
@McpTool( name = "get_incident", description = "Fetch one incident with its notes", generateOutputSchema = true, ) fun getIncident( @McpToolParam(description = "Tenant scope", required = true) tenant: String, @McpToolParam(description = "Incident identifier", required = true) incidentId: String, ): Any = guard { require(incidentId.matches(Regex("inc-[0-9a-f]+"))) { "invalid incidentId" } simulator.incident(tenant, incidentId) }
private fun guard(block: () -> Any): Any = try { block() } catch (e: IllegalArgumentException) { ToolError.of("INVALID_ARGUMENT", e.message ?: "bad arguments") } catch (e: SimulatorClient.SimulatorException) { ToolError.of("UPSTREAM_ERROR", "downstream simulator call failed") } catch (e: org.springframework.web.client.RestClientException) { ToolError.of("UPSTREAM_UNAVAILABLE", "operations backend unreachable") }}package `in`.o612.eng.opsagent.mcp.errors
data class ToolError(val code: String, val message: String) { companion object { fun of(code: String, message: String) = mapOf( "error" to code, "message" to message, ) }}Two deliberate choices worth reading twice: errors return as structured {"error": …} payloads rather than thrown exceptions — a thrown exception surfaces to the model as an opaque protocol error; a structured result is information the agent can reason about (“upstream unavailable” → say so, don’t retry blindly). And the tool methods return Any because success returns domain types while failure returns the error map — Chapter 10’s write tools keep the same convention. Stack traces, SQL, and header values never cross into ToolError.
Commands to build and run
docker compose -f infra/compose/docker-compose.yml up -d postgres./gradlew :operations-simulator:bootRun &./gradlew :mcp-operations-server:bootRun &Point any MCP client at http://localhost:8081/mcp — tools/list should report the three tools with generated input schemas; tools/call get_service_status {"serviceId":"payment-gateway","tenant":"acme"} returns the seeded health payload.
Automated tests
OperationsToolsTest(unit):MockRestServiceServerbehind theRestClient— happy path maps payloads; 404 →UPSTREAM_ERROR; malformed body →UPSTREAM_ERROR, not a Jackson leak; bad args →INVALID_ARGUMENTwithout any HTTP call.WireContractTest: the same fixture JSON serves both the simulator-side serialization test and the client-side parse test — the contract is pinned from both directions.FaultContractIT(simulator + Postgres containers):malformedfault → structuredUPSTREAM_ERROR;outage→UPSTREAM_UNAVAILABLE. The fault modes from Chapter 2 pay rent starting now.ArchUnitaddition:mcptools package must not reference..persistence..(it has none — tools only reachSimulatorClient).
Failure-injection lab
Drive the three “ugly” fault modes through the real HTTP path and watch the tool results:
| Simulator mode | get_service_status returns |
|---|---|
latency 3000 | success after ~3s (read timeout is 10s — prove the budget) |
flaky 1.0 | UPSTREAM_ERROR on every call |
malformed | UPSTREAM_ERROR, body never reaches the caller |
outage | UPSTREAM_UNAVAILABLE |
The point: the model sees clean structured failures. Retry policy belongs to the caller (Chapter 8’s tool policy), not to the tool that already knows the downstream is sick.
Security considerations
Tool inputs are model-originated text: serviceId/incidentId regex checks, limit bounds, and severity allowlisting run after schema generation, in code. tenant is a required argument now and becomes a JWT-derived claim in Chapter 9 — the signature already exists so that change is mechanical. The server binds to the Compose network, not the host’s public interface.
Observability checks
mcp.tool.calls + mcp.tool.errors counters labeled tool + outcome; simulator.request.duration timer; every ToolError logs a WARN with code but never arguments (arguments may contain tenant data — logs get the code, audits get the detail in Chapter 12).
Troubleshooting
tools/listis empty: annotation scanning off or wrong package — checkspring.ai.mcp.server.annotation-scannerand component scan roots.- 404 on
/mcp: the endpoint exists only underprotocol=STREAMABLEwith the webmvc starter;SSEputs it at/sse+/mcp/messageinstead. - Kotlin
packageerrors:inmust be backticked —`in`.o612.eng.opsagent.mcp— everywhere including imports.
Checkpoint verification checklist
-
tools/listshows exactly three tools with generated schemas. - Every fault mode maps to a structured error result — zero stack traces over the wire.
- Argument validation rejects bad input before any downstream call.
- Contract tests pin both sides of the simulator wire format.
Commit message and Git tag
feat(mcp-server): streamable-http MCP server with read-only ops toolsgit tag chapter-07-mcp-server
What comes next
Chapter 8 gives the agent the keys: the MCP client config, the tool policy registry, and the bounded loop that decides what the model may actually invoke.
Project State Ledger — chapter-07-mcp-server
- MCP server:
spring-ai-starter-mcp-server-webmvc,protocol=STREAMABLE, nameops-operations-server, endpointhttp://localhost:8081/mcp, request-timeout 15s - Tools live:
get_service_status,list_recent_incidents,get_incident— read-only; error convention ={"error": code, "message": safe} - Client:
SimulatorClientoverRestClient(connect 2s / read 10s),X-Tenant-Idpropagation - Kotlin packages:
`in`.o612.eng.opsagent.mcp.{config,simulator,tools,errors} - Security gap (intentional, documented): no auth yet — Chapter 9; never deploy outside private networking
- Next:
chapter-08-mcp-client