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
npm install @unhandled/instrumentation-mcp-serverIt 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():
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
| Option | Default | Effect |
|---|---|---|
recordInputs | false | Records tool and prompt arguments as attributes. |
recordOutputs | false | Records tool results and prompt messages as attributes. |
serverName | auto-detected | Overrides the name given to the server constructor. |
serverVersion | auto-detected | Overrides the version given to the server constructor. |
Arguments and results often contain user data, which is why both are off by default.
Spans
| Message | Kind | Name |
|---|---|---|
| Incoming JSON-RPC request | SERVER | <method> <target>, or the method alone, such as tools/call lookup_order or initialize |
| Incoming notification | SERVER | The method, such as notifications/initialized |
| Outgoing notification | CLIENT | The method, such as notifications/tools/list_changed |
| Transport error | INTERNAL | mcp.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:
| Attribute | Where |
|---|---|
mcp.method.name | Every span |
mcp.server.name, mcp.server.version | Every span |
mcp.client.name, mcp.client.version | From initialize onwards, including the initialize span |
mcp.tool.name | tools/call |
mcp.tool.result.is_error | tools/call responses |
rpc.jsonrpc.error_code, rpc.jsonrpc.error_message | Requests 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, plusmcp.tool.result.<i>.content_typeandmcp.tool.result.<i>.contentper content item.prompts/get:mcp.prompt.result.description,mcp.prompt.result.message_count, plusmcp.prompt.result.<i>.roleandmcp.prompt.result.<i>.contentper 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.