#!/usr/bin/env python3 """ Minimal Resumable MCP Client Simple client that demonstrates session resumption with the long_running_agent tool. - If resumption token exists, resumes the session - If no token exists, creates new session and saves token - Clears token when task completes """ import argparse import asyncio import json import logging import os from pathlib import Path from typing import Dict, Any, Optional from mcp import ClientSession, types from mcp.client.streamable_http import streamablehttp_client from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, MCP_PROTOCOL_VERSION_HEADER from mcp.shared.message import ClientMessageMetadata from mcp.types import TextContent # Configure logging to suppress noisy SSE warnings logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) # Suppress specific noisy loggers logging.getLogger('httpx').setLevel(logging.WARNING) logging.getLogger('mcp.client.streamable_http').setLevel(logging.ERROR) logging.getLogger('mcp').setLevel(logging.WARNING) logging.getLogger('pydantic_core').setLevel(logging.ERROR) # Filter out specific SSE JSON parsing errors class SSEFilter(logging.Filter): def filter(self, record): return not ( "Error parsing SSE message" in record.getMessage() or "Invalid JSON: EOF while parsing" in record.getMessage() ) # Apply filter to relevant loggers for logger_name in ['mcp.client.streamable_http', 'mcp', 'pydantic_core']: log = logging.getLogger(logger_name) log.addFilter(SSEFilter()) # Token file location TOKEN_FILE = Path(".mcp_resumption_token.json") def load_resumption_tokens() -> Optional[Dict[str, Any]]: """Load resumption tokens from file.""" if TOKEN_FILE.exists(): try: with open(TOKEN_FILE, 'r') as f: tokens = json.load(f) logger.info(f"šŸ”„ Loaded resumption tokens: session_id={tokens.get('session_id')}") return tokens except Exception as e: logger.error(f"Error loading tokens: {e}") return None def save_resumption_tokens(session_id: str, resumption_token: str, protocol_version: str = "2025-03-26"): """Save resumption tokens to file.""" tokens = { "session_id": session_id, "resumption_token": resumption_token, "protocol_version": protocol_version, "tool_name": "long_running_agent", "tool_args": {} } try: with open(TOKEN_FILE, 'w') as f: json.dump(tokens, f, indent=2) logger.info(f"šŸ’¾ Saved resumption tokens: session_id={session_id}") except Exception as e: logger.error(f"Error saving tokens: {e}") def clear_resumption_tokens(): """Clear resumption tokens file.""" if TOKEN_FILE.exists(): try: TOKEN_FILE.unlink() logger.info("šŸ—‘ļø Cleared resumption tokens") except Exception as e: logger.error(f"Error clearing tokens: {e}") async def run_long_running_task(server_url: str): """Run the long_running_agent with resumption support.""" # Check for existing tokens existing_tokens = load_resumption_tokens() # Prepare headers for resumption if tokens exist headers = {} if existing_tokens: headers[MCP_SESSION_ID_HEADER] = existing_tokens["session_id"] headers[MCP_PROTOCOL_VERSION_HEADER] = existing_tokens.get("protocol_version", "2025-03-26") print("šŸ”„ Resuming existing session...") else: print("šŸ†• Creating new session...") try: async with streamablehttp_client( server_url, headers=headers if headers else None, terminate_on_close=False # Enable resumption ) as (read_stream, write_stream, get_session_id): # Message handler for real-time notifications (standardized to use log messages) async def message_handler(message) -> None: try: if isinstance(message, types.ServerNotification): if isinstance(message.root, types.LoggingMessageNotification): log_data = message.root.params # Format log messages similar to progress notifications for consistency print(f"šŸ“” [{log_data.logger}] {log_data.data}") elif isinstance(message.root, types.ProgressNotification): # Keep support for progress notifications in case other tools use them progress = message.root.params print(f"šŸ“Š Progress: {progress.progress}/{progress.total} - {progress.message}") elif isinstance(message.root, types.ResourceUpdatedNotification): print(f"šŸ”„ Resource updated: {message.root.params.uri}") except Exception: # Silently ignore message handler errors to avoid breaking the flow pass async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: # Only initialize if this is a new session (no existing tokens) if not existing_tokens: result = await session.initialize() print(f"āœ… Session initialized: {result.serverInfo.name}") else: print("āœ… Using existing session (no initialization needed)") # Set up resumption token callback (matching test signature) async def on_resumption_token_update(token: str) -> None: print(f"šŸ’¾ Resumption token received: {token[:20]}...") session_id = get_session_id() if session_id: protocol_version = getattr(session, 'negotiated_protocol_version', '2025-03-26') save_resumption_tokens(session_id, token, protocol_version) print(f"šŸ’¾ Resumption token saved") # Prepare metadata with resumption support if existing_tokens and existing_tokens.get("resumption_token"): # Resume existing task print("šŸ”„ Resuming long-running task...") metadata = ClientMessageMetadata( resumption_token=existing_tokens["resumption_token"] ) else: # Start new task print("šŸš€ Starting long-running task...") metadata = ClientMessageMetadata( on_resumption_token_update=on_resumption_token_update ) # Execute the long_running_agent tool try: print("metadata:", metadata) result = await session.send_request( types.ClientRequest( types.CallToolRequest( method="tools/call", params=types.CallToolRequestParams( name="long_running_agent", arguments={} ), ) ), types.CallToolResult, metadata=metadata, ) # Task completed successfully content_text = "Task completed" if result.content and len(result.content) > 0: first_content = result.content[0] if isinstance(first_content, TextContent): content_text = first_content.text print(f"āœ… Task completed: {content_text}") clear_resumption_tokens() except Exception as e: print(f"āŒ Task failed: {e}") # Keep tokens in case we want to retry except Exception as e: print(f"āŒ Connection error: {e}") async def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Minimal Resumable MCP Client") parser.add_argument("--url", default="http://127.0.0.1:8006/mcp", help="MCP server URL") parser.add_argument("--clear-tokens", action="store_true", help="Clear resumption tokens and exit") args = parser.parse_args() if args.clear_tokens: clear_resumption_tokens() print("šŸ—‘ļø Resumption tokens cleared") return print("šŸ¤– Minimal Resumable MCP Client") print(f"šŸ”— Connecting to: {args.url}") await run_long_running_task(args.url) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nšŸ‘‹ Interrupted by user") except Exception as e: print(f"šŸ’„ Fatal error: {e}")