Class 6: Connecting a Client
Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 5: Prompts and Completion. This class still runs without an API key or a model.
What We'll Cover
- A second module, and why the agent is a separate process
- The MCP client starter, and what it brings with it
- Configuring a Streamable HTTP connection
- What arrives at startup: the handshake, then the tool, resource and prompt lists
- Calling a tool from Java through
McpSyncClient
This class carries on from Class 5. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_5
branch to start from the same place:
git clone --branch class_5 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
A Second Module
Everything so far has been one application answering MCP requests. The other half of the protocol is the application that sends them, and it runs as a separate process.
support-agent connects to order-service over HTTP because that is the connection it would use against a service another team deploys, and because the agent will connect to more servers than ours from Class 9. Putting both in one JVM would mean an application connecting to itself over HTTP.
Both modules sit under the same parent pom, and one connection joins them:
The arrow from support-agent to order-service is HTTP even though both processes run on one
machine, so the same code keeps working when order-service moves to another host.
Let your IDE create the module rather than writing the files by hand. The steps are much the same in every IDE: right-click the support-desk project, ask for a new Maven module, name it support-agent, and check that the parent is support-desk. In IntelliJ IDEA that is New → Module.
That writes those files for us: <module>support-agent</module> goes into the parent pom.xml, support-agent/pom.xml is created with the parent block and the new artifactId, and the source folders appear as src/main/java, src/test/java and src/main/resources.
It cannot know what the module depends on, so replace the generated support-agent/pom.xml with this:
<?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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.themcpguy</groupId>
<artifactId>support-desk</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>support-agent</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Connects to MCP servers and turns their tools into Spring AI tool callbacks -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
</dependencies>
</project>
The dependencies do not list version numbers, because the parent's dependencyManagement block fixes the version of each one for every module.
spring-ai-starter-mcp-client connects over four transports: stdio, Streamable HTTP, stateless Streamable HTTP and SSE. The SSE and Streamable HTTP ones are built on the JDK's HttpClient.
The application class goes in src/main/java, at support-agent/src/main/java/com/themcpguy/supportdesk/agent/SupportAgentApplication.java, and it is the only class that lives in that package. Everything else goes in a sub-package below it, which is the arrangement order-service already uses and what @SpringBootApplication scans:
package com.themcpguy.supportdesk.agent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SupportAgentApplication {
public static void main(String[] args) {
SpringApplication.run(SupportAgentApplication.class, args);
}
}
Configure the Connection
Create support-agent/src/main/resources/application.yaml:
spring:
ai:
mcp:
client:
name: support-agent
version: 1.0.0
request-timeout: 30s
streamable-http:
connections:
orders:
url: http://localhost:8080
logging:
level:
root: WARN
com.themcpguy: INFO
What each property sets, and the default used when it is left out:
Property, under spring.ai.mcp.client | What it sets | Default | This file |
|---|---|---|---|
name | the client name sent in the initialize request | spring-ai-mcp-client | support-agent |
version | the client version sent in the same request | 1.0.0 | 1.0.0 |
request-timeout | how long any request on any connection may take | 20s | 30s |
type | whether the clients are SYNC or ASYNC | SYNC | left at the default |
toolcallback.enabled | hands the discovered tools to ChatClient in Class 7 | true | left at the default |
streamable-http.connections.orders.url | the base address of one server | none | http://localhost:8080 |
streamable-http.connections.orders.endpoint | the path added to url | /mcp | left at the default |
orders is a name we chose. The property path is spring.ai.mcp.client.streamable-http.connections.<name>, and everything under that name describes one server. It appears in logs, and from Class 11 it is how a notification handler says which connection it serves. In Class 10 we add a second name, so the agent holds one connection per server.
url is the base address, not the endpoint. The client appends the endpoint path itself, defaulting to /mcp. A server publishing somewhere else takes an endpoint: alongside url::
streamable-http:
connections:
orders:
url: http://localhost:9000
endpoint: /api/mcp
request-timeout is global. It applies to every connection, and Spring AI 2.0.x does not offer a per-connection setting, though the specification asks SDKs to allow timeouts per request. It needs to cover the slowest legitimate call, which Class 11 makes concrete with a job that works through 87 orders.
Both processes run on your machine, so the connection does not carry any credentials. A server reachable from another host needs the authorization the specification defines, and the transport's security rules ask it to check the Origin header and to bind to 127.0.0.1 while it is local.
Print What the Server Offers
Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/McpInspector.java. A CommandLineRunner runs after the application context is ready, so by the time this method runs the connection is open:
package com.themcpguy.supportdesk.agent.mcp;
import java.util.List;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class McpInspector implements CommandLineRunner {
private final List<McpSyncClient> clients;
McpInspector(List<McpSyncClient> clients) {
this.clients = clients;
}
@Override
public void run(String... args) {
for (McpSyncClient client : clients) {
var info = client.getServerInfo();
var capabilities = client.getServerCapabilities();
System.out.printf("Connected to %s %s%n", info.name(), info.version());
if (capabilities.tools() != null) {
client.listTools().tools().forEach(tool ->
System.out.printf(" tool %s%n", tool.name()));
}
if (capabilities.resources() != null) {
client.listResources().resources().forEach(resource ->
System.out.printf(" resource %s%n", resource.uri()));
}
if (capabilities.prompts() != null) {
client.listPrompts().prompts().forEach(prompt ->
System.out.printf(" prompt %s%n", prompt.name()));
}
}
}
}
Check the capability before asking. order-service advertises all three, so the three if (capabilities...) checks always pass. They look like wasted lines here, but the filesystem server in Class 9 advertises tools only, and calling listResources() on it fails with IllegalStateException: Server does not provide the resources capability rather than returning an empty list.
listTools() returns the whole list. tools/list is paginated, and the no-argument form follows every cursor for you, while listTools(cursor) fetches a single page.
List<McpSyncClient> is a list because there is one client per configured connection. With spring.ai.mcp.client.type set to ASYNC the injectable type would be List<McpAsyncClient> instead, which Class 15 covers.
Now run both, from the project root. order-service goes first, because the agent opens the connection while it starts and fails without it:
mvn -pl order-service spring-boot:run
It logs Registered tools: 4 and then Started OrderServiceApplication. Once that appears, in a second terminal:
mvn -pl support-agent spring-boot:run
Connected to order-service 1.0.0
tool get_customer_orders
tool get_order
tool get_orders_by_status
tool update_order_status
resource policy://shipping
resource policy://returns
prompt draft_refund_email
Every one of those was built into order-service in Classes 2 to 5, and nothing in support-agent names an order or a policy. Watch which arrow carries the names:
The reply to initialize says which kinds of thing the server has, and it does not carry their names. Those came from the three list calls in McpInspector.
policy://returns and policy://shipping appear, and order://{orderId} does not. listResources() returns fixed resources; templates come from listResourceTemplates(). In Class 8 we call both, so the agent reads a single order as well as the two policies.
Spring AI ran the handshake for us. In Classes 2 to 5 we opened every session by hand, copying the Mcp-Session-Id from the initialize reply into every call after it. The client does all of that while the application starts, and tracks the session ID itself. Adding io.modelcontextprotocol: DEBUG to the logging levels shows the two messages the client sends, with the server's reply between them:
Sending message JSONRPCRequest[jsonrpc=2.0, method=initialize, id=9a4b07c9-0,
params=InitializeRequest[protocolVersion=2025-11-25, ... ]]
Server response with Protocol: 2025-11-25, Capabilities: ... ,
Info: Implementation[name=order-service, title=null, version=1.0.0,
description=null, icons=null, websiteUrl=null] and Instructions null
Sending message JSONRPCNotification[jsonrpc=2.0, method=notifications/initialized, params=null]
root: WARN in the agent's application.yaml holds back the framework's startup lines, so the list from McpInspector is easy to find. That class writes with System.out instead of a logger, which keeps its output out of the log. Add Class 2's org.springframework.ai.mcp: INFO line when you want to watch the client side as well.
Call a Tool Without a Model
ChatClient arrives in Class 7. Calling a tool directly first shows a request and its result on the connection the discovery used.
Add these lines at the end of run in McpInspector, below the for loop, and add import java.util.Map; to the imports. CallToolRequest and CallToolResult are already imported by that class:
CallToolResult result = clients.getFirst().callTool(
CallToolRequest.builder("get_order")
.arguments(Map.of("orderId", "ORD-10001"))
.build());
System.out.println(result.content());
IntelliJ marks that call with 'McpSyncClient' used without 'try'-with-resources statement, because
McpSyncClient implements AutoCloseable. Do not act on it.
A client closed once is finished: the next call through it fails with Client failed to initialize,
and on a stdio connection, closing also ends the server's child process.
The MCP client starter registers a CloseableMcpSyncClients bean holding the same list of clients,
and because that bean implements AutoCloseable, Spring calls its close() when the application
shuts down. The solid path below is what happens on its own, and the dotted branch is what a
try-with-resources block would add:
The client here is a bean shared by the whole application, so the dotted branch would leave that bean in place with its connection gone, and take it away from every later call.
The warning is a false positive, and the code is correct with it showing. To hide it, Alt+Enter on
the warning offers Suppress for method, which adds @SuppressWarnings("resource").
Restart the agent. order-service can stay running, since it is unchanged:
mvn -pl support-agent spring-boot:run
One more line appears after the list:
[TextContent[annotations=null, text={"orderId":"ORD-10001","status":"SHIPPED", ... }, meta=null]]
A tool result is a list of content blocks, which is why the output is wrapped in brackets. A content block is one item of that list, carrying its own type. There is one block here, a TextContent, and its text holds the order as JSON. The annotations=null, meta=null around it are the record's other fields printed by its toString(), because we passed the whole list to println.
Reading the JSON out means taking the first block and casting it to TextContent, another record in McpSchema, so add import io.modelcontextprotocol.spec.McpSchema.TextContent; alongside the two imports already there:
TextContent block = (TextContent) result.content().getFirst();
System.out.println(block.text());
That text block is one half of the result. In Class 3 we gave get_order an output schema with generateOutputSchema = true, so the reply also holds the same order as an object, read from result.structuredContent().
The call succeeded, so result.isError() is false. A tool that fails answers with HTTP 200 as well and reports the failure in a result whose isError is true, which we saw in Classes 2 and 3. Client code reads that flag before it uses the content, because the specification asks clients to hand tool failures to the model so it can correct itself.
The JSON is the same one the REST controller returns and curl printed in Class 2. Spring MVC serialises the Order record on the REST route, and Spring AI serialises the same record into the tool result, both with Jackson.
What changed is how we asked:
| Step | Class 2, by hand with curl | Class 6, through McpSyncClient |
|---|---|---|
| Build the request | a JSON-RPC body typed into -d | CallToolRequest.builder("get_order").arguments(...) |
| Attach the session | the Mcp-Session-Id from the initialize reply, pasted into the command | the client tracks the session ID itself |
| Send it | curl to http://localhost:8080/mcp | client.callTool(...) over the connection named orders |
| Read the reply | JSON text on the terminal | a CallToolResult, with content() and structuredContent() as Java objects |
In Class 7 the model picks the tool name and fills in the arguments from the conversation, and Spring AI sends the same request we just built with CallToolRequest.builder.
Troubleshooting
| What you see | What it means | What to check |
|---|---|---|
Client failed to initialize by explicit API call while the agent starts, with java.net.ConnectException: Connection refused further down the stack trace | order-service is not answering on http://localhost:8080/mcp. The client connects while the agent starts, so the mcpSyncClients bean cannot be created and startup stops there | start order-service first, and wait for Started OrderServiceApplication before you start the agent |
| about 20 seconds of silence, then a timeout | the host accepted the connection and did not answer. The SDK bounds the whole handshake with its own initialization timeout of 20 seconds, and the spring.ai.mcp.client properties do not change it | the url, and whether the server answers on /mcp. Our request-timeout of 30 seconds governs the requests made once the session is up, so it does not move this one |
| an immediate failure instead of a wait | the host refused the connection outright | the port |
To watch the protocol itself, set logging.level.io.modelcontextprotocol to DEBUG.
What We Built
We now have two applications: order-service exposes tools, resources and a prompt, and support-agent connects to it, discovers all of them at startup, and calls one. Neither module imports the other, and the protocol is all they share.
In Class 7 we hand the tool list to a model, and that is the first class needing an API key or a local model.
Next: Class 7: Handing the Tools to a Model. ChatClient, a system prompt, conversation memory, and the questions the agent can answer.
Further Reading
- MCP Client Boot Starter :: Spring AI Reference: every
spring.ai.mcp.clientproperty with its default, including thestreamable-httpconnection map this class configures. - MCP Client (Java SDK): the
McpSyncClientAPI behindMcpInspector, with worked examples oflistTools,callTooland each transport. - MCP specification 2025-11-25: Lifecycle: the normative rules for the
initializeexchange, including which fields the server's reply carries and how timeouts are meant to work. - MCP specification 2025-11-25: Transports: Streamable HTTP in full, covering the single MCP endpoint, session management, and the rules a server follows once it is reachable from another host.
- MCP specification 2025-11-25: Tools: the shape of a tool result, both content blocks and
structuredContent, and the difference between a protocol error and anisErrorresult. - MCP specification 2025-11-25: Pagination: how cursors work on
tools/listand the other list calls, which is what the no-argumentlistTools()walks through for you. - MCP specification 2025-11-25: Authorization: what an MCP server needs before it is reachable over anything but localhost.
- CommandLineRunner (Spring Boot API): the contract
McpInspectorimplements, and theApplicationRunneralternative that takes parsed arguments instead of aStringarray.
Sources
- MCP Client Boot Starter :: Spring AI Reference: the defaults in the property table:
nameisspring-ai-mcp-client,versionis1.0.0,request-timeoutis20s,typeisSYNC,toolcallback.enabledistrueandendpointis/mcp. It also names the four transports the standard starter supports, with the SSE and Streamable HTTP ones built on the JDKHttpClient. - MCP specification 2025-11-25: Lifecycle: that the
initializeresponse carriesprotocolVersion,capabilities,serverInfoand an optionalinstructionsstring, and that SDKs SHOULD allow timeouts to be configured per request. - MCP specification 2025-11-25: Tools: that a result may carry structured content in
structuredContentas well as unstructured content blocks. It also gives the rule that a failing tool reports the failure withisErrortrue, so the client can pass it to the model. - MCP specification 2025-11-25: Resources: that resource templates are listed by
resources/templates/listand do not appear inresources/list. - MCP specification 2025-11-25: Transports: the security rules for Streamable HTTP, that a server MUST validate the
Originheader, SHOULD bind only to127.0.0.1when it runs locally, and SHOULD authenticate all connections. - McpAsyncClient.java (MCP Java SDK): the exact
Server does not provide the resources capabilitymessage, and that the no-argumentlistTools(),listResources()andlistPrompts()follow everynextCursorbefore returning. - McpClient.java (MCP Java SDK): the SDK's own
initializationTimeoutof 20 seconds, which is separate fromrequestTimeout. - McpClientAutoConfiguration.java (Spring AI): that the bean method is
mcpSyncClients, that the starter callsclient.initialize()itself, and that it sets onlyrequestTimeoutand leaves the initialization timeout alone. - McpClientAutoConfiguration.CloseableMcpSyncClients (Spring AI javadoc): that the closeable wrapper is a published bean type implementing
AutoCloseablewhoseclose()closes each client.