Send MCP Server Errors to Sentry for Real-Time Alerting
Kashish Hora
Co-founder of AgentCat
The quick answer
Here's the part most people get wrong: as of sentry-sdk 2.x, you don't have to capture MCP tool-handler exceptions by hand. The SDK ships an mcp integration that's on by default, and it already wraps every tool handler, captures anything that raises, and re-raises so the MCP SDK can still return an isError result to the model. For basic error reporting, a plain init is the whole job.
import os
import sentry_sdk
sentry_sdk.init(dsn=os.environ["SENTRY_DSN"], traces_sample_rate=1.0)
@mcp.tool()
async def get_forecast(city: str) -> str:
# No try/except needed. If this raises, the default mcp integration
# captures it and the MCP SDK still returns isError to the model.
return await weather_api.forecast(city)The integration catches the exception inside its handler wrapper, calls capture_exception for you, and re-raises (sentry-python integrations/mcp.py). That means the trap to watch for is the opposite of what you'd expect: if you also write your own capture_exception in the handler, you get two events for one error. The rest of this guide covers when manual capture actually earns its place, how to enrich events with the tool name and scrubbed arguments, and where Sentry fits next to AgentCat's own telemetry.
What the default integration does
If you've used Sentry with a web framework, you're used to unhandled exceptions showing up on their own. With MCP that almost didn't happen, because the MCP SDK deliberately turns a raised exception into a normal result instead of letting it bubble up. The mcp integration exists to bridge that gap, and it's worth understanding the mechanism before you decide whether you need anything beyond it.
MCP has two separate error channels, and this guide's companion, error handling in custom MCP servers, walks through the full distinction. The short version: protocol errors like an unknown tool or invalid params travel as JSON-RPC errors, while errors inside your tool logic come back as a normal result with isError: true so the model can read the failure and decide what to do (MCP tools spec).
The MCP SDK enforces that second channel for you, at least for the high-level API this guide's examples use. When your @mcp.tool() function raises, MCPServer catches it and returns a CallToolResult with isError=True instead of propagating the exception (python-sdk mcpserver/server.py) — the same behavior this class had before mcp 2.0.0, back when it was named FastMCP; the decorator API itself is unchanged. If you're using the lower-level mcp.server.lowlevel.Server directly instead, note that this changed in 2.0.0: a raised exception now propagates to the dispatcher as a JSON-RPC error rather than auto-converting to isError, so you catch it yourself and reserve raising MCPError for genuine protocol errors. On its own, that automatic isError conversion would still leave Sentry blind, because there's no unhandled error for an outer boundary to see.
Sentry closes the gap by wrapping the handler one level further out. The mcp integration patches the server's tool registration so that when your function raises, it captures the exception and then re-raises, letting the MCP SDK do its usual isError conversion afterward. Both audiences get served from a single raise: Sentry gets the traceback, and the model gets its error result. You can read the wrapper doing exactly that, capture_exception(e) followed by raise, in the integration source (sentry-python integrations/mcp.py).
Prerequisites
- A Sentry account with a project, and its DSN (Settings, then Client Keys)
- A running MCP server, ideally a production Streamable HTTP one, in Python (
mcp) or TypeScript (@modelcontextprotocol/sdk) - The Sentry SDK for your language:
pip install "sentry-sdk"ornpm install @sentry/node
The auto-capture in this guide is the mcp integration, which ships with sentry-sdk 2.x. If you're on an older release without it, the manual patterns further down are your path.
Initialize Sentry in your server
Call init once, as early in your process as you can, before the server starts handling requests (Sentry docs). For a Streamable HTTP server that's at module load or in your entry point.
import os
import sentry_sdk
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
environment=os.environ.get("ENVIRONMENT", "production"),
release=os.environ.get("GIT_SHA"),
traces_sample_rate=1.0,
)environment and release are the two options worth setting from day one: they let Sentry group issues by deployment and show you which release introduced a regression. traces_sample_rate turns on performance tracing; drop it below 1.0 on a busy server to sample. That's it for basic error reporting. With this in place, a tool that raises already shows up in your Issues tab.
Node is the same shape:
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.ENVIRONMENT ?? "production",
release: process.env.GIT_SHA,
tracesSampleRate: 1.0,
});When to capture by hand
The default integration covers the common case, so most servers never need a manual capture_exception. There are two situations where you'd reach for it:
- You're on an older sentry-sdk that predates the
mcpintegration, so nothing is capturing tool exceptions on your behalf. - You want to enrich the event with a custom scope, tags, or context tied to the specific call, which the automatic capture doesn't do for you.
The one thing to avoid is writing a manual capture_exception while the default integration is still on. Both fire on the same raise, and you get two events for one error:
@mcp.tool()
async def run_query(sql: str) -> str:
try:
return await db.execute(sql)
except Exception:
sentry_sdk.capture_exception() # double-reports: the mcp integration
raise # already captured this exceptionIf you genuinely want full manual control, turn the integration off at init and then do the capturing yourself. Pass it to disabled_integrations so it stops wrapping your handlers:
import sentry_sdk
from sentry_sdk.integrations.mcp import MCPIntegration
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
disabled_integrations=[MCPIntegration()],
)
@mcp.tool()
async def run_query(sql: str) -> str:
try:
return await db.execute(sql)
except Exception:
sentry_sdk.capture_exception() # now the only capture: exactly one event
raiseWith the integration disabled, raise still lets the MCP SDK hand the model an isError result, while your capture_exception() sends the one traceback you want. If you'd rather return a clean message to the model instead of re-raising, capture first, then return your own error result, the pattern the error handling guide covers.
Add context so the issue is actionable
A bare traceback tells you what broke but not what the model was doing. The two things you'll always want are the tool name and the arguments, and Sentry has direct APIs for both. set_tag makes a value filterable and searchable (Sentry docs); set_context attaches a structured block that shows up on the issue (Sentry docs).
With the default integration on, you don't call capture_exception yourself, you just decorate the event before it raises. Set the tag and context on the current scope inside your except, then re-raise; the integration captures the exception with your enrichment already attached:
@mcp.tool()
async def run_query(sql: str, params: dict) -> str:
try:
return await db.execute(sql, params)
except Exception:
scope = sentry_sdk.get_current_scope()
scope.set_tag("mcp.tool", "run_query")
scope.set_context("arguments", scrub({"sql": sql, "params": params}))
raise # the mcp integration captures, with these fields attachedIf you've turned the integration off for full manual control, do the capture yourself. Scope it to the one event with new_scope() so the tags don't leak onto later captures (Sentry docs). Then capture inside that scope:
except Exception:
with sentry_sdk.new_scope() as scope:
scope.set_tag("mcp.tool", "run_query")
scope.set_context("arguments", scrub({"sql": sql, "params": params}))
sentry_sdk.capture_exception()
raiseScrub before you send. Tool arguments routinely carry connection strings, tokens, and PII, and Sentry keeps whatever you hand it. A denylist on the keys covers the flat case:
SECRET_KEYS = {"password", "token", "api_key", "authorization", "secret"}
def scrub(args: dict) -> dict:
return {
k: ("***" if k.lower() in SECRET_KEYS else v)
for k, v in args.items()
}Be honest about what that does: it only inspects top-level keys, so a secret nested inside a dict (say {"config": {"password": "..."}}) sails straight through. If your arguments have any nesting, recurse:
def scrub(value):
if isinstance(value, dict):
return {
k: ("***" if k.lower() in SECRET_KEYS else scrub(v))
for k, v in value.items()
}
if isinstance(value, list):
return [scrub(v) for v in value]
return valueEven that is a floor, not a ceiling. Sentry can also strip sensitive data server-side and via its default PII rules, so pair app-level scrubbing with your project's data-scrubbing settings for defense in depth. If you have a session or user identity, sentry_sdk.set_user({"id": user_id}) attaches it so you can see who hit the error (Sentry docs).
Node instruments MCP a little differently. Instead of a default integration, @sentry/node gives you an explicit wrapper, Sentry.wrapMcpServerWithSentry(server), that instruments tool, resource, and prompt handlers on the server you pass it (Sentry docs). Wrap the server once after you create it, and the enrichment idea carries over: set your tag and context on the current scope inside the catch, or reach for Sentry.withScope plus Sentry.captureException when you want to capture by hand.
const server = Sentry.wrapMcpServerWithSentry(new McpServer(/* ... */));
server.registerTool("run_query", schema, async ({ sql, params }) => {
try {
return await runQuery(sql, params);
} catch (err) {
Sentry.withScope((scope) => {
scope.setTag("mcp.tool", "run_query");
scope.setContext("arguments", scrub({ sql, params }));
Sentry.captureException(err);
});
throw err;
}
});Where this sits versus returning isError
Landing in Sentry and returning isError are not competing choices; they serve two different audiences. isError is for the model in the loop right now, so it can retry, ask the user, or try another tool. Sentry is for you, later, so a real traceback and the arguments that triggered it are waiting when you go to fix it. A production handler serves both from one raise: the model gets its isError result and the exception lands in your Issues tab.
What you don't want is to leak internals to the model. The isError text goes straight into the model's context and, often, in front of a user, so keep it a plain sentence ("the query failed, check the table name") and let Sentry hold the stack trace and scrubbed arguments.
Sentry alongside AgentCat telemetry
Sentry's own MCP instrumentation already covers the operational layer well: which tools are slowest, which ones fail most, and how traffic splits across transports and clients (Sentry MCP dashboard). The layer it leaves out is the behavior around the error, meaning the session it belonged to, the actor who triggered it, and what they were trying to accomplish. AgentCat captures that, and it can forward events to your existing stack at the same time.
AgentCat's SDK takes an exporters option on track(), and one of them is a Sentry exporter that forwards AgentCat's telemetry events to Sentry as logs and, with tracing on, transactions:
import os
import agentcat
agentcat.track(server, "proj_YOUR_ID", agentcat.AgentCatOptions(
exporters={
"sentry": {
"type": "sentry",
"dsn": os.environ["SENTRY_DSN"],
"environment": "production",
}
},
))The Sentry exporter config is exactly type, dsn, environment, release, and enable_tracing (enableTracing in TypeScript), and nothing else. This exporter path is complementary to the tool-handler capture above: the mcp integration gives you the exceptions and stack traces you want to debug, and the AgentCat layer gives you the session and intent context around them. To fan out to more than one backend at once, see multi-platform MCP telemetry.
Common issues
The same error shows up twice
Two events for one raise means both the default mcp integration and your own capture_exception fired. Pick one: drop the manual call and let the integration handle it, or disable the integration with disabled_integrations=[MCPIntegration()] if you want the manual capture to be the only one.
Errors aren't showing up in Sentry
First check that the integration is actually loaded. It needs the mcp package importable at init time; if it isn't, the integration quietly opts out and nothing captures your tool exceptions. sentry_sdk.get_client().integrations lists what's active, and mcp should be in there. If it's missing, confirm mcp is installed in the same environment as your server.
If a specific handler still shows nothing, check that init actually ran before that handler was invoked. Configuration has to happen before the code you want to monitor runs (Sentry docs), so a late or conditional init will silently miss earlier events.
The DSN looks right but events never arrive
Sentry validates the DSN shape (https://<key>@<host>/<project_id>); a missing scheme or key fails quietly. Also check outbound HTTPS to Sentry's ingest host, corporate egress rules block it more often than you'd expect.
Sensitive data ended up in an issue
set_context and set_extra store whatever you pass, so scrub arguments before capture rather than after. Layer your app-level scrub with Sentry's server-side data-scrubbing rules, and be deliberate about send_default_pii, which controls whether Sentry attaches identifying request data on its own.
Related Guides
Error handling in custom MCP servers
The one distinction that governs MCP error handling: protocol errors are JSON-RPC errors that fail the request, while tool failures are ordinary results with isError set true so the model can see and recover from them.
Debugging message serialization errors in MCP protocol
Debug and fix MCP message serialization errors with proven troubleshooting techniques.
Monitor MCP Server Performance with OpenTelemetry
Connect MCP servers to any OpenTelemetry-compatible platform for distributed tracing and performance monitoring.