MCPの可観測性と監査
MCP サーバー・クライアントの可観測性を、Logging 機能の非推奨を踏まえて設計する方法を解説します。2026-07-28 版は、プロトコルのログ通知を非推奨にし、トレースの伝搬と、HTTP ヘッダーによるリクエストの識別を標準に取り込みました。ここでは、それらを既存のログ基盤、OpenTelemetry、ゲートウェイとどう組み合わせるかを扱います。Go での一般的な実装は ロギングとオブザーバビリティ、認可の監査に関わる部分は MCPセキュリティベストプラクティス - 攻撃と対策 を参照してください。
1. このドキュメントで扱うこと
- Logging 機能の非推奨
- トレースの伝搬
- メトリクス
- 監査ログ
- 機微情報の扱い
- レート制限と異常検知
- トークン使用量の可視化
- エラーの分類
Logging 機能の非推奨
MCP の Logging 機能 (notifications/message) は、2026-07-28 版で非推奨になりました。非推奨の機能は、機能のライフサイクルの方針により、この版のリリースから少なくとも 12 か月間は仕様に残り、その後に削除の対象になります。新しい実装は採用すべきでなく、既存の実装は次のように移行することが求められます。
- stdio のサーバー: ログを stderr に出す。ホストアプリケーションが取り込む
- HTTP のサーバー: stderr はクライアントに取り込まれないため、通常のログ基盤や OpenTelemetry に出す
非推奨の間の仕様上の振る舞いも押さえておきます。クライアントは、リクエストごとに _meta の io.modelcontextprotocol/logLevel (RFC 5424 の 8 段階、debug から emergency) を付けて、ログを要求します。このフィールドのないリクエストに対して、サーバーは notifications/message を送ってはいけません。付いている場合は、そのリクエストの応答ストリームの上で、最終の応答の前に送れます。subscriptions/listen のストリームでは配信しません。logging/setLevel と、セッション単位のログレベルは、新世代にはありません。
つまり、「サーバーのログをクライアントの画面に流す」仕組みは、利用者の要求に応じた補助的なものに後退し、運用の観測は、プロトコルの外の通常の手段に任せる設計になりました。サーバー側の観測は、プロトコルのログ通知に依存せず、最初から構造化ログ、メトリクス、トレースで設計します。
トレースの伝搬
分散トレースを MCP のリクエストにつなぐ規約は、SEP-414 で仕様に取り込まれました。リクエストの _meta に、W3C Trace Context と W3C Baggage の形式で、次のキーを載せます。
| キー | 形式 |
|---|---|
traceparent | W3C Trace Context |
tracestate | W3C Trace Context |
baggage | W3C Baggage |
通常の _meta のキーは、逆ドメイン形式の接頭辞が必要ですが、この 3 つのキーは、既存の実装と OpenTelemetry のセマンティック規約との互換のため、接頭辞のない例外として予約されています。io.modelcontextprotocol.traceparent のように名前空間を付けると、トレースとログの相関が壊れます。
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"_meta": {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
}
}
}
サーバーは、受け取った traceparent を親としてスパンを作り、下流の API 呼び出しに引き継ぎます。これにより、ホストアプリケーションのエージェントの処理、MCP のサーバー、下流の API までが、1 つのトレースとして追跡できます。トレースの文脈には相関 ID が含まれ得るため、環境に合ったデータの取り扱いの指針に従ってください。
HTTP のヘッダーではなく _meta が運び手になっているのは、stdio を含むすべてのトランスポートで同じ方法が使えるからです。
メトリクス
Streamable HTTP では、すべての POST に、リクエストの種類を示すヘッダーが付きます。
| ヘッダー | 値 | 付与される対象 |
|---|---|---|
Mcp-Method | JSON-RPC の method | すべてのリクエスト |
Mcp-Name | params.name または params.uri | tools/call、resources/read、prompts/get |
ロードバランサー、ゲートウェイ、観測の基盤は、JSON の本文を解析せずに、ツール単位の呼び出し数、レイテンシ、エラー率を集計できます。サーバーが本文を解析して処理する場合は、ヘッダーと本文が一致しているか検証し、不一致を 400 と -32020 (HeaderMismatch) で拒否します。ゲートウェイがヘッダーで判断し、サーバーが本文で実行する構成のずれを、攻撃に使わせないためです。
集計では、次の点に注意します。
- ラベルにする値は、
Mcp-Methodとツール名、プロンプト名に限る。resources/readのMcp-Nameはリソースの URI であり、値の種類が無限に増える (カーディナリティが高い) ため、そのままメトリクスのラベルにしない Mcp-Nameの値は、ASCII として安全に表せない場合、=?base64?...?=の形式で届く。集計の前にデコードする- 指標の候補は、メソッドとツールごとの呼び出し数、レイテンシ (ヒストグラム)、エラー率 (プロトコルエラー、ツールの実行エラー、認可エラーで分ける)、進行中のリクエスト数、
subscriptions/listenのストリームの数 - ヘッダーのみでの集計は、ゲートウェイで取れる。アプリケーションの内側の指標 (下流 API のレイテンシ、キャッシュのヒット率) は、サーバーのコードで計測する
ツールの引数を、メトリクスやルーティングのキーにしたいときは、ツールの inputSchema で x-mcp-header を使い、引数を Mcp-Param-{名前} のヘッダーにミラーできます (たとえばテナントやリージョン)。ヘッダーは、ネットワークの中継点から見えるため、パスワード、API キー、トークン、個人情報には付けません。
監査ログ
監査ログは、「誰が、どのクライアントから、何を、どの権限で実行し、結果がどうだったか」を後から追えるようにする記録です。MCP のサーバーは、次を構造化ログとして記録します。
| 項目 | 内容 | 取得元 |
|---|---|---|
| 利用者 | 認証された利用者の識別子 | 検証したアクセストークンの claim (sub など) |
| クライアント | OAuth のクライアント ID | トークンの claim |
| 操作 | メソッド、ツール名 | Mcp-Method、Mcp-Name、本文 |
| 引数 | 実行時の引数 (マスキング後) | 本文 |
| スコープ | 付与されたスコープ、要求されたスコープ | トークン、認可の判定 |
| 結果 | 成功、ツールの実行エラー、拒否 | 結果の isError、HTTP のステータス |
| 時刻と所要時間 | 開始時刻、レイテンシ | サーバー |
| 相関 ID | トレース ID、リクエスト ID | traceparent |
利用者とクライアントは、検証したトークンから取ります。リクエストの _meta の io.modelcontextprotocol/clientInfo は、送信者の自己申告で、プロトコルでは検証されません。表示、ログ、デバッグには使えますが、セキュリティの判断や、監査の根拠にしてはいけません。
スコープの昇格は、特に記録します。セキュリティのベストプラクティスは、サーバーのガイダンスとして、「昇格のイベント (要求したスコープと、付与された部分集合) を、相関 ID を付けて記録する」ことを挙げています。最小のスコープから始めて、特権的な操作が初めて試みられたときに、WWW-Authenticate の scope でステップアップさせる設計では、「どの操作が、どのスコープの追加を引き起こし、利用者が許可したか」の連なりが、監査の中心になります。一連の 401 / 403 とトークンの再取得に、同じ相関 ID を付けて、後から追跡できるようにします。
MCP がステートレスになったため、サーバー側に「セッションの ID」はありません。リクエストをまとめるには、トレース ID と、アプリケーションが発行する明示的なハンドル (ワークフローの ID など) を相関のキーにします。
機微情報の扱い
引数と結果には、個人情報や秘密情報が含まれ得ます。ログに全文を残すと、ログ基盤が新たな漏えい元になります。
- 記録の方針を、ツールごとに決める。引数と結果を、記録しない、ハッシュにする、項目を選んで記録する、全文を記録する、のどれにするか
- 既知の秘密のパターン (トークン、API キー、パスワード、クレジットカードの番号) と、ツールのスキーマで機微と宣言した項目を、記録の前にマスキングする
- アクセストークンや
Authorizationヘッダーは、決して記録しない - 仕様は、ログのメッセージに、認証情報、個人を特定する情報、攻撃の助けになる内部の情報を含めてはならないとしています (プロトコルのログ通知について。運用のログにも、同じ方針が当てはまります)
- 監査ログへのアクセスを制御し、保存期間を決める。監査ログは、残すこと自体が目的なので、アプリケーションのデバッグログとは分けて保管する
x-mcp-headerで、機微な引数をヘッダーに出さない。ヘッダーは、ゲートウェイや CDN のアクセスログに残りやすい
レート制限と異常検知
Mcp-Method と Mcp-Name が付いたことで、ゲートウェイや WAF が、JSON の本文を解析せずに、メソッドとツールの単位でポリシーを適用できます。
- 呼び出し回数の制限: 利用者 (トークンの
sub)、クライアント、ツールの単位で、書き込み系や高コストのツールを厳しくする - ツールごとの制御: 危険なツール (削除、送金、外部への送信) を、特定のスコープか、特定のネットワークからのみ許可する
- 異常の検知: 特定のツールの呼び出しの急増、通常使わないツールの連続呼び出し、エラー率の急上昇、一覧にないツール名への呼び出し (列挙の試み)、短時間でのスコープのステップアップの繰り返し
- ヘッダーで判断するゲートウェイは、
MCP-Protocol-Versionのヘッダーが、ヘッダーと本文の検証を要求する版であることを確認する。古い版や、ヘッダーがないリクエストは、検証されていないヘッダーを信頼せず、拒否する
仕様は、サーバーに、ツールの呼び出しのレート制限を求めています。ゲートウェイでの制限と、サーバー内の制限は、どちらか一方ではなく、重ねて使います。
トークン使用量の可視化
MCP では、ツールの定義と結果が、モデルのコンテキストを消費します (コンテキスト肥大問題と対策)。運用では、これを測らないと、段階的な探索やツールの絞り込みの効果がわかりません。
- ツール定義のサイズ:
tools/listの結果の全体と、ツールごとの文字数 (トークン数の概算)。サーバーのリリースのたびに、増減を記録する - 結果のサイズ: ツールごとの、結果の
contentとstructuredContentの大きさの分布。上位のツールが、結果を絞る対象の候補になる - 利用状況: 定義に含まれているのに、ほとんど呼ばれないツール。削除や、段階的な探索への移行の候補
- クライアント側の指標: モデルの入力と出力のトークン数は、ホストアプリケーションかモデルの API が持つ。サーバーは、自分が返した大きさまでを測り、モデルのトークン数は、トレースの ID でクライアント側の記録と突き合わせる
tools/list には、ttlMs と cacheScope というキャッシュのヒントが付き、順序も決定的にすることが推奨されます。順序が安定すると、クライアントのキャッシュとモデルのプロンプトキャッシュが効きやすくなります。ツール定義のサイズと、キャッシュのヒット率を、並べて見ます。
エラーの分類
MCP には、性質の異なるエラーが混在します。同じ「エラー率」にまとめると、原因が見えなくなるため、分けて集計します。
| 分類 | 見分け方 | 例 | 主な対処 |
|---|---|---|---|
| プロトコルエラー | JSON-RPC の error。HTTP は主に 400、404 | -32602 (不正な引数、不明なツール)、-32601、-32020 HeaderMismatch、-32021 MissingRequiredClientCapability、-32022 UnsupportedProtocolVersion | クライアントの実装やバージョンの問題。バージョンごとの内訳を見る |
| ツールの実行エラー | 結果の isError: true (JSON-RPC としては成功) | API の失敗、入力の検証エラー、業務ロジックのエラー | モデルが自己修正できる文面か。下流の障害か |
| 認可エラー | HTTP の 401、403 | トークンの期限切れ、スコープ不足、audience の不一致 | ステップアップの流れが機能しているか。不審な繰り返しがないか |
| 内部エラー | -32603、HTTP の 5xx | サーバーの不具合 | SLO の対象 |
isError: true は HTTP の 200 で返るため、HTTP のステータスだけを見るゲートウェイの指標では、ツールの実行エラーが成功に見えます。サーバーのアプリケーションの指標で、isError を別に数えます。
-32022 (UnsupportedProtocolVersion) の件数は、移行の進み具合の指標になります。旧世代と新世代を同時に受ける運用では、プロトコルの版ごとにリクエスト数を分け、古い版のクライアントがどれだけ残っているかを監視します (MCPサーバーのデプロイと運用)。
2. 観測の対象と手段
| 対象 | 手段 | 補足 |
|---|---|---|
| リクエストのトレース | OpenTelemetry + _meta の trace context | クライアントから下流 API まで一貫して追跡 |
| ツール別の指標 | Mcp-Method / Mcp-Name ヘッダー | JSON ボディを解析せずに集計できる |
| アプリケーションログ | stderr / 既存のログ基盤 | stdout はプロトコル専用 |
| 監査 | 構造化ログ + 相関 ID | 誰が何を実行したかを後から追える |
| トークンコスト | ツール定義と結果のサイズ計測 | 段階的探索の導入判断に使う |
3. 開発者がつまずきやすいポイント
notifications/message で運用ログを流そうとする
プロトコルのログ通知は非推奨で、クライアントが _meta で要求したリクエストにしか送れません。運用のログは、stderr (stdio) か、ログ基盤と OpenTelemetry (HTTP) に出します。
stdio のサーバーで、stdout にログを書く
stdout は、プロトコルのメッセージ専用です。ログを混ぜると、クライアントがメッセージを解釈できなくなります。必ず stderr に書きます。
traceparent に名前空間を付ける
_meta のほかのキーは逆ドメインの接頭辞が必要ですが、traceparent、tracestate、baggage だけは、接頭辞なしの名前が予約されています。名前空間を付けると、他の実装やゲートウェイがトレースを引き継げません。
Mcp-Name をそのままメトリクスのラベルにする
resources/read では URI が入るため、値の種類が爆発します。ラベルにするのは、tools/call と prompts/get の名前に限り、デコードしてから使います。
clientInfo を監査の根拠にする
io.modelcontextprotocol/clientInfo は自己申告です。誰が実行したかは、検証したトークンの claim から取ります。
引数と結果を全文ログに残す
デバッグのときは便利ですが、個人情報とトークンの保管庫になります。記録の方針をツールごとに決め、マスキングを通します。
isError: true を HTTP の指標だけで見ている
ツールの実行エラーは 200 で返ります。アプリケーションの指標で別に数え、プロトコルエラー、認可エラーと分けて見ます。
相関のキーを「セッション ID」に頼る
新世代にセッションはありません。相関は、トレース ID と、アプリケーションの明示的なハンドルで取ります。
4. コード例
受け取った trace context を、下流の API 呼び出しにつなぐ (Go)
_meta の traceparent を取り出し、OpenTelemetry のコンテキストに復元して、スパンを作ります。
package mcpobs
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
var propagator = propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
// ExtractTrace は、リクエストの _meta から trace context を復元する。
// meta は params._meta を map[string]string に読み替えたもの (文字列でない値は無視する)。
func ExtractTrace(ctx context.Context, meta map[string]string) context.Context {
carrier := propagation.MapCarrier{}
for _, k := range []string{"traceparent", "tracestate", "baggage"} {
if v, ok := meta[k]; ok {
carrier[k] = v
}
}
return propagator.Extract(ctx, carrier)
}
// StartToolSpan は、ツールの呼び出しのスパンを開始する。
func StartToolSpan(ctx context.Context, tool string) (context.Context, trace.Span) {
tracer := otel.Tracer("mcp-server")
return tracer.Start(ctx, "tools/call "+tool,
trace.WithSpanKind(trace.SpanKindServer))
}
下流の HTTP クライアントを otelhttp などで計装すると、ここで作ったスパンが親になり、クライアントから下流 API までが 1 つのトレースにつながります。クライアントを実装する側は、逆に、現在のスパンの文脈を propagator.Inject で _meta に書き込みます。
ヘッダーでツール別に集計するミドルウェア (Go)
JSON の本文を読まずに、Mcp-Method と Mcp-Name から、メソッドとツールごとの件数とレイテンシを記録します。
package mcpobs
import (
"encoding/base64"
"log/slog"
"net/http"
"strings"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// decodeHeader は =?base64?...?= の形式の値をデコードする。
func decodeHeader(v string) string {
if strings.HasPrefix(v, "=?base64?") && strings.HasSuffix(v, "?=") {
raw := strings.TrimSuffix(strings.TrimPrefix(v, "=?base64?"), "?=")
if b, err := base64.StdEncoding.DecodeString(raw); err == nil {
return string(b)
}
}
return v
}
// Metrics は、Mcp-Method と Mcp-Name からツール別の指標を記録する。
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
method := r.Header.Get("Mcp-Method")
name := ""
// resources/read の Mcp-Name は URI でカーディナリティが高いため、ラベルにしない
if method == "tools/call" || method == "prompts/get" {
name = decodeHeader(r.Header.Get("Mcp-Name"))
}
slog.Info("mcp_request",
"mcp_method", method,
"mcp_name", name,
"protocol_version", r.Header.Get("MCP-Protocol-Version"),
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
実際には、slog の代わりに、使用しているメトリクスのライブラリ (Prometheus や OpenTelemetry の Metrics) のカウンターとヒストグラムを更新します。SSE で応答を流す場合は、http.ResponseWriter の Flush を失わないよう、ラッパーに Flush を委譲する実装が必要です。
監査ログの 1 行 (JSON)
{
"ts": "2026-10-13T05:21:08.412Z",
"event": "tool_call",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"user": "00u1abcd2EFGH",
"client_id": "https://client.example.com/oauth/metadata.json",
"mcp_method": "tools/call",
"tool": "create_issue",
"args": { "repo": "acme/app", "title": "[masked]" },
"scopes_granted": ["issues:read", "issues:write"],
"result": "ok",
"is_error": false,
"duration_ms": 184
}
スコープの昇格は、別のイベントとして、同じ trace_id か相関 ID を付けて記録します。
{
"ts": "2026-10-13T05:21:07.980Z",
"event": "scope_elevation",
"correlation_id": "c0a8f1e2-7d3b-4a55-9a58-2a1f5d1b7e90",
"user": "00u1abcd2EFGH",
"client_id": "https://client.example.com/oauth/metadata.json",
"trigger_tool": "create_issue",
"scope_requested": ["issues:write"],
"scope_granted": ["issues:write"],
"result": "granted"
}
5. まとめ
- Logging 機能 (
notifications/message) は 2026-07-28 版で非推奨。リクエストの_metaのio.modelcontextprotocol/logLevelがない限り送れず、運用のログは stderr (stdio) か、ログ基盤と OpenTelemetry (HTTP) に出す - トレースは、
_metaのtraceparent、tracestate、baggageで伝搬する。この 3 つだけは接頭辞なしの予約キーで、クライアントから下流の API まで 1 つのトレースにつなげる Mcp-MethodとMcp-Nameのヘッダーで、本文を解析せずにツール単位のメトリクスを集計できる。URI が入るresources/readのMcp-Nameは、ラベルにしない。ヘッダーと本文の一致は、サーバーが検証する- 監査ログは、検証したトークンから利用者とクライアントを取り、
clientInfoの自己申告に頼らない。スコープの昇格は、相関 ID を付けて記録する。新世代にはセッションがないため、相関は、トレース ID と明示的なハンドルで取る - 引数と結果は、ツールごとに記録の方針を決め、マスキングする。アクセストークンは記録しない。
x-mcp-headerに機微な引数を使わない - レート制限と異常検知は、ゲートウェイでヘッダーを使って、メソッドとツールの単位で適用し、サーバー内の制限と重ねる。ゲートウェイは、ヘッダーの検証を要求する版のリクエストだけを信頼する
- トークンの使用量は、ツール定義のサイズと結果のサイズで測り、コンテキスト問題への対策の効果を確認する
- エラーは、プロトコルエラー、ツールの実行エラー (
isError)、認可エラー、内部エラーに分けて集計する。isErrorは HTTP の200で返るため、アプリケーションの指標で別に数える
関連ドキュメント
- ロギングとオブザーバビリティ
- MCPセキュリティベストプラクティス - 攻撃と対策
- コンテキスト肥大問題と対策
- トランスポート - stdioとStreamable HTTP
- MCPサーバーのデプロイと運用