MCPサーバーを本番運用する方法|メトリクス・アラート・トレースの設計

MCPサーバーを本番運用する方法|メトリクス・アラート・トレースの設計 AI開発

MCPサーバーは、ローカルでToolが動くだけなら比較的簡単に作れます。しかし本番へ公開すると、「Toolが急に遅くなった」「特定のユーザーだけ401になる」「外部APIのTimeoutが増えた」「どのToolがエラーを出したのか分からない」といった問題が起きます。このときprint("error")だけでは原因を追えません。

本番のMCPでは、ログ・メトリクス・トレースを分けて設計します。ログは「何が起きたか」、メトリクスは「どのくらい起きているか」、トレースは「1回のRequestがどこを通り、どこで時間を使ったか」を追います。

この記事では、障害時に「どのToolが・どこで・なぜ遅いか」まで追える構成を、MCP Python SDKの公式ドキュメントとOpenTelemetryの仕様に沿って整理します。ログの出し先の基本はMCPのProtocol Loggingが非推奨になった理由|stderrとOpenTelemetryでのログ設計、リモート公開の認証やHTTPSはMCPサーバーをローカルではなくリモート公開する方法|認証・HTTPSの基本で解説しているため、この記事ではメトリクス・アラート・トレース・運用の設計に絞ります。

なお、この記事はStreamable HTTPで公開するRemote MCPの運用を想定しています。stdioでHostが子Processとして起動するServerは、Process管理やログの扱いがHost側に依存するため、前提が異なります。

スポンサーリンク

本番MCPではログ・メトリクス・トレースを分ける

たとえばsearch_documentsというToolが5秒かかったとします。3つは同じ出来事を別の角度から記録します。

ログ(1回の出来事)
{
  "level": "INFO",
  "tool_name": "search_documents",
  "duration_ms": 5123,
  "result_count": 8
}
メトリクス(集計した状態)
mcp.tool.calls
mcp.tool.duration

→ search_documentsのp95が2.8秒、エラー率が3.2%
トレース(1回のRequestの内訳)
tools/call search_documents    5.1秒
  ├─ Embedding API           4.6秒
  ├─ PostgreSQL              120ms
  └─ その他の処理            380ms

メトリクスで異常に気付き、トレースで遅い箇所を特定し、ログで原因の詳細を確認する、という順番で使います。

Protocol Loggingは非推奨だが、すぐ動かなくなるわけではない

MCP 2026-07-28では、Logging・Sampling・Rootsが非推奨になりました。移行先として公式が示しているのは、stdioならstderr、構造化されたObservabilityにはOpenTelemetryです。

ただしこれは「注釈だけの非推奨」で、メソッド・型・Capability Flagは、このRevisionと、1年以内に公開される仕様でも引き続き動作するとされています。削除には別途SEPが必要です。既存のLogging Notificationが突然壊れるわけではありません。ただし、同じ2026-07-28でlogging/setLevelは削除され、ログレベルはリクエストごとに_metaのio.modelcontextprotocol/logLevelで指定する形に変わりました。Serverは、この項目を含まないリクエストにnotifications/messageを送ってはいけません。新しく作るServerの監視設計はOpenTelemetryと通常のApplication Logを中心にします。

stdio ServerのstdoutはProtocolの通信路なので、ログはstderrへ出します(MCPのstdioサーバーが接続できない原因|stdoutにログを出してはいけない理由)。Remote MCPなら、通常のWeb APIと同じようにApplication Logを出せます。

構造化ログにtool_nameとtrace_idを入れる

本番では、”Tool failed”のような文字列より、JSONなどの構造化ログのほうが検索しやすくなります。tool_nameやerror_typeで絞り込めるため、Tool別のエラー分析もできます。さらにtrace_idを入れておけば、ログからTraceへ移動できます。次のFormatterは、現在有効なSpanがあればtrace_idとspan_idを付けます。

JSON Formatter(stderrへ出力)
import json
import logging
import sys

from opentelemetry import trace


class JsonFormatter(logging.Formatter):
    EXTRA_KEYS = (
        "tool_name",
        "duration_ms",
        "error_type",
        "request_id",
    )

    def format(self, record):
        data = {
            "time": self.formatTime(record),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }

        for key in self.EXTRA_KEYS:
            value = getattr(record, key, None)
            if value is not None:
                data[key] = value

        context = trace.get_current_span().get_span_context()
        if context.is_valid:
            data["trace_id"] = format(context.trace_id, "032x")
            data["span_id"] = format(context.span_id, "016x")

        if record.exc_info:
            data["exception"] = self.formatException(
                record.exc_info
            )

        return json.dumps(data, ensure_ascii=False)


handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(JsonFormatter())

root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers = [handler]
Tool側の呼び出し
logger = logging.getLogger(__name__)

logger.info(
    "tool finished",
    extra={
        "tool_name": "search_documents",
        "duration_ms": 5123,
    },
)

trace_idが出力されるのは、OpenTelemetry SDKを設定して有効なSpanがある場合だけです。Exporterを入れていない段階では、trace_idは付きません。

Tool ArgumentとResultを丸ごとログへ出さない

Debug目的で引数やResultを丸ごとlogger.infoしたくなりますが、Tool Argumentにはメールアドレス、顧客情報、検索Keyword、ファイル内容、Access Tokenなどが含まれる可能性があります。Resultも同様で、Customer DataやDocument全文がLogging BackendやBackupへ複製されます。

本番では、Tool名、引数の件数、安全なIdentifier、処理結果、所要時間、result_countやresult_size_bytesのようなMetadataだけを記録します。Authorization: Bearer ...のようなHeaderは、ログへ出してはいけません。Result側の設計はMCPで巨大なレスポンスを返してはいけない理由|トークン消費を減らす設計、OAuthのTokenの扱いはMCPでOAuth認証を実装する方法|アクセストークンを安全に扱う設計を参照してください。

MCP Python SDKは受信メッセージごとにSpanを作る

MCP Python SDKの公式ドキュメントによると、受信した各メッセージは、メソッドとその対象にちなんだ名前のSERVER Spanになります。たとえばtools/call search_booksやtools/listです。Spanにはmcp.method.name、mcp.protocol.version、jsonrpc.request.idが付きます(通知にはRequest IDは付きません)。

Tool実行ではOpenTelemetryのGenAI Semantic Conventionに合わせて、gen_ai.operation.nameがexecute_tool、gen_ai.tool.nameがTool名になります。Handlerが例外を投げた場合、そしてis_error=TrueのTool Resultも、SpanのStatusはErrorになります。そのため、Tool単位のエラー率をTrace Backendから分析できます。

SDKが依存するのはopentelemetry-apiだけで、OpenTelemetry SDKとExporterを入れるまでは、Spanは何もしないNo-opとして動きます。入れていなければコストはかかりません。

ExporterをInstallしてOTLPで送る

Traceを収集したくなったら、OpenTelemetry SDKとExporterを追加します。Server Codeを変えずに、SDKが作るSpanをそのままBackendへ送れます。

Terminal
uv add opentelemetry-sdk opentelemetry-exporter-otlp
OpenTelemetryの初期化
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
    OTLPSpanExporter,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

resource = Resource.create({
    "service.name": "company-mcp",
    "service.version": "1.8.2",
    "deployment.environment.name": "production",
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter())
)

trace.set_tracer_provider(provider)

送信先は、OpenTelemetryの一般的な環境変数(OTEL_EXPORTER_OTLP_ENDPOINTなど)で設定できます。この初期化は、通常はアプリケーションの起動時に、MCPServerを作る前に行います。

Trace ContextはClientからServerへつながる

2026-07-28のMCPでは、W3C Trace Contextを_metaで伝播する方法が文書化され、Key名もtraceparent・tracestate・baggageに固定されました。Host側で始まったTraceを、Client SDK、MCP Server、Serverが呼ぶ下流のAPIまで、1つのSpan Treeとして見られます。公式ドキュメントでは、ClientとServerの両方がPython SDKなら、この伝播は自動で行われ、ServerのSpanがClientのSpanの下にぶら下がると説明されています。

下流のPostgreSQL、外部API、Redisなどにも、OpenTelemetryのInstrumentationを入れておくと、「Tool全体が4秒」の内訳が「Embedding API 3.7秒、DB 200ms、MCP処理 100ms」のように分かります。この場合、MCPのコードを最適化しても意味がない、と判断できます。

なおbaggageはRequestをまたいで伝播するため、Access Token、Password、API Key、メール本文などの機密や個人情報全文を入れてはいけません。tenant.idのように、伝播して問題がなくObservabilityに必要な情報だけにします。

Tool単位のメトリクスを作る

Traceだけでも詳細は分かりますが、継続的な監視にはメトリクスが適しています。MCP Python SDKの公式ドキュメントで確認できたのはSpanまでで、「Tool別の呼び出し数・エラー数・実行時間」のメトリクスは、自分で出すか、Spanから生成する必要があります。方法は2つあります。

  • アプリケーション側で計測する:OpenTelemetryのMetrics APIでCounterとHistogramを作り、Toolの呼び出しごとに記録します。
  • Collectorで生成する:OpenTelemetry Collector Contribにはspanmetricsコネクタがあり、Spanから集計メトリクスを作れます(利用するCollectorのDistributionを確認してください)。アプリのコードを変えずに済みます。

アプリ側で計測する場合の例です(async関数向けです)。Metrics用のMeterProviderとExporterの設定も別途必要です。

Toolの呼び出し数と実行時間を記録
import time
from functools import wraps

from opentelemetry import metrics

meter = metrics.get_meter("company-mcp")

tool_calls = meter.create_counter(
    "mcp.tool.calls",
    description="Number of tool calls",
)
tool_duration = meter.create_histogram(
    "mcp.tool.duration",
    unit="s",
    description="Tool call duration",
)


def observed(tool_name):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            start = time.perf_counter()
            status = "ok"
            try:
                return await func(*args, **kwargs)
            except Exception:
                status = "error"
                raise
            finally:
                attrs = {
                    "gen_ai.tool.name": tool_name,
                    "status": status,
                }
                tool_calls.add(1, attrs)
                tool_duration.record(
                    time.perf_counter() - start,
                    attrs,
                )
        return wrapper
    return decorator

MCP Toolへ適用するときは、@mcp.tool()の内側に@observed("search_documents")を重ねます。SDKは関数の署名から入力スキーマを作るため、functools.wrapsで元の関数の情報を保っています。筆者の環境(MCP Python SDK 1.27のFastMCP)では、この重ね方で引数のスキーマが保たれ、計測も動くことを確認しました。MCPServerを含め、お使いのSDKのバージョンでも、tools/listで入力スキーマが変わっていないか確認してください。Label名は、SDKのSpan属性に合わせてgen_ai.tool.nameにしています。Span属性と同じ名前にしておくと、TraceとMetricsをつなげて調べやすくなります。

メトリクスはp95・p99とTool別のエラー率を見る

監視する項目は、概念的には次のようなものです。

  • Tool別の呼び出し数
  • Tool別のエラー数とエラー率
  • Tool別の実行時間(p50・p95・p99)
  • Tool Resultのサイズ
  • HTTP Status別のRequest数(Remote MCP)

平均値だけを見ると、一部のユーザーだけ10秒かかるような問題を見逃します。平均500msでも、p99が10秒なら一部のRequestは非常に遅いからです。AIエージェントは1つのTaskで複数のToolを順に呼ぶため、各Toolが1秒ずつ遅れるとWorkflow全体では数秒単位で遅くなります。

Tool名をLabelにするのは有効ですが、customer_id・request_id・emailのような値の種類が多いものはLabelへ入れません。Metric Backendの負荷が大きくなるためです。個別のRequestはログとTraceで調べます。また、Tool名は後から変えるとDashboardやAlertも壊れるため、Stable Identifierとして扱います(MCPで同じツール名が競合するとどうなる?名前付けとNamespace設計)。

MCP Method別・依存先別にも分けて見る

Toolだけでなく、tools/list・tools/call・resources/read・prompts/getのMethod別に見ると、原因を切り分けられます。tools/listまで遅いならServerやNetworkの問題を疑い、tools/listが20msでtools/call search_documentsだけ8秒なら、Tool内部の処理を疑います。

Streamable HTTPではMcp-MethodとMcp-NameがHTTP Headerへ出るため(MCPのstdioとStreamable HTTPの違い|ローカル・リモート構成の選び方)、Gatewayでも、Bodyを解析せずにMethodやTool名ごとの監視ができます。

さらにMCP Server自身のLatencyだけでなく、PostgreSQL・GitHub・Slack・LLM APIなど依存先ごとのLatencyも別のメトリクスにします。そうすれば「MCP Serverが遅い」のではなく「GitHub APIが遅い」と判断できます。

401・403・421もMonitoringの対象にする

Remote MCPでは、Tool内のエラー以外にも、TransportやSecurityの層で失敗します。MCP Python SDKのStreamable HTTPは、Production Hostを許可していなければ、実HostnameへのRequestに421 Misdirected Requestを返します。デプロイ直後に421が急増したら、Tool実装ではなくHost Allowlistの設定ミスを疑います(設定方法はMCPサーバーをローカルではなくリモート公開する方法|認証・HTTPSの基本で解説しています)。401や403の急増は、OAuthのScope変更や認証Layerの問題かもしれません。HTTP Status別のRequest数も見るようにします。

Health Checkは認証されない前提で作る

Load BalancerやContainer Platformから使うHealth Checkは、@mcp.custom_routeで追加できます。公式ドキュメントでも、このRouteは「サーバーの他の部分が認証されていても、常に認証されない」と明記されています。

Health Check
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

from mcp.server import MCPServer

mcp = MCPServer("Company MCP")


@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> Response:
    return JSONResponse({"status": "ok"})


app = mcp.streamable_http_app()

認証されないRouteなので、返すのは{"status": "ok"}程度にします。Database Password、Token、内部IP、環境変数、顧客情報、詳細なError Messageは返しません。詳しい診断情報が必要なら、MCPのcustom_routeではなく、Host Application側で認証付きの別Routeを作ります。

LivenessとReadinessを分ける

Health Checkには、「Processが生きているか」を見るLivenessと、「Trafficを受けられる状態か」を見るReadinessがあります。Process自体は動いていても、Databaseへ接続できなければToolは正常に実行できません。

2つのEndpointの役割
/health
  → Processが生きている

/ready
  → DBなど必須のDependencyも使える

ただしReadinessで毎回重い外部APIを呼ぶと、それ自体が障害の原因になります。確認するのは、Requestを処理するために本当に必要な最低限のDependencyだけにします。

MCPServerはアプリケーションサーバーではない

MCP Python SDKの公式ドキュメントでは、MCPServerは「Protocol Implementationであって、Application Serverではない」とされています。Workers、Timeout、TLS、Graceful Shutdown、Connection Limitといった本番の設定は、意図的にMCPServerの範囲外です。mcp.run("streamable-http")はuvicornのProcessを1つ起動するだけで、複数Workerが必要なら、streamable_http_app()をuvicornやgunicornなどのASGI Server、Platformのプロセス管理で動かします。

したがって、Observabilityも複数の層で見ます。

3つの層
Load Balancer / Gateway / ASGI Server
  → HTTP Status、Request数、Connection、502・504、TLS、Timeout

MCP Application
  → Method、Tool名、Toolのエラー、Latency、Result Size

外部Dependency
  → DB・APIのLatency、Timeout、Rate Limit、エラー率

MCP内部のTool Metricsだけでは、Proxyでの502や504は分かりません。層ごとの指標をTrace IDでつなぐと、障害調査が容易になります。

Alertはログの行ではなく症状から作る

「ERRORという文字列が1件出たら通知」というAlertはNoiseが多くなります。一時的なErrorは、正常な運用でも発生するからです。Alertは、ServiceがユーザーにどんなLatencyや失敗を見せているかという症状から作ります。

  • Error Rate:tools/callのError Rateが5分間継続して5%を超えた
  • Latency:Tool別のp95やp99が、一定時間、基準を超えた
  • Availability:Health Checkが失敗した、または5xxが増えた
  • Dependency:特定の外部APIのTimeoutやRate Limitが増えた

具体的な閾値はToolの用途によって変わるため、一律の値は決めず、運用しながら調整します。タイムアウト時の再試行や冪等性の設計はMCPツールがタイムアウトする原因|再試行・キャンセル・冪等性の設計も参考にしてください。

Tool Failureをすべて「error」にしない

失敗を1つのerrorにまとめると、原因が分からなくなります。少なくとも次の程度は区別できるようにします。

  • Validation Error(入力不正)
  • Authorization Error(権限・Scope)
  • Timeout
  • Dependency Error(外部APIやDBの失敗)
  • Internal Error(実装のBug)
  • Tool Result Error(is_errorのResult)

Timeoutだけが増えたなら外部API・Network・Databaseを、Authorization Errorだけなら、OAuthやScopeの変更を疑えます。ログのerror_typeと、メトリクスのLabelの両方に入れておきます。

Trace Samplingはhead samplingとtail samplingを区別する

Trafficが増えると、Traceの保存コストやNetwork費用も増えるため、Samplingを設計します。OpenTelemetryのドキュメントでは、2つの方式が区別されています。

  • Head Sampling:Samplingの判断をできるだけ早く、Requestの開始時点で行う
  • Tail Sampling:Trace内のすべて、またはほとんどのSpanを見てから判断する

「エラーを含むTraceは必ず残す」「全体のLatencyが長いTraceを残す」といった方針は、Head Samplingでは実現できず、Tail Samplingが必要です。Tail SamplingはOpenTelemetry Collectorのtail sampling processorで実現できますが、設定が複雑になりやすく、大量のデータを保持する状態を持つシステムが必要になる、といった運用上の負担があります。

Trafficが少ないうちは、すべてのTraceを保存して問題ない場合もあります。Sampling率を下げすぎると、まれにしか起きない障害を見失うため、Trafficと障害の頻度に合わせて調整します。

Deploy VersionをLogとTraceに入れる

本番では、いつから壊れたかが非常に重要です。先ほどのOpenTelemetryの初期化のように、service.versionとdeployment.environment.nameをResourceの属性に入れておくと、「v1.8.2へDeployした直後から、delete_customerのError Rateが増えた」と判断できます。Git Commitも入れておくと、さらに調べやすくなります。

複数ReplicaではServer名とInstance IDを分ける

複数のReplicaで動かすと、どのPodやContainerで起きたかをログに入れたくなります。ここで注意したいのが、MCPServer("billing-pod-1")のように、Server名にInstance IDを入れてしまうことです。

ここは、ToolがElicitationなどで追加の入力を求める(Multi Round-Tripを使う)場合の話です。そうしたToolがなければ関係しません。MCP Python SDKの公式ドキュメントによると、Multi Round-TripのrequestStateには、署名されたEnvelopeにServer名がaudienceとして入り、検証されます。複数Instanceで動かすときは、RequestStateSecurity(keys=[...])で全Instanceが同じ鍵を共有し、Server名(または明示的なaudience)を揃える必要があります。揃っていないと、Retryが別のInstanceへ届いたときに、-32602のInvalid or expired requestStateで失敗します。

そのため、Instanceの識別には、Server名ではなくservice.instance.id・pod.name・container.idのようなTelemetryの属性を使います。Multi Round-Tripを使う場合、鍵の共有とServer名の統一は、監視の話というより、複数Instanceで確実に動かすための前提です。

監査ログとApplication Logを分ける

更新・削除Toolを持つMCPでは、通常のApplication Logとは別にAudit Logも必要です。Application Logは、処理時間・例外・Dependency Errorを追うために使います。Audit Logは、誰が・いつ・何を・どのResourceへ・どんな変更をしたかを追跡するために使います。保存期間や閲覧権限も異なるため、可能なら別のStorageやIndexへ分けます。

記録するのはuser_id・client_id・scope・tool_name・resource_id・operation・result・trace_idなどで、Access TokenやAuthorization Headerの値は保存しません。破壊的Toolの設計と監査ログはMCPツールに破壊的操作を持たせるときの設計|削除・更新処理を安全にするで解説しています。

Trace ExporterへPayload全文を送る前にも、保存してよいDataか、保存期間は適切か、誰が閲覧できるかを確認します。Tool名・所要時間・Status・件数・サイズだけを残す構成でも、障害調査には十分です。

障害調査はMetric、Trace、Logの順に絞る

たとえば「検索Toolが遅い」と報告されたとします。巨大なLogを最初から検索するより、次の順に絞り込みます。

調査の流れ
Dashboard
  → search_documentsのp95が上昇

Trace
  → tools/call search_documents 6.4秒
      └─ vector_search 6.0秒

Log(trace_idで検索)
  → upstream timeout

この順番で調べられるのは、メトリクスのLabel、ログのtrace_id、TraceのService属性がそろっているからです。最初に入れておくと、Toolが数個から数十個に、Serverが1台から複数Replicaに増えても、原因を追いやすくなります。

MCPサーバーの本番運用に関するよくある質問

QMCP ProtocolのLogging機能はもう使えませんか?

A使えます。2026-07-28の非推奨は「注釈のみ」で、メソッド・型・Capability Flagは、このRevisionと1年以内に公開される仕様でも動作するとされています。ただしlogging/setLevelは2026-07-28で削除され、ログレベルはリクエストごとの_metaのio.modelcontextprotocol/logLevelで指定する形に変わっています。新規のServerでは、stdioならstderr、構造化されたObservabilityならOpenTelemetryを中心に設計するのが公式の方向です。

QOpenTelemetryを入れないと、SDKのSpanは何も起きませんか?

ASDKが依存するのはopentelemetry-apiだけで、OpenTelemetry SDKとExporterをInstallするまで、Spanはほぼ何もしないNo-opとして動きます。Traceを収集したくなった時点で、SDKとExporterを追加し、初期化コードを入れます。

QTool別のメトリクスはSDKが自動で出してくれますか?

A公式ドキュメントで確認できたのはSpanまでで、Tool別のメトリクスは自分で計測するか、CollectorでSpanから生成します。Collector Contribのspanmetricsコネクタを使えば、アプリのコードを変えずにSpanから集計メトリクスを作れます。

Q「エラーのTraceだけ残す」ことはできますか?

AHead Samplingでは実現できず、Tail Samplingが必要です。OpenTelemetry Collectorのtail sampling processorで実現できますが、設定が複雑になり、データを保持する運用上の負担も増えます。Trafficが少ないうちは、すべて保存する選択肢もあります。

Q/healthに認証を付けられますか?

AMCP Python SDKのcustom_routeは常に認証されないとされています。認証が必要な診断Endpointは、Host Application側で別Routeとして作ります。/healthは機密情報を含まない最小限のResponseにします。

本番MCPは「どのToolが・どこで・なぜ遅いか」を追える状態にする

MCP ProtocolのLoggingは非推奨になりましたが、すぐに壊れるわけではありません。新しく作るServerでは、stdoutをProtocol専用にしてログはstderrへ出し、構造化ログ、Tool単位のメトリクス、OpenTelemetryのTraceを組み合わせます。

MCP Python SDKは、受信メッセージごとにSpanを作り、Toolの実行にはgen_ai.tool.nameなども付けます。OpenTelemetry SDKを入れればSpanをBackendへ送れます。一方、メトリクス、Health Check、Timeout、Workers、TLSなどはSDKの範囲外なので、自分で用意します。複数ReplicaでMulti Round-Tripを使うなら、requestStateの鍵とServer名を揃え、Instanceの識別はTelemetryの属性で行います。

Alertはログの行ではなく、Error Rate・p95やp99のLatency・Availability・依存先の失敗といった症状から作ります。そして障害時は、Metricで異常に気付き、Traceで遅い箇所を特定し、Logで原因を確認します。

重要なのは、単にError Logを残すことではなく、「どのToolが失敗しているか」「何秒かかったか」「どの依存先で止まったか」「どのDeployから悪化したか」まで追跡できる状態を、最初から作っておくことです。(2026年10月時点の公式ドキュメントに基づきます)