Ogni passo del tuo agente, tenuto fino alla fine e messo agli atti.
mcpd è un daemon, non un framework. Conserva un’esecuzione tra un passo e l’altro: i nuovi tentativi, i timer, l’attesa di una settimana per un’approvazione, mentre il tuo codice fa il lavoro dentro ogni passo, in qualsiasi linguaggio. Ogni passo finisce in una traccia, con quello che è costato.
Compila ed esegui
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Poi apri localhost:4317. Nessun account, nessun database da mettere in piedi, niente da firmare. Non c’è ancora una release: l’immagine e i pacchetti arrivano con la prima. Leggi l’avvio rapido di cinque minuti.
Un’esecuzione che si legge.
Un agente che fallisce non fallisce nella riga di log che nomina l’errore. Fallisce tre passi prima, quando il modello ha scelto lo strumento sbagliato e poi lo ha scelto di nuovo.
mcpd ricostruisce l’intera esecuzione come un albero: ogni chiamata al modello, ogni chiamata a uno strumento, ogni recupero, con il prompt e la completion esatti allegati a ciascuna, la latenza di ciascuna e quanto è costata ciascuna.
- 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.
Tamburo dell’esecuzione
La stessa esecuzione, avvolta su un tamburo registratore. Il tempo gira intorno, una traccia per span. Giralo, e il pennino legge lo span sotto di sé.
Un’esecuzione che sopravvive al processo che l’ha avviata.
Il daemon tiene l’esecuzione tra un passo e l’altro. Il tuo processo tiene tutto quello che accade dentro un passo, e non cede niente: né il modello, né il prompt, né una credenziale.
Quadro dei lease
Il daemon tiene sul quadro un’etichetta per passo. Un worker tiene un’etichetta solo finché arrivano i suoi heartbeat. Ferma il worker e, dopo il lease e una finestra di recupero, l’etichetta torna sul quadro per l’altro worker.
Il grafo è un dato, i corpi sono tuoi
Un workflow si scrive una volta sola, in codice. Il grafo arriva al daemon come una definizione: la convalida e la versiona. I corpi dei passi restano nel tuo processo, in uno dei sei linguaggi, e un worker li serve.
Nuovi tentativi, timer e attese sono righe
Un errore transitorio viene ritentato sul budget del passo stesso. Una pausa o un’attesa di approvazione trattiene l’esecuzione senza nessun processo vivo, attraverso un deploy, un crash o una settimana. Un evento risponde all’attesa per nome e per chiave, e un evento ripetuto è un duplicato, non una seconda esecuzione.
Ogni esecuzione è una traccia
Ogni passo è uno span con il suo input, il suo output e il consumo che ha dichiarato, valorizzato con lo stesso listino che valorizza tutti gli altri span. La chiamata al modello fatta dentro un passo si colloca sotto di esso, così la ricerca, il costo e gli scorer lavorano su un’esecuzione senza cambiarla.
Strumenta nel linguaggio che scrivi già.
Sei SDK, un solo comportamento. Registrare uno span non blocca mai, non solleva mai un’eccezione e non cresce mai senza limite: se mcpd è irraggiungibile il tuo agente non se ne accorge, che è l’unico modo accettabile in cui l’osservabilità può guastarsi. Ognuno porta con sé anche un worker, così i passi di un’esecuzione durevole sono serviti dallo stesso processo che li registra.
Nessuno di essi è obbligatorio. Punta OTEL_EXPORTER_OTLP_ENDPOINT su mcpd e un SDK OpenTelemetry che già esegui lo raggiunge, senza una riga del nostro codice dentro la tua applicazione. I sei sono una comodità posata su una superficie HTTP, non una condizione per essere registrati.
L’osservabilità che arriva attaccata a un framework sa registrare solo gli agenti costruiti con quel framework. mcpd prende tutto quello che raggiunge il suo endpoint OTLP: un framework per agenti, un ciclo che hai scritto tu, o i servizi che l’uno o l’altro chiama.
strumentare un’esecuzione
/**
* 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. Sei segnali, correlati dall’esecuzione che li ha prodotti.
Quasi tutti i prodotti ti danno le tracce qui e i log da un’altra parte, e ti lasciano far combaciare i timestamp a occhio. mcpd conserva tutti e sei i segnali sotto gli stessi id di traccia e di span, così un picco su un grafico apre l’esecuzione che lo ha causato e un’esecuzione mostra le righe di log che ha emesso.
Banco di smistamento
Sei tipi di segnale arrivano mescolati su un solo nastro. Il lettore legge il trace id di ciascuno, e ogni pezzo di un’esecuzione finisce nel cassetto di quell’esecuzione.
13 linguaggi che vanno d’accordo.
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
Ogni SDK è testato per conto suo, il che dimostra che ognuno fa quello che i suoi autori volevano e non dice niente sul fatto che coincidano tra loro. Perciò ognuno scrive la stessa esecuzione su un solo daemon, e le tracce vengono rilette e confrontate una con l’altra.
Un comportamento che vale in un linguaggio e non in un altro si vede lì e in nessun altro posto. Gira in CI a ogni modifica. Come regge il confronto.
Chi ha letto che cosa, compreso chi è stato respinto.
La domanda che decide una revisione di sicurezza non è se i dati sono al sicuro. È se puoi mostrare, dopo, chi li ha raggiunti.
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
Sei ambiti di chiave
Emettere telemetria, leggere prompt, modificare dati archiviati, distruggerli, avviare esecuzioni e prendere in carico passi sono privilegi diversi. Un elenco write non impostato accetta le chiavi di ingest, e un elenco admin non impostato accetta le chiavi di lettura. Gli elenchi execute e worker non impostati accettano le chiavi di scrittura. Aggiornare non ti chiude fuori.
Un progetto è un confine
Le chiavi, la conservazione e il campionamento si impostano tutti per progetto. Una chiave elencata sotto un progetto raggiunge quel progetto, e il daemon stabilisce di chi è un dataset prima di restituirne anche un solo caso.
Niente chiama casa
In tutto il daemon ci sono quattro punti da cui parte una richiesta verso l’esterno, e li configuri tutti e quattro: l’inoltro, due webhook e il giudice. Non c’è controllo degli aggiornamenti, non c’è telemetria e non c’è un server di licenze.
Quale chiave, e quale persona
Il registro nomina la chiave. Dai a mcpd l’header che il tuo ingress imposta già, e gli indirizzi autorizzati a impostarlo, e nomina anche la persona. Inviato da qualunque altro posto, un header è un’affermazione che chiunque potrebbe fare, quindi la riga resta vuota invece di portare una supposizione.
Una richiesta di cancellazione è una sola chiamata
Un solo DELETE toglie tutte le esecuzioni registrate a nome di una persona, con gli span, i log, le metriche, i punteggi e i feedback che ne dipendono, e risponde con un conteggio per segnale. Le righe di un progetto che quella chiave non può aprire non vengono né toccate né contate.
Controlla prima di fidarti
mcpd doctor dà un voto a un deploy: se lo storage accetta una scrittura e la restituisce, se un ambito è rimasto aperto, se un progetto configurato ha mai ricevuto uno span. Tutto ciò su cui ti chiede di intervenire nomina l’opzione che lo corregge.
Quello che mcpd non fa.
Ogni prodotto ha una lista come questa. Quasi tutti la tengono interna, e tu la scopri la seconda settimana. Eccola, con il motivo invece che con una data.
- Niente OTLP su gRPC.
- HTTP porta le tracce in entrambe le codifiche, e i log e le metriche in JSON. gRPC non è supportato per niente.
- Nessun exporter per singolo fornitore.
- mcpd inoltra OTLP, che raggiunge quasi tutte le destinazioni con una sola implementazione e fa di mcpd un salto in più invece che il posto dove i tuoi dati devono restare. Una destinazione che parla solo il proprio protocollo resta fuori portata.
- Niente SSO né SCIM.
- Le chiavi sono limitate ai progetti, che è un confine fra tenant, non un identity provider. L’identità di una persona è quella che il tuo ingress stabilisce davanti a mcpd.
- Niente ingest multiregione.
- Su Postgres o ClickHouse gira come più repliche dietro un solo servizio. Non è un failover tra regioni.
Il resoconto completo, misurato rispetto a un audit diretto del codice sorgente dell’alternativa affermata, è in PARITY.md. Viene aggiornato quando qualcosa arriva, non quando qualcosa è previsto.
Cinque minuti alla tua prima traccia.
Esegui il daemon, puntaci il tuo agente, apri l’interfaccia. Se ci vuole più tempo, è un bug e ci interessa la segnalazione.
Avvia
docker run -p 4317:4317 ghcr.io/mcpd/mcpd