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',
}) コードが環境変数より優先され、環境変数が既定値より優先されます。
| オプション | 環境変数 | 既定値 |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | なし |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | なし |
serviceName | MCPD_SERVICE_NAME | なし |
serviceVersion | MCPD_SERVICE_VERSION | なし |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capturePayloads | MCPD_CAPTURE_PAYLOADS | true |
logLevel | MCPD_LOG_LEVEL | debug |
batchSize | MCPD_BATCH_SIZE | 128 |
flushIntervalMs | MCPD_FLUSH_INTERVAL_MS | 2000 |
maxQueue | MCPD_MAX_QUEUE | 8192 |
timeoutMs | MCPD_TIMEOUT_MS | 10000 |
sink | HTTP トランスポート |
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
フラッシュとシャットダウン
記録はキューへの追加です。バックグラウンドのワーカーが送るまで、何もプロセスの
外には出ません。ワーカーは 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}`)
}