Observability patterns using Sentry across the MCP server.
Different Sentry SDKs for different environments:
- Core server:
@sentry/core(platform-agnostic) - Cloudflare Workers:
@sentry/cloudflare - Node.js stdio:
@sentry/node. The stdio server always reports to the Sentry CLI's project (SENTRY_CLI_DSN) and honors the CLI's opt-outs (SENTRY_CLI_NO_TELEMETRY=1,DO_NOT_TRACK=1, orsentry cli defaults telemetry off). It never readsSENTRY_DSN: a server started from a project shell or IDE would inherit the app's DSN. - React client:
@sentry/react
See logIssue in packages/mcp-core/src/telem/logging.ts (documented in Logging Reference) for the canonical way to create an Issue and structured log entry.
For event schema failures, see Event Validation Diagnostics
for the contextType diagnostic and its privacy constraints.
export async function createTracedToolHandler<T extends ToolName>(
name: T,
handler: ToolHandlerFunction<T>,
): Promise<[T, ToolHandlerFunction<T>]> {
return [
name,
async (context: ServerContext, params: ToolParams<T>) => {
const attributes = {
"gen_ai.tool.name": name,
...extractMcpParameters(params),
};
return await withActiveSpan(`tools/call ${name}`, attributes, async () =>
handler(context, params),
);
},
];
}Call setTargetTagsAndAttributes() from src/telem/scope.ts after
resolving the organization, including from URLs. It sets the Sentry
resources that the tool call targets as scope attributes for streamed spans,
logs, and metrics, and as tags for errors. It requires organizationSlug.
All other Target fields are optional, such as projectSlug, teamSlug,
issueId, or traceId, so a tool can pass its params directly when they
use these names. A numeric projectSlugOrId becomes project.id, any other
value project.slug. To send a new kind of resource, add a field to
Target and its key to targetKeys.
For other searchable values that are not a target, such as client.id or
doc.path, put the keys in one object and pass it to both setTags and
setAttributes from @sentry/core. SDK v11 no longer copies scope tags
onto spans, so do not call setTag alone.
For values set at startup, put the same keys in both initialScope.tags and
initialScope.attributes of Sentry.init.
async function withActiveSpan<T>(
name: string,
attributes: Record<string, any>,
fn: () => Promise<T>,
): Promise<T> {
const activeSpan = getActiveSpan();
const span = activeSpan?.startSpan(name) ?? startInactiveSpan({ name });
span.setAttributes(attributes);
try {
return await fn();
} catch (error) {
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
}// sentry.config.ts
export default function getSentryConfig(env: Env, context: ExecutionContext) {
return {
dsn: env.VITE_SENTRY_DSN,
environment: env.VITE_SENTRY_ENVIRONMENT || "development",
context,
integrations: [Sentry.rewriteFramesIntegration({ root: "/" })],
beforeSend(event) {
// Redact sensitive data
if (event.request?.headers?.authorization) {
event.request.headers.authorization = "[REDACTED]";
}
return event;
},
};
}export default Sentry.withSentry((env) => getSentryConfig(env), {
async fetch(request, env, ctx): Promise<Response> {
// Attach OAuth provider to request
request.OAUTH_PROVIDER = oAuthProvider.configure(/* ... */);
return app.fetch(request, env, ctx);
},
});// Init at startup
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: SENTRY_CLI_DSN,
enabled: isTelemetryEnabled(),
environment: process.env.NODE_ENV || "production",
integrations: [Sentry.nodeProfilingIntegration()],
tracesSampleRate: 0.1,
profilesSampleRate: 0.1,
});
// Handle uncaught errors
process.on("uncaughtException", (error) => {
Sentry.captureException(error);
process.exit(1);
});Sentry follows OpenTelemetry semantic conventions for consistent observability.
Based on the current OpenTelemetry MCP conventions, the standard MCP semantic attributes are:
mcp.method.name- The name of the request or notification method (e.g., "notifications/cancelled", "initialize", "tools/call")mcp.protocol.version- The MCP protocol version (e.g., "2025-06-18")mcp.resource.uri- The value of the resource URI (e.g., "postgres://database/customers/schema", "file:///home/user/documents/report.pdf")mcp.session.id- Identifies MCP session (e.g., "191c4850af6c49e08843a3f6c80e5046")
Current MCP spans also use related OpenTelemetry attributes outside the mcp.* namespace:
gen_ai.prompt.name- The prompt or prompt template namegen_ai.tool.name- The tool namejsonrpc.request.id- The JSON-RPC request IDrpc.response.status_code- The JSON-RPC response status or error codeerror.type- Why a failed tool span failed:tool_errorfor anisErrortool result (set by the Sentry SDK), orunknown_toolfor a call to a tool name the server does not registernetwork.transport- Transport protocol (pipefor stdio,tcporquicfor HTTP)gen_ai.tool.call.arguments.<key>- Per-key effective tool arguments after constraintsgen_ai.tool.call.result- Tool result payload when explicitly recorded for low-risk structured outputsgen_ai.tool.call.result.count- Sentry MCP extension for result counts, including zero-result tool calls
These are Sentry MCP application attributes that are not part of the MCP semantic convention:
app.resource.type- Resolved Sentry resource type forget_sentry_resourceapp.transport- The product transport label (values: "http", "sse", "stdio")app.client.family- Low-cardinality MCP client bucket derived from User-Agent or registered client nameapp.constraint.organization_slug- Session organization constraintapp.constraint.project_slug- Session project constraintapp.server.mode.experimental- Whether experimental tools are enabled for this MCP request or stdio sessionapp.server.distribution- stdio only:@sentry/mcp-serverfor the npm package,sentry mcpfor the CLI commandapp.consent.skill.<skill>.granted- Per-skill boolean attributes for skills granted to the MCP request. Skill ids are normalized with-replaced by_app.upstream.host- Upstream Sentry host configured for the serverapp.url.full- MCP URL configured for stdio
Following OpenTelemetry semantic conventions for user agent:
user_agent.original- The original User-Agent header value from the client
Cloudflare Transport: Captured from the initial SSE/WebSocket connection request headers and cached for the session
Based on OpenTelemetry network conventions:
network.transport- Transport protocol used ("pipe" for stdio, "tcp" for SSE)
Based on OpenTelemetry GenAI conventions:
gen_ai.provider.name- The AI provider name (e.g., "anthropic")gen_ai.request.model- Name of the GenAI modelgen_ai.request.max_tokens- Maximum tokens to generategen_ai.operation.name- Type of operation (e.g., "chat")gen_ai.usage.input_tokens- Number of tokens in inputgen_ai.usage.output_tokens- Number of tokens in response
Follows the format: {mcp.method.name} {target} per OpenTelemetry MCP semantic conventions
- Tools:
tools/call {tool_name}(e.g.,tools/call find_issues) - Prompts:
prompts/get {prompt_name}(e.g.,prompts/get analyze-code) - Resources:
resources/readby default. Do not include resource URIs in span names unless explicitly opted in because they can be high-cardinality. - Connect/auth spans may use local names such as
mcp.connect/stdioandmcp.auth/oauth; these are span names, not semantic attribute namespaces.
Note: Span names are flexible and can be adjusted based on your needs. However, attribute names MUST follow the OpenTelemetry semantic conventions exactly as specified above.
// MCP Tool Execution
{
"gen_ai.tool.name": "find_issues",
"mcp.session.id": "191c4850af6c49e08843a3f6c80e5046"
}
// GenAI Agent
{
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": "claude-3-5-sonnet-20241022",
"gen_ai.operation.name": "chat",
"gen_ai.usage.input_tokens": 150,
"gen_ai.usage.output_tokens": 2048
}
// Connection
{
"network.transport": "pipe", // "pipe" for stdio, "tcp" for SSE
"mcp.session.id": "191c4850af6c49e08843a3f6c80e5046",
"app.transport": "stdio", // Custom attribute: "stdio" or "sse"
"service.version": "1.2.3" // Version of the MCP server/client
}
// MCP Client
{
"mcp.session.id": "191c4850af6c49e08843a3f6c80e5046",
"network.transport": "pipe", // "pipe" for stdio, "tcp" for SSE
"app.transport": "stdio", // Custom attribute: "stdio" or "sse"
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": "claude-3-5-sonnet-20241022",
"gen_ai.operation.name": "chat",
"service.version": "1.2.3" // Version of the MCP client
}UserInputError- Expected user errors- 4xx API responses (except 429)
- Validation errors
- 5xx errors
- Network failures
- Unexpected exceptions
- Rate limit errors (429)
For Cloudflare HTTP traffic we send custom Metrics product counters through the JavaScript SDK metrics API:
app.server.response- Count of tracked HTTP responses
Shared low-cardinality attributes:
http.request.method- HTTP methodhttp.route- Normalized route templatehttp.response.status_code- Final HTTP status codeapp.response.status_class- Status family such as2xxor4xxapp.route.group- Coarse route family:mcp,oauth,chat, orsearch
Additional attributes on MCP response metrics:
app.client.family- Bucketed MCP client User-Agentapp.server.mode.experimental-truewhen the MCP URL includes?experimental=1
Additional attributes on direct OAuth client endpoints:
app.client.family- Bucketed client User-Agent on/oauth/tokenand/oauth/register
Optional local rate-limit attributes:
app.response.reason-local_rate_limitapp.rate_limit.scope-iporuser
Optional OAuth error attributes:
app.access.method- Access path:mcp_grantorsentry_accessapp.access.error.code- Bounded OAuth error code such asinvalid_access,invalid_grant, orotherapp.access.error.reason- Low-cardinality description bucket such asinvalid_accessorgrant_not_foundapp.access.request.header_shape- 401 Authorization header shape such asmissing,wrapper, ormalformedapp.access.grant.id_hash- Non-secret grant fingerprint on grant lifecycle logsapp.access.grant.age_bucket- Bounded MCP grant age at refresh/revocationapp.access.upstream.expires_in_bucket- Bounded remaining time before the original upstream Sentry expiry at refresh/revocation
Interpretation:
- Use
sum(app.server.response)grouped byhttp.routeandhttp.response.status_codefor response rates - Use
sum(app.server.response)filtered to the MCP route and grouped byapp.server.mode.experimentalfor mode adoption - Use
sum(app.server.response)filtered byapp.response.reason=local_rate_limitto measure when we rate-limited the customer - Use
sum(app.server.response)filtered byapp.access.error.code=invalid_accessand grouped byapp.client.family,app.access.error.reason, andapp.access.request.header_shapefor OAuth failure attribution - Upstream/provider 429s increment
app.server.responsewith status429, but do not includeapp.response.reason
{
tracesSampleRate: 0.1, // 10% in production
profilesSampleRate: 0.1, // 10% of traces
}# Cloudflare (build-time)
VITE_SENTRY_DSN=https://...@sentry.io/...
VITE_SENTRY_ENVIRONMENT=production
# Node.js stdio (runtime): telemetry goes to the CLI's project; opt out with
SENTRY_CLI_NO_TELEMETRY=1- Context is King: Always include relevant context
- Redact Secrets: Never log tokens or sensitive data
- Sample Wisely: Use appropriate sampling rates
- Tag Everything: Use consistent tags for filtering
- Skip Expected Errors: Don't pollute with user errors
- Core logging:
packages/mcp-core/src/logging.ts - Worker config:
packages/mcp-cloudflare/src/server/sentry.config.ts - Tracing helpers:
packages/mcp-core/src/tracing.ts - Sentry docs: https://docs.sentry.io/