Integrazione
Un daemon gira da qualche parte. Ecco i tre modi in cui tutto il resto lo raggiunge, e come scegliere fra loro.
mcpd ha un solo ingresso e tre modi per attraversarlo. Tutto quello che segue parla alla stessa superficie HTTP: gli SDK sono una comodità posata sopra di essa, e il server MCP ne è un client.
Una porta, quattro superfici
Un solo processo serve tutto quanto su una sola porta, la 4317 come impostazione predefinita. Nessun sidecar, nessun collector da tenere acceso accanto, nessun server di file statici per l’interfaccia.
| Percorso | Che cos’è | Chiave |
|---|---|---|
/v1/traces, /v1/logs, /v1/metrics | Ingestione OpenTelemetry. Qualsiasi SDK OTel, senza una riga di codice specifico per mcpd nella tua applicazione. | Ingestione |
/v1/ingest | Ingestione nativa. Un batch porta qualunque mescolanza dei sei segnali e tutto il modello dei dati, e una richiesta porta un flusso di batch. | Ingestione |
/v1/api/… | L’API di query. L’interfaccia e gli SDK leggono da qui e da nient’altro. | Lettura, scrittura, admin, execute o worker, dichiarata endpoint per endpoint |
/ | L’interfaccia operativa, compilata dentro il binario. | Nessuna per il markup; i suoi dati passano dall’API di query |
Altri due percorsi, /healthz e /readyz, non
chiedono autenticazione, così una sonda arriva a interrogarli. Parti da lì
quando stai cercando di capire se un endpoint sia raggiungibile o no:
$ curl -s "$MCPD/healthz"{"status":"ok","version":"0.1.0","spec_version":"1.0.0",…}$ curl -s "$MCPD/readyz"storage is not reachable: …# readyz tocca lo storage: un 503 qui vuol dire che il daemon è# attivo e che il database che ha dietro non lo è. L’API HTTP, senza SDK
Se il tuo linguaggio ha un client HTTP, può parlare con mcpd. Non c’è niente da registrare, nessun progetto da creare in anticipo e nessuna stretta di mano. Ogni SDK è una comodità sopra le richieste qui sotto, e prendere questa strada ti toglie il raggruppamento in batch, i nuovi tentativi e la generazione degli identificatori, di cui poi ti occupi tu.
Scrivere un’esecuzione
POST /v1/ingest prende un batch. Un batch può portare insieme
span, log, metriche, punteggi, feedback e note di aspetto, ed è la ragione
per cui l’ingresso nativo esiste accanto a quello OTLP: i sei segnali
condividono gli id di traccia e di span, quindi mandarli in una sola
richiesta li tiene correlati senza una seconda ricerca.
Tre forme arrivano a quell’endpoint, e il content type dice quale. Niente viene dedotto dai byte, quindi aggiungere un a capo dentro una stringa non può mai cambiare il significato di un corpo.
| Content type | Corpo |
|---|---|
application/json | Un solo oggetto batch, che è quello che manda ogni SDK |
application/json | Un array di oggetti batch, per un mittente che ne ha parecchi sottomano |
application/x-ndjson | Un batch per riga, letto mentre il corpo arriva anziché tenuto per intero. application/jsonl vuol dire la stessa cosa |
Ogni elemento è un batch a sé: viene archiviato in una transazione propria
quando porta un ingest_key, così un flusso rimandato è
riconosciuto riga per riga anziché come un blocco unico, e gli elementi
senza chiave sono archiviati insieme. L’intera richiesta è rifiutata, senza
archiviare nulla, se una riga non si lascia leggere o se la richiesta nel
suo insieme supera uno dei due limiti. La risposta è un solo
IngestResult che copre tutto quanto.
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
un batch
# 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. Quattro cose che la richiesta deve fare nel modo giusto:
- Gli identificatori hanno la forma di OpenTelemetry. 32 caratteri esadecimali minuscoli per una traccia, 16 per uno span, mai tutti zero. Qualunque altra cosa rompe la correlazione con ogni altro strumento che tieni acceso.
-
ingest_keyrende sicuro un nuovo tentativo. È una chiave di idempotenza stabile per un batch, così un rinvio è riconosciuto e contato come duplicato anziché scritto due volte. - La chiave va in un header.
Authorization: Bearer …, oppureX-Mcpd-Key. L’ingest chiede una chiave di ingest, e solo quando il daemon ne ha configurata qualcuna. - I batch hanno dei limiti. 32 MiB di corpo e 10.000
segnali come impostazione predefinita, entrambi regolabili con
--max-body-bytese--max-batch-signals. Il limite sui segnali conta l’intera richiesta, quindi spargere gli stessi segnali su più righe non lo aggira. Mandacontent-encoding: gzipper qualsiasi cosa di una certa mole.
La risposta riferisce che cosa è successo segnale per segnale:
{"accepted":2,"rejected":0,"duplicate":0}, con un array
errors che nomina l’indice e il motivo di ogni rifiuto. Gli
endpoint OTLP rispondono invece nella forma propria di OTLP, con un oggetto
partialSuccess che porta il conteggio dei rifiutati, perché è
quello che legge un mittente OpenTelemetry.
Rileggerla
leggere
# Read it back. Any filter in the API works here.
curl -s "$MCPD/v1/api/traces?project=default&limit=10"
# The whole run, with its logs, scores and feedback in one request.
curl -s "$MCPD/v1/api/traces/$TRACE?include=all"
# Find the run by something the model actually said.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'q=Leeds depot'
# Find the runs a scorer marked down. This is the query that finds bad answers.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'has_score=answer_relevance' \
--data-urlencode 'score_below=0.5'
# Where the money went, over the last week, by model.
curl -s --get "$MCPD/v1/api/analytics/cost" \
--data-urlencode 'project=default' \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model'
I filtri si compongono, la paginazione va a cursore e project
ricade sul progetto predefinito del daemon quando lo lasci fuori. La ricerca
full-text sui payload archiviati è il parametro q, ed è quello
che rende «trova l’esecuzione in cui il modello ha detto questo» una query e
non un grep.
Un errore che mcpd solleva da sé torna indietro come JSON con un codice
error stabile e leggibile da una macchina, un
message scritto per chi dovrà rimediare, e un
field quando è un parametro a essere in torto. Decidi in base
al codice, mostra il messaggio.
Una classe di errori non ha quella forma, e un client che dà per scontato il
contrario non riuscirà a leggere la risposta. Un corpo di richiesta che non
si lascia decodificare nel tipo che l’endpoint si aspetta viene rifiutato
dal framework web prima che giri una riga di codice di mcpd, quindi risponde
422 con content-type: text/plain e un messaggio
come missing field `scorer_id`. Una sintassi del corpo che non
è JSON per niente risponde 400, anche questo come testo. In
nessuno dei due casi c’è un codice error su cui decidere. Vale
per ogni endpoint che prende un corpo tipizzato:
POST /v1/api/scores, /v1/api/feedback,
/v1/api/score, /v1/api/datasets, le sue rotte
items e capture, e
/v1/api/experiments. Gli ingressi di ingest leggono il
corpo da sé, quindi /v1/ingest e le rotte OTLP restituiscono
davvero la forma JSON. Tratta un corpo non JSON su un 4xx come una richiesta
malformata che arriva dal tuo codice, e leggilo come testo.
Gli SDK
Sei linguaggi: TypeScript, Python, Rust, Go, Kotlin ed Elixir. Ognuno è idiomatico dove può esserlo e identico dove conta. Chi ha letto l’avvio rapido di TypeScript sa prevedere quello di Kotlin.
Che cosa ti danno in più rispetto all’HTTP nudo:
- Raggruppamento in batch e un worker in secondo piano, così registrare uno span accoda e ritorna.
- Nuovi tentativi con backoff sul 429 e sui codici 5xx per cui vale la pena riprovare, e mai su un 4xx che resterà malformato.
- Generazione degli identificatori, propagazione del contesto, e il
traceparentW3C letto in entrata ed emesso in uscita, così una traccia sopravvive al confine fra due servizi. - Tipi generati dalla stessa specifica da cui nascono quelli del daemon, così un campo non può voler dire una cosa nell’SDK e un’altra nello storage.
Tre promesse tengono in ogni linguaggio, e sono la ragione per cui un SDK si può mettere su un percorso caldo. La strumentazione non solleva mai eccezioni: un daemon irraggiungibile finisce in un log al massimo una volta per intervallo e poi viene inghiottito. Non blocca mai: il lavoro di rete avviene sul worker, quindi la latenza del tuo agente non dipende dal fatto che mcpd sia attivo. E la sua coda è limitata: quando si riempie, gli span più vecchi vengono scartati e un contatore sale, perché un buffer senza limite trasforma un’interruzione di rete in un processo ucciso per memoria esaurita.
connettersi
import { Mcpd } from '@mcpd/sdk'
// Every field here also reads an environment variable, so the same code runs
// unchanged in development and in production. With no arguments at all this
// talks to a daemon on http://localhost:4317.
const mcpd = new Mcpd({
project: 'support',
environment: 'development',
serviceName: 'support-agent',
serviceVersion: '1.4.0',
}) from mcpd import FeedbackKind, Mcpd, SpanKind
# Everything here has an MCPD_* environment variable behind it, so the same code
# runs in development and in production with no edit. `Mcpd()` with no arguments
# talks to a local daemon.
mcpd = Mcpd(
endpoint=os.environ.get("MCPD_ENDPOINT", "http://localhost:4317"),
project="support",
environment="development",
service_name="support-agent",
service_version="1.4.2",
) // Nothing here is required. `Mcpd::new()` reads MCPD_ENDPOINT, MCPD_API_KEY
// and the rest from the environment and talks to a local mcpd by default.
// Anything set in code wins over the environment.
let mcpd = Mcpd::with_config(
Config::from_env()
.with_project("support")
.with_environment("local")
.with_service("support-agent", Some("1.0.0")),
); m := mcpd.New(
mcpd.WithEndpoint("http://localhost:4317"),
mcpd.WithProject("support"),
mcpd.WithEnvironment("development"),
mcpd.WithService("support-agent", "1.4.2"),
)
// Shutdown flushes what is still queued and stops the background worker.
// A short-lived process that skips it loses whatever had not been sent.
defer m.Shutdown(ctx) // Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
McpdConfig(
endpoint = "http://localhost:4317",
project = "support-desk",
environment = "development",
serviceName = "support-agent",
serviceVersion = "1.4.0",
)
) # Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
Mcpd.start_link(
endpoint: "http://localhost:4317",
project: "support-desk",
environment: "development",
service_name: "support-agent",
service_version: "1.4.0"
) # mcpd needs no registration and no project to be created up front. Check that
# the daemon is up and which spec version it implements.
curl -s "$MCPD/healthz"
# → {"status":"ok","version":"0.1.0","spec_version":"1.0.0", ...}
Un client costruito senza argomenti funziona contro un daemon locale. Tutto
si imposta nel codice o per variabile d’ambiente, e il codice vince; i nomi
delle variabili sono gli stessi in ogni linguaggio, così un valore copiato
fra due servizi continua a funzionare quando i due sono scritti in linguaggi
diversi: MCPD_ENDPOINT, MCPD_API_KEY,
MCPD_PROJECT, MCPD_ENVIRONMENT,
MCPD_SERVICE_NAME, MCPD_SERVICE_VERSION,
MCPD_SAMPLE e MCPD_ENABLED. Impostare
MCPD_ENABLED=false trasforma ogni chiamata in un’operazione a
vuoto che restituisce comunque oggetti validi, così una suite di test spegne
mcpd senza toccare una riga di codice.
Sono intercambiabili, ed è una cosa verificata
I test unitari dimostrano che ogni SDK fa quello che intendevano i suoi
autori. Non dimostrano niente sul fatto che i sei vadano d’accordo. Perciò
la suite di conformità scrive una sola esecuzione canonica attraverso ogni
linguaggio contro un unico daemon vivo, più un settimo canale che manda la
stessa esecuzione con nient’altro che curl, e verifica che
quello che torna indietro sia strutturalmente identico:
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
Se stai scegliendo un linguaggio per un servizio nuovo, questa è la risposta a «le tracce verranno uguali a quelle che abbiamo già».
Se il tuo linguaggio non è uno dei sei
Punta un SDK OpenTelemetry su /v1/traces e non scrivere una
riga in più. mcpd è un ricevitore OTLP, e mappa sia la convenzione
gen_ai.* di OpenTelemetry sia la llm.* di
OpenInference, così la strumentazione che hai già atterra al posto giusto.
Vedi OpenTelemetry.
Da un agente IA, tramite MCP
La terza strada è per un agente e non per un servizio: invece che sia il tuo codice a leggere le tracce, le legge per tuo conto un agente di programmazione o di assistenza, e risponde a una domanda su di esse. «Perché è fallita l’ultima esecuzione», «mostrami le esecuzioni che uno scorer ha penalizzato questa settimana», «quanto è costata questa sessione». mcpd espone tutto ciò attraverso il Model Context Protocol.
I tre strumenti
Tre, non trenta. Sono le tre mosse di una sola indagine: quale esecuzione, che cosa è successo dentro, e se è una sola esecuzione o sono tutte.
| Strumento | A che cosa risponde |
|---|---|
find_runs | Quale esecuzione. Una riga per ciascuna: id, età, stato, durata, costo, passi. Filtra per stato, modello, utente, sessione o testo libero; ordina per durata per avere le più lente. Se non dici altro, le ultime 24 ore. |
get_run | Che cosa è successo. Il waterfall, il passo fallito e il perché, i prompt e i completion, i log, i punteggi. I payload hanno un tetto, perché una sola esecuzione non riempia una finestra di contesto. |
run_stats | Una sola esecuzione o tutte. Costo, tasso di fallimento, latenza e token su una finestra, raggruppati per modello, nome, stato, ambiente o utente. Un tasso di fallimento del 40% vuol dire che l’esecuzione che stai leggendo non è il problema. |
I risultati tornano come testo e non come JSON, ed è una scelta presa apposta su chi paga questo contesto. Quaranta oggetti span sono per lo più punteggiatura; la stessa esecuzione disegnata come waterfall sono poche centinaia di token, e l’agente ne vede la forma.
Com’è fatto
Il server MCP è un sottocomando dello stesso binario, non un secondo
servizio. Gira come mcpd mcp, parla JSON-RPC su standard input
e standard output, e non tiene dati suoi. Non c’è nessuna porta da aprire,
nessun processo da tenere in vita e niente da mettere in deploy: l’host
dell’agente lo avvia come processo figlio quando una conversazione ne ha
bisogno e lo ferma dopo.
Quello che tiene, invece, è una connessione a un daemon. Ogni chiamata a uno strumento diventa una richiesta alla stessa API di query descritta sopra, con la stessa chiave di lettura e lo stesso ambito di progetto, quindi un agente non arriva a un progetto a cui non arriva la chiave che gli hai dato. Il raggio d’azione di consegnare a un agente questo server è esattamente il raggio d’azione di consegnargli quella chiave, ed è la proprietà da controllare prima di configurarne uno.
L’insieme degli strumenti è piccolo di proposito. Una superficie grande fa
scegliere male un modello, e quasi ogni domanda sul comportamento di un
agente si riduce a elencare le esecuzioni, aprirne una e fare una domanda
numerica su una finestra. Un client scopre l’insieme di oggi con la chiamata
tools/list del protocollo stesso, e non da un elenco scritto
qui che potrebbe restare indietro.
Configurare un client
Un host MCP avvia un server a partire da un comando, qualche argomento e un ambiente. Il file preciso e la chiave precisa sono dell’host, non di mcpd, quindi guarda la sua documentazione; i tre valori che vuole da te sono gli stessi dappertutto.
| Che cosa vuole l’host | Valore |
|---|---|
| Comando | Il binario mcpd, su un percorso che l’host può eseguire. |
| Argomenti |
Il sottocomando mcp. Prende --endpoint, il
daemon da interrogare, e --api-key, una chiave di lettura
per esso, per un host che passa argomenti più volentieri di un
ambiente.
|
| Ambiente |
Gli stessi due valori sotto forma di MCPD_ENDPOINT e
MCPD_API_KEY. L’endpoint vale
http://127.0.0.1:4317 quando non dici altro, e la chiave
si può lasciare fuori quando il daemon non ha chiavi impostate.
|
che cosa esegue l’host
MCPD_ENDPOINT=https://mcpd.internal MCPD_API_KEY=$READ mcpd mcp Tre cose da tenere a mente quando emetti quella chiave.
-
Dai all’agente una chiave
read, non una chiaveingest. Gli ambiti esistono perché una credenziale per scrivere span non sia anche una credenziale per ogni prompt archiviato. - Legala a un progetto. Una chiave configurata al livello più alto arriva a ogni progetto sul daemon.
-
Sappi che una chiave di lettura cancella anche le tracce finché non
configuri un elenco
admin, perché l’ambito admin ricade sull’elenco di lettura quando nessuno lo imposta. Un agente che tiene una chiave di lettura su un daemon a cui non è mai stato dato un elenco admin può cancellare un’esecuzione.
Tutte e tre stanno su Sicurezza.
Quale usare
| Strada | Usala quando |
|---|---|
| HTTP | Il tuo linguaggio non ha un SDK, stai mandando da una shell o da un job, oppure non vuoi nessuna dipendenza. |
| Un SDK | Stai strumentando un’applicazione, dove il batch, il nuovo tentativo e il non sollevare mai eccezioni valgono più della dipendenza. |
| OpenTelemetry | Emetti già OTel e preferisci cambiare un URL anziché aggiungere una libreria. |
| MCP | È una persona a fare domande a un agente sulle esecuzioni archiviate, e non del codice che le legge a orario. |
Si mescolano. Una flotta che emette tramite l’SDK TypeScript, un job
notturno che manda punteggi con curl e un agente che rilegge
tutto tramite MCP sono un solo deploy, non tre.
Poi
- Che cosa viene archiviato e chi lo può leggere: Sicurezza.
- Puntare su mcpd un impianto OTel che hai già: OpenTelemetry.
- Ogni endpoint, generato dalla specifica: API HTTP.
- Le query che vale la pena conoscere: Trovare un’esecuzione.