docs

MCP server

Trace the MCP server your app exposes with @unhandled/instrumentation-mcp-server, covering requests, notifications, tool results and errors.

@unhandled/instrumentation-mcp-server traces an MCP server you build with @modelcontextprotocol/sdk 1.0.0 or later, so tool calls and other requests show up in MCP monitoring. It observes the server at its transport, so every message is covered, including unknown methods and requests the SDK answers itself.

This page is about instrumenting your own MCP server. To connect an assistant to Unhandled's MCP server, see MCP server.

Install

Terminal
npm install @unhandled/instrumentation-mcp-server

It is a standalone package with no /integration entry, so the AdonisJS agent does not register it for you. Start the agent, or any OpenTelemetry SDK, first.

Instrument the server

Call instrumentMcpServer() before connect():

ts
import { z } from 'zod'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { instrumentMcpServer } from '@unhandled/instrumentation-mcp-server'
import { findOrder } from '#services/orders'

const server = new McpServer({ name: 'shop-orders', version: '1.3.0' })
instrumentMcpServer(server, { recordInputs: true })

server.registerTool(
  'lookup_order',
  { description: 'Find an order by number', inputSchema: { number: z.string() } },
  async ({ number }) => ({
    content: [{ type: 'text', text: JSON.stringify(await findOrder(number)) }],
  })
)

await server.connect(new StdioServerTransport())

It accepts a low-level Server as well as an McpServer, and returns the server you passed. Instrumenting the same server twice is a no-op. Called after connect(), it emits a UNHANDLED_MCP_LATE_INSTRUMENTATION process warning and traces only the messages received from then on.

Options

OptionDefaultEffect
recordInputsfalseRecords tool and prompt arguments as attributes.
recordOutputsfalseRecords tool results and prompt messages as attributes.
serverNameauto-detectedOverrides the name given to the server constructor.
serverVersionauto-detectedOverrides the version given to the server constructor.

Arguments and results often contain user data, which is why both are off by default.

Spans

MessageKindName
Incoming JSON-RPC requestSERVER<method> <target>, or the method alone, such as tools/call lookup_order or initialize
Incoming notificationSERVERThe method, such as notifications/initialized
Outgoing notificationCLIENTThe method, such as notifications/tools/list_changed
Transport errorINTERNALmcp.transport.error, with the exception recorded

The target is the tool name for tools/call, the prompt name for prompts/get, and the URI for resources/read, resources/subscribe and resources/unsubscribe.

Handlers run inside the request span, so spans you create in a tool handler, such as database queries, are its children. A notification sent from a handler is a child of that request span.

A JSON-RPC error response sets rpc.jsonrpc.error_code, rpc.jsonrpc.error_message and ERROR status on the request span. A tool result with isError: true also sets ERROR status; the SDK turns thrown handler errors and unknown tool names into such results. Requests still pending when the transport closes end with ERROR.

Attributes

Always set:

AttributeWhere
mcp.method.nameEvery span
mcp.server.name, mcp.server.versionEvery span
mcp.client.name, mcp.client.versionFrom initialize onwards, including the initialize span
mcp.tool.nametools/call
mcp.tool.result.is_errortools/call responses
rpc.jsonrpc.error_code, rpc.jsonrpc.error_messageRequests answered with an error

With recordInputs, each argument of tools/call and prompts/get is recorded as mcp.request.argument.<key>, JSON-encoded.

With recordOutputs:

  • tools/call: mcp.tool.result.content_count, plus mcp.tool.result.<i>.content_type and mcp.tool.result.<i>.content per content item.
  • prompts/get: mcp.prompt.result.description, mcp.prompt.result.message_count, plus mcp.prompt.result.<i>.role and mcp.prompt.result.<i>.content per message.

Text content is recorded as the raw text; any other content item as its JSON.

mcp.method.name puts every span in the MCP category, and mcp.tool.result.is_error and the JSON-RPC error code count toward the error rate.