エージェントが踏むすべてのステップを、最後まで保持し、記録に残す。
mcpd はデーモンであって、フレームワークではありません。ステップ間の実行を保持するのは mcpd です。リトライも、タイマーも、承認を待つ 1 週間もそこに含まれます。ステップの中身は、あなた自身のコードがどの言語でも担当します。すべてのステップは、かかったコストとともにトレースに残ります。
ビルドして実行
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo あとは localhost:4317 を開くだけです。アカウントも、立ち上げるデータベースも、署名するものもありません。リリースはまだありません。イメージとパッケージは最初のリリースと同時に公開します。5 分のクイックスタートを読む。
読み解ける実行。
エージェントが失敗する場所は、エラーが書かれたログ行ではありません。失敗はその 3 ステップ前、モデルが誤ったツールを選び、もう一度同じツールを選んだところにあります。
mcpd は実行全体をツリーとして復元します。モデル呼び出し、ツール呼び出し、検索の一つひとつに、そのときのプロンプトと補完、それぞれのレイテンシ、そしてそれぞれのコストが付きます。
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
実行ドラム
同じ実行を記録ドラムに巻いたものです。時間は周に沿って進み、スパンごとに 1 本のトラックがあります。回すと、ペンが真下のスパンを読みます。
起動したプロセスより長く生きる実行。
ステップ間の実行はデーモンが持ちます。ステップの中身はすべてあなたのプロセスが持ち、何も渡しません。モデルも、プロンプトも、資格情報もです。
リース盤
デーモンはステップごとに 1 枚のタグを盤に掛けています。ワーカーがタグを持てるのはハートビートが届いている間だけです。ワーカーを止めると、リース期間と回収ウィンドウ 1 回分の後、タグは盤に戻り、もう一方のワーカーが取ります。
グラフはデータ、本体はあなたのもの
ワークフローはコードで一度だけ書きます。グラフは定義としてデーモンに渡り、そこで検証されてバージョンが付きます。ステップの本体は 6 言語のいずれかであなたのプロセスに残り、ワーカーがそれを実行します。
リトライ、タイマー、待機は、すべてストアの行
一時的な失敗は、そのステップ自身の予算でリトライします。スリープや承認待ちの間はプロセスが 1 つも生きていなくても実行が保たれ、デプロイも、クラッシュも、1 週間も越えられます。イベントは名前とキーで待機に応え、同じイベントが再び届いても重複であって、2 回目の実行にはなりません。
すべての実行がトレース
各ステップは、入力、出力、報告された使用量を持つスパンです。価格は、ほかのスパンに付けるのと同じ料金表で付きます。ステップ内部のモデル呼び出しはその下にぶら下がるので、検索もコストもスコアラーも、実行をそのまま扱えます。
すでに使っている言語で計装。
6 つの SDK、振る舞いは 1 つです。スパンの記録がブロックすることも、例外を投げることも、際限なく増えることもありません。mcpd に届かなくても、あなたのエージェントは気づきません。可観測性の失敗の仕方として、許されるのはそれだけです。どの SDK もワーカーを備えているので、永続実行のステップは、それを記録するのと同じプロセスから提供されます。
どれも必須ではありません。OTEL_EXPORTER_OTLP_ENDPOINT を mcpd に向ければ、すでに動かしている OpenTelemetry SDK がそのまま届きます。あなたのアプリケーションに私たちのコードは 1 行も入りません。6 つの SDK は HTTP の面の上に置いた便利な層であって、記録される条件ではありません。
フレームワークに付属してくる可観測性が記録できるのは、そのフレームワークで作ったエージェントだけです。mcpd は OTLP エンドポイントに届いたものを何でも受け取ります。エージェントフレームワークでも、自分で書いたループでも、そのどちらかが呼び出すサービスでもかまいません。
実行を計装する
/**
* One ticket, one trace.
*
* `withSpan` opens the span, keeps it active for everything the callback
* starts, and ends it when the callback settles. Nothing inside has to be
* passed a handle to be nested under it.
*/
async function handleTicket(ticket: Ticket): Promise<{ answer: string; traceId: string }> {
let traceId = ''
const answer = await mcpd.withSpan('support_agent', async (run) => {
// Keep the id: a score or a piece of feedback that arrives later attaches
// to this run by it.
traceId = run.traceId
// Tags label the whole run, so the trace list can filter on them.
run.setTags(['support', 'tier-1'])
run.setCorrelation({ sessionId: ticket.id, userId: ticket.customerId })
run.setInput({ question: ticket.question })
const order = await lookUpOrder(ticket)
const answer = await draftAnswer(ticket, order)
run.setOutput({ answer })
return answer
})
return { answer, traceId }
} # The root span is the run. Everything opened inside it becomes a child,
# because the active span lives in a `contextvars.ContextVar`.
with mcpd.trace("support_agent", session_id="sess-8121", user_id=user_id,
tags=["support", "orders"]) as run:
run.set_input({"question": question})
# A model call. `set_usage` is what mcpd prices the run from, so the
# cost on the trace is the cost you were actually billed.
with mcpd.span("plan", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_model("gpt-4o-mini", provider="openai")
generation.set_input([
{"role": "system", "content": "You are a support agent."},
{"role": "user", "content": question},
])
time.sleep(0.12) # stand-in for the provider round trip
generation.set_output("I should look the order up first.")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)
# A tool call that fails. The `with` block records the exception on the
# span, marks it as an error, and lets the exception through unchanged.
order = None
try:
with mcpd.span("lookup_order", kind=SpanKind.TOOL_CALL) as tool:
tool.set_entity("tool", entity_id="orders.lookup", name="Order lookup")
tool.set_input({"order_id": "A-7741"})
order = lookup_order("A-7741")
tool.set_output(order)
except TimeoutError as exc:
# The span already carries the error. This is the agent deciding
# what to do about it, which is a different question.
mcpd.log("warn", "order lookup failed, answering from context", {"error": str(exc)})
mcpd.metric("support.tool_failures", 1, "counter", labels={"tool": "orders.lookup"})
reply = ("I could not reach the order system just now. Your order A-7741 "
"shipped on Tuesday and tracking should update within the hour.")
run.set_output(reply)
run.set_metadata({"used_fallback": order is None})
return reply, run.trace_id // The root span is the run. The sampling decision is made here, once, and
// every child inherits it.
let run = mcpd.trace("support agent");
run.set_input(question);
run.set_session("session-4f21");
run.set_user("customer-91");
run.set_tags(["support", "orders"]);
// A tool call. `run.child_of` nests it under the run.
let lookup = run.child_of("lookup_order", SpanKind::ToolCall);
lookup.set_input(json!({ "order_id": "5512" }));
let order = json!({ "status": "shipped", "carrier": "DHL", "eta": "Tuesday" });
lookup.set_output(&order);
lookup.end();
// A tool that fails. Recording the error sets the span's status to `error`,
// and a message is required: "something failed" is not actionable.
let notify = run.child_of("notify_customer", SpanKind::ToolCall);
notify.set_input(json!({ "channel": "sms" }));
notify.set_error("sms gateway timed out after 5s");
notify.end();
// The failure did not stop the run: the agent falls back to email, and the
// trace shows both the attempt and the recovery.
run.event_with("fell back to email", json!({ "after": "sms gateway timeout" })); // handleTicket is the agent. It drafts an answer with a model, tries to send
// it with a tool, and fails on the send so the error path is recorded too.
func handleTicket(ctx context.Context, m *mcpd.Client, question string) string {
ctx, run := m.Start(ctx, "support_agent",
mcpd.WithSpanKind(mcpd.SpanKindAgentRun),
mcpd.WithEntity(mcpd.EntityKindAgent, "support-agent", "Support agent"),
mcpd.WithSession("sess-4821"),
mcpd.WithUser("u-317"),
mcpd.WithTags("support", "billing"),
mcpd.WithInput(question),
)
defer run.End()
m.Log(ctx, mcpd.LogLevelInfo, "ticket received", map[string]any{"channel": "email"})
answer := draftAnswer(ctx, m, question)
run.SetOutput(answer)
if err := sendReply(ctx, m, answer); err != nil {
// The span is now an error with a message, which is what makes it
// worth reading. End below records the failed status.
run.RecordError(err, mcpd.ErrorDomain("email"), mcpd.ErrorRetryable(true))
}
return run.TraceID()
} val question = "where is my order 41?"
// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
"support_agent",
SpanKind.AGENT_RUN,
input = question,
sessionId = "session-8f21",
userId = "customer-4477",
tags = listOf("support", "email"),
) { run ->
mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))
val plan = planAnswer(mcpd, question)
val order = lookUpOrder(mcpd, 41)
val estimate = estimateDelivery(mcpd, 41)
val answer = buildString {
append(plan).append(' ').append(order)
if (estimate != null) append(" It should arrive ").append(estimate).append('.')
}
run.setOutput(answer)
mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")
// The block's value is the value of `span`.
run.traceId
} question = "where is my order 41?"
# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
Mcpd.span mcpd, "support_agent",
kind: :agent_run,
input: question,
session_id: "session-8f21",
user_id: "customer-4477",
tags: ["support", "email"] do
Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})
plan = plan_answer.(mcpd, question)
order = look_up_order.(mcpd, 41)
estimate = estimate_delivery.(mcpd, 41)
_answer =
[plan, order, estimate && "It should arrive #{estimate}."]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
Mcpd.Context.current().trace_id
end # 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. 6 つのシグナル、結び付けるのはそれを生んだ実行。
多くのツールはトレースをここに、ログを別のどこかに置き、タイムスタンプの突き合わせは目視に任せます。mcpd は 6 つのシグナルすべてを同じ trace id と span id に対して保存します。だからグラフ上のスパイクからは、それを起こした実行が開き、実行からは、そこで出力されたログ行が見えます。
仕分け台
6 種類のシグナルが 1 本のベルトに混ざって届きます。リーダーが各片の trace id を読み、同じ実行の片はすべてその実行の引き出しに入ります。
振る舞いが一致する 13 言語。
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
どの SDK も単体でテストしていますが、それで分かるのは作者が意図したとおりに動くことだけで、互いに一致するかどうかは何も分かりません。そこで、各 SDK が 1 つのデーモンに対して同じ実行を書き込み、そのトレースを読み戻して互いに比較します。
ある言語で成り立ち、別の言語で成り立たない振る舞いは、そこでしか表に出ません。変更のたびに CI で実行します。他の選択肢との比較。
誰が何を読んだか、断られた人も含めて。
セキュリティレビューを決めるのは、そのデータが安全かどうかという問いではありません。あとから、誰がそこに届いたかを示せるかどうかです。
Audit log
GET /v1/api/audit
| Time | Principal | Action | Project | Result |
|---|---|---|---|---|
| 14:02:11 | a3f9c2e1b7d4 | get_trace | checkout | ok |
| 14:02:11 | a3f9c2e1b7d4 | list_spans | checkout | ok |
| 14:04:38 | 7c1e08b53a9f | list_dataset_items | billing | refused |
| 14:07:02 | d52a4f8c0e63 | capture_into_dataset | checkout | ok |
| 14:11:55 | d52a4f8c0e63 | delete_trace | checkout | ok |
| 14:19:20 | 7c1e08b53a9f | list_feedback | billing | ok |
A refusal is recorded like a read. The principal is a digest of the key, never the key. Retention never trims this table.
18 actions
6 つのキースコープ
テレメトリーを送ること、プロンプトを読むこと、保存済みのデータを変えること、それを破棄すること、実行を開始すること、ステップを取得することは、別々の権限です。write を設定しなければ ingest キーを受け付け、admin を設定しなければ read キーを受け付けます。execute と worker を設定しなければ write キーを受け付けます。アップグレードで締め出されることはありません。
プロジェクトが境界
キーも保持期間もサンプリングも、すべてプロジェクト単位です。あるプロジェクトに登録されたキーが届くのはそのプロジェクトだけで、デーモンはデータの持ち主を確かめてから、その中の 1 件を返します。
外部への自動通信はなし
デーモン全体で外向きのリクエストを出す箇所は 4 つだけで、そのすべてをあなたが設定します。フォワーダー、2 つの webhook、そしてジャッジです。更新チェックも、テレメトリーも、ライセンスサーバーもありません。
どのキーで、どの人物か
監査台帳にはキーが残ります。前段のイングレスがすでに付けているヘッダーと、それを付けてよいアドレスを mcpd に伝えれば、人物も残ります。それ以外の場所から送られたヘッダーは誰でも名乗れる主張にすぎないので、その欄は推測を書かずに空のままにします。
削除の依頼は 1 回の呼び出し
1 回の DELETE で、ある人物に対して記録された実行がすべて消え、そこにぶら下がるスパン、ログ、メトリクス、スコア、フィードバックも一緒に消えます。応答はシグナルごとの件数です。そのキーで開けないプロジェクトの行は、触られることも、数に入ることもありません。
信頼する前に点検
mcpd doctor は動いている構成を採点します。ストレージが書き込みを受け取って返せるか、開いたままのスコープがないか、設定済みのプロジェクトがスパンを一度でも受け取ったか。対処が必要な項目には、それを直すフラグの名前が付きます。
mcpd がやらないこと。
どのツールにもこういう一覧があります。多くは社内に留められていて、あなたが気づくのは 2 週目です。ここに出しておきます。添えるのは日付ではなく理由です。
- gRPC 上の OTLP はなし。
- HTTP は両方のエンコーディングでトレースを、JSON でログとメトリクスを運びます。gRPC はまったく対応していません。
- ベンダー名の付いたエクスポーターはなし。
- mcpd が転送するのは OTLP です。1 つの実装でほとんどの送り先に届き、mcpd はデータが留まらなければならない場所ではなく、途中の 1 ホップになります。自社のプロトコルしか話さない送り先には届きません。
- SSO も SCIM もなし。
- キーのスコープはプロジェクトで、これはテナントの境界であって、ID プロバイダーではありません。その人が誰であるかは、mcpd の前段でイングレスが確立したものがすべてです。
- マルチリージョンの取り込みはなし。
- Postgres や ClickHouse の上では、1 つのサービスの背後で複数のレプリカとして動きます。それはリージョン間のフェイルオーバーではありません。
既存製品のソースを直接監査した結果と突き合わせた完全な内訳は PARITY.md にあります。更新するのは何かが入ったときであって、何かが計画されたときではありません。
最初のトレースまで 5 分。
デーモンを起動し、エージェントをそこへ向け、インターフェースを開きます。それより時間がかかるなら不具合です。報告をお待ちしています。
起動
docker run -p 4317:4317 ghcr.io/mcpd/mcpd