#
tokens: 10927/50000 11/11 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .env.example
├── .gitignore
├── chat_handler.py
├── models.py
├── prompts.py
├── pyproject.toml
├── README.md
├── requirements.txt
├── server.py
├── setup.sh
└── storage.py
```

# Files

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
# μ-MCP Configuration
# Copy this file to .env and configure your settings

# OpenRouter API Key (required)
# Get yours at: https://openrouter.ai/keys
OPENROUTER_API_KEY=your_key_here

# Allowed Models (optional)
# Comma-separated list of models to enable
# Leave empty to allow all models
# Examples: gpt-5,gemini-pro,o3,deepseek
# Full list: gpt-5, gpt-5-mini, gpt-4o, o3, o3-mini, o3-mini-high,
#           o4-mini, o4-mini-high, sonnet, opus, haiku,
#           gemini-2.5-pro, gemini-2.5-flash,
#           deepseek-chat, deepseek-r1, grok-4, grok-code-fast-1,
#           qwen3-max
OPENROUTER_ALLOWED_MODELS=

# Logging Level (optional)
# Options: DEBUG, INFO, WARNING, ERROR
# Default: INFO
LOG_LEVEL=INFO
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Virtual Environments
venv/
ENV/
env/
.venv/
.env.local
.env.*.local

# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store

# Testing
.coverage
.pytest_cache/
.mypy_cache/
.dmypy.json
dmypy.json
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
*.log

# Environment variables
.env
.env.local
.env.*.local

# Database
*.db
*.sqlite
*.sqlite3

# Package managers
uv.lock
poetry.lock
Pipfile.lock

# Serena cache
.serena/

# Jupyter Notebook
.ipynb_checkpoints

# pyenv
.python-version

# Celery
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# μ-MCP Server

**μ** (mu) = micro, minimal - in sardonic contrast to zen-mcp's 10,000+ lines of orchestration.

A pure MCP server that does one thing well: enable chat with AI models via OpenRouter.

## Philosophy

Following UNIX principles:
- **Do one thing well**: Provide access to AI models
- **No hardcoded control flow**: The AI agents decide everything
- **Minimal interface**: One tool, clean parameters
- **Persistent state**: Conversations persist across sessions
- **Model agnostic**: Support any OpenRouter model

## Features

- ✅ **Multi-model conversations** - Switch models mid-conversation
- ✅ **Persistent storage** - Conversations saved to disk
- ✅ **Model registry** - Curated models with capabilities
- ✅ **LLM-driven model selection** - Calling agent picks the best model
- ✅ **Reasoning effort control** - Simple pass-through to OpenRouter (low/medium/high)
- ✅ **MCP prompts** - Slash commands `/mu:chat` and `/mu:continue`
- ✅ **Token-based budgeting** - Smart file truncation
- ✅ **Proper MIME types** - Correct image format handling

## What's NOT Included

- ❌ Workflow orchestration (let AI decide)
- ❌ Step tracking (unnecessary complexity)
- ❌ Confidence levels (trust the models)
- ❌ Expert validation (models are the experts)
- ❌ Hardcoded procedures (pure AI agency)
- ❌ Web search implementation (just ask Claude)
- ❌ Multiple providers (OpenRouter handles all)

## Setup

### Quick Install (with uv)

1. **Get OpenRouter API key**: https://openrouter.ai/keys

2. **Run setup script**:
   ```bash
   ./setup.sh
   ```
   
   This will:
   - Install `uv` if not present (blazing fast Python package manager)
   - Install dependencies
   - Show you the Claude Desktop config

3. **Add to Claude Desktop config** (`~/.config/claude/claude_desktop_config.json`):
   ```json
   {
     "mcpServers": {
       "mu-mcp": {
         "command": "uv",
         "args": ["--directory", "/path/to/mu-mcp", "run", "python", "/path/to/mu-mcp/server.py"],
         "env": {
           "OPENROUTER_API_KEY": "your-key-here"
         }
       }
     }
   }
   ```

4. **Restart Claude Desktop**

### Manual Install (traditional)

If you prefer pip/venv:
```bash
python3 -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install -r requirements.txt
```

Then use this Claude Desktop config:
```json
{
  "mcpServers": {
    "mu-mcp": {
      "command": "/path/to/mu-mcp/venv/bin/python",
      "args": ["/path/to/mu-mcp/server.py"],
      "env": {
        "OPENROUTER_API_KEY": "your-key-here"
      }
    }
  }
}

## Usage

### Basic Chat
```
/mu:chat
Then specify model and prompt: "Use gpt-5 to explain quantum computing"
```

### Continue Conversations
```
/mu:continue
Claude sees your recent conversations and can intelligently continue them
Preserves full context even after Claude's memory is compacted
```

**Key Use Case**: Maintain context between different Claude sessions. When Claude's context gets compacted or you need to switch between tasks, `/mu:continue` allows Claude to see all your recent conversations (with titles, timestamps, and models used) and seamlessly resume where you left off. The agent intelligently selects or asks which conversation to continue based on your needs.

### Challenge Mode
```
/mu:challenge
Encourages critical thinking and avoids reflexive agreement
```

### Multi-AI Discussion
```
/mu:discuss
Orchestrate multi-turn discussions among diverse AI models
```

### Model Selection
```
Chat with GPT-5 about code optimization
Chat with O3 Mini High for complex reasoning
Chat with DeepSeek R1 for systematic analysis  
Chat with Claude about API design
```

### Reasoning Effort Control
```
Chat with o3-mini using high reasoning effort for complex problems
Chat with gpt-5 using low reasoning effort for quick responses
Chat with o4-mini-high using medium reasoning effort for balanced analysis
```

Note: Reasoning effort is automatically ignored by models that don't support it.

### With Files and Images
```
Review this code: /path/to/file.py
Analyze this diagram: /path/to/image.png
```

### Model Selection by LLM
The calling LLM agent (Claude) sees all available models with their descriptions and capabilities, allowing intelligent selection based on:
- Task requirements and complexity
- Performance vs cost trade-offs  
- Specific model strengths
- Context window needs
- Image support requirements

## Architecture

```
server.py         # MCP server with prompt handlers
chat_handler.py   # Chat logic with multi-model support
models.py         # Model registry and capabilities
prompts.py        # System prompts for peer AI collaboration
storage.py        # Persistent conversation storage
.env.example      # Configuration template
```

## Configuration

### Environment Variables

- `OPENROUTER_API_KEY` - Your OpenRouter API key (required)
- `OPENROUTER_ALLOWED_MODELS` - Comma-separated list of allowed models (optional)
- `LOG_LEVEL` - Logging verbosity (DEBUG, INFO, WARNING, ERROR)

## Why μ-MCP?

### The Problem with zen-mcp-server

zen-mcp grew to **10,000+ lines** trying to control AI behavior:
- 15+ specialized tools with overlapping functionality
- Complex workflow orchestration that limits AI agency
- Hardcoded decision trees that prescribe solutions
- "Step tracking" and "confidence levels" that add noise
- Redundant schema fields and validation layers

### The μ-MCP Approach

**Less code, more capability**:
- Single tool that does one thing perfectly
- AI agents make all decisions
- Clean, persistent conversation state
- Model capabilities, not hardcoded behaviors
- Trust in AI intelligence over procedural control

### Philosophical Difference

**zen-mcp**: "Let me orchestrate 12 steps for you to debug this code"
**μ-mcp**: "Here's the model catalog. Pick what you need."

The best tool is the one that gets out of the way.

## Related Projects

- [zen-mcp-server](https://github.com/winnative/zen-mcp-server) - The bloated alternative we're reacting against

## License

MIT
```

--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------

```
mcp>=1.0.0
aiohttp>=3.9.0
python-dotenv>=1.0.0
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[project]
name = "mu-mcp"
version = "2.0.0"
description = "Minimal MCP server for AI model interactions via OpenRouter"
requires-python = ">=3.10"
dependencies = [
    "mcp>=1.0.0",
    "aiohttp>=3.9.0",
    "python-dotenv>=1.0.0",
]

[tool.uv]
dev-dependencies = []

[project.scripts]
mu-mcp = "server:main"
```

--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------

```bash
#!/bin/bash

echo "🚀 μ-MCP Server Setup (with uv)"
echo "=============================="

# Check if uv is installed
if ! command -v uv &> /dev/null; then
    echo "📦 Installing uv..."
    curl -LsSf https://astral.sh/uv/install.sh | sh
    
    # Add to PATH for current session
    export PATH="$HOME/.cargo/bin:$PATH"
    
    echo "✅ uv installed successfully"
else
    echo "✅ uv is already installed"
fi

# Install dependencies using uv
echo ""
echo "📥 Installing dependencies..."
uv pip sync requirements.txt

# Check for API key
if [ -z "$OPENROUTER_API_KEY" ]; then
    echo ""
    echo "⚠️  OPENROUTER_API_KEY not set!"
    echo "Please add to your shell profile:"
    echo "export OPENROUTER_API_KEY='your-api-key'"
    echo ""
    echo "Get your API key at: https://openrouter.ai/keys"
else
    echo "✅ OpenRouter API key found"
fi

# Create MCP config
echo ""
echo "📝 Add to Claude Desktop config (~/.config/claude/claude_desktop_config.json):"
echo ""
cat << EOF
{
  "mcpServers": {
    "mu-mcp": {
      "command": "uv",
      "args": ["--directory", "$(pwd)", "run", "python", "$(pwd)/server.py"],
      "env": {
        "OPENROUTER_API_KEY": "\$OPENROUTER_API_KEY"
      }
    }
  }
}
EOF

echo ""
echo "✅ Setup complete! Restart Claude Desktop to use μ-MCP server."
echo ""
echo "To test the server manually, run:"
echo "  uv run python server.py"
```

--------------------------------------------------------------------------------
/prompts.py:
--------------------------------------------------------------------------------

```python
"""System prompts for μ-MCP."""


def get_llm_system_prompt(model_name: str = None) -> str:
    """
    System prompt for the LLM being called.
    Modern, direct, without childish "you are" patterns.
    """
    return """Collaborate as a technical peer with Claude, the AI agent requesting assistance.

Core principles:
- Provide expert analysis and alternative perspectives
- Challenge assumptions constructively when warranted
- Share implementation details and edge cases
- Acknowledge uncertainty rather than guessing

When additional context would strengthen your response:
- Request Claude perform web searches for current documentation
- Ask Claude to provide specific files or code sections

Format code with proper syntax highlighting.
Maintain technical precision over conversational comfort.
Skip unnecessary preambles - dive directly into substance."""


def get_request_wrapper() -> str:
    """
    Wrapper text to inform the peer AI that this request is from Claude.
    """
    return """

---

REQUEST FROM CLAUDE: The following query comes from Claude, an AI assistant seeking peer collaboration."""


def get_response_wrapper(model_name: str) -> str:
    """
    Wrapper text for Claude to understand this is another AI's perspective.
    
    Args:
        model_name: Short model name (e.g., "gpt-5", "sonnet")
    """
    # Format short name for display (e.g., "gpt-5" -> "GPT 5")
    display_name = model_name.upper().replace("-", " ")
    return f"""

---

PEER AI RESPONSE ({display_name}): Evaluate this perspective critically and integrate valuable insights."""


def get_agent_tool_description() -> str:
    """
    Description for the calling agent (Claude) about how to use this tool.
    """
    return """Direct access to state-of-the-art AI models via OpenRouter.

Provide EXACTLY ONE:
- title: Start fresh (when switching topics, context too long, or isolating model contexts)
- continuation_id: Continue existing conversation (preserves full context)

When starting fresh: Model has no context - include background details or attach files
When continuing: Model has conversation history - don't repeat context

FILE ATTACHMENT BEST PRACTICES:
- Proactively attach relevant files when starting new conversations for context
- For long content (git diffs, logs, terminal output), save to a file and attach it rather than pasting verbatim in prompt
- Files are processed more efficiently and precisely than inline text"""
```

--------------------------------------------------------------------------------
/models.py:
--------------------------------------------------------------------------------

```python
"""OpenRouter model registry and capabilities."""

import os
from dataclasses import dataclass
from typing import Optional

# Load environment variables from .env file
from dotenv import load_dotenv
load_dotenv()


@dataclass
class ModelCapabilities:
    """Model metadata for routing and selection."""
    
    name: str  # Full OpenRouter model path
    description: str  # What the model is best for


# OpenRouter model registry - popular models with good support
OPENROUTER_MODELS = {
    # OpenAI Models
    "gpt-5": ModelCapabilities(
        name="openai/gpt-5",
        description="Most advanced OpenAI model with extended context. Excels at complex reasoning, coding, and multimodal understanding",
    ),
    "gpt-5-mini": ModelCapabilities(
        name="openai/gpt-5-mini",
        description="Efficient GPT-5 variant. Balances performance and cost for general-purpose tasks",
    ),
    "gpt-4o": ModelCapabilities(
        name="openai/gpt-4o",
        description="Multimodal model supporting text, image, audio, and video. Strong at creative writing and following complex instructions",
    ),
    "o3": ModelCapabilities(
        name="openai/o3",
        description="Advanced reasoning model with tool integration and visual reasoning. Excels at mathematical proofs and complex problem-solving",
    ),
    "o3-mini": ModelCapabilities(
        name="openai/o3-mini",
        description="Production-ready small reasoning model with function calling and structured outputs. Good for systematic problem-solving",
    ),
    "o3-mini-high": ModelCapabilities(
        name="openai/o3-mini-high",
        description="Enhanced O3 Mini with deeper reasoning, better accuracy vs standard O3 Mini",
    ),
    "o4-mini": ModelCapabilities(
        name="openai/o4-mini",
        description="Fast reasoning model optimized for speed. Exceptional at math, coding, and visual tasks with tool support",
    ),
    "o4-mini-high": ModelCapabilities(
        name="openai/o4-mini-high",
        description="Premium O4 Mini variant with enhanced reasoning depth and accuracy",
    ),
    
    # Anthropic Models
    "sonnet": ModelCapabilities(
        name="anthropic/claude-sonnet-4",
        description="Industry-leading coding model with superior instruction following. Excellent for software development and technical writing",
    ),
    "opus": ModelCapabilities(
        name="anthropic/claude-opus-4.1",
        description="Most capable Claude model for sustained complex work. Strongest at deep analysis and long-running tasks",
    ),
    "haiku": ModelCapabilities(
        name="anthropic/claude-3.5-haiku",
        description="Fast, efficient model matching previous flagship performance. Great for high-volume, quick-response scenarios",
    ),
    
    # Google Models
    "gemini-2.5-pro": ModelCapabilities(
        name="google/gemini-2.5-pro",
        description="Massive context window with thinking mode. Best for analyzing huge datasets, codebases, and STEM reasoning",
    ),
    "gemini-2.5-flash": ModelCapabilities(
        name="google/gemini-2.5-flash",
        description="Best price-performance with thinking capabilities. Ideal for high-volume tasks with multimodal and multilingual support",
    ),
    
    # DeepSeek Models
    "deepseek-chat": ModelCapabilities(
        name="deepseek/deepseek-chat-v3.1",
        description="Hybrid model switching between reasoning and direct modes. Strong multilingual support and code completion",
    ),
    "deepseek-r1": ModelCapabilities(
        name="deepseek/deepseek-r1",
        description="Open-source reasoning model with exceptional math capabilities. Highly cost-effective for complex reasoning tasks",
    ),
    
    # X.AI Models
    "grok-4": ModelCapabilities(
        name="x-ai/grok-4",
        description="Multimodal model with strong reasoning and analysis capabilities. Excellent at complex problem-solving and scientific tasks",
    ),
    "grok-code-fast-1": ModelCapabilities(
        name="x-ai/grok-code-fast-1",
        description="Ultra-fast coding specialist optimized for IDE integration. Best for rapid code generation and bug fixes",
    ),
    
    # Qwen Models
    "qwen3-max": ModelCapabilities(
        name="qwen/qwen3-max",
        description="Trillion-parameter model with ultra-long context. Excels at complex reasoning, structured data, and creative tasks",
    ),
}


def get_allowed_models() -> dict[str, ModelCapabilities]:
    """Get models filtered by OPENROUTER_ALLOWED_MODELS env var."""
    allowed = os.getenv("OPENROUTER_ALLOWED_MODELS", "")
    
    if not allowed:
        # No restrictions, return all models
        return OPENROUTER_MODELS
    
    # Parse comma-separated list
    allowed_names = [name.strip().lower() for name in allowed.split(",")]
    filtered = {}
    
    for key, model in OPENROUTER_MODELS.items():
        # Check main key
        if key.lower() in allowed_names:
            filtered[key] = model
            continue
                
        # Check full model name
        if model.name.split("/")[-1].lower() in allowed_names:
            filtered[key] = model
    
    return filtered


def resolve_model(name: str) -> Optional[str]:
    """Resolve a model name to the full OpenRouter model path."""
    if not name:
        return None
        
    name_lower = name.lower()
    
    # Check if it's already a full path
    if "/" in name:
        return name
    
    # Check available models
    models = get_allowed_models()
    
    # Direct key match
    if name_lower in models:
        return models[name_lower].name
    
    # Check by model name suffix
    for key, model in models.items():
        if model.name.endswith(f"/{name_lower}"):
            return model.name
    
    return None


def get_short_name(full_name: str) -> Optional[str]:
    """Get the short name (key) for a full model path.
    
    Args:
        full_name: Full OpenRouter model path (e.g., "openai/gpt-5")
        
    Returns:
        Short name key (e.g., "gpt-5") or None if not found
    """
    if not full_name:
        return None
    
    # Check available models for matching full name
    models = get_allowed_models()
    
    for key, model in models.items():
        if model.name == full_name:
            return key
    
    # If not found in registry, return None
    # This handles cases where a custom full path was used
    return None

```

--------------------------------------------------------------------------------
/chat_handler.py:
--------------------------------------------------------------------------------

```python
"""Chat handler for μ-MCP."""

import base64
import logging
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Optional, Union

import aiohttp

from models import (
    get_allowed_models,
    resolve_model,
    get_short_name,
)
from prompts import (
    get_llm_system_prompt,
    get_response_wrapper,
    get_request_wrapper,
)
from storage import ConversationStorage

logger = logging.getLogger(__name__)


class ChatHandler:
    """Handle chat interactions with OpenRouter models."""

    def __init__(self):
        self.api_key = os.getenv("OPENROUTER_API_KEY")
        if not self.api_key:
            raise ValueError("OPENROUTER_API_KEY environment variable not set")
        
        self.base_url = "https://openrouter.ai/api/v1/chat/completions"
        
        # Initialize persistent storage with default directory
        self.storage = ConversationStorage()

    async def chat(
        self,
        prompt: str,
        model: str,  # Now required
        title: Optional[str] = None,
        continuation_id: Optional[str] = None,
        files: Optional[list[str]] = None,
        images: Optional[list[str]] = None,
        reasoning_effort: Optional[str] = "medium",
    ) -> dict:
        """
        Chat with an AI model.
        
        Args:
            prompt: The user's message
            model: Model name (required)
            title: Title for a new conversation (provide this OR continuation_id, not both)
            continuation_id: UUID to continue existing conversation (provide this OR title, not both)
            files: List of file paths to include
            images: List of image paths to include
            reasoning_effort: Reasoning depth - "low", "medium", or "high" (for models that support it)
        
        Returns dict with:
        - content: The model's response with wrapper
        - continuation_id: UUID for continuing this conversation
        - model_used: The actual model that was used
        """
        # Resolve model name/alias
        resolved_model = resolve_model(model)
        if not resolved_model:
            # If not found in registry, use as-is (might be full path)
            resolved_model = model
        
        # Validate: exactly one of title or continuation_id must be provided
        if (title and continuation_id):
            return {
                "error": "Cannot provide both 'title' and 'continuation_id'. Use 'title' for new conversations or 'continuation_id' to continue existing ones.",
                "continuation_id": None,
                "model_used": None,
            }
        
        if (not title and not continuation_id):
            return {
                "error": "Must provide either 'title' for a new conversation or 'continuation_id' to continue an existing one.",
                "continuation_id": None,
                "model_used": None,
            }
        
        # Get or create conversation
        messages_with_metadata = []
        if continuation_id:
            # Try to load from persistent storage
            conversation_data = self.storage.load_conversation(continuation_id)
            if conversation_data:
                messages_with_metadata = conversation_data.get("messages", [])
            else:
                # Fail fast - conversation not found
                return {
                    "error": f"Conversation {continuation_id} not found. Please start a new conversation or use a valid continuation_id.",
                    "continuation_id": None,
                    "model_used": None,
                }
        else:
            # New conversation with title provided
            continuation_id = str(uuid.uuid4())

        # Build the user message with metadata and request wrapper
        wrapped_prompt = prompt + get_request_wrapper()
        user_content = self._build_user_content(wrapped_prompt, files, images)
        user_message = self.storage.add_metadata_to_message(
            {"role": "user", "content": user_content},
            {"target_model": resolved_model}
        )
        messages_with_metadata.append(user_message)
        
        # Get clean messages for API (without metadata)
        api_messages = self.storage.get_messages_for_api(messages_with_metadata)
        
        # Add system prompt for the LLM
        system_prompt = get_llm_system_prompt(resolved_model)
        api_messages.insert(0, {"role": "system", "content": system_prompt})

        # Make API call
        response_text = await self._call_openrouter(
            api_messages, resolved_model, reasoning_effort
        )

        # Add assistant response with metadata
        assistant_message = self.storage.add_metadata_to_message(
            {"role": "assistant", "content": response_text},
            {"model": resolved_model, "model_used": resolved_model}
        )
        messages_with_metadata.append(assistant_message)
        
        # Save conversation to persistent storage
        # Pass title only for new conversations (when title was provided)
        self.storage.save_conversation(
            continuation_id,
            messages_with_metadata,
            {"models_used": [resolved_model]},
            title=title  # Will be None for continuations, actual title for new conversations
        )
        
        # Get short name for agent interface
        short_name = get_short_name(resolved_model)
        # Fall back to resolved model if not in registry (custom path)
        display_name = short_name if short_name else resolved_model
        
        # Add response wrapper for Claude with model identification
        wrapped_response = response_text + get_response_wrapper(display_name)

        return {
            "content": wrapped_response,
            "continuation_id": continuation_id,
            "model_used": display_name,
        }

    def _build_user_content(
        self, prompt: str, files: Optional[list[str]], images: Optional[list[str]]
    ) -> str | list:
        """Build user message content with files and images."""
        content_parts = []

        # Add main prompt
        content_parts.append({"type": "text", "text": prompt})

        # Add files as text
        if files:
            file_content = self._read_files(files)
            if file_content:
                content_parts.append({"type": "text", "text": f"\n\nFiles:\n{file_content}"})

        # Add images as base64 with proper MIME type
        if images:
            for image_path in images:
                result = self._encode_image(image_path)
                if result:
                    encoded_data, mime_type = result
                    content_parts.append(
                        {
                            "type": "image_url",
                            "image_url": {"url": f"data:{mime_type};base64,{encoded_data}"},
                        }
                    )

        # If only text, return string; otherwise return multi-part content
        if len(content_parts) == 1:
            return prompt
        return content_parts

    def _read_files(self, file_paths: list[str]) -> str:
        """Read and combine file contents with token-based budgeting."""
        contents = []
        # Simple token estimation: ~4 chars per token
        # Reserve tokens for prompt and response
        max_file_tokens = 50_000  # ~200k chars
        total_tokens = 0

        for file_path in file_paths:
            try:
                path = Path(file_path)
                if path.exists() and path.is_file():
                    content = path.read_text(errors="ignore")
                    # Estimate tokens
                    file_tokens = len(content) // 4
                    
                    if total_tokens + file_tokens > max_file_tokens:
                        # Truncate if needed
                        remaining_tokens = max_file_tokens - total_tokens
                        if remaining_tokens > 100:  # Worth including partial
                            char_limit = remaining_tokens * 4
                            content = content[:char_limit] + "\n[File truncated]"
                            contents.append(f"\n--- {file_path} ---\n{content}")
                        break
                    
                    contents.append(f"\n--- {file_path} ---\n{content}")
                    total_tokens += file_tokens
            except Exception as e:
                logger.warning(f"Could not read file {file_path}: {e}")

        return "".join(contents)

    def _encode_image(self, image_path: str) -> Optional[tuple[str, str]]:
        """Encode image to base64 with proper MIME type."""
        try:
            path = Path(image_path)
            if path.exists() and path.is_file():
                # Detect MIME type
                mime_type, _ = mimetypes.guess_type(str(path))
                if not mime_type or not mime_type.startswith('image/'):
                    # Default to JPEG for unknown types
                    mime_type = 'image/jpeg'
                
                with open(path, "rb") as f:
                    encoded = base64.b64encode(f.read()).decode("utf-8")
                    return encoded, mime_type
        except Exception as e:
            logger.warning(f"Could not encode image {image_path}: {e}")
        return None

    async def _call_openrouter(
        self,
        messages: list,
        model: str,
        reasoning_effort: Optional[str],
    ) -> str:
        """Make API call to OpenRouter."""
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "HTTP-Referer": "https://github.com/mu-mcp",
            "X-Title": "μ-MCP Server",
        }

        data = {
            "model": model,
            "messages": messages,
        }

        # Add reasoning effort if specified
        # OpenRouter will automatically ignore this for non-reasoning models
        if reasoning_effort:
            data["reasoning"] = {
                "effort": reasoning_effort  # "low", "medium", or "high"
            }

        async with aiohttp.ClientSession() as session:
            async with session.post(self.base_url, headers=headers, json=data) as response:
                if response.status != 200:
                    error_text = await response.text()
                    raise Exception(f"OpenRouter API error: {response.status} - {error_text}")

                result = await response.json()
                return result["choices"][0]["message"]["content"]

```

--------------------------------------------------------------------------------
/storage.py:
--------------------------------------------------------------------------------

```python
"""Persistent conversation storage for μ-MCP."""

import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
import logging

from models import get_short_name

logger = logging.getLogger(__name__)


class ConversationStorage:
    """Handles persistent storage of multi-model conversations."""
    
    def __init__(self):
        """Initialize storage with default directory."""
        # Always use default ~/.mu-mcp/conversations
        self.storage_dir = Path.home() / ".mu-mcp" / "conversations"
        
        # Create directory if it doesn't exist
        self.storage_dir.mkdir(parents=True, exist_ok=True)
        
        # In-memory cache for conversations (no limit within MCP lifecycle)
        self._cache = {}
        
        # Track last conversation for "continue" command
        self._last_conversation_id = None
        self._last_model_used = None
        
        logger.info(f"Conversation storage initialized at: {self.storage_dir}")
    
    def save_conversation(self, conversation_id: str, messages: list, 
                         model_metadata: Optional[dict] = None, title: Optional[str] = None) -> bool:
        """
        Save a conversation to disk and update cache.
        
        Args:
            conversation_id: Unique conversation identifier
            messages: List of message dicts with role and content
            model_metadata: Optional metadata about models used
            title: Optional conversation title
        
        Returns:
            True if saved successfully
        """
        try:
            file_path = self.storage_dir / f"{conversation_id}.json"
            
            # Check if conversation exists to determine created time
            existing = {}
            if file_path.exists():
                with open(file_path, "r") as f:
                    existing = json.load(f)
                    created = existing.get("created")
            else:
                created = datetime.utcnow().isoformat()
            
            # Prepare conversation data
            conversation_data = {
                "id": conversation_id,
                "created": created,
                "updated": datetime.utcnow().isoformat(),
                "messages": messages,
            }
            
            # Add title if provided or preserve existing title
            if title:
                conversation_data["title"] = title
            elif "title" in existing:
                conversation_data["title"] = existing["title"]
            
            # Add model metadata if provided
            if model_metadata:
                conversation_data["model_metadata"] = model_metadata
            
            # Write to file
            with open(file_path, "w") as f:
                json.dump(conversation_data, f, indent=2)
            
            # Update cache (write-through)
            self._cache[conversation_id] = conversation_data
            
            # Update last conversation tracking
            self._last_conversation_id = conversation_id
            # Extract the last model used from messages or metadata
            last_full_name = None
            if model_metadata and "models_used" in model_metadata:
                last_full_name = model_metadata["models_used"][-1] if model_metadata["models_used"] else None
            else:
                # Try to extract from the last assistant message
                for msg in reversed(messages):
                    if msg.get("role") == "assistant":
                        metadata = msg.get("metadata", {})
                        if "model" in metadata:
                            last_full_name = metadata["model"]
                            break
            
            # Convert to short name for agent interface
            if last_full_name:
                short_name = get_short_name(last_full_name)
                self._last_model_used = short_name if short_name else last_full_name
            else:
                self._last_model_used = None
            
            logger.debug(f"Saved conversation {conversation_id} with {len(messages)} messages")
            return True
            
        except Exception as e:
            logger.error(f"Failed to save conversation {conversation_id}: {e}")
            return False
    
    def load_conversation(self, conversation_id: str) -> Optional[dict]:
        """
        Load a conversation from cache or disk.
        
        Args:
            conversation_id: Unique conversation identifier
        
        Returns:
            Conversation data dict or None if not found
        """
        # Check cache first
        if conversation_id in self._cache:
            data = self._cache[conversation_id]
            logger.debug(f"Loaded conversation {conversation_id} from cache with {len(data.get('messages', []))} messages")
            return data
        
        # Not in cache, try loading from disk
        try:
            file_path = self.storage_dir / f"{conversation_id}.json"
            
            if not file_path.exists():
                logger.debug(f"Conversation {conversation_id} not found")
                return None
            
            with open(file_path, "r") as f:
                data = json.load(f)
            
            # Add to cache for future access
            self._cache[conversation_id] = data
            
            logger.debug(f"Loaded conversation {conversation_id} from disk with {len(data.get('messages', []))} messages")
            return data
            
        except Exception as e:
            logger.error(f"Failed to load conversation {conversation_id}: {e}")
            return None
    
    def get_last_conversation_info(self) -> tuple[Optional[str], Optional[str]]:
        """
        Get the last conversation ID and model used.
        
        Returns:
            Tuple of (conversation_id, model_used) or (None, None) if no conversations
        """
        return self._last_conversation_id, self._last_model_used
    
    
    def get_messages_for_api(self, messages: list) -> list:
        """
        Extract just role and content for API calls.
        Strips metadata that OpenRouter doesn't understand.
        
        Args:
            messages: List of message dicts potentially with metadata
        
        Returns:
            Clean list of messages for API
        """
        clean_messages = []
        
        for msg in messages:
            # Only include role and content for API
            clean_msg = {
                "role": msg.get("role"),
                "content": msg.get("content")
            }
            clean_messages.append(clean_msg)
        
        return clean_messages
    
    def add_metadata_to_message(self, message: dict, metadata: dict) -> dict:
        """
        Add metadata to a message for storage.
        
        Args:
            message: Basic message dict with role and content
            metadata: Metadata to add (timestamp, model, etc.)
        
        Returns:
            Message with metadata added
        """
        return {
            **message,
            "metadata": {
                "timestamp": datetime.utcnow().isoformat(),
                **metadata
            }
        }
    
    def list_recent_conversations(self, limit: int = 20) -> list[dict]:
        """
        List the most recently updated conversations.
        
        Args:
            limit: Maximum number of conversations to return
        
        Returns:
            List of conversation summaries sorted by update time (newest first)
        """
        conversations = []
        
        try:
            # Get all conversation files with their modification times
            files_with_mtime = []
            for file_path in self.storage_dir.glob("*.json"):
                try:
                    mtime = file_path.stat().st_mtime
                    files_with_mtime.append((mtime, file_path))
                except Exception as e:
                    logger.warning(f"Failed to stat file {file_path}: {e}")
                    continue
            
            # Sort by modification time (newest first) and take only the limit
            files_with_mtime.sort(key=lambda x: x[0], reverse=True)
            recent_files = files_with_mtime[:limit]
            
            # Now load only the recent files
            for _, file_path in recent_files:
                try:
                    with open(file_path, "r") as f:
                        data = json.load(f)
                        
                        # Extract key information
                        conv_summary = {
                            "id": data.get("id"),
                            "title": data.get("title"),
                            "created": data.get("created"),
                            "updated": data.get("updated"),
                        }
                        
                        # Extract model used from messages or metadata
                        model_full_name = None
                        if "model_metadata" in data and "models_used" in data["model_metadata"]:
                            models = data["model_metadata"]["models_used"]
                            model_full_name = models[-1] if models else None
                        else:
                            # Try to extract from the last assistant message
                            for msg in reversed(data.get("messages", [])):
                                if msg.get("role") == "assistant":
                                    metadata = msg.get("metadata", {})
                                    if "model" in metadata:
                                        model_full_name = metadata["model"]
                                        break
                        
                        # Convert to short name for agent interface
                        if model_full_name:
                            short_name = get_short_name(model_full_name)
                            model_used = short_name if short_name else model_full_name
                        else:
                            model_used = None
                        
                        conv_summary["model_used"] = model_used
                        
                        # If no title exists (should not happen with new version)
                        # just use a placeholder
                        if not conv_summary["title"]:
                            conv_summary["title"] = "[Untitled conversation]"
                        
                        conversations.append(conv_summary)
                        
                except Exception as e:
                    logger.warning(f"Failed to read conversation file {file_path}: {e}")
                    continue
            
            # The files are already in order from the filesystem sorting
            # But we should still sort by the actual "updated" field in case of discrepancies
            conversations.sort(key=lambda x: x.get("updated", ""), reverse=True)
            
            return conversations
            
        except Exception as e:
            logger.error(f"Failed to list recent conversations: {e}")
            return []
```

--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------

```python
#!/usr/bin/env python3
"""μ-MCP Server - Minimal MCP server for AI model interactions.

In contrast to zen-mcp's 10,000+ lines of orchestration,
μ-MCP provides pure model access with no hardcoded workflows.
"""

import asyncio
import json
import logging
import os
import sys
from typing import Any

# Load environment variables from .env file
from dotenv import load_dotenv
load_dotenv()

from mcp import McpError, types
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.types import (
    TextContent,
    Tool,
    ServerCapabilities,
    ToolsCapability,
    Prompt,
    GetPromptResult,
    PromptMessage,
    PromptsCapability,
)

from models import get_allowed_models
from prompts import get_agent_tool_description

# Configure logging
log_level = os.getenv("LOG_LEVEL", "INFO")
logging.basicConfig(
    level=getattr(logging, log_level),
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

app = Server("μ-mcp")


@app.list_tools()
async def list_tools() -> list[Tool]:
    """List available tools - just one: chat."""
    # Build model enum for schema
    models = get_allowed_models()
    
    # Build model enum for schema
    model_enum = []
    model_descriptions = []
    
    for key, model in models.items():
        # Use short name (key) in enum
        model_enum.append(key)
        # Show only short name in description, not full path
        model_descriptions.append(f"• {key}: {model.description}")
    
    # Build the combined description
    models_description = "Select the AI model that best fits your task:\n\n" + "\n".join(model_descriptions)
    
    return [
        Tool(
            name="chat",
            description=get_agent_tool_description(),
            inputSchema={
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Your message or question"
                    },
                    "model": {
                        "type": "string",
                        "enum": model_enum,
                        "description": models_description,
                    },
                    "title": {
                        "type": "string",
                        "description": "Title for new conversation (3-10 words). Provide this OR continuation_id, not both",
                    },
                    "continuation_id": {
                        "type": "string",
                        "description": "UUID to continue existing conversation. Provide this OR title, not both",
                    },
                    "files": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Absolute paths to files to include as context",
                    },
                    "images": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Absolute paths to images to include",
                    },
                    "reasoning_effort": {
                        "type": "string",
                        "enum": ["low", "medium", "high"],
                        "description": "Reasoning depth for models that support it (low=20%, medium=50%, high=80% of computation)",
                        "default": "medium",
                    },
                },
                "required": ["prompt", "model"],  # Model is now required
            },
        )
    ]


@app.list_prompts()
async def list_prompts() -> list[Prompt]:
    """List available prompts for slash commands."""
    return [
        Prompt(
            name="chat",
            description="Start a chat with AI models",
            arguments=[],
        ),
        Prompt(
            name="continue",
            description="Continue the previous conversation",
            arguments=[],
        ),
        Prompt(
            name="challenge",
            description="Encourage critical thinking and avoid reflexive agreement",
            arguments=[],
        ),
        Prompt(
            name="discuss",
            description="Orchestrate multi-turn discussion among multiple AIs",
            arguments=[],
        ),
    ]


@app.get_prompt()
async def get_prompt(name: str, arguments: dict[str, Any] = None) -> GetPromptResult:
    """Generate prompt text for slash commands."""
    if name == "chat":
        return GetPromptResult(
            description="Start a chat with AI models",
            messages=[
                PromptMessage(
                    role="user",
                    content=TextContent(
                        type="text",
                        text="Use the chat tool to interact with an AI model."
                    )
                )
            ],
        )
    elif name == "continue":
        # Get the list of recent conversations
        from chat_handler import ChatHandler
        from datetime import datetime
        
        handler = ChatHandler()
        recent_conversations = handler.storage.list_recent_conversations(20)
        
        if recent_conversations:
            # Format the conversation list
            conv_list = []
            for i, conv in enumerate(recent_conversations, 1):
                # Calculate relative time
                if conv.get("updated"):
                    try:
                        updated_time = datetime.fromisoformat(conv["updated"])
                        now = datetime.utcnow()
                        time_diff = now - updated_time
                        
                        # Format relative time
                        if time_diff.days > 0:
                            time_str = f"{time_diff.days} day{'s' if time_diff.days > 1 else ''} ago"
                        elif time_diff.seconds >= 3600:
                            hours = time_diff.seconds // 3600
                            time_str = f"{hours} hour{'s' if hours > 1 else ''} ago"
                        elif time_diff.seconds >= 60:
                            minutes = time_diff.seconds // 60
                            time_str = f"{minutes} minute{'s' if minutes > 1 else ''} ago"
                        else:
                            time_str = "just now"
                    except:
                        time_str = "unknown time"
                else:
                    time_str = "unknown time"
                
                # Get display text (title should always exist)
                display = conv.get("title", "[Untitled]")
                # model_used is already a short name from list_recent_conversations()
                model = conv.get("model_used", "unknown model")
                
                conv_list.append(
                    f"{i}. [{time_str}] {display}\n"
                    f"   Model: {model} | ID: {conv['id']}"
                )
            
            instruction_text = f"""Select a conversation to continue using the chat tool.

Recent Conversations (newest first):
{chr(10).join(conv_list)}

To continue a conversation, use the chat tool with the desired continuation_id.
Example: Use continuation_id: "{recent_conversations[0]['id']}" for the most recent conversation.

This allows you to access the full conversation history even if your context was compacted."""
        else:
            instruction_text = "No previous conversations found. Start a new conversation using the chat tool."
        
        return GetPromptResult(
            description="Continue a previous conversation",
            messages=[
                PromptMessage(
                    role="user",
                    content=TextContent(
                        type="text",
                        text=instruction_text
                    )
                )
            ],
        )
    elif name == "challenge":
        return GetPromptResult(
            description="Encourage critical thinking and avoid reflexive agreement",
            messages=[
                PromptMessage(
                    role="user",
                    content=TextContent(
                        type="text",
                        text="""CRITICAL REASSESSMENT MODE:

When using the chat tool, wrap your prompt with instructions for the AI to:
- Challenge ideas and think critically before responding
- Evaluate whether they actually agree or disagree
- Provide thoughtful analysis rather than reflexive agreement

Example: Instead of accepting a statement, ask the AI to examine it for accuracy, completeness, and reasoning flaws.
This promotes truth-seeking over compliance."""
                    )
                )
            ],
        )
    elif name == "discuss":
        return GetPromptResult(
            description="Orchestrate multi-turn discussion among multiple AIs",
            messages=[
                PromptMessage(
                    role="user",
                    content=TextContent(
                        type="text",
                        text="""MULTI-AI DISCUSSION MODE:

Use the chat tool to orchestrate a multi-turn discussion among diverse AI models.

Requirements:
1. Select models with complementary strengths based on the topic
2. Start fresh conversations (no continuation_id) for each model
3. Provide context about the topic and other participants' perspectives
4. Exchange key insights between models across multiple turns
5. Encourage constructive disagreement - not consensus for its own sake
6. Continue until either consensus emerges naturally OR sufficiently diverse perspectives are gathered

Do NOT stop after one round. Keep the discussion going through multiple exchanges until reaching a natural conclusion.
Synthesize findings, highlighting both agreements and valuable disagreements."""
                    )
                )
            ],
        )
    else:
        raise ValueError(f"Unknown prompt: {name}")


@app.call_tool()
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
    """Handle tool calls - just chat."""
    if name != "chat":
        raise McpError(f"Unknown tool: {name}")

    from chat_handler import ChatHandler

    try:
        handler = ChatHandler()
        result = await handler.chat(**arguments)
        return [TextContent(type="text", text=json.dumps(result, indent=2))]
    except Exception as e:
        logger.error(f"Chat tool error: {e}")
        return [TextContent(type="text", text=f"Error: {str(e)}")]


async def main():
    """Run the MCP server."""
    # Check for API key
    if not os.getenv("OPENROUTER_API_KEY"):
        logger.error("OPENROUTER_API_KEY environment variable not set")
        logger.error("Get your API key at: https://openrouter.ai/keys")
        sys.exit(1)

    # Log configuration
    models = get_allowed_models()
    logger.info(f"Starting μ-MCP Server...")
    logger.info(f"Available models: {len(models)}")
    
    # Use stdio transport
    from mcp.server.stdio import stdio_server

    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="μ-mcp",
                server_version="2.0.0",
                capabilities=ServerCapabilities(
                    tools=ToolsCapability(),
                    prompts=PromptsCapability(),
                ),
            ),
        )


if __name__ == "__main__":
    asyncio.run(main())

```