Skip to content
tnsaiDocs
GitHub

MCP client

com.tnsai.mcp.client.McpClient talks to one MCP server over a transport. It lists and calls the server's tools, reads its resources and prompts, and answers the server's own requests for roots and LLM sampling. To hand a server's tools to an agent, use McpToolBridge (see Give an agent MCP tools).

Example

package com.example.tnsai.docs;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.mcp.McpServerConfig;
import com.tnsai.mcp.client.HttpTransport;
import com.tnsai.mcp.client.McpClient;
import com.tnsai.mcp.client.StdioTransport;
import com.tnsai.mcp.hub.McpToolBridge;
import com.tnsai.mcp.model.McpToolDefinition;
import com.tnsai.mcp.model.ModelPreferences;
import com.tnsai.mcp.model.ResourceContent;
import com.tnsai.mcp.model.Root;
import com.tnsai.mcp.model.SamplingRequest;
import com.tnsai.mcp.model.SamplingResponse;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/** Compilation contract for mcp/client.md. */
public final class McpClientExample {

    private McpClientExample() {
    }

    static String readFile() throws IOException {
        McpClient client = McpClient.builder()
            .transport(StdioTransport.builder()
                .command("npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp")
                .build())
            .clientName("MyApp")
            .clientVersion("1.0.0")
            .requestTimeout(Duration.ofSeconds(30))
            .roots(List.of(Root.fromPath(Path.of("/tmp"), "Scratch")))
            .build();

        client.connect();
        client.initialize();
        try {
            List<McpToolDefinition> tools = client.listTools();
            JsonNode content = client.executeTool("read_file", Map.of("path", "/tmp/notes.txt"));
            return tools.size() + " tools; " + content;
        } finally {
            client.disconnect();
        }
    }

    static String readResource(McpClient client) throws IOException {
        StringBuilder text = new StringBuilder();
        for (ResourceContent part : client.readResource("file:///tmp/notes.txt")) {
            if (part.isText()) {
                text.append(part.getText());
            }
        }
        return text.toString();
    }

    static JsonNode summarizePrompt(McpClient client, String longText) throws IOException {
        return client.getPrompt("summarize", Map.of("text", longText));   // the prompt's messages
    }

    static SamplingRequest samplingRequest() {
        return SamplingRequest.builder()
            .addUserMessage("What is the capital of France?")
            .systemPrompt("You are a geography expert.")
            .maxTokens(100)
            .temperature(0.7)
            .includeContext("thisServer")
            .modelPreferences(ModelPreferences.builder()
                .intelligencePriority(0.8)
                .speedPriority(0.5)
                .hint("claude-sonnet")
                .build())
            .build();
    }

    static McpClient withSampling(HttpTransport transport, Function<String, String> llm) {
        return McpClient.builder()
            .transport(transport)
            .samplingRequestHandler(request -> {
                String prompt = request.getMessages().get(request.getMessages().size() - 1)
                    .getContent().path("text").asText();
                SamplingResponse response = new SamplingResponse();
                response.setRole("assistant");
                response.setModel("my-model");
                response.setContent(JsonNodeFactory.instance.objectNode()
                    .put("type", "text")
                    .put("text", llm.apply(prompt)));
                response.setStopReason("endTurn");
                return response;
            })
            .build();
    }

    static AgentBuilder withMcpTools(AgentBuilder builder, String apiKey) throws IOException {
        McpToolBridge bridge = McpToolBridge.httpWithApiKey("https://mcp.example.com/mcp", apiKey);
        bridge.connect();                                 // fails here, not silently later
        return builder.dynamicTools(bridge.toTnsAITools());
    }

    static McpClient fromConfig() {
        McpServerConfig config = McpServerConfig.builder()
            .endpoint("https://api.example.com/mcp")
            .apiKey("xxx", "X-API-Key")                   // sends X-API-Key: xxx
            .header("X-Workspace", "docs")
            .timeoutSeconds(60)
            .build();
        return McpClient.create(HttpTransport.from(config));
    }
}

Connect

Every client goes through the same three steps:

  1. McpClient.builder()...build(), McpClient.create(transport) or new McpClient(transport). build() throws IllegalArgumentException without a transport.
  2. connect() opens the transport.
  3. initialize() exchanges capabilities and reads the server's name, version and capabilities. Every tool, resource, prompt, sampling and completion call before it throws McpException ("McpClient not initialized").

disconnect() closes the transport and clears the server info, so the next session needs initialize() again.

Builder methodDefaultMeaning
transport(t)none, requiredHow the client reaches the server
clientName(name)TnsAI-MCP-ClientSent in initialize
clientVersion(v)1.0.0Sent in initialize
requestTimeout(d)30 secondsHow long a request waits for its response
roots(list)noneThe roots the client advertises (see Roots)
samplingRequestHandler(fn)noneAnswers the server's sampling requests (see Sampling)

McpException extends IOException and carries the JSON-RPC error code in getCode().

Methods

MethodReturnsNotes
listTools()List<McpToolDefinition>
executeTool(name, args)JsonNodeThe result's content array when the server sends one, otherwise the whole result
listResources(), listResourceTemplates()List<Resource>, List<ResourceTemplate>Needs the server's resources capability
readResource(uri)List<ResourceContent>One entry per part; check isText() or isBlob()
subscribeResource(uri), unsubscribeResource(uri)voidNeeds resource subscription support
listPrompts()List<Prompt>Needs the server's prompts capability
getPrompt(name, args)JsonNodeargs is Map<String, String>; returns the prompt's messages
complete(request), completeResourceUri(partial), completePromptArgument(prompt, arg, partial)CompletionResponseArgument completion
createSamplingMessage(request)SamplingResponseAsks the server to sample
setLogLevel(level), setOnLogMessage(handler)voidServer log messages
getServerInfo(), getServerCapabilities()Set by initialize()
isConnected(), isInitialized()boolean

A resource or prompt method throws McpException ("Server does not support ...") when the server did not declare that capability.

Notifications

setOnToolsListChanged, setOnResourcesListChanged and setOnPromptsListChanged each take a Consumer<String> that runs when the server says that list changed. setOnNotification takes a Consumer<JsonNode> for other notifications.

Roots

Roots tell the server which locations it may work in. The client answers the server's roots/list request with its current roots. getRoots() returns a copy; setRoots, addRoot and removeRoot change them and, after initialize(), notify the server that the list changed.

A root's URI is a file URI. Root.fromPath(path, name) and Root.fromDirectory(path, name) build one from a path; new Root(uri, name) and Root.of(uri, name) take the URI as given.

Sampling

A server can ask the client for an LLM completion. The client handles that request with its sampling handler, a Function<SamplingRequest, SamplingResponse>:

  • initialize() advertises the sampling capability only when a handler is set on the builder. setSamplingRequestHandler replaces the handler later, but it does not advertise the capability.
  • Without a handler, or when the handler throws, the server gets JSON-RPC error -32603. Any other server request except roots/list gets -32601.

SamplingRequest.builder() takes addUserMessage, addAssistantMessage, addMessage, messages, systemPrompt, includeContext (none, thisServer or allServers), temperature, maxTokens, stopSequences, modelPreferences and metadata. build() throws IllegalStateException without a message or without maxTokens.

SamplingResponse has a role, content, model and stop reason, set with setters. getTextContent() reads the text from a text node, a {"type": "text"} object or an array of such parts.

Give an agent MCP tools

com.tnsai.mcp.hub.McpToolBridge wraps a client and turns each of the server's tools into a DynamicToolMethod. Pass the list to AgentBuilder.dynamicTools, as withMcpTools does; the agent then calls them like its own tools.

FactoryTransport
stdio(command...)StdioTransport
http(url), http(url, headers)HttpTransport
httpWithApiKey(url, apiKey)HttpTransport with Authorization: Bearer <apiKey>
custom(transport, name, description)Any transport
  • A bridge connects and lists the tools on first use. But toTnsAITools() logs a failed listing and returns an empty list, so call connect() first: it throws when the server cannot be reached.
  • Keep the bridge open while the agent runs; each tool calls the server through it. A server error reaches the agent as a RuntimeException ("MCP tool error: ...").
  • Each tool takes its description and input schema from the server. A destructiveHint annotation sets requiresConfirmation, and idempotentHint sets idempotent. When a tool requires confirmation and the agent has no tool call filter, the build reports validation warning AGENT-V006.

Server settings as data

com.tnsai.mcp.McpServerConfig holds an endpoint, headers and a timeout; HttpTransport.from(config) turns it into a transport, as fromConfig does.

  • McpServerConfig.of(endpoint) and of(endpoint, apiKey) cover the common cases. The builder requires endpoint; build() throws NullPointerException without it.
  • apiKey(key) sends Authorization: Bearer <key>. apiKey(key, header) sends the bare key in that header, unless the header is Authorization. Both skip a null or empty key.
  • The timeout is 30 seconds unless you set timeout or timeoutSeconds.
  • Without name, getName() uses the second-to-last part of the endpoint host (https://mcp.firecrawl.dev gives firecrawl), or mcp-server.
  • hasApiKey() checks only for an Authorization header, so a key in a custom header reads as false.