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:

CaseHandler runs?Expected result
Empty queryNoInput validation error
Limit above tenNoInput validation error
Unknown note IDYesisError: true
Existing note IDYesStructured 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