本文へスキップ
mcpd
デーモンなし
ソース

Python

同期のコードにも非同期のコードにも使えるコンテキストマネージャー、contextvars で持つアクティブなスパン、そして必須の依存は 1 つもありません。

インストール

Python 3.9 以降で動きます。必須の依存は、意図的に 1 つもありません。 エクスポーターは標準ライブラリの urllib と gzip だけで 動くので、mcpd を入れてもロックファイルには何も増えず、エージェントがすでに 依存しているものと衝突することもありません。

pip install mcpd

httpx エクストラが足すのは、コネクションの再利用だけです。 なくても SDK の動きは変わりません。

クライアントの構築

クライアントは 1 プロセスに 1 つです。どの引数も環境変数から読めるので、 同じコードを書き換えずに開発環境でも本番環境でも動かせます。引数なしの Mcpd() は http://localhost:4317 のデーモンに接続 します。

接続

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",
)

コードが環境変数より優先され、環境変数が既定値より優先されます。

引数環境変数既定値
endpointMCPD_ENDPOINThttp://localhost:4317
api_keyMCPD_API_KEYなし
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTなし
service_nameMCPD_SERVICE_NAMEなし
service_versionMCPD_SERVICE_VERSIONなし
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capture_payloadsMCPD_CAPTURE_PAYLOADStrue
log_levelMCPD_LOG_LEVELdebug
batch_sizeMCPD_BATCH_SIZE128
flush_intervalMCPD_FLUSH_INTERVAL_MS2.0 秒
max_queueMCPD_MAX_QUEUE8192
timeoutMCPD_TIMEOUT_MS10.0 秒

enabled=False にすると、記録の呼び出しはすべて何もしなくなり ますが、返るオブジェクトは有効なままです。テスト対象のコードを変えずに、 テストのあいだ mcpd を止められます。capture_payloads=False に すると、プロンプトと補完はプロセスの中に留まります。スパンは所要時間、 ステータス、使用量、モデル、相関、エラーとともに記録され、 input と output はキューに入る前に捨てられます。 個々のスパンは開始時のフィールドに capture_payloads=True を 書けば別の扱いにでき、逆も同じです。

実行の記録

mcpd.trace(name) は実行を開始し、mcpd.span(name) は そのときアクティブなものの下にステップを開始します。どちらもコンテキスト マネージャーです。ブロックを抜けるときにスパンを終了し、例外が出ていれば それを記録したうえで、例外はそのまま通します。観測されることで、あなた自身の エラー処理が変わることはありません。

実行の記録

# 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

with ブロックを使わない場合は、run.end() を自分で 呼んでください。どちらの書き方でもスパンは開始時にエクスポートされるので、 終わらなかったスパンは、終わらなかったスパンとして見えます。たいていは それが探しているものです。

同期と非同期

# 同期も非同期も同じオブジェクト。どちらも抜けるときにスパンを終了し、
# ブロックが例外を出していればそれを記録する。
with mcpd.trace("nightly_summariser") as run:
    with mcpd.span("fetch", kind=SpanKind.TOOL_CALL) as step:
        step.set_output({"documents": 42})

async with mcpd.trace("agent") as run:
    async with mcpd.span("generate", kind=SpanKind.MODEL_GENERATION) as generation:
        generation.set_output(await model.complete(prompt))
    await mcpd.aflush()

コンテキスト

アクティブなスパンは contextvars.ContextVar です。 await をまたいでタスクの中まで付いていき、スレッドをまたいで 漏れることはありません。3 フレーム下のヘルパー関数でも、呼び出し側が引数を 引き回さずに、正しい親の子を開けます。

入れ子

def draft(question: str) -> str:
    # 何も渡していない。それでもスパンは実行の下に入る。アクティブなスパンは
    # contextvars.ContextVar で、この呼び出しがブロックの中にあるから。
    with mcpd.span("draft", kind=SpanKind.MODEL_GENERATION) as generation:
        generation.set_input(question)
        return "..."


with mcpd.trace("support_agent") as run:
    run.set_output(draft("where is my order?"))

出てくるのは、所要時間とペイロードの付いたツリーです。

Trace b3d9e71a Fault
0ms 2.1s 4.2s 6.3s 8.4s
  1. agent support_agent fail
  2. model chat
  3. tool returns_policy
  4. model chat
  5. tool returns_policy
  6. model chat
  7. 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.

サービスの間では、コンテキストは W3C の traceparent ヘッダーで 運ばれます。形式の壊れたヘッダーは、それを運んできたリクエストを失敗させる のではなく、新しいトレースを始めます。受け取った側は、サンプリングしないと いう判断にも従います。あるサービスは残し、別のサービスは捨てるようなものは、 トレースとは呼べないからです。

サービス境界をまたぐ

# 送信側: アクティブなスパンを次のサービスに渡す。
requests.post(url, headers={"traceparent": mcpd.traceparent()}, json=payload)

# 受信側: 新しいトレースを始めず、そのトレースを続ける。
with mcpd.trace("handler", traceparent=request.headers.get("traceparent")) as run:
    ...

モデル、使用量、エラー

mcpd は取り込み時にトークン数から料金を計算するので、トレースに出るコストは 実際に請求されたコストになります。プロバイダーが返した値をそのまま報告して ください。set_error は文字列でも例外でも受け取り、同時に status を error にし、メッセージを必ず埋めます。 「何かが失敗した」では、打つ手が決まりません。

スパンが持つもの

generation.set_model("gpt-4o-mini", provider="openai")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)

# 自分で捕まえて、自分で扱いを決めた失敗。
generation.set_error("the provider timed out", kind="TimeoutError", retryable=True)

# スパンの中の一点。それ自体は長さを持たない。
generation.event("first_token")

ほかのセッターは set_input、set_output、 set_metadata、set_attributes、 set_tags、set_entity、set_cost、 set_status、add_link です。どれもスパンを返し、 どれもスパンの開始時にキーワード引数として渡せます。 external_parent_span_id= は mcpd 以外のトレーシングシステムの 親を指しますが、相関のために運ばれるだけで、親子関係には使われません。

ペイロード、シンク、完了済みスパン

シンクは、吐き出されたバッチの行き先です。既定は HTTP トランスポート です。RecordingSink はすべてのバッチをメモリに保持するので、 テストはサーバーなしで計装が記録した内容を検証できます。 ConsoleSink は、シグナル 1 件につき JSON を 1 行、標準エラー 出力に書きます。例外を投げるシンクはバッチの失敗として数えられ、報告される だけで、あなたのコードに例外が出ることはありません。stats() は、 クライアントが作られてから何をしたかを返します。

記録内容の検証

from mcpd import Mcpd, RecordingSink

sink = RecordingSink()
mcpd = Mcpd(sink=sink)
with mcpd.trace("answer") as run:
    run.set_usage(input_tokens=12)
mcpd.flush()
assert sink.spans[0].usage.input_tokens == 12
print(mcpd.stats())  # queued, dropped, capacity, exported, failed, rejected

mcpd.record(span) は、すでに終了した SpanRecord を、 手元にある id、親、時刻のままキューに入れます。別のトレーシングシステムの アダプターや一括インポートが持っているのが、これです。親を付け替えたり、 時刻を打ち直したり、サンプリングをやり直したりはしません。形式の壊れた レコードは捨てられ、stats().rejected に数えられます。例外は 投げません。

永続実行

同じクライアントがデーモンの永続実行も扱います。workflow() が 定義を組み立て、ステップの本体を呼び出しとして、あるいはデコレーターとして 保持します。Worker がそれを登録し、スレッドプール上でリースを ロングポーリングして 1 件ずつ処理します。mcpd.runs、 mcpd.events、mcpd.definitions は実行の API を 1 対 1 で写しています。本体は class_="retryable" を付けた StepFailure を投げるとステップの予算の範囲でリトライされ、 StepContext を通して自分のステップの下にスパンを記録します。 それらのスパンが使った分が、そのステップの使用量です。トークンは各スパンの 終了時に合計され、最後に見えたモデルが採用されるので、デーモンはトレースに 出ているとおりにステップの料金を計算します。ctx.set_usage は それをフィールドごとに上書きします。Worker は on_error のほかに 4 つのフック(on_lease、 on_step_start、on_step_end、 on_conflict)を取り、worker.stats() は取得した リース数、実行中の本体数、完了数、クラス別の失敗数、理由別の競合数、処理 できなかったリース数を数えます。

ワークフロー

support = workflow("support_ticket")


@support.step("classify")
def classify(ctx, input, prior):
    return {"kind": "question" if input["ticket"].startswith("Q") else "bug"}


@support.agent("draft", input={"kind": "${steps.classify.output.kind}"})
def draft(ctx, input, prior):
    # Your own model call, recorded under this step in the run's trace. What
    # the span spends is the step's usage: the daemon prices it from here.
    with ctx.span("model_call", kind="model_generation") as span:
        span.set_model("claude-sonnet-4", "anthropic")
        span.set_usage(input_tokens=812, output_tokens=37)
        text = f"an answer to a {input['kind']}"
    return text


support.wait_for("approval", event="approved", key="${input.ticket}",
                 timeout_ms=7 * 24 * 3_600_000,
                 when=when("steps.classify.output.kind", "eq", "bug"))


@support.step("send", input={"text": "${steps.draft.output}"}, effect="at_most_once",
              retry=RetryPolicy(max_attempts=3, backoff="exponential", initial_ms=500))
def send(ctx, input, prior):
    if not deliver(input["text"], ctx.idempotency_key):
        raise StepFailure("mail relay busy", class_="retryable", retry_after_ms=2_000)
    return {"sent": True}


support.output({"sent": "${steps.send.output.sent}"})

それぞれの構成要素の働き、リースのプロトコル、実行の API は ワークフロー、 ワーカー、 実行のページにあります。

スコアとフィードバック

min と max はキーワード専用で、既定はあり ません。スケールの宣言がない裸の数値は何とも比べられないので、あとから 推測するのではなく、呼び出し側でスケールを書かせます。フィードバックは人の 判断であり、スコアとは別に保たれます。2 つは別の問いに答えるものなので、 まとめて平均してはいけません。

スコアとフィードバック

# A machine evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
mcpd.score(
    "answer_relevance",
    value=round(random.uniform(0.6, 0.95), 2),
    min=0.0,
    max=1.0,
    trace_id=trace_id,
    reason="named the order and gave a delivery window, without the live lookup",
)

# A human evaluation. Kept separate from a score, because a person's
# judgement and a model's must never be averaged together.
mcpd.feedback(
    FeedbackKind.THUMBS,
    "up",
    trace_id=trace_id,
    comment="fine given the outage",
    author_kind="end_user",
)

どちらもトレースを指定しなければ、アクティブなスパンのトレースに付きます。 ログとメトリクスも同じなので、チャート上のスパイクから、それを起こした実行を 開けます。

フラッシュとシャットダウン

記録はメモリ上のキューに追加して戻るだけで、HTTP はデーモンスレッドが担い ます。flush() は待っているものを送り、タイムアウトまでに送れ なければ False を返します。shutdown() はフラッシュ してワーカーを止め、トランスポートを解放します。どちらも何度呼んでも結果は 同じです。イベントループの中では aflush() と ashutdown() を呼びます。同じ処理を、ループを止めずに行います。

サーバーレス関数では、これがどこよりも重要になります。ハンドラーが戻った 瞬間にランタイムがプロセスを凍結し、ワーカースレッドは二度と動かないので、 キューに残っていたバッチは失われます。

サーバーレスのハンドラー

def handler(event, context):
    with mcpd.trace("answer") as run:
        run.set_input(event["body"])
        answer = agent(event["body"])
        run.set_output(answer)

    # この時点でバッチはまだメモリの中。ハンドラーが戻った瞬間にランタイムが
    # このプロセスを凍結し、ワーカースレッドは二度と動かない。
    mcpd.flush()
    return {"statusCode": 200, "body": answer}

クライアントは atexit フックも登録するので、シャットダウンを 忘れた普通のスクリプトでも最後のバッチは届きます。このフックは os._exit() や致命的なシグナルでは動かないので、安全網であって 代わりにはなりません。

保証されること

  • 計装は例外を投げません。記録の呼び出しはすべて保護されており、失敗は 1 間隔につき多くても 1 回ログに出て、そのまま飲み込まれます。可観測性が、 観測している相手を落としてはいけません。例外は読み出しの呼び出しだけで、 こちらは例外を投げます。待っているクエリは、話が別だからです。
  • 記録はブロックしません。エージェントのレイテンシが、mcpd の速さや、mcpd が 動いているかどうかに左右されることはありません。
  • メモリには上限があります。保持するシグナルは max_queue 件、 既定は 8192 件です。いっぱいになると古いものから捨てられ、 mcpd.dropped がその数を数え、1 間隔につき 1 回警告をログに 出します。上限のないバッファは、ネットワーク障害をメモリ不足による強制 終了に変えてしまいます。
  • サンプリングの判断はルートでトレース id から一度だけ行われ、すべての子に 引き継がれます。受け取った traceparent が常に優先されます。
  • スパンは開始時にエクスポートされ、終了時に更新されるので、途中でクラッシュ した実行も見えます。
  • タイムスタンプは time.time_ns() から取り、9 桁の UTC として 送ります。ローカル時刻では、分散トレースは読めなくなります。

読み出し

読み出しのメソッドは HTTP API を写したもので、生成された型を返します。 同期的に動き、失敗すると McpdError を投げます。 from_ と to は datetime のほか、 -15m、-1h、-7d のような短縮形も 受け取ります。非同期のコードでは、asyncio.to_thread でループの 外に出して呼んでください。

検索と分析

# Nothing has been sent yet: recording appends to an in-memory queue and a
# background worker does the I/O. Flush before you read your own writes.
mcpd.flush()

detail = mcpd.get_trace(trace_id, include=["logs", "scores", "feedback"])
print(f"\n{detail.trace.name}  {detail.trace.duration_ns / 1e6:.0f} ms  "
      f"{detail.trace.span_count} spans  {detail.trace.error_count} errors")
for span in detail.spans:
    cost = f"  ${span.cost.total_cost:.6f}" if span.cost else ""
    print(f"  {span.kind.value:<18} {span.name:<14} {span.status.value}{cost}")

summary = mcpd.analytics_summary("-1h", "-0s")
print(f"\nlast hour  traces={summary.trace_count}  errors={summary.error_count}  "
      f"p95={summary.latency.p95:.0f}ms  cost=${summary.total_cost:.4f}")

実行を探すに、フィルターの 全体があります。データモデル には、これらの型が持つすべてのフィールドが並んでいます。