Skip to main content

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
Companion code

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.clientWhat it setsDefaultThis file
namethe client name sent in the initialize requestspring-ai-mcp-clientsupport-agent
versionthe client version sent in the same request1.0.01.0.0
request-timeouthow long any request on any connection may take20s30s
typewhether the clients are SYNC or ASYNCSYNCleft at the default
toolcallback.enabledhands the discovered tools to ChatClient in Class 7trueleft at the default
streamable-http.connections.orders.urlthe base address of one servernonehttp://localhost:8080
streamable-http.connections.orders.endpointthe path added to url/mcpleft 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::

A server that publishes on /api/mcp instead
        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.


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:

You run this, in one terminal
mvn -pl order-service spring-boot:run

It logs Registered tools: 4 and then Started OrderServiceApplication. Once that appears, in a second terminal:

You run this, in another 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());
Your IDE will warn about closing the client

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:

You run this, in the agent's terminal
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:

StepClass 2, by hand with curlClass 6, through McpSyncClient
Build the requesta JSON-RPC body typed into -dCallToolRequest.builder("get_order").arguments(...)
Attach the sessionthe Mcp-Session-Id from the initialize reply, pasted into the commandthe client tracks the session ID itself
Send itcurl to http://localhost:8080/mcpclient.callTool(...) over the connection named orders
Read the replyJSON text on the terminala 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 seeWhat it meansWhat to check
Client failed to initialize by explicit API call while the agent starts, with java.net.ConnectException: Connection refused further down the stack traceorder-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 therestart order-service first, and wait for Started OrderServiceApplication before you start the agent
about 20 seconds of silence, then a timeoutthe 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 itthe 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 waitthe host refused the connection outrightthe 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

Sources

  • MCP Client Boot Starter :: Spring AI Reference: the defaults in the property table: name is spring-ai-mcp-client, version is 1.0.0, request-timeout is 20s, type is SYNC, toolcallback.enabled is true and endpoint is /mcp. It also names the four transports the standard starter supports, with the SSE and Streamable HTTP ones built on the JDK HttpClient.
  • MCP specification 2025-11-25: Lifecycle: that the initialize response carries protocolVersion, capabilities, serverInfo and an optional instructions string, 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 structuredContent as well as unstructured content blocks. It also gives the rule that a failing tool reports the failure with isError true, so the client can pass it to the model.
  • MCP specification 2025-11-25: Resources: that resource templates are listed by resources/templates/list and do not appear in resources/list.
  • MCP specification 2025-11-25: Transports: the security rules for Streamable HTTP, that a server MUST validate the Origin header, SHOULD bind only to 127.0.0.1 when it runs locally, and SHOULD authenticate all connections.
  • McpAsyncClient.java (MCP Java SDK): the exact Server does not provide the resources capability message, and that the no-argument listTools(), listResources() and listPrompts() follow every nextCursor before returning.
  • McpClient.java (MCP Java SDK): the SDK's own initializationTimeout of 20 seconds, which is separate from requestTimeout.
  • McpClientAutoConfiguration.java (Spring AI): that the bean method is mcpSyncClients, that the starter calls client.initialize() itself, and that it sets only requestTimeout and leaves the initialization timeout alone.
  • McpClientAutoConfiguration.CloseableMcpSyncClients (Spring AI javadoc): that the closeable wrapper is a published bean type implementing AutoCloseable whose close() closes each client.