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

TypeScript

依存のないパッケージが 1 つ。スパン、ログ、メトリクス、スコア、フィードバックを記録し、計装の呼び出しから例外を投げることはありません。

インストール

Node 20 以降、Deno、Bun、ブラウザ、エッジランタイムで動きます。ESM を基本とし、 require 用に CommonJS ビルドも用意しています。Node 固有の部分は async_hooks と zlib の 2 つだけで、どちらも実行時に 読み込み、存在しない環境では読み飛ばします。

npm install @mcpd/sdk

クライアントの構築

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

接続

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',
})

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

オプション環境変数既定値
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYなし
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTなし
serviceNameMCPD_SERVICE_NAMEなし
serviceVersionMCPD_SERVICE_VERSIONなし
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinkHTTP トランスポート

enabled: false にすると、何も記録せず何も送信しませんが、どの 呼び出しも有効なオブジェクトを返し、一覧の読み出しは空のページを返します。 テストスイートは計装のコードを 1 行も触らずに mcpd を止められます。

capturePayloads: false にすると、プロンプトと補完はプロセスの 中に留まります。スパンは所要時間、ステータス、使用量、モデル、相関、 エラーとともに記録され、input と output はキューに 入る前に捨てられます。フラッシュで機密が外に出ることはありません。個々の スパンは開始オプションの capturePayloads でこの設定を上書き でき、有効にも無効にもできます。

実行の記録

withSpan はスコープ付きの書き方です。スパンを開き、コールバックが 始めたすべての処理に対してアクティブなまま保ち、コールバックが決着した時点で 終了します。コールバックが例外を投げた場合、スパンは投げられたメッセージ とともに error になり、例外はそのまま再送出されます。観測される ことで、あなた自身のエラー処理が変わることはありません。

実行の記録

/**
 * 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 }
}

コンテキスト

Node と Bun では、アクティブなスパンは AsyncLocalStorage に入り ます。withSpan のコールバックの中で始めた処理は、参照を渡さなくても その下に入れ子になります。1 つのイベントループ上で交互に進む 2 つの実行が、 互いのスパンを見ることはありません。

入れ子

// コールバックが始めたものはすべてこの下に入れ子になる。ハンドルは渡さない。
await mcpd.withSpan('support_agent', async (run) => {
  const order = await mcpd.withSpan(
    'lookup_order',
    async (tool) => {
      tool.setInput({ orderId: 'A-1042' })
      mcpd.log('info', 'this line belongs to lookup_order')
      return { status: 'in transit' }
    },
    { kind: 'tool_call' },
  )
  run.setOutput(order)
})

ブラウザとほとんどのエッジランタイムには AsyncLocalStorage が ありません。そこでは SDK が代わりに 1 つの値を保持します。この方式でも入れ子は 正しくなり、1 つの実行の中の await をまたいでも残りますが、同時に 走る 2 つの実行を区別できません。その結果、ある実行のツール呼び出しが別の 実行のツリーに現れます。これらのランタイムでエージェントを並行して動かす ときは、親を明示的に渡してください。

親の明示

// AsyncLocalStorage のないものも含め、どのランタイムでも正しく動く。
const run = mcpd.trace('support_agent')
const step = run.child('call_model', { kind: 'model_generation' })
step.end()

await run.withChild('call_tool', async (tool) => {
  tool.setOutput({ ok: true })
})
run.end()

// 実行時にどちらの仕組みが使われているか。
mcpd.stats().context // 'async-local-storage' | 'fallback'

サービスの間では、コンテキストは W3C の traceparent ヘッダーで 運ばれます。1 つのトレースがネットワークをまたいで残るのは、これだけによります。 形式の壊れたヘッダーは無視され、新しいルートトレースが始まります。誰にも 読めないヘッダーは、それを運んできたリクエストを失敗させる理由にはならない からです。

サービス境界をまたぐ

// 送信側: アクティブなスパンをリクエストに載せる。
await fetch(url, {
  headers: { traceparent: span.traceparent() },
})

// 受信側: 新しいトレースを始めず、そのトレースを続ける。
const run = mcpd.trace('handle_request', {
  traceparent: request.headers.get('traceparent'),
})

モデル、使用量、エラー

mcpd は取り込み時にトークン数から料金を計算するので、トレースに出るコストは 実際に請求されたコストになります。自分でトークンを数えるのではなく、 プロバイダーが返した値をそのまま報告してください。エラーには必ずメッセージが 付き、エラーを設定すると同時にスパンのステータスも error になる ので、この 2 つが食い違うことはありません。

スパンが持つもの

generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
  inputTokens: 812,
  outputTokens: 37,
  inputDetails: { cacheRead: 640 },
})

// 自分で捕まえて処理した失敗。
generation.setError('the provider timed out', {
  kind: 'TimeoutError',
  retryable: true,
})

// スパンの中の一点。それ自体は長さを持たない。
generation.event('first_token', { kind: 'model_chunk' })

永続実行

同じクライアントがデーモンの永続実行も扱います。workflow() が定義を 組み立ててステップの本体を保持し、Worker がそれを登録し、リースを ロングポーリングで受け取って 1 件ずつ処理します。mcpd.runs、 mcpd.events、mcpd.definitions は実行の API を 1 対 1 で 写しています。本体は RetryableError を投げるとステップの予算の 範囲でリトライされ、StepContext を通して自分のステップの下にスパンを 記録します。そうして記録したスパンと、その下に入れ子になったすべてのスパンは、 終了時に使用量をステップの使用量に足し込みます。デーモンはトレースに出ている とおりに料金を計算し、本体がトークンを手で合計することはありません。 ctx.setUsage はそれを明示的に上書きします。onError の 隣には 4 つのフック onLease、onStepStart、 onStepEnd、onConflict があり、 worker.stats() は取得数、完了数、クラス別の失敗数、理由別の競合数を 数えます。

ワークフロー

const support = workflow({ id: 'support_ticket' })
  .step('classify', async (ctx, input: { ticket: string }) => ({
    kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
  }))
  .agent(
    'draft',
    async (ctx, input: { kind: string }) => {
      // Your own model call, recorded under this step in the run's trace. Its
      // usage adds up into the step's, which the daemon prices.
      return await ctx.withSpan('model_call', async (span) => {
        span.setModel('claude-sonnet-4', 'anthropic')
        span.setUsage({ inputTokens: 812, outputTokens: 37 })
        return `an answer to a ${input.kind}`
      })
    },
    { input: { kind: '${steps.classify.output.kind}' } },
  )
  .waitFor('approval', {
    event: 'approved',
    key: '${input.ticket}',
    timeoutMs: 7 * 24 * 3_600_000,
    when: when('steps.classify.output.kind', 'eq', 'bug'),
  })
  .step(
    'send',
    async (ctx, input: { text: string }) => {
      if (!(await deliver(input.text, ctx.idempotencyKey))) {
        throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
      }
      return { sent: true }
    },
    {
      input: { text: '${steps.draft.output}' },
      effect: 'at_most_once',
      retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
    },
  )
  .output({ sent: '${steps.send.output.sent}' })

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

スコアとフィードバック

min と max は型として必須です。スケールが宣言されて いない 0.7 というスコアは何とも比べられませんし、誰かがチャートを見ている時点 では、それが何を意味していたのかを聞くには遅すぎます。フィードバックは人の 判断であり、スコアとは別に保持します。2 つは別の問いに答えるものなので、 まとめて平均してはいけません。

スコアとフィードバック

// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
  traceId,
  scorerId: 'answer_relevance',
  value: 0.86,
  min: 0,
  max: 1,
  reason: 'Answered the question and gave the delivery date.',
})

// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
  traceId,
  kind: 'thumbs',
  value: 'up',
  comment: 'Exactly what the customer asked for.',
  authorKind: 'end_user',
})

// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } })

どちらもトレースを指定しなければ、アクティブなスパンのトレースに付きます。 自分の製品にある評価ボタンは、控えておいたトレース id だけで feedback を呼べます。

5 つのシグナル、1 組の id

スパン、ログ、メトリクス、スコア、フィードバックは同じキューを通り、同じ トレース id とスパン id を持ちます。だからこそ、チャート上のスパイクから、 それを起こした実行を開けます。

  • 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

trace_id Store Search Export

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

記録はキューへの追加です。バックグラウンドのワーカーが送るまで、何もプロセスの 外には出ません。ワーカーは 2 秒ごと、またはバッチがいっぱいになったときに 送ります。flush() と shutdown() はどちらも await でき、 何度呼んでも結果は同じです。shutdown() はワーカーも停止します。

サーバーレス関数では、これは省略できません。レスポンスを返した瞬間にランタイムが プロセスを凍結し、バックグラウンドのワーカーは二度と動かないので、最後のバッチは 失われます。レスポンスを返す前にフラッシュを await してください。

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

export default async function handler(request: Request): Promise<Response> {
  const answer = await mcpd.withSpan('answer', async (run) => {
    run.setInput(await request.text())
    return 'ok'
  })

  // この時点でバッチはまだメモリの中。レスポンスを返す前に送る。
  await mcpd.flush()
  return new Response(answer)
}

Node では、クライアントは beforeExit でもフラッシュするので、 shutdown() を忘れたスクリプトにも最後のバッチは届きます。この フックは明示的な process.exit() や致命的なシグナルでは発火しない ので、安全網であって代わりにはなりません。

記録した内容の検証

RecordingSink はネットワークをメモリに置き換えます。テストは、 サーバーもポートもなしに、対象のコードが作ったスパン、ログ、メトリクス、 スコア、フィードバックを読み返せます。ConsoleSink は代わりに シグナル 1 件につき JSON を 1 行出力します。export(batch, ingestKey) メソッドを持つオブジェクトは、どれもシンクになります。例外を投げるシンクは バッチの失敗と診断になるだけで、あなたのコードに例外が出ることはありません。 mcpd.stats() は queued、dropped、 capacity、exported、failed、 rejected を返します。

サーバーの要らないテスト

import { Mcpd, RecordingSink } from '@mcpd/sdk'

const sink = new RecordingSink()
const mcpd = new Mcpd({ sink })

await answerQuestion(mcpd)
await mcpd.flush()

expect(sink.spans.map((s) => s.name)).toEqual(['answer_question', 'model_call'])
expect(sink.scores[0]?.scorerId).toBe('answer_relevance')

完了済みスパンの記録

mcpd.record(span) は、id、親、時刻をすでに手元に持っているスパンを キューに入れます。別のトレーシングシステムのアダプター、一括インポート、 リプレイが持っているのがこれです。渡されたままキューに入り、親を付け替えたり、 時刻を打ち直したり、サンプリングをやり直したりはしません。空のままだった相関 情報だけがクライアントの値で埋まります。id が OpenTelemetry の形式でないもの、 名前が空のもの、開始時刻がないものは捨てられ、stats().rejected に 数えられます。どのスパンでも、externalParentSpanId は mcpd 以外の トレーシングシステムの親を、相関のためだけに持ちます。

別の場所から来たスパン

// 呼び出し側がすでに持っている id、親、時刻。渡されたままキューに入る。
mcpd.record({
  traceId: otel.traceId,
  spanId: otel.spanId,
  parentSpanId: otel.parentSpanId,
  name: 'GET /orders',
  kind: 'http_request',
  status: 'ok',
  startTime: otel.startTime,
  endTime: otel.endTime,
  isRoot: false,
  isEvent: false,
  correlation: { project: 'support' },
  receivedAt: otel.endTime,
})

// mcpd 以外のトレーシングシステムの親。相関のためだけに使う。
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId })

保証されること

  • 計装は例外を投げません。エクスポートの失敗、到達できないデーモン、 シリアライズできないペイロードは、onError を通して 1 間隔に つき多くても 1 回報告され、そのまま流されます。可観測性が、観測している 相手を落としてはいけません。
  • 記録はブロックしません。キューに追加して戻るだけで、ネットワークは バックグラウンドのワーカーが担います。エージェントのレイテンシが、mcpd の 速さや、mcpd が動いているかどうかに左右されることはありません。
  • メモリには上限があります。キューが保持するシグナルは maxQueue 件です。いっぱいになると最も古いものが捨てられ、mcpd.stats() に 数えられます。上限のないバッファは、ネットワーク障害をメモリ不足による 強制終了に変えてしまうからです。
  • サンプリングの判断はルートで一度だけ行われ、以降は引き継がれます。半分だけ 記録されたトレースは、あなたのシステムのバグに見えてしまいます。
  • 開いたままのスパンも送られます。スパンは開始時と終了時の 2 回キューに入る ので、途中でクラッシュした実行も見えます。開始のレコードがまだ出ていなければ、 終了のレコードがそれを置き換えるので、短いスパンにかかるのは 2 件ではなく 1 件です。
  • ペイロードが書き換えられることはありません。キーは camelCase と デーモンの snake_case の間で変換されますが、変換は input、output、attributes、 metadata、fields、labels の手前で 止まります。プロンプトは書いたとおりに届きます。

読み出し

読み出しも同じクライアントで行います。各メソッドは HTTP エンドポイント 1 つに 対応し、生成された型を返します。読み出しは失敗すると McpdError を 投げます。エクスポートできなかったスパンとは違い、await して答えの得られない クエリは、知らせるべきことだからです。

検索と分析

// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
  hasScore: 'answer_relevance',
  scoreBelow: 0.5,
  limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)

const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
  const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
  console.log(
    `${trace.traceId.slice(0, 8)}  ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
      `${seconds}s  ${trace.spanCount} spans  $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
  )
}

// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
  console.log(`  ${span.kind.padEnd(18)} ${span.name}`)
}

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