Skip to content

Latest commit

 

History

History
225 lines (163 loc) · 8.39 KB

File metadata and controls

225 lines (163 loc) · 8.39 KB

Technical Details

This document contains important implementation details, design decisions, and lessons learned during the development of langchain-mcp-tools.

Table of Contents

Authentication Pre-validation

The Problem We Discovered

During development, we encountered an issue where authentication failures (401 Unauthorized) were causing async generator cleanup errors in the underlying MCP Python client library, which retsults in massive logs that make it difficult to identify that authentication is the root cause.

Symptoms

[ERROR] an error occurred during closing of asynchronous generator <async_generator object streamablehttp_client at 0x...>
asyncgen: <async_generator object streamablehttp_client at 0x...>
  + Exception Group Traceback (most recent call last):
  |   File "/.../anyio/_backends/_asyncio.py", line 772, in __aexit__
  |     raise BaseExceptionGroup(
  | BaseExceptionGroup: unhandled errors in a TaskGroup (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/.../mcp/client/streamable_http.py", line 368, in handle_request_async
    |     await self._handle_post_request(ctx)
    |   File "/.../mcp/client/streamable_http.py", line 252, in _handle_post_request
    |     response.raise_for_status()
    |   File "/.../httpx/_models.py", line 829, in raise_for_status
    |     raise HTTPStatusError(message, request=request, response=self)
    | httpx.HTTPStatusError: Client error '401 Unauthorized' for url '...'

Root Cause Analysis

  1. MCP Client Library Issue?: The streamablehttp_client async generator may have improper cleanup handling when authentication fails during the connection setup phase.

  2. Exception Propagation Failure: Instead of properly propagating the HTTPStatusError, the async generator cleanup failure masks the real authentication error.

  3. User Experience Impact: Users see cryptic async generator errors instead of clear "authentication failed" messages.

Our Solution: Authentication Pre-validation

We implemented validate_auth_before_connection() to detect authentication issues before attempting the actual MCP connection. This ensures that clear error messages like Authentication failed (401 Unauthorized) or Authentication failed (403 Forbidden) appear at the end of the logs, rather than being buried in the middle of extensive error output.

How It Works

async def validate_auth_before_connection(
    url_str: str, 
    headers: dict[str, str] | None = None, 
    timeout: float = 30.0,
    auth: httpx.Auth | None = None,
    logger: logging.Logger = logging.getLogger(__name__)
) -> tuple[bool, str]:
    """Pre-validate authentication with a simple HTTP request."""

Key Implementation Details:

  1. Uses Proper MCP Protocol: Sends a valid MCP InitializeRequest to test authentication
  2. Detects Auth Failures Early: Catches 401, 402, 403 errors before MCP connection
  3. Provides Clear Errors: Returns descriptive error messages for users

Integration Pattern

# In connect_to_mcp_server()
if url_scheme in ["http", "https"]:
    # Pre-validate authentication to avoid MCP async generator cleanup bugs
    auth_valid, auth_message = await validate_auth_before_connection(
        url_str, headers=headers, timeout=timeout or 30.0, auth=auth, logger=logger
    )
    
    if not auth_valid:
        raise McpInitializationError(auth_message, server_name=server_name)
    
    # Only proceed with MCP connection if auth is valid
    transport = await exit_stack.enter_async_context(
        streamablehttp_client(url_str, **kwargs)
    )

Error Handling Strategy

Custom Exception Hierarchy

We moved away from generic ValueError to a purpose-built exception system:

class McpInitializationError(Exception):
    """Raised when MCP server initialization fails."""
    
    def __init__(self, message: str, server_name: str | None = None):
        self.server_name = server_name
        super().__init__(message)
    
    def __str__(self) -> str:
        if self.server_name:
            return f'MCP server "{self.server_name}": {super().__str__()}'
        return super().__str__()

Benefits:

  • Better semantics: McpInitializationError vs generic ValueError
  • Server context: server_name attribute for debugging
  • Consistent formatting: Automatic server name inclusion in error messages
  • Extensibility: Easy to add more specific exception types later

Error Context Propagation

All errors now include server context:

try:
    tools, cleanup = await convert_mcp_to_langchain_tools(server_configs)
except McpInitializationError as e:
    print(f"Failed to initialize server '{e.server_name}': {e}")
    # Server name is available for debugging

MCP Client Library Issues

Known Issues We've Encountered

  1. Async Generator Cleanup Bug (Primary Issue)

    • Affects: streamablehttp_client when authentication fails
    • Workaround: Pre-validate authentication
    • Status: Should be reported to MCP Python SDK team
  2. Transport Tuple Variations

    • Issue: Different transports return different tuple formats
    • SSE/Stdio: 2-tuple (read, write)
    • Streamable HTTP: 3-tuple (read, write, session_info)
    • Solution: Handle both formats gracefully

Best Practices for MCP Client Usage

  1. Always Use AsyncExitStack: Proper resource management is critical
  2. Pre-validate Authentication: Especially for HTTP transports
  3. Handle Both Transport Formats: Check tuple length before unpacking
  4. Comprehensive Error Handling: Wrap MCP operations in try-catch blocks
  5. Proper Cleanup: Always call cleanup functions

Architecture Decisions

User-Controlled Cleanup

Instead of automatic cleanup, we provide user-controlled cleanup via AsyncExitStack:

async def convert_mcp_to_langchain_tools(
    server_configs: McpServersConfig,
    logger: logging.Logger | None = None
) -> tuple[list[BaseTool], McpServerCleanupFn]:
    
    async_exit_stack = AsyncExitStack()
    
    # ... initialize servers ...
    
    async def mcp_cleanup() -> None:
        """User calls this when ready to cleanup."""
        await async_exit_stack.aclose()
    
    return langchain_tools, mcp_cleanup

Benefits:

  • User Control: Users decide when to cleanup resources
  • Batch Operations: All servers cleaned up together
  • Exception Safety: Resources tracked even if individual connections fail
  • Flexibility: Works with different application lifecycles

Parallel Server Initialization

We initialize multiple servers concurrently for efficiency:

# Initialize all servers in parallel
for server_name, server_config in server_configs.items():
    transport = await connect_to_mcp_server(
        server_name, server_config, async_exit_stack, logger
    )
    transports.append(transport)

Note: For stdio servers, the await only blocks until subprocess spawn, then servers initialize in parallel.

Transport Abstraction

We maintain transport-agnostic interfaces while handling transport-specific details internally:

  • Unified Configuration: Same interface for all transport types
  • Automatic Detection: Smart transport selection based on URL/command
  • Fallback Logic: Streamable HTTP → SSE fallback per MCP spec

Development Guidelines

Adding New Features

  1. Maintain Backward Compatibility: Existing user code should continue working
  2. Add Tests: Cover both success and failure scenarios
  3. Update Documentation: Both code docstrings and this README
  4. Consider Error Handling: How do errors propagate to users?

Testing Authentication Features

When testing authentication features:

  1. Test Valid Auth: Ensure successful connections work
  2. Test Invalid Auth: Verify clear error messages for 401/403
  3. Test Network Issues: Handle connection failures gracefully
  4. Test Edge Cases: Empty headers, malformed tokens, etc.

MCP Library Compatibility

When updating MCP library dependencies:

  1. Test Authentication Flows: Verify our workarounds still work
  2. Check Transport Changes: New