From 2e7c49bf68be131624da0b882957e9679863bb23 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 6 Jan 2026 13:52:44 +0100 Subject: [PATCH] Major update --- .gitignore | 11 +- README.md | 535 ++++++-- oai.py | 3397 ++++++++++++++++++++++++++++++++++++++++++---- requirements.txt | 64 +- 4 files changed, 3557 insertions(+), 450 deletions(-) diff --git a/.gitignore b/.gitignore index 107b564..3c2ad4a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,9 @@ Pipfile.lock # Consider if you want to include or exclude ._* *~.nib *~.xib -README.md.old -oai.zip + +# Added by author +*.zip .note diagnose.py *.log @@ -33,4 +34,8 @@ build* compiled/ images/oai-iOS-Default-1024x1024@1x.png images/oai.icon/ -b0.sh \ No newline at end of file +b0.sh +*.bak +*.old +*.sh +*.back diff --git a/README.md b/README.md index 0c91bd6..a3c2e5d 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,77 @@ # oAI - OpenRouter AI Chat -A terminal-based chat interface for OpenRouter API with conversation management, cost tracking, and rich formatting. +A powerful terminal-based chat interface for OpenRouter API with **MCP (Model Context Protocol)** support, enabling AI agents to access local files and query SQLite databases directly. ## Description -oAI is a command-line chat application that provides an interactive interface to OpenRouter's AI models. It features conversation persistence, file attachments, export capabilities, and detailed session metrics. +oAI is a feature-rich command-line chat application that provides an interactive interface to OpenRouter's AI models. It now includes **MCP integration** for local file system access and read-only database querying, allowing AI to help with code analysis, data exploration, and more. ## Features -- Interactive chat with multiple AI models via OpenRouter -- Model selection with search functionality -- Conversation save/load/export (Markdown, JSON, HTML) -- File attachment support (code files and images) -- Session cost tracking and credit monitoring -- Rich terminal formatting with syntax highlighting -- Persistent command history -- Configurable system prompts and token limits -- SQLite-based configuration and conversation storage +### Core Features +- 🤖 Interactive chat with 300+ AI models via OpenRouter +- 🔍 Model selection with search and capability filtering +- 💾 Conversation save/load/export (Markdown, JSON, HTML) +- 📎 File attachment support (images, PDFs, code files) +- 💰 Session cost tracking and credit monitoring +- 🎨 Rich terminal formatting with syntax highlighting +- 📝 Persistent command history with search (Ctrl+R) +- ⚙️ Configurable system prompts and token limits +- 🗄️ SQLite-based configuration and conversation storage +- 🌐 Online mode (web search capabilities) +- 🧠 Conversation memory toggle (save costs with stateless mode) + +### NEW: MCP (Model Context Protocol) v2.1.0-beta +- 🔧 **File Mode**: AI can read, search, and list your local files + - Automatic .gitignore filtering + - Virtual environment exclusion (venv, node_modules, etc.) + - Supports code files, text, JSON, YAML, and more + - Large file handling (auto-truncates >50KB) + +- 🗄️ **Database Mode**: AI can query your SQLite databases + - Read-only access (no data modification possible) + - Schema inspection (tables, columns, indexes) + - Full-text search across all tables + - SQL query execution (SELECT, JOINs, CTEs, subqueries) + - Query validation and timeout protection + - Result limiting (max 1000 rows) + +- 🔒 **Security Features**: + - Explicit folder/database approval required + - System directory blocking + - Read-only database access + - SQL injection protection + - Query timeout (5 seconds) ## Requirements -- Python 3.7 or higher +- Python 3.10-3.13 (3.14 not supported yet) - OpenRouter API key (get one at https://openrouter.ai) +- Function-calling model required for MCP features (GPT-4, Claude, etc.) -## Screenshot (from version 1.0) +## Screenshot [](https://gitlab.pm/rune/oai/src/branch/main/README.md) -Screenshot of `/help` screen. +*Screenshot from version 1.0 - MCP interface shows mode indicators like `[🔧 MCP: Files]` or `[🗄️ MCP: DB #1]`* ## Installation -### 1. Install Dependencies +### Option 1: From Source (Recommended for Development) -Use the included `requirements.txt` file to install the dependencies: +#### 1. Install Dependencies ```bash pip install -r requirements.txt ``` -### 2. Make the Script Executable +#### 2. Make Executable ```bash chmod +x oai.py ``` -### 3. Copy to PATH - -Copy the script to a directory in your `$PATH` environment variable. Common locations include: +#### 3. Copy to PATH ```bash # Option 1: System-wide (requires sudo) @@ -57,145 +81,408 @@ sudo cp oai.py /usr/local/bin/oai mkdir -p ~/.local/bin cp oai.py ~/.local/bin/oai -# Add to PATH if not already (add to ~/.bashrc or ~/.zshrc) +# Add to PATH if needed (add to ~/.bashrc or ~/.zshrc) export PATH="$HOME/.local/bin:$PATH" ``` -### 4. Verify Installation +#### 4. Verify Installation + +```bash +oai --version +``` + +### Option 2: Pre-built Binaries + +Download platform-specific binaries: +- **macOS (Apple Silicon)**: `oai_vx.x.x_mac_arm64.zip` +- **Linux (x86_64)**: `oai_vx.x.x-linux-x86_64.zip` + +```bash +# Extract and install +unzip oai_vx.x.x_mac_arm64.zip # or `oai_vx.x.x-linux-x86_64.zip` +chmod +x oai +mkdir -p ~/.local/bin +mv oai ~/.local/bin/ +``` + +### Option 3: Build Your Own Binary + +```bash +# Install build dependencies +pip install -r requirements.txt +pip install nuitka ordered-set zstandard + +# Run build script +chmod +x build.sh +./build.sh + +# Binary will be in dist/oai +cp dist/oai ~/.local/bin/ +``` + +### Alternative: Shell Alias + +```bash +# Add to ~/.bashrc or ~/.zshrc +alias oai='python3 /path/to/oai.py' +``` + +## Quick Start + +### First Run Setup ```bash oai ``` -### 5. Alternative Installation (for *nix systems) +On first run, you'll be prompted to enter your OpenRouter API key. -If you have issues with the above method you can add an alias in your `.bashrc`, `.zshrc` etc. - -```bash -alias oai='python3 ' -``` - -On first run, you will be prompted to enter your OpenRouter API key. - -### 6. Use Binaries - -You can also just download the supplied binary for either Mac wit Mx (M1, M2 etc) `oai_mac_arm64.zip` and follow [#3](https://gitlab.pm/rune/oai#3-copy-to-path). Or download for Linux (64bit) `oai_linux_x86_64.zip` and also follow [#3](https://gitlab.pm/rune/oai#3-copy-to-path). - -## Usage - -### Starting the Application +### Basic Usage ```bash +# Start chatting oai + +# Select a model +You> /model + +# Enable MCP for file access +You> /mcp enable +You> /mcp add ~/Documents + +# Ask AI to help with files +[🔧 MCP: Files] You> List all Python files in Documents +[🔧 MCP: Files] You> Read and explain main.py + +# Switch to database mode +You> /mcp add db ~/myapp/data.db +You> /mcp db 1 +[🗄️ MCP: DB #1] You> Show me all tables +[🗄️ MCP: DB #1] You> Find all users created this month ``` -### Basic Commands +## MCP Guide -``` -/help Show all available commands -/model Select an AI model -/config api Set OpenRouter API key -exit Quit the application +### File Mode (Default) + +**Setup:** +```bash +/mcp enable # Start MCP server +/mcp add ~/Projects # Grant access to folder +/mcp add ~/Documents # Add another folder +/mcp list # View all allowed folders ``` -### Configuration - -All configuration is stored in `~/.config/oai/`: -- `oai_config.db` - SQLite database for settings and conversations -- `oai.log` - Application log file -- `history.txt` - Command history - -### Common Workflows - -**Select a Model:** +**Natural Language Usage:** ``` -/model +"List all Python files in Projects" +"Read and explain config.yaml" +"Search for files containing 'TODO'" +"What's in my Documents folder?" ``` -**Paste from clipboard:** -Paste and send content to model -``` -/paste +**Available Tools:** +- `read_file` - Read complete file contents +- `list_directory` - List files/folders (recursive optional) +- `search_files` - Search by name or content + +**Features:** +- ✅ Automatic .gitignore filtering +- ✅ Skips virtual environments (venv, node_modules) +- ✅ Handles large files (auto-truncates >50KB) +- ✅ Cross-platform (macOS, Linux, Windows via WSL) + +### Database Mode + +**Setup:** +```bash +/mcp add db ~/app/database.db # Add SQLite database +/mcp db list # View all databases +/mcp db 1 # Switch to database #1 ``` -Paste with prompt and send content to model +**Natural Language Usage:** ``` -/paste Analyze this text +"Show me all tables in this database" +"Find records mentioning 'error'" +"How many users registered last week?" +"Get the schema for the orders table" +"Show me the 10 most recent transactions" ``` -**Start Chatting:** -``` -You> Hello, how are you? -``` +**Available Tools:** +- `inspect_database` - View schema, tables, columns, indexes +- `search_database` - Full-text search across tables +- `query_database` - Execute read-only SQL queries -**Attach Files:** -``` -You> Debug this code @/path/to/script.py -You> Analyze this image @/path/to/image.png -``` +**Supported Queries:** +- ✅ SELECT statements +- ✅ JOINs (INNER, LEFT, RIGHT, FULL) +- ✅ Subqueries +- ✅ CTEs (Common Table Expressions) +- ✅ Aggregations (COUNT, SUM, AVG, etc.) +- ✅ WHERE, GROUP BY, HAVING, ORDER BY, LIMIT +- ❌ INSERT/UPDATE/DELETE (blocked for safety) -**Save Conversation:** -``` -/save my_conversation -``` +### Mode Management -**Export to File:** +```bash +/mcp status # Show current mode, stats, folders/databases +/mcp files # Switch to file mode +/mcp db # Switch to database mode +/mcp gitignore on # Enable .gitignore filtering (default) +/mcp remove 2 # Remove folder/database by number ``` -/export md notes.md -/export json backup.json -/export html report.html -``` - -**View Session Stats:** - -``` -/stats -/credits -``` - -**Prevous commands input:** - -Use the up/down arrows to see earlier `/`commands and earlier input to model and `` to execute the same command or resend the same input. ## Command Reference -Use `/help` within the application for a complete command reference organized by category: -- Session Commands -- Model Commands -- Configuration -- Token & System -- Conversation Management -- Monitoring & Stats -- File Attachments +### Session Commands +``` +/help [command] Show help menu or detailed command help +/help mcp Comprehensive MCP guide +/clear or /cl Clear terminal screen (or Ctrl+L) +/memory on|off Toggle conversation memory (save costs) +/online on|off Enable/disable web search +/paste [prompt] Paste clipboard content +/retry Resend last prompt +/reset Clear history and system prompt +/prev View previous response +/next View next response +``` + +### MCP Commands +``` +/mcp enable Start MCP server +/mcp disable Stop MCP server +/mcp status Show comprehensive status +/mcp add Add folder for file access +/mcp add db Add SQLite database +/mcp list List all folders +/mcp db list List all databases +/mcp db Switch to database mode +/mcp files Switch to file mode +/mcp remove Remove folder/database +/mcp gitignore on Enable .gitignore filtering +``` + +### Model Commands +``` +/model [search] Select/change AI model +/info [model_id] Show model details (pricing, capabilities) +``` + +### Configuration +``` +/config View all settings +/config api Set API key +/config model Set default model +/config online Set default online mode (on|off) +/config stream Enable/disable streaming (on|off) +/config maxtoken Set max token limit +/config costwarning Set cost warning threshold ($) +/config loglevel Set log level (debug/info/warning/error) +/config log Set log file size (MB) +``` + +### Conversation Management +``` +/save Save conversation +/load Load saved conversation +/delete Delete conversation +/list List saved conversations +/export md|json|html Export conversation +``` + +### Token & System +``` +/maxtoken [value] Set session token limit +/system [prompt] Set system prompt (use 'clear' to reset) +/middleout on|off Enable prompt compression +``` + +### Monitoring +``` +/stats View session statistics +/credits Check OpenRouter credits +``` + +### File Attachments +``` +@/path/to/file Attach file (images, PDFs, code) + +Examples: + Debug @script.py + Analyze @data.json + Review @screenshot.png +``` ## Configuration Options -- API Key: `/config api` -- Base URL: `/config url` -- Streaming: `/config stream on|off` -- Default Model: `/config model` -- Cost Warning: `/config costwarning ` -- Max Token Limit: `/config maxtoken ` +All configuration stored in `~/.config/oai/`: -## File Support +### Files +- `oai_config.db` - SQLite database (settings, conversations, MCP config) +- `oai.log` - Application logs (rotating, configurable size) +- `history.txt` - Command history (searchable with Ctrl+R) -**Supported Code Extensions:** -.py, .js, .ts, .cs, .java, .c, .cpp, .h, .hpp, .rb, .ruby, .php, .swift, .kt, .kts, .go, .sh, .bat, .ps1, .R, .scala, .pl, .lua, .dart, .elm, .xml, .json, .yaml, .yml, .md, .txt +### Key Settings +- **API Key**: OpenRouter authentication +- **Default Model**: Auto-select on startup +- **Streaming**: Real-time response display +- **Max Tokens**: Global and session limits +- **Cost Warning**: Alert threshold for expensive requests +- **Online Mode**: Default web search setting +- **Log Level**: debug/info/warning/error/critical +- **Log Size**: Rotating file size in MB -**Image Support:** -Any image format with proper MIME type (PNG, JPEG, GIF, etc.) +## Supported File Types -## Data Storage +### Code Files +`.py, .js, .ts, .cs, .java, .c, .cpp, .h, .hpp, .rb, .ruby, .php, .swift, .kt, .kts, .go, .sh, .bat, .ps1, .R, .scala, .pl, .lua, .dart, .elm` -- Configuration: `~/.config/oai/oai_config.db` -- Logs: `~/.config/oai/oai.log` -- History: `~/.config/oai/history.txt` +### Data Files +`.json, .yaml, .yml, .xml, .csv, .txt, .md` + +### Images +All standard formats: PNG, JPEG, JPG, GIF, WEBP, BMP + +### Documents +PDF (models with document support) + +### Size Limits +- Images: 10 MB max +- Code/Text: Auto-truncates files >50KB +- Binary data: Displayed as `` + +## MCP Security + +### Access Control +- ✅ Explicit folder/database approval required +- ✅ System directories blocked automatically +- ✅ User confirmation for each addition +- ✅ .gitignore patterns respected (file mode) + +### Database Safety +- ✅ Read-only mode (cannot modify data) +- ✅ SQL query validation (blocks INSERT/UPDATE/DELETE) +- ✅ Query timeout (5 seconds max) +- ✅ Result limits (1000 rows max) +- ✅ Database opened in `mode=ro` + +### File System Safety +- ✅ Read-only access (no write/delete) +- ✅ Virtual environment exclusion +- ✅ Build artifact filtering +- ✅ Maximum file size (10 MB) + +## Tips & Tricks + +### Command History +- **↑/↓ arrows**: Navigate previous commands +- **Ctrl+R**: Search command history +- **Auto-complete**: Start typing `/` for command suggestions + +### Cost Optimization +```bash +/memory off # Disable context (stateless mode) +/maxtoken 1000 # Limit response length +/config costwarning 0.01 # Set alert threshold +``` + +### MCP Best Practices +```bash +# Check status frequently +/mcp status + +# Use specific paths to reduce search time +"List Python files in Projects/app/" # Better than +"List all Python files" # Slower + +# Database queries - be specific +"SELECT * FROM users LIMIT 10" # Good +"SELECT * FROM users" # May hit row limit +``` + +### Debugging +```bash +# Enable debug logging +/config loglevel debug + +# Check log file +tail -f ~/.config/oai/oai.log + +# View MCP statistics +/mcp status # Shows tool call counts +``` + +## Troubleshooting + +### MCP Not Working +```bash +# 1. Check if MCP is installed +python3 -c "import mcp; print('MCP OK')" + +# 2. Verify model supports function calling +/info # Look for "tools" in supported parameters + +# 3. Check MCP status +/mcp status + +# 4. Review logs +tail ~/.config/oai/oai.log +``` + +### Import Errors +```bash +# Reinstall dependencies +pip install --force-reinstall -r requirements.txt +``` + +### Binary Issues (macOS) +```bash +# Remove quarantine +xattr -cr ~/.local/bin/oai + +# Check security settings +# System Settings > Privacy & Security > "Allow Anyway" +``` + +### Database Errors +```bash +# Verify it's a valid SQLite database +sqlite3 database.db ".tables" + +# Check file permissions +ls -la database.db +``` + +## Version History + +### v2.1.0-beta (Current) +- ✨ **NEW**: MCP (Model Context Protocol) integration +- ✨ **NEW**: File system access (read, search, list) +- ✨ **NEW**: SQLite database querying (read-only) +- ✨ **NEW**: Dual mode support (Files & Database) +- ✨ **NEW**: .gitignore filtering +- ✨ **NEW**: Binary data handling in databases +- ✨ **NEW**: Mode indicators in prompt +- ✨ **NEW**: Comprehensive `/help mcp` guide +- 🔧 Improved error handling for tool calls +- 🔧 Enhanced logging for MCP operations +- 🔧 Statistics tracking for tool usage + +### v1.9.6 +- Base version with core chat functionality +- Conversation management +- File attachments +- Cost tracking +- Export capabilities ## License MIT License -Copyright (c) 2024 Rune Olsen +Copyright (c) 2024-2025 Rune Olsen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -221,12 +508,22 @@ Full license: https://opensource.org/licenses/MIT **Rune Olsen** -Blog: https://blog.rune.pm +- Blog: https://blog.rune.pm +- Project: https://iurl.no/oai -## Version +## Contributing -1.0 +Contributions welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Submit a pull request with detailed description -## Support +## Acknowledgments -For issues, questions, or contributions, visit https://iurl.no/oai and create an issue. \ No newline at end of file +- OpenRouter team for the unified AI API +- Rich library for beautiful terminal output +- MCP community for the protocol specification + +--- + +**Star ⭐ this project if you find it useful!** diff --git a/oai.py b/oai.py index 26b92ef..e679ce5 100644 --- a/oai.py +++ b/oai.py @@ -2,7 +2,8 @@ import sys import os import requests -import time # For response time tracking +import time +import asyncio from pathlib import Path from typing import Optional, List, Dict, Any import typer @@ -21,20 +22,34 @@ import sqlite3 import json import datetime import logging -from logging.handlers import RotatingFileHandler # Added for log rotation +from logging.handlers import RotatingFileHandler from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory from rich.logging import RichHandler from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from packaging import version as pkg_version -import io # Added for custom handler +import io +import platform +import shutil +import subprocess +import fnmatch +import signal -# App version. Changes by author with new releases. -version = '1.9.6' +# MCP imports +try: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + print("Warning: MCP library not found. Install with: pip install mcp") + +# App version +version = '2.1.0-beta' app = typer.Typer() -# Application identification for OpenRouter +# Application identification APP_NAME = "oAI" APP_URL = "https://iurl.no/oai" @@ -42,25 +57,26 @@ APP_URL = "https://iurl.no/oai" home = Path.home() config_dir = home / '.config' / 'oai' cache_dir = home / '.cache' / 'oai' -history_file = config_dir / 'history.txt' # Persistent input history file +history_file = config_dir / 'history.txt' database = config_dir / 'oai_config.db' log_file = config_dir / 'oai.log' -# Create dirs if needed +# Create dirs config_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True) -# Rich console for chat UI (separate from logging) +# Rich console console = Console() -# Valid commands list for validation +# Valid commands VALID_COMMANDS = { '/retry', '/online', '/memory', '/paste', '/export', '/save', '/load', '/delete', '/list', '/prev', '/next', '/stats', '/middleout', '/reset', - '/info', '/model', '/maxtoken', '/system', '/config', '/credits', '/clear', '/cl', '/help' + '/info', '/model', '/maxtoken', '/system', '/config', '/credits', '/clear', + '/cl', '/help', '/mcp' } -# Detailed command help database +# Command help database (COMPLETE - includes MCP comprehensive guide) COMMAND_HELP = { '/clear': { 'aliases': ['/cl'], @@ -74,13 +90,155 @@ COMMAND_HELP = { }, '/help': { 'description': 'Display help information for commands.', - 'usage': '/help [command]', + 'usage': '/help [command|topic]', 'examples': [ ('Show all commands', '/help'), ('Get help for a specific command', '/help /model'), - ('Get help for config', '/help /config'), + ('Get detailed MCP help', '/help mcp'), ], - 'notes': 'Use /help without arguments to see the full command list, or /help for detailed information about a specific command.' + 'notes': 'Use /help without arguments to see the full command list, /help for detailed command info, or /help mcp for comprehensive MCP documentation.' + }, + 'mcp': { + 'description': 'Complete guide to MCP (Model Context Protocol) - file access and database querying for AI agents.', + 'usage': 'See detailed examples below', + 'examples': [], + 'notes': ''' +MCP (Model Context Protocol) gives your AI assistant direct access to: + • Local files and folders (read, search, list) + • SQLite databases (inspect, search, query) + +╭─────────────────────────────────────────────────────────╮ +│ 🗂️ FILE MODE │ +╰─────────────────────────────────────────────────────────╯ + +SETUP: + /mcp enable Start MCP server + /mcp add ~/Documents Grant access to folder + /mcp add ~/Code/project Add another folder + /mcp list View all allowed folders + +USAGE (just ask naturally): + "List all Python files in my Code folder" + "Read the contents of Documents/notes.txt" + "Search for files containing 'budget'" + "What's in my Documents folder?" + +FEATURES: + ✓ Automatically respects .gitignore patterns + ✓ Skips virtual environments (venv, node_modules, etc.) + ✓ Handles large files (auto-truncates >50KB) + ✓ Cross-platform (macOS, Linux, Windows) + +MANAGEMENT: + /mcp remove ~/Desktop Remove folder access + /mcp remove 2 Remove by number + /mcp gitignore on|off Toggle .gitignore filtering + /mcp status Show comprehensive status + +╭─────────────────────────────────────────────────────────╮ +│ 🗄️ DATABASE MODE │ +╰─────────────────────────────────────────────────────────╯ + +SETUP: + /mcp add db ~/app/data.db Add specific database + /mcp add db ~/.config/oai/oai_config.db + /mcp db list View all databases + +SWITCH TO DATABASE: + /mcp db 1 Work with database #1 + /mcp db 2 Switch to database #2 + /mcp files Switch back to file mode + +USAGE (after selecting a database): + "Show me all tables in this database" + "Find any records mentioning 'Santa Clause'" + "How many users are in the database?" + "Show me the schema for the orders table" + "Get the 10 most recent transactions" + +FEATURES: + ✓ Read-only mode (no data modification possible) + ✓ Smart schema inspection + ✓ Full-text search across all tables + ✓ SQL query execution (SELECT only) + ✓ JOINs, subqueries, CTEs supported + ✓ Automatic query timeout (5 seconds) + ✓ Result limits (max 1000 rows) + +SAFETY: + • All queries are read-only (INSERT/UPDATE/DELETE blocked) + • Database opened in read-only mode + • Query validation prevents dangerous operations + • Timeout protection prevents infinite loops + +╭─────────────────────────────────────────────────────────╮ +│ 💡 TIPS & TRICKS │ +╰─────────────────────────────────────────────────────────╯ + +MODE INDICATORS: + [🔧 MCP: Files] You're in file mode + [🗄️ MCP: DB #1] You're querying database #1 + +QUICK REFERENCE: + /mcp status See current mode, stats, folders/databases + /mcp files Switch to file mode (default) + /mcp db Switch to database mode + /mcp db list List all databases with details + /mcp list List all folders + +TROUBLESHOOTING: + • No results? Check /mcp status to see what's accessible + • Wrong mode? Use /mcp files or /mcp db to switch + • Database errors? Ensure file exists and is valid SQLite + • .gitignore not working? Check /mcp status shows patterns + +SECURITY NOTES: + • MCP only accesses explicitly added folders/databases + • File mode: read-only access (no write/delete) + • Database mode: SELECT queries only (no modifications) + • System directories are blocked automatically + • Each addition requires your explicit confirmation + +For command-specific help: /help /mcp + ''' + }, + '/mcp': { + 'description': 'Manage MCP (Model Context Protocol) for local file access and SQLite database querying. When enabled with a function-calling model, AI can automatically search, read, list files, and query databases. Supports two modes: Files (default) and Database.', + 'usage': '/mcp [args]', + 'examples': [ + ('Enable MCP server', '/mcp enable'), + ('Disable MCP server', '/mcp disable'), + ('Show MCP status and current mode', '/mcp status'), + ('', ''), + ('━━━ FILE MODE ━━━', ''), + ('Add folder for file access', '/mcp add ~/Documents'), + ('Remove folder by path', '/mcp remove ~/Desktop'), + ('Remove folder by number', '/mcp remove 2'), + ('List allowed folders', '/mcp list'), + ('Toggle .gitignore filtering', '/mcp gitignore on'), + ('', ''), + ('━━━ DATABASE MODE ━━━', ''), + ('Add SQLite database', '/mcp add db ~/app/data.db'), + ('List all databases', '/mcp db list'), + ('Switch to database #1', '/mcp db 1'), + ('Remove database', '/mcp remove db 2'), + ('Switch back to file mode', '/mcp files'), + ], + 'notes': '''MCP allows AI to read local files and query SQLite databases. Works automatically with function-calling models (GPT-4, Claude, etc.). + +FILE MODE (default): +- Automatically loads and respects .gitignore patterns +- Skips virtual environments and build artifacts +- Supports search, read, and list operations + +DATABASE MODE: +- Read-only access (no data modification) +- Execute SELECT queries with JOINs, subqueries, CTEs +- Full-text search across all tables +- Schema inspection and data exploration +- Automatic query validation and timeout protection + +Use /help mcp for comprehensive guide with examples.''' }, '/memory': { 'description': 'Toggle conversation memory. When ON, the AI remembers conversation history. When OFF, each request is independent (saves tokens and cost).', @@ -272,7 +430,7 @@ COMMAND_HELP = { }, } -# Supported code file extensions +# Supported code extensions SUPPORTED_CODE_EXTENSIONS = { '.py', '.js', '.ts', '.cs', '.java', '.c', '.cpp', '.h', '.hpp', '.rb', '.ruby', '.php', '.swift', '.kt', '.kts', '.go', @@ -280,16 +438,16 @@ SUPPORTED_CODE_EXTENSIONS = { '.elm', '.xml', '.json', '.yaml', '.yml', '.md', '.txt' } -# Session metrics constants (per 1M tokens, in USD; adjustable) +# Pricing MODEL_PRICING = { - 'input': 3.0, # $3/M input tokens (adjustable) - 'output': 15.0 # $15/M output tokens (adjustable) + 'input': 3.0, + 'output': 15.0 } -LOW_CREDIT_RATIO = 0.1 # Warn if credits left < 10% of total -LOW_CREDIT_AMOUNT = 1.0 # Warn if credits left < $1 in absolute terms -HIGH_COST_WARNING = "cost_warning_threshold" # Configurable key for cost threshold, default $0.01 +LOW_CREDIT_RATIO = 0.1 +LOW_CREDIT_AMOUNT = 1.0 +HIGH_COST_WARNING = "cost_warning_threshold" -# Valid log levels mapping +# Valid log levels VALID_LOG_LEVELS = { 'debug': logging.DEBUG, 'info': logging.INFO, @@ -298,14 +456,18 @@ VALID_LOG_LEVELS = { 'critical': logging.CRITICAL } -# DB configuration -database = config_dir / 'oai_config.db' -DB_FILE = str(database) +# System directories to block (cross-platform) +SYSTEM_DIRS_BLACKLIST = { + '/System', '/Library', '/private', '/usr', '/bin', '/sbin', # macOS + '/boot', '/dev', '/proc', '/sys', '/root', # Linux + 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)' # Windows +} +# Database functions def create_table_if_not_exists(): - """Ensure the config and conversation_sessions tables exist.""" + """Ensure tables exist.""" os.makedirs(config_dir, exist_ok=True) - with sqlite3.connect(DB_FILE) as conn: + with sqlite3.connect(str(database)) as conn: conn.execute('''CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -314,32 +476,106 @@ def create_table_if_not_exists(): id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, timestamp TEXT NOT NULL, - data TEXT NOT NULL -- JSON of session_history + data TEXT NOT NULL + )''') + # MCP configuration table + conn.execute('''CREATE TABLE IF NOT EXISTS mcp_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )''') + # MCP statistics table + conn.execute('''CREATE TABLE IF NOT EXISTS mcp_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + tool_name TEXT NOT NULL, + folder TEXT, + success INTEGER NOT NULL, + error_message TEXT + )''') + # MCP databases table + conn.execute('''CREATE TABLE IF NOT EXISTS mcp_databases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + size INTEGER, + tables TEXT, + added_timestamp TEXT NOT NULL )''') conn.commit() def get_config(key: str) -> Optional[str]: create_table_if_not_exists() - with sqlite3.connect(DB_FILE) as conn: + with sqlite3.connect(str(database)) as conn: cursor = conn.execute('SELECT value FROM config WHERE key = ?', (key,)) result = cursor.fetchone() return result[0] if result else None def set_config(key: str, value: str): create_table_if_not_exists() - with sqlite3.connect(DB_FILE) as conn: + with sqlite3.connect(str(database)) as conn: conn.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', (key, value)) conn.commit() -# ============================================================================ -# ROTATING RICH HANDLER - Combines RotatingFileHandler with Rich formatting -# ============================================================================ +def get_mcp_config(key: str) -> Optional[str]: + """Get MCP configuration value.""" + create_table_if_not_exists() + with sqlite3.connect(str(database)) as conn: + cursor = conn.execute('SELECT value FROM mcp_config WHERE key = ?', (key,)) + result = cursor.fetchone() + return result[0] if result else None + +def set_mcp_config(key: str, value: str): + """Set MCP configuration value.""" + create_table_if_not_exists() + with sqlite3.connect(str(database)) as conn: + conn.execute('INSERT OR REPLACE INTO mcp_config (key, value) VALUES (?, ?)', (key, value)) + conn.commit() + +def log_mcp_stat(tool_name: str, folder: Optional[str], success: bool, error_message: Optional[str] = None): + """Log MCP tool usage statistics.""" + create_table_if_not_exists() + timestamp = datetime.datetime.now().isoformat() + with sqlite3.connect(str(database)) as conn: + conn.execute( + 'INSERT INTO mcp_stats (timestamp, tool_name, folder, success, error_message) VALUES (?, ?, ?, ?, ?)', + (timestamp, tool_name, folder, 1 if success else 0, error_message) + ) + conn.commit() + +def get_mcp_stats() -> Dict[str, Any]: + """Get MCP usage statistics.""" + create_table_if_not_exists() + with sqlite3.connect(str(database)) as conn: + cursor = conn.execute(''' + SELECT + COUNT(*) as total_calls, + SUM(CASE WHEN tool_name = 'read_file' THEN 1 ELSE 0 END) as reads, + SUM(CASE WHEN tool_name = 'list_directory' THEN 1 ELSE 0 END) as lists, + SUM(CASE WHEN tool_name = 'search_files' THEN 1 ELSE 0 END) as searches, + SUM(CASE WHEN tool_name = 'inspect_database' THEN 1 ELSE 0 END) as db_inspects, + SUM(CASE WHEN tool_name = 'search_database' THEN 1 ELSE 0 END) as db_searches, + SUM(CASE WHEN tool_name = 'query_database' THEN 1 ELSE 0 END) as db_queries, + MAX(timestamp) as last_used + FROM mcp_stats + ''') + row = cursor.fetchone() + return { + 'total_calls': row[0] or 0, + 'reads': row[1] or 0, + 'lists': row[2] or 0, + 'searches': row[3] or 0, + 'db_inspects': row[4] or 0, + 'db_searches': row[5] or 0, + 'db_queries': row[6] or 0, + 'last_used': row[7] + } + +# Rotating Rich Handler class RotatingRichHandler(RotatingFileHandler): - """Custom handler that combines RotatingFileHandler with Rich formatting.""" + """Custom handler combining RotatingFileHandler with Rich formatting.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Create a Rich console that writes to a string buffer self.rich_console = Console(file=io.StringIO(), width=120, force_terminal=False) self.rich_handler = RichHandler( console=self.rich_console, @@ -351,46 +587,31 @@ class RotatingRichHandler(RotatingFileHandler): def emit(self, record): try: - # Let RichHandler format the record self.rich_handler.emit(record) - - # Get the formatted output from the string buffer output = self.rich_console.file.getvalue() - - # Clear the buffer for next use self.rich_console.file.seek(0) self.rich_console.file.truncate(0) - - # Write the Rich-formatted output to our rotating file if output: self.stream.write(output) self.flush() - except Exception: self.handleError(record) -# ============================================================================ -# LOGGING SETUP - MUST BE DONE AFTER CONFIG IS LOADED -# ============================================================================ - -# Load log configuration from DB FIRST (before creating handler) +# Logging setup LOG_MAX_SIZE_MB = int(get_config('log_max_size_mb') or "10") LOG_BACKUP_COUNT = int(get_config('log_backup_count') or "2") LOG_LEVEL_STR = get_config('log_level') or "info" LOG_LEVEL = VALID_LOG_LEVELS.get(LOG_LEVEL_STR.lower(), logging.INFO) -# Global reference to the handler for dynamic reloading app_handler = None app_logger = None def setup_logging(): - """Setup or reset logging configuration with current settings.""" + """Setup or reset logging configuration.""" global app_handler, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, app_logger - # Get the root logger root_logger = logging.getLogger() - # Remove existing handler if present if app_handler is not None: root_logger.removeHandler(app_handler) try: @@ -398,13 +619,12 @@ def setup_logging(): except: pass - # Check if log file needs immediate rotation + # Check if log needs rotation if os.path.exists(log_file): current_size = os.path.getsize(log_file) max_bytes = LOG_MAX_SIZE_MB * 1024 * 1024 if current_size >= max_bytes: - # Perform immediate rotation import shutil timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') backup_file = f"{log_file}.{timestamp}" @@ -413,7 +633,7 @@ def setup_logging(): except Exception as e: print(f"Warning: Could not rotate log file: {e}") - # Clean up old backups if exceeding limit + # Clean old backups log_dir = os.path.dirname(log_file) log_basename = os.path.basename(log_file) backup_pattern = f"{log_basename}.*" @@ -421,7 +641,6 @@ def setup_logging(): import glob backups = sorted(glob.glob(os.path.join(log_dir, backup_pattern))) - # Keep only the most recent backups while len(backups) > LOG_BACKUP_COUNT: oldest = backups.pop(0) try: @@ -429,7 +648,6 @@ def setup_logging(): except: pass - # Create new handler with current settings app_handler = RotatingRichHandler( filename=str(log_file), maxBytes=LOG_MAX_SIZE_MB * 1024 * 1024, @@ -437,15 +655,11 @@ def setup_logging(): encoding='utf-8' ) - # Set handler level to NOTSET so it processes all records app_handler.setLevel(logging.NOTSET) - - # Configure root logger - set to WARNING to suppress third-party library noise root_logger.setLevel(logging.WARNING) root_logger.addHandler(app_handler) - # Suppress noisy third-party loggers - # These libraries create DEBUG logs that pollute our log file + # Suppress noisy loggers logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('urllib3').setLevel(logging.WARNING) logging.getLogger('requests').setLevel(logging.WARNING) @@ -454,19 +668,14 @@ def setup_logging(): logging.getLogger('openai').setLevel(logging.WARNING) logging.getLogger('openrouter').setLevel(logging.WARNING) - # Get or create app logger and set its level (this filters what gets logged) app_logger = logging.getLogger("oai_app") app_logger.setLevel(LOG_LEVEL) - # Don't propagate to avoid root logger filtering app_logger.propagate = True return app_logger -# Initial logging setup -app_logger = setup_logging() - def set_log_level(level_str: str) -> bool: - """Set the application log level. Returns True if successful.""" + """Set application log level.""" global LOG_LEVEL, LOG_LEVEL_STR, app_logger level_str_lower = level_str.lower() if level_str_lower not in VALID_LOG_LEVELS: @@ -474,40 +683,1911 @@ def set_log_level(level_str: str) -> bool: LOG_LEVEL = VALID_LOG_LEVELS[level_str_lower] LOG_LEVEL_STR = level_str_lower - # Update the logger level immediately if app_logger: app_logger.setLevel(LOG_LEVEL) return True def reload_logging_config(): - """Reload logging configuration from database and reinitialize handler.""" + """Reload logging configuration.""" global LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, LOG_LEVEL_STR, app_logger - # Reload from database LOG_MAX_SIZE_MB = int(get_config('log_max_size_mb') or "10") LOG_BACKUP_COUNT = int(get_config('log_backup_count') or "2") LOG_LEVEL_STR = get_config('log_level') or "info" LOG_LEVEL = VALID_LOG_LEVELS.get(LOG_LEVEL_STR.lower(), logging.INFO) - # Reinitialize logging app_logger = setup_logging() - return app_logger -# ============================================================================ -# END OF LOGGING SETUP -# ============================================================================ - +app_logger = setup_logging() logger = logging.getLogger(__name__) -def check_for_updates(current_version: str) -> str: - """ - Check if a new version is available using semantic versioning. +# Load configs +API_KEY = get_config('api_key') +OPENROUTER_BASE_URL = get_config('base_url') or "https://openrouter.ai/api/v1" +STREAM_ENABLED = get_config('stream_enabled') or "on" +DEFAULT_MODEL_ID = get_config('default_model') +MAX_TOKEN = int(get_config('max_token') or "100000") +COST_WARNING_THRESHOLD = float(get_config(HIGH_COST_WARNING) or "0.01") +DEFAULT_ONLINE_MODE = get_config('default_online_mode') or "off" + +# Fetch models +models_data = [] +text_models = [] +try: + headers = { + "Authorization": f"Bearer {API_KEY}", + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } if API_KEY else { + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } + response = requests.get(f"{OPENROUTER_BASE_URL}/models", headers=headers) + response.raise_for_status() + models_data = response.json()["data"] + text_models = [m for m in models_data if "modalities" not in m or "video" not in (m.get("modalities") or [])] + selected_model_default = None + if DEFAULT_MODEL_ID: + selected_model_default = next((m for m in text_models if m["id"] == DEFAULT_MODEL_ID), None) + if not selected_model_default: + console.print(f"[bold yellow]Warning: Default model '{DEFAULT_MODEL_ID}' unavailable. Use '/config model'.[/]") +except Exception as e: + models_data = [] + text_models = [] + app_logger.error(f"Failed to fetch models: {e}") + +# ============================================================================ +# MCP INTEGRATION CLASSES +# ============================================================================ + +class CrossPlatformMCPConfig: + """Handle OS-specific MCP configuration.""" - Returns: - Formatted status string for display - """ + def __init__(self): + self.system = platform.system() + self.is_macos = self.system == "Darwin" + self.is_linux = self.system == "Linux" + self.is_windows = self.system == "Windows" + + app_logger.info(f"Detected OS: {self.system}") + + def get_default_allowed_dirs(self) -> List[Path]: + """Get safe default directories.""" + home = Path.home() + + if self.is_macos: + return [ + home / "Documents", + home / "Desktop", + home / "Downloads" + ] + elif self.is_linux: + dirs = [home / "Documents"] + + try: + for xdg_dir in ["DOCUMENTS", "DESKTOP", "DOWNLOAD"]: + result = subprocess.run( + ["xdg-user-dir", xdg_dir], + capture_output=True, + text=True, + timeout=1 + ) + if result.returncode == 0: + dir_path = Path(result.stdout.strip()) + if dir_path.exists(): + dirs.append(dir_path) + except: + dirs.extend([ + home / "Desktop", + home / "Downloads" + ]) + + return list(set(dirs)) + + elif self.is_windows: + return [ + home / "Documents", + home / "Desktop", + home / "Downloads" + ] + + return [home] + + def get_python_command(self) -> str: + """Get Python command.""" + import sys + return sys.executable + + def get_filesystem_warning(self) -> str: + """Get OS-specific security warning.""" + if self.is_macos: + return """ +⚠️ macOS Security Notice: +The Filesystem MCP server needs access to your selected folder. +You may see a security prompt - click 'Allow' to proceed. +(System Settings > Privacy & Security > Files and Folders) +""" + elif self.is_linux: + return """ +⚠️ Linux Security Notice: +The Filesystem MCP server will access your selected folder. +Ensure oAI has appropriate file permissions. +""" + elif self.is_windows: + return """ +⚠️ Windows Security Notice: +The Filesystem MCP server will access your selected folder. +You may need to grant file access permissions. +""" + return "" + + def normalize_path(self, path: str) -> Path: + """Normalize path for current OS.""" + return Path(os.path.expanduser(path)).resolve() + + def is_system_directory(self, path: Path) -> bool: + """Check if path is a system directory.""" + path_str = str(path) + for blocked in SYSTEM_DIRS_BLACKLIST: + if path_str.startswith(blocked): + return True + return False + + def is_safe_path(self, requested_path: Path, allowed_dirs: List[Path]) -> bool: + """Check if path is within allowed directories.""" + try: + requested = requested_path.resolve() + + for allowed in allowed_dirs: + try: + allowed_resolved = allowed.resolve() + requested.relative_to(allowed_resolved) + return True + except ValueError: + continue + + return False + except: + return False + + def get_folder_stats(self, folder: Path) -> Dict[str, Any]: + """Get folder statistics.""" + try: + if not folder.exists() or not folder.is_dir(): + return {'exists': False} + + file_count = 0 + total_size = 0 + + for item in folder.rglob('*'): + if item.is_file(): + file_count += 1 + try: + total_size += item.stat().st_size + except: + pass + + return { + 'exists': True, + 'file_count': file_count, + 'total_size': total_size, + 'size_mb': total_size / (1024 * 1024) + } + except Exception as e: + app_logger.error(f"Error getting folder stats for {folder}: {e}") + return {'exists': False, 'error': str(e)} + + +class GitignoreParser: + """Parse .gitignore files and check if paths should be ignored.""" + + def __init__(self): + self.patterns = [] # List of (pattern, is_negation, source_dir) + + def add_gitignore(self, gitignore_path: Path): + """Parse and add patterns from a .gitignore file.""" + if not gitignore_path.exists(): + return + + try: + source_dir = gitignore_path.parent + with open(gitignore_path, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.rstrip('\n\r') + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Check for negation pattern + is_negation = line.startswith('!') + if is_negation: + line = line[1:] + + # Remove leading slash (make relative to gitignore location) + if line.startswith('/'): + line = line[1:] + + self.patterns.append((line, is_negation, source_dir)) + + app_logger.debug(f"Loaded {len(self.patterns)} patterns from {gitignore_path}") + except Exception as e: + app_logger.warning(f"Error reading {gitignore_path}: {e}") + + def should_ignore(self, path: Path) -> bool: + """Check if a path should be ignored based on gitignore patterns.""" + if not self.patterns: + return False + + ignored = False + + for pattern, is_negation, source_dir in self.patterns: + # Only apply pattern if path is under the source directory + try: + rel_path = path.relative_to(source_dir) + except ValueError: + # Path is not relative to this gitignore's directory + continue + + rel_path_str = str(rel_path) + + # Check if pattern matches + if self._match_pattern(pattern, rel_path_str, path.is_dir()): + if is_negation: + ignored = False # Negation patterns un-ignore + else: + ignored = True + + return ignored + + def _match_pattern(self, pattern: str, path: str, is_dir: bool) -> bool: + """Match a gitignore pattern against a path.""" + # Directory-only pattern (ends with /) + if pattern.endswith('/'): + if not is_dir: + return False + pattern = pattern[:-1] + + # ** matches any number of directories + if '**' in pattern: + # Convert ** to regex-like pattern + pattern_parts = pattern.split('**') + if len(pattern_parts) == 2: + prefix, suffix = pattern_parts + # Match if path starts with prefix and ends with suffix + if prefix: + if not path.startswith(prefix.rstrip('/')): + return False + if suffix: + suffix = suffix.lstrip('/') + if not (path.endswith(suffix) or f'/{suffix}' in path): + return False + return True + + # Direct match + if fnmatch.fnmatch(path, pattern): + return True + + # Match as subdirectory pattern + if '/' not in pattern: + # Pattern without / matches in any directory + parts = path.split('/') + if any(fnmatch.fnmatch(part, pattern) for part in parts): + return True + + return False + + +class SQLiteQueryValidator: + """Validate SQLite queries for read-only safety.""" + + @staticmethod + def is_safe_query(query: str) -> tuple[bool, str]: + """ + Validate that query is a safe read-only SELECT. + Returns (is_safe, error_message) + """ + query_upper = query.strip().upper() + + # Must start with SELECT or WITH (for CTEs) + if not (query_upper.startswith('SELECT') or query_upper.startswith('WITH')): + return False, "Only SELECT queries are allowed (including WITH/CTE)" + + # Block dangerous keywords even in SELECT + dangerous_keywords = [ + 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', + 'ALTER', 'TRUNCATE', 'REPLACE', 'ATTACH', 'DETACH', + 'PRAGMA', 'VACUUM', 'REINDEX' + ] + + # Check for dangerous keywords (but allow them in string literals) + # Simple check: look for keywords outside of quotes + query_no_strings = re.sub(r"'[^']*'", '', query_upper) + query_no_strings = re.sub(r'"[^"]*"', '', query_no_strings) + + for keyword in dangerous_keywords: + if re.search(r'\b' + keyword + r'\b', query_no_strings): + return False, f"Keyword '{keyword}' not allowed in read-only mode" + + return True, "" + # P2 +class MCPFilesystemServer: + """MCP Filesystem Server with file access and SQLite database querying.""" + + def __init__(self, allowed_folders: List[Path]): + self.allowed_folders = allowed_folders + self.config = CrossPlatformMCPConfig() + self.max_file_size = 10 * 1024 * 1024 # 10MB limit + self.max_list_items = 1000 # Max items to return in list_directory + self.respect_gitignore = True # Default enabled + + # Initialize gitignore parser + self.gitignore_parser = GitignoreParser() + self._load_gitignores() + + # SQLite configuration + self.max_query_timeout = 5 # seconds + self.max_query_results = 1000 # max rows + self.default_query_limit = 100 # default rows + + app_logger.info(f"MCP Filesystem Server initialized with {len(allowed_folders)} folders") + + def _load_gitignores(self): + """Load all .gitignore files from allowed folders.""" + gitignore_count = 0 + for folder in self.allowed_folders: + if not folder.exists(): + continue + + # Load root .gitignore + root_gitignore = folder / '.gitignore' + if root_gitignore.exists(): + self.gitignore_parser.add_gitignore(root_gitignore) + gitignore_count += 1 + app_logger.info(f"Loaded .gitignore from {folder}") + + # Load nested .gitignore files + try: + for gitignore_path in folder.rglob('.gitignore'): + if gitignore_path != root_gitignore: + self.gitignore_parser.add_gitignore(gitignore_path) + gitignore_count += 1 + app_logger.debug(f"Loaded nested .gitignore: {gitignore_path}") + except Exception as e: + app_logger.warning(f"Error loading nested .gitignores from {folder}: {e}") + + if gitignore_count > 0: + app_logger.info(f"Loaded {gitignore_count} .gitignore file(s) with {len(self.gitignore_parser.patterns)} total patterns") + + def reload_gitignores(self): + """Reload all .gitignore files (call when allowed_folders changes).""" + self.gitignore_parser = GitignoreParser() + self._load_gitignores() + app_logger.info("Reloaded .gitignore patterns") + + def is_allowed_path(self, path: Path) -> bool: + """Check if path is allowed.""" + return self.config.is_safe_path(path, self.allowed_folders) + + async def read_file(self, file_path: str) -> Dict[str, Any]: + """Read file contents.""" + try: + path = self.config.normalize_path(file_path) + + if not self.is_allowed_path(path): + error_msg = f"Access denied: {path} not in allowed folders" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + if not path.exists(): + error_msg = f"File not found: {path}" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + if not path.is_file(): + error_msg = f"Not a file: {path}" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Check if file should be ignored + if self.respect_gitignore and self.gitignore_parser.should_ignore(path): + error_msg = f"File ignored by .gitignore: {path}" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + file_size = path.stat().st_size + if file_size > self.max_file_size: + error_msg = f"File too large: {file_size / (1024*1024):.1f}MB (max: {self.max_file_size / (1024*1024):.0f}MB)" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Try to read as text + try: + content = path.read_text(encoding='utf-8') + + # If file is large (>50KB), provide summary instead of full content + max_content_size = 50 * 1024 # 50KB + if file_size > max_content_size: + lines = content.split('\n') + total_lines = len(lines) + + # Return first 500 lines + last 100 lines with notice + head_lines = 500 + tail_lines = 100 + + if total_lines > (head_lines + tail_lines): + truncated_content = ( + '\n'.join(lines[:head_lines]) + + f"\n\n... [TRUNCATED: {total_lines - head_lines - tail_lines} lines omitted to stay within size limits] ...\n\n" + + '\n'.join(lines[-tail_lines:]) + ) + + app_logger.info(f"Read file (truncated): {path} ({file_size} bytes, {total_lines} lines)") + log_mcp_stat('read_file', str(path.parent), True) + + return { + 'content': truncated_content, + 'path': str(path), + 'size': file_size, + 'truncated': True, + 'total_lines': total_lines, + 'lines_shown': head_lines + tail_lines, + 'note': f'File truncated: showing first {head_lines} and last {tail_lines} lines of {total_lines} total' + } + + app_logger.info(f"Read file: {path} ({file_size} bytes)") + log_mcp_stat('read_file', str(path.parent), True) + return { + 'content': content, + 'path': str(path), + 'size': file_size + } + except UnicodeDecodeError: + error_msg = f"Cannot decode file as UTF-8: {path}" + app_logger.warning(error_msg) + log_mcp_stat('read_file', str(path.parent), False, error_msg) + return {'error': error_msg} + + except Exception as e: + error_msg = f"Error reading file: {e}" + app_logger.error(error_msg) + log_mcp_stat('read_file', file_path, False, str(e)) + return {'error': error_msg} + + async def list_directory(self, dir_path: str, recursive: bool = False) -> Dict[str, Any]: + """List directory contents.""" + try: + path = self.config.normalize_path(dir_path) + + if not self.is_allowed_path(path): + error_msg = f"Access denied: {path} not in allowed folders" + app_logger.warning(error_msg) + log_mcp_stat('list_directory', str(path), False, error_msg) + return {'error': error_msg} + + if not path.exists(): + error_msg = f"Directory not found: {path}" + app_logger.warning(error_msg) + log_mcp_stat('list_directory', str(path), False, error_msg) + return {'error': error_msg} + + if not path.is_dir(): + error_msg = f"Not a directory: {path}" + app_logger.warning(error_msg) + log_mcp_stat('list_directory', str(path), False, error_msg) + return {'error': error_msg} + + items = [] + pattern = '**/*' if recursive else '*' + + # Directories to skip (hardcoded common patterns) + skip_dirs = { + '.venv', 'venv', 'env', 'virtualenv', + 'site-packages', 'dist-packages', + '__pycache__', '.pytest_cache', '.mypy_cache', + 'node_modules', '.git', '.svn', + '.idea', '.vscode', + 'build', 'dist', 'eggs', '.eggs' + } + + def should_skip_path(item_path: Path) -> bool: + """Check if path should be skipped.""" + # Check hardcoded skip directories + path_parts = item_path.parts + if any(part in skip_dirs for part in path_parts): + return True + + # Check gitignore patterns (if enabled) + if self.respect_gitignore and self.gitignore_parser.should_ignore(item_path): + return True + + return False + + for item in path.glob(pattern): + # Stop if we hit the limit + if len(items) >= self.max_list_items: + break + + # Skip excluded directories + if should_skip_path(item): + continue + + try: + stat = item.stat() + items.append({ + 'name': item.name, + 'path': str(item), + 'type': 'directory' if item.is_dir() else 'file', + 'size': stat.st_size if item.is_file() else 0, + 'modified': datetime.datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + except: + continue + + truncated = len(items) >= self.max_list_items + + app_logger.info(f"Listed directory: {path} ({len(items)} items, recursive={recursive}, truncated={truncated})") + log_mcp_stat('list_directory', str(path), True) + + result = { + 'path': str(path), + 'items': items, + 'count': len(items), + 'truncated': truncated + } + + if truncated: + result['note'] = f'Results limited to {self.max_list_items} items. Use more specific search path or disable recursive mode.' + + return result + + except Exception as e: + error_msg = f"Error listing directory: {e}" + app_logger.error(error_msg) + log_mcp_stat('list_directory', dir_path, False, str(e)) + return {'error': error_msg} + + async def search_files(self, pattern: str, search_path: Optional[str] = None, content_search: bool = False) -> Dict[str, Any]: + """Search for files.""" + try: + # Determine search roots + if search_path: + path = self.config.normalize_path(search_path) + if not self.is_allowed_path(path): + error_msg = f"Access denied: {path} not in allowed folders" + app_logger.warning(error_msg) + log_mcp_stat('search_files', str(path), False, error_msg) + return {'error': error_msg} + search_roots = [path] + else: + search_roots = self.allowed_folders + + matches = [] + + # Directories to skip (virtual environments, caches, etc.) + skip_dirs = { + '.venv', 'venv', 'env', 'virtualenv', + 'site-packages', 'dist-packages', + '__pycache__', '.pytest_cache', '.mypy_cache', + 'node_modules', '.git', '.svn', + '.idea', '.vscode', + 'build', 'dist', 'eggs', '.eggs' + } + + def should_skip_path(item_path: Path) -> bool: + """Check if path should be skipped.""" + # Check hardcoded skip directories + path_parts = item_path.parts + if any(part in skip_dirs for part in path_parts): + return True + + # Check gitignore patterns (if enabled) + if self.respect_gitignore and self.gitignore_parser.should_ignore(item_path): + return True + + return False + + for root in search_roots: + if not root.exists(): + continue + + # Filename search + if not content_search: + for item in root.rglob(pattern): + if item.is_file() and not should_skip_path(item): + try: + stat = item.stat() + matches.append({ + 'path': str(item), + 'name': item.name, + 'size': stat.st_size, + 'modified': datetime.datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + except: + continue + else: + # Content search (slower) + for item in root.rglob('*'): + if item.is_file() and not should_skip_path(item): + try: + # Skip large files + if item.stat().st_size > self.max_file_size: + continue + + # Try to read and search content + try: + content = item.read_text(encoding='utf-8') + if pattern.lower() in content.lower(): + stat = item.stat() + matches.append({ + 'path': str(item), + 'name': item.name, + 'size': stat.st_size, + 'modified': datetime.datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + except: + continue + except: + continue + + search_type = "content" if content_search else "filename" + app_logger.info(f"Searched files: pattern='{pattern}', type={search_type}, found={len(matches)}") + log_mcp_stat('search_files', str(search_roots[0]) if search_roots else None, True) + + return { + 'pattern': pattern, + 'search_type': search_type, + 'matches': matches, + 'count': len(matches) + } + + except Exception as e: + error_msg = f"Error searching files: {e}" + app_logger.error(error_msg) + log_mcp_stat('search_files', search_path or "all", False, str(e)) + return {'error': error_msg} + + # ======================================================================== + # SQLite DATABASE METHODS + # ======================================================================== + + async def inspect_database(self, db_path: str, table_name: Optional[str] = None) -> Dict[str, Any]: + """Inspect SQLite database schema.""" + try: + path = Path(db_path).resolve() + + if not path.exists(): + error_msg = f"Database not found: {path}" + app_logger.warning(error_msg) + log_mcp_stat('inspect_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + if not path.is_file(): + error_msg = f"Not a file: {path}" + app_logger.warning(error_msg) + log_mcp_stat('inspect_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Open database in read-only mode + try: + conn = sqlite3.connect(f'file:{path}?mode=ro', uri=True) + cursor = conn.cursor() + except sqlite3.DatabaseError as e: + error_msg = f"Not a valid SQLite database: {path} ({e})" + app_logger.warning(error_msg) + log_mcp_stat('inspect_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + try: + if table_name: + # Get info for specific table + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,)) + result = cursor.fetchone() + + if not result: + return {'error': f"Table '{table_name}' not found"} + + create_sql = result[0] + + # Get column info + cursor.execute(f"PRAGMA table_info({table_name})") + columns = [] + for row in cursor.fetchall(): + columns.append({ + 'id': row[0], + 'name': row[1], + 'type': row[2], + 'not_null': bool(row[3]), + 'default_value': row[4], + 'primary_key': bool(row[5]) + }) + + # Get indexes + cursor.execute(f"PRAGMA index_list({table_name})") + indexes = [] + for row in cursor.fetchall(): + indexes.append({ + 'name': row[1], + 'unique': bool(row[2]) + }) + + # Get row count + cursor.execute(f"SELECT COUNT(*) FROM {table_name}") + row_count = cursor.fetchone()[0] + + app_logger.info(f"Inspected table: {table_name} in {path}") + log_mcp_stat('inspect_database', str(path.parent), True) + + return { + 'database': str(path), + 'table': table_name, + 'create_sql': create_sql, + 'columns': columns, + 'indexes': indexes, + 'row_count': row_count + } + else: + # Get all tables + cursor.execute("SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name") + tables = [] + for row in cursor.fetchall(): + table = row[0] + # Get row count + cursor.execute(f"SELECT COUNT(*) FROM {table}") + count = cursor.fetchone()[0] + tables.append({ + 'name': table, + 'row_count': count + }) + + # Get indexes + cursor.execute("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name") + indexes = [{'name': row[0], 'table': row[1]} for row in cursor.fetchall()] + + # Get triggers + cursor.execute("SELECT name, tbl_name FROM sqlite_master WHERE type='trigger' ORDER BY name") + triggers = [{'name': row[0], 'table': row[1]} for row in cursor.fetchall()] + + # Get database size + db_size = path.stat().st_size + + app_logger.info(f"Inspected database: {path} ({len(tables)} tables)") + log_mcp_stat('inspect_database', str(path.parent), True) + + return { + 'database': str(path), + 'size': db_size, + 'size_mb': db_size / (1024 * 1024), + 'tables': tables, + 'table_count': len(tables), + 'indexes': indexes, + 'triggers': triggers + } + finally: + conn.close() + + except Exception as e: + error_msg = f"Error inspecting database: {e}" + app_logger.error(error_msg) + log_mcp_stat('inspect_database', db_path, False, str(e)) + return {'error': error_msg} + + async def search_database(self, db_path: str, search_term: str, table_name: Optional[str] = None, column_name: Optional[str] = None) -> Dict[str, Any]: + """Search for a value across database tables.""" + try: + path = Path(db_path).resolve() + + if not path.exists(): + error_msg = f"Database not found: {path}" + app_logger.warning(error_msg) + log_mcp_stat('search_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Open database in read-only mode + try: + conn = sqlite3.connect(f'file:{path}?mode=ro', uri=True) + cursor = conn.cursor() + except sqlite3.DatabaseError as e: + error_msg = f"Not a valid SQLite database: {path} ({e})" + app_logger.warning(error_msg) + log_mcp_stat('search_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + try: + matches = [] + + # Get tables to search + if table_name: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,)) + tables = [row[0] for row in cursor.fetchall()] + if not tables: + return {'error': f"Table '{table_name}' not found"} + else: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + + for table in tables: + # Get columns for this table + cursor.execute(f"PRAGMA table_info({table})") + columns = [row[1] for row in cursor.fetchall()] + + # Filter columns if specified + if column_name: + if column_name not in columns: + continue + columns = [column_name] + + # Search in each column + for column in columns: + try: + # Use LIKE for partial matching + query = f"SELECT * FROM {table} WHERE {column} LIKE ? LIMIT {self.default_query_limit}" + cursor.execute(query, (f'%{search_term}%',)) + results = cursor.fetchall() + + if results: + # Get column names + col_names = [desc[0] for desc in cursor.description] + + for row in results: + # Convert row to dict, handling binary data + row_dict = {} + for col_name, value in zip(col_names, row): + if isinstance(value, bytes): + # Convert bytes to hex string or base64 + try: + # Try to decode as UTF-8 first + row_dict[col_name] = value.decode('utf-8') + except UnicodeDecodeError: + # If binary, show as hex string + row_dict[col_name] = f"" + else: + row_dict[col_name] = value + + matches.append({ + 'table': table, + 'column': column, + 'row': row_dict + }) + except sqlite3.Error: + # Skip columns that can't be searched (e.g., BLOBs) + continue + + app_logger.info(f"Searched database: {path} for '{search_term}' - found {len(matches)} matches") + log_mcp_stat('search_database', str(path.parent), True) + + result = { + 'database': str(path), + 'search_term': search_term, + 'matches': matches, + 'count': len(matches) + } + + if table_name: + result['table_filter'] = table_name + if column_name: + result['column_filter'] = column_name + + if len(matches) >= self.default_query_limit: + result['note'] = f'Results limited to {self.default_query_limit} matches. Use more specific search or query_database for full results.' + + return result + + finally: + conn.close() + + except Exception as e: + error_msg = f"Error searching database: {e}" + app_logger.error(error_msg) + log_mcp_stat('search_database', db_path, False, str(e)) + return {'error': error_msg} + + async def query_database(self, db_path: str, query: str, limit: Optional[int] = None) -> Dict[str, Any]: + """Execute a read-only SQL query on database.""" + try: + path = Path(db_path).resolve() + + if not path.exists(): + error_msg = f"Database not found: {path}" + app_logger.warning(error_msg) + log_mcp_stat('query_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Validate query + is_safe, error_msg = SQLiteQueryValidator.is_safe_query(query) + if not is_safe: + app_logger.warning(f"Unsafe query rejected: {error_msg}") + log_mcp_stat('query_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + # Open database in read-only mode + try: + conn = sqlite3.connect(f'file:{path}?mode=ro', uri=True) + cursor = conn.cursor() + except sqlite3.DatabaseError as e: + error_msg = f"Not a valid SQLite database: {path} ({e})" + app_logger.warning(error_msg) + log_mcp_stat('query_database', str(path.parent), False, error_msg) + return {'error': error_msg} + + try: + # Set query timeout + def timeout_handler(signum, frame): + raise TimeoutError(f"Query exceeded {self.max_query_timeout} second timeout") + + # Note: signal.alarm only works on Unix-like systems + if hasattr(signal, 'SIGALRM'): + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(self.max_query_timeout) + + try: + # Execute query + cursor.execute(query) + + # Get results + result_limit = min(limit or self.default_query_limit, self.max_query_results) + results = cursor.fetchmany(result_limit) + + # Get column names + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + + # Convert to list of dicts, handling binary data + rows = [] + for row in results: + row_dict = {} + for col_name, value in zip(columns, row): + if isinstance(value, bytes): + # Convert bytes to readable format + try: + # Try to decode as UTF-8 first + row_dict[col_name] = value.decode('utf-8') + except UnicodeDecodeError: + # If binary, show as hex string or summary + row_dict[col_name] = f"" + else: + row_dict[col_name] = value + rows.append(row_dict) + + # Check if more results available + has_more = len(results) == result_limit + if has_more: + # Try to fetch one more to check + one_more = cursor.fetchone() + has_more = one_more is not None + + finally: + if hasattr(signal, 'SIGALRM'): + signal.alarm(0) # Cancel timeout + + app_logger.info(f"Executed query on {path}: returned {len(rows)} rows") + log_mcp_stat('query_database', str(path.parent), True) + + result = { + 'database': str(path), + 'query': query, + 'columns': columns, + 'rows': rows, + 'count': len(rows) + } + + if has_more: + result['truncated'] = True + result['note'] = f'Results limited to {result_limit} rows. Use LIMIT clause in query for more control.' + + return result + + except TimeoutError as e: + error_msg = str(e) + app_logger.warning(error_msg) + log_mcp_stat('query_database', str(path.parent), False, error_msg) + return {'error': error_msg} + except sqlite3.Error as e: + error_msg = f"SQL error: {e}" + app_logger.warning(error_msg) + log_mcp_stat('query_database', str(path.parent), False, error_msg) + return {'error': error_msg} + finally: + if hasattr(signal, 'SIGALRM'): + signal.alarm(0) # Ensure timeout is cancelled + conn.close() + + except Exception as e: + error_msg = f"Error executing query: {e}" + app_logger.error(error_msg) + log_mcp_stat('query_database', db_path, False, str(e)) + return {'error': error_msg} + + +class MCPManager: + """Manage MCP server lifecycle, tool calls, and mode switching.""" + + def __init__(self): + self.enabled = False + self.mode = "files" # "files" or "database" + self.selected_db_index = None + + self.server: Optional[MCPFilesystemServer] = None + + # File/folder mode + self.allowed_folders: List[Path] = [] + + # Database mode + self.databases: List[Dict[str, Any]] = [] + + self.config = CrossPlatformMCPConfig() + self.session_start_time: Optional[datetime.datetime] = None + + # Load persisted data + self._load_folders() + self._load_databases() + + app_logger.info("MCP Manager initialized") + + def _load_folders(self): + """Load allowed folders from database.""" + folders_json = get_mcp_config('allowed_folders') + if folders_json: + try: + folder_paths = json.loads(folders_json) + self.allowed_folders = [self.config.normalize_path(p) for p in folder_paths] + app_logger.info(f"Loaded {len(self.allowed_folders)} folders from config") + except Exception as e: + app_logger.error(f"Error loading MCP folders: {e}") + self.allowed_folders = [] + + def _save_folders(self): + """Save allowed folders to database.""" + folder_paths = [str(p) for p in self.allowed_folders] + set_mcp_config('allowed_folders', json.dumps(folder_paths)) + app_logger.info(f"Saved {len(self.allowed_folders)} folders to config") + + def _load_databases(self): + """Load databases from database.""" + create_table_if_not_exists() + try: + with sqlite3.connect(str(database)) as conn: + cursor = conn.execute('SELECT id, path, name, size, tables, added_timestamp FROM mcp_databases ORDER BY id') + self.databases = [] + for row in cursor.fetchall(): + tables_list = json.loads(row[4]) if row[4] else [] + self.databases.append({ + 'id': row[0], + 'path': row[1], + 'name': row[2], + 'size': row[3], + 'tables': tables_list, + 'added': row[5] + }) + app_logger.info(f"Loaded {len(self.databases)} databases from config") + except Exception as e: + app_logger.error(f"Error loading databases: {e}") + self.databases = [] + + def _save_database(self, db_info: Dict[str, Any]): + """Save a database to config.""" + create_table_if_not_exists() + try: + with sqlite3.connect(str(database)) as conn: + conn.execute( + 'INSERT INTO mcp_databases (path, name, size, tables, added_timestamp) VALUES (?, ?, ?, ?, ?)', + (db_info['path'], db_info['name'], db_info['size'], json.dumps(db_info['tables']), db_info['added']) + ) + conn.commit() + # Get the ID + cursor = conn.execute('SELECT id FROM mcp_databases WHERE path = ?', (db_info['path'],)) + db_info['id'] = cursor.fetchone()[0] + app_logger.info(f"Saved database {db_info['name']} to config") + except Exception as e: + app_logger.error(f"Error saving database: {e}") + + def _remove_database_from_config(self, db_path: str): + """Remove database from config.""" + create_table_if_not_exists() + try: + with sqlite3.connect(str(database)) as conn: + conn.execute('DELETE FROM mcp_databases WHERE path = ?', (db_path,)) + conn.commit() + app_logger.info(f"Removed database from config: {db_path}") + except Exception as e: + app_logger.error(f"Error removing database from config: {e}") + + def enable(self) -> Dict[str, Any]: + """Enable MCP server.""" + if not MCP_AVAILABLE: + return { + 'success': False, + 'error': 'MCP library not installed. Run: pip install mcp' + } + + if self.enabled: + return { + 'success': False, + 'error': 'MCP is already enabled' + } + + try: + self.server = MCPFilesystemServer(self.allowed_folders) + self.enabled = True + self.session_start_time = datetime.datetime.now() + + set_mcp_config('mcp_enabled', 'true') + + app_logger.info("MCP Filesystem Server enabled") + + return { + 'success': True, + 'folder_count': len(self.allowed_folders), + 'database_count': len(self.databases), + 'message': 'MCP Filesystem Server started successfully' + } + except Exception as e: + app_logger.error(f"Error enabling MCP: {e}") + return { + 'success': False, + 'error': str(e) + } + + def disable(self) -> Dict[str, Any]: + """Disable MCP server.""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled' + } + + try: + self.server = None + self.enabled = False + self.session_start_time = None + self.mode = "files" + self.selected_db_index = None + + set_mcp_config('mcp_enabled', 'false') + + app_logger.info("MCP Filesystem Server disabled") + + return { + 'success': True, + 'message': 'MCP Filesystem Server stopped' + } + except Exception as e: + app_logger.error(f"Error disabling MCP: {e}") + return { + 'success': False, + 'error': str(e) + } + + def switch_mode(self, new_mode: str, db_index: Optional[int] = None) -> Dict[str, Any]: + """Switch between files and database mode.""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled. Use /mcp enable first' + } + + if new_mode == "files": + self.mode = "files" + self.selected_db_index = None + app_logger.info("Switched to file mode") + return { + 'success': True, + 'mode': 'files', + 'message': 'Switched to file mode', + 'tools': ['read_file', 'list_directory', 'search_files'] + } + elif new_mode == "database": + if db_index is None: + return { + 'success': False, + 'error': 'Database index required. Use /mcp db ' + } + + if db_index < 1 or db_index > len(self.databases): + return { + 'success': False, + 'error': f'Invalid database number. Use 1-{len(self.databases)}' + } + + self.mode = "database" + self.selected_db_index = db_index - 1 # Convert to 0-based + db = self.databases[self.selected_db_index] + + app_logger.info(f"Switched to database mode: {db['name']}") + return { + 'success': True, + 'mode': 'database', + 'database': db, + 'message': f"Switched to database #{db_index}: {db['name']}", + 'tools': ['inspect_database', 'search_database', 'query_database'] + } + else: + return { + 'success': False, + 'error': f'Invalid mode: {new_mode}' + } + + def add_folder(self, folder_path: str) -> Dict[str, Any]: + """Add folder to allowed list.""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled. Use /mcp enable first' + } + + try: + path = self.config.normalize_path(folder_path) + + # Validation checks + if not path.exists(): + return { + 'success': False, + 'error': f'Directory does not exist: {path}' + } + + if not path.is_dir(): + return { + 'success': False, + 'error': f'Not a directory: {path}' + } + + if self.config.is_system_directory(path): + return { + 'success': False, + 'error': f'Cannot add system directory: {path}' + } + + # Check if already added + if path in self.allowed_folders: + return { + 'success': False, + 'error': f'Folder already in allowed list: {path}' + } + + # Check for nested paths + parent_folder = None + for existing in self.allowed_folders: + try: + path.relative_to(existing) + parent_folder = existing + break + except ValueError: + continue + + # Get folder stats + stats = self.config.get_folder_stats(path) + + # Add folder + self.allowed_folders.append(path) + self._save_folders() + + # Update server and reload gitignores + if self.server: + self.server.allowed_folders = self.allowed_folders + self.server.reload_gitignores() + + app_logger.info(f"Added folder to MCP: {path}") + + result = { + 'success': True, + 'path': str(path), + 'stats': stats, + 'total_folders': len(self.allowed_folders) + } + + if parent_folder: + result['warning'] = f'Note: {parent_folder} is already allowed (parent folder)' + + return result + + except Exception as e: + app_logger.error(f"Error adding folder: {e}") + return { + 'success': False, + 'error': str(e) + } + + def remove_folder(self, folder_ref: str) -> Dict[str, Any]: + """Remove folder from allowed list (by path or number).""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled. Use /mcp enable first' + } + + try: + # Check if it's a number + if folder_ref.isdigit(): + index = int(folder_ref) - 1 + if 0 <= index < len(self.allowed_folders): + path = self.allowed_folders[index] + else: + return { + 'success': False, + 'error': f'Invalid folder number: {folder_ref}' + } + else: + # Treat as path + path = self.config.normalize_path(folder_ref) + if path not in self.allowed_folders: + return { + 'success': False, + 'error': f'Folder not in allowed list: {path}' + } + + # Remove folder + self.allowed_folders.remove(path) + self._save_folders() + + # Update server and reload gitignores + if self.server: + self.server.allowed_folders = self.allowed_folders + self.server.reload_gitignores() + + app_logger.info(f"Removed folder from MCP: {path}") + + result = { + 'success': True, + 'path': str(path), + 'total_folders': len(self.allowed_folders) + } + + if len(self.allowed_folders) == 0: + result['warning'] = 'No allowed folders remaining. Add one with /mcp add ' + + return result + + except Exception as e: + app_logger.error(f"Error removing folder: {e}") + return { + 'success': False, + 'error': str(e) + } + + def add_database(self, db_path: str) -> Dict[str, Any]: + """Add SQLite database.""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled. Use /mcp enable first' + } + + try: + path = Path(db_path).resolve() + + # Validation + if not path.exists(): + return { + 'success': False, + 'error': f'Database file not found: {path}' + } + + if not path.is_file(): + return { + 'success': False, + 'error': f'Not a file: {path}' + } + + # Check if already added + if any(db['path'] == str(path) for db in self.databases): + return { + 'success': False, + 'error': f'Database already added: {path.name}' + } + + # Validate it's a SQLite database and get schema + try: + conn = sqlite3.connect(f'file:{path}?mode=ro', uri=True) + cursor = conn.cursor() + + # Get tables + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + tables = [row[0] for row in cursor.fetchall()] + + conn.close() + except sqlite3.DatabaseError as e: + return { + 'success': False, + 'error': f'Not a valid SQLite database: {e}' + } + + # Get file size + db_size = path.stat().st_size + + # Create database entry + db_info = { + 'path': str(path), + 'name': path.name, + 'size': db_size, + 'tables': tables, + 'added': datetime.datetime.now().isoformat() + } + + # Save to config + self._save_database(db_info) + + # Add to list + self.databases.append(db_info) + + app_logger.info(f"Added database: {path.name}") + + return { + 'success': True, + 'database': db_info, + 'number': len(self.databases), + 'message': f'Added database #{len(self.databases)}: {path.name}' + } + + except Exception as e: + app_logger.error(f"Error adding database: {e}") + return { + 'success': False, + 'error': str(e) + } + + def remove_database(self, db_ref: str) -> Dict[str, Any]: + """Remove database (by number or path).""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled' + } + + try: + # Check if it's a number + if db_ref.isdigit(): + index = int(db_ref) - 1 + if 0 <= index < len(self.databases): + db = self.databases[index] + else: + return { + 'success': False, + 'error': f'Invalid database number: {db_ref}' + } + else: + # Treat as path + path = str(Path(db_ref).resolve()) + db = next((d for d in self.databases if d['path'] == path), None) + if not db: + return { + 'success': False, + 'error': f'Database not found: {db_ref}' + } + index = self.databases.index(db) + + # If currently selected, deselect + if self.mode == "database" and self.selected_db_index == index: + self.mode = "files" + self.selected_db_index = None + + # Remove from config + self._remove_database_from_config(db['path']) + + # Remove from list + self.databases.pop(index) + + app_logger.info(f"Removed database: {db['name']}") + + result = { + 'success': True, + 'database': db, + 'message': f"Removed database: {db['name']}" + } + + if len(self.databases) == 0: + result['warning'] = 'No databases remaining. Add one with /mcp add db ' + + return result + + except Exception as e: + app_logger.error(f"Error removing database: {e}") + return { + 'success': False, + 'error': str(e) + } + + def list_databases(self) -> Dict[str, Any]: + """List all databases.""" + try: + db_list = [] + for idx, db in enumerate(self.databases, 1): + db_info = { + 'number': idx, + 'name': db['name'], + 'path': db['path'], + 'size_mb': db['size'] / (1024 * 1024), + 'tables': db['tables'], + 'table_count': len(db['tables']), + 'added': db['added'] + } + + # Check if file still exists + if not Path(db['path']).exists(): + db_info['warning'] = 'File not found' + + db_list.append(db_info) + + return { + 'success': True, + 'databases': db_list, + 'count': len(db_list) + } + + except Exception as e: + app_logger.error(f"Error listing databases: {e}") + return { + 'success': False, + 'error': str(e) + } + + def toggle_gitignore(self, enabled: bool) -> Dict[str, Any]: + """Toggle .gitignore filtering.""" + if not self.enabled: + return { + 'success': False, + 'error': 'MCP is not enabled' + } + + if not self.server: + return { + 'success': False, + 'error': 'MCP server not running' + } + + self.server.respect_gitignore = enabled + status = "enabled" if enabled else "disabled" + + app_logger.info(f".gitignore filtering {status}") + + return { + 'success': True, + 'message': f'.gitignore filtering {status}', + 'pattern_count': len(self.server.gitignore_parser.patterns) if enabled else 0 + } + + def list_folders(self) -> Dict[str, Any]: + """List all allowed folders with stats.""" + try: + folders_info = [] + total_files = 0 + total_size = 0 + + for idx, folder in enumerate(self.allowed_folders, 1): + stats = self.config.get_folder_stats(folder) + + folder_info = { + 'number': idx, + 'path': str(folder), + 'exists': stats.get('exists', False) + } + + if stats.get('exists'): + folder_info['file_count'] = stats['file_count'] + folder_info['size_mb'] = stats['size_mb'] + total_files += stats['file_count'] + total_size += stats['total_size'] + + folders_info.append(folder_info) + + return { + 'success': True, + 'folders': folders_info, + 'total_folders': len(self.allowed_folders), + 'total_files': total_files, + 'total_size_mb': total_size / (1024 * 1024) + } + + except Exception as e: + app_logger.error(f"Error listing folders: {e}") + return { + 'success': False, + 'error': str(e) + } + + def get_status(self) -> Dict[str, Any]: + """Get comprehensive MCP status.""" + try: + stats = get_mcp_stats() + + uptime = None + if self.session_start_time: + delta = datetime.datetime.now() - self.session_start_time + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + uptime = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m" + + folder_info = self.list_folders() + + gitignore_status = "enabled" if (self.server and self.server.respect_gitignore) else "disabled" + gitignore_patterns = len(self.server.gitignore_parser.patterns) if self.server else 0 + + # Current mode info + mode_info = { + 'mode': self.mode, + 'mode_display': '🔧 Files' if self.mode == 'files' else f'🗄️ DB #{self.selected_db_index + 1}' + } + + if self.mode == 'database' and self.selected_db_index is not None: + db = self.databases[self.selected_db_index] + mode_info['database'] = db + + return { + 'success': True, + 'enabled': self.enabled, + 'uptime': uptime, + 'mode_info': mode_info, + 'folder_count': len(self.allowed_folders), + 'database_count': len(self.databases), + 'total_files': folder_info.get('total_files', 0), + 'total_size_mb': folder_info.get('total_size_mb', 0), + 'stats': stats, + 'tools_available': self._get_current_tools(), + 'gitignore_status': gitignore_status, + 'gitignore_patterns': gitignore_patterns + } + + except Exception as e: + app_logger.error(f"Error getting MCP status: {e}") + return { + 'success': False, + 'error': str(e) + } + + def _get_current_tools(self) -> List[str]: + """Get tools available in current mode.""" + if not self.enabled: + return [] + + if self.mode == "files": + return ['read_file', 'list_directory', 'search_files'] + elif self.mode == "database": + return ['inspect_database', 'search_database', 'query_database'] + + return [] + + async def call_tool(self, tool_name: str, **kwargs) -> Dict[str, Any]: + """Call an MCP tool.""" + if not self.enabled or not self.server: + return { + 'error': 'MCP is not enabled' + } + + try: + # File mode tools + if tool_name == 'read_file': + return await self.server.read_file(kwargs.get('file_path', '')) + elif tool_name == 'list_directory': + return await self.server.list_directory( + kwargs.get('dir_path', ''), + kwargs.get('recursive', False) + ) + elif tool_name == 'search_files': + return await self.server.search_files( + kwargs.get('pattern', ''), + kwargs.get('search_path'), + kwargs.get('content_search', False) + ) + + # Database mode tools + elif tool_name == 'inspect_database': + if self.mode != 'database' or self.selected_db_index is None: + return {'error': 'Not in database mode. Use /mcp db first'} + db = self.databases[self.selected_db_index] + return await self.server.inspect_database( + db['path'], + kwargs.get('table_name') + ) + elif tool_name == 'search_database': + if self.mode != 'database' or self.selected_db_index is None: + return {'error': 'Not in database mode. Use /mcp db first'} + db = self.databases[self.selected_db_index] + return await self.server.search_database( + db['path'], + kwargs.get('search_term', ''), + kwargs.get('table_name'), + kwargs.get('column_name') + ) + elif tool_name == 'query_database': + if self.mode != 'database' or self.selected_db_index is None: + return {'error': 'Not in database mode. Use /mcp db first'} + db = self.databases[self.selected_db_index] + return await self.server.query_database( + db['path'], + kwargs.get('query', ''), + kwargs.get('limit') + ) + else: + return { + 'error': f'Unknown tool: {tool_name}' + } + except Exception as e: + app_logger.error(f"Error calling MCP tool {tool_name}: {e}") + return { + 'error': str(e) + } + + def get_tools_schema(self) -> List[Dict[str, Any]]: + """Get MCP tools as OpenAI function calling schema (for current mode).""" + if not self.enabled: + return [] + + if self.mode == "files": + return self._get_file_tools_schema() + elif self.mode == "database": + return self._get_database_tools_schema() + + return [] + + def _get_file_tools_schema(self) -> List[Dict[str, Any]]: + """Get file mode tools schema.""" + if len(self.allowed_folders) == 0: + return [] + + allowed_dirs_str = ", ".join(str(f) for f in self.allowed_folders) + + return [ + { + "type": "function", + "function": { + "name": "search_files", + "description": f"Search for files in the user's local filesystem. Allowed directories: {allowed_dirs_str}. Can search by filename pattern (e.g., '*.py' for Python files) or search within file contents. Automatically respects .gitignore patterns and excludes virtual environments.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Search pattern. For filename search, use glob patterns like '*.py', '*.txt', 'report*'. For content search, use plain text to search for." + }, + "content_search": { + "type": "boolean", + "description": "If true, searches inside file contents for the pattern (SLOW - use only when specifically asked to search file contents). If false, searches only filenames (FAST - use for finding files by name). Default is false. Virtual environments and package directories are automatically excluded.", + "default": False + }, + "search_path": { + "type": "string", + "description": "Optional: specific directory to search in. If not provided, searches all allowed directories.", + } + }, + "required": ["pattern"] + } + } + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read the complete contents of a text file from the user's local filesystem. Only works for files within allowed directories. Maximum file size: 10MB. Files larger than 50KB are automatically truncated. Respects .gitignore patterns.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Full path to the file to read (e.g., /Users/username/Documents/report.txt or ~/Documents/notes.md)" + } + }, + "required": ["file_path"] + } + } + }, + { + "type": "function", + "function": { + "name": "list_directory", + "description": "List all files and subdirectories in a directory from the user's local filesystem. Only works for directories within allowed paths. Automatically filters out virtual environments, build artifacts, and .gitignore patterns. Limited to 1000 items.", + "parameters": { + "type": "object", + "properties": { + "dir_path": { + "type": "string", + "description": "Directory path to list (e.g., ~/Documents or /Users/username/Projects)" + }, + "recursive": { + "type": "boolean", + "description": "If true, lists all files in subdirectories recursively. If false, lists only immediate children. Default is true. WARNING: Recursive listings can be very large - use specific paths when possible.", + "default": True + } + }, + "required": ["dir_path"] + } + } + } + ] + + def _get_database_tools_schema(self) -> List[Dict[str, Any]]: + """Get database mode tools schema.""" + if self.selected_db_index is None or self.selected_db_index >= len(self.databases): + return [] + + db = self.databases[self.selected_db_index] + db_name = db['name'] + tables_str = ", ".join(db['tables']) + + return [ + { + "type": "function", + "function": { + "name": "inspect_database", + "description": f"Inspect the schema of the currently selected SQLite database ({db_name}). Can get all tables or details for a specific table including columns, types, indexes, and row counts. Available tables: {tables_str}.", + "parameters": { + "type": "object", + "properties": { + "table_name": { + "type": "string", + "description": f"Optional: specific table to inspect. If not provided, returns info for all tables. Available: {tables_str}" + } + }, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "search_database", + "description": f"Search for a value across all tables in the database ({db_name}). Performs a LIKE search (partial matching) across all columns or specific table/column. Returns matching rows with all column data. Limited to {self.server.default_query_limit} results.", + "parameters": { + "type": "object", + "properties": { + "search_term": { + "type": "string", + "description": "Value to search for (partial match supported)" + }, + "table_name": { + "type": "string", + "description": f"Optional: limit search to specific table. Available: {tables_str}" + }, + "column_name": { + "type": "string", + "description": "Optional: limit search to specific column within the table" + } + }, + "required": ["search_term"] + } + } + }, + { + "type": "function", + "function": { + "name": "query_database", + "description": f"Execute a read-only SQL query on the database ({db_name}). Supports SELECT queries including JOINs, subqueries, CTEs, aggregations, etc. Maximum {self.server.max_query_results} rows returned. Query timeout: {self.server.max_query_timeout} seconds. INSERT/UPDATE/DELETE/DROP are blocked for safety.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": f"SQL SELECT query to execute. Available tables: {tables_str}. Example: SELECT * FROM table WHERE column = 'value' LIMIT 10" + }, + "limit": { + "type": "integer", + "description": f"Optional: maximum rows to return (default {self.server.default_query_limit}, max {self.server.max_query_results}). You can also use LIMIT in your query." + } + }, + "required": ["query"] + } + } + } + ] + +# Global MCP manager +mcp_manager = MCPManager() + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + +def supports_function_calling(model: Dict[str, Any]) -> bool: + """Check if model supports function calling.""" + supported_params = model.get("supported_parameters", []) + return "tools" in supported_params or "functions" in supported_params + +def check_for_updates(current_version: str) -> str: + """Check for updates.""" try: response = requests.get( 'https://gitlab.pm/api/v1/repos/rune/oai/releases/latest', @@ -534,36 +2614,20 @@ def check_for_updates(current_version: str) -> str: logger.debug(f"Already up to date: {current_version}") return f"[bold green]oAI version {current_version} (up to date)[/]" - except requests.exceptions.HTTPError as e: - logger.warning(f"HTTP error checking for updates: {e.response.status_code}") - return f"[bold green]oAI version {current_version}[/]" - except requests.exceptions.ConnectionError: - logger.warning("Network error checking for updates (offline?)") - return f"[bold green]oAI version {current_version}[/]" - except requests.exceptions.Timeout: - logger.warning("Timeout checking for updates") - return f"[bold green]oAI version {current_version}[/]" - except requests.exceptions.RequestException as e: - logger.warning(f"Request error checking for updates: {type(e).__name__}") - return f"[bold green]oAI version {current_version}[/]" - except (KeyError, ValueError) as e: - logger.warning(f"Invalid API response checking for updates: {e}") - return f"[bold green]oAI version {current_version}[/]" - except Exception as e: - logger.error(f"Unexpected error checking for updates: {e}") + except: return f"[bold green]oAI version {current_version}[/]" def save_conversation(name: str, data: List[Dict[str, str]]): - """Save conversation history to DB.""" + """Save conversation.""" timestamp = datetime.datetime.now().isoformat() data_json = json.dumps(data) - with sqlite3.connect(DB_FILE) as conn: + with sqlite3.connect(str(database)) as conn: conn.execute('INSERT INTO conversation_sessions (name, timestamp, data) VALUES (?, ?, ?)', (name, timestamp, data_json)) conn.commit() def load_conversation(name: str) -> Optional[List[Dict[str, str]]]: - """Load conversation history from DB (latest by timestamp).""" - with sqlite3.connect(DB_FILE) as conn: + """Load conversation.""" + with sqlite3.connect(str(database)) as conn: cursor = conn.execute('SELECT data FROM conversation_sessions WHERE name = ? ORDER BY timestamp DESC LIMIT 1', (name,)) result = cursor.fetchone() if result: @@ -571,15 +2635,15 @@ def load_conversation(name: str) -> Optional[List[Dict[str, str]]]: return None def delete_conversation(name: str) -> int: - """Delete all conversation sessions with the given name. Returns number of deleted rows.""" - with sqlite3.connect(DB_FILE) as conn: + """Delete conversation.""" + with sqlite3.connect(str(database)) as conn: cursor = conn.execute('DELETE FROM conversation_sessions WHERE name = ?', (name,)) conn.commit() return cursor.rowcount def list_conversations() -> List[Dict[str, Any]]: - """List all saved conversations from DB with metadata.""" - with sqlite3.connect(DB_FILE) as conn: + """List conversations.""" + with sqlite3.connect(str(database)) as conn: cursor = conn.execute(''' SELECT name, MAX(timestamp) as last_saved, data FROM conversation_sessions @@ -598,34 +2662,32 @@ def list_conversations() -> List[Dict[str, Any]]: return conversations def estimate_cost(input_tokens: int, output_tokens: int) -> float: - """Estimate cost in USD based on token counts.""" + """Estimate cost.""" return (input_tokens * MODEL_PRICING['input'] / 1_000_000) + (output_tokens * MODEL_PRICING['output'] / 1_000_000) def has_web_search_capability(model: Dict[str, Any]) -> bool: - """Check if model supports web search based on supported_parameters.""" + """Check web search capability.""" supported_params = model.get("supported_parameters", []) - # Web search is typically indicated by 'tools' parameter support return "tools" in supported_params def has_image_capability(model: Dict[str, Any]) -> bool: - """Check if model supports image input based on input modalities.""" + """Check image capability.""" architecture = model.get("architecture", {}) input_modalities = architecture.get("input_modalities", []) return "image" in input_modalities def supports_online_mode(model: Dict[str, Any]) -> bool: - """Check if model supports :online suffix for web search.""" - # Models that support tools parameter can use :online + """Check online mode support.""" return has_web_search_capability(model) def get_effective_model_id(base_model_id: str, online_enabled: bool) -> str: - """Get the effective model ID with :online suffix if enabled.""" + """Get effective model ID.""" if online_enabled and not base_model_id.endswith(':online'): return f"{base_model_id}:online" return base_model_id def export_as_markdown(session_history: List[Dict[str, str]], session_system_prompt: str = "") -> str: - """Export conversation history as Markdown.""" + """Export as Markdown.""" lines = ["# Conversation Export", ""] if session_system_prompt: lines.extend([f"**System Prompt:** {session_system_prompt}", ""]) @@ -651,7 +2713,7 @@ def export_as_markdown(session_history: List[Dict[str, str]], session_system_pro return "\n".join(lines) def export_as_json(session_history: List[Dict[str, str]], session_system_prompt: str = "") -> str: - """Export conversation history as JSON.""" + """Export as JSON.""" export_data = { "export_date": datetime.datetime.now().isoformat(), "system_prompt": session_system_prompt, @@ -661,8 +2723,7 @@ def export_as_json(session_history: List[Dict[str, str]], session_system_prompt: return json.dumps(export_data, indent=2, ensure_ascii=False) def export_as_html(session_history: List[Dict[str, str]], session_system_prompt: str = "") -> str: - """Export conversation history as HTML.""" - # Escape HTML special characters + """Export as HTML.""" def escape_html(text): return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''') @@ -733,99 +2794,84 @@ def export_as_html(session_history: List[Dict[str, str]], session_system_prompt: return "\n".join(html_parts) -def show_command_help(command: str): - """Display detailed help for a specific command.""" - # Normalize command to ensure it starts with / - if not command.startswith('/'): - command = '/' + command +def show_command_help(command_or_topic: str): + """Display detailed help for command or topic.""" + # Handle topics (like 'mcp') + if not command_or_topic.startswith('/'): + if command_or_topic.lower() == 'mcp': + help_data = COMMAND_HELP['mcp'] + + help_content = [] + help_content.append(f"[bold cyan]Description:[/]") + help_content.append(help_data['description']) + help_content.append("") + help_content.append(help_data['notes']) + + console.print(Panel( + "\n".join(help_content), + title="[bold green]MCP - Model Context Protocol Guide[/]", + title_align="left", + border_style="green", + width=console.width - 4 + )) + + app_logger.info("Displayed MCP comprehensive guide") + return + else: + command_or_topic = '/' + command_or_topic - # Check if command exists - if command not in COMMAND_HELP: - console.print(f"[bold red]Unknown command: {command}[/]") + if command_or_topic not in COMMAND_HELP: + console.print(f"[bold red]Unknown command: {command_or_topic}[/]") console.print("[bold yellow]Type /help to see all available commands.[/]") - app_logger.warning(f"Help requested for unknown command: {command}") + console.print("[bold yellow]Type /help mcp for comprehensive MCP guide.[/]") + app_logger.warning(f"Help requested for unknown command: {command_or_topic}") return - help_data = COMMAND_HELP[command] + help_data = COMMAND_HELP[command_or_topic] - # Create detailed help panel help_content = [] - # Aliases if available if 'aliases' in help_data: aliases_str = ", ".join(help_data['aliases']) help_content.append(f"[bold cyan]Aliases:[/] {aliases_str}") help_content.append("") - # Description help_content.append(f"[bold cyan]Description:[/]") help_content.append(help_data['description']) help_content.append("") - # Usage help_content.append(f"[bold cyan]Usage:[/]") help_content.append(f"[yellow]{help_data['usage']}[/]") help_content.append("") - # Examples if 'examples' in help_data and help_data['examples']: help_content.append(f"[bold cyan]Examples:[/]") for desc, example in help_data['examples']: - help_content.append(f" [dim]{desc}:[/]") - help_content.append(f" [green]{example}[/]") - help_content.append("") + if not desc and not example: + help_content.append("") + elif desc.startswith('━━━'): + help_content.append(f"[bold yellow]{desc}[/]") + else: + help_content.append(f" [dim]{desc}:[/]" if desc else "") + help_content.append(f" [green]{example}[/]" if example else "") + help_content.append("") - # Notes if 'notes' in help_data: help_content.append(f"[bold cyan]Notes:[/]") help_content.append(f"[dim]{help_data['notes']}[/]") console.print(Panel( "\n".join(help_content), - title=f"[bold green]Help: {command}[/]", + title=f"[bold green]Help: {command_or_topic}[/]", title_align="left", border_style="green", width=console.width - 4 )) - app_logger.info(f"Displayed detailed help for command: {command}") - -# Load configs (AFTER logging is set up) -API_KEY = get_config('api_key') -OPENROUTER_BASE_URL = get_config('base_url') or "https://openrouter.ai/api/v1" -STREAM_ENABLED = get_config('stream_enabled') or "on" -DEFAULT_MODEL_ID = get_config('default_model') -MAX_TOKEN = int(get_config('max_token') or "100000") -COST_WARNING_THRESHOLD = float(get_config(HIGH_COST_WARNING) or "0.01") # Configurable cost threshold for alerts -DEFAULT_ONLINE_MODE = get_config('default_online_mode') or "off" # New: Default online mode setting - -# Fetch models with app identification headers -models_data = [] -text_models = [] -try: - headers = { - "Authorization": f"Bearer {API_KEY}", - "HTTP-Referer": APP_URL, - "X-Title": APP_NAME - } if API_KEY else { - "HTTP-Referer": APP_URL, - "X-Title": APP_NAME - } - response = requests.get(f"{OPENROUTER_BASE_URL}/models", headers=headers) - response.raise_for_status() - models_data = response.json()["data"] - text_models = [m for m in models_data if "modalities" not in m or "video" not in (m.get("modalities") or [])] - selected_model_default = None - if DEFAULT_MODEL_ID: - selected_model_default = next((m for m in text_models if m["id"] == DEFAULT_MODEL_ID), None) - if not selected_model_default: - console.print(f"[bold yellow]Warning: Default model '{DEFAULT_MODEL_ID}' unavailable. Use '/config model'.[/]") -except Exception as e: - models_data = [] - text_models = [] - app_logger.error(f"Failed to fetch models: {e}") + app_logger.info(f"Displayed detailed help for command: {command_or_topic}") def get_credits(api_key: str, base_url: str = OPENROUTER_BASE_URL) -> Optional[Dict[str, str]]: + """Get credits.""" if not api_key: return None url = f"{base_url}/credits" @@ -851,7 +2897,7 @@ def get_credits(api_key: str, base_url: str = OPENROUTER_BASE_URL) -> Optional[D return None def check_credit_alerts(credits_data: Optional[Dict[str, str]]) -> List[str]: - """Check and return list of credit-related alerts.""" + """Check credit alerts.""" alerts = [] if credits_data: credits_left_value = float(credits_data['credits_left'].strip('$')) @@ -863,26 +2909,23 @@ def check_credit_alerts(credits_data: Optional[Dict[str, str]]) -> List[str]: return alerts def clear_screen(): + """Clear screen.""" try: print("\033[H\033[J", end="", flush=True) except: print("\n" * 100) def display_paginated_table(table: Table, title: str): - """Display a table with pagination support using Rich console for colored output, repeating header on each page.""" - # Get terminal height (subtract some lines for prompt and margins) + """Display paginated table.""" try: terminal_height = os.get_terminal_size().lines - 8 except: - terminal_height = 20 # Fallback if terminal size can't be determined + terminal_height = 20 - # Create a segment-based approach to capture Rich-rendered output from rich.segment import Segment - # Render the table to segments segments = list(console.render(table)) - # Convert segments to lines while preserving style current_line_segments = [] all_lines = [] @@ -893,27 +2936,22 @@ def display_paginated_table(table: Table, title: str): else: current_line_segments.append(segment) - # Add last line if not empty if current_line_segments: all_lines.append(current_line_segments) total_lines = len(all_lines) - # If fits on one screen after segment analysis if total_lines <= terminal_height: console.print(Panel(table, title=title, title_align="left")) return - # Separate header from data rows header_lines = [] data_lines = [] - # Find where the header ends header_end_index = 0 found_header_text = False for i, line_segments in enumerate(all_lines): - # Check if this line contains header-style text has_header_style = any( seg.style and ('bold' in str(seg.style) or 'magenta' in str(seg.style)) for seg in line_segments @@ -922,56 +2960,44 @@ def display_paginated_table(table: Table, title: str): if has_header_style: found_header_text = True - # After finding header text, the next line with box-drawing chars is the separator if found_header_text and i > 0: line_text = ''.join(seg.text for seg in line_segments) if any(char in line_text for char in ['─', '━', '┼', '╪', '┤', '├']): header_end_index = i break - # If we found a header separator, split there if header_end_index > 0: header_lines = all_lines[:header_end_index + 1] data_lines = all_lines[header_end_index + 1:] else: - # Fallback: assume first 3 lines are header header_lines = all_lines[:min(3, len(all_lines))] data_lines = all_lines[min(3, len(all_lines)):] - # Calculate how many data lines fit per page lines_per_page = terminal_height - len(header_lines) - # Display with pagination current_line = 0 page_number = 1 while current_line < len(data_lines): - # Clear screen for each page clear_screen() - # Print title console.print(f"[bold cyan]{title} (Page {page_number})[/]") - # Print header on every page for line_segments in header_lines: for segment in line_segments: console.print(segment.text, style=segment.style, end="") console.print() - # Calculate how many data lines to show on this page end_line = min(current_line + lines_per_page, len(data_lines)) - # Print data lines for this page for line_segments in data_lines[current_line:end_line]: for segment in line_segments: console.print(segment.text, style=segment.style, end="") console.print() - # Update position current_line = end_line page_number += 1 - # If there's more content, wait for user if current_line < len(data_lines): console.print(f"\n[dim yellow]--- Press SPACE for next page, or any other key to finish (Page {page_number - 1}, showing {end_line}/{len(data_lines)} data rows) ---[/dim yellow]") try: @@ -991,16 +3017,20 @@ def display_paginated_table(table: Table, title: str): finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) except: - # Fallback for Windows or if termios not available input_char = input().strip() if input_char != '': break else: break + # P3 + # ============================================================================ +# MAIN CHAT FUNCTION WITH FULL MCP SUPPORT (FILES + DATABASE) +# ============================================================================ @app.command() def chat(): global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD, DEFAULT_ONLINE_MODE, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, LOG_LEVEL_STR, app_logger + session_max_token = 0 session_system_prompt = "" session_history = [] @@ -1009,11 +3039,11 @@ def chat(): total_output_tokens = 0 total_cost = 0.0 message_count = 0 - middle_out_enabled = False # Session-level middle-out transform flag - conversation_memory_enabled = True # Memory ON by default - memory_start_index = 0 # Track when memory was last enabled - saved_conversations_cache = [] # Cache for /list results to use with /load by number - online_mode_enabled = DEFAULT_ONLINE_MODE == "on" # Initialize from config + middle_out_enabled = False + conversation_memory_enabled = True + memory_start_index = 0 + saved_conversations_cache = [] + online_mode_enabled = DEFAULT_ONLINE_MODE == "on" app_logger.info("Starting new chat session with memory enabled") @@ -1035,7 +3065,6 @@ def chat(): console.print("[bold red]No models available. Check API key/URL.[/]") raise typer.Exit() - # Check for credit alerts at startup credits_data = get_credits(API_KEY, OPENROUTER_BASE_URL) startup_credit_alerts = check_credit_alerts(credits_data) if startup_credit_alerts: @@ -1045,7 +3074,6 @@ def chat(): selected_model = selected_model_default - # Initialize OpenRouter client client = OpenRouter(api_key=API_KEY) if selected_model: @@ -1057,18 +3085,35 @@ def chat(): if not selected_model: console.print("[bold yellow]No model selected. Use '/model'.[/]") - # Persistent input history session = PromptSession(history=FileHistory(str(history_file))) while True: try: - user_input = session.prompt("You> ", auto_suggest=AutoSuggestFromHistory()).strip() + # ============================================================ + # BUILD PROMPT PREFIX WITH MODE INDICATOR + # ============================================================ + prompt_prefix = "You> " + if mcp_manager.enabled: + if mcp_manager.mode == "files": + prompt_prefix = "[🔧 MCP: Files] You> " + elif mcp_manager.mode == "database" and mcp_manager.selected_db_index is not None: + db = mcp_manager.databases[mcp_manager.selected_db_index] + prompt_prefix = f"[🗄️ MCP: DB #{mcp_manager.selected_db_index + 1}] You> " - # Handle // escape sequence - convert to single / and treat as regular text + # ============================================================ + # INITIALIZE LOOP VARIABLES + # ============================================================ + text_part = "" + file_attachments = [] + content_blocks = [] + + user_input = session.prompt(prompt_prefix, auto_suggest=AutoSuggestFromHistory()).strip() + + # Handle escape sequence if user_input.startswith("//"): - user_input = user_input[1:] # Remove first slash, keep the rest + user_input = user_input[1:] - # Check for unknown commands + # Check unknown commands elif user_input.startswith("/") and user_input.lower() not in ["exit", "quit", "bye"]: command_word = user_input.split()[0].lower() if user_input.split() else user_input.lower() @@ -1084,7 +3129,479 @@ def chat(): console.print("[bold yellow]Goodbye![/]") return - # Commands with logging + # ============================================================ + # MCP COMMANDS + # ============================================================ + if user_input.lower().startswith("/mcp"): + parts = user_input[5:].strip().split(maxsplit=1) + mcp_command = parts[0].lower() if parts else "" + mcp_args = parts[1] if len(parts) > 1 else "" + + if mcp_command == "enable": + result = mcp_manager.enable() + if result['success']: + console.print(f"[bold green]✓ {result['message']}[/]") + if result['folder_count'] == 0 and result['database_count'] == 0: + console.print("\n[bold yellow]No folders or databases configured yet.[/]") + console.print("\n[bold cyan]To get started:[/]") + console.print(" [bold yellow]Files:[/] /mcp add ~/Documents") + console.print(" [bold yellow]Databases:[/] /mcp add db ~/app/data.db") + else: + folders_msg = f"{result['folder_count']} folder(s)" if result['folder_count'] else "no folders" + dbs_msg = f"{result['database_count']} database(s)" if result['database_count'] else "no databases" + console.print(f"\n[bold cyan]MCP active with {folders_msg} and {dbs_msg}.[/]") + + warning = mcp_manager.config.get_filesystem_warning() + if warning: + console.print(warning) + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command == "disable": + result = mcp_manager.disable() + if result['success']: + console.print(f"[bold green]✓ {result['message']}[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command == "add": + # Check if it's a database or folder + if mcp_args.startswith("db "): + # Database + db_path = mcp_args[3:].strip() + if not db_path: + console.print("[bold red]Usage: /mcp add db [/]") + continue + + result = mcp_manager.add_database(db_path) + + if result['success']: + db = result['database'] + + console.print("\n[bold yellow]⚠️ Database Check:[/]") + console.print(f"Adding: [bold]{db['name']}[/]") + console.print(f"Size: {db['size'] / (1024 * 1024):.2f} MB") + console.print(f"Tables: {', '.join(db['tables'])} ({len(db['tables'])} total)") + + if db['size'] > 100 * 1024 * 1024: # > 100MB + console.print("\n[bold yellow]⚠️ Large database detected! Queries may be slow.[/]") + + console.print("\nMCP will be able to:") + console.print(" [green]✓[/green] Inspect database schema") + console.print(" [green]✓[/green] Search for data across tables") + console.print(" [green]✓[/green] Execute read-only SQL queries") + console.print(" [red]✗[/red] Modify data (read-only mode)") + + try: + confirm = typer.confirm("\nProceed?", default=True) + if not confirm: + # Remove it + mcp_manager.remove_database(str(result['number'])) + console.print("[bold yellow]Cancelled. Database not added.[/]") + continue + except (EOFError, KeyboardInterrupt): + mcp_manager.remove_database(str(result['number'])) + console.print("\n[bold yellow]Cancelled. Database not added.[/]") + continue + + console.print(f"\n[bold green]✓ {result['message']}[/]") + console.print(f"\n[bold cyan]Use '/mcp db {result['number']}' to start querying this database.[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + else: + # Folder + folder_path = mcp_args + if not folder_path: + console.print("[bold red]Usage: /mcp add or /mcp add db [/]") + continue + + result = mcp_manager.add_folder(folder_path) + + if result['success']: + stats = result['stats'] + + console.print("\n[bold yellow]⚠️ Security Check:[/]") + console.print(f"You are granting MCP access to: [bold]{result['path']}[/]") + + if stats.get('exists'): + file_count = stats['file_count'] + size_mb = stats['size_mb'] + console.print(f"This folder contains: [bold]{file_count} files ({size_mb:.1f} MB)[/]") + + console.print("\nMCP will be able to:") + console.print(" [green]✓[/green] Read files in this folder") + console.print(" [green]✓[/green] List and search files") + console.print(" [green]✓[/green] Access subfolders recursively") + console.print(" [green]✓[/green] Automatically respect .gitignore patterns") + console.print(" [red]✗[/red] Delete or modify files (read-only)") + + try: + confirm = typer.confirm("\nProceed?", default=True) + if not confirm: + mcp_manager.allowed_folders.remove(mcp_manager.config.normalize_path(folder_path)) + mcp_manager._save_folders() + console.print("[bold yellow]Cancelled. Folder not added.[/]") + continue + except (EOFError, KeyboardInterrupt): + mcp_manager.allowed_folders.remove(mcp_manager.config.normalize_path(folder_path)) + mcp_manager._save_folders() + console.print("\n[bold yellow]Cancelled. Folder not added.[/]") + continue + + console.print(f"\n[bold green]✓ Added {result['path']} to MCP allowed folders[/]") + console.print(f"[dim cyan]MCP now has access to {result['total_folders']} folder(s) total.[/]") + + if result.get('warning'): + console.print(f"\n[bold yellow]⚠️ {result['warning']}[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command in ["remove", "rem"]: + # Check if it's a database or folder + if mcp_args.startswith("db "): + # Database + db_ref = mcp_args[3:].strip() + if not db_ref: + console.print("[bold red]Usage: /mcp remove db [/]") + continue + + result = mcp_manager.remove_database(db_ref) + + if result['success']: + console.print(f"\n[bold yellow]Removing: {result['database']['name']}[/]") + + try: + confirm = typer.confirm("Confirm removal?", default=False) + if not confirm: + console.print("[bold yellow]Cancelled.[/]") + # Re-add it + mcp_manager.databases.append(result['database']) + mcp_manager._save_database(result['database']) + continue + except (EOFError, KeyboardInterrupt): + console.print("\n[bold yellow]Cancelled.[/]") + mcp_manager.databases.append(result['database']) + mcp_manager._save_database(result['database']) + continue + + console.print(f"\n[bold green]✓ {result['message']}[/]") + + if result.get('warning'): + console.print(f"\n[bold yellow]⚠️ {result['warning']}[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + else: + # Folder + if not mcp_args: + console.print("[bold red]Usage: /mcp remove or /mcp remove db [/]") + continue + + result = mcp_manager.remove_folder(mcp_args) + + if result['success']: + console.print(f"\n[bold yellow]Removing: {result['path']}[/]") + + try: + confirm = typer.confirm("Confirm removal?", default=False) + if not confirm: + mcp_manager.allowed_folders.append(mcp_manager.config.normalize_path(result['path'])) + mcp_manager._save_folders() + console.print("[bold yellow]Cancelled. Folder not removed.[/]") + continue + except (EOFError, KeyboardInterrupt): + mcp_manager.allowed_folders.append(mcp_manager.config.normalize_path(result['path'])) + mcp_manager._save_folders() + console.print("\n[bold yellow]Cancelled. Folder not removed.[/]") + continue + + console.print(f"\n[bold green]✓ Removed {result['path']} from MCP allowed folders[/]") + console.print(f"[dim cyan]MCP now has access to {result['total_folders']} folder(s) total.[/]") + + if result.get('warning'): + console.print(f"\n[bold yellow]⚠️ {result['warning']}[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command == "list": + result = mcp_manager.list_folders() + + if result['success']: + if result['total_folders'] == 0: + console.print("[bold yellow]No folders configured.[/]") + console.print("\n[bold cyan]Add a folder with: /mcp add ~/Documents[/]") + continue + + status_indicator = "[green]✓[/green]" if mcp_manager.enabled else "[red]✗[/red]" + + table = Table( + "No.", "Path", "Files", "Size", + show_header=True, + header_style="bold magenta" + ) + + for folder_info in result['folders']: + number = str(folder_info['number']) + path = folder_info['path'] + + if folder_info['exists']: + files = f"📁 {folder_info['file_count']}" + size = f"{folder_info['size_mb']:.1f} MB" + else: + files = "[red]Not found[/red]" + size = "-" + + table.add_row(number, path, files, size) + + gitignore_info = "" + if mcp_manager.server: + gitignore_status = "on" if mcp_manager.server.respect_gitignore else "off" + pattern_count = len(mcp_manager.server.gitignore_parser.patterns) + gitignore_info = f" | .gitignore: {gitignore_status} ({pattern_count} patterns)" + + console.print(Panel( + table, + title=f"[bold green]MCP Folders: {'Active' if mcp_manager.enabled else 'Inactive'} {status_indicator}[/]", + title_align="left", + subtitle=f"[dim]Total: {result['total_folders']} folders, {result['total_files']} files ({result['total_size_mb']:.1f} MB){gitignore_info}[/]", + subtitle_align="right" + )) + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command == "db": + if not mcp_args: + # Show current database or list all + if mcp_manager.mode == "database" and mcp_manager.selected_db_index is not None: + db = mcp_manager.databases[mcp_manager.selected_db_index] + console.print(f"[bold cyan]Currently using database #{mcp_manager.selected_db_index + 1}: {db['name']}[/]") + console.print(f"[dim]Path: {db['path']}[/]") + console.print(f"[dim]Tables: {', '.join(db['tables'])}[/]") + else: + console.print("[bold yellow]Not in database mode. Use '/mcp db ' to select a database.[/]") + + # Also show hint to list + console.print("\n[dim]Use '/mcp db list' to see all databases[/]") + continue + + if mcp_args == "list": + result = mcp_manager.list_databases() + + if result['success']: + if result['count'] == 0: + console.print("[bold yellow]No databases configured.[/]") + console.print("\n[bold cyan]Add a database with: /mcp add db ~/app/data.db[/]") + continue + + table = Table( + "No.", "Name", "Tables", "Size", "Status", + show_header=True, + header_style="bold magenta" + ) + + for db_info in result['databases']: + number = str(db_info['number']) + name = db_info['name'] + table_count = f"{db_info['table_count']} tables" + size = f"{db_info['size_mb']:.1f} MB" + + if db_info.get('warning'): + status = f"[red]{db_info['warning']}[/red]" + else: + status = "[green]✓[/green]" + + table.add_row(number, name, table_count, size, status) + + console.print(Panel( + table, + title="[bold green]MCP Databases[/]", + title_align="left", + subtitle=f"[dim]Total: {result['count']} database(s) | Use '/mcp db ' to select[/]", + subtitle_align="right" + )) + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + # Switch to database mode + try: + db_num = int(mcp_args) + result = mcp_manager.switch_mode("database", db_num) + + if result['success']: + db = result['database'] + console.print(f"\n[bold green]✓ {result['message']}[/]") + console.print(f"[dim cyan]Tables: {', '.join(db['tables'])}[/]") + console.print(f"\n[bold cyan]Available tools:[/] inspect_database, search_database, query_database") + console.print(f"\n[bold yellow]You can now ask questions about this database![/]") + console.print(f"[dim]Examples:[/]") + console.print(f" 'Show me all tables'") + console.print(f" 'Search for records mentioning X'") + console.print(f" 'How many rows in the users table?'") + console.print(f"\n[dim]Switch back to files: /mcp files[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + except ValueError: + console.print(f"[bold red]Invalid database number: {mcp_args}[/]") + console.print(f"[bold yellow]Use '/mcp db list' to see available databases[/]") + continue + + elif mcp_command == "files": + result = mcp_manager.switch_mode("files") + + if result['success']: + console.print(f"[bold green]✓ {result['message']}[/]") + console.print(f"\n[bold cyan]Available tools:[/] read_file, list_directory, search_files") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + elif mcp_command == "gitignore": + if not mcp_args: + if mcp_manager.server: + status = "enabled" if mcp_manager.server.respect_gitignore else "disabled" + pattern_count = len(mcp_manager.server.gitignore_parser.patterns) + console.print(f"[bold blue].gitignore filtering: {status}[/]") + console.print(f"[dim cyan]Loaded {pattern_count} pattern(s) from .gitignore files[/]") + else: + console.print("[bold yellow]MCP is not enabled. Enable with /mcp enable[/]") + continue + + if mcp_args.lower() == "on": + result = mcp_manager.toggle_gitignore(True) + if result['success']: + console.print(f"[bold green]✓ {result['message']}[/]") + console.print(f"[dim cyan]Using {result['pattern_count']} .gitignore pattern(s)[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + elif mcp_args.lower() == "off": + result = mcp_manager.toggle_gitignore(False) + if result['success']: + console.print(f"[bold green]✓ {result['message']}[/]") + console.print("[bold yellow]Warning: All files will be visible, including those in .gitignore[/]") + else: + console.print(f"[bold red]❌ {result['error']}[/]") + else: + console.print("[bold yellow]Usage: /mcp gitignore on|off[/]") + continue + + elif mcp_command == "status": + result = mcp_manager.get_status() + + if result['success']: + status_color = "green" if result['enabled'] else "red" + status_text = "Active ✓" if result['enabled'] else "Inactive ✗" + + table = Table( + "Property", "Value", + show_header=True, + header_style="bold magenta" + ) + + table.add_row("Status", f"[{status_color}]{status_text}[/{status_color}]") + + # Mode info + mode_info = result['mode_info'] + table.add_row("Current Mode", mode_info['mode_display']) + if 'database' in mode_info: + db = mode_info['database'] + table.add_row("Database Path", db['path']) + table.add_row("Database Tables", ', '.join(db['tables'])) + + if result['uptime']: + table.add_row("Uptime", result['uptime']) + + table.add_row("", "") + table.add_row("[bold]Configuration[/]", "") + table.add_row("Allowed Folders", str(result['folder_count'])) + table.add_row("Databases", str(result['database_count'])) + table.add_row("Total Files Accessible", str(result['total_files'])) + table.add_row("Total Size", f"{result['total_size_mb']:.1f} MB") + table.add_row(".gitignore Filtering", result['gitignore_status']) + if result['gitignore_patterns'] > 0: + table.add_row(".gitignore Patterns", str(result['gitignore_patterns'])) + + if result['enabled']: + table.add_row("", "") + table.add_row("[bold]Tools Available[/]", "") + for tool in result['tools_available']: + table.add_row(f" ✓ {tool}", "") + + stats = result['stats'] + if stats['total_calls'] > 0: + table.add_row("", "") + table.add_row("[bold]Session Stats[/]", "") + table.add_row("Total Tool Calls", str(stats['total_calls'])) + + if stats['reads'] > 0: + table.add_row("Files Read", str(stats['reads'])) + if stats['lists'] > 0: + table.add_row("Directories Listed", str(stats['lists'])) + if stats['searches'] > 0: + table.add_row("File Searches", str(stats['searches'])) + if stats['db_inspects'] > 0: + table.add_row("DB Inspections", str(stats['db_inspects'])) + if stats['db_searches'] > 0: + table.add_row("DB Searches", str(stats['db_searches'])) + if stats['db_queries'] > 0: + table.add_row("SQL Queries", str(stats['db_queries'])) + + if stats['last_used']: + try: + last_used = datetime.datetime.fromisoformat(stats['last_used']) + time_ago = datetime.datetime.now() - last_used + + if time_ago.seconds < 60: + time_str = f"{time_ago.seconds} seconds ago" + elif time_ago.seconds < 3600: + time_str = f"{time_ago.seconds // 60} minutes ago" + else: + time_str = f"{time_ago.seconds // 3600} hours ago" + + table.add_row("Last Used", time_str) + except: + pass + + console.print(Panel( + table, + title="[bold green]MCP Status[/]", + title_align="left" + )) + else: + console.print(f"[bold red]❌ {result['error']}[/]") + continue + + else: + console.print("[bold cyan]MCP (Model Context Protocol) Commands:[/]\n") + console.print(" [bold yellow]/mcp enable[/] - Start MCP server") + console.print(" [bold yellow]/mcp disable[/] - Stop MCP server") + console.print(" [bold yellow]/mcp status[/] - Show comprehensive MCP status") + console.print("") + console.print(" [bold cyan]FILE MODE (default):[/]") + console.print(" [bold yellow]/mcp add [/] - Add folder for file access") + console.print(" [bold yellow]/mcp remove [/] - Remove folder") + console.print(" [bold yellow]/mcp list[/] - List all folders") + console.print(" [bold yellow]/mcp files[/] - Switch to file mode") + console.print(" [bold yellow]/mcp gitignore [on|off][/] - Toggle .gitignore filtering") + console.print("") + console.print(" [bold cyan]DATABASE MODE:[/]") + console.print(" [bold yellow]/mcp add db [/] - Add SQLite database") + console.print(" [bold yellow]/mcp db list[/] - List all databases") + console.print(" [bold yellow]/mcp db [/] - Switch to database mode") + console.print(" [bold yellow]/mcp remove db [/]- Remove database") + console.print("") + console.print("[dim]Use '/help /mcp' for detailed command help[/]") + console.print("[dim]Use '/help mcp' for comprehensive MCP guide[/]") + continue + + # ============================================================ + # ALL OTHER COMMANDS + # ============================================================ + if user_input.lower() == "/retry": if not session_history: console.print("[bold red]No history to retry.[/]") @@ -1094,6 +3611,7 @@ def chat(): console.print("[bold green]Retrying last prompt...[/]") app_logger.info(f"Retrying prompt: {last_prompt[:100]}...") user_input = last_prompt + elif user_input.lower().startswith("/online"): args = user_input[8:].strip() if not args: @@ -1129,6 +3647,7 @@ def chat(): else: console.print("[bold yellow]Usage: /online on|off (or /online to view status)[/]") continue + elif user_input.lower().startswith("/memory"): args = user_input[8:].strip() if not args: @@ -1154,6 +3673,7 @@ def chat(): else: console.print("[bold yellow]Usage: /memory on|off (or /memory to view status)[/]") continue + elif user_input.lower().startswith("/paste"): optional_prompt = user_input[7:].strip() @@ -1205,7 +3725,7 @@ def chat(): console.print(f"[bold red]Error processing clipboard content: {e}[/]") app_logger.error(f"Clipboard processing error: {e}") continue - + elif user_input.lower().startswith("/export"): args = user_input[8:].strip().split(maxsplit=1) if len(args) != 2: @@ -1244,6 +3764,7 @@ def chat(): console.print(f"[bold red]Export failed: {e}[/]") app_logger.error(f"Export error: {e}") continue + elif user_input.lower().startswith("/save"): args = user_input[6:].strip() if not args: @@ -1256,6 +3777,7 @@ def chat(): console.print(f"[bold green]Conversation saved as '{args}'.[/]") app_logger.info(f"Conversation saved as '{args}' with {len(session_history)} messages") continue + elif user_input.lower().startswith("/load"): args = user_input[6:].strip() if not args: @@ -1292,6 +3814,7 @@ def chat(): console.print(f"[bold green]Conversation '{conversation_name}' loaded with {len(session_history)} messages.[/]") app_logger.info(f"Conversation '{conversation_name}' loaded with {len(session_history)} messages") continue + elif user_input.lower().startswith("/delete"): args = user_input[8:].strip() if not args: @@ -1331,6 +3854,7 @@ def chat(): console.print(f"[bold red]Conversation '{conversation_name}' not found.[/]") app_logger.warning(f"Delete failed for '{conversation_name}' - not found") continue + elif user_input.lower() == "/list": conversations = list_conversations() if not conversations: @@ -1359,6 +3883,7 @@ def chat(): console.print(Panel(table, title=f"[bold green]Saved Conversations ({len(conversations)} total)[/]", title_align="left", subtitle="[dim]Use /load or /delete to manage conversations[/]", subtitle_align="right")) app_logger.info(f"User viewed conversation list - {len(conversations)} conversations") continue + elif user_input.lower() == "/prev": if not session_history or current_index <= 0: console.print("[bold red]No previous response.[/]") @@ -1369,6 +3894,7 @@ def chat(): console.print(Panel(md, title=f"[bold green]Previous Response ({current_index + 1}/{len(session_history)})[/]", title_align="left")) app_logger.debug(f"Viewed previous response at index {current_index}") continue + elif user_input.lower() == "/next": if not session_history or current_index >= len(session_history) - 1: console.print("[bold red]No next response.[/]") @@ -1379,6 +3905,7 @@ def chat(): console.print(Panel(md, title=f"[bold green]Next Response ({current_index + 1}/{len(session_history)})[/]", title_align="left")) app_logger.debug(f"Viewed next response at index {current_index}") continue + elif user_input.lower() == "/stats": credits = get_credits(API_KEY, OPENROUTER_BASE_URL) credits_left = credits['credits_left'] if credits else "Unknown" @@ -1395,6 +3922,7 @@ def chat(): console.print(f"[bold red]⚠️ {warning_text}[/]") app_logger.warning(f"Warnings in stats: {warning_text}") continue + elif user_input.lower().startswith("/middleout"): args = user_input[11:].strip() if not args: @@ -1409,6 +3937,7 @@ def chat(): else: console.print("[bold yellow]Usage: /middleout on|off (or /middleout to view status)[/]") continue + elif user_input.lower() == "/reset": try: confirm = typer.confirm("Reset conversation context? This clears history and prompt.", default=False) @@ -1496,7 +4025,6 @@ def chat(): if 1 <= choice <= len(filtered_models): selected_model = filtered_models[choice - 1] - # Apply default online mode if model supports it if supports_online_mode(selected_model) and DEFAULT_ONLINE_MODE == "on": online_mode_enabled = True console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]") @@ -1634,7 +4162,6 @@ def chat(): set_config('log_max_size_mb', str(new_size_mb)) LOG_MAX_SIZE_MB = new_size_mb - # Reload logging configuration immediately app_logger = reload_logging_config() console.print(f"[bold green]Log size limit set to {new_size_mb} MB and applied immediately.[/]") @@ -1715,6 +4242,14 @@ def chat(): DEFAULT_MODEL_ID = get_config('default_model') memory_status = "Enabled" if conversation_memory_enabled else "Disabled" memory_tracked = len(session_history) - memory_start_index if conversation_memory_enabled else 0 + + mcp_status = "Enabled" if mcp_manager.enabled else "Disabled" + mcp_folders = len(mcp_manager.allowed_folders) + mcp_databases = len(mcp_manager.databases) + mcp_mode = mcp_manager.mode + gitignore_status = "enabled" if (mcp_manager.server and mcp_manager.server.respect_gitignore) else "disabled" + gitignore_patterns = len(mcp_manager.server.gitignore_parser.patterns) if mcp_manager.server else 0 + table = Table("Setting", "Value", show_header=True, header_style="bold magenta", width=console.width - 10) table.add_row("API Key", API_KEY or "[Not set]") table.add_row("Base URL", OPENROUTER_BASE_URL or "[Not set]") @@ -1728,6 +4263,9 @@ def chat(): table.add_row("Current Model", "[Not set]" if selected_model is None else str(selected_model["name"])) table.add_row("Default Online Mode", "Enabled" if DEFAULT_ONLINE_MODE == "on" else "Disabled") table.add_row("Session Online Mode", "Enabled" if online_mode_enabled else "Disabled") + table.add_row("MCP Status", f"{mcp_status} ({mcp_folders} folders, {mcp_databases} DBs)") + table.add_row("MCP Mode", f"{mcp_mode}") + table.add_row("MCP .gitignore", f"{gitignore_status} ({gitignore_patterns} patterns)") table.add_row("Max Token", str(MAX_TOKEN)) table.add_row("Session Token", "[Not set]" if session_max_token == 0 else str(session_max_token)) table.add_row("Session System Prompt", session_system_prompt or "[Not set]") @@ -1767,22 +4305,24 @@ def chat(): if user_input.lower() == "/clear" or user_input.lower() == "/cl": clear_screen() DEFAULT_MODEL_ID = get_config('default_model') - token_value = session_max_token if session_max_token != 0 else " Not set" + token_value = session_max_token if session_max_token != 0 else "Not set" console.print(f"[bold cyan]Token limits: Max= {MAX_TOKEN}, Session={token_value}[/]") console.print("[bold blue]Active model[/] [bold red]%s[/]" %(str(selected_model["name"]) if selected_model else "None")) if online_mode_enabled: console.print("[bold cyan]Online mode: Enabled (web search active)[/]") + if mcp_manager.enabled: + gitignore_status = "on" if (mcp_manager.server and mcp_manager.server.respect_gitignore) else "off" + mode_display = "Files" if mcp_manager.mode == "files" else f"DB #{mcp_manager.selected_db_index + 1}" + console.print(f"[bold cyan]MCP: Enabled (Mode: {mode_display}, {len(mcp_manager.allowed_folders)} folders, {len(mcp_manager.databases)} DBs, .gitignore {gitignore_status})[/]") continue if user_input.lower().startswith("/help"): args = user_input[6:].strip() - # If a specific command is requested if args: show_command_help(args) continue - # Otherwise show the full help menu help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan", width=console.width - 10) # SESSION COMMANDS @@ -1793,50 +4333,117 @@ def chat(): ) help_table.add_row( "/clear or /cl", - "Clear the terminal screen for a clean interface. You can also use the keycombo [bold]ctrl+l[/]", + "Clear terminal screen. Keyboard shortcut: [bold]Ctrl+L[/]", "/clear\n/cl" ) help_table.add_row( - "/help [command]", - "Show this help menu or get detailed help for a specific command.", - "/help\n/help /model" + "/help [command|topic]", + "Show help menu or detailed help. Use '/help mcp' for MCP guide.", + "/help\n/help mcp" ) help_table.add_row( "/memory [on|off]", - "Toggle conversation memory. ON sends history (AI remembers), OFF sends only current message (saves cost).", + "Toggle conversation memory. ON sends history, OFF is stateless (saves cost).", "/memory\n/memory off" ) help_table.add_row( "/next", - "View the next response in history.", + "View next response in history.", "/next" ) help_table.add_row( "/online [on|off]", - "Enable/disable online mode (web search) for current session. Overrides default setting.", + "Enable/disable online mode (web search) for session.", "/online on\n/online off" ) help_table.add_row( "/paste [prompt]", - "Paste plain text/code from clipboard and send to AI. Optional prompt can be added.", - "/paste\n/paste Explain this code" + "Paste clipboard text/code. Optional prompt.", + "/paste\n/paste Explain" ) help_table.add_row( "/prev", - "View the previous response in history.", + "View previous response in history.", "/prev" ) help_table.add_row( "/reset", - "Clear conversation history and reset system prompt (resets session metrics). Requires confirmation.", + "Clear history and reset system prompt (requires confirmation).", "/reset" ) help_table.add_row( "/retry", - "Resend the last prompt from history.", + "Resend last prompt.", "/retry" ) + # MCP COMMANDS + help_table.add_row( + "[bold yellow]━━━ MCP (FILE & DATABASE ACCESS) ━━━[/]", + "", + "" + ) + help_table.add_row( + "/mcp enable", + "Start MCP server for file and database access.", + "/mcp enable" + ) + help_table.add_row( + "/mcp disable", + "Stop MCP server.", + "/mcp disable" + ) + help_table.add_row( + "/mcp status", + "Show mode, stats, folders, databases, and .gitignore status.", + "/mcp status" + ) + help_table.add_row( + "/mcp add ", + "Add folder for file access (auto-loads .gitignore).", + "/mcp add ~/Documents" + ) + help_table.add_row( + "/mcp add db ", + "Add SQLite database for querying.", + "/mcp add db ~/app.db" + ) + help_table.add_row( + "/mcp list", + "List all allowed folders with stats.", + "/mcp list" + ) + help_table.add_row( + "/mcp db list", + "List all databases with details.", + "/mcp db list" + ) + help_table.add_row( + "/mcp db ", + "Switch to database mode (select DB by number).", + "/mcp db 1" + ) + help_table.add_row( + "/mcp files", + "Switch to file mode (default).", + "/mcp files" + ) + help_table.add_row( + "/mcp remove ", + "Remove folder by path or number.", + "/mcp remove 2" + ) + help_table.add_row( + "/mcp remove db ", + "Remove database by number.", + "/mcp remove db 1" + ) + help_table.add_row( + "/mcp gitignore [on|off]", + "Toggle .gitignore filtering (respects project excludes).", + "/mcp gitignore on" + ) + # MODEL COMMANDS help_table.add_row( "[bold yellow]━━━ MODEL COMMANDS ━━━[/]", @@ -1845,16 +4452,16 @@ def chat(): ) help_table.add_row( "/info [model_id]", - "Display detailed info (pricing, modalities, context length, online support, etc.) for current or specified model.", + "Display model details (pricing, capabilities, context).", "/info\n/info gpt-4o" ) help_table.add_row( "/model [search]", - "Select or change the current model for the session. Shows image and online capabilities. Supports searching by name or ID.", + "Select/change model. Shows image and online capabilities.", "/model\n/model gpt" ) - # CONFIGURATION COMMANDS + # CONFIGURATION help_table.add_row( "[bold yellow]━━━ CONFIGURATION ━━━[/]", "", @@ -1862,56 +4469,56 @@ def chat(): ) help_table.add_row( "/config", - "View all current configurations, including limits, credits, and history.", + "View all configurations.", "/config" ) help_table.add_row( "/config api", - "Set or update the OpenRouter API key.", + "Set/update API key.", "/config api" ) help_table.add_row( - "/config costwarning [value]", - "Set the cost warning threshold. Alerts when response exceeds this cost (in USD).", + "/config costwarning [val]", + "Set cost warning threshold (USD).", "/config costwarning 0.05" ) help_table.add_row( "/config log [size_mb]", - "Set log file size limit in MB. Older logs are rotated automatically. Takes effect immediately.", + "Set log file size limit. Takes effect immediately.", "/config log 20" ) help_table.add_row( "/config loglevel [level]", - "Set log verbosity level. Valid levels: debug, info, warning, error, critical. Takes effect immediately.", - "/config loglevel debug\n/config loglevel warning" + "Set log verbosity (debug/info/warning/error/critical).", + "/config loglevel debug" ) help_table.add_row( - "/config maxtoken [value]", - "Set stored max token limit (persisted in DB). View current if no value provided.", + "/config maxtoken [val]", + "Set stored max token limit.", "/config maxtoken 50000" ) help_table.add_row( "/config model [search]", - "Set default model that loads on startup. Shows image and online capabilities. Doesn't change current session model.", + "Set default startup model.", "/config model gpt" ) help_table.add_row( "/config online [on|off]", - "Set default online mode for new model selections. Use '/online on|off' to override current session.", + "Set default online mode for new models.", "/config online on" ) help_table.add_row( "/config stream [on|off]", - "Enable or disable response streaming.", + "Enable/disable response streaming.", "/config stream off" ) help_table.add_row( "/config url", - "Set or update the base URL for OpenRouter API.", + "Set/update base URL.", "/config url" ) - # TOKEN & SYSTEM COMMANDS + # TOKEN & SYSTEM help_table.add_row( "[bold yellow]━━━ TOKEN & SYSTEM ━━━[/]", "", @@ -1919,21 +4526,21 @@ def chat(): ) help_table.add_row( "/maxtoken [value]", - "Set temporary session token limit (≤ stored max). View current if no value provided.", + "Set temporary session token limit.", "/maxtoken 2000" ) help_table.add_row( "/middleout [on|off]", - "Enable/disable middle-out transform to compress prompts exceeding context size.", + "Enable/disable prompt compression for large contexts.", "/middleout on" ) help_table.add_row( "/system [prompt|clear]", - "Set session-level system prompt to guide AI behavior. Use 'clear' to reset.", + "Set session system prompt. Use 'clear' to reset.", "/system You are a Python expert" ) - # CONVERSATION MANAGEMENT + # CONVERSATION MGMT help_table.add_row( "[bold yellow]━━━ CONVERSATION MGMT ━━━[/]", "", @@ -1941,31 +4548,31 @@ def chat(): ) help_table.add_row( "/delete ", - "Delete a saved conversation by name or number (from /list). Requires confirmation.", + "Delete saved conversation (requires confirmation).", "/delete my_chat\n/delete 3" ) help_table.add_row( - "/export ", - "Export conversation to file. Formats: md (Markdown), json (JSON), html (HTML).", - "/export md notes.md\n/export html report.html" + "/export ", + "Export conversation (md/json/html).", + "/export md notes.md" ) help_table.add_row( "/list", - "List all saved conversations with numbers, message counts, and timestamps.", + "List saved conversations with numbers and stats.", "/list" ) help_table.add_row( "/load ", - "Load a saved conversation by name or number (from /list). Resets session metrics.", + "Load saved conversation.", "/load my_chat\n/load 3" ) help_table.add_row( "/save ", - "Save current conversation history to database.", + "Save current conversation.", "/save my_chat" ) - # MONITORING & STATS + # MONITORING help_table.add_row( "[bold yellow]━━━ MONITORING & STATS ━━━[/]", "", @@ -1973,12 +4580,12 @@ def chat(): ) help_table.add_row( "/credits", - "Display credits left on your OpenRouter account with alerts.", + "Display OpenRouter credits with alerts.", "/credits" ) help_table.add_row( "/stats", - "Display session cost summary: tokens, cost, credits left, and warnings.", + "Display session stats: tokens, cost, credits.", "/stats" ) @@ -1990,17 +4597,17 @@ def chat(): ) help_table.add_row( "@/path/to/file", - "Attach files to messages: images (PNG, JPG, etc.), PDFs, and code files (.py, .js, etc.).", - "Debug @script.py\nSummarize @document.pdf\nAnalyze @image.png" + "Attach files: images (PNG, JPG), PDFs, code files.", + "Debug @script.py\nAnalyze @image.png" ) help_table.add_row( "Clipboard paste", - "Use /paste to send clipboard content (plain text/code) to AI.", - "/paste\n/paste Explain this" + "Use /paste to send clipboard content.", + "/paste\n/paste Explain" ) help_table.add_row( "// escape", - "Start message with // to send a literal / character (e.g., //command sends '/command' as text, not a command)", + "Start with // to send literal / character.", "//help sends '/help' as text" ) @@ -2012,7 +4619,7 @@ def chat(): ) help_table.add_row( "exit | quit | bye", - "Quit the chat application and display session summary.", + "Quit with session summary.", "exit" ) @@ -2020,7 +4627,7 @@ def chat(): help_table, title="[bold cyan]oAI Chat Help (Version %s)[/]" % version, title_align="center", - subtitle="💡 Tip: Commands are case-insensitive • Use /help for detailed help • Memory ON by default • Use // to escape / • Visit: https://iurl.no/oai", + subtitle="💡 Commands are case-insensitive • Use /help for details • Use /help mcp for MCP guide • Memory ON by default • MCP has file & DB modes • Use // to escape / • Visit: https://iurl.no/oai", subtitle_align="center", border_style="cyan" )) @@ -2029,10 +4636,11 @@ def chat(): if not selected_model: console.print("[bold yellow]Select a model first with '/model'.[/]") continue - # Process file attachments with PDF support - content_blocks = [] + + # ============================================================ + # PROCESS FILE ATTACHMENTS + # ============================================================ text_part = user_input - file_attachments = [] for match in re.finditer(r'@([^\s]+)', user_input): file_path = match.group(1) expanded_path = os.path.expanduser(os.path.abspath(file_path)) @@ -2049,7 +4657,6 @@ def chat(): with open(expanded_path, 'rb') as f: file_data = f.read() - # Handle images if mime_type and mime_type.startswith('image/'): modalities = selected_model.get("architecture", {}).get("input_modalities", []) if "image" not in modalities: @@ -2060,7 +4667,6 @@ def chat(): content_blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64_data}"}}) console.print(f"[dim green]✓ Image attached: {os.path.basename(expanded_path)} ({file_size / 1024:.1f} KB)[/]") - # Handle PDFs elif mime_type == 'application/pdf' or file_ext == '.pdf': modalities = selected_model.get("architecture", {}).get("input_modalities", []) supports_pdf = any(mod in modalities for mod in ["document", "pdf", "file"]) @@ -2072,7 +4678,6 @@ def chat(): content_blocks.append({"type": "image_url", "image_url": {"url": f"data:application/pdf;base64,{b64_data}"}}) console.print(f"[dim green]✓ PDF attached: {os.path.basename(expanded_path)} ({file_size / 1024:.1f} KB)[/]") - # Handle code/text files elif (mime_type == 'text/plain' or file_ext in SUPPORTED_CODE_EXTENSIONS): text_content = file_data.decode('utf-8') content_blocks.append({"type": "text", "text": f"Code File: {os.path.basename(expanded_path)}\n\n{text_content}"}) @@ -2105,12 +4710,28 @@ def chat(): console.print("[bold red]Prompt cannot be empty.[/]") continue - # Build API messages with conversation history if memory is enabled + # ============================================================ + # BUILD API MESSAGES + # ============================================================ api_messages = [] if session_system_prompt: api_messages.append({"role": "system", "content": session_system_prompt}) + # Add database context if in database mode + if mcp_manager.enabled and mcp_manager.mode == "database" and mcp_manager.selected_db_index is not None: + db = mcp_manager.databases[mcp_manager.selected_db_index] + db_context = f"""You are currently connected to SQLite database: {db['name']} +Available tables: {', '.join(db['tables'])} + +You can: +- Inspect the database schema with inspect_database +- Search for data across tables with search_database +- Execute read-only SQL queries with query_database + +All queries are read-only. INSERT/UPDATE/DELETE are not allowed.""" + api_messages.append({"role": "system", "content": db_context}) + if conversation_memory_enabled: for i in range(memory_start_index, len(session_history)): history_entry = session_history[i] @@ -2125,10 +4746,12 @@ def chat(): api_messages.append({"role": "user", "content": message_content}) - # Get effective model ID with :online suffix if enabled + # Get effective model ID effective_model_id = get_effective_model_id(selected_model["id"], online_mode_enabled) - # Build API params + # ======================================================================== + # BUILD API PARAMS WITH MCP TOOLS SUPPORT + # ======================================================================== api_params = { "model": effective_model_id, "messages": api_messages, @@ -2138,6 +4761,27 @@ def chat(): "X-Title": APP_NAME } } + + # Add MCP tools if enabled + mcp_tools_added = False + mcp_tools = [] + if mcp_manager.enabled: + if supports_function_calling(selected_model): + mcp_tools = mcp_manager.get_tools_schema() + if mcp_tools: + api_params["tools"] = mcp_tools + api_params["tool_choice"] = "auto" + mcp_tools_added = True + + # IMPORTANT: Disable streaming if MCP tools are present + if api_params["stream"]: + api_params["stream"] = False + app_logger.info("Disabled streaming due to MCP tool calls") + + app_logger.info(f"Added {len(mcp_tools)} MCP tools to request (mode: {mcp_manager.mode})") + else: + app_logger.debug(f"Model {selected_model['id']} doesn't support function calling - MCP tools not added") + if session_max_token > 0: api_params["max_tokens"] = session_max_token if middle_out_enabled: @@ -2148,17 +4792,44 @@ def chat(): history_messages_count = len(session_history) - memory_start_index if conversation_memory_enabled else 0 memory_status = "ON" if conversation_memory_enabled else "OFF" online_status = "ON" if online_mode_enabled else "OFF" - app_logger.info(f"API Request: Model '{effective_model_id}' (Online: {online_status}), Prompt length: {len(text_part)} chars, {file_count} file(s) attached, Memory: {memory_status}, History sent: {history_messages_count} messages, Transforms: middle-out {'enabled' if middle_out_enabled else 'disabled'}, App: {APP_NAME} ({APP_URL}).") + + if mcp_tools_added: + if mcp_manager.mode == "files": + mcp_status = f"ON (Files, {len(mcp_manager.allowed_folders)} folders, {len(mcp_tools)} tools)" + elif mcp_manager.mode == "database": + db = mcp_manager.databases[mcp_manager.selected_db_index] + mcp_status = f"ON (DB: {db['name']}, {len(mcp_tools)} tools)" + else: + mcp_status = f"ON ({len(mcp_tools)} tools)" + else: + mcp_status = "OFF" + + app_logger.info(f"API Request: Model '{effective_model_id}' (Online: {online_status}, MCP: {mcp_status}), Prompt length: {len(text_part)} chars, {file_count} file(s) attached, Memory: {memory_status}, History sent: {history_messages_count} messages.") - # Send and handle response - is_streaming = STREAM_ENABLED == "on" + # Send request + is_streaming = api_params["stream"] if is_streaming: console.print("[bold green]Streaming response...[/] [dim](Press Ctrl+C to cancel)[/]") if online_mode_enabled: console.print("[dim cyan]🌐 Online mode active - model has web search access[/]") console.print("") else: - console.print("[bold green]Thinking...[/]", end="\r") + thinking_msg = "Thinking" + if mcp_tools_added: + if mcp_manager.mode == "database": + thinking_msg = "Thinking (database tools available)" + else: + thinking_msg = "Thinking (MCP tools available)" + console.print(f"[bold green]{thinking_msg}...[/]", end="\r") + if online_mode_enabled: + console.print(f"[dim cyan]🌐 Online mode active[/]") + if mcp_tools_added: + if mcp_manager.mode == "files": + gitignore_status = "on" if (mcp_manager.server and mcp_manager.server.respect_gitignore) else "off" + console.print(f"[dim cyan]🔧 MCP active - AI can access {len(mcp_manager.allowed_folders)} folder(s) with {len(mcp_tools)} tools (.gitignore {gitignore_status})[/]") + elif mcp_manager.mode == "database": + db = mcp_manager.databases[mcp_manager.selected_db_index] + console.print(f"[dim cyan]🗄️ MCP active - AI can query database: {db['name']} ({len(db['tables'])} tables, {len(mcp_tools)} tools)[/]") start_time = time.time() try: @@ -2169,8 +4840,150 @@ def chat(): app_logger.error(f"API Error: {type(e).__name__}: {e}") continue + # ======================================================================== + # HANDLE TOOL CALLS (MCP FUNCTION CALLING) + # ======================================================================== + tool_call_loop_count = 0 + max_tool_loops = 5 + + while tool_call_loop_count < max_tool_loops: + wants_tool_call = False + + if hasattr(response, 'choices') and response.choices: + message = response.choices[0].message + + if hasattr(message, 'tool_calls') and message.tool_calls: + wants_tool_call = True + tool_calls = message.tool_calls + + console.print(f"\n[dim yellow]🔧 AI requesting {len(tool_calls)} tool call(s)...[/]") + app_logger.info(f"Model requested {len(tool_calls)} tool calls") + + tool_results = [] + for tool_call in tool_calls: + tool_name = tool_call.function.name + + try: + tool_args = json.loads(tool_call.function.arguments) + except json.JSONDecodeError as e: + app_logger.error(f"Failed to parse tool arguments: {e}") + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": json.dumps({"error": f"Invalid arguments: {e}"}) + }) + continue + + args_display = ', '.join(f'{k}="{v}"' if isinstance(v, str) else f'{k}={v}' for k, v in tool_args.items()) + console.print(f"[dim cyan] → Calling: {tool_name}({args_display})[/]") + app_logger.info(f"Executing MCP tool: {tool_name} with args: {tool_args}") + + try: + result = asyncio.run(mcp_manager.call_tool(tool_name, **tool_args)) + except Exception as e: + app_logger.error(f"MCP tool execution error: {e}") + result = {"error": str(e)} + + if 'error' in result: + result_content = json.dumps({"error": result['error']}) + console.print(f"[dim red] ✗ Error: {result['error']}[/]") + app_logger.warning(f"MCP tool {tool_name} returned error: {result['error']}") + else: + # Display appropriate success message based on tool + if tool_name == 'search_files': + count = result.get('count', 0) + console.print(f"[dim green] ✓ Found {count} file(s)[/]") + elif tool_name == 'read_file': + size = result.get('size', 0) + truncated = " (truncated)" if result.get('truncated') else "" + console.print(f"[dim green] ✓ Read file ({size} bytes{truncated})[/]") + elif tool_name == 'list_directory': + count = result.get('count', 0) + truncated = " (limited to 1000)" if result.get('truncated') else "" + console.print(f"[dim green] ✓ Listed {count} item(s){truncated}[/]") + elif tool_name == 'inspect_database': + if 'table' in result: + console.print(f"[dim green] ✓ Inspected table: {result['table']} ({result['row_count']} rows)[/]") + else: + console.print(f"[dim green] ✓ Inspected database ({result['table_count']} tables)[/]") + elif tool_name == 'search_database': + count = result.get('count', 0) + console.print(f"[dim green] ✓ Found {count} match(es)[/]") + elif tool_name == 'query_database': + count = result.get('count', 0) + truncated = " (truncated)" if result.get('truncated') else "" + console.print(f"[dim green] ✓ Query returned {count} row(s){truncated}[/]") + else: + console.print(f"[dim green] ✓ Success[/]") + + result_content = json.dumps(result, indent=2) + app_logger.info(f"MCP tool {tool_name} succeeded") + + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": result_content + }) + + api_messages.append({ + "role": "assistant", + "content": message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments + } + } for tc in tool_calls + ] + }) + api_messages.extend(tool_results) + + console.print("\n[dim cyan]💭 Processing tool results...[/]") + app_logger.info("Sending tool results back to model") + + followup_params = { + "model": effective_model_id, + "messages": api_messages, + "stream": False, # Keep streaming disabled for follow-ups + "http_headers": { + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } + } + + if mcp_tools_added: + followup_params["tools"] = mcp_tools + followup_params["tool_choice"] = "auto" + + if session_max_token > 0: + followup_params["max_tokens"] = session_max_token + + try: + response = client.chat.send(**followup_params) + tool_call_loop_count += 1 + app_logger.info(f"Follow-up request successful (loop {tool_call_loop_count})") + except Exception as e: + console.print(f"[bold red]Error getting follow-up response: {e}[/]") + app_logger.error(f"Follow-up API error: {e}") + break + + if not wants_tool_call: + break + + if tool_call_loop_count >= max_tool_loops: + console.print(f"[bold yellow]⚠️ Reached maximum tool call depth ({max_tool_loops}). Stopping.[/]") + app_logger.warning(f"Hit max tool call loop limit: {max_tool_loops}") + response_time = time.time() - start_time + # ======================================================================== + # PROCESS FINAL RESPONSE + # ======================================================================== full_response = "" if is_streaming: try: @@ -2194,7 +5007,7 @@ def chat(): continue else: full_response = response.choices[0].message.content if response.choices else "" - console.print(f"\r{' ' * 20}\r", end="") + console.print(f"\r{' ' * 50}\r", end="") if full_response: if not is_streaming: @@ -2204,7 +5017,6 @@ def chat(): session_history.append({'prompt': user_input, 'response': full_response}) current_index = len(session_history) - 1 - # Process metrics usage = getattr(response, 'usage', None) input_tokens = usage.input_tokens if usage and hasattr(usage, 'input_tokens') else 0 output_tokens = usage.output_tokens if usage and hasattr(usage, 'output_tokens') else 0 @@ -2215,9 +5027,8 @@ def chat(): total_cost += msg_cost message_count += 1 - app_logger.info(f"Response: Tokens - I:{input_tokens} O:{output_tokens} T:{input_tokens + output_tokens}, Cost: ${msg_cost:.4f}, Time: {response_time:.2f}s, Online: {online_mode_enabled}") + app_logger.info(f"Response: Tokens - I:{input_tokens} O:{output_tokens} T:{input_tokens + output_tokens}, Cost: ${msg_cost:.4f}, Time: {response_time:.2f}s, Tool loops: {tool_call_loop_count}") - # Per-message metrics display if conversation_memory_enabled: context_count = len(session_history) - memory_start_index context_info = f", Context: {context_count} msg(s)" if context_count > 1 else "" @@ -2225,12 +5036,18 @@ def chat(): context_info = ", Memory: OFF" online_info = " 🌐" if online_mode_enabled else "" - console.print(f"\n[dim blue]📊 Metrics: {input_tokens + output_tokens} tokens | ${msg_cost:.4f} | {response_time:.2f}s{context_info}{online_info} | Session: {total_input_tokens + total_output_tokens} tokens | ${total_cost:.4f}[/]") + mcp_info = "" + if mcp_tools_added: + if mcp_manager.mode == "files": + mcp_info = " 🔧" + elif mcp_manager.mode == "database": + mcp_info = " 🗄️" + tool_info = f" ({tool_call_loop_count} tool loop(s))" if tool_call_loop_count > 0 else "" + console.print(f"\n[dim blue]📊 Metrics: {input_tokens + output_tokens} tokens | ${msg_cost:.4f} | {response_time:.2f}s{context_info}{online_info}{mcp_info}{tool_info} | Session: {total_input_tokens + total_output_tokens} tokens | ${total_cost:.4f}[/]") - # Cost and credit alerts warnings = [] if msg_cost > COST_WARNING_THRESHOLD: - warnings.append(f"High cost alert: This response exceeded configurable threshold ${COST_WARNING_THRESHOLD:.4f}") + warnings.append(f"High cost alert: This response exceeded threshold ${COST_WARNING_THRESHOLD:.4f}") credits_data = get_credits(API_KEY, OPENROUTER_BASE_URL) if credits_data: warning_alerts = check_credit_alerts(credits_data) diff --git a/requirements.txt b/requirements.txt index 8a0a132..ea6105b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,38 +1,26 @@ -anyio==4.11.0 -beautifulsoup4==4.14.2 -charset-normalizer==3.4.4 -click==8.3.1 -docopt==0.6.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.11 -latex2mathml==3.78.1 -loguru==0.7.3 -markdown-it-py==4.0.0 -markdown2==2.5.4 -mdurl==0.1.2 -natsort==8.4.0 -openrouter==0.0.19 -packaging==25.0 -pipreqs==0.4.13 -prompt-toolkit==3.0.52 -Pygments==2.19.2 -pyperclip==1.11.0 -python-dateutil==2.9.0.post0 -python-magic==0.4.27 -PyYAML==6.0.3 -requests==2.32.5 -rich==14.2.0 -shellingham==1.5.4 -six==1.17.0 -sniffio==1.3.1 -soupsieve==2.8 -svgwrite==1.4.3 -tqdm==4.67.1 -typer==0.20.0 -typing-extensions==4.15.0 -urllib3==2.5.0 -wavedrom==2.0.3.post3 -wcwidth==0.2.14 -yarg==0.1.10 +# oai.py v2.1.0-beta - Core Dependencies +anyio>=4.11.0 +charset-normalizer>=3.4.4 +click>=8.3.1 +h11>=0.16.0 +httpcore>=1.0.9 +httpx>=0.28.1 +idna>=3.11 +markdown-it-py>=4.0.0 +mdurl>=0.1.2 +openrouter>=0.0.19 +packaging>=25.0 +prompt-toolkit>=3.0.52 +Pygments>=2.19.2 +pyperclip>=1.11.0 +requests>=2.32.5 +rich>=14.2.0 +shellingham>=1.5.4 +sniffio>=1.3.1 +typer>=0.20.0 +typing-extensions>=4.15.0 +urllib3>=2.5.0 +wcwidth>=0.2.14 + +# MCP (Model Context Protocol) +mcp>=1.25.0 \ No newline at end of file