Écrire votre premier serveur MCP en Java et Spring Boot

1. Aperçu

Model Context Protocol (MCP) est un standard de communication qui permet aux assistants IA (Claude, Copilot, etc.) d’appeler des tools, de lire des resources de votre système de manière structurée. Cet article vous guide pour écrire un serveur MCP en Java 21, exposer un tool de recherche de commande (lookup_order), et le tester avec un client MCP via stdio transport. Après sa lecture, vous aurez un serveur MCP fonctionnant réellement, saurez écrire des tests pour votre handler de tool, et connaîtrez les erreurs courantes quand on débute.

2. Qu’est-ce que MCP et quand avez-vous besoin d’écrire un serveur

MCP définit trois concepts principaux qu’un serveur peut exposer :

  • Tool : une fonction que le modèle IA peut appeler, avec un schema d’entrée bien défini (comme un function calling).
  • Resource : des données statiques ou dynamiques que le client peut lire (fichiers, config, enregistrements de BD…).
  • Prompt : des templates de prompt prédéfinis que le client peut réutiliser.

Vous avez besoin d’écrire un serveur MCP quand vous voulez qu’un assistant IA manipule directement votre système interne — par exemple consulter une commande, créer un ticket, interroger des logs — sans avoir à copier-coller manuellement les données dans le chat.

Attention : si vous avez seulement besoin que le modèle réponde en fonction de texte statique, utiliser RAG ou un prompt classique sera beaucoup plus simple qu’un serveur MCP complet.

3. Préparation du projet

3.1. Dépendances Maven

Le SDK MCP pour Java fournit déjà les classes pour construire un serveur et son transport. Ajoutez cette dépendance dans 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 sert à sérialiser/désérialiser les messages JSON-RPC que MCP utilise en interne.

3.2. Modèle de données simple

Avant d’écrire un tool, vous avez besoin de données que le tool retournera. Utilisez une simple Order simulée :

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 n’est qu’une Map en mémoire pour cette démo — dans un vrai projet, vous appelleriez un repository ou une API.

4. Définir le premier tool : lookup_order

Chaque tool dans MCP a besoin d’un nom, d’une description (le modèle l’utilise pour décider s’il doit appeler ce tool), et d’un schema d’entrée au format JSON Schema.

public final class OrderTools {

    public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
        "lookup_order",
        "Chercher le statut et le montant total d'une commande par son orderId",
        """
        {
          "type": "object",
          "properties": {
            "orderId": {
              "type": "string",
              "description": "Code de la commande, par exemple ORD-001"
            }
          },
          "required": ["orderId"]
        }
        """
    );
}
  • "lookup_order" est le nom du tool, le client l’utilisera avec exactement cette chaîne pour l’appeler.
  • La deuxième description est très importante : le modèle s’en sert pour décider quand appeler ce tool plutôt qu’un autre.
  • Le schema impose que orderId soit un champ requis de type string.

Ensuite, écrivez le handler qui exécute le tool — il reçoit les paramètres, appelle OrderService, et retourne un 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("Commande introuvable : " + orderId));
    }

    private McpSchema.CallToolResult success(Order order) {
        String text = "Commande %s : statut %s, montant total %.0f €"
            .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);
    }
}

Le deuxième paramètre de CallToolResult est isError. Quand il vaut true, le client sait que le tool a échoué et peut le signaler au modèle pour qu’il essaie une autre approche, au lieu de traiter cela comme un résultat valide.

5. Initialiser le serveur MCP avec stdio transport

Le transport le plus courant au début est stdio : le serveur lit les requêtes depuis stdin, écrit les réponses vers stdout. C’est aussi la façon dont Claude Desktop démarre un serveur 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 transforme stdin/stdout du process en canal de communication JSON-RPC.
  • serverInfo(...) déclare le nom et la version — le client affichera ces informations quand on liste les serveurs connectés.
  • capabilities().tools(true) indique au client que ce serveur expose des tools, il enverra tools/list pour récupérer la liste.
  • server.start() bloque le processus, en attente de requêtes du client via stdin.

Attention : puisque stdio transport garde le processus actif en le bloquant, si vous le lancez dans une app web Spring Boot classique (avec @SpringBootApplication et un serveur web), vous devriez désactiver le serveur web (spring.main.web-application-type=none dans application.properties) pour éviter des conflits de port et que les logs se mélangent à stdout.

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

La bannière et les logs console doivent être complètement désactivés — MCP utilise stdout comme canal de transmission de données, tout log qui s’y mêle cassera le message JSON-RPC.

6. Ajouter une resource : exposer des données statiques

En plus des tools, vous pouvez exposer des resources que le client lira directement (sans passer par le modèle) :

McpSchema.Resource policyResource = new McpSchema.Resource(
    "file:///policies/return-policy.md",
    "Politique de retour",
    "Contenu de la politique de retour de marchandise",
    "text/markdown",
    null
);

McpServerFeatures.SyncResourceSpecification policySpec =
    new McpServerFeatures.SyncResourceSpecification(
        policyResource,
        (exchange, request) -> new McpSchema.ReadResourceResult(
            List.of(new McpSchema.TextResourceContents(
                policyResource.uri(),
                "text/markdown",
                "# Politique de retour\nLes clients peuvent retourner dans les 7 jours."
            ))
        )
    );

Ajoutez .resources(policySpec) au builder de l’étape 5 et le serveur expose désormais cette resource supplémentaire.

7. Tester avec Claude Desktop

Buildez le jar puis configurez Claude Desktop (ou un autre client MCP) pour pointer vers votre serveur :

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

Redémarrez le client, ouvrez une nouvelle conversation et posez la question : “Quel est le statut de la commande ORD-001 ?”. Le modèle décidera automatiquement d’appeler lookup_order avec orderId = "ORD-001", le serveur retournera le texte, et le modèle utilisera ce résultat pour vous répondre.

8. Écrire des tests pour le handler du tool

La partie logique la plus importante — traiter les paramètres et retourner un CallToolResult — est testable comme du code Java classique, sans transport réel.

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();
    }
}

Le premier test vérifie une commande valide : isError() doit être false et le contenu texte doit contenir le statut SHIPPED. Le second test assure que quand aucune commande n’est trouvée, isError() retourne true — c’est un signal important pour que le client/modèle sache qu’il faut procéder différemment.

✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound

9. Erreurs courantes

  • Les logs se mélangent à stdout : si vous utilisez stdio transport mais oubliez de désactiver la bannière/les logs console, le client recevra un message JSON-RPC cassé, et l’erreur parse est très difficile à debugger. Vérifiez toujours que stdout ne contient que les messages MCP en redirigeant vers un fichier quand vous testez manuellement.
  • La description du tool est trop vague : si vous écrivez juste “lookup order”, le modèle ne sait souvent pas quand l’appeler. Écrivez une description claire avec les entrées/sorties attendues.
  • Le schema d’entrée manque required : le modèle peut appeler le tool sans un champ obligatoire, et le handler se prend une NullPointerException quand il cast le paramètre. Validez toujours les paramètres en entrée du handler, ne faites pas confiance uniquement au schema.
  • Utiliser un serveur async (McpServer.async) mais sans attendre correctement : risque de race condition quand plusieurs requêtes tool arrivent en même temps. Si vous débutez avec la programmation réactive, commencez avec McpServer.sync comme dans cet article.

Quand ne pas écrire un serveur MCP séparé : si votre système a déjà une API REST stable et qu’un seul client a besoin de l’appeler, ajouter une couche MCP est redondant. MCP vaut vraiment le coup quand plusieurs clients IA différents (Claude Desktop, plugin IDE, agent écrit à la main) veulent utiliser les mêmes tools sans intégration séparée pour chacun.

10. Conclusion

Vous venez de construire un serveur MCP Java complet : déclarer un tool avec un schema clair, écrire un handler traitant la logique, exposer une resource supplémentaire, et tester le handler indépendamment du transport. L’étape suivante naturelle est d’essayer SSE transport pour lancer le serveur via HTTP au lieu de stdio, ou d’ajouter des templates de prompt pour les tâches répétitives fréquentes dans votre domaine.

mcpjavaspring-bootaitool-calling