Aller au contenu
mcpd
aucun daemon
Source

TypeScript

Un seul paquet, sans dépendances. Il enregistre les spans, les logs, les métriques, les scores et le feedback, et il ne lève jamais d’exception depuis un appel d’instrumentation.

Installation

Node 20 ou plus récent, Deno, Bun, les navigateurs et les runtimes edge. ESM d’abord, avec une version CommonJS pour require. Les seules parties propres à Node, async_hooks et zlib, sont chargées à l’exécution et ignorées là où elles n’existent pas.

npm install @mcpd/sdk

Construire le client

Un client par processus. Chaque option lit aussi une variable d’environnement, si bien que le même code tourne en développement et en production sans la moindre modification. Sans aucun argument, il s’adresse à un daemon sur http://localhost:4317.

se connecter

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',
})

Le code l’emporte sur l’environnement, et l’environnement l’emporte sur la valeur par défaut.

OptionEnvironnementPar défaut
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYaucune
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTaucun
serviceNameMCPD_SERVICE_NAMEaucun
serviceVersionMCPD_SERVICE_VERSIONaucune
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinkle transport HTTP

enabled: false n’enregistre rien et n’envoie rien, alors que chaque appel renvoie toujours un objet valide et que les lectures de liste renvoient une page vide. Une suite de tests peut couper mcpd sans toucher une seule ligne d’instrumentation.

capturePayloads: false garde les prompts et les complétions dans le processus. Le span est toujours enregistré avec ses temps, son statut, sa consommation, son modèle, sa corrélation et ses erreurs ; input et output sont écartés avant d’atteindre la file d’attente, si bien que rien de sensible ne peut sortir lors d’un flush. Un span change ce réglage pour lui-même avec capturePayloads dans ses options de départ, dans un sens comme dans l’autre.

Enregistrer une exécution

withSpan est la forme à portée délimitée. Elle ouvre le span, le garde actif pour tout ce que le callback démarre, et le termine quand le callback se résout. Si le callback lève une exception, le span est marqué error avec le message levé et l’erreur est relancée telle quelle : votre propre gestion des erreurs n’est pas altérée par le fait d’être observée.

enregistrer 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 }
}

Contexte

Sur Node et Bun, le span actif vit dans un AsyncLocalStorage. Tout ce qui démarre à l’intérieur d’un callback withSpan s’imbrique dessous sans recevoir de référence, et deux exécutions entrelacées sur une même boucle d’événements ne voient jamais les spans l’une de l’autre.

imbrication

// Tout ce que le callback démarre s’imbrique dessous. Rien n’est passé.
await mcpd.withSpan('support_agent', async (run) => {
  const order = await mcpd.withSpan(
    'lookup_order',
    async (tool) => {
      tool.setInput({ orderId: 'A-1042' })
      mcpd.log('info', 'this line belongs to lookup_order')
      return { status: 'in transit' }
    },
    { kind: 'tool_call' },
  )
  run.setOutput(order)
})

Les navigateurs et la plupart des runtimes edge n’ont pas d’AsyncLocalStorage. Le SDK y conserve à la place une seule valeur mémorisée. Elle s’imbrique correctement et survit à un await au sein d’une exécution, mais elle ne sait pas distinguer deux exécutions concurrentes, et le résultat serait l’appel d’outil d’une exécution apparaissant dans l’arbre d’une autre. Quand vous faites tourner des agents en parallèle sur ces runtimes, passez le parent explicitement.

parent explicite

// Correct sur tous les runtimes, même ceux sans AsyncLocalStorage.
const run = mcpd.trace('support_agent')
const step = run.child('call_model', { kind: 'model_generation' })
step.end()

await run.withChild('call_tool', async (tool) => {
  tool.setOutput({ ok: true })
})
run.end()

// Quel mécanisme est en usage, à l’exécution.
mcpd.stats().context // 'async-local-storage' | 'fallback'

Entre services, le contexte voyage dans un en-tête W3C traceparent. C’est tout ce qui fait qu’une trace survit à un saut réseau. Un en-tête mal formé est ignoré et une nouvelle trace racine démarre, car un en-tête que personne ne peut lire n’est pas une raison de faire échouer la requête qui le transportait.

d’un service à l’autre

// En sortie, posez le span actif sur la requête.
await fetch(url, {
  headers: { traceparent: span.traceparent() },
})

// En entrée, continuez cette trace au lieu d’en démarrer une nouvelle.
const run = mcpd.trace('handle_request', {
  traceparent: request.headers.get('traceparent'),
})

Modèle, consommation et erreurs

mcpd tarife les décomptes de tokens à l’ingestion : le coût porté par la trace est donc celui qui vous a été facturé. Reportez ce que le fournisseur vous a indiqué plutôt que de compter les tokens vous-même. Une erreur porte toujours un message, et en définir une place du même geste le statut du span sur error, si bien que les deux ne peuvent jamais se contredire.

ce que porte un span

generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
  inputTokens: 812,
  outputTokens: 37,
  inputDetails: { cacheRead: 640 },
})

// Un échec que vous avez intercepté et traité vous-même.
generation.setError('the provider timed out', {
  kind: 'TimeoutError',
  retryable: true,
})

// Un instant à l’intérieur du span, sans durée propre.
generation.event('first_token', { kind: 'model_chunk' })

Exécutions durables

Le même client dessert les exécutions durables du daemon. workflow() construit la définition et retient le corps des étapes ; un Worker l’enregistre, attend des leases en long polling et sert chacun d’eux, et mcpd.runs, mcpd.events et mcpd.definitions reprennent un pour un la surface des exécutions. Un corps d’étape lève RetryableError pour être réessayé dans le budget de l’étape, et enregistre des spans sous son étape via le StepContext. Un span enregistré ainsi, et chaque span imbriqué dessous, ajoute sa consommation à celle de l’étape quand il se termine : le daemon tarife donc ce que montre la trace et le corps ne totalise jamais les tokens à la main ; ctx.setUsage est une substitution explicite. Quatre hooks accompagnent onError : onLease, onStepStart, onStepEnd et onConflict, et worker.stats() compte les leases prises, les achèvements, les échecs par classe et les conflits par motif.

un workflow

const support = workflow({ id: 'support_ticket' })
  .step('classify', async (ctx, input: { ticket: string }) => ({
    kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
  }))
  .agent(
    'draft',
    async (ctx, input: { kind: string }) => {
      // Your own model call, recorded under this step in the run's trace. Its
      // usage adds up into the step's, which the daemon prices.
      return await ctx.withSpan('model_call', async (span) => {
        span.setModel('claude-sonnet-4', 'anthropic')
        span.setUsage({ inputTokens: 812, outputTokens: 37 })
        return `an answer to a ${input.kind}`
      })
    },
    { input: { kind: '${steps.classify.output.kind}' } },
  )
  .waitFor('approval', {
    event: 'approved',
    key: '${input.ticket}',
    timeoutMs: 7 * 24 * 3_600_000,
    when: when('steps.classify.output.kind', 'eq', 'bug'),
  })
  .step(
    'send',
    async (ctx, input: { text: string }) => {
      if (!(await deliver(input.text, ctx.idempotencyKey))) {
        throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
      }
      return { sent: true }
    },
    {
      input: { text: '${steps.draft.output}' },
      effect: 'at_most_once',
      retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
    },
  )
  .output({ sent: '${steps.send.output.sent}' })

Ce que fait chaque construction, le protocole de lease et l’API des exécutions sont décrits dans Workflows, Workers et Exécutions.

Scores et feedback

min et max sont exigés par le type. Un score de 0.7 sur une échelle non déclarée ne peut être comparé à rien, et quand quelqu’un lit le graphique il est trop tard pour demander ce qu’il voulait dire. Le feedback est le jugement d’une personne et reste distinct d’un score, car les deux répondent à des questions différentes et ne doivent jamais être moyennés ensemble.

score et feedback

// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
  traceId,
  scorerId: 'answer_relevance',
  value: 0.86,
  min: 0,
  max: 1,
  reason: 'Answered the question and gave the delivery date.',
})

// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
  traceId,
  kind: 'thumbs',
  value: 'up',
  comment: 'Exactly what the customer asked for.',
  authorKind: 'end_user',
})

// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } })

Les deux se rattachent à la trace du span actif quand vous n’en nommez aucune, si bien qu’un bouton de vote dans votre propre produit peut appeler feedback avec pour seule information l’identifiant de trace que vous avez conservé.

Les cinq signaux, un seul jeu d’identifiants

Les spans, les logs, les métriques, les scores et le feedback passent par la même file d’attente et portent les mêmes identifiants de trace et de span. C’est ce qui permet à un pic sur un graphique d’ouvrir l’exécution qui l’a causé.

  • 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

trace_id Store Search Export

Flush et arrêt

Enregistrer, c’est ajouter à une file d’attente. Rien n’a quitté le processus tant que le worker ne l’a pas envoyé, ce qu’il fait toutes les deux secondes ou quand un lot est plein. flush() comme shutdown() s’attendent avec await et sont idempotents, et shutdown() arrête aussi le worker.

Dans une fonction serverless, cela n’est pas facultatif. Le runtime gèle le processus dès que vous renvoyez une réponse, et le worker de fond ne tourne plus jamais : le dernier lot est donc perdu. Attendez un flush avant que la réponse ne parte.

un handler serverless

export default async function handler(request: Request): Promise<Response> {
  const answer = await mcpd.withSpan('answer', async (run) => {
    run.setInput(await request.text())
    return 'ok'
  })

  // Le lot est encore en mémoire ici. Envoyez-le avant que la réponse ne parte.
  await mcpd.flush()
  return new Response(answer)
}

Sur Node, le client vide aussi la file sur beforeExit, si bien qu’un script qui oublie shutdown() obtient quand même son dernier lot. Ce hook ne se déclenche ni sur un process.exit() explicite ni sur un signal fatal : c’est un filet de sécurité, pas un remplacement.

Vérifier ce que vous avez enregistré

Un RecordingSink remplace le réseau par la mémoire, si bien qu’un test relit les spans, les logs, les métriques, les scores et le feedback produits par le code testé, sans serveur et sans port. Un ConsoleSink imprime à la place une ligne JSON par signal, et tout objet doté d’une méthode export(batch, ingestKey) est un sink. Un sink qui lève une exception donne un lot en échec et un diagnostic, jamais une exception dans votre code. mcpd.stats() répond sur queued, dropped, capacity, exported, failed et rejected.

un test sans serveur

import { Mcpd, RecordingSink } from '@mcpd/sdk'

const sink = new RecordingSink()
const mcpd = new Mcpd({ sink })

await answerQuestion(mcpd)
await mcpd.flush()

expect(sink.spans.map((s) => s.name)).toEqual(['answer_question', 'model_call'])
expect(sink.scores[0]?.scorerId).toBe('answer_relevance')

Enregistrer un span déjà terminé

mcpd.record(span) met en file un span dont vous détenez déjà les identifiants, le parent et les temps : ce dont dispose un adaptateur pour un autre système de tracing, un import par lots ou un rejeu. Il est mis en file tel quel, jamais re-parenté, re-daté ni ré-échantillonné, la corrélation que vous avez laissée vide étant remplie depuis le client. Un enregistrement dont les identifiants n’ont pas la forme OpenTelemetry, dont le nom est vide ou dont l’heure de début manque est écarté et compté dans stats().rejected. Sur n’importe quel span, externalParentSpanId porte un parent venu d’un système de tracing autre que mcpd, pour la seule corrélation.

un span venu d’ailleurs

// Identifiants, parent et temps déjà détenus. Mis en file tels quels.
mcpd.record({
  traceId: otel.traceId,
  spanId: otel.spanId,
  parentSpanId: otel.parentSpanId,
  name: 'GET /orders',
  kind: 'http_request',
  status: 'ok',
  startTime: otel.startTime,
  endTime: otel.endTime,
  isRoot: false,
  isEvent: false,
  correlation: { project: 'support' },
  receivedAt: otel.endTime,
})

// Un parent venu d’un autre système de tracing, pour la seule corrélation.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId })

Ce sur quoi vous pouvez compter

  • L’instrumentation ne lève jamais d’exception. Un export en échec, un daemon injoignable ou un payload qui refuse de se sérialiser sont signalés au plus une fois par intervalle via onError, puis abandonnés. L’observabilité ne doit jamais faire tomber ce qu’elle observe.
  • L’enregistrement ne bloque pas. Il ajoute à une file et rend la main ; le réseau se passe sur un worker de fond, si bien que la latence de votre agent ne dépend jamais de la vitesse de mcpd, ni de sa disponibilité.
  • La mémoire est bornée. La file contient maxQueue signaux. Quand elle est pleine, le plus ancien est écarté et compté dans mcpd.stats(), car un tampon sans limite transforme une panne réseau en arrêt pour mémoire épuisée.
  • La décision d’échantillonnage est prise une seule fois, à la racine, puis héritée. Une trace enregistrée à moitié se lit comme un bug dans votre système.
  • Un span ouvert est envoyé quand même. Un span est mis en file à son début et de nouveau à sa fin, si bien qu’une exécution qui plante en plein vol reste visible. Si l’enregistrement de début n’est pas encore parti, celui de fin le remplace : un span rapide coûte un enregistrement et non deux.
  • Votre payload n’est jamais réécrit. Les clés sont converties entre camelCase et le snake_case du daemon, et la conversion s’arrête à input, output, attributes, metadata, fields et labels. Votre prompt arrive exactement comme vous l’avez écrit.

Relire ce qui a été enregistré

Le même client lit. Chaque méthode reflète un endpoint HTTP et renvoie les types générés. Les lectures lèvent une McpdError quand elles échouent : une requête que vous avez attendue et à laquelle il est impossible de répondre est une nouvelle qui compte, contrairement à un span qui n’a pas pu être exporté.

rechercher et analyser

// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
  hasScore: 'answer_relevance',
  scoreBelow: 0.5,
  limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)

const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
  const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
  console.log(
    `${trace.traceId.slice(0, 8)}  ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
      `${seconds}s  ${trace.spanCount} spans  $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
  )
}

// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
  console.log(`  ${span.kind.padEnd(18)} ${span.name}`)
}

Retrouver une exécution couvre les filtres en détail. Le modèle de données liste chaque champ que ces types portent.