Você subiu o servidor MCP, expôs as tools direitinho, testou com o inspector e estava tudo lá. Aí foi plugar o seu app Java como cliente e nada. O listTools() volta uma lista vazia, ou pior, a chamada fica pendurada até estourar o timeout. Você revisa o servidor, revisa de novo, e o servidor está certo. O problema está do lado do cliente, e quase sempre tem nome: transporte errado.
Esse é o erro número 1 de quem escreve um cliente MCP em Java hoje. Usar o SSE legado contra um servidor que fala Streamable HTTP, ou chamar listTools() antes do initialize(). A conexão até abre, mas nenhuma capability é negociada, e tool nenhuma aparece. Bora resolver isso de vez, com o código certo para cada caso, usando o MCP Java SDK 2.0 que saiu como GA em junho.
O que é o cliente MCP, e por que o transporte importa tanto
O Model Context Protocol (MCP) é um protocolo aberto para conectar modelos de IA a ferramentas e dados externos. Tem dois lados: o servidor, que expõe tools, resources e prompts, e o cliente, que descobre e invoca essas tools. No artigo anterior sobre o lado servidor com anotações Spring AI eu mostrei como expor as capacidades. Hoje a gente vai para o outro lado da conversa: o cliente Java.
Por baixo, MCP é JSON-RPC 2.0 sobre uma conexão com estado. E "com estado" é a palavra-chave. Antes de qualquer coisa útil, cliente e servidor fazem um handshake para negociar capacidades. Quem entrega esses bytes de um lado para o outro é o transporte. E é exatamente aí que a maioria tropeça, porque a spec atual (revisão 2025-11-25) define três transportes diferentes, e eles não são intercambiáveis.
- STDIO: o servidor roda como subprocesso local, comunicação por entrada e saída padrão. Ótimo para ferramentas locais e CLIs.
- Streamable HTTP: o transporte HTTP moderno, para servidores remotos ou em rede. É o que você quer na maioria dos casos de produção.
- SSE (Server-Sent Events): o transporte HTTP legado. Ainda suportado, mas é o que sobra, não o que você escolhe para um projeto novo.
Pensa numa tomada. Você pode ter o melhor aparelho do mundo, se o plugue não bate com a tomada, não liga. Escolher SSE para falar com um servidor que só expõe o endpoint Streamable HTTP é tentar enfiar o plugue errado. A conexão TCP até estabelece, o cliente fica esperando o stream de eventos que nunca vem no formato esperado, e você fica olhando para um listTools() vazio sem entender o porquê.
O jeito errado: o código que parece certo mas não conecta
Veja o caso clássico. O dev pega o primeiro exemplo que achou no Google, que provavelmente era de SSE, aponta para o servidor moderno e ainda esquece o handshake. Olha como isso fica:
var transport = HttpClientSseClientTransport
.builder("http://localhost:8080/mcp") // servidor moderno nao fala SSE aqui
.build();
McpSyncClient client = McpClient.sync(transport).build();
// Pulou o initialize() e ja foi listar as tools
ListToolsResult tools = client.listTools(); // conexao abre, nada e negociado
System.out.println(tools.tools().size()); // imprime 0, ou estoura timeout
Dois erros num bloco só. O primeiro é o transporte: HttpClientSseClientTransport para um servidor que espera Streamable HTTP. O segundo é mais sutil e pega gente experiente: chamar listTools() sem ter chamado initialize() antes. Sem o handshake, o servidor nunca anuncia que tem a capability de tools. O cliente pergunta no escuro e recebe o vazio.
Na empresa em que trabalhei, quando plugamos nosso primeiro agente Java num servidor MCP de dados interno, foi exatamente esse o nó. Passamos quase uma tarde achando que o servidor estava bugado. O servidor estava perfeito. O cliente é que estava falando o protocolo errado e pulando etapa. Resolveu em duas linhas.
O jeito certo: Streamable HTTP, handshake e callTool
Primeiro, a dependência. O MCP Java SDK 2.0.0 saiu como GA em 11 de junho de 2026, o primeiro release maior desde a linha 1.x. Ele roda em Java 17+, cobre cliente e servidor, síncrono e assíncrono, e rastreia a spec 2025-11-25. No Maven é uma coordenada só, que já traz o core e o Jackson 3:
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>2.0.0</version>
</dependency>
Agora o cliente do jeito certo. Transporte Streamable HTTP, handshake antes de tudo, e só então descobrir e invocar as tools. Repare na ordem, ela não é decoração:
// Jeito CERTO: transporte moderno e handshake na ordem
var transport = HttpClientStreamableHttpTransport
.builder("http://localhost:8080/mcp")
.build();
McpSyncClient client = McpClient.sync(transport).build();
client.initialize(); // negocia capacidades. SEM isto, listTools volta vazio
ListToolsResult tools = client.listTools();
tools.tools().forEach(t -> System.out.println(t.name()));
CallToolResult resultado = client.callTool(
CallToolRequest.builder("calculator")
.arguments(Map.of("op", "sum", "a", 2, "b", 3))
.build());
System.out.println(resultado.content()); // [TextContent[text=5]]
O fluxo é sempre esse, e vale gravar: criar o transporte, construir o cliente com McpClient.sync(transport).build(), chamar initialize(), e aí sim listTools() e callTool(). Os tipos de retorno são reais e ajudam o compilador a te proteger: ListToolsResult e CallToolResult. A requisição de tool sai por CallToolRequest.builder(nome).
E se o servidor for local, um binário ou container que você sobe como subprocesso? Aí o transporte certo é STDIO, não HTTP. Mesma API de cliente, só troca a peça do transporte:
// Servidor local como subprocesso: STDIO, nao HTTP
var params = ServerParameters.builder("docker")
.args("run", "-i", "--rm", "meu-servidor-mcp:latest")
.build();
var transport = new StdioClientTransport(params, McpJsonDefaults.getMapper());
McpSyncClient client = McpClient.sync(transport).build();
client.initialize();
Regra prática para nunca mais errar o transporte. Servidor remoto ou em rede, na sua infra ou de terceiro: Streamable HTTP. Servidor que você sobe como processo filho da própria aplicação: STDIO. SSE só se você está integrando com um servidor antigo que ainda não migrou. E não importa qual deles, o initialize() vem sempre antes de listar ou chamar qualquer coisa.
Os três caminhos: SDK puro, Spring AI e Quarkus
Tem três formas de ser um cliente MCP em Java no mercado de hoje, e a escolha depende do que já roda na sua stack. O SDK puro que você viu acima é a base. Mas se a sua aplicação já é Spring ou Quarkus, dá para deixar o framework cuidar do trabalho chato de fiação.
No Spring AI 2.0 (também GA, confirmado no roundup do InfoQ de junho), você adiciona o starter spring-ai-starter-mcp-client (ou a variante -webflux, recomendada para produção). O starter autoconfigura o McpSyncClient a partir do application.yml e expõe um SyncMcpToolCallbackProvider. Aí as tools do servidor MCP entram direto no seu ChatClient, e o modelo passa a chamar as ferramentas sozinho durante a conversa:
@RestController
class AgenteController {
private final ChatClient chatClient;
// O starter ja autoconfigurou o McpSyncClient e o provider de tools
AgenteController(ChatClient.Builder builder,
SyncMcpToolCallbackProvider mcpTools) {
this.chatClient = builder
.defaultToolCallbacks(mcpTools.getToolCallbacks())
.build();
}
@GetMapping("/perguntar")
String perguntar(@RequestParam String q) {
// o ChatClient ja pode invocar as tools do servidor MCP por conta propria
return chatClient.prompt(q).call().content();
}
}
No Quarkus, o cliente MCP vem pela extensão quarkus-langchain4j-mcp, parte do Quarkiverse e alinhada à mesma spec 2025-11-25. Você configura a conexão por propriedades e habilita as tools num AI Service com a anotação @McpToolBox:
// application.properties (transporte stdio, confirmado na doc):
// quarkus.langchain4j.mcp.dados.transport-type=stdio
// quarkus.langchain4j.mcp.dados.command=docker,run,-i,--rm,meu-servidor-mcp
@RegisterAiService
interface Agente {
@McpToolBox("dados") // habilita as tools do cliente MCP nomeado "dados"
String responder(String pergunta);
}
Repare que o conceito não muda entre os três. Sempre tem um transporte, um handshake e uma descoberta de tools. O Spring e o Quarkus só escondem essa mecânica para você. Quando algo dá errado neles, é quase sempre o mesmo culpado do SDK puro: o tipo de transporte configurado não bate com o que o servidor expõe.
Checklist de diagnóstico quando ainda não conecta
Trocou o transporte, colocou o initialize() e mesmo assim o listTools() volta vazio? Não entra no modo tentativa e erro. Segue essa ordem, que resolve a grande maioria dos casos:
- Confirme o endpoint exato. Streamable HTTP costuma viver num path específico, tipo
/mcp. Bater na raiz do host não resolve. - Teste o servidor na unha primeiro. Um
curlno endpoint já te diz se é 404 (path errado), 401 (autenticação) ou se responde. Antes de culpar o SDK, veja se o servidor está de pé. - Logue o retorno do initialize(). Ele devolve as capabilities negociadas. Se tools não veio ali, o problema mora no servidor, não no cliente.
- Confirme que o servidor registrou tools. Servidor no ar mas sem tool nenhuma registrada devolve lista vazia com tudo certinho do lado de cá.
- Suba o timeout para servidores lentos. Um subprocesso STDIO que demora a iniciar pode estourar o tempo padrão antes do handshake terminar.
Uma coisa boba que economiza horas: logue o que o initialize() retorna logo na largada. Esse retorno é a verdade sobre o que os dois lados combinaram. Se a tool que você espera não aparece na lista de capabilities ali, não adianta insistir no callTool(). O contrato não fechou, e o resto é consequência.
O que muda na sua prática
Saber o lado cliente do MCP destrava uma coisa concreta: o seu código Java deixa de ser só mais um serviço REST e vira algo que conversa com agentes de IA e com qualquer ferramenta que fale MCP. O mesmo cliente que pluga no seu servidor interno pluga num servidor de terceiro amanhã, sem reescrever protocolo.
Quando usar cada caminho? Se você quer o controle total, uma lib pequena ou um job que não é Spring nem Quarkus, vá de SDK puro (Java 17+, dependência única). Se a aplicação já é Spring e você quer o LLM chamando as tools sozinho, o Spring AI client starter economiza o boilerplate todo. Se o seu mundo é Quarkus e LangChain4j, a extensão quarkus-langchain4j-mcp já entrega cliente nomeado e injeção via CDI.
E quando não usar? Se o seu servidor é local e roda no mesmo processo, talvez você nem precise de MCP, uma chamada de método direta resolve. MCP brilha quando há uma fronteira de processo ou de rede entre quem chama e quem executa. Forçar o protocolo onde não há essa fronteira é complexidade sem retorno.
Perguntas frequentes
Preciso usar Spring para ter um cliente MCP em Java?
Não. O MCP Java SDK 2.0.0 é standalone, roda em Java 17+ e cobre cliente e servidor sem depender de framework nenhum. Spring AI e Quarkus são camadas de conveniência por cima, úteis quando você já está nesses ecossistemas.
O SSE foi descontinuado?
Não foi removido, mas é considerado o transporte HTTP legado na spec 2025-11-25. Para projeto novo, escolha Streamable HTTP. Mantenha SSE apenas para falar com servidores antigos que ainda não migraram.
Meu listTools() volta vazio mesmo depois do initialize(). E agora?
Cheque três coisas, nessa ordem: a URL do endpoint está correta e aponta para o caminho que o servidor expõe, o transporte do cliente bate com o do servidor (Streamable HTTP contra Streamable HTTP), e o servidor de fato registrou tools. Um servidor sem tools registradas devolve lista vazia mesmo com tudo certo do lado cliente.
Dá para usar o cliente MCP com qualquer LLM?
O cliente MCP é agnóstico de modelo. Ele só descobre e invoca tools. A ponte com um LLM específico vem da camada de cima: o ChatClient no Spring AI ou o AI Service no Quarkus, que decidem quando chamar cada tool.
STDIO ou HTTP, qual escolher?
STDIO quando o servidor é um subprocesso local da sua aplicação. Streamable HTTP quando o servidor está em outra máquina ou exposto em rede. A regra é a fronteira: processo filho usa STDIO, fronteira de rede usa HTTP.
Resumo e próximo passo
- Transporte certo é metade da batalha: Streamable HTTP para rede, STDIO para subprocesso, SSE só como legado.
- initialize() sempre antes de listTools() ou callTool(), senão nenhuma capability é negociada e a lista volta vazia.
- Três caminhos, um conceito: SDK 2.0.0 puro, Spring AI client starter ou Quarkus/LangChain4j, todos sobre a mesma spec 2025-11-25.
Você já plugou um app Java como cliente num servidor MCP? Travou no transporte ou no handshake como a gente travou aqui? Conta nos comentários como foi na sua stack, quero saber se o vilão foi o mesmo.
Na próxima a gente vai um passo além: o lado Sampling e Elicitation, onde o servidor pede de volta uma decisão do modelo ou uma informação do usuário durante a execução da tool. É aí que o cliente MCP em Java deixa de ser só consumidor e vira parte ativa da conversa. Para revisar o lado servidor antes disso, dá uma olhada no artigo sobre tool calling no Spring AI 2.0 e no de o que cai em entrevista sênior sobre Spring AI.
Fontes oficiais: MCP Java SDK 2.0.0 (releases), spec MCP 2025-11-25, Spring AI MCP Client Boot Starter e Quarkus LangChain4j MCP.