#!/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()