에이전트가 밟는 모든 단계를 끝까지 붙들고, 기록으로 남깁니다.
mcpd는 프레임워크가 아니라 데몬입니다. 단계와 단계 사이는 mcpd가 붙들고 있습니다. 재시도, 타이머, 승인을 기다리는 일주일까지 붙들어 둡니다. 각 단계 안의 작업은 언어에 상관없이 직접 작성한 코드가 합니다. 모든 단계는 비용과 함께 트레이스에 남습니다.
빌드해서 실행하기
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo 그다음 localhost:4317을 엽니다. 계정도, 따로 세울 데이터베이스도, 서명할 것도 없습니다. 아직 릴리스는 없습니다. 이미지와 패키지는 첫 릴리스와 함께 나옵니다. 5분 빠른 시작 읽기.
읽을 수 있는 실행.
에이전트가 실패할 때, 실패는 오류를 적어 둔 로그 줄에서 일어나지 않습니다. 세 단계 앞에서 일어납니다. 모델이 잘못된 도구를 고르고, 같은 도구를 한 번 더 골랐을 때입니다.
mcpd는 실행 전체를 트리로 되살립니다. 모든 모델 호출, 모든 도구 호출, 모든 검색에 그때의 프롬프트와 응답, 각각의 지연 시간, 각각의 비용이 붙어 있습니다.
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
실행 드럼
같은 실행을 기록 드럼에 감은 것입니다. 시간은 둘레를 따라 흐르고, 스팬마다 트랙이 하나씩 있습니다. 돌리면 펜이 그 아래의 스팬을 읽습니다.
자신을 시작한 프로세스보다 오래 남는 실행.
단계 사이에서 실행을 소유하는 쪽은 데몬입니다. 한 단계 안의 모든 것은 직접 돌리는 프로세스가 소유하고, 데몬에 넘기는 것은 없습니다. 모델도, 프롬프트도, 자격 증명도 넘어가지 않습니다.
리스 보드
데몬은 단계마다 태그 하나를 보드에 걸어 둡니다. 워커는 하트비트가 도착하는 동안에만 태그를 쥡니다. 워커를 멈추면 리스 기간과 회수 창 한 번이 지난 뒤 태그가 보드로 돌아가 다른 워커가 가져갑니다.
그래프는 데이터, 단계 본체는 직접 작성한 코드
워크플로는 코드로 한 번 작성합니다. 그래프는 정의로서 데몬에 넘어가고, 데몬이 그것을 검증하고 버전을 매깁니다. 단계 본체는 직접 돌리는 프로세스에 그대로 남아, 여섯 언어 중 무엇으로든 워커가 실행합니다.
재시도와 타이머와 대기는 모두 데이터 행
일시적인 실패는 그 단계에 배정된 예산 안에서 재시도합니다. 슬립이나 승인 대기는 살아 있는 프로세스 없이도 실행을 붙들어 둡니다. 배포를 지나도, 프로세스가 죽어도, 일주일이 지나도 그대로입니다. 이벤트는 이름과 키로 대기에 응답하고, 같은 이벤트가 다시 오면 중복으로 처리할 뿐 두 번째 실행이 되지 않습니다.
모든 실행은 하나의 트레이스
각 단계는 입력과 출력과 보고된 사용량을 담은 스팬입니다. 다른 모든 스팬과 같은 가격표로 값을 매깁니다. 단계 안의 모델 호출은 그 아래에 놓이므로, 검색과 비용 산정과 스코어러는 실행을 그대로 다룹니다.
이미 쓰고 있는 언어로 계측합니다.
SDK는 여섯 개, 동작은 하나입니다. 스팬을 기록하는 일은 블로킹하지 않고, 예외를 던지지 않으며, 끝없이 늘어나지 않습니다. mcpd에 닿지 않아도 에이전트는 알아채지 못합니다. 관측 가능성이 실패해도 되는 방식은 그것 하나뿐입니다. 각 SDK에는 워커도 들어 있어서, 지속성 실행의 각 단계는 그 단계를 기록하는 바로 그 프로세스에서 처리됩니다.
여섯 개 모두 필수가 아닙니다. OTEL_EXPORTER_OTLP_ENDPOINT를 mcpd로 향하게 하면 이미 돌리고 있는 OpenTelemetry SDK가 그대로 데이터를 보내고, 애플리케이션에는 mcpd 쪽 코드가 한 줄도 들어가지 않습니다. 여섯 SDK는 HTTP 인터페이스 위에 얹은 편의 계층일 뿐, 기록되기 위한 조건이 아닙니다.
프레임워크에 딸려 오는 관측 가능성은 그 프레임워크로 만든 에이전트만 기록할 수 있습니다. mcpd는 자신의 OTLP 엔드포인트에 도착한 것이면 무엇이든 받습니다. 에이전트 프레임워크든, 직접 작성한 루프든, 그 둘이 호출하는 서비스든 마찬가지입니다.
실행 계측하기
/**
* 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 }
} # The root span is the run. Everything opened inside it becomes a child,
# because the active span lives in a `contextvars.ContextVar`.
with mcpd.trace("support_agent", session_id="sess-8121", user_id=user_id,
tags=["support", "orders"]) as run:
run.set_input({"question": question})
# A model call. `set_usage` is what mcpd prices the run from, so the
# cost on the trace is the cost you were actually billed.
with mcpd.span("plan", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_model("gpt-4o-mini", provider="openai")
generation.set_input([
{"role": "system", "content": "You are a support agent."},
{"role": "user", "content": question},
])
time.sleep(0.12) # stand-in for the provider round trip
generation.set_output("I should look the order up first.")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)
# A tool call that fails. The `with` block records the exception on the
# span, marks it as an error, and lets the exception through unchanged.
order = None
try:
with mcpd.span("lookup_order", kind=SpanKind.TOOL_CALL) as tool:
tool.set_entity("tool", entity_id="orders.lookup", name="Order lookup")
tool.set_input({"order_id": "A-7741"})
order = lookup_order("A-7741")
tool.set_output(order)
except TimeoutError as exc:
# The span already carries the error. This is the agent deciding
# what to do about it, which is a different question.
mcpd.log("warn", "order lookup failed, answering from context", {"error": str(exc)})
mcpd.metric("support.tool_failures", 1, "counter", labels={"tool": "orders.lookup"})
reply = ("I could not reach the order system just now. Your order A-7741 "
"shipped on Tuesday and tracking should update within the hour.")
run.set_output(reply)
run.set_metadata({"used_fallback": order is None})
return reply, run.trace_id // The root span is the run. The sampling decision is made here, once, and
// every child inherits it.
let run = mcpd.trace("support agent");
run.set_input(question);
run.set_session("session-4f21");
run.set_user("customer-91");
run.set_tags(["support", "orders"]);
// A tool call. `run.child_of` nests it under the run.
let lookup = run.child_of("lookup_order", SpanKind::ToolCall);
lookup.set_input(json!({ "order_id": "5512" }));
let order = json!({ "status": "shipped", "carrier": "DHL", "eta": "Tuesday" });
lookup.set_output(&order);
lookup.end();
// A tool that fails. Recording the error sets the span's status to `error`,
// and a message is required: "something failed" is not actionable.
let notify = run.child_of("notify_customer", SpanKind::ToolCall);
notify.set_input(json!({ "channel": "sms" }));
notify.set_error("sms gateway timed out after 5s");
notify.end();
// The failure did not stop the run: the agent falls back to email, and the
// trace shows both the attempt and the recovery.
run.event_with("fell back to email", json!({ "after": "sms gateway timeout" })); // handleTicket is the agent. It drafts an answer with a model, tries to send
// it with a tool, and fails on the send so the error path is recorded too.
func handleTicket(ctx context.Context, m *mcpd.Client, question string) string {
ctx, run := m.Start(ctx, "support_agent",
mcpd.WithSpanKind(mcpd.SpanKindAgentRun),
mcpd.WithEntity(mcpd.EntityKindAgent, "support-agent", "Support agent"),
mcpd.WithSession("sess-4821"),
mcpd.WithUser("u-317"),
mcpd.WithTags("support", "billing"),
mcpd.WithInput(question),
)
defer run.End()
m.Log(ctx, mcpd.LogLevelInfo, "ticket received", map[string]any{"channel": "email"})
answer := draftAnswer(ctx, m, question)
run.SetOutput(answer)
if err := sendReply(ctx, m, answer); err != nil {
// The span is now an error with a message, which is what makes it
// worth reading. End below records the failed status.
run.RecordError(err, mcpd.ErrorDomain("email"), mcpd.ErrorRetryable(true))
}
return run.TraceID()
} val question = "where is my order 41?"
// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
"support_agent",
SpanKind.AGENT_RUN,
input = question,
sessionId = "session-8f21",
userId = "customer-4477",
tags = listOf("support", "email"),
) { run ->
mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))
val plan = planAnswer(mcpd, question)
val order = lookUpOrder(mcpd, 41)
val estimate = estimateDelivery(mcpd, 41)
val answer = buildString {
append(plan).append(' ').append(order)
if (estimate != null) append(" It should arrive ").append(estimate).append('.')
}
run.setOutput(answer)
mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")
// The block's value is the value of `span`.
run.traceId
} question = "where is my order 41?"
# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
Mcpd.span mcpd, "support_agent",
kind: :agent_run,
input: question,
session_id: "session-8f21",
user_id: "customer-4477",
tags: ["support", "email"] do
Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})
plan = plan_answer.(mcpd, question)
order = look_up_order.(mcpd, 41)
estimate = estimate_delivery.(mcpd, 41)
_answer =
[plan, order, estimate && "It should arrive #{estimate}."]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
Mcpd.Context.current().trace_id
end # One batch carries any mix of the five signals. Send the whole run at once.
curl -s -X POST "$MCPD/v1/ingest" \
-H 'content-type: application/json' \
-H "authorization: Bearer ${MCPD_API_KEY:-}" \
-d @- <<JSON
{
"ingest_key": "run-$TRACE",
"spans": [
{
"trace_id": "$TRACE",
"span_id": "$ROOT",
"name": "support_agent",
"kind": "agent_run",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": true, "is_event": false, "received_at": "$NOW",
"input": { "question": "Where is order 4417?" },
"output": { "answer": "It is in the Leeds depot." },
"correlation": { "project": "default", "environment": "production" }
},
{
"trace_id": "$TRACE",
"span_id": "$GEN",
"parent_span_id": "$ROOT",
"name": "chat",
"kind": "model_generation",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": false, "is_event": false, "received_at": "$NOW",
"model": "gpt-4o",
"provider": "openai",
"usage": { "input_tokens": 1284, "output_tokens": 210 },
"correlation": { "project": "default" }
}
]
}
JSON
# → {"accepted":2,"rejected":0,"duplicate":0}
#
# mcpd prices the model span from its own price book on the way in, so cost is
# stored with the run rather than recomputed later from rates that may have
# changed since. 여섯 가지 시그널을, 그것을 만든 실행으로 묶습니다.
대부분의 도구는 트레이스를 한곳에, 로그를 다른 곳에 두고, 타임스탬프를 눈으로 맞추는 일은 읽는 사람에게 남깁니다. mcpd는 여섯 시그널을 모두 같은 트레이스 id와 스팬 id에 붙여 저장합니다. 그래서 차트에 솟은 지점에서 그것을 일으킨 실행을 바로 열 수 있고, 실행에서는 그 실행이 남긴 로그 줄을 볼 수 있습니다.
분류대
여섯 종류의 신호가 한 벨트에 섞여 도착합니다. 리더가 각 조각의 trace id를 읽고, 한 실행의 조각은 모두 그 실행의 서랍에 들어갑니다.
13개 언어가 같은 말을 합니다.
Cross-language conformance
tests/e2e/run.mjs
| Channel | root | tree | status | error | model | tokens | score | feedback | ids |
|---|---|---|---|---|---|---|---|---|---|
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust | |||||||||
| cURL | |||||||||
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust |
13 languages, one canonical run, one daemon. Every trace comes back the same shape.
481 checks
SDK는 각각 따로 테스트합니다. 그것으로 증명되는 것은 각 SDK가 만든 사람의 의도대로 동작한다는 사실뿐이고, 서로 일치하는지는 알 수 없습니다. 그래서 모든 SDK가 하나의 데몬에 같은 실행을 기록하고, 그 트레이스를 다시 읽어 서로 비교합니다.
어떤 언어에서는 성립하고 다른 언어에서는 성립하지 않는 동작은 여기서만 드러납니다. 변경이 있을 때마다 CI에서 실행합니다. 다른 도구와의 비교.
누가 무엇을 읽었는지, 거절당한 사람까지.
보안 검토의 결론을 가르는 질문은 데이터가 안전한가가 아닙니다. 나중에 누가 그 데이터에 닿았는지 보여 줄 수 있는가입니다.
Audit log
GET /v1/api/audit
| Time | Principal | Action | Project | Result |
|---|---|---|---|---|
| 14:02:11 | a3f9c2e1b7d4 | get_trace | checkout | ok |
| 14:02:11 | a3f9c2e1b7d4 | list_spans | checkout | ok |
| 14:04:38 | 7c1e08b53a9f | list_dataset_items | billing | refused |
| 14:07:02 | d52a4f8c0e63 | capture_into_dataset | checkout | ok |
| 14:11:55 | d52a4f8c0e63 | delete_trace | checkout | ok |
| 14:19:20 | 7c1e08b53a9f | list_feedback | billing | ok |
A refusal is recorded like a read. The principal is a digest of the key, never the key. Retention never trims this table.
18 actions
키 스코프 여섯 가지
텔레메트리 보내기, 프롬프트 읽기, 저장된 데이터 변경, 데이터 파기, 실행 시작, 단계 가져가기는 서로 다른 권한입니다. write를 설정하지 않으면 ingest 키를 받고, admin을 설정하지 않으면 read 키를 받습니다. execute와 worker를 설정하지 않으면 write 키를 받습니다. 업그레이드해도 잠기지 않습니다.
프로젝트가 곧 경계
키와 보존 기간과 샘플링은 모두 프로젝트 단위입니다. 한 프로젝트에 등록된 키는 그 프로젝트에만 닿습니다. 데몬은 데이터 한 건을 돌려주기 전에 그 데이터가 어느 프로젝트 것인지 먼저 확인합니다.
밖으로 아무것도 알리지 않음
데몬 전체에서 바깥으로 요청을 보내는 지점은 네 곳뿐이고, 네 곳 모두 직접 설정합니다. OTLP 전달, 웹훅 두 개, 그리고 판정 모델입니다. 업데이트 확인도, 텔레메트리도, 라이선스 서버도 없습니다.
어느 키였는지, 그리고 누구였는지
감사 로그에는 어느 키였는지가 남습니다. 앞단의 인그레스가 이미 설정하는 헤더와, 그 헤더를 설정해도 되는 주소를 mcpd에 알려 주면 누구였는지도 남습니다. 다른 곳에서 온 헤더는 누구나 붙일 수 있는 주장일 뿐이므로, 추측을 적는 대신 그 칸을 비워 둡니다.
삭제 요청은 호출 한 번
DELETE 한 번이면 한 사람 앞으로 기록된 모든 실행이 지워집니다. 거기 딸린 스팬과 로그와 메트릭과 점수와 피드백까지 함께 지우고, 시그널별 건수를 응답으로 돌려줍니다. 그 키로 열 수 없는 프로젝트의 데이터는 건드리지도, 세지도 않습니다.
믿기 전에 점검
mcpd doctor는 배포 상태에 등급을 매깁니다. 스토리지가 쓰기를 받아 다시 돌려주는지, 열린 채로 남은 권한이 있는지, 설정된 프로젝트가 스팬을 한 번이라도 받은 적이 있는지 확인합니다. 조치가 필요한 항목은 그것을 고치는 플래그 이름까지 알려 줍니다.
mcpd가 하지 않는 일.
이런 목록은 어느 도구에나 있습니다. 대부분은 내부에만 두어서, 쓰는 사람은 둘째 주에야 알게 됩니다. 여기에는 그 목록을 날짜가 아니라 이유와 함께 적어 둡니다.
- gRPC 위의 OTLP는 없습니다.
- HTTP가 두 가지 인코딩으로 트레이스를 나르고, 로그와 메트릭은 JSON으로 나릅니다. gRPC는 전혀 지원하지 않습니다.
- 벤더 이름이 붙은 익스포터는 없습니다.
- mcpd가 전달하는 것은 OTLP입니다. 구현 하나로 대부분의 목적지에 닿고, mcpd는 데이터가 머물러야 하는 종착지가 아니라 경유지 한 곳이 됩니다. 자기 프로토콜만 쓰는 목적지에는 닿지 않습니다.
- SSO나 SCIM은 없습니다.
- 키의 범위는 프로젝트이고, 프로젝트는 테넌시 경계이지 ID 공급자가 아닙니다. 사람의 신원은 mcpd 앞단의 인그레스가 확인해 준 것이 전부입니다.
- 다중 리전 수집은 없습니다.
- Postgres나 ClickHouse에서는 서비스 하나 뒤에 여러 레플리카로 돌아갑니다. 그것은 리전 장애 조치가 아닙니다.
기존 제품의 소스를 직접 감사해 견준 전체 내용은 PARITY.md에 있습니다. 계획이 잡힐 때가 아니라 실제로 들어갔을 때 갱신합니다.
첫 트레이스까지 5분.
데몬을 실행하고, 에이전트를 그쪽으로 향하게 하고, 인터페이스를 엽니다. 그보다 오래 걸린다면 그것은 버그이니 제보해 주시면 좋겠습니다.
시작
docker run -p 4317:4317 ghcr.io/mcpd/mcpd