Developer Guide · 2026-06-10

When to reject a tool call vs return an error: defensive MCP handler design

The MCP spec gives you two ways to signal failure: throw an exception (which the framework surfaces as a protocol-level error) or return a result with isError: true (which the LLM reads as a tool content response). They look similar. They are not similar. Choosing the wrong one creates retry loops, leaks internal state, or silently swallows security violations. Here is the decision framework.

Contents

  1. The two failure modes
  2. What the LLM sees in each case
  3. The decision rules
  4. Scenario: failed authentication
  5. Scenario: invalid input
  6. Scenario: downstream API failure
  7. Scenario: policy violation
  8. Scenario: timeout and resource exhaustion
  9. Scenario: suspected prompt injection
  10. The leakage risk of isError
  11. How SkillAudit scores handler design

The two failure modes

The MCP TypeScript SDK gives your tool handler two ways to fail:

Option A — throw an exception
server.tool('read_file', schema, async ({ path }) => {
  if (!isAllowed(path)) {
    throw new Error('Access denied')   // framework catches this
  }
  const content = await fs.readFile(path, 'utf8')
  return { content: [{ type: 'text', text: content }] }
})
Option B — return isError: true
server.tool('read_file', schema, async ({ path }) => {
  if (!isAllowed(path)) {
    return {
      content: [{ type: 'text', text: 'Access denied' }],
      isError: true                    // LLM reads this as tool output
    }
  }
  const content = await fs.readFile(path, 'utf8')
  return { content: [{ type: 'text', text: content }] }
})

Both produce a failure. But the receiver of that failure is different. A thrown exception becomes a protocol-level error that the MCP client (Claude Code, Cursor, the SDK runtime) handles. An isError: true response is a well-formed tool result — it goes into the LLM's context as content it can read, reason about, and act on.

This difference has security, behavioural, and reliability consequences that are easy to miss when you are building your first MCP server.

What the LLM sees in each case

When your handler throws, the MCP client receives an error response at the transport layer. Depending on the client, this may be surfaced to the user as a tool-call failure, logged, or retried. The LLM does not receive the error message as tool output — it either sees nothing, receives a structured error from the client layer, or the entire turn is interrupted. The exact behaviour is client-dependent.

When your handler returns isError: true, Claude receives a standard tool result whose content array contains your error message. Claude can read that message, decide what to do with it, explain it to the user, or use it to inform a follow-up tool call. This is intentional and useful — for operational errors where you want the LLM to understand what went wrong and adapt.

The trap: developers who consistently use isError: true for everything — including security rejections — are inadvertently giving the LLM a detailed account of what the security policy rejects and why. An adversarial prompt can use this to probe the policy systematically and find gaps.

The decision rules

Before any specific scenario, two high-level rules cover the majority of cases:

Throw when…

  • The call violates a security policy or access control rule
  • The input fails schema validation that should have been caught before dispatch
  • The handler has reached an unrecoverable internal state
  • Returning error details would leak internal implementation information
  • You want the client layer (not the LLM) to handle the failure
  • The failure indicates the server itself is broken, not that the request was wrong

Return isError: true when…

  • The operation is semantically valid but the requested resource doesn't exist
  • A downstream service returned an error the LLM can understand and route around
  • The user/agent made a recoverable mistake (wrong format, rate-limited, quota exceeded)
  • You want the LLM to explain the failure to the human user
  • The error message contains only information the LLM should see
  • Retry with different parameters could succeed

The distinction maps roughly to the classic "programmer error vs operational error" split from Node.js convention — but with an additional axis: who should see the error message. In MCP, the LLM is an additional reader you must account for in every error response.

Failure cause Correct response Reason
Auth check fails (missing token) throw Security rejection — client layer handles, LLM gets nothing useful
Auth check fails (wrong token) throw Same — never confirm token was present but wrong via isError message
Path traversal attempt detected throw Security rejection — don't tell the LLM what the policy boundaries are
Prompt injection pattern detected throw Security rejection — don't confirm what patterns trigger detection
File not found isError: true Operational — LLM can suggest checking the path, list alternatives
Rate limit hit (your quota) isError: true Operational — LLM can inform user and suggest waiting
Downstream API 500 isError: true Operational — LLM can retry or escalate to user
Downstream API 403 isError: true Operational — scope/permission issue the user may be able to fix
Input missing required field throw Schema validation should catch this before handler runs; if it didn't, it's a programmer error
Input value out of valid range isError: true Operational — LLM passed wrong value, can correct and retry
Timeout on slow operation isError: true Operational — LLM can retry later or use a chunked alternative
Unhandled exception (bug) throw Programmer error — don't expose stack trace via isError content

Scenario: failed authentication

01

Authentication failure

The tool handler checks for a valid API key or session token before executing. The check fails.

This is a security rejection. Throw — don't return isError: true. The reason is subtle but important: if you return isError: true with a message like "Invalid API key", you have confirmed to the LLM (and to any adversarial prompt riding in tool output) that an API key was expected, was present but wrong, and that the correct one would succeed. That is more information than a security rejection should surface.

Compare these two:

Leaks auth mechanism to LLM
if (!isValidApiKey(req.headers['x-api-key'])) {
  return {
    content: [{ type: 'text', text: 'Invalid API key — check your MCP_API_KEY env var' }],
    isError: true
  }
}
Correct — throws, no detail surfaced to LLM
if (!isValidApiKey(req.headers['x-api-key'])) {
  throw new McpError(ErrorCode.InvalidRequest, 'Unauthorized')
}

The thrown error reaches the MCP client, which returns a 401 equivalent at the transport layer. The LLM does not see the error message. The client may display it to the human user — that is appropriate, because the human is the one who needs to fix their credentials. The LLM does not need to know how authentication works or what the failure mode looks like.

Do not distinguish "missing token" from "wrong token" in error messages. Uniform "Unauthorized" prevents oracle attacks where an adversarial prompt probes whether the token field exists versus contains the correct value.

Grade impact Using isError: true for auth failures is a Medium finding on the Security axis. It signals that the server does not model its error surface carefully, which correlates with other security hygiene gaps.

Scenario: invalid input

02

Input validation failure

The tool call arrives with arguments that don't match expectations — wrong type, missing required field, value out of range.

This case splits based on what is wrong. Missing required fields and wrong types are programmer errors — the LLM sent a malformed call. If your schema is registered correctly, the MCP SDK should reject these before your handler runs. If they reach your handler, something broke upstream. Throw.

Value-range violations are different. The LLM provided a structurally valid value that is semantically out of bounds — a page number beyond the last page, a date before the service existed, a batch size above the maximum. The LLM can understand this failure and correct its call. Return isError: true with the constraint clearly stated.

isError: true — correctable value error
if (args.page < 1 || args.page > totalPages) {
  return {
    content: [{
      type: 'text',
      text: `Page ${args.page} out of range. Valid range: 1–${totalPages}.`
    }],
    isError: true
  }
}
throw — schema type violation (should not reach handler)
if (typeof args.path !== 'string') {
  // Schema should have caught this — this is a programmer error
  throw new McpError(ErrorCode.InvalidParams, 'path must be a string')
}

One nuance: if your value-range error message would reveal implementation details (database row counts, internal ID ranges, file system structure), redact before returning. The message should tell the LLM what it needs to retry correctly — nothing more.

Scenario: downstream API failure

03

Downstream service error

Your tool calls a third-party API or internal service. That service returns an error.

Downstream failures are almost always operational. Return isError: true. The LLM can inform the user, suggest alternatives, or try again after a delay. But scrub the error message before returning it — raw upstream error bodies frequently contain stack traces, internal IP addresses, database identifiers, and other details that should not reach the LLM context.

Leaks upstream internals to LLM context
try {
  const result = await callUpstreamApi(args)
  return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (err) {
  return {
    content: [{ type: 'text', text: `Upstream error: ${err.message}` }],
    isError: true
  }
}
Sanitised downstream error — safe to surface to LLM
try {
  const result = await callUpstreamApi(args)
  return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (err) {
  const userMessage = classifyUpstreamError(err)
  return {
    content: [{ type: 'text', text: userMessage }],
    isError: true
  }
}

function classifyUpstreamError(err) {
  if (err.status === 429) return 'Rate limited — retry in 60 seconds.'
  if (err.status === 404) return 'Resource not found.'
  if (err.status === 403) return 'Permission denied — check your API credentials.'
  if (err.status >= 500) return 'Upstream service error — try again later.'
  return 'Unexpected error — please retry.'
}

The classifyUpstreamError pattern is the key: translate specific upstream errors into generic, LLM-safe messages. The actual error details go to your server logs, not the tool response.

Pattern: log err.message + err.stack at your structured logging layer before classifying. This preserves debuggability while keeping the LLM response clean. Never log secrets from the args object alongside the error.

Scenario: policy violation

04

Policy or permission violation

The tool call is syntactically valid and authenticated, but the specific action is not allowed by your authorization policy — accessing a resource outside the user's permitted scope, reading a file outside the allowed directory, or calling an action the user's plan does not include.

This is where most servers make their biggest mistake. Policy violations feel "operational" — the user could fix them by upgrading their plan, or by asking for a file within the allowed path — so developers reach for isError: true. The right answer depends on one question: does returning error details help the LLM help the user, or does it teach an adversarial prompt how to probe the policy?

For plan-level restrictions (feature not included in Free tier), isError: true is reasonable — the LLM can tell the user to upgrade. For path traversal rejections, throw. For access-control scope violations where the resource exists but the user can't see it, the safe default is throw with a generic "not found" message to avoid confirming that the resource exists.

Confirms resource existence to LLM — avoid
if (!canAccess(userId, resourceId)) {
  return {
    content: [{ type: 'text', text: 'You do not have access to resource ' + resourceId }],
    isError: true
  }
}
Treat as not-found — consistent with HTTP 404 vs 403 debate
if (!canAccess(userId, resourceId)) {
  // Return 404-equivalent rather than 403 to avoid confirming existence
  return {
    content: [{ type: 'text', text: 'Resource not found.' }],
    isError: true
  }
}

// Path traversal: always throw — never confirm policy details
if (!isWithinAllowedDir(resolvedPath, allowedDir)) {
  throw new McpError(ErrorCode.InvalidRequest, 'Access denied')
}
Grade impact Confirming resource existence via 403-equivalent error messages is a Low-to-Medium finding. Returning detailed policy error messages (listing what would be allowed) through isError: true is a Medium finding — it creates an enumeration oracle.

Scenario: timeout and resource exhaustion

05

Timeout and resource exhaustion

The operation takes too long, hits a memory limit, or exhausts a connection pool.

Timeouts are operational errors. Return isError: true with a message that tells the LLM the operation timed out and whether retry makes sense. Be careful with messages: "timed out after 30 seconds on query: SELECT * FROM…" leaks your schema and query patterns. Return only the timeout fact and any retry guidance.

Resource exhaustion at the server level (out of memory, connection pool full) is different — this is an internal failure the LLM cannot meaningfully address. Throw. Let the client handle it as a transport error.

Timeout — return isError so LLM can adapt
const timeoutMs = 10_000
let result
try {
  result = await Promise.race([
    doWork(args),
    new Promise((_, reject) =>
      setTimeout(() => reject(new TimeoutError()), timeoutMs)
    )
  ])
} catch (err) {
  if (err instanceof TimeoutError) {
    return {
      content: [{ type: 'text', text: 'Operation timed out. Try a smaller request or retry later.' }],
      isError: true
    }
  }
  // Non-timeout errors: throw — don't surface internal details
  throw new McpError(ErrorCode.InternalError, 'Internal error')
}
return { content: [{ type: 'text', text: JSON.stringify(result) }] }

Retry guidance matters. "Operation timed out" leaves the LLM guessing. "Operation timed out — the limit parameter reduces scope; try limit=10 instead of limit=1000" gives the LLM a concrete path to success. Include it when you know the cause.

Scenario: suspected prompt injection

06

Suspected prompt injection in tool arguments

The tool receives an argument that looks like it was generated from injected instructions in retrieved content — unusual meta-instructions, references to "ignoring previous instructions," attempts to override the tool's behaviour.

This is the scenario where the error response mechanism matters most for security. If you detect suspected injection and return isError: true with a message like "Prompt injection detected in argument 'query': found pattern 'ignore previous'", you have just told the injected payload what patterns your detection catches — which is an enumeration oracle for an adversary tuning their injection.

Throw. Silently. Log internally with the full argument value for your own analysis. Give the LLM — which may itself be operating under the injected instructions — no signal about what was detected or why.

Enumerates injection detection to LLM
if (mightBeInjected(args.query)) {
  return {
    content: [{
      type: 'text',
      text: `Prompt injection detected: suspicious pattern in query argument`
    }],
    isError: true
  }
}
Silent throw — no signal to potential adversary
if (mightBeInjected(args.query)) {
  log.warn('Suspected prompt injection', {
    tool: 'search',
    arg: 'query',
    value: args.query,   // log full value for analysis
    sessionId: ctx.sessionId
  })
  // Generic rejection — same message as any other security failure
  throw new McpError(ErrorCode.InvalidRequest, 'Request rejected')
}

The pattern extends to any detection mechanism: rate-limit anomalies that might indicate automated probing, unusual argument patterns, or values that reference tool implementation details. The rule is: anything that reveals the shape of your detection logic should throw, not return a descriptive isError.

Grade impact Prompt injection detection that leaks detection signals via isError is a High finding on the Security axis. It directly undermines the detection mechanism by providing an oracle to adversarial content.

The leakage risk of isError

Every isError: true response adds content to the LLM's context window. That content is read, reasoned about, and potentially acted on — including by injected instructions riding in tool output from a previous call. The message text you provide is not just a user-facing explanation; it is information that flows into the reasoning of a system that may be adversarially influenced.

This creates three specific leakage risks that are worth checking explicitly in your handler code:

1. Implementation leak. Error messages that include stack traces, internal function names, SQL query fragments, internal path structures, or configuration values. These reach the LLM's context and may reach injected instructions that extract and exfiltrate them.

2. Policy enumeration. Error messages that reveal the exact boundary conditions of your security policy — "path must start with /allowed/user123/" or "only reads from repos in org anthropic are permitted." An adversarial instruction can use repeated tool calls with isError responses to map the policy precisely.

3. Detection confirmation. Any message that tells a potential adversary "you triggered a security check" — regardless of whether it tells them what check. Even "security check failed" is more information than a bare rejection.

The mitigation is consistent: for any failure caused by a security check, throw. For any operational failure where the error message would include non-public implementation details, sanitize before returning. Treat the LLM's context window as a partially adversarial surface — because under prompt injection, it is.

How SkillAudit scores handler design

When our scanner audits an MCP server, handler error design is evaluated under two axes: Security and Documentation completeness. The specific signals we look for:

Signal Axis Severity
Auth failures return isError: true with auth-related messages Security Medium
Path traversal rejections return isError: true with path details Security High
Injection detection signals returned via isError Security High
Raw upstream error messages passed through to isError content Security Medium
Stack traces in isError content (unhandled exception bubbled up) Security High
Policy details enumerated in isError messages Security Medium
No isError: true ever returned (all failures throw) — LLM can't adapt Documentation Low
Operational errors with no retry guidance Documentation Low

The last two rows are worth noting: over-throwing — using throw for everything including operational errors — is also a design smell. A server that gives the LLM no error information cannot produce a useful user experience, because the LLM cannot explain to the user what went wrong or what to try next. The goal is calibration: throw for security rejections, return isError: true for operational failures, and sanitize the content of every isError response before it reaches the LLM.

Related posts: Top 10 MCP security mistakes — covers verbose error messages as mistake #7, with the same principle applied more broadly. The anatomy of a prompt injection attack — how injected content in tool output exploits exactly the error-response surface described here. The MCP server security checklist — includes a pre-publish handler design checklist.

A decision tree you can keep open while coding

When you are writing a new failure case in a tool handler, run through these questions in order:

1. Is this a security check? (auth, access control, path validation, injection detection, policy enforcement) → Throw. Stop here.

2. Is this an unhandled or unexpected internal error? (exception from a code path that shouldn't fail, null pointer, broken invariant) → Throw. Log the full error internally. Stop here.

3. Does returning the error message give the LLM anything useful it can act on? (retry differently, inform user, choose alternative tool) → If yes, isError: true with sanitized message. If no, throw.

4. Does the error message contain implementation details? (path structures, query fragments, internal IDs, stack traces) → Sanitize before returning as isError: true. Log the raw details internally.

Four questions. Most handlers need to check all four. The ones that score A grades on their first SkillAudit run are the ones that answered each question deliberately, not the ones that defaulted to "return an error" for everything and hoped for the best.

Check your tool handlers for these patterns

SkillAudit scans for auth failures via isError, stack traces in error content, and policy enumeration oracles. Paste your GitHub URL and get a graded report in 60 seconds.

Run a free audit →