Advanced Topics

Logging

Stream logs to MCP clients and capture structured wide events with useMcpLogger().

Two Channels, One Composable

useMcpLogger() exposes a split-channel API because the two destinations have very different audiences:

ChannelAudienceAPI
Client notifications (notifications/message)The end user / agent UI (Cursor, Claude, MCP Inspector, …)log.notify(...), log.notify.debug/info/warning/error(...)
Wide events (server-side, powered by evlog)Operators, dev terminal, drains (Axiom, OTLP, Datadog, …)log.set(...), log.event(...), log.evlog

Notifications are user-facing and may end up in chat transcripts. Wide events are operator-facing, pretty-printed in the dev terminal at the end of each request and shipped to drains in production.

Prompt
Add structured logging to my Nuxt MCP server (@nuxtjs/mcp-toolkit).

- Use useMcpLogger() inside a tool handler (auto-imported)
- log.notify.info({ … }) sends notifications/message to the connected MCP client
- Shortcuts: log.notify.debug/info/warning/error — respect logging/setLevel
- log.set({ user: { id } }) accumulates context onto the request's evlog wide event
- log.event('charge_started', { amount }) captures a discrete event in the same wide event
- log.evlog gives you the underlying RequestLogger (fork, error, getContext, …)
- Install the optional evlog peer dep to enable wide events: `pnpm add evlog`
- Ship to Axiom / Sentry / Datadog / OTLP / HyperDX / Better Stack / PostHog with one Nitro plugin (`server/plugins/evlog-axiom.ts`)
- Custom drains: register any `(ctx) => Promise<void>` on the `evlog:drain` hook
- Disable observability entirely with mcp.logging: false (notify still works)

Docs: https://mcp-toolkit.nuxt.dev/advanced/logging

Setup

log.notify(...) works out of the box — no extra setup needed. Server-side wide events are powered by the optional evlog peer dependency.

Enable wide events

Install evlog alongside the toolkit:

pnpm add evlog

That's it. The toolkit auto-detects evlog and wires it into Nitro automatically — wide events show up in your dev terminal on every MCP request, with mcp.tool, mcp.session_id, and friends already tagged.

Configure (optional)

Forward evlog Nitro options under mcp.logging:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/mcp-toolkit'],
  mcp: {
    logging: {
      // Forward any evlog/nitro options here (drains, sampling, redaction, …)
    },
  },
})

Force on / opt out

mcp.logging valueBehavior
undefined (default)Auto-detect: on if evlog is installed, off otherwise.
true or objectForce on. Build throws with install instructions if evlog is missing.
falseForce off. log.notify(...) keeps working; log.set(...) / log.event(...) / log.evlog throw an McpObservabilityNotEnabledError.

useMcpLogger()

Auto-imported. Must be called inside a tool, resource, or prompt handler.

server/mcp/tools/charge.ts
import { z } from 'zod'
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'

export default defineMcpTool({
  name: 'charge_card',
  description: 'Charge a payment method',
  inputSchema: {
    userId: z.string(),
    amount: z.number().int(),
  },
  handler: async ({ userId, amount }) => {
    const log = useMcpLogger('billing')

    // → server: merged into the request's wide event, printed in the
    //   dev terminal at the end of the request, shipped to drains.
    log.set({ user: { id: userId }, billing: { amount } })

    // → client: appears in the MCP Inspector "Server Notifications" panel
    //   (and in Cursor / Claude's log viewer). Honours `logging/setLevel`.
    await log.notify.info({ msg: 'starting charge', amount })

    try {
      const receipt = await charge(userId, amount)
      log.event('charge_completed', { receiptId: receipt.id })
      await log.notify.info({ msg: 'charge ok', receiptId: receipt.id })
      return `Charged ${amount}.`
    }
    catch (err) {
      log.evlog.error('charge failed', err)
      await log.notify.error({ msg: 'charge failed', error: String(err) })
      throw err
    }
  },
})

What happens:

  1. The MCP client receives two notifications/message entries (info, info) and one error if the charge fails.
  2. The wide event for this request is enriched with user.id, billing.amount, plus a charge_completed (or charge_failed) discrete event — visible in your dev terminal and forwarded to any configured evlog drain.

API

MethodChannelDescription
notify(level, data, logger?)clientSend a notifications/message. Drops silently when the level is filtered out by the client.
notify.debug(data, logger?)clientShortcut for notify('debug', …).
notify.info(data, logger?)clientShortcut for notify('info', …).
notify.warning(data, logger?)clientShortcut for notify('warning', …).
notify.error(data, logger?)clientShortcut for notify('error', …).
set(fields)server (evlog)Merge fields into the current request's wide event.
event(name, fields?)server (evlog)Capture a discrete event in the wide event's request log.
evlogserver (evlog)Underlying RequestLoggerfork, error, getContext, emit, …

logger is the prefix attached to the notification (visible to the client). Pass it as the argument to useMcpLogger('prefix') to set a default for the request, or pass it per call to override.

Level Filtering

Per the MCP spec, clients can call logging/setLevel to filter which messages they want. The toolkit forwards the current MCP session id to the SDK so each session's filter is applied independently — log.notify.debug(...) becomes a no-op for clients that asked for warning or higher.

The notify methods always resolve and never throw, even when the transport is disconnected or the level is filtered out, so you can use them freely on hot paths.

Wide Events with evlog

Every MCP request is wrapped in an evlog wide event — one structured log line per request, accumulated as the handler runs. The toolkit automatically tags each wide event with:

FieldDescription
mcp.transportstreamable-http (default) or cloudflare-do on Workers
mcp.routeThe configured MCP endpoint path
mcp.session_idCopied from the mcp-session-id header (when present)
mcp.methodThe JSON-RPC method (tools/call, tools/list, initialize, resources/read, prompts/get, notifications/initialized, …)
mcp.request_idThe JSON-RPC request id when the message has one
mcp.toolThe tool name on a tools/call request
mcp.resourceThe URI on a resources/read request
mcp.promptThe prompt name on a prompts/get request

For batched JSON-RPC payloads the singular keys (method, tool, resource, prompt, request_id) flip to plural arrays (methods, tools, resources, prompts, request_ids). No user code or middleware is required — these fields land on every request automatically.

You stack additional context with set() and capture milestones with event():

server/mcp/tools/import-csv.ts
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'

export default defineMcpTool({
  name: 'import_csv',
  description: 'Import a CSV file',
  inputSchema: { url: z.string().url() },
  handler: async ({ url }) => {
    const log = useMcpLogger('import')
    log.set({ source: { kind: 'csv', url } })

    const rows = await fetchCsv(url)
    log.event('rows_fetched', { count: rows.length })

    const inserted = await insertRows(rows)
    log.set({ result: { inserted } })

    return `Imported ${inserted} rows.`
  },
})

In your dev terminal you'll get something like:

INFO [Playground MCP] POST /mcp 200 in 18ms
├─ requestId: c6a9094f-bd24-4971-88ee-a53a28fab13a
├─ mcp: transport=streamable-http route=/mcp session_id=ef39698f-… method=tools/call request_id=1 tool=import_csv
├─ source: kind=csv url=https://example.com/data.csv
├─ result: inserted=128
└─ requestLogs: 0={"level":"info","message":"rows_fetched",...}

Ship to Your Observability Stack

evlog ships drain adapters for Axiom, Sentry, OpenTelemetry / OTLP, HyperDX, Datadog, Better Stack, and PostHog. A drain is just a function registered on the evlog:drain Nitro hook — every MCP wide event (already tagged with mcp.tool, mcp.session_id, …) is forwarded to whichever destinations you wire up.

The pattern is always the same: drop a Nitro plugin under server/plugins/ that imports a createXxxDrain() from evlog/adapters/* and registers it on the hook. Most adapters are zero-config if you set the standard env vars.

server/plugins/evlog-axiom.ts
import { createAxiomDrain } from 'evlog/adapters/axiom'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
.env
NUXT_AXIOM_TOKEN=xaat-...
NUXT_AXIOM_DATASET=mcp-server

You can now slice MCP traffic in Axiom by mcp.tool, mcp.session_id, user.id, latency, error rate — every field you set() on a wide event is queryable.

The hook is additive: register multiple drains for parallel forwarding (e.g. Axiom for storage + Sentry for alerting), and a failure in one drain doesn't affect the others.

server/plugins/evlog-drains.ts
import { createAxiomDrain } from 'evlog/adapters/axiom'
import { createSentryDrain } from 'evlog/adapters/sentry'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
  nitroApp.hooks.hook('evlog:drain', createSentryDrain())
})

A drain is just (ctx: DrainContext | DrainContext[]) => Promise<void> — write your own for Slack alerts on failed tools/call, persisting to your own database for audit, forwarding to an internal SIEM, etc.

See the evlog docs for the full list of adapters, configuration options (sampling, redaction, enrichers), and how to build custom drains.

Production Tips

  • Use notify sparingly. Each notification is a network round-trip and ends up in the user's chat history. Keep it for actionable progress updates ("opened PR #42") or genuine errors.
  • Use set and event liberally. They're cheap, batched into the wide event, and never leak to the client. This is where you put structured business data, timings, retry counts, etc.
  • Pair with extractToolNames(). Combine useMcpLogger() in middleware with extractToolNames(event) to record every tool call, including failed ones, for audit trails.

Requirements

useMcpLogger() requires nitro.experimental.asyncContext to be true (default since Nuxt 3.8+). The set, event, and evlog channels throw an McpObservabilityNotEnabledError when the optional evlog peer dependency is missing or mcp.logging is set to false. The notify channel keeps working in both cases.
Copyright © 2026