使用 TypeScript 创建 Elasticsearch MCP 服务器

0 阅读13分钟

作者:来自 Elastic Jeffrey Rengifo

学习如何使用 TypeScript 和 Claude Desktop 创建 Elasticsearch MCP 服务器。

Agent Builder 现已正式可用正式发布。通过 Elastic Cloud 试用开始使用,并在此查看 Agent Builder 文档。


Elasticsearch 中处理大型知识库时,查找信息只是问题的一半。工程师通常需要从多个文档中综合结果、生成摘要,并将答案追溯到其来源。Model Context Protocol(MCP)提供了一种标准化方式,将 Elasticsearch 与由大语言模型(LLM-powered)驱动的应用连接起来以实现这些目标。虽然 Elastic 提供了官方解决方案,例如 Elastic Agent Builder(其功能之一包括 MCP endpoint),但构建自定义 MCP 服务器可以让你完全控制搜索逻辑、结果格式,以及如何将检索内容传递给 LLM 用于综合、摘要和引用。

在本文中,我们将探讨构建自定义 Elasticsearch MCP 服务器的优势,并展示如何使用 TypeScript 创建一个,将 Elasticsearch 连接到 LLM-powered 应用。

为什么要构建自定义 Elasticsearch MCP 服务器?

Elastic 为 MCP 服务器提供了一些替代方案:

如果你需要更精细地控制 MCP 服务器如何与 Elasticsearch 交互,构建自定义服务器可以让你灵活地根据需求进行定制。例如,Agent Builder 的 MCP endpoint 仅限于 Elasticsearch Query Language(ES|QL)查询,而自定义服务器允许你使用完整的 Query DSL。你还可以控制在将结果传递给 LLM 之前的格式,并可集成额外处理步骤,例如我们将在本教程中实现的基于 OpenAI 的摘要生成。

在本文结束时,你将拥有一个用 TypeScript 编写的 MCP 服务器,它可以搜索存储在 Elasticsearch 索引中的信息,对其进行摘要,并提供引用。我们将使用 Elasticsearch 进行检索,使用 OpenAI 的 gpt-4o-mini 模型进行摘要和生成引用,并使用 Claude Desktop 作为 MCP 客户端和 UI 来接收用户查询并返回响应。最终结果是一个内部知识助手,帮助工程师在其组织的技术文档中发现并整合最佳实践。

前置条件:

  • Node.js 20+
  • Elasticsearch
  • OpenAI API key
  • Claude Desktop

什么是 MCP?

MCP 是由 Anthropic 创建的开放标准,为 LLMs 与外部系统(如 Elasticsearch)之间提供安全的双向连接。你可以在这篇文章中了解更多关于 MCP 当前状态的信息。

MCP 生态每天都在发展,已有服务器可用于各种使用场景。此外,构建你自己的自定义 MCP 服务器也很容易,正如我们将在本文中展示的那样。

MCP 客户端

目前有大量可用的 MCP 客户端,每个都有其自身特性和限制。为了简洁和流行性,我们将使用 Claude Desktop 作为 MCP 客户端。它将作为聊天界面,用户可以用自然语言提问,并自动调用我们 MCP 服务器暴露的工具来搜索文档并生成摘要。

创建 Elasticsearch MCP 服务器

使用 TypeScript SDK,我们可以轻松创建一个服务器,根据用户查询输入来理解如何查询 Elasticsearch 数据。

本文将按以下步骤将 Elasticsearch MCP 服务器与 Claude Desktop 客户端集成:

  • 为 Elasticsearch 配置 MCP 服务器。
  • 将 MCP 服务器加载到 Claude Desktop。
  • 进行测试。

为 Elasticsearch 配置 MCP 服务器

首先,让我们初始化一个 node 应用:

`npm init -y`AI写代码

这将创建一个 package.json 文件,有了它,我们可以开始为该应用安装必要的依赖。

`npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod && npm install --save-dev ts-node @types/node typescript`AI写代码
  • @elastic/elasticsearch 将为我们提供访问 Elasticsearch Node.js 库的能力。
  • @modelcontextprotocol/sdk 提供创建和管理 MCP 服务器、注册工具以及与 MCP 客户端通信的核心工具。
  • openai 允许与 OpenAI 模型交互,以生成摘要或自然语言响应。
  • zod 帮助为每个工具的输入和输出数据定义和验证结构化 schema。

ts-node、@types/node 和 typescript 将在开发过程中用于类型标注和编译脚本。

设置数据集

为了提供 Claude Desktop 可以通过我们的 MCP 服务器查询的数据,我们将使用一个模拟的内部知识库数据集。下面是该数据集中一个文档的示例:

`

1.  {
2.      "id": 5,
3.      "title": "Logging Standards for Microservices",
4.      "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.",
5.      "tags": ["logging", "microservices", "standards"]
6.  }

`AI写代码

为了摄取数据,我们准备了一个脚本,用于在 Elasticsearch 中创建索引并将数据集加载进去。你可以在这里找到它。

MCP 服务器

创建一个名为 index.ts 的文件,并添加以下代码以导入依赖并处理环境变量:

`

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

8.  const ELASTICSEARCH_ENDPOINT =
9.    process.env.ELASTICSEARCH_ENDPOINT ?? "http://localhost:9200";
10.  const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY ?? "";
11.  const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
12.  const INDEX = "documents";

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

另外,让我们初始化客户端以处理 Elasticsearch 和 OpenAI 调用:

`

1.  const openai = new OpenAI({
2.    apiKey: OPENAI_API_KEY,
3.  });

5.  const _client = new Client({
6.    node: ELASTICSEARCH_ENDPOINT,
7.    auth: {
8.      apiKey: ELASTICSEARCH_API_KEY,
9.    },
10.  });

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

为了让我们的实现更健壮并确保结构化输入和输出,我们将使用 zod 定义 schema。这使我们能够在运行时验证数据、尽早捕获错误,并让工具响应更易于进行程序化处理:

`

1.  const DocumentSchema = z.object({
2.    id: z.number(),
3.    title: z.string(),
4.    content: z.string(),
5.    tags: z.array(z.string()),
6.  });

8.  const SearchResultSchema = z.object({
9.    id: z.number(),
10.    title: z.string(),
11.    content: z.string(),
12.    tags: z.array(z.string()),
13.    score: z.number(),
14.  });

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

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

这里了解更多关于结构化输出的信息。

现在让我们初始化 MCP 服务器:

`

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

`AI写代码

定义 MCP 工具

一切配置完成后,我们可以开始编写将由 MCP 服务器暴露的工具。该服务器暴露两个工具:

  • search_docs:使用全文搜索在 Elasticsearch 中查找文档。
  • summarize_and_cite:从之前检索到的文档中总结并综合信息,以回答用户问题。该工具还会添加引用,指向源文档。

这两个工具共同构成一个简单的 “先检索再总结” 工作流,其中一个工具获取相关文档,另一个工具使用这些文档生成带引用的摘要响应。

工具响应格式

每个工具可以接受任意输入参数,但必须以以下结构返回:

  • Content:这是工具以非结构化格式返回的响应。该字段通常用于返回文本、图像、音频、链接或 embeddings。在本应用中,它将用于返回由工具生成的信息的格式化文本。
  • structuredContent:这是一个可选返回,用于以结构化格式提供每个工具的结果。这对于程序化处理很有用。虽然在此 MCP 服务器中未使用,但如果你想开发其他工具或对结果进行程序化处理,它会很有帮助。

在理解该结构后,让我们深入了解每个工具。

Search_docs 工具

该工具在 Elasticsearch 索引中执行全文搜索,根据用户查询检索最相关的文档。它会高亮关键匹配并提供带相关性评分的快速概览。

``

1.  server.registerTool(
2.    "search_docs",
3.    {
4.      title: "Search Documents",
5.      description:
6.        "Search for documents in Elasticsearch using full-text search. Returns the most relevant documents with their content, title, tags, and relevance score.",
7.      inputSchema: {
8.        query: z
9.          .string()
10.          .describe("The search query terms to find relevant documents"),
11.        max_results: z
12.          .number()
13.          .optional()
14.          .default(5)
15.          .describe("Maximum number of results to return"),
16.      },
17.      outputSchema: {
18.        results: z.array(SearchResultSchema),
19.        total: z.number(),
20.      },
21.    },
22.    async ({ query, max_results }) => {
23.      if (!query) {
24.        return {
25.          content: [
26.            {
27.              type: "text",
28.              text: "Query parameter is required",
29.            },
30.          ],
31.          isError: true,
32.        };
33.      }

35.      try {
36.        const response = await _client.search({
37.          index: INDEX,
38.          size: max_results,
39.          query: {
40.            bool: {
41.              must: [
42.                {
43.                  multi_match: {
44.                    query: query,
45.                    fields: ["title^2", "content", "tags"],
46.                    fuzziness: "AUTO",
47.                  },
48.                },
49.              ],
50.              should: [
51.                {
52.                  match_phrase: {
53.                    title: {
54.                      query: query,
55.                      boost: 2,
56.                    },
57.                  },
58.                },
59.              ],
60.            },
61.          },
62.          highlight: {
63.            fields: {
64.              title: {},
65.              content: {},
66.            },
67.          },
68.        });

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

73.          return {
74.            id: source.id,
75.            title: source.title,
76.            content: source.content,
77.            tags: source.tags,
78.            score: hit._score ?? 0,
79.          };
80.        });

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

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

96.        return {
97.          content: [
98.            {
99.              type: "text",
100.              text: `Found ${results.length} relevant documents:\n\n${contentText}`,
101.            },
102.          ],
103.          structuredContent: {
104.            results: results,
105.            total: totalHits,
106.          },
107.        };
108.      } catch (error: any) {
109.        console.log("Error during search:", error);

111.        return {
112.          content: [
113.            {
114.              type: "text",
115.              text: `Error searching documents: ${error.message}`,
116.            },
117.          ],
118.          isError: true,
119.        };
120.      }
121.    }
122.  );

``AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)收起代码块![](https://csdnimg.cn/release/blogv2/dist/pc/img/arrowup-line-top-White.png)

我们配置 fuzziness:“AUTO”,以根据正在分析的 token 长度提供可变的拼写错误容忍度。我们还设置 title^2,以提高在 title 字段中匹配的文档得分。

summarize_and_cite 工具

该工具基于之前搜索中检索到的文档生成摘要。它使用 OpenAI 的 gpt-4o-mini 模型来综合最相关的信息以回答用户问题,提供直接来源于搜索结果的响应。除了摘要外,它还返回用于引用的源文档元数据。

``

1.  server.registerTool(
2.    "summarize_and_cite",
3.    {
4.      title: "Summarize and Cite",
5.      description:
6.        "Summarize the provided search results to answer a question and return citation metadata for the sources used.",
7.      inputSchema: {
8.        results: z
9.          .array(SearchResultSchema)
10.          .describe("Array of search results from search_docs"),
11.        question: z.string().describe("The question to answer"),
12.        max_length: z
13.          .number()
14.          .optional()
15.          .default(500)
16.          .describe("Maximum length of the summary in characters"),
17.        max_docs: z
18.          .number()
19.          .optional()
20.          .default(5)
21.          .describe("Maximum number of documents to include in the context"),
22.      },
23.      outputSchema: {
24.        summary: z.string(),
25.        sources_used: z.number(),
26.        citations: z.array(
27.          z.object({
28.            id: z.number(),
29.            title: z.string(),
30.            tags: z.array(z.string()),
31.            relevance_score: z.number(),
32.          })
33.        ),
34.      },
35.    },
36.    async ({ results, question, max_length, max_docs }) => {
37.      if (!results || results.length === 0 || !question) {
38.        return {
39.          content: [
40.            {
41.              type: "text",
42.              text: "Both results and question parameters are required, and results must not be empty",
43.            },
44.          ],
45.          isError: true,
46.        };
47.      }

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

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

59.        // Generate summary with OpenAI
60.        const completion = await openai.chat.completions.create({
61.          model: "gpt-4o-mini",
62.          messages: [
63.            {
64.              role: "system",
65.              content:
66.                "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.",
67.            },
68.            {
69.              role: "user",
70.              content: `Question: ${question}\\n\\nRelevant Documents:\\n${context}`,
71.            },
72.          ],
73.          max_tokens: Math.min(Math.ceil(max_length / 4), 1000),
74.          temperature: 0.3,
75.        });

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

80.        const citations = used.map((r: SearchResult) => ({
81.          id: r.id,
82.          title: r.title,
83.          tags: r.tags,
84.          relevance_score: r.score,
85.        }));

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

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

98.        return {
99.          content: [
100.            {
101.              type: "text",
102.              text: combinedText,
103.            },
104.          ],
105.          structuredContent: {
106.            summary: summaryText,
107.            sources_used: citations.length,
108.            citations: citations,
109.          },
110.        };
111.      } catch (error: any) {
112.        return {
113.          content: [
114.            {
115.              type: "text",
116.              text: `Error generating summary and citations: ${error.message}`,
117.            },
118.          ],
119.          isError: true,
120.        };
121.      }
122.    }
123.  );

``AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)收起代码块![](https://csdnimg.cn/release/blogv2/dist/pc/img/arrowup-line-top-White.png)

最后,我们需要使用 stdio 启动服务器。这意味着 MCP 客户端将通过读取和写入标准输入和输出流与我们的服务器通信。stdio 是最简单的传输方式,非常适合由客户端作为子进程启动的本地 MCP 服务器。在文件末尾添加以下代码:

`

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

`AI写代码

现在使用以下命令编译项目:

`npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop`AI写代码

这将创建一个 dist 文件夹,里面包含一个 index.js 文件。

将 MCP 服务器加载到 Claude Desktop

按照本指南将 MCP 服务器配置到 Claude Desktop。在 Claude 配置文件中,我们需要设置以下值:

`

1.  {
2.    "mcpServers": {
3.      "elasticsearch-rag-mcp": {
4.        "command": "node",
5.        "args": [   "/Users/user-name/app-dir/dist/index.js"
6.        ],
7.        "env": {
8.          "ELASTICSEARCH_ENDPOINT": "your-endpoint-here",
9.          "ELASTICSEARCH_API_KEY": "your-api-key-here",
10.          "OPENAI_API_KEY": "your-openai-key-here"
11.        }
12.      }
13.    }
14.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

args 值应指向 dist 文件夹中已编译的文件。你还需要在配置文件中设置与代码中定义的名称完全相同的环境变量。

进行测试

在执行每个工具之前,点击 Search and Tools 以确保工具已启用。在这里你还可以启用或禁用每个工具:

最后,让我们从 Claude Desktop 聊天中测试 MCP 服务器,并开始提问:

对于问题 “Search for documents about authentication methods and role-based access control”,search_docs 工具将被执行,并返回以下结果:

`

1.  Most Relevant Documents:
2.  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.
3.  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.
4.  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.
5.  Incident Response Playbook - This mentions role assignment during incidents (incident commander, communications lead, etc.), which relates to access control in emergency scenarios.
6.  Logging Standards for Microservices - This document includes guidance on avoiding logging sensitive information, which is relevant to authentication security.

`AI写代码

响应为:“Great! I found 5 relevant documents about authentication methods and role-based access control. Here's what was found:”

工具调用会将源文档作为响应负载的一部分返回,随后用于生成引用。

也可以在单次交互中链式调用多个工具。在这种情况下,Claude Desktop 会分析用户的问题,并确定需要先调用 search_docs 来检索相关文档,然后将这些结果传递给 summarize_and_cite 以生成最终答案,整个过程无需用户提供额外的提示:

在这种情况下,对于查询 “What are the main recommendations to improve authentication and access control across our systems? Include references.”,我们获得了以下结果:

`

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

3.  Key Recommendations

5.  1. Implement Role-Based Access Control (RBAC)
6.  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]

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

11.  3. Just-in-Time (JIT) Access
12.  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]

14.  4. OAuth 2.0 for Secure Authentication
15.  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]

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

20.  Store refresh tokens securely
21.  Validate access tokens consistently to maintain security [2]
22.  References

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

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

与上一步类似,我们可以看到每个工具针对该问题的响应:

注意:如果出现子菜单询问你是否批准使用每个工具,请选择 Always allowAllow once

结论

MCP 服务器代表了将 LLM 工具标准化以支持本地和远程应用的重要一步。虽然完全兼容性仍在开发中,但我们正快速朝这个方向前进。

在本文中,我们学习了如何使用 TypeScript 构建一个自定义 MCP 服务器,将 Elasticsearch 连接到由 LLM 驱动的应用。我们的服务器暴露了两个工具:search_docs,用于使用 Query DSL 检索相关文档;以及 summarize_and_cite,通过 OpenAI 模型和 Claude Desktop 作为客户端 UI 生成带引用的摘要。

不同客户端和服务器提供商之间的未来兼容性前景良好。下一步包括为你的 agent 添加更多功能和灵活性。还有一篇实用文章介绍了如何使用搜索模板对查询进行参数化,以获得更高的精确性和灵活性。

原文:www.elastic.co/search-labs…