Class 18: Building a Spring AI Agent Client
Duration: 50 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 17. An Anthropic API key, or Ollama on your machine.
The default provider in this tutorial is Anthropic. The lesson also shows how to switch to OpenAI or a local Ollama model, and runs the same conversation with Claude and with a local Qwen3 model.
This lesson carries on from Class 17. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_17 branch to start from the same place:
git clone --branch mcp_class_17 https://github.com/graphqlguy/graphQLMovieDB-MCP.git
By the end of this lesson a separate Spring Boot application, moviedb-agent, runs as an autonomous agent. It connects to your moviedb-mcp server, discovers the movie tools, and uses an LLM-driven ChatClient to choose which tool to call for a user message. You can follow the whole loop:
- A user types
recommend me a movie for a rainy Sunday. - The model picks
recommendMoviesForMood. - The tool runs against your local GraphQL execution engine.
- The model writes a reply in plain language, and the user sees a recommendation.
Until now we tested the server with MCP Inspector, a developer tool. This lesson builds the kind of client your server meets in production.
This lesson uses a few Spring AI terms that may be new. Each one gets a fuller explanation where the lesson puts it to work:
- ChatClient is Spring AI's fluent API for sending a prompt to a model and reading its reply.
- Tool callback is Spring AI's representation of one tool a model can call: its definition and the code that runs it.
- MCP client starter is the Spring Boot starter that connects to MCP servers and turns their tools into tool callbacks.
- Streamable HTTP is the MCP transport that runs over HTTP, which the agent uses to reach
/mcp. - MCP client annotations, such as
@McpProgress, mark methods that handle messages the server sends to the client. - Chat memory is Spring AI's store for the earlier messages of a conversation, so the model can refer back to them.
- Advisor is a Spring AI component that changes a request before it reaches the model, or the response after it comes back.
- Tool context is extra data that Spring AI sends along with every tool call. For an MCP tool, it becomes the request's
_meta. - Progress token is the value a client puts in a request's
_metato ask the server for progress notifications. - Password grant is an OAuth flow where the client sends a user's name and password to get a token, which suits local testing only.
- Ollama runs open-weight models on your own machine and serves them through a local HTTP API.
What This Lesson Builds
We build a new Spring Boot application in a separate Maven module called moviedb-agent, inside the moviedb project directory. It depends on spring-ai-starter-mcp-client, a model starter (spring-ai-starter-model-anthropic by default), and a ChatClient configured with the model. The MCP client auto-configuration discovers the tools that your moviedb-mcp server exposes. The ChatClient takes a user message, decides whether to call a tool and which one, runs the call and writes the final response. The agent is a command-line application without an embedded web server, and it connects to the server over streamable HTTP at http://localhost:8080/mcp.
The agent can be any application, and Claude Desktop is only one option. Any application that works over MCP can use your tools, and Spring AI lets a Java team build that client with the same skills it used to build the server.
Setting Up the Module
Create a new Maven module inside your moviedb project directory. The directory layout becomes:
moviedb/ (existing, the GraphQL + MCP server; the project root)
pom.xml
src/...
moviedb-agent/ (new, the MCP client agent app, nested in the project)
pom.xml
src/main/java/com/graphqlguy/moviedb/agent/...
src/main/resources/application.yaml
Because moviedb-agent lives inside the moviedb project directory, its pom.xml sits one level below the server's pom.xml. By default, Maven looks for a declared parent at ../pom.xml. That file's coordinates do not match the declared Boot parent, so every build would print a warning before it falls back to the repository. The warning says that parent.relativePath points at com.graphqlguy:moviedb instead of org.springframework.boot:spring-boot-starter-parent. The agent's parent declaration below uses an empty <relativePath/>, which skips that lookup and resolves the Spring Boot parent from the repository directly.
The agent's pom.xml declares Spring Boot, the MCP client starter and two model starters. The Anthropic starter is the default, and the Ollama starter lets the same agent run against a local model:
<?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>
<groupId>com.graphqlguy</groupId>
<artifactId>moviedb-agent</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
<!-- Empty relativePath: this module is a subdirectory of the moviedb project.
Without it, Maven first checks ../pom.xml, finds the moviedb project
instead of the Boot parent, and prints a warning on every build before
it takes the Boot parent from the repository. -->
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- MCP client starter: no -webmvc suffix (that name is the server's). -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!-- Anthropic model starter: -model- infix; the un-infixed name does not exist. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
<!-- Ollama model starter, for running the agent against a local model.
The ollama profile selects it (see application-ollama.yaml). -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
First, a note on versions. The moviedb stack is Spring Boot 4, so the agent module uses the same Boot 4.0.x parent and Spring AI 2.0, the line built for Boot 4. The spring-ai-bom import sets one patch level for every Spring AI artifact in one place, so the individual starters leave out their versions. The agent uses 2.0.1, one patch level above the 2.0.0 that the server uses. With 2.0.0, some Claude replies came back empty in our runs. Claude can start a reply with a thinking block, Spring AI 2.0.0 turns that block into a separate result, and .content() returns the first result, whose text is empty. Version 2.0.1 keeps the thinking and the text in one result. The agent and the server are separate applications, so their patch levels do not have to match.
The older Spring AI 1.1.x line is still maintained for teams on Boot 3, and it does run on a Boot 4 parent. Version 2.0 is the line built and tested against Boot 4. It negotiates MCP revision 2025-11-25, the newest revision that MCP Java SDK 2.0.0 supports. The current revision, 2026-07-28, is not in that SDK yet. Use 2.0.
Two artifact names are easy to get wrong:
- The MCP client starter is
spring-ai-starter-mcp-client, without a-webmvcsuffix. The name with that suffix belongs to the server starter. - The Anthropic starter is
spring-ai-starter-model-anthropic, with-model-in the middle. A name without-model-does not exist on Maven Central.
The agent app is only a client, so it does not need the GraphQL or persistence dependencies.
The Spring Boot main class is the standard one:
package com.graphqlguy.moviedb.agent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AgentApplication {
public static void main(String[] args) {
SpringApplication.run(AgentApplication.class, args);
}
}
Configuring the MCP Client
The agent's application.yaml configures the MCP client to talk to your local moviedb-mcp server:
spring:
main:
# CLI agent, no embedded web server.
web-application-type: none
ai:
model:
# The pom has two chat model starters. This property picks the one to use;
# without it Spring AI creates both and the ChatClient cannot choose.
chat: anthropic
anthropic:
api-key: ${ANTHROPIC_API_KEY}
chat:
# Pin the model explicitly; starter defaults can point at retired models.
model: claude-sonnet-5
mcp:
client:
enabled: true
name: moviedb-agent
version: 1.0.0
type: SYNC
request-timeout: 30s
streamable-http:
connections:
moviedb:
url: http://localhost:8080
endpoint: /mcp
The spring.ai.model.chat property names the chat model the application uses. The pom.xml has two model starters, and each one creates its chat model unless this property names the other provider. Without the property, the agent stops at startup with required a single bean, but 2 were found, followed by anthropicChatModel and ollamaChatModel. The Ollama profile in Swapping to OpenAI or Ollama sets the property to ollama.
The spring.ai.mcp.client.streamable-http.connections.moviedb block points at the server. You choose the connection name, moviedb. It appears in logs and metrics, so pick a name you will recognise. type: SYNC matches the usual chat workflow: ask, get tool calls back, run them and respond. Reactive, non-blocking applications use ASYNC instead. The two client types register different kinds of handler, sync handlers for SYNC and async handlers for ASYNC, so this choice also affects the notification handlers later in this lesson.
The application reads the ANTHROPIC_API_KEY environment variable from your shell. For Anthropic, you get the key from platform.claude.com. For OpenAI, you would use OPENAI_API_KEY and spring.ai.openai.api-key. Always set the chat model explicitly, as the configuration above does with claude-sonnet-5. That is the current mainstream Claude model, and claude-opus-5 suits more complex agent work at a higher price. Name the model yourself, because a starter's default can still point at a model after the provider retires it. An early Spring AI 1.0 default pointed at Claude 3.7 Sonnet, which was retired on February 19, 2026. When that happens, you get a confusing API error, and the configuration does not warn you.
From Class 15 onward, the moviedb-mcp server requires OAuth, so the client needs a token. Readers who work straight through often get stuck here, because the MCP client transport does not have a configuration property for an Authorization header. Without the token, the agent fails at startup, before any prompt, with Client failed to initialize by explicit API call fifteen frames down a bean-creation stack trace. That error looks like a wiring bug, and nothing in it mentions the missing token.
The solution is a customizer bean. The transport auto-configuration collects every McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> in the context and applies it to the builder before it builds the transport. The builder's httpRequestCustomizer is where you add a header:
package com.graphqlguy.moviedb.agent;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Class 18: attaches a bearer token to every outgoing MCP request, for talking
* to the OAuth-protected /mcp endpoint Class 15 built. The transport does not
* have a configuration property for an Authorization header, so this class adds a
* customizer that the transport auto-configuration applies to its builder.
*
* Conditional on the property, so an unprotected server needs no change: leave
* moviedb.mcp.token unset and this bean never exists.
*/
@Configuration
public class McpAuthConfig {
@Bean
@ConditionalOnProperty("moviedb.mcp.token")
McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> bearerTokenCustomizer(
@Value("${moviedb.mcp.token}") String token) {
return (serverName, builder) -> builder.httpRequestCustomizer(
(requestBuilder, method, uri, body, context) ->
requestBuilder.header("Authorization", "Bearer " + token));
}
}
@ConditionalOnProperty creates the bean only when you set moviedb.mcp.token, so an unprotected server works as before. When you set the property, every outgoing MCP request carries the header.
A Token for a Real User
The agent works on a user's watch list, so its token has to name a user that the Movie DB knows. The client_credentials token from Class 15's Verifying section names the mcp-test client. As that section explains, the Movie DB does not have a user with that identity, so the token only tests the scope checks. For this lesson we get a token for Mara, one of the users the Movie DB creates at startup. That takes one change on the server and one setting in Keycloak.
On the server, the watch list code takes the user's name from Principal.getName(). For a JWT, Spring Security fills that name from the sub claim by default. Keycloak puts its internal user id in sub, which is a UUID, and puts the username in preferred_username. The principal-claim-name property tells Spring Security to use the username claim:
jwt:
issuer-uri: http://localhost:8180/realms/moviedb
audiences:
- moviedb-mcp
jwk-set-uri: http://localhost:8180/realms/moviedb/protocol/openid-connect/certs
# Class 18: a token for a real user names that user in preferred_username,
# and the watch list belongs to that username, so it becomes the principal.
principal-claim-name: preferred_username
The two URIs are the local Keycloak defaults from Class 15.
The new property affects the MCP tests from Class 15. Their JwtDecoder bean fakes every token and puts the token text in sub. With the property set, Spring Security looks for preferred_username, the fake token does not have it, and every MCP call in the tests fails with HTTP 500. The decoder needs one more claim, which sets preferred_username to the token text as well.
The complete McpSecurityTestConfig.java, with the new claim
package com.graphqlguy.moviedb.mcp;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.time.Instant;
/**
* Class 15 test support. The production /mcp chain validates JWTs against an
* external issuer's JWK Set, which no test can reach. This test-only
* {@link JwtDecoder} bean replaces the network-backed decoder (Boot's own bean
* is {@code @ConditionalOnMissingBean}, so it backs off) and decodes any token
* string into a principal carrying every scope the tools require. The subject
* claim is the token text itself, so a test can authenticate as any seeded
* {@code AppUser} (for example "user" or "mara") just by sending that
* username as the bearer token; {@link #TEST_TOKEN} keeps working for tests
* that only need an authenticated caller and never touch a user-owned record.
* From Class 18 the principal comes from preferred_username, so the decoder
* sets that claim to the token text too.
*/
@TestConfiguration
public class McpSecurityTestConfig {
static final String TEST_TOKEN = "test-user";
static final String ALL_SCOPES = "movies:read watchlist:read watchlist:write reviews:read";
@Bean
JwtDecoder jwtDecoder() {
return token -> Jwt.withTokenValue(token)
.header("alg", "none")
.subject(token)
.claim("preferred_username", token)
.claim("scope", ALL_SCOPES)
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
}
}
The users user and mara already exist in Keycloak, from step 9 of Class 15's setup, with the same passwords as in the Movie DB app. The agent signs in as mara and asks Keycloak for her token with a password, and mcp-test needs one more setting for that. If you start Keycloak from the realm file in Class 15, take keycloak/moviedb-realm.json from this lesson's branch, which already has the setting. Otherwise, change it in the admin console:
- In the left menu, choose Clients, then
mcp-test. - On its Settings tab, under Capability config, check Direct access grants, and choose Save.
Direct access grants is off by default. Class 15 left it that way, because the client_credentials grant and Inspector's sign-in both work without it. The Keycloak username has to match a Movie DB username, such as mara, because the watch list looks the user up by that name.
Now request a token with the password grant:
TOKEN=$(curl -s -X POST http://localhost:8180/realms/moviedb/protocol/openid-connect/token \
-d "client_id=mcp-test" \
-d "client_secret=<the Client Secret of mcp-test, or mcp-test-secret with the realm file>" \
-d "grant_type=password" \
-d "username=mara" \
-d "password=mara123" \
-d "scope=movies:read watchlist:read watchlist:write reviews:read" \
| jq -r .access_token)
The token carries preferred_username with the value mara, the four scopes, and moviedb-mcp in aud from Class 15's audience mapper. Pass it to the agent on the command line:
./mvnw -f moviedb-agent/pom.xml spring-boot:run \
-Dspring-boot.run.arguments=--moviedb.mcp.token=$TOKEN
The password grant suits local testing only. With it, the client sees the user's password, and the OAuth 2.1 draft leaves the grant out for that reason. A real agent sends the user to the authorization server's sign-in page with the authorization code flow, so only Keycloak sees the password.
This setup is for development. The token is a static string that expires after an hour, the lifespan that Class 15 set, and this code does not refresh it. The community mcp-security project gets and refreshes tokens on the client side, and a production setup would put a token broker in front. The bean above lets you run the rest of this lesson against the server you built.
Wiring the ChatClient
A @Configuration class exposes a ChatClient bean that uses the MCP tool callbacks:
package com.graphqlguy.moviedb.agent;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Class 18: a ChatClient wired with the MCP tool callbacks the client starter
* auto-discovered. The defaultSystem prompt aligns the agent's behavior with the
* server contract - notably how to handle the null summary from
* summarizeMovieReviews so the model reports "not enough reviews yet" instead of
* inventing a summary. The memory advisor adds the earlier messages of the
* conversation to each prompt, so a follow-up such as "add your top pick" works.
*/
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ToolCallbackProvider mcpTools,
ChatMemory chatMemory) {
return builder
.defaultSystem("""
You are a helpful movie recommender. The user is asking for recommendations
or watchlist actions. Your tools are all backed by the user's local movie
database; the tool list you were given is the authoritative one, so work
from it instead of from any assumption about what is available. Prefer
calling a tool over speculating from memory; if the user gives you a movie
ID and asks about it, look it up. If a tool returns null for a summary,
tell the user "not enough reviews yet" instead of guessing. If a tool
stages an action and returns a confirmation token, do not confirm it on
your own: report what will happen and wait for the user to ask for it.
Write every movie you mention as "Title (year), id N". Later turns see your
earlier replies but not the tool results, so the ids must be in the replies.
""")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.defaultToolCallbacks(mcpTools)
.build();
}
}
The MCP client starter provides the ToolCallbackProvider. It contains every tool from every connected MCP server. The defaultSystem(...) block is where you make the agent's behavior match the server's contract. This prompt tells the model how to handle a null summary from summarizeMovieReviews. Guidance like that stops the agent from inventing an answer.
Remembering the Conversation
Each chatClient.prompt(...) call is a separate request to the model. On its own, the model sees the system prompt, the tools and the new message. A follow-up such as Add your top pick to my watchlist then has nothing to refer to, and the model cannot tell which movie is the top pick.
MessageChatMemoryAdvisor solves this. An advisor runs around every call. Before the call, this one adds the earlier messages of the conversation to the prompt. After the call, it stores the new user message and the model's reply. The ChatMemory bean comes from Spring AI's auto-configuration. It keeps the last 20 messages of each conversation in the agent's memory, so a restart starts a new conversation.
The memory stores the user's messages and the model's final replies. The tool calls and their results stay out of it, and those results hold the movie ids. In our first run with memory, qwen3:8b recommended Casablanca without its id. On the next message it tried tt0068646, Casablanca's IMDb id, then 68646, and then asked the user what to do.
The last two lines of the system prompt make the model write each movie as Title (year), id N, so the ids reach the memory in the replies. A softer instruction, to include the id, did not change what qwen3:8b wrote, and the fixed format did.
A Conversational Loop
A simple command-line loop shows the whole flow:
package com.graphqlguy.moviedb.agent;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Scanner;
import java.util.UUID;
/**
* Class 18: the full loop in a single CLI. chatClient.prompt(...).call().content()
* lets Spring AI drive the tool-call protocol - when the model decides to call a
* tool, the framework dispatches it to the MCP client, awaits the result, and
* feeds it back into the model before .content() returns.
*/
@Component
public class CliRunner implements CommandLineRunner {
private final ChatClient chatClient;
public CliRunner(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public void run(String... args) {
// One conversation per run. The memory advisor stores the messages under
// this id and adds them to every prompt that carries it.
String conversationId = UUID.randomUUID().toString();
try (Scanner in = new Scanner(System.in)) {
System.out.println("moviedb-agent ready. Ask me about movies.");
while (true) {
System.out.print("> ");
if (!in.hasNextLine()) break;
String userInput = in.nextLine();
if (userInput.isBlank()) continue;
if ("quit".equalsIgnoreCase(userInput.trim())) break;
// The tool context becomes the MCP request's _meta, and a progressToken
// there asks the server for progress notifications.
String response = chatClient.prompt(userInput)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.toolContext(Map.of("progressToken", UUID.randomUUID().toString()))
.call()
.content();
System.out.println(response);
System.out.println();
}
}
}
}
That is the whole main loop, and chatClient.prompt(userInput).call().content() does the work. Two calls between prompt(...) and call() add values to each request:
.advisors(...)passes the conversation id to the memory advisor fromChatConfig.runcreates the id once, so every prompt in a session belongs to the same conversation. Spring AI 2.0 needs this id on every request, and without it the first prompt fails withconversationId cannot be null..toolContext(...)puts a progress token into each MCP tool call. Asking the Server for Progress explains why.
Spring AI handles the tool-call protocol with the model. When the model decides to call a tool, the framework sends the call to the MCP client, waits for the result and passes it back to the model. When .content() returns, the model has either answered directly without a tool, or called one or more tools and written a response from the results.
Watching the Loop
Start moviedb on port 8080 (./mvnw spring-boot:run), set ANTHROPIC_API_KEY in your environment, and run moviedb-agent with Mara's token from A Token for a Real User. Without the token, the agent fails during startup, before the first prompt. Then type a message and watch the output.
Two warnings appear before the prompt, No sampling methods found and No elicitation methods found. They say that the agent has no handlers for those server requests yet. Receiving Server-Initiated Notifications lists the annotations for them.
Here is a run with Claude:
moviedb-agent ready. Ask me about movies.
> Recommend me a movie for a rainy Sunday afternoon.
For a cozy rainy Sunday, here are some top comfort picks:
1. **Casablanca (1942), id 60** - Timeless romance, rating 8.5
2. **Eternal Sunshine of the Spotless Mind (2004), id 62** - Bittersweet and thoughtful romance, rating 8.3
3. **Some Like It Hot (1959), id 16** - Classic comedy, rating 8.2
4. **The Grand Budapest Hotel (2014), id 15** - Whimsical and charming, rating 8.1
5. **La La Land (2016), id 61** - Dreamy musical romance, rating 7.9
If you want something light and funny, I'd lean toward **The Grand Budapest Hotel** or **Some Like It Hot**. If you're in the mood for something more emotional and romantic, **Casablanca** or **Eternal Sunshine of the Spotless Mind** would be perfect.
Want me to add any of these to your watchlist?
> Add your top pick to my watchlist.
Added **Casablanca (1942), id 60** to your watchlist with status "Want to Watch." Enjoy your rainy Sunday! 🎬🌧️
> What do reviewers think of it?
[0%] Loading reviews
Turns out there are no reviews at all yet for **Casablanca (1942), id 60** - so I can't share reviewer opinions on it. It's a classic though, so you may just have to judge for yourself! 🎬
The server's audit log from Class 16 shows one tool call for each message, all for the user mara:
recommendMoviesForMoodwith theCOMFORTmood.addToWatchlistwithmovieId60. Claude left the status out, so the server recorded its default,WANT_TO_WATCH.summarizeMovieReviewsfor movie 60. The tool sent one progress notification, and the handler from the next section printed it as[0%] Loading reviews. Casablanca has no reviews in the seed data. The tool stops when a movie has fewer than three reviews and returns anullsummary, and the system prompt tells the model how to report that.
The second message works because of the chat memory. The model reads id 60 in its own earlier reply. Each call goes through the MCP client to the moviedb-mcp tool, which runs the matching GraphQL operation in process. The tool returns a typed result, and the model turns it into a reply for the user.
Here are the same three messages with qwen3:8b, using the ollama profile from Swapping to OpenAI or Ollama:
moviedb-agent ready. Ask me about movies.
> Recommend me a movie for a rainy Sunday afternoon.
Here are some comforting movies perfect for a rainy Sunday afternoon, ranked by rating:
1. **Casablanca (1942), id 60** (8.5) - A timeless romance with iconic dialogue and suspense.
2. **Eternal Sunshine of the Spotless Mind (2004), id 62** (8.3) - A heartfelt romantic comedy about love and memory.
3. **Some Like It Hot (1959), id 16** (8.2) - A hilarious 1950s comedy with unforgettable charm.
4. **The Grand Budapest Hotel (2014), id 15** (8.1) - A whimsical, visually stunning comedy.
5. **La La Land (2016), id 61** (7.9) - A vibrant romantic musical with emotional depth.
Would you like to add one of these to your watchlist? 🌧️
> Add your top pick to my watchlist.
Casablanca (1942), id 60, has been added to your watchlist as "WANT_TO_WATCH". 🎬 The system confirmed the addition with entry ID 6 in its database. Would you like to adjust the status (e.g., mark as WATCHED) or manage another title?
> What do reviewers think of it?
[0%] Loading reviews
There are no reviews yet for *Casablanca (1942), id 60*. This could mean the movie hasn’t received any ratings or feedback in the system, or it’s not yet part of the review catalog. Would you like to add it to your watchlist and track its reviews over time, or explore other movies with available summaries? 📝
The local model made the same three tool calls. It passed the status WANT_TO_WATCH itself instead of leaving it out, and the server stored the same row. Its last reply suggests adding Casablanca to the watch list, a step it took one message earlier. Here the model lost track of the conversation, so check a small model's replies against the tool calls in the server log.
On macOS, the Ollama run also logs an ERROR line from Netty: Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults. Netty then uses the system's DNS settings, and the run works.
If you added the metrics from Class 17, you can watch the counters go up. You can observe every layer of the stack.
Receiving Server-Initiated Notifications
Class 10 added progress notifications to summarizeMovieReviews, and Class 11 added elicitation to addToWatchlist. Those are messages from the server to the client, and so far the agent app ignores them. The MCP client starter provides an annotation for each kind of message:
@McpProgressfor progress notifications (the "loading reviews / summarizing / done" updates)@McpLoggingfor log messages the server sends to the client@McpSamplingfor sampling requests, where the server asks the client to invoke its LLM on the server's behalf@McpElicitationfor elicitation requests, where the server asks the client to prompt the user for additional input
A minimal handler component in the agent app:
package com.graphqlguy.moviedb.agent;
import io.modelcontextprotocol.spec.McpSchema.ProgressNotification;
import org.springframework.ai.mcp.annotation.McpProgress;
import org.springframework.stereotype.Component;
/**
* Class 18: handles server-initiated progress notifications (Class 10 wired these
* on summarizeMovieReviews). The clients = "moviedb" attribute matches the server
* connection name in application.yaml so this handler only fires for that server,
* not every connected one.
*/
@Component
public class AgentNotifications {
@McpProgress(clients = "moviedb")
public void onProgress(ProgressNotification notification) {
System.out.printf("[%.0f%%] %s%n",
notification.progress() * 100.0,
notification.message());
}
}
The clients = "moviedb" attribute is required. It says which connection's notifications reach this handler, and it matches the connection name from application.yaml. The attribute also accepts several connection names.
Asking the Server for Progress
The handler only receives notifications for tool calls that ask for them. As Class 10 explained, the client chooses a progressToken and puts it in the _meta of its tools/call request. The server reports progress only on calls that carry a token.
By default, the ChatClient sends its MCP tool calls with an empty _meta. The handler then does not print anything, and the server logs Progress notification not supported by the client! once for each context.progress(...) call in summarizeMovieReviews.
Spring AI copies the prompt's tool context into the _meta of every MCP tool call that the prompt makes. That is why CliRunner sets the token with .toolContext(...). A new UUID for each prompt is enough. Spring AI runs the tool calls of one prompt one after another, and a token only has to be unique among the requests that are still in progress. Every other entry in the tool context also reaches the server in _meta, so keep secrets out of it.
To look at the notifications without the agent, call summarizeMovieReviews from MCP Inspector, which adds a progress token to every tool call, as Class 10's Verifying section describes.
This setup can easily create a circular dependency. A notification handler may inject a ChatClient to ask the model what to do with a notification, and the ChatConfig bean depends on the tools and progress handlers. Each bean then needs the other before it can be created. Spring AI's documentation recommends annotating the ChatClient injection with @Lazy in handler classes:
public AgentNotifications(@Lazy ChatClient chatClient) {
this.chatClient = chatClient;
}
With @Lazy, Spring creates the ChatClient later, and the cycle is gone. The moviedb-agent above avoids the problem, because its handler does not call the chat client. As soon as the handler uses the model, you need @Lazy.
Swapping to OpenAI or Ollama
The agent app works with any supported model provider. To switch to OpenAI, add spring-ai-starter-model-openai to the pom.xml and point spring.ai.model.chat at it:
spring:
ai:
model:
chat: openai
openai:
api-key: ${OPENAI_API_KEY}
chat:
model: gpt-5.6-sol
Restart the agent, and the Java code stays the same. Model names change every few months while the shape of the configuration stays the same, so check the provider's current model list before you set a model.
For local development without cloud costs, Ollama runs models on your laptop. The pom.xml already has the Ollama starter, so switching to it only takes a Spring profile. The profile's settings go in their own file:
# Class 18: run the agent against a local model with --spring.profiles.active=ollama.
# spring.ai.model.chat selects which chat model auto-configuration runs, so the
# Anthropic starter can stay on the classpath and no API key is needed.
spring:
ai:
model:
chat: ollama
ollama:
base-url: http://localhost:11434
chat:
# A model trained for tool calling. Smaller models pick tools less reliably.
model: qwen3:8b
The profile sets spring.ai.model.chat to ollama, so Spring AI creates only the Ollama chat model, and the agent starts without ANTHROPIC_API_KEY. Download the model once, then run the agent with the profile:
ollama pull qwen3:8b
./mvnw -f moviedb-agent/pom.xml spring-boot:run \
-Dspring-boot.run.profiles=ollama \
-Dspring-boot.run.arguments=--moviedb.mcp.token=$TOKEN
Pick a local model that was trained for tool calling. The Qwen3 family has tool-call support in its chat template and works well with Ollama's tools API. We ran this lesson with qwen3:8b, which is a 5.2 GB download.
With qwen3:8b, the agent picked the right tool for each message in Watching the Loop. It needed the id format in the system prompt more than Claude did. Without that format, Claude found the id by calling recommendMoviesForMood again, and qwen3:8b asked the user for it. The Java code and the server stay the same for every provider, and only the model's behavior changes.
Summary
This lesson built moviedb-agent, a second Spring Boot application that acts as the MCP client:
- The MCP client starter discovered the server's tools automatically.
- A
ChatClientwithdefaultToolCallbacksmade those tools available to the LLM. - A chat memory advisor let later messages refer to earlier replies.
- A short command-line loop showed the whole agent conversation.
We also set the chat model explicitly, mapped Keycloak's username claim so that a token names a Movie DB user, asked the server for progress through the tool context, and printed the notifications with @McpProgress. Switching from Anthropic to a local Ollama model takes a Spring profile, and OpenAI takes a dependency and a property. The Java code stays the same. Any application that works over MCP can use your tools, and you have now built one.
Further Reading
- Spring AI: MCP Client Boot Starter - configuration reference for the client side.
- Spring AI: Annotations for MCP clients -
@McpProgress,@McpLogging,@McpSampling,@McpElicitation. - Spring AI: ChatClient API - the fluent client used to drive the agent loop.
- Claude API docs - the upstream reference for Claude's tool-calling behavior and current model IDs.
- Dan Vega: Building MCP Clients with Spring AI - alternative walkthrough of the same client patterns.
- Christian Tzolov: spring-ai-mcp-blogpost - the reference example showing multi-server and bidirectional patterns.
What's Next
Class 19 is an extra hands-on lesson, and the optional Class 20 after it looks ahead. So far we have run our own MCP client, the agent app. For personal tools and developer workflows, the usual client is Claude Desktop, which connects over stdio instead of HTTP. In Class 19 we switch the moviedb-mcp starter from streamable HTTP to stdio, write a launcher script, configure claude_desktop_config.json, and see the moviedb tools appear in Claude Desktop.