Cliente MCP em Java: erro de transporte (SSE vs Streamable HTTP) e o codigo certo com o MCP Java SDK 2.0 - Meu Universo Nerd

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:

  1. Confirme o endpoint exato. Streamable HTTP costuma viver num path específico, tipo /mcp. Bater na raiz do host não resolve.
  2. Teste o servidor na unha primeiro. Um curl no 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é.
  3. 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.
  4. Confirme que o servidor registrou tools. Servidor no ar mas sem tool nenhuma registrada devolve lista vazia com tudo certinho do lado de cá.
  5. 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.