1. MCP 服务器工具调用为什么需要链路追踪
MCP 服务器工具调用链路可观测性,说白了就是回答三个问题:这次工具调用花了多久、慢在哪一段、失败时服务器到底执行到哪一步。MCP(Model Context Protocol)服务器现在越来越多地作为 AI 应用的基础设施,给模型提供数据库、内部 API、业务数据的访问能力。但 MCP SDK 本身不带任何内置可观测性,工具调用延迟、错误分布、性能基线对开发者来说全是黑盒。
我拿一个真实场景举例。你写了一个get-sum工具,逻辑就是两个数相加,理论上 1ms 返回。但线上偶尔出现 3 秒的调用。没有追踪时你只能看到「这次调用慢」,看不到瓶颈是在参数解析、业务逻辑,还是在某个下游 HTTP 请求。再比如trigger-long-running-operation这种多步骤工具,失败时你只拿到一句错误消息,不知道服务器在失败前执行了哪几步。
OpenTelemetry 解决的就是这个问题。它是一套语言无关的分布式追踪标准,MCP 已经有官方语义约定(semantic conventions),规定了 span 该怎么命名、属性该怎么设置。Elastic APM 则负责把这些 span 呈现成 transaction 列表、trace waterfall、延迟百分位和错误面板。两者组合,你就能在 Kibana 里按工具名分组看每个工具的 p50/p95/p99,点进单个 trace 看时间消耗分布。
这套方案适合谁?适合已经在跑 MCP 服务器、想搞清楚工具性能的开发者;适合团队里多人往同一个 MCP 服务器加工具、需要统一命名规范的场景;也适合想把模型侧 endpoint 和鉴权统一管理、不想在每个 MCP 配置里散落一堆 API Key 的人。后者正是本文要重点讲的:把模型侧 endpoint 与鉴权统一改到 TaoToken,MCP 服务器只管发遥测数据,模型调用走统一入口。
整条链路是这样的:Claude Desktop 通过 MCP 协议调用你的工具服务器,工具服务器用 OpenTelemetry SDK 生成 span,通过 OTLP 导出到 Elastic APM,Kibana 呈现调用耗时与错误分布。同时模型侧的请求(比如 Claude 的推理调用)走 TaoToken 统一 Key,这样你只需要维护一套鉴权,不用在claude_desktop_config.json里塞多个供应商的 Key。
下面从环境准备开始,一步步给出可复制的配置。我会先讲 TaoToken 的前置准备(拿 Key、确认 Base URL),再讲 OTel SDK 初始化、Elastic APM 接入片段,最后跑一次工具调用验证追踪数据是否真的进了 Kibana。
2. TaoToken 前置准备:统一 Key 与模型侧 endpoint
在动手写 OTel 配置之前,先把模型侧的鉴权统一掉。这一步的意义在于:你的 MCP 服务器配置里会同时出现「遥测导出配置」和「模型调用配置」,如果模型侧 Key 散落在多个地方,排障时很容易搞混是遥测的 401 还是模型的 401。TaoToken 提供统一的 API 入口,把模型侧 endpoint 和 Key 收敛到一处。
先拿 Key。打开 TaoToken 控制台,进入 API Keys 页面创建一个新 Key。建议按用途命名,比如mcp-otel-demo,这样后面在 Elastic APM 里看到异常流量时能快速定位是哪个 Key 产生的。创建后立刻复制保存,页面刷新后就不再完整显示。
拿到 Key 后,确认两个地址:
- 模型对话与推理请求的 Base URL:
https://taotoken.net/api - 控制台与 Key 管理:
https://taotoken.net/console
注意这里有个容易踩的坑:Base URL 不要带 UTM 参数,也不要自己拼/v1之外的路径。很多 OpenAI 兼容客户端要求 Base URL 精确到/api,然后由客户端自己拼/v1/chat/completions。如果你在 Base URL 里多写了/v1,请求会变成/api/v1/v1/chat/completions,直接 404。
模型 ID 这块,TaoToken 支持多种主流模型,具体可用列表在控制台的模型页面能看到。你在 MCP 服务器里如果用到模型调用(比如某些工具内部要调 LLM 做摘要),就把模型 ID 填成控制台里列出的那个。三件套记牢:Base URL、API Key、Model ID,后面配置里反复用到。
如果你只是想让 MCP 服务器发遥测数据、模型调用走别的通道,那 TaoToken 这步可以只做 Key 创建,把 Key 留着给需要模型调用的工具用。但既然标题是「统一 Key 接入实践」,我建议把模型侧也切过来,这样claude_desktop_config.json里只有一套鉴权来源,排障时心智负担小很多。
还有一个细节:TaoToken 的 Key 和 Elastic APM 的 API Key 是两回事。前者用于模型调用鉴权,后者用于 OTLP 数据导出鉴权。两者不要混用,也不要把 TaoToken 的 Key 填到OTEL_EXPORTER_OTLP_HEADERS里。我在第一次配置时就把这两个搞混了,结果遥测数据一直 401,排查了半天才发现是 Header 里填错了 Key。
前置准备做完,你应该手上有:一个 TaoToken API Key、确认过的 Base URLhttps://taotoken.net/api、一个可用的 Model ID。接下来进入 OTel SDK 初始化。
3. 可复制配置:OTel SDK 初始化与 Elastic APM 接入
这一节是全文技术密度最高的部分。我会给出完整的 OTel SDK 初始化代码、Elastic APM 接入片段,以及claude_desktop_config.json的完整配置。所有片段都可以直接复制,路径和参数按你本地实际情况替换。
先装依赖。MCP 服务器是 Node 进程,用 Elastic 发行的 EDOT Node.js(@elastic/opentelemetry-node)最省事,它把原本需要单独安装的五六个 OTel 包合并成一个,还内置了elasticapmconnector,这是 Kibana APM UI 构建服务拓扑图和 transaction 分组所必需的。没有这个 connector,原始 OTLP 数据虽然会进 Elasticsearch,但 APM 视图里没有可用数据。
npm install @elastic/opentelemetry-node npm install @opentelemetry/api@opentelemetry/api是用来在业务代码里获取 tracer、创建 span 的。EDOT 负责 SDK 初始化和导出,API 包负责你在工具处理器里手动埋点。
接下来是 OTel SDK 初始化。EDOT 支持通过--import标志零代码自动插桩,但自动插桩只能捕获 HTTP 调用、数据库查询这些 Node.js 已支持插桩的库。MCP 工具调用属于应用逻辑,需要手动 span。所以初始化分两部分:环境变量配置 + 手动 wrapper。
先看环境变量,这些直接写进claude_desktop_config.json的env字段:
{ "mcpServers": { "everything": { "command": "node", "args": [ "--import", "/path/to/node_modules/@elastic/opentelemetry-node/import.mjs", "/path/to/everything/dist/index.js", "stdio" ], "env": { "OTEL_SERVICE_NAME": "everything-mcp-server", "OTEL_EXPORTER_OTLP_ENDPOINT": "https://your-apm-endpoint:443", "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=ApiKey your-elastic-apm-api-key", "OTEL_LOG_LEVEL": "none", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_API_KEY": "your-taotoken-api-key", "TAOTOKEN_MODEL_ID": "your-model-id" } } } }这里OTEL_SERVICE_NAME是服务名,会出现在 Kibana 的 Services Inventory 里。OTEL_EXPORTER_OTLP_ENDPOINT指向你的 Elastic APM OTLP 端点,Elastic APM 从 8.x 起直接支持 OTLP,不需要额外 collector。OTEL_EXPORTER_OTLP_HEADERS里填 Elastic 的 API Key,注意格式是Authorization=ApiKey <key>,中间有个空格。
TAOTOKEN_*三个变量是给工具内部模型调用用的。如果你的工具不需要调模型,这三个可以不加。但既然要统一 Key,建议加上,后面工具代码里直接读环境变量。
然后是手动 span 的 wrapper。MCP 的 OTel 语义约定规定 span 命名遵循{mcp.method.name} {target}模式,工具调用就是tools/call echo或tools/call get-sum。关键属性有四个:
| 属性名 | 值 | 用途 |
|---|---|---|
mcp.method.name | tools/call | MCP 协议方法 |
gen_ai.tool.name | echo | 被调用的具体工具 |
gen_ai.operation.name | execute_tool | GenAI 语义约定 |
error.type | 错误类名 | 仅在失败时设置 |
wrapper 代码如下,直接放进你的 MCP 服务器入口文件:
const { trace, SpanStatusCode } = require('@opentelemetry/api'); const tracer = trace.getTracer('everything-mcp-server', '1.0.0'); function withToolSpan(toolName, fn) { return tracer.startActiveSpan(`tools/call ${toolName}`, (span) => { span.setAttribute('mcp.method.name', 'tools/call'); span.setAttribute('gen_ai.tool.name', toolName); span.setAttribute('gen_ai.operation.name', 'execute_tool'); try { const result = fn(); span.end(); return result; } catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); span.setAttribute('error.type', err.constructor.name); span.end(); throw err; } }); }每个 tool handler 都用withToolSpan包一层。比如echo工具:
server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === 'echo') { return withToolSpan('echo', () => { return { content: [{ type: 'text', text: args.message }] }; }); } if (name === 'get-sum') { return withToolSpan('get-sum', () => { const sum = Number(args.a) + Number(args.b); return { content: [{ type: 'text', text: String(sum) }] }; }); } // 其他工具... });注意withToolSpan是同步的,如果你的工具处理器是 async,需要改成await版本,把fn()换成await fn(),并在span.end()前处理 Promise。异步版本的 wrapper 我放在下面:
async function withToolSpanAsync(toolName, fn) { return tracer.startActiveSpan(`tools/call ${toolName}`, async (span) => { span.setAttribute('mcp.method.name', 'tools/call'); span.setAttribute('gen_ai.tool.name', toolName); span.setAttribute('gen_ai.operation.name', 'execute_tool'); try { const result = await fn(); span.end(); return result; } catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); span.setAttribute('error.type', err.constructor.name); span.end(); throw err; } }); }安全提醒:OTel 规范定义了两个可选属性gen_ai.tool.call.arguments和gen_ai.tool.call.result,这两个字段可能包含敏感数据。比如get-env工具会返回所有环境变量,里面可能有 API Key。只有在确认数据可以安全存储时才采集这两个属性,建议在 SDK 层做脱敏或过滤后再导出。我在 demo 里默认不采集这两个字段。
配置写完,启动 Claude Desktop,触发几次工具调用。接下来验证数据是否真的进了 Elastic APM。
4. 验证请求:一次工具调用追踪的完整过程
配置好之后,怎么确认追踪真的生效了?这一节给出完整的验证步骤,从触发调用到在 Kibana 里看到 trace。
第一步,重启 Claude Desktop。claude_desktop_config.json修改后必须完全退出 Claude Desktop 再重新打开,不是关窗口,是从托盘或任务管理器里彻底退出。这一步很多人漏掉,导致配置没生效还以为代码有问题。
第二步,在 Claude Desktop 里触发工具调用。用自然语言描述就行,比如:
使用 echo 工具说 hello,然后使用 get-sum 计算 1337 加 42,最后运行一个包含 3 个步骤的 long-running operation。
Claude 会依次调用三个工具。每次调用都会生成一个 span,通过 OTLP 导出到 Elastic APM。
第三步,打开 Kibana,进入 Observability > Applications > Services Inventory。你应该能看到everything-mcp-server这个服务。点进去,进入 Transactions 视图。因为遵循了语义约定,每个工具会单独一行:tools/call echo、tools/call get-sum、tools/call trigger-long-running-operation。每行显示延迟、吞吐量和错误率,不需要任何额外配置。
第四步,点进某个具体 trace,看 waterfall 视图。单个工具调用的结构很简单:一个 span 覆盖整个执行过程。如果你的 tool handler 内部调了下游 HTTP 请求或数据库查询,这些会被自动插桩,作为子 span 显示出来。你能清楚看到时间消耗是在业务逻辑里,还是在等外部调用。
第五步,看延迟分布。延迟图展示 p50、p95、p99 的分布,覆盖某个工具的所有执行记录。trigger-long-running-operation因为步骤数不同,分布会比较宽,这是设置告警前理解预期执行时间范围的有效基线。
第六步,验证错误追踪。故意让某个工具抛错,比如在get-sum里传非数字参数。失败的工具调用会出现在 Errors 面板,包含完整堆栈、失败时的 span 属性,以及错误发生次数。因为用了span.recordException(err),Kibana 会把错误直接关联到对应 trace。
到这里,一次完整的工具调用追踪就验证完了。你可以在 Kibana 里按工具名筛选、按时间范围过滤、按错误状态过滤。这套数据是后续做告警和性能优化的基础。
如果你同时配了 TaoToken 的模型侧调用,可以在工具内部加一段模型调用代码,验证模型请求也走通了。比如某个工具需要调 LLM 做文本摘要:
async function summarizeWithModel(text) { const resp = await fetch(`${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}` }, body: JSON.stringify({ model: process.env.TAOTOKEN_MODEL_ID, messages: [{ role: 'user', content: `Summarize: ${text}` }] }) }); const data = await resp.json(); return data.choices[0].message.content; }这段代码放在withToolSpan内部,模型调用的耗时会被算进工具 span 的总时长里。如果模型调用慢,你在 waterfall 里能看到工具 span 变长,但子 span 里没有对应的 HTTP 记录(因为 fetch 的自动插桩可能没覆盖到这个调用),这时你可以手动加一个子 span 把模型调用单独标出来。
验证通过后,你就有了一个可观测的 MCP 服务器。接下来讲排障,这是实际落地时最容易卡住的地方。
5. 常见报错排查:401、local proxy failed、reading choices
配置过程中会遇到几类典型报错。我按实际踩过的坑逐个讲,每个都给出报错原文和排查路径。
401 Unauthorized(遥测导出)
报错通常出现在 MCP 服务器启动日志里,或者 Kibana 里看不到任何数据。原因几乎都是OTEL_EXPORTER_OTLP_HEADERS格式不对。正确格式是:
Authorization=ApiKey your-elastic-apm-api-key注意三点:Authorization和ApiKey之间是等号,ApiKey和你的 key 之间是空格,整个字符串不要加引号(在 JSON 里作为字符串值自然有引号,但值内部不要再套引号)。如果你把 TaoToken 的 Key 填到这里,也会 401,因为 Elastic APM 不认这个 Key。两个 Key 分清楚:遥测导出用 Elastic 的,模型调用用 TaoToken 的。
local proxy failed
这个报错一般出现在 Claude Desktop 启动 MCP 服务器时,提示连接本地代理失败。排查顺序:先确认command和args里的路径是绝对路径,不能是相对路径。--import后面的import.mjs路径要精确到文件,不能只写到目录。再确认 Node 版本,EDOT 要求 Node 18 以上。最后确认claude_desktop_config.json是合法 JSON,多一个逗号都会导致整个配置解析失败,Claude Desktop 会静默忽略这个服务器。
reading choices 报错
这个报错出现在模型调用侧,通常是data.choices为 undefined。原因有几个:Base URL 拼错,比如写成了https://taotoken.net/api/v1导致路径重复;API Key 无效或过期;Model ID 填了一个控制台里不存在的模型。排查方法是在工具代码里把resp.status和data打出来:
if (!resp.ok) { console.error('Model call failed', resp.status, await resp.text()); }这样你能看到是 401(Key 问题)、404(路径或模型 ID 问题)还是 429(限流)。确认 Base URL 是https://taotoken.net/api,不要自己加/v1。
OAuth 相关报错
如果你用的是需要 OAuth 的 MCP 服务器(比如某些远程 MCP),报错可能是OAuth token expired或invalid_grant。这类问题跟 OTel 无关,是 MCP 服务器自身的鉴权。排查时先确认 OAuth token 是否过期,重新走一遍授权流程。如果同时配了 TaoToken,注意不要把 TaoToken 的 Key 当成 OAuth token 用,两者协议不同。
Kibana 里看不到服务
配置都对了,但 Services Inventory 里没有everything-mcp-server。排查:确认OTEL_SERVICE_NAME设置了;确认OTEL_EXPORTER_OTLP_ENDPOINT指向的是 APM 的 OTLP 端点而不是 Elasticsearch 的地址;确认 Elastic APM 版本是 9.3+ 或 Serverless;确认网络能通到 OTLP 端点。如果用的是 EDOT,确认elasticapmconnector 被加载了,没有这个 connector,数据会进 Elasticsearch 但 APM UI 不显示。
span 没有子 span
工具内部调了下游 HTTP,但 waterfall 里看不到子 span。这是因为自动插桩没覆盖到那个库。EDOT 自动插桩覆盖常见的 HTTP 客户端和数据库驱动,但如果你用的是比较冷门的库,需要手动加插桩或手动创建子 span。手动创建子 span 的方式是在withToolSpan内部再tracer.startActiveSpan,嵌套的 span 会自动形成父子关系。
排障的核心思路是:先确认数据有没有到 Elasticsearch,再确认 APM UI 能不能解析。前者看 OTLP 导出日志,后者看 connector 是否加载。模型侧的报错则先看 HTTP 状态码,再看响应体,基本能定位到是 Key、路径还是模型 ID 的问题。
6. 把模型侧和遥测侧统一到一套 Key 管理
回到标题里的「统一 Key 接入实践」。整篇文章配下来,你手上其实有两套鉴权:Elastic APM 的 API Key 用于遥测导出,TaoToken 的 API Key 用于模型调用。这两套不要混,但可以统一管理。
统一管理的价值在于排障时心智负担小。当你在 Kibana 里看到某个工具调用异常,能快速判断是遥测链路的问题还是模型调用的问题。如果两套 Key 散落在多个配置文件里,排查时容易搞混。
具体做法:把 TaoToken 的 Base URL、API Key、Model ID 作为环境变量注入 MCP 服务器,工具代码里只读环境变量,不硬编码。这样换 Key 或换模型时只改一处配置。Elastic APM 的配置同理,OTEL_EXPORTER_OTLP_HEADERS里的 Key 也只在一处出现。
如果你需要长期跑编码类 Agent 或做多轮工具调用,可以考虑 TaoToken 的 Coding Plan,它适合需要稳定模型调用配额的场景。模型对话调试可以用模型对话页面快速验证 Key 和模型 ID 是否配对。接入文档里有各语言的完整示例,配置时对照着看能少踩很多坑。
最后给一个实用技巧:在 MCP 服务器启动时打印一行配置摘要,把服务名、OTLP 端点(脱敏)、TaoToken Base URL(脱敏)打出来。这样每次启动你都能确认配置加载正确,不用等到 Kibana 里看不到数据才回头查。
console.error('[mcp-otel] service=%s otlp=%s taotoken=%s', process.env.OTEL_SERVICE_NAME, process.env.OTEL_EXPORTER_OTLP_ENDPOINT, process.env.TAOTOKEN_BASE_URL );这行日志在 stdio 模式下会输出到 Claude Desktop 的 MCP 日志里,排查时非常有用。注意用console.error而不是console.log,因为 stdio 模式下console.log会污染 MCP 协议的正常输出,导致协议解析失败。这个坑我踩过,工具调用突然全部失败,最后发现是日志打到了 stdout。
整套方案跑通后,你的 MCP 服务器就有了完整的可观测性:工具调用耗时、错误分布、慢工具识别、性能基线对比。模型侧走 TaoToken 统一入口,遥测侧走 Elastic APM,两边各司其职。后续要加告警、做性能优化、分析异常,都有数据支撑。