2026/10/9 15:48:25

使用 TypeScript 创建 Elasticsearch MCP 服务器:把 endpoint 改到 TaoToken

使用 TypeScript 创建 Elasticsearch MCP 服务器:把 endpoint 改到 TaoToken 1. 为什么要在 TypeScript 里自己写一个 Elasticsearch MCP 服务器如果你手里有一套 Elasticsearch 知识库又想让 Claude、Cline 这类支持 MCP 的客户端直接查它最省事的路径不是等官方插件而是自己用 TypeScript 写一个 MCP 服务器。MCP 全称 Model Context Protocol是一套让大模型应用和外部系统标准化通信的协议服务器把能力注册成「工具」客户端发现并调用这些工具模型负责决定什么时候调、传什么参数。你写一次服务器所有兼容 MCP 的客户端都能复用。这件事适合三类人一是做企业内部知识库检索的 Node/前端工程师TypeScript 类型安全比 Python 方案更贴合现有技术栈二是想让 LLM 检索结果可溯源、可后处理的团队官方方案往往只返回原始命中没法加摘要和引用三是想练手 MCP 协议、理解工具注册与 Stdio 传输机制的开发者。我试过把检索、摘要、引用拆成两个工具串起来跑端到端链路是通的下面把可复制的配置和代码都摊开讲。需要先明确一点MCP 服务器本身只负责「检索 组织上下文」真正生成答案的是客户端里的大模型。所以本文的架构是——TypeScript 服务器连 Elasticsearch 做检索再把命中的文档交给模型做摘要最后把引用元数据一并返回。模型调用这一层我们统一走 TaoToken 的 Key/API 通道这样换模型、换客户端都不用改业务代码。2. 前置准备TaoToken 通道与项目依赖怎么配在写代码之前先把两件事定下来模型调用的 endpoint 指向哪里以及项目依赖装哪些。模型调用我们统一走 TaoToken它的价值在于把多家模型的调用收敛到一个 Key、一个 Base URL 上MCP 服务器里只维护一份配置后面换模型只改 Model ID 就行。第一步去 TaoToken 控制台创建一个 API Key。打开 https://taotoken.net/api-keys 登录后新建一个 Key复制出来先存到本地环境变量里别硬编码进代码。这个 Key 后面会同时用于 MCP 服务器里的模型调用。第二步确认你要用的模型 ID。在模型对话页 https://taotoken.net/model-chat 里可以试跑一下确认目标模型能正常返回再把它写进配置。Base URL 固定用 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为 OpenAI 兼容的 baseURL 使用。第三步初始化项目并装依赖。Node.js 需要 20 及以上保证 MCP SDK 和 Elasticsearch 客户端兼容mkdir es-mcp-server cd es-mcp-server npm init -y npm install elastic/elasticsearch modelcontextprotocol/sdk openai zod npm install --save-dev typescript ts-node types/node依赖分工要说清楚elastic/elasticsearch封装 ES 的 REST 接口负责 DSL 查询modelcontextprotocol/sdk提供服务器骨架、工具注册和 Stdio 传输openai这个 SDK 我们用来调 TaoToken 的兼容接口因为 TaoToken 的 API 是 OpenAI 兼容格式直接改 baseURL 即可zod做运行时参数校验弥补 TypeScript 只在编译期检查的短板。第四步准备环境变量。在项目根目录建一个.env记得加进.gitignore把 ES 地址、ES 认证、TaoToken Key 都放进去ELASTICSEARCH_ENDPOINThttp://localhost:9200 ELASTICSEARCH_API_KEY TAOTOKEN_API_KEYsk-你的TaoToken密钥 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELgpt-4o-mini这里有个容易踩的坑ES 本地开发通常没开认证ELASTICSEARCH_API_KEY留空即可但 Elastic Cloud 强制要求 API Key留空会直接 401。TaoToken 这边TAOTOKEN_BASE_URL一定要写完整到/api少写路径会导致请求打到错误的路由上。3. 可复制配置tsconfig、MCP 服务器与工具注册代码这一节是全文的核心所有片段都可以直接复制。先建tsconfig.json保证编译产物是 Node 20 能跑的 ESM{ compilerOptions: { target: ES2022, module: node16, moduleResolution: node16, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*.ts] }然后在package.json里补上type: module和启动脚本否则node16模块解析会报错{ type: module, scripts: { build: tsc, start: node dist/index.js } }接下来是服务器主体src/index.ts。先写依赖导入和客户端初始化注意模型客户端这里指向 TaoTokenimport { 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 ES_ENDPOINT process.env.ELASTICSEARCH_ENDPOINT ?? http://localhost:9200; const ES_API_KEY process.env.ELASTICSEARCH_API_KEY ?? ; const TAOTOKEN_KEY process.env.TAOTOKEN_API_KEY ?? ; const TAOTOKEN_BASE process.env.TAOTOKEN_BASE_URL ?? https://taotoken.net/api; const TAOTOKEN_MODEL process.env.TAOTOKEN_MODEL ?? gpt-4o-mini; const INDEX documents; const es new Client({ node: ES_ENDPOINT, auth: ES_API_KEY ? { apiKey: ES_API_KEY } : undefined, }); const llm new OpenAI({ apiKey: TAOTOKEN_KEY, baseURL: TAOTOKEN_BASE, });这里三件套要记牢Base URL 是https://taotoken.net/apiKey 是控制台创建的TAOTOKEN_API_KEYModel ID 是TAOTOKEN_MODEL。三者缺一模型调用就会失败。然后是 Zod 模式定义和服务器实例。Zod 在这里不只是校验它还能反推 TypeScript 类型省得手写 interfaceconst SearchResultSchema z.object({ id: z.number(), title: z.string(), content: z.string(), tags: z.array(z.string()), score: z.number(), }); type SearchResult z.infertypeof SearchResultSchema; const server new McpServer({ name: Elasticsearch RAG MCP, version: 1.0.0, });注册第一个工具search_docs负责 ES 全文检索。查询 DSL 里title^2给标题加权fuzziness: AUTO做拼写容错server.registerTool( search_docs, { title: Search Documents, description: Full-text search over the Elasticsearch knowledge base., inputSchema: { query: z.string().describe(Search terms), max_results: z.number().optional().default(5), }, outputSchema: { results: z.array(SearchResultSchema), total: z.number(), }, }, async ({ query, max_results }) { const res await es.search({ index: INDEX, size: max_results, query: { bool: { must: [ { multi_match: { query, fields: [title^2, content, tags], fuzziness: AUTO, }, }, ], }, }, }); const results: SearchResult[] res.hits.hits.map((hit: any) ({ id: hit._source.id, title: hit._source.title, content: hit._source.content, tags: hit._source.tags, score: hit._score ?? 0, })); const total typeof res.hits.total number ? res.hits.total : res.hits.total?.value ?? 0; return { content: [ { type: text, text: Found ${results.length} docs:\n results.map((r, i) [${i 1}] ${r.title} (${r.score.toFixed(2)})).join(\n), }, ], structuredContent: { results, total }, }; } );注册第二个工具summarize_and_cite把检索结果交给 TaoToken 上的模型做摘要同时返回引用元数据server.registerTool( summarize_and_cite, { title: Summarize and Cite, description: Summarize search results and return citations., inputSchema: { results: z.array(SearchResultSchema), question: z.string(), max_docs: z.number().optional().default(5), }, outputSchema: { summary: z.string(), citations: z.array( z.object({ id: z.number(), title: z.string(), score: z.number(), }) ), }, }, async ({ results, question, max_docs }) { const used results.slice(0, max_docs); const context used .map((r, i) [Doc ${i 1}: ${r.title}]\n${r.content}) .join(\n\n---\n\n); const completion await llm.chat.completions.create({ model: TAOTOKEN_MODEL, messages: [ { role: system, content: Answer the question strictly based on the provided documents. If not found, say so., }, { role: user, content: Question: ${question}\n\n${context} }, ], temperature: 0.3, max_tokens: 800, }); const summary completion.choices[0]?.message?.content ?? No summary.; const citations used.map((r) ({ id: r.id, title: r.title, score: r.score, })); return { content: [ { type: text, text: ${summary}\n\nSources:\n citations.map((c, i) [${i 1}] ${c.title}).join(\n), }, ], structuredContent: { summary, citations }, }; } );最后启动 Stdio 传输const transport new StdioServerTransport(); await server.connect(transport);编译一下npm run build产物在dist/index.js这就是客户端要启动的入口文件。4. 验证请求从客户端配置到一次端到端检索代码写完不算完得真跑通一次。先在客户端里注册这个 MCP 服务器。以 Claude Desktop 为例配置文件在 macOS 是~/Library/Application Support/Claude/claude_desktop_config.jsonWindows 是%APPDATA%\Claude\claude_desktop_config.json。写入{ mcpServers: { es-rag: { command: node, args: [/绝对路径/es-mcp-server/dist/index.js], env: { ELASTICSEARCH_ENDPOINT: http://localhost:9200, ELASTICSEARCH_API_KEY: , TAOTOKEN_API_KEY: sk-你的TaoToken密钥, TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_MODEL: gpt-4o-mini } } } }args必须是绝对路径相对路径客户端找不到。改完重启客户端在工具列表里应该能看到es-rag下的两个工具。验证分两步。第一步单独测检索直接问「搜索关于认证和访问控制的文档」客户端会调用search_docs返回带相关性得分的命中列表。如果 ES 里没数据先灌几条测试文档curl -X POST http://localhost:9200/documents/_doc/1 \ -H Content-Type: application/json \ -d {id:1,title:OAuth 2.0 Authentication,content:OAuth 2.0 uses access tokens and refresh tokens for secure API access.,tags:[auth,oauth]}第二步测工具串联问「改进认证和访问控制的主要建议是什么附引用」。客户端会先调search_docs再把结果传给summarize_and_cite最终返回一段摘要加引用列表。这一步能跑通说明 ES 检索、TaoToken 模型调用、MCP 工具编排三条链路都正常。如果你想在命令行里单独验证模型通道是否通可以写个最小脚本import OpenAI from openai; const llm new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: https://taotoken.net/api, }); const r await llm.chat.completions.create({ model: gpt-4o-mini, messages: [{ role: user, content: ping }], }); console.log(r.choices[0].message.content);返回正常内容就说明 Key、Base URL、Model ID 三件套没问题问题只可能出在 MCP 层。5. 常见报错排查401、local proxy failed 与 reading choices跑不通的时候报错信息基本能定位到具体环节。下面几个是我实际遇到过的。401 Unauthorized模型侧多半是 TaoToken Key 没读到或写错。检查env里的TAOTOKEN_API_KEY是否和代码里的变量名完全一致注意大小写。还有一种情况是 Key 复制时带了空格肉眼看不出来重新复制一次。如果报错信息里出现invalid api key基本就是这个原因。401 UnauthorizedES 侧Elastic Cloud 必须带 API Key本地 ES 如果开了安全认证也会要求。确认ELASTICSEARCH_API_KEY是否为空、是否过期。本地开发建议先关掉 ES 安全认证减少变量。local proxy failed / connection refused这是客户端启动 MCP 服务器时连不上。先确认args里的路径是绝对路径且文件存在再确认node命令在系统 PATH 里。如果客户端日志里出现spawn node ENOENT说明它找不到 node把command改成 node 的绝对路径比如/usr/local/bin/node。reading choices of undefined这个报错出现在completion.choices[0]那一行说明模型返回体里没有choices字段。常见原因是baseURL写错比如漏了/api或写成了别的路径请求打到了非兼容接口上。确认TAOTOKEN_BASE_URL是https://taotoken.net/api不要带尾斜杠。另一个原因是 Model ID 写错模型不存在时接口可能返回错误结构同样导致choices缺失。OAuth / 授权弹窗反复出现客户端首次调用工具会弹授权选「始终允许」即可。如果每次都弹检查配置文件是否被其他进程覆盖或者客户端版本过旧。ES 返回 0 命中但没报错检查索引名是否写对INDEX常量要和实际索引一致。另外multi_match的字段名必须存在于映射里字段名拼错不会报错只会静默返回空。排查顺序建议从外到内先单独验证 TaoToken 通道第 4 节的最小脚本再验证 ES 直连curl 一条查询最后才怀疑 MCP 层。这样能快速缩小范围不用在三个环节之间反复猜。6. 把通道固定下来后续扩展就轻松了整套跑通之后你会发现最值得固定下来的其实是模型调用这一层。把 Base URL、Key、Model ID 收敛到环境变量里MCP 服务器代码里不出现任何硬编码的模型信息后面想换模型只改一个变量。TaoToken 的接入文档在 https://taotoken.net/doc 里面有各语言 SDK 的对接示例遇到兼容性问题可以对照查。如果你打算长期跑编码类或 Agent 类任务可以看看 Coding Plan https://taotoken.net/coding-plan 它更适合高频调用的场景。日常调试模型效果用模型对话页 https://taotoken.net/model-chat 快速试跑就行。Key 管理统一在控制台 https://taotoken.net/api-keys 建议按项目分 Key方便排查和轮换。回到 MCP 服务器本身下一步可以做的扩展不少给search_docs加过滤参数支持按 tags 或时间范围筛选把摘要工具改成流式返回长文档体验更好再注册一个index_document工具让模型能往知识库里写数据。这些都不需要动传输层MCP 的工具注册机制天然支持增量扩展。真正要守住的是那条链路——ES 负责检索TaoToken 负责模型调用MCP 负责把两者串给客户端各司其职换任何一环都不影响其他部分。