MCP tools that let exceptions propagate unhandled either crash the server process or produce an opaque JSON-RPC internal error (-32603) with no actionable detail. CWE-703 (improper check of exceptional conditions) is the root; CWE-209 (generation of error messages with sensitive information) is the follow-on risk when raw stack traces leak internal file paths and dependency versions into tool output. The correct MCP pattern is to catch exceptions inside the handler and return isError: true with a descriptive, scrubbed message — keeping the server alive and giving the AI something useful to act on.
High because unhandled exceptions crash the server or surface raw stack traces, taking down all tools simultaneously and potentially exposing internal implementation details.
Wrap every tool handler body in a try/catch. Return isError: true with a clean error message — never rethrow.
// src/tools/query.ts
server.tool('query_db', ..., async ({ sql }) => {
try {
const result = await db.query(sql)
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (error) {
// Return error as content — do not rethrow
return {
content: [{ type: 'text', text: `Query failed: ${(error as Error).message}` }],
isError: true,
}
}
})
Strip stack traces before including error messages in the response. Expose the error type and message, not the stack or internal paths.
ID: mcp-server.error-resilience.tool-error-catch
Severity: high
What to look for: Count all tool handler functions. Enumerate which have try/catch blocks or error boundaries vs. which let exceptions propagate unhandled. Examine every tool handler function. Check that each handler has try/catch (or equivalent error handling). When an error occurs inside a tool handler, the MCP pattern is to return a normal result with isError: true and the error message in the content — not to throw an unhandled exception (which would crash the server or produce a JSON-RPC error). Check that error messages are user-readable, not raw stack traces.
Pass criteria: Every tool handler has error handling that catches exceptions and returns isError: true with a descriptive error message in the content array. No unhandled exceptions escape tool handlers. 100% of tool handlers must have error handling that catches exceptions and returns structured errors.
Fail criteria: Tool handlers have no try/catch (exceptions crash the server), or errors are caught but re-thrown as JSON-RPC errors instead of returned as isError: true, or error messages expose raw stack traces.
Skip (N/A) when: The server registers no tools. All checks skip when no MCP server is detected.
Do NOT pass when: Tool handlers catch errors but return them as plain text strings instead of structured MCP error responses.
Cross-reference: For structured error format, see structured-errors. For malformed input handling, see malformed-input.
Detail on fail: "4 of 6 tool handlers have no try/catch — any error will crash the server or produce an opaque JSON-RPC error instead of a readable message" or "Tool 'query_db' catches errors but returns the full stack trace in content — exposes internal paths and implementation details"
Remediation: Tool errors should be returned as content, not thrown:
// src/tools/search.ts — tool with error handling
try { const results = await search(args.query); return { content: [{ type: "text", text: JSON.stringify(results) }] } }
catch (e) { return { content: [{ type: "text", text: "Error: " + e.message }], isError: true } }
server.tool('query_db', ..., async ({ sql }) => {
try {
const result = await db.query(sql)
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (error) {
return {
content: [{ type: 'text', text: `Query failed: ${error.message}` }],
isError: true,
}
}
})
@mcp.tool()
async def query_db(sql: str) -> str:
try:
result = await db.execute(sql)
return json.dumps(result)
except Exception as e:
# Return error as content, not as exception
raise McpError(ErrorCode.InternalError, f"Query failed: {str(e)}")