MCP Server Observability
Add trace-level observability to your Model Context Protocol server tool handlers — in Python (FastMCP) or TypeScript (@modelcontextprotocol/sdk) — in minutes.
Overview
Model Context Protocol (MCP) is an open standard for connecting AI assistants to external tools and data sources. Each tool handler in your MCP server is a unit of work worth measuring: latency, input, and whether it succeeded or errored.
Nexus wraps each tool handler with a trace and span, giving you per-tool latency breakdowns, input previews, error rates, and a full timeline in your dashboard — with no changes to the MCP protocol itself.
What you get
- Per-tool invocation latency and error rates
- Tool name and input previews in span metadata
- Full trace timeline across multi-tool agent sessions
- Works with stdio, SSE, and HTTP transports
Python — FastMCP
Install the packages:
pip install mcp fastmcp nexus-agent
Initialize both clients at the top of your server file:
import nexus_agent
import fastmcp
nexus = nexus_agent.Nexus(api_key="YOUR_NEXUS_API_KEY", agent_id="my-mcp-server")
mcp = fastmcp.FastMCP("my-server")
Wrap each tool handler with a trace and span:
@mcp.tool()
def search_docs(query: str) -> str:
"""Search the documentation for relevant content."""
trace = nexus.start_trace(name="mcp_tool", metadata={"tool": "search_docs"})
span = nexus.start_span(
trace_id=trace["trace_id"],
name="search_docs",
metadata={"tool": "search_docs", "query": query[:200]},
)
try:
result = _do_search(query)
nexus.end_span(span_id=span["id"], status="success", metadata={"result_length": len(result)})
nexus.end_trace(trace_id=trace["trace_id"], status="success")
return result
except Exception as e:
nexus.end_span(span_id=span["id"], status="error", metadata={"error": str(e)})
nexus.end_trace(trace_id=trace["trace_id"], status="error")
raise
if __name__ == "__main__":
mcp.run()
The metadata dict on the span captures the tool name and a truncated preview of the input.
End the span and trace in both the success and error paths so every invocation is recorded.
Decorator helper (Python)
If you have many tools, extract the tracing logic into a reusable decorator so each handler stays clean:
import functools
from typing import Callable, Any
def trace_tool(tool_name: str):
"""Decorator that wraps any FastMCP tool handler with Nexus tracing."""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
trace = nexus.start_trace(name="mcp_tool", metadata={"tool": tool_name})
span = nexus.start_span(
trace_id=trace["trace_id"],
name=tool_name,
metadata={"tool": tool_name, "kwargs": str(kwargs)[:300]},
)
try:
result = fn(*args, **kwargs)
nexus.end_span(span_id=span["id"], status="success")
nexus.end_trace(trace_id=trace["trace_id"], status="success")
return result
except Exception as e:
nexus.end_span(span_id=span["id"], status="error", metadata={"error": str(e)})
nexus.end_trace(trace_id=trace["trace_id"], status="error")
raise
return wrapper
return decorator
@mcp.tool()
@trace_tool("search_docs")
def search_docs(query: str) -> str:
return _do_search(query)
TypeScript — @modelcontextprotocol/sdk
Install the packages:
npm install @modelcontextprotocol/sdk @keylightdigital/nexus
Initialize the Nexus client and MCP server:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import Nexus from '@keylightdigital/nexus'
const nexus = new Nexus({ apiKey: 'YOUR_NEXUS_API_KEY', agentId: 'my-mcp-server' })
const server = new McpServer({ name: 'my-server', version: '1.0.0' })
Add a traced tool handler and start the transport:
import { z } from 'zod'
server.tool(
'search_docs',
{ query: z.string().describe('Search query') },
async ({ query }) => {
const trace = await nexus.startTrace({ name: 'mcp_tool', metadata: { tool: 'search_docs' } })
const span = await nexus.startSpan({
traceId: trace.traceId,
name: 'search_docs',
metadata: { tool: 'search_docs', query: query.slice(0, 200) },
})
try {
const result = await doSearch(query)
await nexus.endSpan({ spanId: span.id, status: 'success', metadata: { resultLength: result.length } })
await nexus.endTrace({ traceId: trace.traceId, status: 'success' })
return { content: [{ type: 'text', text: result }] }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
await nexus.endSpan({ spanId: span.id, status: 'error', metadata: { error: msg } })
await nexus.endTrace({ traceId: trace.traceId, status: 'error' })
throw err
}
}
)
const transport = new StdioServerTransport()
await server.connect(transport)
The pattern mirrors Python: start a trace and span before your tool logic, end both in try/catch, and record the tool name and input as span metadata. The MCP transport (stdio, SSE, or HTTP) doesn't matter — Nexus reports over HTTPS to your dashboard regardless.
Troubleshooting
- Traces not appearing in the dashboard
- Check that
agent_id/agentIdmatches the agent you configured in Nexus. Bothend_spanandend_tracemust be called — an unclosed trace won't appear until it's ended. - Tool errors are not recorded
- Make sure your
except/catchblock calls bothend_spanandend_tracewithstatus="error"before re-raising. Uncaught exceptions leave traces open. - Large inputs in metadata
- Truncate long inputs before adding them to metadata (e.g.,
query[:200]in Python orquery.slice(0, 200)in TypeScript). Nexus accepts up to 4 KB of metadata per span.