MCP 서버가 블랙박스였다: FastMCP·TypeScript에 OpenTelemetry를 붙여 에이전트 추론부터 DB 호출까지 한 화면에
이 글은 MCP 서버를 이미 운영 중이거나 도입을 검토 중인 개발자를 대상으로 합니다. AI 에이전트가 MCP 서버의 도구를 호출할 때 내부에서 무슨 일이 벌어지는지, 처음엔 솔직히 전혀 알 수 없었습니다. 에이전트가 추론하고, Tool을 골라서, MCP 서버가 실행되고, 그게 다시 데이터베이스를 치는 과정이 통째로 블랙박스였거든요. 어딘가에서 응답이 느려지면 어디가 문제인지 알 방법이 없고, 오류가 터져도 로그를 뒤지다가 결국 포기하는 상황이 반복됐습니다.
MCP SDK는 기본적으로 옵저버빌리티 기능을 전혀 내장하지 않습니다. OpenTelemetry 계측을 직접 붙여야 하는데, 막상 시작하려면 어디서부터 손대야 할지 막막하죠. 이 글을 읽고 나면 Python(FastMCP) 서버는 환경변수 몇 개만으로, TypeScript 서버는 --import 플래그 하나만 추가해서, 기존 코드 거의 수정 없이 Jaeger에서 에이전트 전체 트레이스를 한 화면에 확인할 수 있게 됩니다. 실무에서 자주 걸리는 민감 데이터 노출, 스팬 중복, 샘플링 누락 함정도 함께 짚어봤습니다.
몇 달 전만 해도 MCP+OTel 관련 자료가 거의 없었는데, 요즘은 Elastic, Google, SigNoz가 거의 동시에 계측 가이드를 냈습니다. OTel v1.39에서는 MCP 전용 시맨틱 컨벤션까지 공식 추가됐고요. 에이전틱 워크플로가 프로덕션에 올라가는 지금, 계측 없이 서버를 운영하는 건 로그도 없이 배포하는 것과 다를 바가 없습니다.
핵심 개념
개념보다 코드를 먼저 보고 싶다면 아래 실전 적용 섹션으로 바로 이동해도 됩니다.
왜 MCP 서버 계측이 까다로운가
일반 HTTP API는 미들웨어 하나 붙이면 요청 단위 트레이스가 뚝딱 나옵니다. MCP는 다릅니다. 에이전트 프레임워크(Claude, LangChain, LlamaIndex 등)가 위에 있고, MCP 서버가 그 아래에 있으며, 둘 사이에는 JSON-RPC 메시지가 오갑니다. 에이전트 쪽 트레이스와 서버 쪽 트레이스를 같은 trace_id로 이어붙이는 것이 핵심 과제입니다.
거기다 MCP는 HTTP/SSE 외에 stdio 전송도 지원하는데, stdio에는 HTTP 헤더가 없습니다. 기존 OTel의 W3C TraceContext 전파 방식(traceparent 헤더)을 그대로 쓸 수가 없는 거죠.
트레이스 컨텍스트 전파(Context Propagation): 분산 시스템에서 서로 다른 프로세스가 같은 요청 흐름임을 알 수 있도록
trace_id와span_id를 메시지에 실어 전달하는 메커니즘. HTTP라면 헤더, MCP라면_meta필드를 통로로 사용합니다.
_meta 필드: 전송 계층에 관계없이 컨텍스트 흘리기
MCP 프로토콜의 JSON-RPC 메시지에는 원래부터 _meta 필드가 있습니다. 여기에 traceparent, tracestate, baggage 값을 넣어서 HTTP든 stdio든 동일하게 컨텍스트를 흘려보내는 방식이, FastMCP를 비롯한 여러 계측 라이브러리에서 실질적으로 사용되고 있습니다. MCP 공식 스펙에서 컨텍스트 전파를 _meta로 완전히 표준화한 것은 아니고, 커뮤니티와 벤더들의 관행이 수렴되어가는 단계입니다.
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "search_database",
"arguments": { "query": "user:123" },
"_meta": {
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"tracestate": ""
}
}
}이렇게 하면 에이전트가 _meta에 자신의 스팬 정보를 심고, MCP 서버는 그걸 꺼내서 부모 컨텍스트로 삼는 방식으로 단일 분산 트레이스가 완성됩니다.
OTel v1.39 MCP 시맨틱 컨벤션
v1.39에서 처음 도입되어 현재 v1.41.0까지 발전해온 MCP 전용 속성명이 공식화됐습니다. tools/call 스팬에 다음 속성들을 표준으로 붙이도록 명세가 정해졌어요.
| 속성명 | 설명 | 예시 값 |
|---|---|---|
gen_ai.operation.name |
작업 유형 | execute_tool |
mcp.method.name |
JSON-RPC 메서드 | tools/call |
gen_ai.tool.name |
호출된 Tool 이름 | search_database |
mcp.session.id |
MCP 세션 식별자 | sess_abc123 |
mcp.protocol.version |
프로토콜 버전 | 2024-11-05 |
주의 — GenAI/MCP 시맨틱 컨벤션은 현재 Development 상태입니다(v1.39 도입, 현재 v1.41.0 기준). Breaking Change 없이 속성명이 바뀔 수 있으니, 프로덕션에 올릴 때는
opentelemetry/semantic-conventions를1.39.0처럼 정확한 버전으로 고정해두는 것을 권장합니다.
실전 적용
예시 1: Python — FastMCP 3.0으로 서버 코드 0줄 계측하기
FastMCP 3.0은 HTTP 트랜스포트에서 OTel 계측을 기본으로 내장하고 있어서, 이게 맞나 싶을 정도로 간단합니다. 환경변수 몇 개로 전부 됩니다(stdio 트랜스포트에서 컨텍스트 전파까지 필요하다면 뒤의 수동 구현 방식을 참고해보시면 좋습니다).
# pip install fastmcp opentelemetry-sdk opentelemetry-exporter-otlp
from fastmcp import FastMCP
# db, users는 기존 ORM/클라이언트 인스턴스
mcp = FastMCP("my-mcp-server")
@mcp.tool()
def search_database(query: str) -> str:
"""데이터베이스를 검색합니다."""
return db.search(query)
@mcp.tool()
def get_user_info(user_id: int) -> dict:
"""사용자 정보를 가져옵니다."""
return users.find(user_id)# 환경변수만으로 OTel 활성화 — 서버 코드는 그대로
OTEL_SERVICE_NAME=my-mcp-server \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
python server.pyFastMCP는 tool, prompt, resource, resource_template 호출 전체에 스팬을 자동 생성합니다. 서버 코드를 한 줄도 건드리지 않아도 됩니다.
async def와 def 중 어느 쪽을 쓸까요? FastMCP는 동기(def)·비동기(async def) 핸들러를 모두 지원하며, OTel 계측도 둘 다 동일하게 동작합니다. DB 호출이나 외부 HTTP 요청처럼 IO 바운드 작업이 있다면 async def가, 순수 연산이라면 def도 무방합니다.
컨텍스트 전파를 직접 제어하거나 stdio 트랜스포트를 사용하는 경우, _meta 삽입/추출을 아래처럼 명시적으로 구현할 수 있습니다.
from opentelemetry import trace, propagate
from opentelemetry.trace import SpanStatusCode
from fastmcp import FastMCP, Context
mcp = FastMCP("my-mcp-server")
@mcp.tool()
async def search_database(query: str, ctx: Context) -> str:
# _meta에서 부모 컨텍스트 추출
parent_ctx = propagate.extract(ctx.meta or {})
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"search_database",
context=parent_ctx
) as span:
span.set_attribute("gen_ai.tool.name", "search_database")
span.set_attribute("mcp.method.name", "tools/call")
# 주의: 민감 데이터(API 키, PII)는 스팬 속성에 직접 넣지 말 것
span.set_attribute("db.query.length", len(query))
try:
result = db.search(query) # db는 기존 클라이언트 인스턴스
span.set_attribute("db.result_count", len(result))
return result
except Exception as e:
# 오류 경로: 예외와 스팬 상태를 함께 기록해야 트레이스에서 실패 원인을 추적할 수 있습니다
span.record_exception(e)
span.set_status(SpanStatusCode.ERROR, str(e))
raise예시 2: TypeScript — --import 플래그로 기존 서버에 계측 붙이기
처음에 Jaeger를 열어봤더니 tools/call 스팬이 두 개씩 찍혀서 한참 헤맸는데, 알고 보니 에이전트 프레임워크와 계측 라이브러리가 동시에 스팬을 만들고 있었습니다. 이건 뒤의 실수 목록에서 다시 다루겠습니다.
TypeScript MCP 서버를 이미 운영 중이라면 코드를 전혀 수정하지 않고 계측을 붙일 수 있습니다.
// npm install @opentelemetry/sdk-node opentelemetry-instrumentation-mcp
// npm install @opentelemetry/exporter-trace-otlp-grpc @opentelemetry/resources
//
// 참고: opentelemetry-instrumentation-mcp는 공식 OTel 패키지가 아닌
// 커뮤니티 패키지입니다 (github.com/theharithsa/opentelemetry-instrumentation-mcp).
// 프로덕션 도입 전에 라이선스와 유지보수 상태를 확인해보시는 것을 권장합니다.
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { MCPInstrumentation } from 'opentelemetry-instrumentation-mcp';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
const sdk = new NodeSDK({
resource: new Resource({
'service.name': 'my-mcp-server', // SEMRESATTRS_SERVICE_NAME은 deprecated 예정이므로 문자열 리터럴 사용
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4317',
}),
instrumentations: [new MCPInstrumentation()],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().then(() => process.exit(0));
});# JS로 컴파일한 경우 (가장 안정적)
node --import ./instrumentation.js your-mcp-server.js
# tsx 사용 중이라면 (Node.js 22+, tsx 4.x 기준으로 검증됨)
npx tsx --import ./instrumentation.ts your-mcp-server.ts
# ts-node + ESM 조합은 Node.js 버전과 tsconfig 설정에 따라 막히는 경우가 있으니
# 위 두 방법 중 하나를 먼저 시도해보시면 좋습니다MCPInstrumentation이 MCP SDK의 내부 메서드를 패치해서 tools/call마다 스팬을 생성하고, _meta 컨텍스트 전파도 처리해 줍니다. 오류 스팬을 직접 기록해야 할 때는 아래 패턴이 기준점이 됩니다.
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('my-mcp-server');
async function searchDatabase(query: string): Promise<string[]> {
return tracer.startActiveSpan('search_database', async (span) => {
span.setAttribute('gen_ai.tool.name', 'search_database');
try {
const result = await db.search(query);
span.setAttribute('db.result_count', result.length);
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
throw err;
} finally {
span.end();
}
});
}예시 3: OTel Collector로 RED 메트릭 자동 추출하기
트레이스를 수집하고 있다면, 별도 메트릭 구현 없이 spanmetrics 커넥터로 Rate/Error/Duration 메트릭을 자동으로 뽑아낼 수 있습니다. Prometheus로 내보내면 Grafana 대시보드에서 바로 확인 가능합니다.
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
connectors:
spanmetrics:
histogram:
explicit:
# spanmetrics 커넥터는 Go duration 문자열을 받습니다
buckets: ["10ms", "50ms", "100ms", "500ms", "1s", "5s"]
dimensions:
- name: gen_ai.tool.name # Tool별 메트릭 분리
- name: mcp.session.id
- name: service.name
metrics_expiration: 5m
exporters:
prometheus:
endpoint: "0.0.0.0:8889"
otlp/jaeger:
endpoint: http://jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger, spanmetrics]
metrics:
receivers: [spanmetrics]
exporters: [prometheus]RED 메트릭: Rate(요청 수/초), Error(오류율), Duration(응답 시간)의 약자.
spanmetrics커넥터는 트레이스 스팬에서 이 세 가지를 자동으로 계산해 줍니다. 별도 메트릭 계측 코드를 작성하지 않아도 됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 엔드투엔드 가시성 | 에이전트 추론 → Tool 선택 → MCP 실행 → DB 호출까지 단일 트레이스로 연결 |
| 벤더 중립 | Jaeger, Datadog, New Relic, Elastic APM, Google Cloud Trace 등 어디서든 동작 |
| 낮은 도입 비용 | FastMCP는 환경변수만, TypeScript는 --import 플래그 하나로 시작 가능 |
| 표준화된 속성명 | OTel 시맨틱 컨벤션으로 팀 간 속성명이 통일되어 일관된 대시보드 구성 가능 |
| 메트릭 자동 파생 | 트레이스 하나로 RED 메트릭까지 생성, 별도 계측 코드 불필요 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 시맨틱 컨벤션 미완성 | MCP/GenAI 컨벤션이 Development 상태, 속성명 변경 가능성 있음 | opentelemetry/semantic-conventions를 1.39.0처럼 정확한 버전으로 고정 |
| stdio 전파 복잡성 | HTTP 헤더를 쓸 수 없어 _meta 방식을 클라이언트-서버 양측이 모두 구현해야 함 |
공식 계측 라이브러리 사용으로 직접 구현 최소화 |
| 스팬 중복 | GenAI 계층과 MCP 계층이 동시에 execute_tool 스팬을 생성할 수 있음 |
에이전트 프레임워크와 계측 라이브러리의 스팬 생성 범위 확인 후 중복 제거 |
| 스토리지 비용 급증 | 고빈도 Tool 호출 시 전량 수집하면 트레이스 저장소 비용이 폭증함. 처음 프로덕션에 올린 주에 청구서를 보고 당황했다는 이야기가 실무에서 꽤 많이 들립니다 | Head-based 또는 Tail-based 샘플링 설정 필수 |
| 민감 데이터 유출 | Tool 인자를 스팬 속성으로 기록하면 API 키, 개인정보가 트레이스에 남음 | OTel Collector의 attributesprocessor로 민감 속성 필터링 |
Tail-based 샘플링: 요청이 완전히 끝난 뒤 전체 트레이스를 보고 오류/지연 여부에 따라 저장 여부를 결정하는 방식. 정상 요청은 버리고 문제 있는 요청만 남길 수 있어 고빈도 환경에서 효과적입니다.
실무에서 가장 흔한 실수
-
민감 데이터 필터링 없이 배포: Tool 인자를 스팬에 통째로 기록하면
{ "api_key": "sk-..." }같은 값이 그대로 트레이스 저장소에 쌓입니다. Collector의attributesprocessordelete액션으로 수집 전에 걸러내는 것이 좋습니다. 개발 환경에서는 문제 없다가 프로덕션 배포 직후 발견하는 경우가 많습니다. -
샘플링 설정 없이 프로덕션 투입: 개발 환경에서는 전량 수집이 편하지만, 에이전트가 초당 수백 번 Tool을 호출하는 프로덕션 환경에서는 스토리지 비용이 예상보다 훨씬 빠르게 불어납니다.
OTEL_TRACES_SAMPLER=parentbased_traceidratio와OTEL_TRACES_SAMPLER_ARG=0.1환경변수만으로 10% 샘플링을 간단하게 걸어둘 수 있습니다. -
스팬 중복 모니터링 누락: LangChain이나 LlamaIndex 같은 에이전트 프레임워크도 자체 OTel 계측을 포함하는 경우가 많습니다. Jaeger에서 같은 Tool 호출에
tools/call스팬이 두 개 보인다면, 에이전트 프레임워크와 MCP 계측 라이브러리가 동시에 스팬을 만들고 있는 것입니다. 계층별 스팬 생성 범위를 확인해서 둘 중 하나를 비활성화하거나 범위를 좁히면 됩니다.
마치며
지금 바로 시작해볼 수 있는 3단계입니다.
-
로컬에서 Jaeger 올리기:
docker run -d --name jaeger -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one:latest로 Jaeger 컨테이너를 띄우면http://localhost:16686에서 트레이스 뷰어가 바로 열립니다. -
MCP 서버에 환경변수 붙이기: Python(FastMCP)이라면
OTEL_SERVICE_NAME=my-mcp-server OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317를 붙여서 서버를 실행해볼 수 있습니다. TypeScript라면 예시 2의instrumentation.ts를 만들고node --import ./instrumentation.js your-mcp-server.js로 실행해볼 수 있습니다. -
Tool 하나 호출해보기: 에이전트나 MCP 클라이언트로 Tool을 한 번 호출한 뒤 Jaeger UI에서
my-mcp-server를 검색해보면,tools/call스팬에gen_ai.tool.name,mcp.method.name속성이 찍혀 있는 걸 확인할 수 있습니다.
MCP 서버가 프로덕션에 올라간다면, 계측은 기능이 아니라 인프라입니다. 에이전트의 추론과 Tool 실행이 단일 트레이스로 연결되는 순간, 성능 병목 위치를 바로 집어낼 수 있고 오류 원인 추적 시간이 드라마틱하게 줄어드는 경험을 해볼 수 있습니다.
참고 자료
시작 가이드
- OpenTelemetry - FastMCP 공식 문서
- MCP Observability with OpenTelemetry | SigNoz Blog
- Monitor MCP Performance with OpenTelemetry | MCPcat
- How to Instrument MCP Servers with OpenTelemetry for Production Observability | OneUptime Blog
심화
- How to trace MCP server tool calls with OpenTelemetry and Elastic APM | Elastic Observability Labs
- Distributed tracing for agentic workflows with OpenTelemetry | Red Hat Developer
- FastMCP Distributed Tracing: Transport-Agnostic Context Propagation with _meta
- How OpenTelemetry Traces LLM Calls, Agent Reasoning, and MCP Tools | Greptime Blog