Build a local TypeScript server
Keep errors useful
Design validation, not-found, backend, and unexpected failures so callers can recover without receiving sensitive internals.
A good error tells the caller what to do next. A bad error tells an attacker how the server works. We want the first kind, every time.
MCP exposes failures at two levels.
Protocol errors mean the request could not be dispatched at all: an unknown method, a malformed message. Tool execution errors mean the tool ran but could not finish its job. We saw one in the previous lesson, the isError: true result for a missing note.
The SDK handles the first layer for us. It also validates our Zod input before the handler runs. What’s left for the handler is domain failure: a missing note, a database that isn’t answering, a backend that timed out.
Say what to do, not what broke
Here’s a tool error for a backend that’s down:
return {
content: [{
type: 'text',
text: 'The notes backend is temporarily unavailable. Try again later.'
}],
isError: true
}
Read it as the model would. It knows the call failed, it knows it can retry, and it knows nothing else. That’s the right amount.
Never return a stack trace, a SQL statement, an environment value, or the raw body of an upstream response. Those details belong in a diagnostic channel only you can read.
Unexpected failures get an incident ID
Sometimes the handler throws something you didn’t plan for. My approach is to generate a short incident ID, log the ID together with the real error, and return only the ID and a generic message:
const incidentId = crypto.randomUUID()
console.error(JSON.stringify({ event: 'tool_failure', incidentId, error: String(error) }))
return {
content: [{
type: 'text',
text: `Something went wrong. Reference: ${incidentId}`
}],
isError: true
}
The caller can report Reference: 4f1c... to you. You look it up in your logs and see the full picture. The caller never sees the internals.
Write the failure table down
I like to keep a small table in TESTING.md that says what should happen for each kind of bad call:
| Case | Handler runs? | Expected result |
|---|---|---|
| Empty query | No | Input validation error |
| Limit above ten | No | Input validation error |
| Unknown note ID | Yes | isError: true |
| Existing note ID | Yes | Structured success |
The second column is the one people get wrong. If you find yourself writing an if (query === '') check inside a handler, something is off. That case should have been stopped by the schema. Fix the schema, not the handler.
Useful errors reveal the safe next step. They never reveal the server’s insides.
Lesson completed