Series overview
Part 8 of 1747% complete
2026-08-26•6 min read

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.yml
mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/
config/HttpClientConfig.kt
simulator/SimulatorClient.kt
tools/OperationsTools.kt
errors/ToolError.kt
mcp-operations-server/src/test/kotlin/...

Complete code

mcp-operations-server/src/main/resources/application.yml
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: 10s

Kotlin first: every file opens with the escaped package — `in` is a keyword.

mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/config/HttpClientConfig.kt
package `in`.o612.eng.opsagent.mcp.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.web.client.RestClient
import java.time.Duration
@Configuration
class 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:

mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/simulator/SimulatorClient.kt
package `in`.o612.eng.opsagent.mcp.simulator
import `in`.o612.eng.opsagent.contracts.incident.IncidentDetail
import `in`.o612.eng.opsagent.contracts.incident.IncidentSummary
import org.springframework.core.ParameterizedTypeReference
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
@Component
class 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:

mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/tools/OperationsTools.kt
package `in`.o612.eng.opsagent.mcp.tools
import `in`.o612.eng.opsagent.mcp.errors.ToolError
import `in`.o612.eng.opsagent.mcp.simulator.SimulatorClient
import org.springframework.ai.mcp.annotation.McpTool
import org.springframework.ai.mcp.annotation.McpToolParam
import org.springframework.stereotype.Component
@Component
class 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")
}
}
mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/errors/ToolError.kt
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

terminal
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): MockRestServiceServer behind the RestClient — happy path maps payloads; 404 → UPSTREAM_ERROR; malformed body → UPSTREAM_ERROR, not a Jackson leak; bad args → INVALID_ARGUMENT without 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): malformed fault → structured UPSTREAM_ERROR; outage → UPSTREAM_UNAVAILABLE. The fault modes from Chapter 2 pay rent starting now.
  • ArchUnit addition: mcp tools package must not reference ..persistence.. (it has none — tools only reach SimulatorClient).

Failure-injection lab

Drive the three “ugly” fault modes through the real HTTP path and watch the tool results:

Simulator modeget_service_status returns
latency 3000success after ~3s (read timeout is 10s — prove the budget)
flaky 1.0UPSTREAM_ERROR on every call
malformedUPSTREAM_ERROR, body never reaches the caller
outageUPSTREAM_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/list is empty: annotation scanning off or wrong package — check spring.ai.mcp.server.annotation-scanner and component scan roots.
  • 404 on /mcp: the endpoint exists only under protocol=STREAMABLE with the webmvc starter; SSE puts it at /sse+/mcp/message instead.
  • Kotlin package errors: in must be backticked — `in`.o612.eng.opsagent.mcp — everywhere including imports.

Checkpoint verification checklist

  • tools/list shows 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 tools

git 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, name ops-operations-server, endpoint http://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: SimulatorClient over RestClient (connect 2s / read 10s), X-Tenant-Id propagation
  • 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
KotlinSpring BootAIMicroservices

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind