Skip to main content
Version: 0.1.0

Centralized Error Handling

This page documents the MCP server's error system, including the ToolResponse envelope, strict error codes, and the @handle_tool_errors decorator.

Summary

All MCP tools return a uniform envelope at runtime: ToolResponse[T] containing ok, result, and issues[].

Rationale (Problem Details + AIP-193)

Our error design follows the spirit of RFC 9457 (Problem Details) and Google AIP-193 canonical codes. We map upstream HTTP failures into concise, machine-readable ToolIssue codes so agents can reason and retry without parsing vendor payloads. The envelope is token-lean compared to full upstream dumps.

  • RFC 9457: Problem Details for HTTP APIs
  • Google AIP-193: Standard error codes and semantics

Envelope shape

ToolResponse (example)
{
"ok": false,
"result": null,
"issues": [
{
"code": "RATE_LIMIT",
"message": "Rate limit exceeded",
"retry_after_ms": 3000,
"details": {"status_code":429}
}
]
}
  • ok: boolean success flag.
  • result: typed payload when ok: true.
  • issues[]: machine-readable codes with optional retry_after_ms.
Pydantic v2 models (simplified)
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel

T = TypeVar("T")

class ToolIssue(BaseModel):
code: str # VALIDATION_ERROR | PERMISSION_DENIED | NOT_FOUND | CONFLICT | RATE_LIMIT | TRANSIENT | INTERNAL
message: str
hint: Optional[str] = None
retry_after_ms: Optional[int] = None

class ToolResponse(BaseModel, Generic[T]):
ok: bool
result: Optional[T] = None
issues: list[ToolIssue] = []

Decorator

Runtime annotation exposure
from clickup_mcp.mcp_server.errors.handler import handle_tool_errors

@handle_tool_errors
async def get_authorized_teams() -> WorkspaceListResult: # exposed as ToolResponse[WorkspaceListResult]
...
Decorator skeleton (ParamSpec aware)
from __future__ import annotations
from typing import Any, Awaitable, Callable, ParamSpec, TypeVar, overload
from functools import wraps
from clickup_mcp.mcp_server.errors.mapping import map_exception
from clickup_mcp.mcp_server.errors.models import ToolIssue, ToolResponse

P = ParamSpec("P")
R = TypeVar("R")

def handle_tool_errors(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[ToolResponse[R]]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> ToolResponse[R]:
try:
result = await func(*args, **kwargs)
return ToolResponse[R](ok=True, result=result, issues=[])
except Exception as exc: # noqa: BLE001 - centralized mapping
issue: ToolIssue = map_exception(exc)
return ToolResponse[R](ok=False, result=None, issues=[issue])
return wrapper

See unit tests validating annotations and wrapper behavior:

Mapping (HTTP → ToolIssue.code)

UpstreamCodeNotes
401AUTH_ERRORInvalid/missing token
403FORBIDDENInsufficient permissions
404NOT_FOUNDResource missing
409CONFLICTVersioning/state conflict
429RATE_LIMITInclude retry_after_ms when present
5xx/timeoutUPSTREAM_ERRORGeneric upstream failure
Auth error (401)
{"ok":false,"result":null,"issues":[{"code":"AUTH_ERROR","message":"Invalid API token"}]}
Related

User-facing guidance: see per-function Errors blocks in each MCP API page; retry/backoff details in the Errors and Retries (MCP) page.

Gotchas
  • Do not echo vendor payloads verbatim into issues.details; keep messages short and hints actionable.
  • Always prefer a single, canonical ToolIssue over multiple near-duplicates.
  • When mapping 429, parse Retry-After headers into retry_after_ms when available.

Relations to MCP tool modules

The centralized error system wraps every MCP tool in clickup_mcp/mcp_server/*. This diagram shows how tool functions flow through the decorator, mapping, and transports.

End-to-end call (sequence)

Component dependency map

These charts illustrate that all tool modules depend on errors/handler.py for runtime envelopes, errors/mapping.py for stable IssueCode mapping, and errors/models.py for the canonical ToolResponse/ToolIssue contracts, regardless of which transport is used.