Cada etapa que o seu agente dá, mantida até o fim e registrada.
O mcpd é um daemon, não um framework. Ele segura uma execução entre as etapas: as repetições, os temporizadores, a semana de espera por uma aprovação, enquanto o seu próprio código faz o trabalho dentro de cada etapa, em qualquer linguagem. Cada etapa cai em um trace com o que custou.
Compile e execute
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Depois abra localhost:4317. Sem conta, sem banco de dados para subir, nada para assinar. Ainda não há release: a imagem e os pacotes chegam junto com a primeira. Leia o início rápido de cinco minutos.
Uma execução que dá para ler.
Um agente que falha não falha na linha de log que menciona o erro. Ele falha três etapas antes, quando o modelo escolheu a ferramenta errada e depois escolheu de novo.
O mcpd reconstrói a execução inteira como uma árvore: cada chamada de modelo, cada chamada de ferramenta, cada recuperação, com o prompt e a resposta exatos anexados a cada uma, a latência de cada uma e quanto cada uma custou.
- 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 da execução
A mesma execução, enrolada em um tambor registrador. O tempo dá a volta nele, uma trilha por span. Gire-o, e a pena lê o span embaixo dela.
Uma execução que sobrevive ao processo que a iniciou.
O daemon é dono da execução entre as etapas. O seu processo é dono de tudo que acontece dentro de uma etapa, e não entrega nada: nem o modelo, nem o prompt, nem uma credencial.
Quadro de leases
O daemon mantém uma etiqueta por etapa no quadro. Um worker só segura uma etiqueta enquanto seus heartbeats chegam. Pare o worker e, depois do lease e de uma janela de recuperação, a etiqueta volta ao quadro para o outro worker.
O grafo é dado, os corpos são seus
Um workflow é escrito uma vez, em código. O grafo vai para o daemon como uma definição que ele valida e versiona. Os corpos das etapas ficam no seu processo, em qualquer uma das seis linguagens, para um worker servir.
Repetições, temporizadores e esperas são linhas
Uma falha transitória é repetida dentro do orçamento da própria etapa. Um sleep ou uma espera por aprovação segura a execução sem nenhum processo vivo, atravessando uma implantação, uma queda ou uma semana. Um evento responde à espera por nome e por chave, e um evento que chega duas vezes é uma duplicata, não uma segunda execução.
Toda execução é um trace
Cada etapa é um span com a sua entrada, a sua saída e o uso que ela reportou, precificado com a mesma tabela que precifica todos os outros spans. A chamada de modelo dentro de uma etapa fica abaixo dela, então a busca, o custo e os avaliadores funcionam sobre uma execução sem nenhuma mudança.
Instrumente na linguagem que você já escreve.
Seis SDKs, um comportamento. Registrar um span nunca bloqueia, nunca lança uma exceção e nunca cresce sem limite: se o mcpd estiver inacessível, o seu agente não percebe, que é a única maneira aceitável de a observabilidade falhar. Cada um também traz um worker, então as etapas de uma execução durável são servidas pelo mesmo processo que as registra.
Nenhum deles é obrigatório. Aponte OTEL_EXPORTER_OTLP_ENDPOINT para o mcpd e um SDK do OpenTelemetry que você já executa chega até ele, sem nenhum código nosso dentro da sua aplicação. Os seis são uma conveniência sobre uma superfície HTTP, não uma condição para ser registrado.
A observabilidade que vem presa a um framework só consegue registrar agentes construídos com aquele framework. O mcpd aceita o que chegar ao seu endpoint OTLP: um framework de agentes, um laço que você mesmo escreveu, ou os serviços que qualquer um dos dois chama.
instrumente uma execução
/**
* 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 sinais, correlacionados pela execução que os produziu.
A maioria das ferramentas dá os traces em um lugar e os logs em outro, e deixa você casar os horários no olho. O mcpd guarda os seis sinais sob os mesmos ids de trace e de span, então um pico em um gráfico abre a execução que o causou, e uma execução mostra as linhas de log que emitiu.
Bancada de triagem
Seis tipos de sinal chegam misturados em uma só esteira. O leitor lê o trace id de cada um, e cada peça de uma execução cai na gaveta dessa execução.
13 linguagens que concordam entre si.
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 é testado por conta própria, o que prova que cada um faz o que os seus autores quiseram e não prova nada sobre eles combinarem entre si. Por isso cada um escreve a mesma execução contra um único daemon, e os traces são lidos de volta e comparados uns com os outros.
Um comportamento que vale em uma linguagem e não vale em outra aparece ali e em nenhum outro lugar. Roda no CI a cada mudança. Como isso se compara.
Quem leu o quê, inclusive quem foi barrado.
A pergunta que decide uma revisão de segurança não é se os dados estão seguros. É se você consegue mostrar, depois, quem chegou até eles.
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 escopos de chave
Emitir telemetria, ler prompts, alterar dados guardados, destruí-los, iniciar execuções e reivindicar etapas são privilégios diferentes. Uma lista write sem definir aceita as chaves de ingestão, e uma lista admin sem definir aceita as chaves de leitura. Listas execute e worker sem definir aceitam as chaves de escrita. Atualizar não tranca você do lado de fora.
Um projeto é uma fronteira
Chaves, retenção e amostragem são todas por projeto. Uma chave listada em um projeto alcança aquele projeto, e o daemon resolve de quem é um dataset antes de devolver um único caso dele.
Nada telefona para casa
Existem quatro pontos de requisição para fora no daemon inteiro, e você configura cada um deles: o encaminhador, dois webhooks e o juiz. Não há verificação de atualização, telemetria nem servidor de licença.
Qual chave, e qual pessoa
O registro nomeia a chave. Dê ao mcpd o cabeçalho que o seu ingress já define, e os endereços autorizados a defini-lo, e ele nomeia também a pessoa. Vindo de qualquer outro lugar, um cabeçalho é uma afirmação que qualquer um poderia fazer, então a linha fica vazia em vez de carregar um palpite.
Um pedido de exclusão é uma chamada
Um DELETE remove todas as execuções registradas para uma pessoa, com os spans, os logs, as métricas, as notas e os feedbacks pendurados nelas, e responde com uma contagem por sinal. Linhas de um projeto que aquela chave não pode abrir não são tocadas nem contadas.
Confira antes de confiar
mcpd doctor avalia uma implantação: se o armazenamento aceita uma escrita e a devolve, se um escopo ficou aberto, se um projeto configurado já recebeu algum span. Tudo que ele quer que você resolva vem com o nome da opção que corrige.
O que o mcpd não faz.
Toda ferramenta tem uma lista dessas. A maioria a mantém para uso interno, e você descobre o conteúdo dela na segunda semana. Aqui está a nossa, com o motivo no lugar de uma data.
- Sem OTLP sobre gRPC.
- O HTTP leva traces nas duas codificações, e logs e métricas em JSON. gRPC não tem suporte nenhum.
- Sem exporters de fornecedor pelo nome.
- O mcpd encaminha OTLP, o que alcança a maioria dos destinos com uma única implementação e faz do mcpd um salto no caminho, e não o lugar onde os seus dados têm de ficar. Um destino que só fala o próprio protocolo está fora de alcance.
- Sem SSO nem SCIM.
- As chaves têm escopo por projeto, o que é uma fronteira de isolamento, não um provedor de identidade. A identidade de uma pessoa é o que o seu ingress estabelecer na frente do mcpd.
- Sem ingestão em várias regiões.
- No Postgres ou no ClickHouse, ele roda como várias réplicas atrás de um único serviço. Isso não é failover entre regiões.
O relato completo, medido contra uma auditoria direta do código do produto estabelecido, está em PARITY.md. Ele é atualizado quando alguma coisa chega, não quando alguma coisa é planejada.
Cinco minutos até o seu primeiro trace.
Execute o daemon, aponte o seu agente para ele, abra a interface. Se demorar mais do que isso, é um defeito e nós queremos o relato.
Começar
docker run -p 4317:4317 ghcr.io/mcpd/mcpd