Cada paso que da tu agente, sostenido hasta el final y registrado.
mcpd es un daemon, no un framework. Sostiene una ejecución entre pasos: los reintentos, los temporizadores, la espera de una semana hasta una aprobación, mientras tu propio código hace el trabajo dentro de cada paso, en cualquier lenguaje. Cada paso queda en una traza con lo que costó.
Compílalo y ejecútalo
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Luego abre localhost:4317. Sin cuenta, sin base de datos que levantar, nada que firmar. Todavía no hay versión publicada: la imagen y los paquetes llegan con la primera. Lee el inicio rápido de cinco minutos.
Una ejecución que puedes leer.
Un agente que falla no falla en la línea de log que menciona el error. Falla tres pasos antes, cuando el modelo eligió la herramienta equivocada y volvió a elegirla.
mcpd reconstruye la ejecución entera como un árbol: cada llamada al modelo, cada llamada a una herramienta, cada recuperación, con el prompt y la completion exactos adjuntos a cada una, la latencia de cada una y lo que costó cada una.
- 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.
Tambor de ejecución
La misma ejecución, enrollada en un tambor registrador. El tiempo le da la vuelta, una pista por span. Gírelo, y la plumilla lee el span que tiene debajo.
Una ejecución que sobrevive al proceso que la inició.
El daemon es dueño de la ejecución entre pasos. Tu proceso es dueño de todo lo que ocurre dentro de uno, y no entrega nada: ni el modelo, ni el prompt, ni una credencial.
Tablero de arriendos
El daemon guarda una etiqueta por paso en su tablero. Un worker conserva una etiqueta solo mientras llegan sus latidos. Detenga el worker y, tras el arriendo y una ventana de recuperación, la etiqueta vuelve al tablero para el otro worker.
El grafo son datos, los cuerpos son tuyos
Un workflow se escribe una vez, en código. El grafo llega al daemon como una definición que él valida y versiona. Los cuerpos de los pasos se quedan en tu proceso, en cualquiera de los seis lenguajes, para que un worker los sirva.
Reintentos, temporizadores y esperas son filas
Un fallo transitorio se reintenta con el presupuesto del propio paso. Una pausa o una espera de aprobación sostiene la ejecución sin ningún proceso vivo, a través de un despliegue, de una caída o de una semana. Un evento responde a la espera por nombre y clave, y un evento repetido es un duplicado, no una segunda ejecución.
Cada ejecución es una traza
Cada paso es un span con su entrada, su salida y el uso que declaró, valorado con el mismo libro de precios que cualquier otro span. La llamada al modelo dentro de un paso cuelga de él, así que la búsqueda, el costo y los evaluadores funcionan igual sobre una ejecución.
Instrumenta en el lenguaje que ya escribes.
Seis SDK, un mismo comportamiento. Registrar un span nunca bloquea, nunca lanza una excepción y nunca crece sin límite: si mcpd está inalcanzable, tu agente no se entera, que es la única forma aceptable de que falle la observabilidad. Cada uno lleva además un worker, así que los pasos de una ejecución duradera se sirven desde el mismo proceso que los registra.
Ninguno es obligatorio. Apunta OTEL_EXPORTER_OTLP_ENDPOINT a mcpd y el SDK de OpenTelemetry que ya ejecutas llegará hasta él, sin código nuestro en tu aplicación. Los seis son una comodidad sobre una superficie HTTP, no una condición para quedar registrado.
La observabilidad que viene pegada a un framework solo puede registrar agentes construidos con ese framework. mcpd acepta lo que llegue a su endpoint OTLP: un framework de agentes, un bucle que escribiste tú, o los servicios que llame cualquiera de los dos.
instrumentar una ejecución
/**
* 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. Seis señales, correlacionadas por la ejecución que las produjo.
La mayoría de las herramientas te dan las trazas en un sitio y los logs en otro, y te dejan cuadrar a ojo las marcas de tiempo. mcpd guarda las seis señales contra los mismos ids de traza y de span, así que un pico en un gráfico abre la ejecución que lo causó y una ejecución muestra las líneas de log que emitió.
Banco de clasificación
Seis tipos de señal llegan mezclados en una sola cinta. El lector lee el id de traza de cada una, y cada pieza de una ejecución cae en el cajón de esa ejecución.
13 lenguajes que coinciden.
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
Cada SDK se prueba por su cuenta, lo que demuestra que cada uno hace lo que quisieron sus autores y nada sobre si coinciden entre sí. Por eso cada uno escribe la misma ejecución contra un mismo daemon, y las trazas se leen de vuelta y se comparan unas con otras.
Un comportamiento que se cumple en un lenguaje y no en otro aparece ahí y en ningún otro sitio. Se ejecuta en CI con cada cambio. Cómo se compara.
Quién leyó qué, incluido quién quedó fuera.
La pregunta que decide una revisión de seguridad no es si los datos están a salvo. Es si puedes demostrar, después, quién llegó a ellos.
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
Seis ámbitos de clave
Emitir telemetría, leer prompts, cambiar datos guardados, destruirlos, arrancar ejecuciones y reclamar pasos son privilegios distintos. Una lista write sin definir acepta las claves de ingesta, y una lista admin sin definir acepta las claves de lectura. Las listas execute y worker sin definir aceptan las claves de escritura. Actualizar no te deja fuera.
Un proyecto es una frontera
Las claves, la retención y el muestreo son por proyecto. Una clave listada bajo uno llega a ese, y el daemon resuelve de quién es un dataset antes de devolver un solo caso de él.
Nada llama a casa
En todo el daemon hay cuatro puntos que hacen solicitudes salientes, y tú configuras cada uno: el reenviador, dos webhooks y el juez. No hay comprobación de actualizaciones, ni telemetría, ni servidor de licencias.
Qué clave, y qué persona
El registro nombra la clave. Dale a mcpd la cabecera que tu ingress ya establece, y las direcciones autorizadas a establecerla, y nombrará también a la persona. Enviada desde cualquier otro sitio, una cabecera es una afirmación que podría hacer cualquiera, así que la fila queda vacía en vez de llevar una suposición.
Una solicitud de borrado es una llamada
Un DELETE elimina todas las ejecuciones registradas a nombre de una persona, con los spans, logs, métricas, puntuaciones y feedback que cuelgan de ellas, y responde con un recuento por señal. Las filas de un proyecto que esa clave no puede abrir ni se tocan ni se cuentan.
Compruébalo antes de confiar en él
mcpd doctor califica un despliegue: si el almacenamiento acepta una escritura y la devuelve, si quedó un ámbito abierto, si un proyecto configurado ha recibido alguna vez un span. Todo lo que te pide corregir nombra el flag que lo arregla.
Lo que mcpd no hace.
Toda herramienta tiene una lista como esta. La mayoría la guarda para dentro, y te enteras en la segunda semana. Aquí está, con el motivo en vez de una fecha.
- Nada de OTLP sobre gRPC.
- HTTP transporta trazas en las dos codificaciones, y logs y métricas en JSON. gRPC no está soportado en absoluto.
- Sin exportadores de proveedores por nombre.
- mcpd reenvía OTLP, que llega a la mayoría de los destinos con una sola implementación y hace de mcpd un salto más, no el sitio donde tus datos tienen que quedarse. Un destino que solo habla su propio protocolo queda fuera de alcance.
- Sin SSO ni SCIM.
- Las claves están acotadas a proyectos, que es una frontera de tenencia, no un proveedor de identidad. La identidad de una persona es la que establezca tu ingress delante de mcpd.
- Sin ingesta multirregión.
- Sobre Postgres o ClickHouse se ejecuta como varias réplicas detrás de un solo servicio. Eso no es failover entre regiones.
El recuento completo, medido contra una auditoría directa del código fuente del producto establecido, está en PARITY.md. Se actualiza cuando algo se entrega, no cuando algo se planifica.
Cinco minutos hasta tu primera traza.
Ejecuta el daemon, apunta tu agente hacia él, abre la interfaz. Si tarda más que eso, es un error y queremos el informe.
Empezar
docker run -p 4317:4317 ghcr.io/mcpd/mcpd