Chaque étape de votre agent, tenue jusqu’au bout et consignée.
mcpd est un daemon, pas un framework. Il conserve une exécution entre les étapes : les nouvelles tentatives, les timers, l’attente d’une approbation qui dure une semaine, pendant que votre propre code fait le travail à l’intérieur de chaque étape, dans n’importe quel langage. Chaque étape arrive dans une trace, avec ce qu’elle a coûté.
Compiler et lancer
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Ouvrez ensuite localhost:4317. Pas de compte, pas de base de données à monter, rien à signer. Aucune version publiée pour l’instant : l’image et les paquets arriveront avec la première. Lisez la prise en main en cinq minutes.
Une exécution qui se lit.
Un agent qui échoue n’échoue pas dans la ligne de log qui mentionne l’erreur. Il échoue trois étapes plus tôt, quand le modèle a choisi le mauvais outil, puis l’a choisi de nouveau.
mcpd reconstitue l’exécution entière sous forme d’arbre : chaque appel de modèle, chaque appel d’outil, chaque récupération, avec le prompt et la complétion exacts attachés à chacun, la latence de chacun, et ce que chacun a coûté.
- 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.
Tambour d’exécution
La même exécution, enroulée sur un tambour enregistreur. Le temps en fait le tour, une piste par span. Tournez-le, et la plume lit le span sous elle.
Une exécution qui survit au processus qui l’a lancée.
Le daemon détient l’exécution entre les étapes. Votre processus détient tout ce qui se passe à l’intérieur d’une étape, et ne cède rien : ni le modèle, ni le prompt, ni un secret.
Tableau des baux
Le daemon garde une étiquette par étape sur son tableau. Un worker ne garde une étiquette que tant que ses battements arrivent. Arrêtez le worker : après le bail et une fenêtre de reprise, l’étiquette retourne au tableau pour l’autre worker.
Le graphe est une donnée, les corps sont à vous
Un workflow s’écrit une fois, en code. Le graphe part vers le daemon comme une définition qu’il valide et versionne. Les corps des étapes restent dans votre processus, dans l’un des six langages, servis par un worker.
Les nouvelles tentatives, les timers et les attentes sont des lignes
Une défaillance passagère donne lieu à une nouvelle tentative, sur le budget propre à l’étape. Une mise en veille ou une attente d’approbation retient l’exécution sans qu’aucun processus ne tourne : elle survit à un déploiement, à un plantage ou à une semaine entière. Un événement répond à l’attente par nom et par clé, et un événement répété est un doublon, pas une seconde exécution.
Chaque exécution est une trace
Chaque étape est un span avec son entrée, sa sortie et la consommation qu’elle a déclarée, tarifée avec la grille qui tarife tous les autres spans. L’appel de modèle effectué à l’intérieur d’une étape se place sous elle, si bien que la recherche, le coût et les scorers travaillent sur une exécution sans rien y changer.
Instrumentez dans le langage que vous écrivez déjà.
Six SDK, un seul comportement. Enregistrer un span ne bloque jamais, ne lève jamais d’exception et ne grossit jamais sans limite : si mcpd est injoignable, votre agent ne s’en aperçoit pas, c’est la seule défaillance acceptable pour de l’observabilité. Chacun porte aussi un worker, si bien que les étapes d’une exécution durable sont servies par le processus même qui les enregistre.
Aucun n’est obligatoire. Pointez OTEL_EXPORTER_OTLP_ENDPOINT vers mcpd et un SDK OpenTelemetry que vous exécutez déjà l’atteint, sans une ligne de notre code dans votre application. Les six sont une facilité posée sur une surface HTTP, pas une condition pour être enregistré.
Une observabilité livrée avec un framework ne sait enregistrer que les agents bâtis avec ce framework. mcpd prend tout ce qui atteint son endpoint OTLP : un framework d’agents, une boucle que vous avez écrite vous-même, ou les services que l’un ou l’autre appelle.
instrumenter une exécution
/**
* 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. Six signaux, corrélés par l’exécution qui les a produits.
La plupart des outils vous donnent les traces ici et les logs ailleurs, puis vous laissent apparier les horodatages à l’œil. mcpd range les six signaux sous les mêmes identifiants de trace et de span : un pic sur un graphique ouvre l’exécution qui l’a causé, et une exécution montre les lignes de log qu’elle a émises.
Banc de tri
Six types de signaux arrivent mêlés sur un seul tapis. Le lecteur lit l’identifiant de trace de chacun, et chaque pièce d’une exécution tombe dans le tiroir de cette exécution.
Les 13 langages s’accordent.
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
Chaque SDK est testé isolément, ce qui prouve que chacun fait ce que ses auteurs voulaient et ne dit rien de leur accord entre eux. Chacun écrit donc la même exécution vers un seul daemon, et les traces sont relues puis comparées entre elles.
Un comportement qui tient dans un langage et pas dans un autre se voit là et nulle part ailleurs. Cela tourne en CI à chaque changement. Comment mcpd se compare.
Qui a lu quoi, y compris qui s’est vu refuser l’accès.
La question qui décide d’une revue de sécurité n’est pas de savoir si les données sont à l’abri. C’est de savoir si vous pouvez montrer, après coup, qui les a atteintes.
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
Six portées de clé
Émettre de la télémétrie, lire des prompts, modifier des données stockées, les détruire, démarrer des exécutions et réclamer des étapes sont des privilèges distincts. Une liste write non définie accepte les clés d’ingestion, et une liste admin non définie accepte les clés de lecture. Des listes execute et worker non définies acceptent les clés d’écriture. Une mise à jour ne vous ferme pas l’accès.
Un projet est une frontière
Les clés, la conservation et l’échantillonnage se règlent par projet. Une clé inscrite sous un projet atteint ce projet, et le daemon établit à qui appartient un dataset avant d’en renvoyer le moindre cas.
Rien n’appelle l’extérieur
Le daemon entier compte quatre endroits d’où part une requête sortante, et vous les configurez tous les quatre : le transfert, deux webhooks et le juge. Il n’y a pas de vérification de mise à jour, pas de télémétrie et pas de serveur de licences.
Quelle clé, et quelle personne
Le journal nomme la clé. Donnez à mcpd l’en-tête que votre ingress définit déjà, et les adresses autorisées à le définir, et il nomme aussi la personne. Envoyé depuis ailleurs, un en-tête est une affirmation que n’importe qui pourrait faire : la ligne reste vide plutôt que de porter une supposition.
Une demande de suppression tient en un appel
Un seul DELETE retire toutes les exécutions enregistrées pour une personne, avec les spans, logs, métriques, scores et feedback qui en dépendent, et répond par un décompte signal par signal. Les lignes d’un projet que cette clé ne peut pas ouvrir ne sont ni touchées ni comptées.
Vérifiez avant de faire confiance
mcpd doctor note un déploiement : si le stockage accepte une écriture et la restitue, si une portée est restée ouverte, si un projet configuré a déjà reçu un span. Tout ce sur quoi il vous demande d’agir nomme l’indicateur qui le corrige.
Ce que mcpd ne fait pas.
Tout outil a une liste comme celle-ci. La plupart la gardent en interne, et vous la découvrez la deuxième semaine. La voici, avec la raison plutôt qu’une date.
- Pas d’OTLP sur gRPC.
- HTTP porte les traces dans les deux encodages, et les logs et les métriques en JSON. gRPC n’est pas pris en charge du tout.
- Pas d’exporteur nommé par fournisseur.
- mcpd transfère de l’OTLP, ce qui atteint la plupart des destinations avec une seule implémentation et fait de mcpd un saut de plus plutôt que l’endroit où vos données doivent rester. Une destination qui ne parle que son propre protocole reste hors de portée.
- Pas de SSO ni de SCIM.
- Les clés sont limitées à des projets, ce qui est une frontière de cloisonnement, pas un fournisseur d’identité. L’identité d’une personne est celle que votre ingress établit devant mcpd.
- Pas d’ingestion multirégion.
- Sur Postgres ou ClickHouse, il tourne en plusieurs répliques derrière un seul service. Ce n’est pas un basculement entre régions.
Le relevé complet, mesuré par rapport à un audit direct du code de l’outil en place, est dans PARITY.md. Il est mis à jour quand quelque chose est livré, pas quand quelque chose est prévu.
Cinq minutes jusqu’à votre première trace.
Lancez le daemon, pointez votre agent vers lui, ouvrez l’interface. Si cela prend plus longtemps, c’est un bug et le rapport nous intéresse.
Démarrer
docker run -p 4317:4317 ghcr.io/mcpd/mcpd