Class 6: Connecting a Client
Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 5: Prompts and Completion. Still no API key and no model.
What We'll Cover
- A second module, and why the agent is a separate process
- The MCP client starter, and the dependency that is easy to miss
- Configuring a Streamable HTTP connection
- What arrives at startup: the handshake, the tool list, the schemas
- Calling a tool from Java through
McpSyncClient
A Second Module
Everything so far has been one application that answers MCP requests. The other half of the protocol is the application that sends them, and it is a separate process.
Keeping them separate is not a teaching device. 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.
Add the module to the parent pom.xml:
<modules>
<module>order-service</module>
<module>support-agent</module>
</modules>
Create support-agent/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
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>
<!-- Publishes the ObjectMapper bean the MCP client needs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
</dependencies>
</project>
spring-ai-starter-mcp-client uses the JDK's HttpClient and supports stdio, Streamable HTTP and SSE. There is also spring-ai-starter-mcp-client-webflux, which Class 15 covers.
Then the application class, support-agent/src/main/java/com/themcpguy/supportdesk/agent/SupportAgentApplication.java:
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);
}
}
The JSON starter is worth explaining, because leaving it out produces a confusing failure. Spring AI's MCP client depends on Jackson and uses it internally, so the Jackson classes are on the classpath and the code compiles. But autoconfiguration only publishes an ObjectMapper bean when the JSON starter is present, and spring-boot-starter alone does not bring it in. Without this dependency the application compiles, then fails at startup saying it cannot find a bean of a type that is sitting on the classpath.
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
main:
banner-mode: "off"
logging:
level:
root: WARN
com.themcpguy: INFO
Four things there deserve a note.
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. Class 10 adds more names.
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:.
request-timeout is global. It applies to every connection; Spring AI 2.0.x has no per-connection setting. The default is 20 seconds. It needs to cover the slowest legitimate call, which Class 11 makes concrete with a job that works through 200 orders.
banner-mode: "off" is quoted, and has to be. Unquoted, YAML reads off as the boolean false, and binding false to the Banner.Mode enum fails at startup with Failed to bind properties under 'spring.main.banner-mode'.
Two defaults are already what we want and do not appear above. spring.ai.mcp.client.type is SYNC, and spring.ai.mcp.client.toolcallback.enabled is true, which is what makes discovered tools available to ChatClient in Class 7.
Look at What Arrived
The client connects at startup and calls tools/list before our code runs. Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/McpInspector.java to print what came back:
package com.themcpguy.supportdesk.agent;
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 guards look redundant here. They are not: the filesystem server in Class 9 advertises tools and nothing else, and calling listResources() on it fails with IllegalStateException: Server does not provide the resources capability rather than returning an empty list. The handshake said what the server has, and asking for anything else is an error.
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.
Start order-service first, then the agent:
mvn -pl order-service spring-boot:run
mvn -pl support-agent spring-boot:run
Connected to order-service 1.0.0
tool get_order
tool get_customer_orders
tool get_orders_by_status
tool update_order_status
resource policy://returns
resource policy://shipping
prompt draft_refund_email
That is everything Classes 2 to 5 built, seen from the other side. Nothing in support-agent names an order or a policy: the list came over the wire during the handshake.
Note that policy://returns and policy://shipping appear but order://{orderId} does not. listResources() returns fixed resources; templates come from listResourceTemplates(). Class 8 uses both.
Call a Tool Without a Model
ChatClient arrives in Class 7. Before that, calling a tool directly shows that the connection carries a real request rather than only a list of names:
CallToolResult result = clients.getFirst().callTool(
new CallToolRequest("get_order", Map.of("orderId", "ORD-10001")));
System.out.println(result.content());
[TextContent[audience=null, priority=null, text={"orderId":"ORD-10001","status":"SHIPPED", ... }]]
The JSON is the same Order the REST controller returns and the same one curl produced in Class 2. It travelled as JSON-RPC over HTTP, through a client we configured in YAML and did not write.
This is also the shape a model's tool call takes. In Class 7 the model decides the name and the arguments; the call itself is the one above.
Troubleshooting
The agent starts but reports no tools. Check order-service is running and answering on http://localhost:8080/mcp. The client logs a failure at startup, which logging.level.root: WARN will show.
Startup fails on a missing ObjectMapper. The JSON starter is missing from the POM.
Nothing happens for 20 seconds and then a timeout. The default request-timeout applies to the handshake too, so an unreachable server takes that long to give up.
To watch the protocol itself, set logging.level.org.springframework.ai.mcp to DEBUG.
What We Built
Two applications. order-service exposes tools, resources and a prompt; support-agent connects to it, discovers all of them at startup, and calls one. Neither module imports the other, and the only thing they share is the protocol.
Class 7 hands the tool list to a model, which is the first class that needs 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.