Skip to content

Commit

Permalink
Merge branch 'feature/error-handling'
Browse files Browse the repository at this point in the history
  • Loading branch information
tumf committed Jan 3, 2025
1 parent 37bee30 commit bfd3d41
Show file tree
Hide file tree
Showing 8 changed files with 747 additions and 183 deletions.
99 changes: 99 additions & 0 deletions src/mcp_text_editor/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Error handling for MCP Text Editor."""
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional


class ErrorCode(str, Enum):
"""Error codes for MCP Text Editor."""

# Protocol level errors (1000-1999)
INVALID_REQUEST = "INVALID_REQUEST"
INVALID_SCHEMA = "INVALID_SCHEMA"
INVALID_FIELD = "INVALID_FIELD"

# File operation errors (2000-2999)
FILE_NOT_FOUND = "FILE_NOT_FOUND"
FILE_ALREADY_EXISTS = "FILE_ALREADY_EXISTS"
FILE_ACCESS_DENIED = "FILE_ACCESS_DENIED"
FILE_HASH_MISMATCH = "FILE_HASH_MISMATCH"

# Content operation errors (3000-3999)
CONTENT_HASH_MISMATCH = "CONTENT_HASH_MISMATCH"
INVALID_LINE_RANGE = "INVALID_LINE_RANGE"
CONTENT_TOO_LARGE = "CONTENT_TOO_LARGE"

# Internal errors (9000-9999)
INTERNAL_ERROR = "INTERNAL_ERROR"


@dataclass
class MCPError(Exception):
"""Base exception class for MCP Text Editor."""

code: ErrorCode
message: str
details: Optional[Dict[str, Any]] = None

def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format for JSON response."""
error_dict = {
"error": {
"code": self.code,
"message": self.message,
}
}
if self.details:
error_dict["error"]["details"] = self.details
return error_dict


class ValidationError(MCPError):
"""Raised when input validation fails."""

def __init__(
self,
message: str,
details: Optional[Dict[str, Any]] = None,
code: ErrorCode = ErrorCode.INVALID_SCHEMA,
):
super().__init__(code=code, message=message, details=details)


class FileOperationError(MCPError):
"""Raised when file operations fail."""

def __init__(
self,
message: str,
details: Optional[Dict[str, Any]] = None,
code: ErrorCode = ErrorCode.FILE_NOT_FOUND,
):
super().__init__(code=code, message=message, details=details)


class ContentOperationError(MCPError):
"""Raised when content operations fail."""

def __init__(
self,
message: str,
details: Optional[Dict[str, Any]] = None,
code: ErrorCode = ErrorCode.CONTENT_HASH_MISMATCH,
):
super().__init__(code=code, message=message, details=details)


class InternalError(MCPError):
"""Raised when internal errors occur."""

def __init__(
self,
message: str = "An internal error occurred",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
code=ErrorCode.INTERNAL_ERROR,
message=message,
details=details,
)
71 changes: 69 additions & 2 deletions src/mcp_text_editor/handlers/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
"""Base handler for MCP Text Editor."""

from typing import Any, Dict, Sequence
from functools import wraps
from typing import Any, Dict, Sequence, TypeVar

from mcp.types import TextContent, Tool

from ..errors import MCPError, InternalError, ValidationError
from ..text_editor import TextEditor

T = TypeVar("T")


def handle_errors(func):
"""Decorator to handle errors in handler methods."""

@wraps(func)
async def wrapper(*args, **kwargs) -> Sequence[TextContent]:
try:
return await func(*args, **kwargs)
except MCPError as e:
# Known application errors
return [TextContent(text=str(e.to_dict()))]
except Exception as e:
# Unexpected errors
internal_error = InternalError(
message="An unexpected error occurred",
details={"error": str(e), "type": e.__class__.__name__},
)
return [TextContent(text=str(internal_error.to_dict()))]

return wrapper


class BaseHandler:
"""Base class for handlers."""
Expand All @@ -17,10 +42,52 @@ def __init__(self, editor: TextEditor | None = None):
"""Initialize the handler."""
self.editor = editor if editor is not None else TextEditor()

def validate_arguments(self, arguments: Dict[str, Any]) -> None:
"""Validate the input arguments.
Args:
arguments: The arguments to validate
Raises:
ValidationError: If the arguments are invalid
"""
raise NotImplementedError

def get_tool_description(self) -> Tool:
"""Get the tool description."""
raise NotImplementedError

@handle_errors
async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
"""Execute the tool with given arguments."""
"""Execute the tool with given arguments.
This method is decorated with handle_errors to provide consistent
error handling across all handlers.
Args:
arguments: The arguments for the tool
Returns:
Sequence[TextContent]: The tool's output
Raises:
MCPError: For known application errors
Exception: For unexpected errors
"""
# Validate arguments before execution
self.validate_arguments(arguments)
return await self._execute_tool(arguments)

async def _execute_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
"""Internal method to execute the tool.
This should be implemented by each handler to provide the actual
tool functionality.
Args:
arguments: The validated arguments for the tool
Returns:
Sequence[TextContent]: The tool's output
"""
raise NotImplementedError
6 changes: 3 additions & 3 deletions src/mcp_text_editor/handlers/delete_text_file_contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class DeleteTextFileContentsHandler(BaseHandler):
"""Handler for deleting content from a text file."""

name = "delete_text_file_contents"
description = "Delete specified content ranges from a text file. The file must exist. File paths must be absolute. You need to provide the file_hash comes from get_text_file_contents."
description = "Delete specified content ranges from a text file. The file must exist. File paths must be absolute. You need to provide the file_hash comes from get_text_file_contents." # noqa: E501

def get_tool_description(self) -> Tool:
"""Get the tool description."""
Expand All @@ -34,7 +34,7 @@ def get_tool_description(self) -> Tool:
},
"file_hash": {
"type": "string",
"description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.",
"description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.", # noqa: E501
},
"ranges": {
"type": "array",
Expand All @@ -52,7 +52,7 @@ def get_tool_description(self) -> Tool:
},
"range_hash": {
"type": "string",
"description": "Hash of the content being deleted. it should be matched with the range_hash when get_text_file_contents is called with the same range.",
"description": "Hash of the content being deleted. it should be matched with the range_hash when get_text_file_contents is called with the same range.", # noqa: E501
},
},
"required": ["start", "range_hash"],
Expand Down
4 changes: 2 additions & 2 deletions src/mcp_text_editor/handlers/insert_text_file_contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class InsertTextFileContentsHandler(BaseHandler):
"""Handler for inserting content before or after a specific line in a text file."""

name = "insert_text_file_contents"
description = "Insert content before or after a specific line in a text file. Uses hash-based validation for concurrency control. You need to provide the file_hash comes from get_text_file_contents."
description = "Insert content before or after a specific line in a text file. Uses hash-based validation for concurrency control. You need to provide the file_hash comes from get_text_file_contents." # noqa: E501

def get_tool_description(self) -> Tool:
"""Get the tool description."""
Expand All @@ -33,7 +33,7 @@ def get_tool_description(self) -> Tool:
},
"file_hash": {
"type": "string",
"description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.",
"description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.", # noqa: E501
},
"contents": {
"type": "string",
Expand Down
Loading

0 comments on commit bfd3d41

Please # to comment.