集成
某处有一个守护进程正在运行。这里讲的是其他东西到达它的三种方式,以及怎么在其中做选择。
mcpd 只有一道门,进这道门有三种走法。下面讲到的一切都在和同一套 HTTP 接口对话:SDK 是它之上的一层便利封装,MCP 服务器则是它的一个客户端。
一个端口,四类接口
单个进程在一个端口上提供全部服务,默认是 4317。不需要 sidecar,不需要在旁边 再跑一个 collector,也不需要为界面单独跑一个静态文件服务器。
| 路径 | 是什么 | 密钥 |
|---|---|---|
/v1/traces、/v1/logs、/v1/metrics | OpenTelemetry 摄取入口。任何 OTel SDK 都可以用,你的应用里不需要写任何 mcpd 专有的代码。 | ingest |
/v1/ingest | 原生摄取入口。一个批次可以任意混合六种信号,覆盖整个数据模型;一个请求可以携带一串批次。 | ingest |
/v1/api/… | 查询 API。界面和 SDK 都只通过它读取数据,没有别的通道。 | read、write、admin、execute 或 worker,由每个端点各自声明 |
/ | 运维界面,已编译进这个二进制文件。 | 页面本身不需要;它的数据走查询 API |
还有两个路径,/healthz 和 /readyz,不需要认证,
这样探针才能访问到它们。当你要弄清一个端点到底通不通时,先从这里开始:
$ curl -s "$MCPD/healthz"{"status":"ok","version":"0.1.0","spec_version":"1.0.0",…}$ curl -s "$MCPD/readyz"storage is not reachable: …# readyz 会访问存储,所以这里返回 503 表示守护进程是活的,# 而它背后的数据库不是。 不用 SDK,直接调 HTTP API
只要你的语言有 HTTP 客户端,它就能和 mcpd 通信。没有什么要注册,不用事先创建 项目,也没有握手。每个 SDK 都只是下面这些请求之上的一层便利封装;选这条路, 你付出的是批处理、重试和 id 生成,这些之后要你自己做。
写入一次运行
POST /v1/ingest 接收一个批次。一个批次可以同时携带 span、日志、
指标、评分、反馈和方面注释,这也是原生入口要和 OTLP 入口并存的原因:
这六种信号共用 trace id 和 span id,把它们放在一个请求里发送,它们的关联就不需要再查一次。
有三种形态可以送到这个端点,由 content type 说明是哪一种。没有任何东西是从 字节里猜出来的,所以在字符串里加一个换行,绝不会改变一个请求体的含义。
| Content type | 请求体 |
|---|---|
application/json | 一个批次对象,每个 SDK 发的都是这个 |
application/json | 一个批次对象的数组,给手上攒了好几个批次的发送方用 |
application/x-ndjson | 每行一个批次,请求体边到达边读,不必整个收完再处理。application/jsonl 是同一个意思 |
每个元素就是它自己的那个批次:带 ingest_key 的元素会用自己的
事务存储,所以重发的流是逐行识别的,而不是当成一整块;没有键的元素则一起
存储。只要有任何一行读不出来,或者整个请求超过了任一上限,整个请求都会被
拒绝,一条也不存。响应是一个 IngestResult,覆盖全部内容。
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
一个批次
# One batch carries any mix of the five signals. Send the whole run at once.
curl -s -X POST "$MCPD/v1/ingest" \
-H 'content-type: application/json' \
-H "authorization: Bearer ${MCPD_API_KEY:-}" \
-d @- <<JSON
{
"ingest_key": "run-$TRACE",
"spans": [
{
"trace_id": "$TRACE",
"span_id": "$ROOT",
"name": "support_agent",
"kind": "agent_run",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": true, "is_event": false, "received_at": "$NOW",
"input": { "question": "Where is order 4417?" },
"output": { "answer": "It is in the Leeds depot." },
"correlation": { "project": "default", "environment": "production" }
},
{
"trace_id": "$TRACE",
"span_id": "$GEN",
"parent_span_id": "$ROOT",
"name": "chat",
"kind": "model_generation",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": false, "is_event": false, "received_at": "$NOW",
"model": "gpt-4o",
"provider": "openai",
"usage": { "input_tokens": 1284, "output_tokens": 210 },
"correlation": { "project": "default" }
}
]
}
JSON
# → {"accepted":2,"rejected":0,"duplicate":0}
#
# mcpd prices the model span from its own price book on the way in, so cost is
# stored with the run rather than recomputed later from rates that may have
# changed since. 这个请求必须做对四件事:
- id 是 OpenTelemetry 的形状。trace 是 32 个小写十六进制 字符,span 是 16 个,且都不能全为零。别的写法会破坏它和你在跑的其他每一个 工具之间的关联。
-
ingest_key让重试变得安全。它是一个在批次内 保持稳定的幂等键,重发会被识别出来并计为重复,而不是写入两次。 - 密钥放在请求头里。
Authorization: Bearer …,或者X-Mcpd-Key。摄取 需要一把 ingest 密钥,而且只在守护进程配置了密钥时才需要。 - 批次有上限。默认是 32 MiB 请求体和 10,000 个信号,两者
分别用
--max-body-bytes和--max-batch-signals设置。信号上限统计的是整个请求,所以把同样的信号拆到多行上并绕不过去。 数据量大时请带上content-encoding: gzip。
响应会逐个信号地报告结果:
{"accepted":2,"rejected":0,"duplicate":0},其中的
errors 数组会给出每个被拒绝项的下标和原因。OTLP 端点则按 OTLP
自己的形状回答,用一个 partialSuccess 对象带上被拒绝的数量,
因为这才是 OpenTelemetry 发送方要读的东西。
把它读回来
读取
# Read it back. Any filter in the API works here.
curl -s "$MCPD/v1/api/traces?project=default&limit=10"
# The whole run, with its logs, scores and feedback in one request.
curl -s "$MCPD/v1/api/traces/$TRACE?include=all"
# Find the run by something the model actually said.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'q=Leeds depot'
# Find the runs a scorer marked down. This is the query that finds bad answers.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'has_score=answer_relevance' \
--data-urlencode 'score_below=0.5'
# Where the money went, over the last week, by model.
curl -s --get "$MCPD/v1/api/analytics/cost" \
--data-urlencode 'project=default' \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model'
过滤条件可以叠加,翻页用游标;省略 project 时取守护进程的默认
项目。对已存储载荷做全文检索的是 q 参数,正是它让“找出模型说过
这句话的那次运行”成为一次查询,而不是一次 grep。
mcpd 自己抛出的失败会以 JSON 返回:一个稳定的、机器可读的 error
码,一条写给要去修它的人看的 message,以及在某一个参数出错时
给出的 field。按码分支,把消息展示出来。
有一类失败不是这个形状,假定它是的客户端会解析不了这个响应。如果请求体无法
解码成端点期望的类型,Web 框架会在任何 mcpd 代码运行之前就拒绝它,于是返回
422,带 content-type: text/plain 和一条形如
missing field `scorer_id` 的消息。请求体在语法上根本不是 JSON
时返回 400,同样是纯文本。这两种情况都没有 error
码可以拿来分支。所有接收带类型请求体的端点都是如此:
POST /v1/api/scores、/v1/api/feedback、
/v1/api/score、/v1/api/datasets 及其
items 和 capture 路由,还有
/v1/api/experiments。摄取入口自己读取请求体,所以
/v1/ingest 和 OTLP 路由确实会返回 JSON 形状。遇到 4xx 而响应体
不是 JSON 时,把它当成你自己代码发出的畸形请求,按纯文本读。
那些 SDK
六种语言:TypeScript、 Python、 Rust、 Go、 Kotlin 和 Elixir。每一个在可以 地道的地方都地道,在要紧的地方则完全一致。读过 TypeScript 快速开始的人, 能猜出 Kotlin 的那一份长什么样。
相比裸 HTTP,你得到的是:
- 批处理和一个后台 worker,所以记录一个 span 只是往队列里追加一条然后返回。
- 对 429 和值得重试的那些 5xx 码做带退避的重试,对注定还是畸形的 4xx 则从不重试。
- id 生成、上下文传播,以及进来时解析、出去时写出的 W3C
traceparent,让一条 trace 跨过服务边界后仍然成立。 - 类型和守护进程的类型由同一份规范生成,所以一个字段不可能在 SDK 里是一个意思,在存储里是另一个意思。
有三条承诺在每种语言里都成立,它们是把 SDK 放进热路径也安全的原因。接入代码 从不抛异常:守护进程不可达时,每个间隔最多记一条日志,然后把错误吞掉。它 从不阻塞:网络工作发生在 worker 上,所以你的智能体的延迟不取决于 mcpd 是不是 活着。它的队列有上限:队列满时最旧的 span 会被丢弃,同时一个计数器加一,因为 一个没有上限的缓冲区会把一次网络故障变成一次内存耗尽被杀。
连接
import { Mcpd } from '@mcpd/sdk'
// Every field here also reads an environment variable, so the same code runs
// unchanged in development and in production. With no arguments at all this
// talks to a daemon on http://localhost:4317.
const mcpd = new Mcpd({
project: 'support',
environment: 'development',
serviceName: 'support-agent',
serviceVersion: '1.4.0',
}) from mcpd import FeedbackKind, Mcpd, SpanKind
# Everything here has an MCPD_* environment variable behind it, so the same code
# runs in development and in production with no edit. `Mcpd()` with no arguments
# talks to a local daemon.
mcpd = Mcpd(
endpoint=os.environ.get("MCPD_ENDPOINT", "http://localhost:4317"),
project="support",
environment="development",
service_name="support-agent",
service_version="1.4.2",
) // Nothing here is required. `Mcpd::new()` reads MCPD_ENDPOINT, MCPD_API_KEY
// and the rest from the environment and talks to a local mcpd by default.
// Anything set in code wins over the environment.
let mcpd = Mcpd::with_config(
Config::from_env()
.with_project("support")
.with_environment("local")
.with_service("support-agent", Some("1.0.0")),
); m := mcpd.New(
mcpd.WithEndpoint("http://localhost:4317"),
mcpd.WithProject("support"),
mcpd.WithEnvironment("development"),
mcpd.WithService("support-agent", "1.4.2"),
)
// Shutdown flushes what is still queued and stops the background worker.
// A short-lived process that skips it loses whatever had not been sent.
defer m.Shutdown(ctx) // Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
McpdConfig(
endpoint = "http://localhost:4317",
project = "support-desk",
environment = "development",
serviceName = "support-agent",
serviceVersion = "1.4.0",
)
) # Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
Mcpd.start_link(
endpoint: "http://localhost:4317",
project: "support-desk",
environment: "development",
service_name: "support-agent",
service_version: "1.4.0"
) # mcpd needs no registration and no project to be created up front. Check that
# the daemon is up and which spec version it implements.
curl -s "$MCPD/healthz"
# → {"status":"ok","version":"0.1.0","spec_version":"1.0.0", ...}
不带参数构造的客户端就能连上本地的守护进程。所有设置都可以在代码里给,也可以
用环境变量给,代码优先;变量名在每种语言里都一样,所以一个值在两个服务之间
抄过去,即使两者是用不同语言写的也照样管用:
MCPD_ENDPOINT、MCPD_API_KEY、
MCPD_PROJECT、MCPD_ENVIRONMENT、
MCPD_SERVICE_NAME、MCPD_SERVICE_VERSION、
MCPD_SAMPLE 和 MCPD_ENABLED。设置
MCPD_ENABLED=false 会让每次调用都变成空操作,但仍然返回合法的
对象,于是一个测试套件不用碰任何代码就能把 mcpd 关掉。
它们可以互换,而且这一点有测试
单元测试能证明每个 SDK 做的是它自己的作者想做的事,却证明不了这六个彼此一致。
所以一致性套件用每种语言向同一个在线的守护进程写入同一次标准运行,再加上
第七条通道,只用 curl 发送同样的这次运行,然后断言取回来的东西
在结构上完全相同:
Cross-language conformance
tests/e2e/run.mjs
| Channel | root | tree | status | error | model | tokens | score | feedback | ids |
|---|---|---|---|---|---|---|---|---|---|
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust | |||||||||
| cURL | |||||||||
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust |
13 languages, one canonical run, one daemon. Every trace comes back the same shape.
481 checks
如果你正在为一个新服务挑语言,这就是对“这些 trace 会不会和我们已经有的那些 长得一样”的回答。
如果你的语言不在这六种里
把一个 OpenTelemetry SDK 指向 /v1/traces,不用写新代码。mcpd
是一个 OTLP 接收端,它同时映射 OpenTelemetry 的 gen_ai.* 约定和
OpenInference 的 llm.* 约定,所以你已经有的接入代码会正确落地。
参见 OpenTelemetry。
通过 MCP,从 AI 智能体访问
第三条路是给智能体的,不是给服务的:不是你的代码去读 trace,而是一个编码 智能体或客服智能体替你去读,并回答关于它们的问题。“上一次运行为什么失败”, “把这周被某个评分器打低分的运行给我列出来”,“这次会话花了多少钱”。mcpd 通过 Model Context Protocol 把这件事暴露出来。
三个工具
是三个,不是三十个。它们是一次排查的三个动作:是哪一次运行,那次运行里面 发生了什么,以及这是一次运行的事还是所有运行的事。
| 工具 | 它回答什么 |
|---|---|
find_runs | 是哪一次运行。每次一行:id、距今多久、状态、耗时、成本、步骤数。 可以按状态、模型、用户、会话或自由文本过滤;按耗时排序就拿到最慢的 那几次。默认查最近 24 小时。 |
get_run | 发生了什么。瀑布图、失败的那一步以及失败的原因、提示词和补全、日志、 评分。载荷有上限,所以一次运行填不满一个上下文窗口。 |
run_stats | 一次运行,还是全部运行。一个时间窗口内的成本、失败率、延迟和 token 数,可按模型、名称、状态、环境或用户分组。40% 的失败率说明你正在看的 这次运行不是问题所在。 |
结果以文本返回,而不是 JSON,这是一个关于这件事花的是谁的上下文的有意选择。 四十个 span 对象里大半是标点;同一次运行画成一张瀑布图只有几百个 token, 而且智能体能看出它的形状。
它长什么样
MCP 服务器是同一个二进制文件的一个子命令,而不是第二个服务。它以
mcpd mcp 运行,在标准输入和标准输出上讲 JSON-RPC,自己不持有
任何数据。没有端口要开,没有进程要保活,也没有东西要部署:智能体宿主在一次
对话需要它的时候把它作为子进程启动,用完就停掉。
它确实持有的,是一条通向某个守护进程的连接。每一次工具调用都会变成一次对 上面那套查询 API 的请求,用的是同一把读取密钥、同一套项目范围,所以智能体够不到 它拿到的那把密钥够不到的项目。把这个服务器交给一个智能体,影响范围恰好等于 把那把密钥交给它的影响范围,这也是你在配置它之前要核对的那条性质。
工具集是有意做小的。接口面一大,模型就会选错;而关于一个智能体的行为的大多数
问题,都可以归结为列出运行、打开其中一次,以及在一个时间窗口上问一个数值
问题。客户端用协议自己的 tools/list 调用来发现当前这一组工具,
而不是靠这里写下的、可能会过时的一份清单。
配置一个客户端
MCP 宿主用一条命令、一些参数和一份环境变量来启动服务器。具体是哪个文件、哪个 配置键,是宿主的事,不是 mcpd 的事,所以请查它的文档;而它要从你这里拿到的 三个值,在哪里都一样。
| 宿主需要什么 | 值 |
|---|---|
| 命令 | mcpd 二进制文件,放在宿主能执行到的路径上。 |
| 参数 | mcp 子命令。它接受 --endpoint,也就是要查询的
守护进程,以及 --api-key,也就是它的一把读取密钥,给那些传
参数比传环境变量更方便的宿主用。
|
| 环境变量 |
同样这两个值,写成 MCPD_ENDPOINT 和
MCPD_API_KEY。端点默认是
http://127.0.0.1:4317;守护进程没有设置密钥时,密钥可以
不给。
|
宿主实际运行的命令
MCPD_ENDPOINT=https://mcpd.internal MCPD_API_KEY=$READ mcpd mcp 签发那把密钥时要记住三件事。
-
给智能体一把
read密钥,不要给ingest密钥。 作用域之所以存在,就是为了让写入 span 的凭据不同时成为读取每一条已存 提示词的凭据。 - 把它限定到一个项目。在顶层配置的密钥能够到这个守护进程上的每一个项目。
-
要知道,在你配置
admin列表之前,一把读取密钥同样能删除 trace, 因为 admin 作用域在未设置时会回退到 read 列表。在一个从来没有给过 admin 列表的守护进程上,一个持有读取密钥的智能体可以删掉一次运行。
这三点都在安全页上。
该用哪一种
| 路线 | 什么时候用 |
|---|---|
| HTTP | 你的语言没有 SDK,你是从一个 shell 或一个任务里发送的,或者你一个依赖都不想加。 |
| 某个 SDK | 你在给一个应用做接入,这时批处理、重试和从不抛异常比多一个依赖更值。 |
| OpenTelemetry | 你已经在发 OTel 数据,宁可改一个 URL,也不想再加一个库。 |
| MCP | 是人在向一个智能体提关于已存运行的问题,而不是代码按计划去读它们。 |
它们可以混用。一批通过 TypeScript SDK 发送数据的服务、一个每晚用
curl 提交评分的任务,加上一个通过 MCP 把这一切读回来的智能体,
这是一套部署,不是三套。
接下来
- 存了什么、谁能读:安全。
- 把已有的 OTel 配置指向 mcpd:OpenTelemetry。
- 每一个端点,都由规范生成:HTTP API。
- 值得知道的那些查询:查找一次运行。