Logging
Two Channels, One Composable
useMcpLogger() exposes a split-channel API because the two destinations have very different audiences:
| Channel | Audience | API |
|---|---|---|
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.
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:
export default defineNuxtConfig({
modules: ['@nuxtjs/mcp-toolkit'],
mcp: {
logging: {
// Forward any evlog/nitro options here (drains, sampling, redaction, …)
},
},
})
Force on / opt out
mcp.logging value | Behavior |
|---|---|
undefined (default) | Auto-detect: on if evlog is installed, off otherwise. |
true or object | Force on. Build throws with install instructions if evlog is missing. |
false | Force 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.
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:
- The MCP client receives two
notifications/messageentries (info,info) and oneerrorif the charge fails. - The wide event for this request is enriched with
user.id,billing.amount, plus acharge_completed(orcharge_failed) discrete event — visible in your dev terminal and forwarded to any configured evlog drain.
API
| Method | Channel | Description |
|---|---|---|
notify(level, data, logger?) | client | Send a notifications/message. Drops silently when the level is filtered out by the client. |
notify.debug(data, logger?) | client | Shortcut for notify('debug', …). |
notify.info(data, logger?) | client | Shortcut for notify('info', …). |
notify.warning(data, logger?) | client | Shortcut for notify('warning', …). |
notify.error(data, logger?) | client | Shortcut 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. |
evlog | server (evlog) | Underlying RequestLogger — fork, 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:
| Field | Description |
|---|---|
mcp.transport | streamable-http (default) or cloudflare-do on Workers |
mcp.route | The configured MCP endpoint path |
mcp.session_id | Copied from the mcp-session-id header (when present) |
mcp.method | The JSON-RPC method (tools/call, tools/list, initialize, resources/read, prompts/get, notifications/initialized, …) |
mcp.request_id | The JSON-RPC request id when the message has one |
mcp.tool | The tool name on a tools/call request |
mcp.resource | The URI on a resources/read request |
mcp.prompt | The 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():
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.
import { createAxiomDrain } from 'evlog/adapters/axiom'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
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.
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.
Production Tips
- Use
notifysparingly. 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
setandeventliberally. 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(). CombineuseMcpLogger()in middleware withextractToolNames(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.