Ajanınızın attığı her adım, sonuna kadar tutulur ve kayda geçer.
mcpd bir daemon, bir framework değil. Bir çalıştırmayı adımlar arasında tutar: yeniden denemeleri, zamanlayıcıları, bir onay için haftalarca süren beklemeyi. İşi her adımın içinde, istediğiniz dilde, sizin kodunuz yapar. Her adım, maliyetiyle birlikte bir trace'e düşer.
Derleyin ve çalıştırın
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Sonra localhost:4317 adresini açın. Hesap yok, ayağa kaldırılacak veritabanı yok, imzalanacak bir şey yok. Henüz bir sürüm yok: imaj da paketler de ilk sürümle birlikte gelir. Beş dakikalık hızlı başlangıcı okuyun.
Okunabilir bir çalıştırma.
Başarısız olan bir ajan, hatadan söz eden log satırında başarısız olmaz. Üç adım önce, model yanlış aracı seçtiğinde ve sonra aynı aracı bir kez daha seçtiğinde başarısız olur.
mcpd çalıştırmanın tamamını bir ağaç olarak yeniden kurar: her model çağrısı, her araç çağrısı, her getirme; her birine iliştirilmiş tam prompt ve tamamlama, her birinin gecikmesi ve her birinin maliyeti.
- 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.
Çalıştırma tamburu
Aynı çalıştırma, bir kayıt tamburuna sarılmış. Zaman çevresi boyunca akar, her span için bir iz vardır. Çevirin; kalem altındaki span’i okur.
Kendisini başlatan süreçten sonra da yaşayan bir çalıştırma.
Adımlar arasında çalıştırmayı daemon tutar. Bir adımın içindeki her şey sizin sürecinize aittir ve hiçbir şey devredilmez: ne model, ne prompt, ne de bir kimlik bilgisi.
Kira panosu
Daemon panosunda her adım için bir etiket tutar. Bir worker etiketi yalnızca kalp atışları geldiği sürece elinde tutar. Worker’ı durdurun; kira süresi ve bir geri alma penceresinden sonra etiket diğer worker için panoya döner.
Grafik veridir, gövdeler sizindir
Bir iş akışı kodda bir kez yazılır. Grafik, daemon'un doğruladığı ve sürümlediği bir tanım olarak ona gider. Adım gövdeleri altı dilden herhangi birinde sizin sürecinizde kalır ve bir worker onları sunar.
Yeniden denemeler, zamanlayıcılar ve beklemeler birer satırdır
Geçici bir hata, adımın kendi bütçesiyle yeniden denenir. Bir uyku ya da bir onay beklemesi, ayakta hiçbir süreç olmadan çalıştırmayı tutar: bir dağıtım, bir çökme ya da bir hafta boyunca. Bir olay, beklemeyi ad ve anahtarla yanıtlar; tekrar gelen olay ikinci bir çalıştırma değil, bir yinelemedir.
Her çalıştırma bir trace'tir
Her adım; girdisi, çıktısı ve bildirdiği kullanımıyla bir span'dir ve diğer bütün span'lerle aynı fiyat kitabından fiyatlandırılır. Adımın içindeki model çağrısı onun altında durur; böylece arama, maliyet ve puanlayıcılar bir çalıştırma üzerinde değişmeden çalışır.
Zaten yazdığınız dilde ölçümleyin.
Altı SDK, tek bir davranış. Bir span kaydetmek asla bloklamaz, asla hata fırlatmaz ve sınırsızca büyümez: mcpd'ye ulaşılamıyorsa ajanınız bunu fark etmez; gözlemlenebilirliğin bozulabileceği tek kabul edilebilir biçim de budur. Her SDK bir worker da taşır, böylece kalıcı bir çalıştırmanın adımları onları kaydeden sürecin içinden sunulur.
Hiçbiri zorunlu değil. OTEL_EXPORTER_OTLP_ENDPOINT değişkenini mcpd'ye yöneltin; zaten çalıştırdığınız bir OpenTelemetry SDK'sı, uygulamanızda bizim kodumuz olmadan ona ulaşır. Bu altısı bir HTTP yüzeyinin üzerindeki kolaylıktır, kaydedilmenin koşulu değil.
Bir framework'ün içinde gelen gözlemlenebilirlik, yalnızca o framework'le yazılmış ajanları kaydedebilir. mcpd, OTLP endpoint'ine ne ulaşırsa onu alır: bir ajan framework'ü, kendi yazdığınız bir döngü ya da ikisinin çağırdığı servisler.
bir çalıştırmayı ölçümleme
/**
* 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. Altı sinyal, onları üreten çalıştırmayla ilişkilendirilir.
Çoğu araç trace'leri bir yerde, logları başka bir yerde verir ve zaman damgalarını gözünüzle eşleştirmeyi size bırakır. mcpd altı sinyalin hepsini aynı trace ve span kimlikleri altında saklar; böylece bir grafikteki sıçrama ona yol açan çalıştırmayı açar, bir çalıştırma da ürettiği log satırlarını gösterir.
Ayırma tezgâhı
Altı tür sinyal tek bir bantta karışık gelir. Okuyucu her birinin trace id’sini okur ve bir çalıştırmanın her parçası o çalıştırmanın çekmecesine düşer.
13 dil, birbiriyle uyuşuyor.
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
Her SDK kendi başına test edilir; bu, her birinin yazarlarının istediği şeyi yaptığını kanıtlar ama birbirleriyle örtüşüp örtüşmediklerine dair hiçbir şey söylemez. Bu yüzden hepsi aynı çalıştırmayı tek bir daemon üzerine yazar, trace'ler geri okunur ve birbiriyle karşılaştırılır.
Bir dilde geçerli olup bir başkasında olmayan davranış yalnızca orada görünür. Her değişiklikte CI içinde çalışır. Karşılaştırmayı okuyun.
Kimin neyi okuduğu, geri çevrilenler dahil.
Bir güvenlik incelemesini belirleyen soru, verinin güvende olup olmadığı değildir. Sonradan, ona kimin eriştiğini gösterebilmenizdir.
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
Altı anahtar kapsamı
Telemetri göndermek, prompt'ları okumak, saklanan veriyi değiştirmek, onu yok etmek, çalıştırma başlatmak ve adım talep etmek ayrı yetkilerdir. Tanımsız bir yazma listesi alım anahtarlarını, tanımsız bir yönetim listesi okuma anahtarlarını kabul eder. Tanımsız execute ve worker listeleri yazma anahtarlarını kabul eder. Yükseltme sizi dışarıda bırakmaz.
Proje bir sınırdır
Anahtarlar, saklama ve örnekleme proje başınadır. Bir projenin altında listelenen anahtar yalnızca o projeye ulaşır ve daemon, bir veri kümesinden tek bir kayıt döndürmeden önce kümenin sahibini çözer.
Hiçbir şey merkeze bağlanmaz
Daemon'un tamamında dışarı istek atan dört yer vardır ve hepsini siz yapılandırırsınız: iletici, iki webhook ve yargıç. Güncelleme denetimi yok, telemetri yok, lisans sunucusu yok.
Hangi anahtar, hangi kişi
Defter anahtarın adını yazar. mcpd'ye ingress'inizin zaten koyduğu başlığı ve onu koymasına izin verilen adresleri verin, kişinin adını da yazsın. Başka bir yerden gelen başlık, herkesin öne sürebileceği bir iddiadır; satır bu yüzden tahmin taşımaktansa boş kalır.
Silme talebi tek bir çağrıdır
Tek bir DELETE, bir kişiye karşı kaydedilmiş her çalıştırmayı, onlara bağlı span'ler, loglar, metrikler, puanlar ve geri bildirimlerle birlikte siler ve sinyal başına bir sayıyla yanıt verir. O anahtarın açamadığı bir projedeki satırlara ne dokunulur ne de sayılır.
Güvenmeden önce denetleyin
mcpd doctor bir dağıtımı notlandırır: depo bir yazmayı alıp geri veriyor mu, bir kapsam açık mı bırakılmış, yapılandırılmış bir proje hiç span almış mı. Sizden bir şey yapmanızı istediği her maddede, onu düzelten seçeneğin adı yazar.
mcpd'nin yapmadıkları.
Her aracın böyle bir listesi vardır. Çoğu bunu içeride tutar ve siz ikinci haftada öğrenirsiniz. İşte liste; tarih yerine gerekçesiyle.
- gRPC üzerinden OTLP yok.
- HTTP, trace'leri her iki kodlamada, logları ve metrikleri de JSON olarak taşır. gRPC hiç desteklenmiyor.
- Satıcı adına yazılmış exporter yok.
- mcpd OTLP iletir; bu, tek bir uygulamayla çoğu hedefe ulaşır ve mcpd'yi verinizin kalmak zorunda olduğu yer değil, yol üstünde bir durak yapar. Yalnızca kendi protokolünü konuşan bir hedefe erişilemez.
- SSO ya da SCIM yok.
- Anahtarlar projelerle sınırlıdır; bu bir kiracılık sınırıdır, kimlik sağlayıcı değil. Bir kişinin kimliği, mcpd'nin önünde ingress'inizin ne kurduysa odur.
- Çok bölgeli alım yok.
- Postgres ya da ClickHouse üzerinde, tek bir servisin arkasında birkaç kopya olarak çalışır. Bu, bölgesel yük devretme değildir.
Tam döküm PARITY.md dosyasında; ölçüm, yerleşik ürünün kaynak kodu doğrudan okunarak yapıldı. Bir şey planlandığında değil, geldiğinde güncellenir.
İlk trace'inize beş dakika.
Daemon'u çalıştırın, ajanınızı ona yöneltin, arayüzü açın. Bundan uzun sürüyorsa bu bir hatadır ve bildirimini almak isteriz.
Başlayın
docker run -p 4317:4317 ghcr.io/mcpd/mcpd