#!/usr/bin/env python3 """ Matrix Control MCP Server Model Context Protocol server that allows Claude to interact with Matrix messages queued by the Matrix integration. Main Tools: - get_matrix_messages() - Retrieve queued messages (text + images) - get_matrix_image(event_id) - Retrieve a Matrix image by event ID - matrix_mark_messages_processed(event_ids) - Mark messages as processed - matrix_send_message(room_id, message) - Send text message with markdown formatting - matrix_send_emote(room_id, action) - Send emote/action (like /me) - matrix_send_image(room_id, file_path) - Send image file to a Matrix room - matrix_send_voice(room_id, file_path) - Send voice message (audio file) to a Matrix room - get_matrix_status() - Check Matrix integration status - list_matrix_rooms() - List available Matrix rooms - list_matrix_invites() - List room invites - join_matrix_room(room_id) - Join a Matrix room - matrix_reply_to_message(room_id, event_id, message) - Reply to a message - matrix_react_to_message(room_id, event_id, emoji) - React with emoji """ import json import logging import asyncio import fcntl import os from datetime import datetime from pathlib import Path from typing import List, Optional, Dict, Any from fastmcp import FastMCP from fastmcp.utilities.types import Image as MCPImage try: from nio import ( AsyncClient, RoomSendError, RoomSendResponse, UploadResponse, UploadError, JoinResponse, JoinError, RoomLeaveResponse, RoomLeaveError, RoomGetStateResponse, RoomGetStateError, RoomMessagesResponse, RoomMessagesError, ProfileGetResponse, ProfileGetError, ProfileGetDisplayNameResponse, ProfileGetAvatarResponse, DownloadResponse, DownloadError, RoomMemberEvent, ) except ImportError: raise ImportError( "matrix-nio not installed. Install with: pip3 install matrix-nio\n" "Note: Without [e2e] extra, only unencrypted rooms are supported" ) # Configuration STATE_FILE = Path.home() / ".claude-automation-state.json" CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json" MATRIX_DATA_DIR = Path.home() / ".matrix-data" LOG_FILE = Path("/tmp/matrix-mcp.log") # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Initialize MCP server mcp = FastMCP("Matrix Control") def load_state() -> dict: """Load automation state from JSON file""" if not STATE_FILE.exists(): return { 'interval_minutes': 60, 'paused': False, 'last_wake': None, 'next_wake_timestamp': None, 'matrix_messages': [], 'matrix_invites': [], 'matrix_last_wake': None, 'matrix_wake_requested': False, } try: with open(STATE_FILE, 'r') as f: return json.load(f) except Exception as e: logger.error(f"Failed to load state: {e}") return {} def save_state(state: dict): """Save automation state to JSON file atomically with file locking""" try: temp_path = STATE_FILE.with_suffix('.tmp') with open(temp_path, 'w') as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) json.dump(state, f, indent=2) f.flush() os.fsync(f.fileno()) temp_path.rename(STATE_FILE) logger.info(f"Saved state atomically") except Exception as e: logger.error(f"Failed to save state: {e}") def load_credentials() -> dict: """Load Matrix credentials""" if not CREDENTIALS_FILE.exists(): raise FileNotFoundError( f"Credentials file not found: {CREDENTIALS_FILE}\n" f"Run setup_matrix.sh to create credentials" ) try: with open(CREDENTIALS_FILE, 'r') as f: return json.load(f) except Exception as e: logger.error(f"Failed to load credentials: {e}") raise @mcp.tool() def get_matrix_messages( limit: int = 10, include_processed: bool = False ) -> List[Dict[str, Any]]: """ Retrieve queued Matrix messages (metadata only). **Messages are automatically marked as processed when retrieved!** Returns metadata for both text messages and images. For images, use get_matrix_image(event_id) to retrieve the actual image. Args: limit: Maximum number of messages to return (default: 10) include_processed: Include already-processed messages (default: False) Returns: List of messages with structure: Text messages: - type: "text" - room_id: Matrix room ID - room_name: Human-readable room name - sender: Message sender's Matrix ID - message: Message text - timestamp: ISO timestamp - event_id: Matrix event ID - processed: True (auto-marked when retrieved) Image messages (metadata only): - type: "image" - room_id: Matrix room ID - room_name: Human-readable room name - sender: Message sender's Matrix ID - filename: Image filename - timestamp: ISO timestamp - event_id: Matrix event ID (use with get_matrix_image()) - processed: True (auto-marked when retrieved) Examples: get_matrix_messages() - Get up to 10 unprocessed messages (auto-marks as processed) get_matrix_messages(limit=50) - Get up to 50 unprocessed messages get_matrix_messages(include_processed=True) - Include already-processed messages # For images: messages = get_matrix_messages() for msg in messages: if msg['type'] == 'image': image = get_matrix_image(msg['event_id']) # Fetches actual image """ try: state = load_state() messages = state.get('matrix_messages', []) logger.debug(f"Total messages in state: {len(messages)}") # Filter by processed status if not include_processed: unprocessed_before = [msg for msg in messages if not msg.get('processed', False)] logger.debug(f"Unprocessed messages before filtering: {len(unprocessed_before)}") messages = unprocessed_before # Limit results messages = messages[:limit] logger.debug(f"Messages after limit: {len(messages)}") # Automatically mark these messages as processed (they're being retrieved) event_ids_to_mark = [msg['event_id'] for msg in messages] if event_ids_to_mark: logger.debug(f"About to mark {len(event_ids_to_mark)} messages as processed") marked_count = _mark_messages_processed_internal(event_ids_to_mark) logger.info(f"Auto-marked {marked_count} messages as processed") else: logger.debug("No messages to mark as processed") # Format messages for return (metadata only) formatted_messages = [] for msg in messages: if msg['type'] == 'image': # Return metadata only - use get_matrix_image(event_id) to fetch actual image formatted_msg = { 'type': 'image', 'room_id': msg['room_id'], 'room_name': msg['room_name'], 'sender': msg['sender'], 'filename': msg['filename'], 'timestamp': msg['timestamp'], 'event_id': msg['event_id'], 'processed': True, # Now marked as processed } elif msg['type'] == 'audio': # Return metadata only - use get_matrix_audio(event_id) to fetch actual audio formatted_msg = { 'type': 'audio', 'room_id': msg['room_id'], 'room_name': msg['room_name'], 'sender': msg['sender'], 'filename': msg['filename'], 'timestamp': msg['timestamp'], 'event_id': msg['event_id'], 'processed': True, # Now marked as processed } else: formatted_msg = msg.copy() # Make a copy to avoid modifying original formatted_msg['processed'] = True # Now marked as processed formatted_messages.append(formatted_msg) logger.info(f"Returned {len(formatted_messages)} Matrix messages") return formatted_messages except Exception as e: logger.error(f"Failed to get Matrix messages: {e}") return [] @mcp.tool() def get_matrix_image(event_id: str) -> MCPImage: """ Retrieve a Matrix image by event ID. Use this after get_matrix_messages() returns an image message to fetch the actual image for inline display. Args: event_id: Matrix event ID of the image message Returns: MCPImage object for inline display in Claude Desktop Examples: get_matrix_image("$abc123...") """ try: import base64 state = load_state() messages = state.get('matrix_messages', []) # Find the image message for msg in messages: if msg['event_id'] == event_id and msg['type'] == 'image': # Decode base64 to raw bytes image_bytes = base64.b64decode(msg['image_data']) logger.info(f"Returning image: {msg['filename']} ({len(image_bytes)} bytes)") # Return MCPImage object like image-watch-mcp does return MCPImage(data=image_bytes, format="jpeg") logger.warning(f"Image not found for event_id: {event_id}") raise ValueError(f"No image found with event_id: {event_id}") except Exception as e: logger.error(f"Failed to get image: {e}") raise @mcp.tool() def get_matrix_audio(event_id: str, save_path: str = None) -> str: """ Retrieve a Matrix audio/voice message by event ID. Use this after get_matrix_messages() returns an audio message. Saves the audio to a file and returns the path. Args: event_id: Matrix event ID of the audio message save_path: Optional path to save audio (defaults to temp file) Returns: Path to the saved audio file Examples: audio_path = get_matrix_audio("$abc123...") transcription = ear_transcribe(audio_path) """ try: import base64 import tempfile from pathlib import Path state = load_state() messages = state.get('matrix_messages', []) # Find the audio message for msg in messages: if msg['event_id'] == event_id and msg['type'] == 'audio': # Decode base64 to raw bytes audio_bytes = base64.b64decode(msg['audio_data']) # Determine save path if save_path: audio_path = Path(save_path).expanduser() else: # Use temp file with original extension filename = msg.get('filename', 'voice.ogg') ext = Path(filename).suffix or '.ogg' temp_dir = Path(tempfile.gettempdir()) audio_path = temp_dir / f"matrix_audio_{event_id[:8]}{ext}" # Save audio file with open(audio_path, 'wb') as f: f.write(audio_bytes) logger.info(f"Saved audio: {audio_path} ({len(audio_bytes)} bytes)") return str(audio_path) logger.warning(f"Audio not found for event_id: {event_id}") raise ValueError(f"No audio found with event_id: {event_id}") except Exception as e: logger.error(f"Failed to get audio: {e}") raise def _mark_messages_processed_internal(event_ids: List[str]) -> int: """ Internal helper to mark messages as processed. Returns: Number of messages marked """ state = load_state() messages = state.get('matrix_messages', []) marked_count = 0 for msg in messages: if msg['event_id'] in event_ids: msg['processed'] = True marked_count += 1 state['matrix_messages'] = messages save_state(state) # Clear wake request flag if all messages processed unprocessed = [msg for msg in messages if not msg.get('processed', False)] if not unprocessed: state['matrix_wake_requested'] = False save_state(state) return marked_count @mcp.tool() def matrix_mark_messages_processed(event_ids: List[str]) -> str: """ Mark Matrix messages as processed. After Claude has responded to messages, mark them as processed so they won't be returned in future get_matrix_messages() calls. Args: event_ids: List of Matrix event IDs to mark as processed Returns: Confirmation message Examples: matrix_mark_messages_processed(["$event1", "$event2"]) """ try: marked_count = _mark_messages_processed_internal(event_ids) logger.info(f"Marked {marked_count} messages as processed") return f"✓ Marked {marked_count} messages as processed" except Exception as e: logger.error(f"Failed to mark messages: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_send_image(room_id: str, file_path: str) -> str: """ Send an image to a Matrix room from a local file. Reads the image from the file system, uploads it to the Matrix homeserver, and sends it to the specified room. Works with files from DreamTail, image-watch, or any other local image file. Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") file_path: Path to the image file on disk Returns: Confirmation message or error Examples: matrix_send_image("!abc123:matrix.org", "/path/to/image.jpg") matrix_send_image("!abc123:matrix.org", "~/Downloads/photo.png") """ try: from pathlib import Path from io import BytesIO from PIL import Image # Resolve path path = Path(file_path).expanduser().resolve() if not path.exists(): return f"Error: File not found: {file_path}" if not path.is_file(): return f"Error: Not a file: {file_path}" # Read image data try: image_data = path.read_bytes() except Exception as e: return f"Error reading file: {str(e)}" # Get image dimensions and format try: img = Image.open(BytesIO(image_data)) width, height = img.size img_format = img.format.lower() if img.format else 'jpeg' mime_type = f'image/{img_format}' except Exception as e: logger.warning(f"Could not extract image info: {e}") width, height = None, None mime_type = 'image/jpeg' # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Upload image to Matrix homeserver using aiohttp directly # (nio's upload has timeout issues) filename = path.name logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") import aiohttp upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" headers = { "Authorization": f"Bearer {creds['access_token']}", "Content-Type": mime_type, } async with aiohttp.ClientSession() as session: async with session.post(upload_url, data=image_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: if resp.status == 200: result = await resp.json() content_uri = result.get("content_uri") logger.info(f"Upload successful: {content_uri}") else: error_text = await resp.text() await client.close() logger.error(f"Upload failed: {resp.status} - {error_text}") return f"Error uploading image: {resp.status} - {error_text}" # Send image message content = { "msgtype": "m.image", "body": filename, "url": content_uri, "info": { "mimetype": mime_type, "size": len(image_data), } } if width and height: content["info"]["w"] = width content["info"]["h"] = height response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent image to {room_id}: {filename}") return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" elif isinstance(response, RoomSendError): logger.error(f"Failed to send image: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send image from file: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_send_voice(room_id: str, file_path: str) -> str: """ Send a voice message to a Matrix room from a local audio file. Reads the audio file (WAV, OGG, MP3, etc.) from the file system, uploads it to the Matrix homeserver, and sends it as a voice message. Works with files from voice-mcp or any other local audio file. Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") file_path: Path to the audio file on disk (e.g., "~/voice_audio/abc123.wav") Returns: Confirmation message or error Examples: matrix_send_voice("!abc123:matrix.org", "~/voice_audio/job123.wav") matrix_send_voice("!abc123:matrix.org", "/tmp/recording.ogg") """ try: from pathlib import Path import wave # Resolve path path = Path(file_path).expanduser().resolve() if not path.exists(): return f"Error: File not found: {file_path}" if not path.is_file(): return f"Error: Not a file: {file_path}" # Read audio data try: audio_data = path.read_bytes() except Exception as e: return f"Error reading file: {str(e)}" # Detect audio format and get metadata file_ext = path.suffix.lower() # Map file extension to MIME type mime_type_map = { '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.opus': 'audio/opus', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac', } mime_type = mime_type_map.get(file_ext, 'audio/wav') # Try to get duration for WAV files duration_ms = None if file_ext == '.wav': try: with wave.open(str(path), 'rb') as wav_file: frames = wav_file.getnframes() rate = wav_file.getframerate() duration_ms = int((frames / rate) * 1000) except Exception as e: logger.warning(f"Could not extract WAV duration: {e}") # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Upload audio to Matrix homeserver using aiohttp directly # (nio's upload has timeout issues) filename = path.name logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") import aiohttp upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" headers = { "Authorization": f"Bearer {creds['access_token']}", "Content-Type": mime_type, } async with aiohttp.ClientSession() as session: async with session.post(upload_url, data=audio_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: if resp.status == 200: result = await resp.json() content_uri = result.get("content_uri") logger.info(f"Upload successful: {content_uri}") else: error_text = await resp.text() await client.close() logger.error(f"Upload failed: {resp.status} - {error_text}") return f"Error uploading audio: {resp.status} - {error_text}" # Send voice message content = { "msgtype": "m.audio", "body": filename, "url": content_uri, "info": { "mimetype": mime_type, "size": len(audio_data), } } # Add duration if we extracted it if duration_ms: content["info"]["duration"] = duration_ms response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent voice message to {room_id}: {filename}") return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" elif isinstance(response, RoomSendError): logger.error(f"Failed to send voice message: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send voice message from file: {e}") return f"Error: {str(e)}" def _convert_markdown_to_html(text: str) -> str: """ Convert basic markdown formatting to Matrix HTML. Supports: - **bold** → bold - *italic* → italic - ***bold italic*** → bold italic """ import re # Convert ***bold italic*** first (three asterisks) text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text) # Convert **bold** (two asterisks) text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # Convert *italic* (single asterisk) text = re.sub(r'\*(.+?)\*', r'\1', text) return text @mcp.tool() async def matrix_send_message(room_id: str, message: str) -> str: """ Send a message to a Matrix room. Supports markdown-style formatting (*italic*, **bold**). Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") message: Text message to send (supports *italic*, **bold**) Returns: Confirmation message or error Examples: matrix_send_message("!abc123:matrix.org", "Hello from Claude!") matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Convert markdown to HTML html_body = _convert_markdown_to_html(message) # Build message content content = { "msgtype": "m.text", "body": message, # Plain text fallback } # Only add formatted_body if there's actual HTML formatting if html_body != message: content["format"] = "org.matrix.custom.html" content["formatted_body"] = html_body # Send message response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent message to {room_id}: {message[:50]}...") return f"✓ Message sent to room (event ID: {response.event_id})" elif isinstance(response, RoomSendError): logger.error(f"Failed to send message: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send message: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_send_emote(room_id: str, action: str) -> str: """ Send an emote/action to a Matrix room (like /me in IRC). Displays as "* username action" instead of "username: message". Supports markdown-style formatting (*italic*, **bold**). Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") action: Action text (supports *italic*, **bold**) Returns: Confirmation message or error Examples: matrix_send_emote("!abc123:matrix.org", "waves hello") matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Convert markdown to HTML html_body = _convert_markdown_to_html(action) # Build message content content = { "msgtype": "m.emote", "body": action, # Plain text fallback } # Only add formatted_body if there's actual HTML formatting if html_body != action: content["format"] = "org.matrix.custom.html" content["formatted_body"] = html_body # Send message response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent emote to {room_id}: {action[:50]}...") return f"✓ Emote sent to room (event ID: {response.event_id})" elif isinstance(response, RoomSendError): logger.error(f"Failed to send emote: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send emote: {e}") return f"Error: {str(e)}" @mcp.tool() def get_matrix_status() -> str: """ Get Matrix integration status. Shows: - Connection status - Number of queued messages - Last Matrix wake time - Rate limit status Returns: Formatted status report """ try: state = load_state() status_lines = ["Matrix Integration Status:"] status_lines.append("=" * 40) # Credentials check if CREDENTIALS_FILE.exists(): try: creds = load_credentials() status_lines.append(f"✓ Connected as: {creds['user_id']}") status_lines.append(f" Homeserver: {creds['homeserver']}") # Room whitelist whitelist = creds.get('room_whitelist', []) if whitelist: status_lines.append(f" Monitoring: {len(whitelist)} rooms (whitelist)") else: status_lines.append(f" Monitoring: All rooms") except Exception as e: status_lines.append(f"⚠️ Credentials error: {e}") else: status_lines.append("✗ Not configured (run setup_matrix.sh)") # Message queue messages = state.get('matrix_messages', []) unprocessed_msgs = [msg for msg in messages if not msg.get('processed', False)] status_lines.append(f"Messages queued: {len(unprocessed_msgs)} unprocessed, {len(messages)} total") # Invite queue invites = state.get('matrix_invites', []) unprocessed_invs = [inv for inv in invites if not inv.get('processed', False)] status_lines.append(f"Invites queued: {len(unprocessed_invs)} unprocessed, {len(invites)} total") # Last wake last_wake_str = state.get('matrix_last_wake') if last_wake_str: try: last_wake = datetime.fromisoformat(last_wake_str) time_ago = datetime.now() - last_wake status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") except: status_lines.append(f"Last Matrix wake: {last_wake_str}") else: status_lines.append("Last Matrix wake: Never") # Rate limit status if last_wake_str: try: last_wake = datetime.fromisoformat(last_wake_str) elapsed = (datetime.now() - last_wake).total_seconds() if elapsed >= 120: status_lines.append("Rate limit: OK (can wake)") else: remaining = 120 - elapsed status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)") except: status_lines.append("Rate limit: Unknown") else: status_lines.append("Rate limit: OK (can wake)") # Wake request flag if state.get('matrix_wake_requested'): status_lines.append("⚡ Wake requested: Yes (daemon will wake Claude)") else: status_lines.append("Wake requested: No") return "\n".join(status_lines) except Exception as e: logger.error(f"Failed to get status: {e}") return f"Error getting status: {str(e)}" @mcp.tool() def list_matrix_invites(include_processed: bool = False) -> List[Dict[str, Any]]: """ List pending Matrix room invites. **Invites are automatically marked as processed when retrieved!** Shows all rooms the bot has been invited to but hasn't joined yet. Args: include_processed: Include already-processed invites (default: False) Returns: List of invites with structure: - room_id: Matrix room ID - room_name: Human-readable room name - inviter: Who invited you - timestamp: ISO timestamp - processed: True (auto-marked when retrieved) Examples: list_matrix_invites() - Get unprocessed invites (auto-marks as processed) list_matrix_invites(include_processed=True) - Include processed invites """ try: state = load_state() invites = state.get('matrix_invites', []) # Filter by processed status if not include_processed: invites = [inv for inv in invites if not inv.get('processed', False)] # Automatically mark these invites as processed (they're being retrieved) room_ids_to_mark = [inv['room_id'] for inv in invites] if room_ids_to_mark: marked_count = _mark_invites_processed_internal(room_ids_to_mark) logger.info(f"Auto-marked {marked_count} invites as processed") # Update the returned data to reflect processed status for inv in invites: inv['processed'] = True logger.info(f"Returned {len(invites)} Matrix invites") return invites except Exception as e: logger.error(f"Failed to list invites: {e}") return [] def _mark_invites_processed_internal(room_ids: List[str]) -> int: """ Internal helper to mark invites as processed. Returns: Number of invites marked """ state = load_state() invites = state.get('matrix_invites', []) marked_count = 0 for invite in invites: if invite['room_id'] in room_ids: invite['processed'] = True marked_count += 1 state['matrix_invites'] = invites save_state(state) return marked_count @mcp.tool() def matrix_mark_invites_processed(room_ids: List[str]) -> str: """ Mark Matrix invites as processed. After joining or declining invites, mark them as processed so they won't appear in future list_matrix_invites() calls. Args: room_ids: List of Matrix room IDs to mark as processed Returns: Confirmation message Examples: mark_invites_processed(["!abc123:matrix.org", "!xyz789:matrix.org"]) """ try: marked_count = _mark_invites_processed_internal(room_ids) logger.info(f"Marked {marked_count} invites as processed") return f"✓ Marked {marked_count} invites as processed" except Exception as e: logger.error(f"Failed to mark invites: {e}") return f"Error: {str(e)}" @mcp.tool() async def join_matrix_room(room_id: str) -> str: """ Join a Matrix room (accept invite or join public room). Use this to accept pending invites or join public rooms. Automatically marks the invite as processed. Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") Returns: Confirmation message or error Examples: join_matrix_room("!abc123:matrix.org") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Join room logger.info(f"Attempting to join room: {room_id}") response = await client.join(room_id) await client.close() if isinstance(response, JoinResponse): logger.info(f"Successfully joined room: {room_id}") # Mark invite as processed _mark_invites_processed_internal([room_id]) return f"✓ Joined room: {room_id}" elif isinstance(response, JoinError): logger.error(f"Failed to join room: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to join room: {e}") return f"Error: {str(e)}" @mcp.tool() async def leave_matrix_room(room_id: str) -> str: """ Leave a Matrix room. Use this to leave rooms you no longer want to monitor. Args: room_id: Matrix room ID (e.g., "!abc123:matrix.org") Returns: Confirmation message or error Examples: leave_matrix_room("!abc123:matrix.org") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Leave room logger.info(f"Attempting to leave room: {room_id}") response = await client.room_leave(room_id) await client.close() if isinstance(response, RoomLeaveResponse): logger.info(f"Successfully left room: {room_id}") return f"✓ Left room: {room_id}" elif isinstance(response, RoomLeaveError): logger.error(f"Failed to leave room: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to leave room: {e}") return f"Error: {str(e)}" @mcp.tool() async def list_matrix_rooms() -> str: """ List Matrix rooms the bot is in. Shows room IDs, names, and member counts for all joined rooms. Returns: Formatted list of rooms """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Sync to get rooms await client.sync(timeout=30000) # Get room whitelist whitelist = set(creds.get('room_whitelist', [])) # Format room list rooms_lines = ["Matrix Rooms:"] rooms_lines.append("=" * 60) for room_id, room in client.rooms.items(): # Check if room is whitelisted monitored = "✓" if (not whitelist or room_id in whitelist) else "✗" rooms_lines.append(f"{monitored} {room.display_name or room.room_id}") rooms_lines.append(f" ID: {room_id}") rooms_lines.append(f" Members: {len(room.users)}") rooms_lines.append("") await client.close() logger.info(f"Listed {len(client.rooms)} Matrix rooms") return "\n".join(rooms_lines) except Exception as e: logger.error(f"Failed to list rooms: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_reply_to_message(room_id: str, event_id: str, message: str) -> str: """ Reply to a specific Matrix message (threaded reply). Creates a threaded reply that shows context of the original message. Args: room_id: Matrix room ID event_id: Event ID of message to reply to message: Your reply text Returns: Confirmation message or error Examples: reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Send threaded reply content = { "msgtype": "m.text", "body": message, "m.relates_to": { "m.in_reply_to": { "event_id": event_id } } } response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent reply to {event_id} in {room_id}") return f"✓ Reply sent (event ID: {response.event_id})" elif isinstance(response, RoomSendError): logger.error(f"Failed to send reply: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send reply: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> str: """ Add an emoji reaction to a Matrix message. Args: room_id: Matrix room ID event_id: Event ID of message to react to emoji: Emoji to react with (e.g., "👍", "❤️", "😂") Returns: Confirmation message or error Examples: react_to_message("!abc:matrix.org", "$event123", "👍") react_to_message("!abc:matrix.org", "$event123", "❤️") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Send reaction content = { "m.relates_to": { "rel_type": "m.annotation", "event_id": event_id, "key": emoji } } response = await client.room_send( room_id=room_id, message_type="m.reaction", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent reaction {emoji} to {event_id}") return f"✓ Reacted with {emoji}" elif isinstance(response, RoomSendError): logger.error(f"Failed to send reaction: {response.message}") return f"Error: {response.message}" else: return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send reaction: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]: """ Get a Matrix user's profile information. Returns display name, avatar URL, and other profile data. Args: user_id: Matrix user ID (e.g., "@user:matrix.org") Returns: Dict with profile info: display_name, avatar_url Examples: get_user_profile("@friend:matrix.org") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Get display name display_name_response = await client.get_displayname(user_id) display_name = None if isinstance(display_name_response, ProfileGetDisplayNameResponse): display_name = display_name_response.displayname # Get avatar avatar_response = await client.get_avatar(user_id) avatar_url = None if isinstance(avatar_response, ProfileGetAvatarResponse): avatar_url = avatar_response.avatar_url await client.close() profile = { "user_id": user_id, "display_name": display_name or user_id, "avatar_url": avatar_url, } logger.info(f"Retrieved profile for {user_id}") return profile except Exception as e: logger.error(f"Failed to get user profile: {e}") return {"error": str(e)} @mcp.tool() async def matrix_set_presence(status: str, message: str = "") -> str: """ Set your Matrix presence status. Args: status: "online", "offline", or "unavailable" message: Optional status message Returns: Confirmation message or error Examples: set_presence("online", "Working on tasks") set_presence("away", "Out for lunch") set_presence("offline") """ try: # Validate status valid_statuses = ["online", "offline", "unavailable"] if status not in valid_statuses: return f"Error: status must be one of {valid_statuses}" # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Set presence await client.set_presence(status, message if message else None) await client.close() logger.info(f"Set presence to {status}") return f"✓ Presence set to {status}" + (f": {message}" if message else "") except Exception as e: logger.error(f"Failed to set presence: {e}") return f"Error: {str(e)}" @mcp.tool() async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: str = None) -> str: """ Send a file to a Matrix room. Accepts either file path (string) or file data (bytes). Auto-detects type. Args: room_id: Matrix room ID file: File path string OR file bytes/data filename: Filename to display mime_type: Optional MIME type (auto-detected if None) Returns: Confirmation message or error Examples: send_matrix_file("!abc:matrix.org", "/path/to/document.pdf", "report.pdf") send_matrix_file("!abc:matrix.org", file_bytes, "data.json", "application/json") """ try: from pathlib import Path import mimetypes # Auto-detect: file path vs file data if isinstance(file, str): # File path file_path = Path(file).expanduser() if not file_path.exists(): return f"Error: File not found: {file_path}" with open(file_path, 'rb') as f: file_data = f.read() # Auto-detect MIME type from path if not mime_type: mime_type, _ = mimetypes.guess_type(str(file_path)) if not mime_type: mime_type = 'application/octet-stream' else: # File data (bytes or object with .data) if isinstance(file, bytes): file_data = file elif hasattr(file, 'data'): file_data = file.data else: return "Error: file must be path string or bytes/data object" if not mime_type: mime_type = 'application/octet-stream' # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Upload file logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)") upload_response = await client.upload( data_provider=lambda *args: file_data, content_type=mime_type, filename=filename, filesize=len(file_data) ) if isinstance(upload_response, UploadError): await client.close() return f"Error uploading file: {upload_response.message}" # Send file message content = { "msgtype": "m.file", "body": filename, "url": upload_response.content_uri, "info": { "mimetype": mime_type, "size": len(file_data), } } response = await client.room_send( room_id=room_id, message_type="m.room.message", content=content ) await client.close() if isinstance(response, RoomSendResponse): logger.info(f"Sent file to {room_id}: {filename}") return f"✓ File sent: {filename} (event ID: {response.event_id})" else: return f"Error: Failed to send file" except Exception as e: logger.error(f"Failed to send file: {e}") return f"Error: {str(e)}" @mcp.tool() async def download_matrix_attachment(event_id: str, save_path: str = None) -> Any: """ Download a file attachment from a Matrix message. If save_path provided, saves to disk and returns path. If no save_path, returns raw file bytes. Args: event_id: Event ID of file message save_path: Optional path to save file (if None, returns bytes) Returns: File path (if saved) or file bytes (if not saved) Examples: download_matrix_attachment("$event123", "~/Downloads/file.pdf") download_matrix_attachment("$event123") # Returns bytes """ try: from pathlib import Path # Find file in state state = load_state() messages = state.get('matrix_messages', []) file_msg = None for msg in messages: if msg.get('event_id') == event_id and msg.get('type') in ['image', 'file']: file_msg = msg break if not file_msg: return "Error: File message not found with that event ID" # For images, decode from base64 if file_msg['type'] == 'image': import base64 file_data = base64.b64decode(file_msg['image_data']) else: # For files, need to download from Matrix (would need mxc URL in state) return "Error: File download not yet implemented for non-image files" # Save or return if save_path: save_path = Path(save_path).expanduser() save_path.parent.mkdir(parents=True, exist_ok=True) with open(save_path, 'wb') as f: f.write(file_data) logger.info(f"Saved file to {save_path}") return f"✓ Saved to {save_path}" else: logger.info(f"Returning file bytes ({len(file_data)} bytes)") return file_data except Exception as e: logger.error(f"Failed to download attachment: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]: """ Get list of members in a Matrix room. Returns member list with display names and power levels. Args: room_id: Matrix room ID Returns: List of members with user_id, display_name, power_level Examples: get_room_members("!abc:matrix.org") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Sync to get rooms await client.sync(timeout=30000) # Find the room if room_id not in client.rooms: await client.close() return [{"error": f"Room not found: {room_id}"}] room = client.rooms[room_id] # Get members members = [] for user_id, member in room.users.items(): members.append({ "user_id": user_id, "display_name": member.display_name or user_id, "power_level": member.power_level, }) await client.close() logger.info(f"Retrieved {len(members)} members from {room_id}") return members except Exception as e: logger.error(f"Failed to get room members: {e}") return [{"error": str(e)}] @mcp.tool() async def matrix_set_room_topic(room_id: str, topic: str) -> str: """ Set the topic/description of a Matrix room. Requires sufficient power level in the room. Args: room_id: Matrix room ID topic: New room topic/description Returns: Confirmation message or error Examples: set_room_topic("!abc:matrix.org", "Discussion about AI projects") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Set room topic await client.room_put_state( room_id=room_id, event_type="m.room.topic", content={"topic": topic} ) await client.close() logger.info(f"Set topic for {room_id}: {topic[:50]}...") return f"✓ Room topic updated" except Exception as e: logger.error(f"Failed to set room topic: {e}") return f"Error: {str(e)}" @mcp.tool() async def matrix_get_room_state(room_id: str) -> Dict[str, Any]: """ Get comprehensive state information about a Matrix room. Returns room name, topic, member count, encryption status, etc. Args: room_id: Matrix room ID Returns: Dict with room state information Examples: get_room_state("!abc:matrix.org") """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Sync to get rooms await client.sync(timeout=30000) # Find the room if room_id not in client.rooms: await client.close() return {"error": f"Room not found: {room_id}"} room = client.rooms[room_id] # Build state dict state = { "room_id": room_id, "name": room.name or room.display_name or room_id, "topic": room.topic, "member_count": len(room.users), "encrypted": room.encrypted, "room_version": getattr(room, 'room_version', 'unknown'), } await client.close() logger.info(f"Retrieved state for {room_id}") return state except Exception as e: logger.error(f"Failed to get room state: {e}") return {"error": str(e)} @mcp.tool() async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> List[Dict[str, Any]]: """ Search for messages in a Matrix room. Searches recent chat history for text matching the query. Args: room_id: Matrix room ID query: Search text limit: Maximum number of results (default: 10) Returns: List of matching messages with sender, text, timestamp Examples: search_messages("!abc:matrix.org", "meeting", limit=5) """ try: # Load credentials creds = load_credentials() # Create Matrix client MATRIX_DATA_DIR.mkdir(exist_ok=True) client = AsyncClient( homeserver=creds['homeserver'], user=creds['user_id'], store_path=str(MATRIX_DATA_DIR), ) # Restore session client.access_token = creds['access_token'] client.device_id = creds['device_id'] # Get room messages response = await client.room_messages( room_id=room_id, start="", limit=100 # Search last 100 messages ) results = [] if isinstance(response, RoomMessagesResponse): # Search through messages for event in response.chunk: if hasattr(event, 'body') and query.lower() in event.body.lower(): results.append({ "sender": event.sender, "message": event.body, "timestamp": datetime.fromtimestamp(event.server_timestamp / 1000).isoformat(), "event_id": event.event_id, }) if len(results) >= limit: break await client.close() logger.info(f"Found {len(results)} messages matching '{query}'") return results except Exception as e: logger.error(f"Failed to search messages: {e}") return [{"error": str(e)}] if __name__ == "__main__": logger.info("=" * 60) logger.info("Matrix Control MCP Server starting...") logger.info(f"State file: {STATE_FILE}") logger.info(f"Credentials file: {CREDENTIALS_FILE}") logger.info("=" * 60) # Run MCP server (uses stdio transport by default) mcp.run()