Escrever o primeiro MCP server com Java e Spring Boot

1. Visão geral

Model Context Protocol (MCP) é um protocolo de comunicação que permite que assistentes de IA (Claude, Copilot, etc.) chamem tools, leiam recursos do seu sistema de forma estruturada. Este artigo guia você na escrita de um MCP server em Java 21, expõe uma tool de consulta de pedidos (lookup_order), e testa a conexão com um cliente MCP via transporte stdio. Depois de ler, você consegue construir um MCP server em funcionamento, escrever testes para o handler da tool, e conhecer os erros mais comuns ao começar.

2. O que é MCP e quando você precisa escrever um servidor

MCP define três conceitos principais que um servidor pode expor:

  • Tool: uma função que o modelo de IA pode chamar, com um schema de entrada claro (similar ao function calling).
  • Resource: dados estáticos ou dinâmicos que o cliente pode ler (arquivo, configuração, registros de BD…).
  • Prompt: template de prompt pré-construído para o cliente reutilizar.

Você precisa escrever um MCP server quando quer que o assistente de IA interaja diretamente com seu sistema interno — por exemplo, consultar pedidos, criar tickets, consultar logs — sem precisar copiar e colar dados manualmente no chat.

Observação: Se você só precisa que o modelo responda com base em texto estático, usar RAG ou prompts convencionais será muito mais simples do que construir um MCP server inteiro.

3. Preparando o projeto

3.1. Dependências Maven

O MCP Java SDK fornece classes prontas para construir o servidor e o transporte. Adicione a seguinte dependência ao pom.xml:

<dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp</artifactId>
    <version>0.8.1</version>
</dependency>

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Jackson é usado para serializar/desserializar mensagens JSON-RPC que MCP usa internamente.

3.2. Modelo de domínio simples

Antes de escrever a tool, você precisa ter dados para ela retornar. Use um Order simulado:

public record Order(String id, String status, double total) {
}
@Service
public class OrderService {

    private final Map<String, Order> orders = Map.of(
        "ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
        "ORD-002", new Order("ORD-002", "PENDING", 120_000)
    );

    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(orders.get(orderId));
    }
}

OrderService é apenas um mapa em memória para demonstração — em um projeto real, você chamaria um repositório ou API.

4. Definindo a primeira tool: lookup_order

Cada tool no MCP precisa de um nome, descrição (o modelo usa a descrição para decidir se deve chamar a tool), e um schema de entrada em formato JSON Schema.

public final class OrderTools {

    public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
        "lookup_order",
        "Consultar status e valor total de um pedido pelo código do pedido",
        """
        {
          "type": "object",
          "properties": {
            "orderId": {
              "type": "string",
              "description": "Código do pedido, por exemplo ORD-001"
            }
          },
          "required": ["orderId"]
        }
        """
    );
}
  • "lookup_order" é o nome da tool, o cliente usará exatamente essa string para chamá-la.
  • A descrição na segunda sentença é muito importante: o modelo se baseia nela para saber quando chamar essa tool em vez de outra.
  • O schema exige que orderId seja um campo obrigatório do tipo string.

Agora, escreva o handler que executa a tool — recebe parâmetros, chama OrderService, e retorna CallToolResult:

@Component
public class OrderToolHandler {

    private final OrderService orderService;

    public OrderToolHandler(OrderService orderService) {
        this.orderService = orderService;
    }

    public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
        String orderId = (String) arguments.get("orderId");

        return orderService.findById(orderId)
            .map(order -> success(order))
            .orElseGet(() -> error("Pedido não encontrado: " + orderId));
    }

    private McpSchema.CallToolResult success(Order order) {
        String text = "Pedido %s: status %s, total %.0f reais"
            .formatted(order.id(), order.status(), order.total());
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
    }

    private McpSchema.CallToolResult error(String message) {
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
    }
}

O segundo parâmetro de CallToolResult é isError. Quando true, o cliente sabe que a tool falhou e pode informar novamente ao modelo para tentar outra coisa, em vez de tratá-lo como um resultado válido.

5. Inicializando o MCP server com transporte stdio

O transporte mais comum ao começar a escrever um MCP server é stdio: o servidor lê requisições de stdin e escreve respostas em stdout. É também o modo como Claude Desktop inicia um servidor local.

@Component
public class McpOrderServer implements CommandLineRunner {

    private final OrderToolHandler toolHandler;

    public McpOrderServer(OrderToolHandler toolHandler) {
        this.toolHandler = toolHandler;
    }

    @Override
    public void run(String... args) {
        McpServerTransportProvider transportProvider =
            new StdioServerTransportProvider(new ObjectMapper());

        McpServerFeatures.SyncToolSpecification lookupOrderSpec =
            new McpServerFeatures.SyncToolSpecification(
                OrderTools.LOOKUP_ORDER,
                (exchange, arguments) -> toolHandler.handle(arguments)
            );

        McpSyncServer server = McpServer.sync(transportProvider)
            .serverInfo("nonla-order-server", "1.0.0")
            .capabilities(McpSchema.ServerCapabilities.builder()
                .tools(true)
                .build())
            .tools(lookupOrderSpec)
            .build();

        server.start();
    }
}
  • StdioServerTransportProvider transforma stdin/stdout do processo em um canal de comunicação JSON-RPC.
  • serverInfo(...) declara o nome e versão — o cliente exibirá essas informações quando listar servidores conectados.
  • capabilities().tools(true) informa ao cliente que esse servidor expõe tools, o cliente enviará tools/list para obter a lista.
  • server.start() bloqueia o processo, aguardando requisições do cliente via stdin.

Observação: Como o transporte stdio mantém o processo vivo bloqueando, se executado em um aplicativo web Spring Boot normal (com @SpringBootApplication com servidor web), você deve desabilitar o servidor web (spring.main.web-application-type=none em application.properties) para evitar conflito de portas e logs se misturando no stdout.

spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=

Banner e logs do console devem ser completamente desativados — MCP usa stdout como canal de transmissão de dados, qualquer linha de log que se misture nele vai corromper as mensagens JSON-RPC.

6. Adicionar resource: expor dados estáticos

Além de tools, você pode expor resources para o cliente ler diretamente (sem precisar chamar via modelo):

McpSchema.Resource policyResource = new McpSchema.Resource(
    "file:///policies/return-policy.md",
    "Política de devolução",
    "Conteúdo da política de devolução de produtos",
    "text/markdown",
    null
);

McpServerFeatures.SyncResourceSpecification policySpec =
    new McpServerFeatures.SyncResourceSpecification(
        policyResource,
        (exchange, request) -> new McpSchema.ReadResourceResult(
            List.of(new McpSchema.TextResourceContents(
                policyResource.uri(),
                "text/markdown",
                "# Política de devolução\nClientes podem devolver produtos em até 7 dias."
            ))
        )
    );

Adicionar .resources(policySpec) ao builder na etapa 5 e o servidor já expõe esse resource adicional.

7. Testando com Claude Desktop

Faça build do jar e configure Claude Desktop (ou outro cliente MCP) para apontar para seu servidor:

{
  "mcpServers": {
    "nonla-order-server": {
      "command": "java",
      "args": ["-jar", "/path/to/order-mcp-server.jar"]
    }
  }
}

Reinicie o cliente, abra uma nova conversa e pergunte: “Qual é o status do pedido ORD-001?”. O modelo decidirá por si só chamar lookup_order com orderId = "ORD-001", o servidor retorna o texto, e o modelo usa esse resultado para respondê-lo.

8. Escrevendo testes para o handler da tool

A parte mais importante da lógica — processar parâmetros e retornar CallToolResult — é completamente testável como código Java comum, sem precisar de um transporte real.

class OrderToolHandlerTest {

    private final OrderService orderService = new OrderService();
    private final OrderToolHandler handler = new OrderToolHandler(orderService);

    @Test
    void shouldReturnOrderInfo_whenOrderExists() {
        var result = handler.handle(Map.of("orderId", "ORD-001"));

        assertThat(result.isError()).isFalse();
        assertThat(result.content())
            .hasSize(1)
            .first()
            .isInstanceOfSatisfying(McpSchema.TextContent.class,
                content -> assertThat(content.text()).contains("SHIPPED"));
    }

    @Test
    void shouldReturnError_whenOrderNotFound() {
        var result = handler.handle(Map.of("orderId", "ORD-999"));

        assertThat(result.isError()).isTrue();
    }
}

O primeiro teste verifica um pedido válido: isError() deve ser false e o conteúdo do texto deve conter o status SHIPPED. O segundo teste garante que quando o pedido não é encontrado, isError() retorna true — esse é um sinal importante para que o cliente/modelo saiba que precisa de um tratamento diferente.

✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound

9. Erros mais comuns

  • Logs se misturando no stdout: usar transporte stdio e esquecer de desabilitar banner/logs do console fará com que o cliente receba mensagens JSON-RPC corrompidas, erros de parse muito difíceis de debugar. Sempre verifique se stdout contém apenas mensagens MCP redirecionando para um arquivo ao testar manualmente.
  • Descrição de tool muito superficial: se a descrição apenas disser “lookup order”, o modelo muitas vezes não sabe quando chamá-la. Escreva a descrição deixando claro quais são as entradas/saídas esperadas.
  • Input schema sem required: o modelo pode chamar a tool faltando campos obrigatórios, o handler pode sofrer NullPointerException ao fazer cast dos parâmetros. Sempre valide os parâmetros de entrada no handler, não confie completamente no schema.
  • Usar servidor async (McpServer.async) mas não fazer await corretamente: fácil sofrer race condition quando múltiplas requisições de tool chegam juntas. Se você não está familiarizado com reactive, comece com McpServer.sync como neste artigo.

Quando não escrever um MCP server separado: se o seu sistema já tem uma REST API estável e só precisa de um cliente chamando diretamente, adicionar uma camada MCP é redundante. MCP vale a pena investir apenas quando você quer que múltiplos clientes de IA diferentes (Claude Desktop, plugin de IDE, agentes personalizados) possam usar as mesmas tools sem precisar integrar separadamente com cada um.

10. Conclusão

Você acabou de construir um MCP server Java completo: declarar tools com schema claro, escrever handlers que processam a lógica, expor recursos adicionais, e testar handlers independentemente do transporte. O próximo passo natural é tentar o transporte SSE para rodar o servidor via HTTP em vez de stdio, ou adicionar templates de prompt para tarefas repetidas comuns no seu domínio.

mcpjavaspring-bootaitool-calling