Articles de blog

Création d'un serveur Elasticsearch MCP avec TypeScript

Apprenez à créer un serveur MCP Elasticsearch avec TypeScript et Claude Desktop.

Lorsque vous travaillez avec de grandes bases de connaissances dans Elasticsearch, trouver des informations n’est que la moitié du travail. Les ingénieurs ont souvent besoin de synthétiser des résultats issus de plusieurs documents, de générer des résumés et de faire remonter les réponses à leur source. Model Context Protocol (MCP) fournit un moyen standardisé de connecter Elasticsearch à des applications alimentées par des grands modèles de langage (LLM) afin d’y parvenir. Bien qu’Elastic propose des solutions officielles, comme Elastic Agent Builder (qui inclut un point de terminaison MCP parmi ses fonctionnalités), la création d’un serveur MCP personnalisé vous offre un contrôle total sur la logique de recherche, la mise en forme des résultats et la manière dont le contenu récupéré est transmis à un LLM pour la synthèse, les résumés et les citations.

Dans cet article, nous examinerons les avantages de la création d’un serveur MCP Elasticsearch personnalisé et expliquerons comment en créer un en TypeScript pour connecter Elasticsearch aux applications alimentées par des modèles LLM.

Pourquoi créer un serveur Elasticsearch MCP personnalisé ?

Elastic propose quelques alternatives pour les serveurs MCP :

Si vous avez besoin de plus de contrôle sur la façon dont votre serveur MCP interagit avec Elasticsearch, la création de votre propre serveur personnalisé vous donne la flexibilité de l'adapter exactement à vos besoins. Par exemple, le point de terminaison MCP d'Agent Builder est limité aux requêtes du langage de requête Elasticsearch (ES|QL), tandis qu'un serveur personnalisé vous permet d'utiliser le langage de requête DSL complet. Vous gagnez également le contrôle sur la façon dont les résultats sont formatés avant d'être transmis au LLM et pouvez intégrer des étapes de traitement supplémentaires, comme la summarisation alimentée par OpenAI que nous mettrons en œuvre dans ce tutoriel.

À la fin de cet article, vous aurez un serveur MCP dans TypeScript qui recherche les informations stockées dans un index Elasticsearch, les résume et fournit des citations. Nous utiliserons Elasticsearch pour la récupération, le modèle gpt-4o-mini d'OpenAI pour résumer et générer des citations, et Claude Desktop comme client MCP et interface utilisateur pour recevoir les requêtes des utilisateurs et fournir des réponses. Le résultat final est un assistant de connaissances interne qui aide les ingénieurs à découvrir et à synthétiser les bonnes pratiques dans l'ensemble de la documentation technique de leur organisation.

Création d’un serveur MCP Elastic avec TypeScript et Claude Desktop.

Produits requis

  • Node.js 20 +

  • Elasticsearch

  • Clé API OpenAI

  • Claude Desktop

Qu'est-ce que le MCP ?

MCP est une norme ouverte, créée par Anthropic, qui fournit des connexions bidirectionnelles sécurisées entre les LLM et les systèmes externes, comme Elasticsearch. Vous pouvez en savoir plus sur l'état actuel du MCP dans cet article.

Le paysage des MCP évolue chaque jour, avec des serveurs disponibles pour un large éventail de cas d'utilisation. De plus, il est facile de créer votre propre serveur MCP personnalisé, comme nous le montrerons dans cet article.

Clients MCP

Il existe une longue liste de clients MCP disponibles, chacun ayant ses propres caractéristiques et limitations. Par souci de simplicité et de popularité, nous utiliserons Claude Desktop comme client MCP. Il servira d'interface de chat où les utilisateurs pourront poser des questions en langage naturel, et il invoquera automatiquement les outils exposés par notre serveur MCP pour rechercher des documents et générer des résumés.

Page de Claude 4.5 du Sonnet, avec la note : « Café avec Claude ? » Comment puis-je vous aider aujourd'hui ?

Créer un serveur Elasticsearch MCP

Grâce au SDK TypeScript, nous pouvons facilement créer un serveur qui comprend comment interroger nos données Elasticsearch à partir d'une requête utilisateur.

Voici les étapes dans cet article pour intégrer le serveur Elasticsearch MCP avec le client Claude Desktop :

  1. Configurer le serveur MCP pour Elasticsearch.

  2. Chargez le serveur MCP dans Claude Desktop.

  3. Testez-le.

Configurez le serveur MCP pour Elasticsearch

Pour commencer, initialisons une application Node :

npm init -y

Cela créera un fichier package.json, et avec lui, nous pourrons commencer à installer les dépendances nécessaires pour cette application.

npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod && npm install --save-dev ts-node @types/node typescript
  • @elastic/elasticsearch nous donnera accès à la bibliothèque de Node.js Elasticsearch.

  • @modelcontextprotocol/sdk fournit les outils du noyau pour créer et gérer un serveur MCP, enregistrer les outils et gérer la communication avec les clients MCP.

  • OpenAI permet d'interagir avec les modèles OpenAI pour générer des résumés ou des réponses en langage naturel.

  • ZOD aide à définir et valider des schémas structurés pour les données d’entrée et de sortie dans chaque outil.

ts-node, @types/node et typescript seront utilisés pendant le développement pour écrire le code et compiler les scripts.

Configurer l’ensemble de données

Pour fournir les données que Claude Desktop peut interroger via notre serveur MCP, nous utiliserons un ensemble de données simulé de base de connaissances interne. Voici à quoi ressemblera un document issu de cet ensemble de données :

{
    "id": 5,
    "title": "Logging Standards for Microservices",
    "content": "Consistent logging across microservices helps with debugging and tracing. Use structured JSON logs and include request IDs and timestamps. Avoid logging sensitive information. Centralize logs in Elasticsearch or a similar system. Configure log rotation to prevent storage issues and ensure logs are searchable for at least 30 days.",
    "tags": ["logging", "microservices", "standards"]
}

Pour ingérer les données, nous avons préparé un script qui crée un index dans Elasticsearch et y charge l’ensemble de données. Vous pouvez le trouver ici.

Serveur MCP

Créez un fichier nommé index.ts et ajoutez le code suivant pour importer les dépendances et gérer les variables d’environnement :

// index.ts
import { z } from "zod";
import { Client } from "@elastic/elasticsearch";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import OpenAI from "openai";

const ELASTICSEARCH_ENDPOINT =
  process.env.ELASTICSEARCH_ENDPOINT ?? "http://localhost:9200";
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY ?? "";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
const INDEX = "documents";

Aussi, initialisons les clients pour gérer les appels Elasticsearch et OpenAI :

const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});

const _client = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: ELASTICSEARCH_API_KEY,
  },
});

Pour rendre notre implémentation plus robuste et garantir des entrées et des sorties structurées, nous définirons des schémas en utilisant zod. Cela nous permet de valider les données au moment de l'exécution, de détecter les erreurs tôt et de rendre les réponses des outils plus faciles à traiter de manière programmatique :

const DocumentSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
});

const SearchResultSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  score: z.number(),
});

type Document = z.infer<typeof DocumentSchema>;
type SearchResult = z.infer<typeof SearchResultSchema>;

Pour en savoir plus sur les sorties structurées, cliquez ici.

Maintenant, initialisons le serveur MCP :

const server = new McpServer({
  name: "Elasticsearch RAG MCP",
  description:
    "A RAG server using Elasticsearch. Provides tools for document search, result summarization, and source citation.",
  version: "1.0.0",
});

Définition des outils MCP

Une fois que tout est configuré, nous pouvons commencer à écrire les outils qui seront exposés par notre serveur MCP. Ce serveur expose deux outils :

  • search_docs: Recherche des documents dans Elasticsearch à l'aide de la recherche full-text.

  • summarize_and_cite: Résume et synthétise les informations provenant de documents précédemment récupérés pour répondre à la question d'un utilisateur. Cet outil ajoute également des citations faisant référence aux documents sources.

Ensemble, ces outils forment un workflow simple de « récupération puis synthèse », où un outil extrait les documents pertinents et l'autre utilise ces documents pour générer une réponse synthétisée et citée.

Format de réponse de l'outil

Chaque outil peut accepter des paramètres d'entrée arbitraires, mais il doit répondre avec la structure suivante :

  • Contenu : il s'agit de la réponse de l'outil dans un format non structuré. Ce champ est généralement utilisé pour renvoyer du texte, des images, de l’audio, des liens ou des plongements. Pour cette application, il sera utilisé pour renvoyer un texte formaté contenant les informations générées par les outils.

  • structuredContent : il s'agit d'un retour facultatif utilisé pour fournir les résultats de chaque outil dans un format structuré. Ceci est utile à des fins de programmation. Bien qu'il ne soit pas utilisé dans ce serveur MCP, il peut être utile si vous souhaitez développer d'autres outils ou traiter les résultats de manière programmée.

En gardant cette structure à l’esprit, entrons dans le vif du sujet en examinant chaque outil en détail.

Outil de recherche

Cet outil effectue une recherche full-text dans l’index Elasticsearch pour récupérer les documents les plus pertinents selon la requête de l’utilisateur. Il met en évidence les correspondances clés et offre un aperçu rapide avec des scores de pertinence.

server.registerTool(
  "search_docs",
  {
    title: "Search Documents",
    description:
      "Search for documents in Elasticsearch using full-text search. Returns the most relevant documents with their content, title, tags, and relevance score.",
    inputSchema: {
      query: z
        .string()
        .describe("The search query terms to find relevant documents"),
      max_results: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of results to return"),
    },
    outputSchema: {
      results: z.array(SearchResultSchema),
      total: z.number(),
    },
  },
  async ({ query, max_results }) => {
    if (!query) {
      return {
        content: [
          {
            type: "text",
            text: "Query parameter is required",
          },
        ],
        isError: true,
      };
    }

    try {
      const response = await _client.search({
        index: INDEX,
        size: max_results,
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query: query,
                  fields: ["title^2", "content", "tags"],
                  fuzziness: "AUTO",
                },
              },
            ],
            should: [
              {
                match_phrase: {
                  title: {
                    query: query,
                    boost: 2,
                  },
                },
              },
            ],
          },
        },
        highlight: {
          fields: {
            title: {},
            content: {},
          },
        },
      });

      const results: SearchResult[] = response.hits.hits.map((hit: any) => {
        const source = hit._source as Document;

        return {
          id: source.id,
          title: source.title,
          content: source.content,
          tags: source.tags,
          score: hit._score ?? 0,
        };
      });

      const contentText = results
        .map(
          (r, i) =>
            `[${i + 1}] ${r.title} (score: ${r.score.toFixed(
              2,
            )})\n${r.content.substring(0, 200)}...`,
        )
        .join("\n\n");

      const totalHits =
        typeof response.hits.total === "number"
          ? response.hits.total
          : (response.hits.total?.value ?? 0);

      return {
        content: [
          {
            type: "text",
            text: `Found ${results.length} relevant documents:\n\n${contentText}`,
          },
        ],
        structuredContent: {
          results: results,
          total: totalHits,
        },
      };
    } catch (error: any) {
      console.log("Error during search:", error);

      return {
        content: [
          {
            type: "text",
            text: `Error searching documents: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

Nous configurons fuzziness: “AUTO” pour que la tolérance aux fautes de frappe soit variable en fonction de la longueur du jeton analysé. Nous configurons également title^2 pour qu'il augmente le score des documents dont la correspondance se fait sur le champ du titre.

outil summarize_and_cite

Cet outil génère un résumé basé sur les documents récupérés lors de la recherche précédente. Il utilise le modèle gpt-4o-mini d’OpenAI pour synthétiser les informations les plus pertinentes afin de répondre à la question de l’utilisateur, en fournissant des réponses dérivées directement des résultats de recherche. Outre le résumé, il renvoie également les métadonnées de citation des documents sources utilisés.

server.registerTool(
  "summarize_and_cite",
  {
    title: "Summarize and Cite",
    description:
      "Summarize the provided search results to answer a question and return citation metadata for the sources used.",
    inputSchema: {
      results: z
        .array(SearchResultSchema)
        .describe("Array of search results from search_docs"),
      question: z.string().describe("The question to answer"),
      max_length: z
        .number()
        .optional()
        .default(500)
        .describe("Maximum length of the summary in characters"),
      max_docs: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of documents to include in the context"),
    },
    outputSchema: {
      summary: z.string(),
      sources_used: z.number(),
      citations: z.array(
        z.object({
          id: z.number(),
          title: z.string(),
          tags: z.array(z.string()),
          relevance_score: z.number(),
        })
      ),
    },
  },
  async ({ results, question, max_length, max_docs }) => {
    if (!results || results.length === 0 || !question) {
      return {
        content: [
          {
            type: "text",
            text: "Both results and question parameters are required, and results must not be empty",
          },
        ],
        isError: true,
      };
    }

    try {
      const used = results.slice(0, max_docs);

      const context = used
        .map(
          (r: SearchResult, i: number) =>
            `[Document ${i + 1}: ${r.title}]\\n${r.content}`
        )
        .join("\n\n---\n\n");

      // Generate summary with OpenAI
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content:
              "You are a helpful assistant that answers questions based on provided documents. Synthesize information from the documents to answer the user's question accurately and concisely. If the documents don't contain relevant information, say so.",
          },
          {
            role: "user",
            content: `Question: ${question}\\n\\nRelevant Documents:\\n${context}`,
          },
        ],
        max_tokens: Math.min(Math.ceil(max_length / 4), 1000),
        temperature: 0.3,
      });

      const summaryText =
        completion.choices[0]?.message?.content ?? "No summary generated.";

      const citations = used.map((r: SearchResult) => ({
        id: r.id,
        title: r.title,
        tags: r.tags,
        relevance_score: r.score,
      }));

      const citationText = citations
        .map(
          (c: any, i: number) =>
            `[${i + 1}] ID: ${c.id}, Title: "${c.title}", Tags: ${c.tags.join(
              ", ",
            )}, Score: ${c.relevance_score.toFixed(2)}`,
        )
        .join("\n");

      const combinedText = `Summary:\\n\\n${summaryText}\\n\\nSources used (${citations.length}):\\n\\n${citationText}`;

      return {
        content: [
          {
            type: "text",
            text: combinedText,
          },
        ],
        structuredContent: {
          summary: summaryText,
          sources_used: citations.length,
          citations: citations,
        },
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error generating summary and citations: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

Enfin, il faut démarrer le serveur avec stdio. Cela signifie que le client MCP communiquera avec notre serveur en lisant et en écrivant dans ses flux d'entrée et de sortie standard. StDIO est l’option de transport la plus simple et fonctionne bien pour les serveurs MCP locaux lancés en sous-processus par le client. Ajoutez le code suivant à la fin du fichier :

const transport = new StdioServerTransport();
server.connect(transport);

Compilez le projet en utilisant la commande suivante :

npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop

Cela créera un dossier dist, dans lequel se trouvera un fichier index.js.

Chargez le serveur MCP dans Claude Desktop.

Suivez ce guide pour configurer le serveur MCP avec Claude Desktop. Dans le fichier de configuration Claude, nous devons définir les valeurs suivantes :

{
  "mcpServers": {
    "elasticsearch-rag-mcp": {
      "command": "node",
      "args": [   "/Users/user-name/app-dir/dist/index.js"
      ],
      "env": {
        "ELASTICSEARCH_ENDPOINT": "your-endpoint-here",
        "ELASTICSEARCH_API_KEY": "your-api-key-here",
        "OPENAI_API_KEY": "your-openai-key-here"
      }
    }
  }
}

La valeur args doit pointer vers le fichier compilé dans le dossier dist . Vous devez également définir les variables d'environnement dans le fichier de configuration avec les noms exacts définis dans le code.

Testez-le

Avant d’exécuter chaque outil, cliquez sur Recherche et Outils pour vous assurer que les outils sont activés. Vous pouvez également activer ou désactiver chaque option ici :

Claude 4.5 Page de sonnet, avec la note : « Bonjour, Jeff. » Comment puis-je vous aider aujourd'hui ?

Enfin, testons le serveur MCP depuis le chat Claude Desktop et commençons à poser des questions :

Requête de recherche d'utilisateur dans le chat de Claude Desktop pour des documents sur les méthodes d'authentification et le contrôle d'accès basé sur les rôles, ainsi que sur les réponses de Claude.

Pour la question « Recherche de documents sur les méthodes d’authentification et le contrôle d’accès basé sur les rôles », l’outil search_docs est exécuté et renvoie les résultats suivants :

Most Relevant Documents:
Access Control and Role Management (highest relevance) - This document covers role-based access control (RBAC) principles, including ensuring users only have necessary permissions, regular auditing of user roles, revoking inactive accounts, and implementing just-in-time access for sensitive operations.
User Authentication with OAuth 2.0 - This document explains OAuth 2.0 authentication, which enables secure delegated access without credential sharing. It covers configuring identity providers, token management with limited scope and lifetime, and secure storage of refresh tokens.
Container Security Guidelines - While primarily about container security, this document touches on access control aspects like running containers as non-root users and avoiding embedded credentials.
Incident Response Playbook - This mentions role assignment during incidents (incident commander, communications lead, etc.), which relates to access control in emergency scenarios.
Logging Standards for Microservices - This document includes guidance on avoiding logging sensitive information, which is relevant to authentication security.

La réponse est : « Super ! J'ai trouvé 5 documents pertinents sur les méthodes d'authentification et le contrôle d'accès basé sur les rôles. Voici ce qui a été découvert : »

L'appel d'outil renvoie les documents sources dans le cadre de sa charge utile de réponse, qui sont ensuite utilisés pour générer des citations.

Page Claude 4.5 Sonnet, avec des réponses qui défilent et incluent les cinq documents pertinents sur les méthodes d'authentification et le contrôle d'accès basé sur les rôles.

Il est également possible d'enchaîner plusieurs outils dans une même interaction. Dans ce cas, Claude Desktop analyse la question de l’utilisateur et détermine qu’il doit d’abord appeler search_docs pour récupérer les documents pertinents, puis transmettre ces résultats à summarize_and_cite pour générer la réponse finale, le tout sans nécessiter d’invites séparées de la part de l’utilisateur :

Chat de bureau Claude, avec la note « Jeff revient », plus une nouvelle question d'utilisateur : « Quelles sont les principales recommandations pour améliorer l'authentification et le contrôle d'accès à travers nos systèmes ? » Incluez des références. »

Dans ce cas, pour la requête « Quelles sont les principales recommandations pour améliorer l’authentification et le contrôle d’accès dans l’ensemble de nos systèmes ? Veuillez inclure des références. », Nous avons obtenu les résultats suivants :

Based on the documentation, here are the main recommendations to improve authentication and access control across your systems:

Key Recommendations

1. Implement Role-Based Access Control (RBAC)
Ensure users have only the permissions necessary for their job functions. This principle of least privilege minimizes security risks by limiting access to what's truly needed. [1]

2. Regular Access Audits
Conduct regular audits of user roles and promptly revoke access for inactive accounts. This helps prevent unauthorized access from dormant or compromised accounts. [1]

3. Just-in-Time (JIT) Access
Implement just-in-time access for sensitive operations, where permissions are granted only when needed and for a limited duration. This reduces the attack surface by minimizing standing privileges. [1]

4. OAuth 2.0 for Secure Authentication
Use OAuth 2.0 to enable secure delegated access without sharing user credentials. This protocol allows third-party applications to access resources on behalf of users without exposing passwords. [2]

5. Token Security and Management
Configure your identity provider to issue tokens with limited scope and lifetime

Store refresh tokens securely
Validate access tokens consistently to maintain security [2]
References

Access Control and Role Management (Tags: security, access-control)
User Authentication with OAuth 2.0 (Tags: authentication, oauth)
These recommendations work together to create a defense-in-depth approach, where multiple security layers protect your systems from unauthorized access.

Comme à l’étape précédente, nous pouvons voir la réponse de chaque outil à cette question :

Page de chat de Claude Desktop, avec un texte défilant qui inclut la réponse de chaque outil à la question, « Quelles sont les principales recommandations pour améliorer l'authentification et le contrôle d'accès à travers nos systèmes ? » Incluez des références. »

Note : Si un sous-menu apparaît demandant si vous approuvez l’utilisation de chaque outil, sélectionnez Toujours autoriser ou Permettre une fois.

Claude Desktop propose à l’utilisateur les options « Toujours autoriser » et « Autoriser une seule fois ».

Conclusion

Les serveurs MCP représentent une étape importante vers la standardisation des outils LLM pour les applications locales et distantes. Bien que la compatibilité totale soit encore en cours de développement, nous avançons rapidement dans cette direction.

Dans cet article, nous avons appris à créer un serveur MCP personnalisé en TypeScript qui connecte Elasticsearch aux applications basées sur LLM. Notre serveur propose deux outils : search_docs pour récupérer les documents pertinents à l'aide de Query DSL ; et summarize_and_cite pour générer des résumés avec des citations via des modèles OpenAI et Claude Desktop comme interface utilisateur client.

L'avenir de la compatibilité entre les différents fournisseurs côté client et côté serveur semble prometteur. Les prochaines étapes consistent à ajouter davantage de fonctionnalités et de flexibilité à votre agent. Vous trouverez un article pratique expliquant comment paramétrer vos requêtes à l'aide de modèles de rechercher pour gagner en précision et en flexibilité.

Pour aller plus loin

Agent Builder, bien plus qu’une interface de discussion : vers une infrastructure augmentée

Alexander Wert

Gestion de la mémoire agentique avec Elasticsearch.

Someshwaran Mohankumar

Utilisation de l'API d'inférence Elasticsearch avec les modèles Hugging Face

Jeffrey Rengifo

Construire un agent d'IA pour les RH avec Elastic Agent Builder et GPT-OSS

Tomás Murúa

L'outil shell n'est pas une solution miracle pour l'ingénierie du contexte

Leonie Monigatti

Prêt à créer des expériences de recherche d'exception ?

Une recherche suffisamment avancée ne se fait pas avec les efforts d'une seule personne. Elasticsearch est alimenté par des data scientists, des ML ops, des ingénieurs et bien d'autres qui sont tout aussi passionnés par la recherche que vous. Mettons-nous en relation et travaillons ensemble pour construire l'expérience de recherche magique qui vous permettra d'obtenir les résultats que vous souhaitez.

Jugez-en par vous-même