news 2026/9/20 19:28:05

MCP Python SDK 服务端 OpenTelemetry 追踪指南:默认开启的 span、GenAI 语义与零成本观测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCP Python SDK 服务端 OpenTelemetry 追踪指南:默认开启的 span、GenAI 语义与零成本观测

MCP Python SDK 服务端 OpenTelemetry 追踪指南:默认开启的 span、GenAI 语义与零成本观测

【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk

本篇指南围绕官方 Python SDK(Model Context Protocol 的 Python 实现)讲解服务端内置的 OpenTelemetry 追踪能力:从创建MCPServer的那一刻起,每一个入站消息都会被自动包装成一个SERVERspan,你无需编写或导入任何追踪代码。读完本文,你将掌握这些 span 的命名规则与属性语义、tools/callprompts/get的 GenAI 语义约定、如何以零成本获得观测能力(直至你主动安装导出器),以及如何在需要时彻底关闭追踪。

你的服务器已经处于被追踪状态

这是本文最核心、也最反直觉的事实:每一个你用本 SDK 创建的服务器,默认就会为它处理的每一个消息发射一个 OpenTelemetry span。这段逻辑不是你写的,你也不需要 import 它——它在你调用MCPServer(...)的那一刻就已经存在。

例如下面就是一个完整、且已被追踪的服务器(见 docs_src/opentelemetry/tutorial001.py):

from mcp.server import MCPServer mcp = MCPServer("Bookshop") @mcp.tool() def search_books(query: str) -> str: """Search the catalog by title or author.""" return f"Found 3 books matching {query!r}."

调用search_books,就会为这次调用创建一个 span。这一行为同时覆盖高层 API 与底层 API:MCPServer(FastMCP 风格的高层封装)和底层的Server都内置同样的追踪逻辑,本文所述的规则对两者完全一致。

从源码看,这一默认行为是在底层Server构造时写入的——其 middleware 列表初始化时就包含了追踪中间件(src/mcp/server/lowlevel/server.py#L434-L440):

# `OpenTelemetryMiddleware` ships on by default so every server emits a # ... span per inbound message ... self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]

OpenTelemetryMiddleware的实现位于 src/mcp/server/_otel.py,它通过mcp.shared._otel.otel_span创建 span,tracer 名称为mcp-python-sdk(见 src/mcp/shared/_otel.py#L14)。

你获得了什么:span 命名、属性与错误状态

Span 的命名规则

每一个入站消息都成为一个SERVER类型的 span(SpanKind.SERVER),名称由方法名与目标拼接而成:

  • search_bookstools/call,span 名为tools/call search_books
  • 单纯的tools/list,span 名就是tools/list

这条规则对应 src/mcp/server/_otel.py#L36 中的实现:name=f"{ctx.method}{f' {target}' if target else ''}",其中target取自请求参数中的name字段。

Span 携带的属性

每个 span 都带有若干标准属性:

属性说明
mcp.method.name被调用的 MCP 方法名,例如tools/call
mcp.protocol.version协议版本号
jsonrpc.request.id仅存在于请求上;通知(notification)没有请求 id,因此不携带该属性

这三个属性在 src/mcp/server/_otel.py#L21-L26 中被写入 span。注意jsonrpc.request.id只有在ctx.request_id is not None时才设置——这正对应"通知没有 id"的语义。

错误状态如何反映到 span

  • 处理器抛异常:span 状态被置为 error。具体到实现,src/mcp/server/_otel.py#L45-L59 对三类异常做了差异化处理:
    • MCPError:写入error.type(错误码)与rpc.response.status_code,并将状态置为StatusCode.ERROR
    • ValidationError:对应INVALID_PARAMS错误码,同样标记为错误(此处刻意镜像了脱敏后的线上响应,因为 pydantic 的原始消息可能包含客户端输入);
    • 其他异常:写入error.type(异常类名)、record_exception记录异常对象,并将状态置为 error。
  • 工具返回is_error=True的结果:span 状态同样被置为 error。实现中(src/mcp/server/_otel.py#L61-L71)会在响应序列化之前匹配CallToolResult(is_error=True){"isError": True}两种形态,命中则写入error.type: "tool_error"

GenAI 语义约定:让工具调用像任何 Agent 一样被分组

追踪一次工具调用是遥测里最常见也最被需要的场景,因此tools/call的 span 会额外遵循 OpenTelemetry 的GenAI 语义约定(GenAI semantic conventions),以对齐主流 Agent 追踪平台的展示方式:

  • gen_ai.operation.name:固定为"execute_tool"
  • gen_ai.tool.name:被调用的工具名称。

同样地,prompts/get的 span 会获得gen_ai.prompt.name(提示词名称)。而各类 list 方法(如tools/listresources/list)由于没有可命名的对象,不会携带任何gen_ai.*键。

这些逻辑集中在 src/mcp/server/_otel.py#L28-L33:

if ctx.method == "tools/call": attributes["gen_ai.operation.name"] = "execute_tool" if target is not None: attributes["gen_ai.tool.name"] = target elif ctx.method == "prompts/get" and target is not None: attributes["gen_ai.prompt.name"] = target

提示:正是这些 GenAI 属性,让你的追踪界面把 MCP 工具调用与其他 Agent 的工具调用归入同一组视图。你无需编写任何额外代码,就免费获得了这种分组能力。

测试代码对上述行为做了逐一验证,例如 tests/server/test_otel.py#L66-L68 断言tools/callspan 携带mcp.method.namegen_ai.operation.namegen_ai.tool.name,而 tests/server/test_otel.py#L122-L135 验证非工具方法不会携带gen_ai.*属性。

零成本:直到你真正想要观测的那一刻

"默认开启"之所以是一个让人安心的默认值,关键在于成本控制:

SDK 只依赖opentelemetry-api——OpenTelemetry 的轻量一半。当环境中没有安装 OpenTelemetry SDK 与导出器时,创建 span 是一个no-op(空操作):你的服务器此刻"正在发射"的 span 几乎不消耗任何资源,也没有任何人收集它们

当你某一天想真正"看到"这些 span 时,只需安装另一半并把它指向某个后端:

uv add opentelemetry-sdk opentelemetry-exporter-otlp

然后用 OpenTelemetry 的常规方式配置一个导出器,SDK 此前一直在默默创建的每一个 span 就会立即"点亮"。而你的服务器代码一行都不需要改

一个开箱即用的后端:Pydantic Logfire

Pydantic Logfire 就是这类后端之一,而且它把配置也替你做好了:

pip install logfire
logfire.configure()

之后你的 MCP span 就会出现在 Logfire 的实时视图中。由于 Logfire 本身构建在 OpenTelemetry 之上,本文所述的一切规则对它同样适用。

跨网络的追踪:一条贯穿客户端与服务端的 trace

一条 trace 最大的价值,在于能以一幅连贯的画面追踪请求从客户端进入服务端的完整旅程。

当客户端与服务端都运行本 SDK 时,这种串联是自动完成的:客户端把 W3C trace context(traceparent/tracestate)注入到请求中,服务端在收到请求时读取它,于是服务端 span 会嵌套在客户端 span 之下,二者属于同一条 trace。这一行为对应规范 SEP-414,你无需做任何事即可获得。

从源码看,链路两侧的实现分别是:

  • 客户端注入:在 src/mcp/shared/jsonrpc_dispatcher.py#L389-L390 中,出站请求的_meta字典被调用inject_trace_context注入 W3C trace context(注释明确标注了 SEP-414)。inject_trace_context的定义在 src/mcp/shared/_otel.py#L39-L41。
  • 服务端提取:在 src/mcp/server/_otel.py#L39,extract_trace_context(ctx.meta)从入站消息的_meta中解析 trace context,并将其作为 span 的父上下文传入。

extract_trace_context还有一个重要的降级逻辑(src/mcp/shared/_otel.py#L44-L60):当入站消息没有携带任何 trace context(例如请求来自一个非 SDK 编写的客户端)时,它会返回None,此时服务端 span 并不会启动一条全新的孤儿 trace,而是简单地挂到服务端当前已激活的 span 之下(ambient parenting)。源码注释特别强调:若返回一个显式的空Context反而会导致 span 变成孤儿,无法嵌套到当前 span 之下。

如何关闭追踪

追踪本质上是一个 middleware——而且是你服务器 middleware 列表中的第一个(位于列表最前、最靠近网络侧,参见 中间件文档 中"列表按最外层优先执行,middleware[0]最接近线上"的说明)。如果你确实需要一个不发射任何 span 的服务器,可以把它移除:

from mcp.server._otel import OpenTelemetryMiddleware mcp._lowlevel_server.middleware[:] = [ m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) ]

警告:请注意上面这个 import 以下划线开头(_otel),这是刻意为之——该类是临时性(provisional)的,与 底层Server.middleware的临时状态 一致,导入路径在未来版本中可能变化。实际上你几乎永远不需要这么做:在没有安装导出器时,span 是免费的,常规做法就是保持开启、不安装导出器

小结

  • 每个MCPServer和每个底层Server都默认针对每个入站消息发射一个SERVERspan,你不需要编写任何代码。
  • span 携带mcp.method.namemcp.protocol.versiontools/callprompts/get还携带 GenAI 属性,使你的工具调用能像任何其他 Agent 的调用一样被分组展示。
  • 在你安装 OpenTelemetry SDK 与导出器之前,这一切零成本;安装之后一切自动"点亮",服务器代码无需任何改动。
  • 当客户端与服务端都运行本 SDK 时,客户端到服务端的 trace context 自动传播(SEP-414),服务端 span 会正确嵌套进同一条 trace。

一个请求最终能否被真正执行,取决于授权(Authorization)机制——追踪负责"记录",而授权负责"放行"。

【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 19:27:58

Windows Defender无法启动?5步修复流程解决所有常见报错

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 19:27:38

Atlas 300V 24G推理卡部署YOLO全流程:从NPU概念到模型转换与调优

从“atlas部署yolo”和“atlas 300v 24g 是运算加速卡吗”这两组高频问题来看,很多人第一次接触Atlas系列产品时,卡住的点往往不是模型本身,而是根本没搞明白自己手里这块卡到底是什么东西。我手头这块Atlas 300V已经用了三个多月&#xff0c…

作者头像 李华
网站建设 2026/9/20 19:27:37

Molio 编排 Claude Code 写作,Base URL 填 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 19:25:51

C语言const深度解析:从编译期契约到指针实战的避坑指南

1. 被低估的const:从"只读"到编译期契约很多人对const的第一印象就是"定义常量",觉得它跟#define差不多,无非是换了个写法。我刚学C语言那会儿也是这么想的,直到有一次在项目里因为一个const修饰的指针参数写…

作者头像 李华
网站建设 2026/9/20 19:21:43

RxDB RxStorage 层详解:为每种运行环境选择与组合最佳存储引擎

RxDB RxStorage 层详解:为每种运行环境选择与组合最佳存储引擎 【免费下载链接】rxdb The local-first database that runs on every JS runtime and replicates with your existing backend - no vendor, no lock-in - https://rxdb.info/ 项目地址: https://git…

作者头像 李华