Docs / MCP Servers

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 / agentId matches the agent you configured in Nexus. Both end_span and end_trace must be called — an unclosed trace won't appear until it's ended.
Tool errors are not recorded
Make sure your except / catch block calls both end_span and end_trace with status="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 or query.slice(0, 200) in TypeScript). Nexus accepts up to 4 KB of metadata per span.