Skip to main content

Class 17: Observability and Deployment

Duration: 40 minutes | Difficulty: Intermediate | Prerequisites: Class 16.


Companion code

This lesson carries on from Class 16. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_16 branch to start from the same place:

git clone --branch mcp_class_16 https://github.com/graphqlguy/graphQLMovieDB-MCP.git

By the end of this lesson you can observe, deploy and operate your MCP server:

  • Metrics show how many tool calls happen and how long they take.
  • Traces follow a slow tool call from the HTTP request through the GraphQL execution to each resolver.
  • Structured logs let you connect log lines to those traces.
  • Tools that call LLMs report what they cost.

The deployment topology section explains when a production setup would move the MCP server into its own service. This lesson does not teach AWS or GCP. It describes what the production-ready Spring Boot artifact should look like before you hand it to the team that runs your platform.

This lesson uses several observability terms that may be new. Each one gets a fuller explanation where the lesson puts it to work:

  • Spring Boot Actuator adds production endpoints, such as health and metrics, to a Spring Boot application.
  • Micrometer is the metrics library Spring Boot uses, and its meter registry sends counters and timers to a monitoring system.
  • Prometheus is a monitoring system that collects metrics by reading an HTTP endpoint on each application.
  • Percentile histogram is a timer's set of latency buckets, from which a monitoring system computes percentiles such as p99.
  • OpenTelemetry is a vendor-neutral standard for telemetry, where a trace records one request as a tree of spans, one span per unit of work.
  • OTLP is the protocol OpenTelemetry uses to send telemetry to a collector.
  • Observation API is Micrometer's API for instrumenting code once and getting both metrics and spans from it.
  • Sampling keeps only some traces: head sampling decides when a request starts, and tail sampling decides after it ends.
  • MDC (Mapped Diagnostic Context) is a per-thread map of values, such as a trace id, that the logging library adds to every log line.
  • Structured logging writes each log line as a JSON object, so log tools can filter on its fields.
  • Spring profile is a named set of configuration, such as production, that Spring Boot applies when the profile is active.
  • GraalVM native image compiles a Java application ahead of time into a native binary that starts much faster than a JVM.

What This Lesson Covers​

This lesson is not a cloud deployment guide. What production looks like depends on what your platform team runs: ECS, EKS, GKE, plain VMs, App Engine, Cloud Run, on-premises Kubernetes or something else. We cover what should be true of the application artifact wherever it runs. If your artifact has the metrics, traces, logs and configuration below, your platform team has what it needs to deploy it.

Observability: Metrics​

Add Spring Boot Actuator and the Micrometer registry that matches your metrics stack. For the common setup, where a metrics server scrapes the application, that is the Prometheus registry:

pom.xml (the metrics dependencies)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Adding the registry to the classpath is only the first step. By default, Boot 4 exposes only health over the web, so /actuator/prometheus returns 404 until you expose it in the base application.yaml. This is the first of three additions to a single management: key, which is a new top-level key in this file:

src/main/resources/application.yaml (new top-level key)
management:
endpoints:
web:
exposure:
include: health, info, prometheus

Check two things before you paste it:

  • application.yaml ends without a trailing newline. Add a newline first, or the block joins the last line and YAML reports mapping values are not allowed here.
  • This is the only management: key in the file. The two later additions in this lesson go inside it, next to endpoints. A second top-level management: key fails at startup with found duplicate key management, and every Spring test in the suite fails with it.

With that in place, the actuator serves Prometheus-compatible metrics on /actuator/prometheus for scraping. If your stack receives metrics pushed over instead, use micrometer-registry-otlp. The meters and the aspect below stay the same in both cases. The tracing dependency is separate, and the tracing section adds it.

Per-tool metrics help with capacity planning. An aspect on @McpTool, similar to the audit aspect from Class 16, records a counter and a timer for each tool:

src/main/java/com/graphqlguy/moviedb/mcp/ToolMetricsAspect.java
package com.graphqlguy.moviedb.mcp;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

/**
* Class 17: per-tool counters and timers for capacity planning. Emits
* {@code mcp.tool.calls} (graph as call rate) and {@code mcp.tool.duration}
* (graph as latency percentiles) tagged by tool name and outcome, into whichever
* Micrometer registry the stack scrapes or receives. Sits alongside the Class 16
* audit aspect on the same {@code @McpTool} pointcut; both advices run per call.
*/
@Aspect
@Component
public class ToolMetricsAspect {

private final MeterRegistry meters;

public ToolMetricsAspect(MeterRegistry meters) {
this.meters = meters;
}

@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
String toolName = joinPoint.getSignature().getName();
Timer.Sample sample = Timer.start(meters);
String outcome = "success";

try {
return joinPoint.proceed();
} catch (Throwable t) {
outcome = "error";
throw t;
} finally {
sample.stop(meters.timer("mcp.tool.duration",
"tool", toolName,
"outcome", outcome));
meters.counter("mcp.tool.calls",
"tool", toolName,
"outcome", outcome).increment();
}
}
}

The aspect produces two metrics: mcp_tool_calls_total{tool="X", outcome="success"} (a counter you graph as call rate per tool) and mcp_tool_duration_seconds_bucket{tool="X", outcome="success"} (a histogram you graph as latency percentiles). With these metrics, capacity planning starts from a specific question, such as which tool's call rate doubled.

The tool-level timer works well with a check one layer down. The Movie Database app's FieldTimingInstrumentation still runs in the same process. It logs a warning with the exact field path whenever a resolver takes longer than the slow-resolver threshold, which the app sets at 100 ms. The timer tells you that summarizeMovieReviews got slow, and the slow-resolver log tells you that the reviews fetch underneath caused it. One signal goes to metrics and the other to logs, and together they point to the slow field.

One configuration detail: the _bucket series exist only when percentile histograms are enabled for the timer. Either build the timer with .publishPercentileHistogram(), or turn histograms on in configuration:

src/main/resources/application.yaml (the management: block after this step)
management:
endpoints:
web:
exposure:
include: health, info, prometheus
metrics:
distribution:
percentiles-histogram:
mcp.tool.duration: true

It is also useful to count tools/list and tools/call separately, and the HTTP-layer metrics cannot do that. Every MCP request is a POST to the same path, and the JSON-RPC method name is in the request body, which the standard http.server.requests instrumentation does not read. The aspect above already gives you per-tool numbers. To add a per-method tag to the HTTP metrics, declare a custom ServerRequestObservationConvention bean, which replaces the default convention that Spring MVC uses.

The difficulty is timing. The convention reads its tags from the servlet request after the body has been consumed, so it cannot read the JSON-RPC envelope itself. You would need a filter that wraps each request in a ContentCachingRequestWrapper, or a filter that reads the method name early and stores it as a request attribute for the convention. Unless you need that tag on the HTTP metrics, use the aspect above.

Observability: Tracing​

Spring Boot's OpenTelemetry integration creates a span for every HTTP request automatically. The work is in connecting those spans to the GraphQL execution below them, so that one trace shows the path from the MCP request down to each resolver.

Tracing needs one dependency of its own, separate from the metrics registry. Spring Boot 4 keeps its tracing auto-configuration in a separate module, spring-boot-micrometer-tracing-opentelemetry, and spring-boot-starter-opentelemetry brings that module in. The starter also brings the two libraries the auto-configuration connects:

  • micrometer-tracing-bridge-otel, the bridge that lets Micrometer's Observation API create OpenTelemetry spans
  • opentelemetry-exporter-otlp, the exporter that sends the spans to a collector over OTLP

The starter adds a third library, micrometer-registry-otlp, which pushes every metric to http://localhost:4318/v1/metrics once a minute. This lesson publishes metrics through the Prometheus endpoint, so the listing excludes that registry. If your platform collects metrics over OTLP, keep the registry and remove the exclusion:

pom.xml (the tracing dependency)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
<exclusions>
<exclusion>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-otlp</artifactId>
</exclusion>
</exclusions>
</dependency>

The OTLP endpoint depends on the environment, so it belongs in the production profile. The base application.yaml sets only the sampling probability:

src/main/resources/application.yaml (the management: block after this step)
management:
endpoints:
web:
exposure:
include: health, info, prometheus
metrics:
distribution:
percentiles-histogram:
mcp.tool.duration: true
tracing:
sampling:
probability: 1.0

By this point the file's single management: key holds all three: endpoints, metrics, and tracing.

The collector address goes in the production profile, so the default profile avoids a hardcoded endpoint:

src/main/resources/application-production.yaml (inside its single management: block)
management:
opentelemetry:
tracing:
export:
otlp:
endpoint: http://otel-collector:4318/v1/traces
headers: {}

The production profile has its own management: key, separate from the base file's, and the same one-key rule applies. The actuator block at the end of this lesson goes inside that key, next to otlp.

Spring for GraphQL creates a span for every ExecutionGraphQlService.execute(...) call through the Observation API, and a span for each resolver it calls. Spring Security adds spans for its filter chain. We called recommendMoviesForMood through /mcp with Jaeger as the collector, and Jaeger showed this trace for the tools/call request:

http post /mcp
├── security filterchain before
│ ├── authenticate bearertoken
│ └── authorize request
├── secured request
│ ├── authorize method
│ ├── graphql query
│ └── graphql field recommendMoviesForMood
├── security filterchain after
├── security filterchain before
│ └── authorize request
├── secured request
└── security filterchain after

Each span comes from one of three components:

  • http post /mcp is the root span, from Spring Boot's web instrumentation.
  • The security filterchain, authenticate bearertoken, authorize request, secured request and authorize method spans come from Spring Security. The filter chain runs a second time at the end of the request, and that pass took under a millisecond.
  • graphql query is the GraphQL execution, and graphql field recommendMoviesForMood is the resolver for the query's top-level field.

The resolver span sits next to the execution span, and both have Spring Security's secured request span as their parent. The resolver's time falls inside the execution's time, so the timeline in Jaeger still shows the resolver running during the query. Spring for GraphQL skips the simple data fetchers that only read a property, so the scalar fields of each movie, such as title and rating, do not get a span.

The trace does not contain a span for the tool call itself, because Spring AI 2.0.0 and MCP Java SDK 2.0.0 leave the Observation API out of the MCP server. If you want that span, create it yourself with Observation.createNotStarted("mcp.tool.call", registry) around the tool method body. In most cases, the aspect-based metrics above and the framework's spans are enough.

You can also put the database in the trace. The starter's instrumentation covers HTTP, Spring Security and GraphQL. Add net.ttddyy.observation:datasource-micrometer-spring-boot to get each SQL query as its own span under its resolver. The companion branch leaves that dependency out, so its traces stop at the resolvers. A trace viewer such as Jaeger, Tempo or Honeycomb shows all the spans on one timeline.

In production, sample less than 100% of traces. The probability of 1.0 above is for development. A typical setup keeps 10% of traces when requests start, which is head sampling. It also uses tail sampling in the collector, such as the OTel Collector's tail sampling processor, to keep every trace that contains an error. Head sampling cannot do that, because it decides before the request has run.

Observability: Logging​

Structured JSON logs let you connect log lines to traces. Since Spring Boot 3.4, basic structured logging works without an extra dependency. One property switches console output to Logstash-style JSON:

src/main/resources/application.yaml (new top-level key)
logging:
structured:
format:
console: logstash

The built-in logstash format adds every MDC (Mapped Diagnostic Context) entry to the JSON object. Spring's tracing integration puts the trace and span ids into MDC automatically, and the JSON object includes them whenever they are set. The audit line for a tool call carries both, so filtering logs by trace id shows what happened during that call. Not every line has them. The slow-resolver warning from FieldTimingInstrumentation, for example, is written without them. Boot also ships ecs and gelf formats, if your log pipeline expects one of those.

When the built-in format is too limited, for example when you need custom fields or a separate appender for the audit logger, use logstash-logback-encoder. It replaces the property above instead of adding to it. An explicit logback-spring.xml controls all console output, so the logging.structured.format.console setting stops having any effect. That is why the companion branch contains only the XML. Keep the property while the built-in format is enough, and remove it when you switch to the encoder:

pom.xml (the Logstash encoder)
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>9.0</version>
</dependency>
src/main/resources/logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
Class 17: structured JSON logging. Every line is a Logstash-encoded JSON object
with all MDC entries included, so the trace and span ids Spring's tracing puts
in MDC appear on lines such as the audit.mcp entry, and filtering by trace id
reconstructs one tool call. The audit.mcp logger from Class 16 gets its own logger stanza
(additivity="false") so its stream can be pointed at a separate destination in
production without duplicating into the application stream.
-->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
<logger name="audit.mcp" level="INFO" additivity="false">
<appender-ref ref="JSON"/>
</logger>
</configuration>

In both setups, the audit logger from Class 16 uses the same JSON structure, so audit lines have the same shape as application lines and match the same queries. The explicit Logback configuration above lets you send audit.mcp to a separate appender when the audit stream needs its own destination.

With the encoder in place and the logging property removed, pom.xml and application.yaml now match the companion branch:

The complete pom.xml, collapsed because it runs to 240 lines
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.graphqlguy</groupId>
<artifactId>moviedb</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name/>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-validation</artifactId>
<version>24.0</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-scalars</artifactId>
<version>24.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Class 15: a second, OAuth 2.1 resource-server chain in front of /mcp.
The jjwt stack below stays in place for the self-issued /graphql chain. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<!-- Class 16: @Aspect audit logging on @McpTool methods. Boot 4 renamed
the historical spring-boot-starter-aop to spring-boot-starter-aspectj. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<!-- Class 16: token-bucket rate limiting per authenticated principal -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.14.0</version>
</dependency>
<!-- Class 17: production observability. Actuator + a Micrometer registry
expose per-tool metrics on /actuator/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>
<!-- Class 17: tracing. Spring Boot's OpenTelemetry starter brings the tracing
auto-configuration, the bridge that turns Micrometer observations into
OpenTelemetry spans, and the OTLP exporter that sends them to a collector.
The exclusion removes the starter's OTLP metrics registry, which would push
metrics to localhost:4318 every minute. Metrics stay on Prometheus. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
<exclusions>
<exclusion>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-otlp</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Class 17: structured JSON logging with a separate audit.mcp appender
(logback-spring.xml). Boot's one-line logging.structured.format is the
lighter alternative; the encoder is the customization path. -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>9.0</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

</project>
The complete application.yaml, collapsed because it runs to 102 lines
src/main/resources/application.yaml
server:
port: 8080

spring:
application:
name: moviedb
datasource:
url: jdbc:h2:mem:moviedb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
properties:
hibernate:
format_sql: false
graphql:
graphiql:
enabled: true
websocket:
path: /graphql
cors:
allowed-origins: http://localhost:5173
allow-credentials: true
threads:
virtual:
enabled: true
ai:
mcp:
server:
name: moviedb-mcp
version: 1.0.0
type: SYNC
protocol: STREAMABLE
capabilities:
tool: true
resource: false
prompt: false
completion: false
# Class 8: tools are registered through the schemaDescribedTools bean
# (SyncToolSpecification list); the scanner would register duplicates.
annotation-scanner:
enabled: false
# Class 15: the /mcp chain validates JWTs issued by an external OAuth 2.1
# authorization server. These defaults point at the local Keycloak from the
# Class 15 lesson (port 8180, realm moviedb); the Class 17 production profile
# sets them from environment variables. jwk-set-uri keeps the decoder lazy
# (no fetch at startup), issuer-uri and audiences add iss/aud validation.
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/moviedb
audiences:
- moviedb-mcp
jwk-set-uri: http://localhost:8180/realms/moviedb/protocol/openid-connect/certs
jwt:
secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000
demo:
# Artificial resolver latency for demoing batching / parallel field fetching. 0 = no delay.
latency: 0s
tmdb:
# API Read Access Token (v4); empty fallback keeps the app fully usable without a key
api-key: ${TMDB_API_KEY:}
base-url: https://api.themoviedb.org/3
image-base-url: https://image.tmdb.org/t/p/w500
moviedb:
mcp:
# Class 15: the canonical resource URI (RFC 8707), here the local MCP
# endpoint; the production profile sets it from MCP_RESOURCE. Clients send
# resource=<this> when requesting a token. The audiences entry above must
# match the aud value your authorization server issues. Keycloak ignores
# resource, so the lesson adds an audience mapper for moviedb-mcp.
resource: http://localhost:8080/mcp
graphql:
# Instrumentation configuration
# 15, not 10: the standard introspection query is depth 13, so anything lower
# breaks GraphiQL docs/autocomplete and codegen tooling.
max-query-depth: 15
slow-resolver-threshold-ms: 100
# Class 17: production observability. The Prometheus registry exposes per-tool
# meters (ToolMetricsAspect) on /actuator/prometheus; percentile histograms turn
# mcp.tool.duration into latency buckets. Tracing samples every request in dev
# (sampling 1.0); the OTLP endpoint is set per-environment in
# application-production.yaml so the default profile ships no collector address.
management:
endpoints:
web:
exposure:
include: health, info, prometheus
metrics:
distribution:
percentiles-histogram:
mcp.tool.duration: true
tracing:
sampling:
probability: 1.0

Cost Instrumentation for LLM-Backed Tools​

If summarizeMovieReviews calls an LLM, on the server or through sampling, each call costs money. Without instrumentation, you see that cost only when the provider's monthly invoice arrives, long after the expensive tool shipped. Count the tokens of every LLM call:

src/main/java/com/graphqlguy/moviedb/mcp/CostMetrics.java
package com.graphqlguy.moviedb.mcp;

import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;

/**
* Class 17: token metering for LLM-backed tools. An LLM call from a tool records
* its prompt and completion token counts here; the meters roll up to per-tool,
* per-model token totals that translate directly to cost at the provider's rate.
* For sampling (client-side model), only the prompt length is predictable on the
* server; completion tokens are billed to the user and counted client-side.
*/
@Component
public class CostMetrics {

private final MeterRegistry meters;

public CostMetrics(MeterRegistry meters) {
this.meters = meters;
}

public void recordLlmCall(String tool, String model, long promptTokens, long completionTokens) {
meters.counter("mcp.llm.tokens",
"tool", tool,
"model", model,
"kind", "prompt").increment(promptTokens);
meters.counter("mcp.llm.tokens",
"tool", tool,
"model", model,
"kind", "completion").increment(completionTokens);
meters.counter("mcp.llm.calls",
"tool", tool,
"model", model).increment();
}
}

After every LLM call, the tool calls recordLlmCall(...) with the token counts from the response. The metrics add up to token totals per tool and per model, which you multiply by your provider's per-token rate to get the cost. A dashboard panel with tokens per hour by tool shows which tools are expensive, and whether they are worth what they cost.

With sampling, the client's model runs the prompt, so the client counts the tokens, and those counts may stay on the client. In that case, measure the prompt length on the server, because input tokens are predictable. The user pays for the completion tokens.

Deployment Topology​

The decision is whether the MCP server lives in the same Spring Boot deployable as the GraphQL service or in a separate one. This course keeps them together: one JAR, one process, one set of credentials, one deployment artifact. We chose that because an introductory course is easier to follow with one project to clone and run. For production, a separate MCP service is often the better shape, for the reasons below.

Splitting is worth considering when any of these apply:

  • Scaling diverges. If MCP traffic is bursty and the GraphQL service is steady, sizing them together means over-provisioning one or under-provisioning the other. The same happens when the MCP server holds large per-session state, such as rate limit buckets or conversation memory, and the GraphQL service is stateless.
  • Different agents need different tool sets. Class 16 gives each agent audience its own MCP server, and the starter serves one endpoint per application. In one deployable, each audience runs a full copy of the GraphQL service, database connection pool included. Separate MCP services can share one GraphQL service.
  • Release schedules differ. Spring AI and the MCP Java SDK are young projects that release often. In one deployable, a Spring AI upgrade that requires a newer Spring Boot forces the same upgrade on the GraphQL service.
  • Tool calls should pass through the API's request handling. A separate MCP service calls /graphql like any other client, so every WebGraphQlInterceptor runs for its requests. In one deployable, tool calls enter the engine below that chain, which is why Class 7 seeds the security context by hand.
  • Different teams own them. The team that owns the agent surface deploys and monitors its MCP server itself.

The split looks like a separate Spring Boot module with the MCP-related code, a thin HTTP client into the GraphQL service, and shared library JARs for the data models. The trust boundary becomes the network hop between the two, so the MCP service has to pass the caller's identity on to the GraphQL service. Class 5 discusses the same trust boundary for Apollo. The cost is one more deployable, one more set of metrics dashboards, one more thing to monitor.

This course stops at one deployable, because a second service would add a second project to every Track B lesson. We plan a follow-up course that builds a more capable standalone MCP server on this split.

The STATELESS protocol option​

Spring AI's MCP server starter has a third protocol value besides STREAMABLE and the deprecated SSE: STATELESS. Stdio, which Class 19 uses, is a separate option. As Class 6 noted, the spring.ai.mcp.server.stdio property turns it on, and the protocol setting does not affect it.

A stateless server does not keep per-session state between requests. That makes it a good fit for deployments where a load balancer spreads requests across many instances, and any instance can answer any request. Set spring.ai.mcp.server.protocol=STATELESS on either the WebMVC or the WebFlux starter.

The cost is that the server can no longer send requests to the client. Stateless servers cannot use elicitation, sampling or roots, because those features need a long-lived channel from the server to the client, and the stateless protocol leaves that channel out on purpose. Spring AI enforces this in the tool method signature. In STATELESS mode, a tool method cannot take a McpSyncRequestContext parameter. The stateless tool callback rejects one with an UnsupportedOperationException that reads Stateless tool methods do not support McpSyncRequestContext parameter. The client receives that as an error tool result. That check covers tools that the annotation scanner registers. This lesson's server builds its tools in the Class 8 schemaDescribedTools bean as McpServerFeatures.SyncToolSpecification objects, and the stateless auto-configuration reads only McpStatelessServerFeatures tool specifications. With STATELESS set, this server starts and tools/list returns an empty list.

The elicitEnabled() and sampleEnabled() guards belong to the stateful protocols. They report whether the connected client declared the capability. If you skip the guard and call elicit() or sample() for a client that lacks the capability, the call throws an IllegalStateException. If your tools need Class 11's elicitation patterns, you cannot use stateless mode. Otherwise stateless is the simplest setup for production. Scaling out means changing the load balancer configuration, and new instances can serve requests at once, because they do not hold session state.

The moviedb-mcp server stays on STREAMABLE, because Class 11 adds elicitation to addToWatchlist. A team that does not need elicitation could register its tools as stateless specifications, switch to STATELESS, and scale out more easily. Stateless mode also matches the direction of the protocol. The 2026-07-28 MCP revision makes the core protocol stateless, so a stateless deployment is a step toward that model.

GraalVM native image and McpHints​

Some deployments are sensitive to cold starts, such as Cloud Run, AWS Lambda and Knative, which scale to zero when idle. For those, Spring AI ships a McpHints class that registers reflection hints for the MCP schema types, so the application compiles cleanly with GraalVM native image. Add the org.graalvm.buildtools:native-maven-plugin plugin, and run Maven with the -Pnative profile. The resulting binary starts in tens to hundreds of milliseconds, where the JVM artifact takes seconds. The costs are a much longer build and the usual native-image limits around dynamic class loading. For a moviedb-style service, the first MCP request to an idle instance completes in under a second, including the platform's scale-up work.

Production does not require native image. Use it when cold-start latency is part of your .

Configuration Profiles​

Production deployments need separate configuration for each environment, such as dev, staging and production. Spring Boot profiles handle this. Pin Spring AI to an explicit version in your build, and avoid relying on -managed defaults from a parent POM in a different repository. This course pins spring-ai-bom to 2.0.0, the version the companion repository uses, and newer 2.0.x patch releases may exist when you read this. The moviedb application runs Spring Boot 4, and Spring AI 2.0 is the line for Boot 4. It ships MCP Java SDK 2.x (spec revision 2025-11-25), with the MCP annotations moved into core as org.springframework.ai.mcp.annotation. The 1.1.x line is the maintenance line for teams on Boot 3. Keep secrets in your platform's secret store, and leave them out of container images.

src/main/resources/application-production.yaml
# Class 17: production profile. Activate with SPRING_PROFILES_ACTIVE=production.
# Deployment values (issuer, key set, audience, resource) come from the environment; the
# actuator surface is scoped to health/info/prometheus and health details are
# hidden so nothing leaks to whoever can reach the actuator path.
spring:
ai:
mcp:
server:
type: SYNC
protocol: STREAMABLE
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${MCP_JWT_ISSUER}
audiences:
- ${MCP_JWT_AUDIENCE}
# Replaces the base profile's local Keycloak address. Without it the key
# set is still fetched from localhost:8180, and every request with a token
# fails.
jwk-set-uri: ${MCP_JWT_JWK_SET_URI}
moviedb:
mcp:
# The resource URI that the metadata document advertises. The base profile's
# value is the local endpoint, so production must set its public address.
resource: ${MCP_RESOURCE}

management:
endpoints:
web:
exposure:
include: health, info, prometheus
endpoint:
health:
show-details: never
opentelemetry:
tracing:
export:
otlp:
endpoint: http://otel-collector:4318/v1/traces
headers: {}

Four details in this file matter:

  • MCP_JWT_ISSUER, MCP_JWT_JWK_SET_URI, MCP_JWT_AUDIENCE and MCP_RESOURCE come from the environment, which keeps operational values out of the application properties.
  • The profile sets jwk-set-uri as well as issuer-uri. Spring merges a profile into the base application.yaml one key at a time. Without this line, the production profile keeps the base file's local Keycloak address, localhost:8180. Spring then tries to fetch the signing keys from that address, and every request with a token fails with HTTP 500.
  • MCP_RESOURCE sets moviedb.mcp.resource, the resource URI in the metadata document. Without it, a production server advertises the base file's local address, http://localhost:8080/mcp.
  • The actuator exposes only health, info and prometheus. In production, keep env, beans and configprops away from anyone who can reach the actuator path.

Health details are hidden, because health responses can leak information about downstream dependencies.

Summary​

The production-ready artifact now reports on itself:

  • An aspect records per-tool counters and timers in whichever Micrometer registry your stack uses, with percentile histograms for latency.
  • Traces follow a tool call from the HTTP request through the GraphQL execution to each resolver, with a lower sampling rate in production.
  • One Spring Boot property turns on structured JSON logging, and the Logstash encoder covers custom needs. The audit stream has the same shape.
  • LLM-backed tools record the tokens they use.

The course keeps MCP and GraphQL in one deployable to stay simple, and a production setup would more likely split them once scaling, agent audiences, release schedules or ownership differ. STATELESS mode scales out easily if you can give up elicitation. Profile-based configuration keeps secrets in the environment and limits what the actuator exposes.

Further Reading​

What's Next​

Class 18 starts the agent client track, where we build a Spring AI agent app that uses your MCP server. So far we have tested the server with MCP Inspector, which is a developer tool. Class 18 builds a real client: a separate Spring Boot application that acts as an MCP client and uses Spring AI's ChatClient to drive an LLM. The LLM chooses which tools to call, and you can follow the whole loop from that choice to the tool result.