Skip to content
mcpd
no daemon
Source

TypeScript

One package with no dependencies. It records spans, logs, metrics, scores and feedback, and it never throws out of an instrumentation call.

Install

Node 20 or later, Deno, Bun, browsers and edge runtimes. ESM first, with a CommonJS build for require. The only Node-specific pieces, async_hooks and zlib, are loaded at runtime and skipped where they do not exist.

npm install @mcpd/sdk

Construct the client

One client per process. Every option also reads an environment variable, so the same code runs in development and in production without an edit. With no arguments at all it talks to a daemon on http://localhost:4317.

connect

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

Code wins over the environment, and the environment wins over the default.

OptionEnvironmentDefault
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYnone
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTnone
serviceNameMCPD_SERVICE_NAMEnone
serviceVersionMCPD_SERVICE_VERSIONnone
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinkthe HTTP transport

enabled: false records nothing and sends nothing, while every call still returns a valid object and the list reads return an empty page. A test suite can switch mcpd off without touching a line of instrumentation.

capturePayloads: false keeps prompts and completions in the process. A span is still recorded with its timing, status, usage, model, model parameters, correlation and errors; input and output are dropped before they reach the queue, so nothing sensitive can leave on a flush. A span overrides it for itself with capturePayloads in its start options, in either direction.

Record a run

withSpan is the scoped form. It opens the span, keeps it active for everything the callback starts, and ends it when the callback settles. If the callback throws, the span is marked error with the thrown message and the error is rethrown unchanged, so your own error handling is not altered by being observed.

record a run

/**
 * 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 }
}

Context

On Node and Bun the active span lives in an AsyncLocalStorage. Anything started inside a withSpan callback nests under it without being handed a reference, and two runs interleaved on one event loop never see each other's spans.

nesting

// Anything the callback starts nests under it. No handle is passed down.
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)
})

Browsers and most edge runtimes have no AsyncLocalStorage. There the SDK keeps a single saved value instead. That nests correctly and survives an await within one run, but it cannot tell two concurrent runs apart, and the result would be one run's tool call appearing inside another run's tree. When you run agents concurrently on those runtimes, pass the parent explicitly.

explicit parent

// Correct on every runtime, including ones with no 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()

// Which mechanism is in use, at runtime.
mcpd.stats().context // 'async-local-storage' | 'fallback'

Between services, context travels in a W3C traceparent header. This is the whole of what makes one trace survive a network hop. A malformed header is ignored and a fresh root trace is started, because a header nobody can read is not a reason to fail the request that carried it.

across a service boundary

// Outbound: put the active span on the request.
await fetch(url, {
  headers: { traceparent: span.traceparent() },
})

// Inbound: continue that trace instead of starting a new one.
const run = mcpd.trace('handle_request', {
  traceparent: request.headers.get('traceparent'),
})

Model, usage and errors

Token counts are what mcpd prices at ingest, so the cost on the trace is the cost you were billed. Report what the provider told you rather than counting tokens yourself. An error always carries a message, and setting one sets the span's status to error in the same move, so the two can never disagree.

setModelParameters records the settings the call was made with: temperature, topP, topK, maxTokens, seed, stopSequences and hasResponseFormat. They are settings rather than payload, so they survive capturePayloads: false, which is the case that matters: they are what somebody reproducing the run has to match. A field you leave out is absent and a field you pass is kept whatever it is, so seed: 0 is a seed. The schema behind hasResponseFormat is not recorded, only whether one was in force.

what a span carries

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

// How the model was called. Settings, not payload: kept with capture off.
generation.setModelParameters({ temperature: 0, seed: 0, maxTokens: 1024 })

// A failure you caught and handled yourself.
generation.setError('the provider timed out', {
  kind: 'TimeoutError',
  retryable: true,
})

// A point in time inside the span, with no duration of its own.
generation.event('first_token', { kind: 'model_chunk' })

Durable runs

The same client serves the daemon's durable runs. workflow() builds the definition and keeps the step bodies; a Worker registers it, long-polls for leases and serves each one, and mcpd.runs, mcpd.events and mcpd.definitions mirror the run surface one for one. A body throws RetryableError to be retried within the step's budget, and records spans under its step through the StepContext. A span recorded that way, and every span nested under it, adds its usage to the step's when it ends, so the daemon prices what the trace shows and the body never sums tokens by hand; ctx.setUsage is an explicit override. Four hooks sit beside onError: onLease, onStepStart, onStepEnd and onConflict, and worker.stats() counts claims, completions, failures by class and conflicts by reason.

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

What each construct does, the lease protocol and the run API are on Workflows, Workers and Runs.

Scores and feedback

min and max are required by the type. A score of 0.7 on an undeclared scale cannot be compared to anything, and by the time someone is reading the chart it is too late to ask what it meant. Feedback is a person's judgement and is kept separate from a score, because the two answer different questions and must never be averaged together.

score and 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' } })

Both attach to the active span's trace when you do not name one, so a thumbs control in your own product can call feedback with nothing but the trace id you kept.

All five signals, one set of ids

Spans, logs, metrics, scores and feedback go through the same queue and carry the same trace and span ids. That is what lets a spike on a chart open the run that caused it.

  • 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 and shutdown

Recording is a queue append. Nothing has left the process until the worker sends it, which it does every two seconds or when a batch fills. Both flush() and shutdown() are awaitable and idempotent, and shutdown() also stops the worker.

In a serverless function this is not optional. The runtime freezes the process the moment you return a response, and the background worker never runs again, so the last batch is lost. Await a flush before the response goes out.

a serverless handler

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

  // The batch is still in memory here. Send it before the response goes out.
  await mcpd.flush()
  return new Response(answer)
}

On Node the client also flushes on beforeExit, so a script that forgets shutdown() still gets its last batch. That hook does not fire on an explicit process.exit() or on a fatal signal, so it is a safety net and not a replacement.

Assert what you recorded

A RecordingSink replaces the network with memory, so a test reads back the spans, logs, metrics, scores and feedback the code under test produced, with no server and no port. A ConsoleSink prints one JSON line per signal instead, and any object with an export(batch, ingestKey) method is a sink. One that throws is a failed batch and a diagnostic, never an exception in your code. mcpd.stats() answers queued, dropped, capacity, exported, failed and rejected.

a test with no server

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

Record a finished span

mcpd.record(span) queues a span whose ids, parent and times you already hold: what an adapter for another tracing system, a batch import or a replay has in hand. It is queued as given, never re-parented, re-timed or re-sampled, with the correlation it left empty filled from the client. A record whose ids are not OpenTelemetry-shaped, whose name is empty or whose start time is missing is dropped and counted in stats().rejected. On any span, externalParentSpanId carries a parent from a tracing system that is not mcpd, for correlation only.

a span from elsewhere

// Ids, parent and times the caller already holds. Queued as given.
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,
})

// A parent from a tracing system that is not mcpd, for correlation only.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId })

What you can rely on

  • Instrumentation never throws. A failed export, an unreachable daemon or a payload that will not serialise is reported at most once per interval through onError and then let go. Observability must never take down the thing it observes.
  • Recording does not block. It appends to a queue and returns; the network happens on a background worker, so your agent's latency never depends on mcpd being fast, or up.
  • Memory is bounded. The queue holds maxQueue signals. When it is full the oldest is dropped and counted in mcpd.stats(), because an unbounded buffer turns a network outage into an out-of-memory kill.
  • The sampling decision is made once, at the root, and inherited. A half-recorded trace reads as a bug in your system.
  • An open span is still sent. A span is queued when it starts and again when it ends, so a run that crashes mid-flight is visible. If the start record has not left yet, the end record replaces it, so a fast span costs one record and not two.
  • Your payload is never rewritten. Keys are converted between camelCase and the daemon's snake_case, and the conversion stops at input, output, attributes, metadata, fields and labels. Your prompt arrives exactly as you wrote it.

Reading it back

The same client reads. Every method mirrors one HTTP endpoint and returns the generated types. Reads throw an McpdError when they fail: a query you awaited and cannot answer is news, unlike a span that could not be exported.

search and analyse

// 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}`)
}

Finding a run covers the filters in full. The data model lists every field these types carry.