# Directory Structure ``` ├── .gitignore ├── commands ├── docker-compose.yml ├── docker-entrypoint.sh ├── Dockerfile ├── docs │ └── developer-guide.md ├── README.md ├── requirements.txt ├── setup.py ├── src │ ├── __init__.py │ ├── api │ │ └── metabase.py │ ├── config │ │ └── settings.py │ ├── server │ │ ├── mcp_server.py │ │ └── web_interface.py │ └── tools │ ├── metabase_action_tools.py │ └── metabase_tools.py └── templates └── config.html ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Environment and configuration 2 | .env 3 | .env.* 4 | !.env.example 5 | venv/ 6 | env/ 7 | ENV/ 8 | .venv 9 | .python-version 10 | 11 | # Python cache files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | *.so 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # Docker 34 | .docker/ 35 | docker-compose.override.yml 36 | 37 | # IDE specific files 38 | .idea/ 39 | .vscode/ 40 | *.swp 41 | *.swo 42 | .DS_Store 43 | .project 44 | .classpath 45 | .settings/ 46 | *.sublime-workspace 47 | *.sublime-project 48 | 49 | # Logs 50 | logs/ 51 | *.log 52 | npm-debug.log* 53 | yarn-debug.log* 54 | yarn-error.log* 55 | 56 | # Testing 57 | .coverage 58 | htmlcov/ 59 | .pytest_cache/ 60 | .tox/ 61 | .nox/ 62 | coverage.xml 63 | *.cover 64 | .hypothesis/ 65 | 66 | # Metabase specific 67 | metabase-data/ 68 | 69 | # Temporary files 70 | tmp/ 71 | temp/ 72 | 73 | # Jupyter Notebooks 74 | .ipynb_checkpoints 75 | *.ipynb 76 | 77 | # Local development 78 | local_settings.py 79 | db.sqlite3 80 | db.sqlite3-journal 81 | ``` 82 | 83 | This `.gitignore` file: 84 | 85 | 1. Excludes environment files (`.env`) that might contain sensitive API keys 86 | 2. Ignores Python cache files and build artifacts 87 | 3. Excludes virtual environment directories 88 | 4. Ignores IDE-specific files and directories 89 | 5. Excludes logs and testing artifacts 90 | 6. Ignores any Metabase data directories 91 | 7. Excludes temporary files and Jupyter notebooks 92 | 93 | You can create this file in the root directory of your project. If you need to make any adjustments based on your specific setup, feel free to modify it. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Metabase MCP Server 2 | 3 | A Model Control Protocol (MCP) server that enables AI assistants to interact with Metabase databases and actions. 4 | 5 | ![Metabase MCP Server] 6 | 7 | ## Overview 8 | 9 | The Metabase MCP Server provides a bridge between AI assistants and Metabase, allowing AI models to: 10 | 11 | - List and explore databases configured in Metabase 12 | - Retrieve detailed metadata about database schemas, tables, and fields 13 | - Visualize relationships between tables in a database 14 | - List and execute Metabase actions 15 | - Perform operations on Metabase data through a secure API 16 | 17 | This server implements the [Model Control Protocol (MCP)] specification, making it compatible with AI assistants that support MCP tools. 18 | 19 | ## Features 20 | 21 | - **Database Exploration**: List all databases and explore their schemas 22 | - **Metadata Retrieval**: Get detailed information about tables, fields, and relationships 23 | - **Relationship Visualization**: Generate visual representations of database relationships 24 | - **Action Management**: List, view details, and execute Metabase actions 25 | - **Secure API Key Handling**: Store API keys encrypted and prevent exposure 26 | - **Web Interface**: Test and debug functionality through a user-friendly web interface 27 | - **Docker Support**: Easy deployment with Docker and Docker Compose 28 | 29 | ## Prerequisites 30 | 31 | - Metabase instance (v0.46.0 or higher recommended) 32 | - Metabase API key with appropriate permissions 33 | - Docker (for containerized deployment) 34 | - Python 3.10+ (for local development) 35 | 36 | ## Installation 37 | 38 | ### Using Docker (Recommended) 39 | 40 | 1. Clone this repository: 41 | ```bash 42 | git clone https://github.com/yourusername/metabase-mcp.git 43 | cd metabase-mcp 44 | ``` 45 | 46 | 2. Build and run the Docker container: 47 | ```bash 48 | docker-compose up -d 49 | ``` 50 | 51 | 3. Access the configuration interface at http://localhost:5001 52 | 53 | ### Manual Installation 54 | 55 | 1. Clone this repository: 56 | ```bash 57 | git clone https://github.com/yourusername/metabase-mcp.git 58 | cd metabase-mcp 59 | ``` 60 | 61 | 2. Install dependencies: 62 | ```bash 63 | pip install -r requirements.txt 64 | ``` 65 | 66 | 3. Run the configuration interface: 67 | ```bash 68 | python -m src.server.web_interface 69 | ``` 70 | 71 | 4. Access the configuration interface at http://localhost:5000 72 | 73 | ## Configuration 74 | 75 | 1. Open the web interface in your browser 76 | 2. Enter your Metabase URL (e.g., http://localhost:3000) 77 | 3. Enter your Metabase API key 78 | 4. Click "Save Configuration" and test the connection 79 | 80 | ### Obtaining a Metabase API Key 81 | 82 | 1. Log in to your Metabase instance as an administrator 83 | 2. Go to Settings > Admin settings > API Keys 84 | 3. Create a new API key with appropriate permissions 85 | 4. Copy the generated key for use in the MCP server 86 | 87 | ## Usage 88 | 89 | ### Running the MCP Server 90 | 91 | After configuration, you can run the MCP server: 92 | 93 | ```bash 94 | # Using Docker 95 | docker run -p 5001:5000 metabase-mcp 96 | 97 | # Manually 98 | python -m src.server.mcp_server 99 | ``` 100 | 101 | ### Available Tools 102 | 103 | The MCP server provides the following tools to AI assistants: 104 | 105 | 1. **list_databases**: List all databases configured in Metabase 106 | 2. **get_database_metadata**: Get detailed metadata for a specific database 107 | 3. **db_overview**: Get a high-level overview of all tables in a database 108 | 4. **table_detail**: Get detailed information about a specific table 109 | 5. **visualize_database_relationships**: Generate a visual representation of database relationships 110 | 6. **run_database_query**: Execute a SQL query against a database 111 | 7. **list_actions**: List all actions configured in Metabase 112 | 8. **get_action_details**: Get detailed information about a specific action 113 | 9. **execute_action**: Execute a Metabase action with parameters 114 | 115 | ### Testing Tools via Web Interface 116 | 117 | The web interface provides a testing area for each tool: 118 | 119 | 1. **List Databases**: View all databases configured in Metabase 120 | 2. **Get Database Metadata**: View detailed schema information for a database 121 | 3. **DB Overview**: View a concise list of all tables in a database 122 | 4. **Table Detail**: View detailed information about a specific table 123 | 5. **Visualize Database Relationships**: Generate a visual representation of table relationships 124 | 6. **Run Query**: Execute SQL queries against databases 125 | 7. **List Actions**: View all actions configured in Metabase 126 | 8. **Get Action Details**: View detailed information about a specific action 127 | 9. **Execute Action**: Test executing an action with parameters 128 | 129 | ## Security Considerations 130 | 131 | - API keys are stored encrypted at rest 132 | - The web interface never displays API keys in plain text 133 | - All API requests use HTTPS when configured with a secure Metabase URL 134 | - The server should be deployed behind a secure proxy in production environments 135 | 136 | ## Development 137 | 138 | ### Project Structure 139 | 140 | ``` 141 | metabase-mcp/ 142 | ├── src/ 143 | │ ├── api/ # Metabase API client 144 | │ ├── config/ # Configuration management 145 | │ ├── server/ # MCP and web servers 146 | │ └── tools/ # Tool implementations 147 | ├── templates/ # Web interface templates 148 | ├── docker-compose.yml # Docker Compose configuration 149 | ├── Dockerfile # Docker build configuration 150 | ├── requirements.txt # Python dependencies 151 | └── README.md # Documentation 152 | ``` 153 | 154 | ### Adding New Tools 155 | 156 | To add a new tool: 157 | 158 | 1. Implement the tool function in `src/tools/` 159 | 2. Register the tool in `src/server/mcp_server.py` 160 | 3. Add a testing interface in `templates/config.html` (optional) 161 | 4. Add a route in `src/server/web_interface.py` (if adding a testing interface) 162 | 163 | ## Troubleshooting 164 | 165 | ### Common Issues 166 | 167 | - **Connection Failed**: Ensure your Metabase URL is correct and accessible 168 | - **Authentication Error**: Verify your API key has the necessary permissions 169 | - **Docker Network Issues**: When using Docker, ensure proper network configuration 170 | 171 | ### Logs 172 | 173 | Check the logs for detailed error information: 174 | 175 | ```bash 176 | # Docker logs 177 | docker logs metabase-mcp 178 | 179 | # Manual execution logs 180 | # Logs are printed to the console 181 | ``` 182 | 183 | ## Contributing 184 | 185 | Contributions are welcome! Please feel free to submit a Pull Request. 186 | 187 | 188 | ``` -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # This file can be empty or contain package-level imports ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- ``` 1 | mcp>=1.2.0 2 | httpx>=0.24.0 3 | flask[async]>=3.1.0 4 | python-dotenv>=0.19.0 5 | cryptography>=41.0.0 ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: '3' 2 | 3 | services: 4 | metabase-mcp: 5 | build: . 6 | ports: 7 | - "5001:5000" 8 | volumes: 9 | - ./data:/app/data 10 | environment: 11 | - METABASE_URL=http://host.docker.internal:3000 12 | - FLASK_DEBUG=False 13 | command: config ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="metabase-mcp", 5 | version="0.1.0", 6 | packages=find_packages(), 7 | install_requires=[ 8 | "mcp>=1.2.0", 9 | "httpx>=0.24.0", 10 | "flask[async]>=3.1.0", 11 | "python-dotenv>=0.19.0", 12 | ], 13 | python_requires=">=3.8", 14 | ) ``` -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Default behavior: Start both MCP server and Web Interface 4 | echo "Starting MCP server in background..." 5 | python -m src.server.mcp_server & 6 | MCP_PID=$! # Get the Process ID of the backgrounded MCP server 7 | 8 | echo "Starting Web Interface in foreground..." 9 | # The FLASK_PORT environment variable should be set (e.g., in your .env file or Dockerfile) 10 | # to the port exposed in the Dockerfile (5000). 11 | # The Flask app in web_interface.py must be configured to bind to 0.0.0.0. 12 | exec python -m src.server.web_interface 13 | 14 | # Optional: A more robust script might wait for the MCP_PID to exit 15 | # and handle cleanup, but for now, this keeps the web interface as the main process. ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /app 4 | 5 | # Copy requirements and install dependencies 6 | COPY requirements.txt . 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | # Install cryptography package 10 | RUN pip install --no-cache-dir cryptography 11 | 12 | # Copy application code 13 | COPY src/ ./src/ 14 | COPY templates/ ./templates/ 15 | COPY setup.py . 16 | 17 | # Install the package in development mode 18 | RUN pip install -e . 19 | 20 | # Expose port for web interface 21 | EXPOSE 5000 22 | 23 | # Set environment variables 24 | ENV METABASE_URL=http://localhost:3000 25 | ENV METABASE_API_KEY="" 26 | ENV PYTHONUNBUFFERED=1 27 | 28 | # Use entrypoint script to allow different commands 29 | COPY docker-entrypoint.sh . 30 | RUN chmod +x docker-entrypoint.sh 31 | 32 | ENTRYPOINT ["/app/docker-entrypoint.sh"] ``` -------------------------------------------------------------------------------- /src/server/mcp_server.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | from src.config.settings import Config 3 | from src.tools.metabase_tools import list_databases, get_database_metadata, db_overview, table_detail, visualize_database_relationships, run_database_query 4 | from src.tools.metabase_action_tools import list_actions, get_action_details, execute_action 5 | 6 | def create_mcp_server(): 7 | """Create and configure an MCP server instance.""" 8 | mcp = FastMCP(Config.MCP_NAME) 9 | 10 | # Register database tools 11 | mcp.tool( 12 | description="List all databases configured in Metabase" 13 | )(list_databases) 14 | 15 | mcp.tool( 16 | description="Get detailed metadata for a specific database" 17 | )(get_database_metadata) 18 | 19 | mcp.tool( 20 | description="Get a high-level overview of all tables in a database" 21 | )(db_overview) 22 | 23 | mcp.tool( 24 | description="Get detailed information about a specific table" 25 | )(table_detail) 26 | 27 | mcp.tool( 28 | description="Generate a visual representation of database relationships" 29 | )(visualize_database_relationships) 30 | 31 | mcp.tool( 32 | description="Run a read-only SQL query against a database" 33 | )(run_database_query) 34 | 35 | # Register action tools 36 | mcp.tool( 37 | description="List all actions configured in Metabase" 38 | )(list_actions) 39 | 40 | mcp.tool( 41 | description="Get detailed information about a specific action" 42 | )(get_action_details) 43 | 44 | mcp.tool( 45 | description="Execute a Metabase action with parameters" 46 | )(execute_action) 47 | 48 | return mcp 49 | 50 | def run_mcp_server(): 51 | """Run the MCP server""" 52 | mcp = create_mcp_server() 53 | mcp.run(transport='stdio') 54 | 55 | if __name__ == "__main__": 56 | run_mcp_server() ``` -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import base64 3 | from cryptography.fernet import Fernet 4 | from dotenv import load_dotenv 5 | 6 | # Load environment variables from .env file 7 | load_dotenv() 8 | 9 | # Configuration class 10 | class Config: 11 | # Secret key for encryption (generate once and store securely) 12 | # In production, this should be set as an environment variable 13 | SECRET_KEY = os.environ.get("SECRET_KEY") or Fernet.generate_key().decode() 14 | 15 | # Metabase settings 16 | METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000") 17 | _METABASE_API_KEY = os.environ.get("METABASE_API_KEY", "") 18 | 19 | # Flask settings 20 | FLASK_DEBUG = os.environ.get("FLASK_DEBUG", "False").lower() == "true" 21 | FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0") 22 | FLASK_PORT = int(os.environ.get("FLASK_PORT", "5000")) 23 | 24 | # MCP settings 25 | MCP_NAME = os.environ.get("MCP_NAME", "metabase") 26 | 27 | # File paths 28 | CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env') 29 | TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'templates') 30 | 31 | @classmethod 32 | def encrypt_api_key(cls, api_key): 33 | """Encrypt the API key""" 34 | if not api_key: 35 | return "" 36 | 37 | cipher_suite = Fernet(cls.SECRET_KEY.encode()) 38 | encrypted_key = cipher_suite.encrypt(api_key.encode()) 39 | return base64.urlsafe_b64encode(encrypted_key).decode() 40 | 41 | @classmethod 42 | def decrypt_api_key(cls, encrypted_key): 43 | """Decrypt the API key""" 44 | if not encrypted_key: 45 | return "" 46 | 47 | try: 48 | cipher_suite = Fernet(cls.SECRET_KEY.encode()) 49 | decoded = base64.urlsafe_b64decode(encrypted_key.encode()) 50 | decrypted_key = cipher_suite.decrypt(decoded) 51 | return decrypted_key.decode() 52 | except Exception: 53 | # If decryption fails, return empty string 54 | return "" 55 | 56 | @classmethod 57 | def save_metabase_config(cls, metabase_url, api_key): 58 | """Save Metabase configuration to .env file""" 59 | # Encrypt the API key before saving 60 | encrypted_key = cls.encrypt_api_key(api_key) 61 | 62 | with open(cls.CONFIG_FILE, 'w') as f: 63 | f.write(f"METABASE_URL={metabase_url}\n") 64 | f.write(f"METABASE_API_KEY={encrypted_key}\n") 65 | f.write(f"SECRET_KEY={cls.SECRET_KEY}\n") 66 | 67 | # Update current environment 68 | os.environ['METABASE_URL'] = metabase_url 69 | os.environ['METABASE_API_KEY'] = encrypted_key 70 | 71 | # Update class attributes 72 | cls.METABASE_URL = metabase_url 73 | cls._METABASE_API_KEY = encrypted_key 74 | 75 | @classmethod 76 | def get_metabase_url(cls): 77 | """Get the current Metabase URL, refreshing from environment if needed""" 78 | cls.METABASE_URL = os.environ.get("METABASE_URL", cls.METABASE_URL) 79 | return cls.METABASE_URL 80 | 81 | @classmethod 82 | def get_metabase_api_key(cls): 83 | """Get the current Metabase API key, decrypting it first""" 84 | encrypted_key = os.environ.get("METABASE_API_KEY", cls._METABASE_API_KEY) 85 | return cls.decrypt_api_key(encrypted_key) ``` -------------------------------------------------------------------------------- /src/tools/metabase_action_tools.py: -------------------------------------------------------------------------------- ```python 1 | from src.api.metabase import MetabaseAPI 2 | from typing import Dict, Any 3 | 4 | async def list_actions() -> str: 5 | """ 6 | List all actions configured in Metabase. 7 | 8 | Returns: 9 | A formatted string with information about all configured actions. 10 | """ 11 | # Check Metabase version 12 | version_info = await MetabaseAPI.get_request("version") 13 | version = "unknown" 14 | if version_info and not "error" in version_info: 15 | version = version_info.get("version", "unknown") 16 | 17 | response = await MetabaseAPI.get_actions() 18 | 19 | if response is None or "error" in response: 20 | error_message = response.get('message', 'Unknown error') if response else 'No response' 21 | return f"Error fetching actions: {error_message}" 22 | 23 | if not response: 24 | return "No actions found in Metabase. You may need to create some actions first." 25 | 26 | result = "## Actions in Metabase\n\n" 27 | for action in response: 28 | result += f"- **ID**: {action.get('id')}\n" 29 | result += f" **Name**: {action.get('name')}\n" 30 | result += f" **Type**: {action.get('type')}\n" 31 | result += f" **Model ID**: {action.get('model_id')}\n" 32 | result += f" **Created At**: {action.get('created_at')}\n\n" 33 | 34 | return result 35 | 36 | async def get_action_details(action_id: int) -> str: 37 | """ 38 | Get detailed information about a specific action. 39 | 40 | Args: 41 | action_id: The ID of the action to fetch 42 | 43 | Returns: 44 | A formatted string with the action's details. 45 | """ 46 | response = await MetabaseAPI.get_request(f"action/{action_id}") 47 | 48 | if response is None or "error" in response: 49 | return f"Error fetching action details: {response.get('message', 'Unknown error')}" 50 | 51 | result = f"## Action: {response.get('name')}\n\n" 52 | result += f"**ID**: {response.get('id')}\n" 53 | result += f"**Type**: {response.get('type')}\n" 54 | result += f"**Model ID**: {response.get('model_id')}\n" 55 | result += f"**Database ID**: {response.get('database_id')}\n" 56 | result += f"**Created At**: {response.get('created_at')}\n\n" 57 | 58 | # Add parameters if available 59 | parameters = response.get('parameters', []) 60 | if parameters: 61 | result += f"### Parameters ({len(parameters)})\n\n" 62 | for param in parameters: 63 | result += f"- **{param.get('id')}**: {param.get('name')}\n" 64 | result += f" - Type: {param.get('type')}\n" 65 | if param.get('required'): 66 | result += f" - Required: {param.get('required')}\n" 67 | if param.get('default'): 68 | result += f" - Default: {param.get('default')}\n" 69 | result += "\n" 70 | 71 | return result 72 | 73 | async def execute_action(action_id: int, parameters: Dict[str, Any] = None) -> str: 74 | """ 75 | Execute a Metabase action with the provided parameters. 76 | 77 | Args: 78 | action_id: The ID of the action to execute 79 | parameters: Dictionary of parameter values to use when executing the action 80 | 81 | Returns: 82 | A formatted string with the execution results. 83 | """ 84 | if parameters is None: 85 | parameters = {} 86 | 87 | # First, verify the action exists 88 | action_details = await MetabaseAPI.get_request(f"action/{action_id}") 89 | if "error" in action_details: 90 | return f"Error: Action with ID {action_id} not found. {action_details.get('message', '')}" 91 | 92 | # Execute the action 93 | response = await MetabaseAPI.make_request( 94 | f"action/{action_id}/execute", 95 | method="POST", 96 | data={"parameters": parameters} 97 | ) 98 | 99 | if response is None or "error" in response: 100 | error_msg = response.get('message', 'Unknown error') if response else 'No response' 101 | return f"Error executing action: {error_msg}" 102 | 103 | # Format the successful response 104 | result = f"## Action Execution Results for '{action_details.get('name')}'\n\n" 105 | 106 | # Format the response based on what was returned 107 | if isinstance(response, dict): 108 | for key, value in response.items(): 109 | result += f"**{key}**: {value}\n" 110 | elif isinstance(response, list): 111 | result += f"Returned {len(response)} rows\n\n" 112 | if response and len(response) > 0: 113 | # Get keys from first item 114 | keys = response[0].keys() 115 | # Create table header 116 | result += "| " + " | ".join(keys) + " |\n" 117 | result += "| " + " | ".join(["---" for _ in keys]) + " |\n" 118 | # Add rows 119 | for row in response[:10]: # Limit to first 10 rows 120 | result += "| " + " | ".join([str(row.get(k, "")) for k in keys]) + " |\n" 121 | 122 | if len(response) > 10: 123 | result += "\n_Showing first 10 rows of results_\n" 124 | else: 125 | result += f"Result: {response}\n" 126 | 127 | return result 128 | 129 | async def check_actions_enabled() -> bool: 130 | """Check if actions are enabled in this Metabase instance""" 131 | # Check settings endpoint to see if actions are enabled 132 | settings = await MetabaseAPI.get_request("setting") 133 | 134 | if settings and not "error" in settings: 135 | for setting in settings: 136 | if setting.get("key") == "enable-actions" or setting.get("key") == "actions-enabled": 137 | return setting.get("value") == "true" or setting.get("value") == True 138 | 139 | # If we can't determine from settings, try to fetch actions as a test 140 | actions = await MetabaseAPI.get_actions() 141 | return not (actions is None or "error" in actions) ``` -------------------------------------------------------------------------------- /src/api/metabase.py: -------------------------------------------------------------------------------- ```python 1 | import httpx 2 | from typing import Dict, Any, Optional 3 | from src.config.settings import Config 4 | 5 | class MetabaseAPI: 6 | """Class for interacting with the Metabase API""" 7 | 8 | @staticmethod 9 | async def make_request(endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Any: 10 | """Make a request to the Metabase API with proper error handling. 11 | 12 | Args: 13 | endpoint: API endpoint to call (without the base URL) 14 | method: HTTP method to use (GET, POST, etc.) 15 | data: Optional JSON data to send with the request 16 | 17 | Returns: 18 | JSON response from the API or error dict 19 | """ 20 | # Get fresh values from config using the getter methods 21 | metabase_url = Config.get_metabase_url() 22 | api_key = Config.get_metabase_api_key() 23 | 24 | headers = { 25 | "x-api-key": api_key, 26 | "Content-Type": "application/json" 27 | } 28 | 29 | url = f"{metabase_url}/api/{endpoint.lstrip('/')}" 30 | print(f"Making request to: {url}") # Debugging 31 | 32 | async with httpx.AsyncClient() as client: 33 | try: 34 | if method == "GET": 35 | response = await client.get(url, headers=headers, timeout=30.0) 36 | elif method == "POST": 37 | response = await client.post(url, headers=headers, json=data, timeout=30.0) 38 | elif method == "PUT": 39 | response = await client.put(url, headers=headers, json=data, timeout=30.0) 40 | elif method == "DELETE": 41 | response = await client.delete(url, headers=headers, timeout=30.0) 42 | else: 43 | return {"error": f"Unsupported HTTP method: {method}"} 44 | 45 | response.raise_for_status() 46 | 47 | # Try to parse as JSON, but handle non-JSON responses 48 | try: 49 | return response.json() 50 | except ValueError: 51 | # If response is not JSON, return as error with the text content 52 | return {"error": "Non-JSON response", "message": response.text} 53 | 54 | except httpx.HTTPStatusError as e: 55 | # Try to get JSON error response 56 | try: 57 | error_json = e.response.json() 58 | return {"error": f"HTTP error: {e.response.status_code}", "message": str(error_json)} 59 | except ValueError: 60 | # If error is not JSON, return the text 61 | return {"error": f"HTTP error: {e.response.status_code}", "message": e.response.text} 62 | except Exception as e: 63 | return {"error": "Failed to make request", "message": str(e)} 64 | 65 | @classmethod 66 | async def get_request(cls, endpoint: str) -> Any: 67 | """Shorthand for GET requests""" 68 | return await cls.make_request(endpoint, method="GET") 69 | 70 | @classmethod 71 | async def post_request(cls, endpoint: str, data: Dict) -> Any: 72 | """Shorthand for POST requests""" 73 | return await cls.make_request(endpoint, method="POST", data=data) 74 | 75 | @classmethod 76 | async def test_connection(cls) -> tuple: 77 | """Test connection to Metabase API""" 78 | try: 79 | response = await cls.get_request("database") 80 | if response is None or "error" in response: 81 | return False, f"Connection failed: {response.get('message', 'Unknown error')}" 82 | return True, "Connection successful!" 83 | except Exception as e: 84 | return False, f"Connection failed: {str(e)}" 85 | 86 | @classmethod 87 | async def get_databases(cls): 88 | """Get list of all databases""" 89 | response = await cls.get_request("database") 90 | 91 | # Handle case where response might be a string 92 | if isinstance(response, str): 93 | return {"error": "Unexpected string response", "message": response} 94 | 95 | # Check if response is a dictionary with a 'data' key (new Metabase API format) 96 | if isinstance(response, dict) and 'data' in response: 97 | print(f"Found 'data' key in response with {len(response['data'])} databases") 98 | return response['data'] # Return just the list of databases 99 | 100 | return response 101 | 102 | @classmethod 103 | async def get_database_metadata(cls, database_id: int): 104 | """Get metadata for a specific database""" 105 | return await cls.get_request(f"database/{database_id}/metadata") 106 | 107 | @classmethod 108 | async def get_actions(cls): 109 | """Get list of all actions with support for different Metabase versions""" 110 | # Try the standard endpoint first 111 | response = await cls.get_request("action") 112 | 113 | # If that fails, try alternative endpoints 114 | if response is None or "error" in response: 115 | # Try the legacy endpoint (some older Metabase versions) 116 | response_legacy = await cls.get_request("api/action") 117 | if response_legacy and not "error" in response_legacy: 118 | return response_legacy 119 | 120 | # If all attempts failed, return the original error 121 | return response 122 | 123 | return response 124 | 125 | @classmethod 126 | async def get_action(cls, action_id: int): 127 | """Get details for a specific action""" 128 | return await cls.get_request(f"action/{action_id}") 129 | 130 | @classmethod 131 | async def execute_action(cls, action_id: int, parameters: Dict = None): 132 | """Execute an action with parameters""" 133 | if parameters is None: 134 | parameters = {} 135 | 136 | # Validate action_id 137 | if not isinstance(action_id, int) or action_id <= 0: 138 | return {"error": "Invalid action ID", "message": "Action ID must be a positive integer"} 139 | 140 | # Sanitize parameters 141 | sanitized_params = {} 142 | for key, value in parameters.items(): 143 | sanitized_params[str(key)] = value 144 | 145 | return await cls.post_request(f"action/{action_id}/execute", {"parameters": sanitized_params}) 146 | 147 | @classmethod 148 | async def get_table_metadata(cls, table_id: int): 149 | """Get detailed metadata for a specific table""" 150 | return await cls.get_request(f"table/{table_id}/query_metadata") 151 | 152 | @classmethod 153 | async def get_field_metadata(cls, field_id: int): 154 | """Get detailed metadata for a specific field""" 155 | return await cls.get_request(f"field/{field_id}") 156 | 157 | @classmethod 158 | async def get_database_schema(cls, database_id: int): 159 | """Get the database schema with relationships between tables""" 160 | # First get the basic metadata 161 | metadata = await cls.get_database_metadata(database_id) 162 | 163 | if metadata is None or "error" in metadata: 164 | return metadata 165 | 166 | # For each table, get detailed metadata including foreign keys 167 | tables = metadata.get('tables', []) 168 | enhanced_tables = [] 169 | 170 | for table in tables: 171 | table_id = table.get('id') 172 | if table_id: 173 | table_details = await cls.get_table_metadata(table_id) 174 | if table_details and not "error" in table_details: 175 | enhanced_tables.append(table_details) 176 | else: 177 | enhanced_tables.append(table) 178 | else: 179 | enhanced_tables.append(table) 180 | 181 | # Replace tables with enhanced versions 182 | metadata['tables'] = enhanced_tables 183 | return metadata 184 | 185 | @classmethod 186 | async def run_query(cls, database_id: int, query_string: str, row_limit: int = 5): 187 | """Run a native query against a database with a row limit 188 | 189 | Args: 190 | database_id: The ID of the database to query 191 | query_string: The SQL query to execute 192 | row_limit: Maximum number of rows to return (default: 5) 193 | 194 | Returns: 195 | Query results or error message 196 | """ 197 | # Remove trailing semicolons that can cause issues with Metabase API 198 | query_string = query_string.strip() 199 | if query_string.endswith(';'): 200 | query_string = query_string[:-1] 201 | 202 | # Ensure the query has a LIMIT clause for safety 203 | query_string = cls._ensure_query_limit(query_string, row_limit) 204 | 205 | # Prepare the query payload 206 | payload = { 207 | "database": database_id, 208 | "type": "native", 209 | "native": { 210 | "query": query_string, 211 | "template-tags": {} 212 | } 213 | } 214 | 215 | # Execute the query 216 | response = await cls.post_request("dataset", payload) 217 | 218 | # Improved error handling for Metabase error responses 219 | if response and isinstance(response, dict) and "error" in response: 220 | error_data = response.get("error") 221 | 222 | # Check for common error patterns in Metabase responses 223 | if isinstance(error_data, str) and "does not exist" in error_data: 224 | # This is likely a SQL syntax error from the database 225 | return {"error": "SQL Error", "message": error_data} 226 | 227 | # If the error message contains additional info 228 | message = response.get("message", "Unknown error") 229 | if isinstance(message, dict) and "data" in message: 230 | if "errors" in message["data"]: 231 | return {"error": "SQL Error", "message": message["data"]["errors"]} 232 | 233 | return response 234 | 235 | @staticmethod 236 | def _ensure_query_limit(query: str, limit: int) -> str: 237 | """Ensure the query has a LIMIT clause 238 | 239 | This is a simple implementation and may not work for all SQL dialects 240 | or complex queries. It's a basic safety measure. 241 | """ 242 | # Convert to uppercase for case-insensitive matching 243 | query_upper = query.upper() 244 | 245 | # Check if query already has a LIMIT clause 246 | if "LIMIT" in query_upper: 247 | return query 248 | 249 | # Add LIMIT clause 250 | return f"{query} LIMIT {limit}" ``` -------------------------------------------------------------------------------- /src/server/web_interface.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | from flask import Flask, request, render_template, redirect, url_for, flash, jsonify 3 | from src.config.settings import Config 4 | from src.api.metabase import MetabaseAPI 5 | 6 | def create_app(): 7 | """Create and configure the Flask application""" 8 | app = Flask(__name__, template_folder=Config.TEMPLATE_DIR) 9 | app.secret_key = os.urandom(24) 10 | 11 | @app.route('/') 12 | def home(): 13 | """Home page with configuration form""" 14 | # Don't pass the actual API key to the template 15 | # Just indicate if one is set 16 | api_key_set = bool(Config._METABASE_API_KEY) 17 | 18 | return render_template( 19 | 'config.html', 20 | metabase_url=Config.METABASE_URL, 21 | api_key="" if not api_key_set else "••••••••••••••••••••••", 22 | api_key_set=api_key_set 23 | ) 24 | 25 | @app.route('/save_config', methods=['POST']) 26 | def update_config(): 27 | """Save configuration from form""" 28 | metabase_url = request.form.get('metabase_url', '').strip() 29 | api_key = request.form.get('api_key', '').strip() 30 | 31 | if not metabase_url: 32 | flash('Metabase URL is required!') 33 | return redirect(url_for('home')) 34 | 35 | # Only update API key if it's changed (not the masked version) 36 | if "•" not in api_key: 37 | Config.save_metabase_config(metabase_url, api_key) 38 | flash('Configuration saved successfully!') 39 | else: 40 | # Only update URL if API key wasn't changed 41 | with open(Config.CONFIG_FILE, 'w') as f: 42 | f.write(f"METABASE_URL={metabase_url}\n") 43 | f.write(f"METABASE_API_KEY={Config._METABASE_API_KEY}\n") 44 | f.write(f"SECRET_KEY={Config.SECRET_KEY}\n") 45 | 46 | # Update current environment and class attribute 47 | os.environ['METABASE_URL'] = metabase_url 48 | Config.METABASE_URL = metabase_url 49 | flash('URL updated successfully!') 50 | 51 | return redirect(url_for('home')) 52 | 53 | @app.route('/test_connection', methods=['POST']) 54 | async def test_connection(): 55 | """Test connection with current or provided credentials""" 56 | metabase_url = request.form.get('metabase_url', Config.METABASE_URL).strip() 57 | api_key = request.form.get('api_key', '').strip() 58 | 59 | # Don't use the masked version 60 | if "•" in api_key: 61 | api_key = Config.get_metabase_api_key() 62 | 63 | # Temporarily update config for testing 64 | old_url = Config.METABASE_URL 65 | old_key = Config.get_metabase_api_key() 66 | Config.METABASE_URL = metabase_url 67 | 68 | try: 69 | success, message = await MetabaseAPI.test_connection() 70 | 71 | # Restore original config if not saving 72 | if 'save' not in request.form: 73 | Config.METABASE_URL = old_url 74 | 75 | return jsonify({'success': success, 'message': message}) 76 | except Exception as e: 77 | # Restore original config 78 | Config.METABASE_URL = old_url 79 | 80 | # Return error as JSON 81 | return jsonify({'success': False, 'message': f"Error: {str(e)}"}) 82 | 83 | @app.route('/test_list_databases') 84 | async def test_list_databases(): 85 | """Test the list_databases tool""" 86 | from src.tools.metabase_tools import list_databases 87 | 88 | try: 89 | # Log the current configuration 90 | print(f"Testing list_databases with URL: {Config.get_metabase_url()}") 91 | 92 | result = await list_databases() 93 | return jsonify({'success': True, 'result': result}) 94 | except Exception as e: 95 | import traceback 96 | error_traceback = traceback.format_exc() 97 | print(f"Error in test_list_databases: {str(e)}\n{error_traceback}") 98 | return jsonify({'success': False, 'error': str(e)}) 99 | 100 | @app.route('/test_get_metadata', methods=['POST']) 101 | async def test_get_metadata(): 102 | """Test the get_database_metadata tool""" 103 | from src.tools.metabase_tools import get_database_metadata 104 | 105 | database_id = request.form.get('database_id') 106 | if not database_id or not database_id.isdigit(): 107 | return jsonify({'success': False, 'error': 'Valid database ID is required'}) 108 | 109 | try: 110 | result = await get_database_metadata(int(database_id)) 111 | return jsonify({'success': True, 'result': result}) 112 | except Exception as e: 113 | return jsonify({'success': False, 'error': str(e)}) 114 | 115 | @app.route('/test_list_actions') 116 | async def test_list_actions(): 117 | """Test the list_actions tool""" 118 | from src.tools.metabase_action_tools import list_actions 119 | 120 | try: 121 | # Get version info 122 | version_info = await MetabaseAPI.get_request("version") 123 | version = "unknown" 124 | if version_info and not "error" in version_info: 125 | version = version_info.get("version", "unknown") 126 | 127 | result = await list_actions() 128 | result_with_version = f"Metabase Version: {version}\n\n{result}" 129 | 130 | return jsonify({'success': True, 'result': result_with_version}) 131 | except Exception as e: 132 | return jsonify({'success': False, 'error': str(e)}) 133 | 134 | @app.route('/test_get_action_details', methods=['POST']) 135 | async def test_get_action_details(): 136 | """Test the get_action_details tool""" 137 | from src.tools.metabase_action_tools import get_action_details 138 | 139 | action_id = request.form.get('action_id') 140 | if not action_id or not action_id.isdigit(): 141 | return jsonify({'success': False, 'error': 'Valid action ID is required'}) 142 | 143 | try: 144 | result = await get_action_details(int(action_id)) 145 | return jsonify({'success': True, 'result': result}) 146 | except Exception as e: 147 | return jsonify({'success': False, 'error': str(e)}) 148 | 149 | @app.route('/test_execute_action', methods=['POST']) 150 | async def test_execute_action(): 151 | """Test the execute_action tool""" 152 | from src.tools.metabase_action_tools import execute_action 153 | 154 | action_id = request.form.get('action_id') 155 | if not action_id or not action_id.isdigit(): 156 | return jsonify({'success': False, 'error': 'Valid action ID is required'}) 157 | 158 | # Parse parameters from form 159 | parameters = {} 160 | for key, value in request.form.items(): 161 | if key.startswith('param_'): 162 | param_name = key[6:] # Remove 'param_' prefix 163 | parameters[param_name] = value 164 | 165 | try: 166 | result = await execute_action(int(action_id), parameters) 167 | return jsonify({'success': True, 'result': result}) 168 | except Exception as e: 169 | return jsonify({'success': False, 'error': str(e)}) 170 | 171 | @app.route('/test_visualize_relationships', methods=['POST']) 172 | async def test_visualize_relationships(): 173 | """Test the visualize_database_relationships tool""" 174 | from src.tools.metabase_tools import visualize_database_relationships 175 | 176 | database_id = request.form.get('database_id') 177 | if not database_id or not database_id.isdigit(): 178 | return jsonify({'success': False, 'error': 'Valid database ID is required'}) 179 | 180 | try: 181 | result = await visualize_database_relationships(int(database_id)) 182 | return jsonify({'success': True, 'result': result}) 183 | except Exception as e: 184 | return jsonify({'success': False, 'error': str(e)}) 185 | 186 | @app.route('/test_run_query', methods=['POST']) 187 | async def test_run_query(): 188 | """Test the run_database_query tool""" 189 | from src.tools.metabase_tools import run_database_query 190 | 191 | database_id = request.form.get('database_id') 192 | query = request.form.get('query') 193 | 194 | if not database_id or not database_id.isdigit(): 195 | return jsonify({'success': False, 'error': 'Valid database ID is required'}) 196 | 197 | if not query or not query.strip(): 198 | return jsonify({'success': False, 'error': 'SQL query is required'}) 199 | 200 | try: 201 | result = await run_database_query(int(database_id), query) 202 | 203 | # Check if the result contains an error message 204 | if result and isinstance(result, str) and result.startswith("Error executing query:"): 205 | return jsonify({'success': False, 'error': result}) 206 | 207 | return jsonify({'success': True, 'result': result}) 208 | except Exception as e: 209 | import traceback 210 | error_traceback = traceback.format_exc() 211 | print(f"Error in test_run_query: {str(e)}\n{error_traceback}") 212 | return jsonify({'success': False, 'error': str(e)}) 213 | 214 | @app.route('/test_db_overview', methods=['POST']) 215 | async def test_db_overview(): 216 | """Test the db_overview tool""" 217 | from src.tools.metabase_tools import db_overview 218 | 219 | database_id = request.form.get('database_id') 220 | if not database_id or not database_id.isdigit(): 221 | return jsonify({'success': False, 'error': 'Valid database ID is required'}) 222 | 223 | try: 224 | result = await db_overview(int(database_id)) 225 | return jsonify({'success': True, 'result': result}) 226 | except Exception as e: 227 | import traceback 228 | error_traceback = traceback.format_exc() 229 | print(f"Error in test_db_overview: {str(e)}\n{error_traceback}") 230 | return jsonify({'success': False, 'error': str(e)}) 231 | 232 | @app.route('/test_table_detail', methods=['POST']) 233 | async def test_table_detail(): 234 | """Test the table_detail tool""" 235 | from src.tools.metabase_tools import table_detail 236 | 237 | database_id = request.form.get('database_id') 238 | table_id = request.form.get('table_id') 239 | 240 | if not database_id or not database_id.isdigit(): 241 | return jsonify({'success': False, 'error': 'Valid database ID is required'}) 242 | 243 | if not table_id or not table_id.isdigit(): 244 | return jsonify({'success': False, 'error': 'Valid table ID is required'}) 245 | 246 | try: 247 | result = await table_detail(int(database_id), int(table_id)) 248 | return jsonify({'success': True, 'result': result}) 249 | except Exception as e: 250 | import traceback 251 | error_traceback = traceback.format_exc() 252 | print(f"Error in test_table_detail: {str(e)}\n{error_traceback}") 253 | return jsonify({'success': False, 'error': str(e)}) 254 | 255 | return app 256 | 257 | if __name__ == '__main__': 258 | app = create_app() 259 | port = int(os.environ.get('FLASK_PORT', 5000)) 260 | # Use a Config attribute for debug for consistency, e.g., Config.FLASK_DEBUG 261 | # Ensure FLASK_DEBUG is set appropriately in your .env or config settings 262 | app.run(host='0.0.0.0', port=port, debug=getattr(Config, 'FLASK_DEBUG', False)) ``` -------------------------------------------------------------------------------- /src/tools/metabase_tools.py: -------------------------------------------------------------------------------- ```python 1 | from src.api.metabase import MetabaseAPI 2 | from src.config.settings import Config 3 | 4 | async def list_databases() -> str: 5 | """ 6 | List all databases configured in Metabase. 7 | 8 | Returns: 9 | A formatted string with information about all configured databases. 10 | """ 11 | response = await MetabaseAPI.get_databases() 12 | 13 | # Handle different response types 14 | if isinstance(response, str): 15 | return f"Error: Received unexpected string response: {response}" 16 | 17 | if response is None: 18 | return "Error: No response received from Metabase API" 19 | 20 | if isinstance(response, dict) and "error" in response: 21 | return f"Error fetching databases: {response.get('message', 'Unknown error')}" 22 | 23 | # If we got a dictionary without an error, try to extract databases 24 | if isinstance(response, dict) and not isinstance(response, list): 25 | # Try to find databases in common locations 26 | if 'data' in response: 27 | response = response['data'] 28 | elif 'databases' in response: 29 | response = response['databases'] 30 | elif 'results' in response: 31 | response = response['results'] 32 | else: 33 | return f"Error: Unexpected response format: {response}" 34 | 35 | if not response: 36 | return "No databases found in Metabase." 37 | 38 | if not isinstance(response, list): 39 | return f"Error: Expected a list of databases, but got {type(response).__name__}: {response}" 40 | 41 | # Now we should have a list of databases 42 | result = "## Databases in Metabase\n\n" 43 | 44 | for db in response: 45 | # Check if each item is a dictionary 46 | if not isinstance(db, dict): 47 | result += f"- Warning: Found non-dictionary item: {db}\n\n" 48 | continue 49 | 50 | # Safely extract values with fallbacks 51 | db_id = db.get('id', 'Unknown') 52 | db_name = db.get('name', 'Unnamed') 53 | db_engine = db.get('engine', 'Unknown') 54 | db_created = db.get('created_at', 'Unknown') 55 | 56 | result += f"- **ID**: {db_id}\n" 57 | result += f" **Name**: {db_name}\n" 58 | result += f" **Engine**: {db_engine}\n" 59 | result += f" **Created At**: {db_created}\n\n" 60 | 61 | return result 62 | 63 | async def get_database_metadata(database_id: int) -> str: 64 | """ 65 | Get metadata for a specific database in Metabase, including table relationships. 66 | 67 | Args: 68 | database_id: The ID of the database to fetch metadata for 69 | 70 | Returns: 71 | A formatted string with the database's metadata including tables, fields, and relationships. 72 | """ 73 | response = await MetabaseAPI.get_database_schema(database_id) 74 | 75 | if response is None or "error" in response: 76 | return f"Error fetching database metadata: {response.get('message', 'Unknown error')}" 77 | 78 | result = f"## Metadata for Database: {response.get('name')}\n\n" 79 | 80 | # Add database details 81 | result += f"**ID**: {response.get('id')}\n" 82 | result += f"**Engine**: {response.get('engine')}\n" 83 | result += f"**Is Sample**: {response.get('is_sample', False)}\n\n" 84 | 85 | # Add tables information 86 | tables = response.get('tables', []) 87 | result += f"### Tables ({len(tables)})\n\n" 88 | 89 | # Create a map of table IDs to names for reference 90 | table_map = {table.get('id'): table.get('name') for table in tables} 91 | 92 | for table in tables: 93 | result += f"#### {table.get('name')}\n" 94 | result += f"**ID**: {table.get('id')}\n" 95 | result += f"**Schema**: {table.get('schema', 'N/A')}\n" 96 | result += f"**Description**: {table.get('description', 'No description')}\n\n" 97 | 98 | # Add fields for this table 99 | fields = table.get('fields', []) 100 | result += f"##### Fields ({len(fields)})\n\n" 101 | 102 | # Track foreign keys for relationship section 103 | foreign_keys = [] 104 | 105 | for field in fields: 106 | result += f"- **{field.get('name')}**\n" 107 | result += f" - Type: {field.get('base_type')}\n" 108 | result += f" - Description: {field.get('description', 'No description')}\n" 109 | 110 | # Check if this is a foreign key 111 | fk_target_field_id = field.get('fk_target_field_id') 112 | if fk_target_field_id: 113 | foreign_keys.append({ 114 | 'source_field': field.get('name'), 115 | 'source_field_id': field.get('id'), 116 | 'target_field_id': fk_target_field_id 117 | }) 118 | result += f" - **Foreign Key** to another table\n" 119 | 120 | if field.get('special_type'): 121 | result += f" - Special Type: {field.get('special_type')}\n" 122 | 123 | # Add relationships section if there are foreign keys 124 | if foreign_keys: 125 | result += "\n##### Relationships\n\n" 126 | 127 | for fk in foreign_keys: 128 | # Find target field information 129 | target_field_info = "Unknown field" 130 | target_table_name = "Unknown table" 131 | 132 | # Search all tables for the target field 133 | for t in tables: 134 | for f in t.get('fields', []): 135 | if f.get('id') == fk['target_field_id']: 136 | target_field_info = f.get('name') 137 | target_table_name = t.get('name') 138 | break 139 | 140 | result += f"- **{fk['source_field']}** → **{target_table_name}.{target_field_info}**\n" 141 | 142 | result += "\n" 143 | 144 | # Add a visual representation of relationships 145 | result += "### Database Relationships\n\n" 146 | result += "```\n" 147 | 148 | # Create a simple text-based diagram of relationships 149 | for table in tables: 150 | table_name = table.get('name') 151 | result += f"{table_name}\n" 152 | 153 | for field in table.get('fields', []): 154 | fk_target_field_id = field.get('fk_target_field_id') 155 | if fk_target_field_id: 156 | # Find target field information 157 | for t in tables: 158 | for f in t.get('fields', []): 159 | if f.get('id') == fk_target_field_id: 160 | target_field = f.get('name') 161 | target_table = t.get('name') 162 | result += f" └── {field.get('name')} → {target_table}.{target_field}\n" 163 | 164 | result += "\n" 165 | 166 | result += "```\n" 167 | 168 | return result 169 | 170 | async def visualize_database_relationships(database_id: int) -> str: 171 | """ 172 | Generate a visual representation of database relationships. 173 | 174 | Args: 175 | database_id: The ID of the database to visualize 176 | 177 | Returns: 178 | A formatted string with a visualization of table relationships. 179 | """ 180 | response = await MetabaseAPI.get_database_schema(database_id) 181 | 182 | if response is None or "error" in response: 183 | return f"Error fetching database schema: {response.get('message', 'Unknown error')}" 184 | 185 | tables = response.get('tables', []) 186 | if not tables: 187 | return "No tables found in this database." 188 | 189 | result = f"## Database Relationship Diagram for: {response.get('name')}\n\n" 190 | 191 | # Generate a text-based ER diagram 192 | result += "```\n" 193 | 194 | # First list all tables 195 | result += "Tables:\n" 196 | for table in tables: 197 | result += f" {table.get('name')}\n" 198 | 199 | result += "\nRelationships:\n" 200 | 201 | # Then show all relationships 202 | for table in tables: 203 | table_name = table.get('name') 204 | 205 | for field in table.get('fields', []): 206 | fk_target_field_id = field.get('fk_target_field_id') 207 | if fk_target_field_id: 208 | # Find target field information 209 | for t in tables: 210 | for f in t.get('fields', []): 211 | if f.get('id') == fk_target_field_id: 212 | target_field = f.get('name') 213 | target_table = t.get('name') 214 | result += f" {table_name}.{field.get('name')} → {target_table}.{target_field}\n" 215 | 216 | result += "```\n\n" 217 | 218 | # Add a more detailed description of each relationship 219 | result += "### Detailed Relationships\n\n" 220 | 221 | for table in tables: 222 | table_name = table.get('name') 223 | has_relationships = False 224 | 225 | for field in table.get('fields', []): 226 | fk_target_field_id = field.get('fk_target_field_id') 227 | if fk_target_field_id: 228 | if not has_relationships: 229 | result += f"**{table_name}** has the following relationships:\n\n" 230 | has_relationships = True 231 | 232 | # Find target field information 233 | for t in tables: 234 | for f in t.get('fields', []): 235 | if f.get('id') == fk_target_field_id: 236 | target_field = f.get('name') 237 | target_table = t.get('name') 238 | result += f"- Field **{field.get('name')}** references **{target_table}.{target_field}**\n" 239 | 240 | if has_relationships: 241 | result += "\n" 242 | 243 | return result 244 | 245 | async def run_database_query(database_id: int, query: str) -> str: 246 | """ 247 | Run a read-only SQL query against a database and return the first 5 rows. 248 | 249 | Args: 250 | database_id: The ID of the database to query 251 | query: The SQL query to execute (will be limited to 5 rows) 252 | 253 | Returns: 254 | A formatted string with the query results or error message 255 | """ 256 | # Execute the query with a limit of 5 rows 257 | response = await MetabaseAPI.run_query(database_id, query, row_limit=5) 258 | 259 | if response is None: 260 | return "Error: No response received from Metabase API" 261 | 262 | if isinstance(response, dict) and "error" in response: 263 | # Extract more detailed error information if available 264 | error_message = response.get('message', 'Unknown error') 265 | 266 | # Try to extract structured error info 267 | if isinstance(error_message, dict) and 'data' in error_message: 268 | data = error_message.get('data', {}) 269 | if 'errors' in data: 270 | return f"Error executing query: {data['errors']}" 271 | 272 | # Handle different error formats from Metabase 273 | if isinstance(error_message, str) and "does not exist" in error_message: 274 | return f"Error executing query: {error_message}" 275 | 276 | # If it's a raw JSON string representation, try to parse it 277 | if isinstance(error_message, str) and error_message.startswith('{'): 278 | try: 279 | import json 280 | error_json = json.loads(error_message) 281 | if 'data' in error_json and 'errors' in error_json['data']: 282 | return f"Error executing query: {error_json['data']['errors']}" 283 | except: 284 | pass 285 | 286 | return f"Error executing query: {error_message}" 287 | 288 | # Format the results 289 | result = f"## Query Results\n\n" 290 | result += f"```sql\n{query}\n```\n\n" 291 | 292 | # Extract and format the data 293 | try: 294 | # Get column names 295 | if "data" in response and "cols" in response["data"]: 296 | columns = [col.get("name", f"Column {i}") for i, col in enumerate(response["data"]["cols"])] 297 | 298 | # Get rows (limited to 5) 299 | rows = [] 300 | if "data" in response and "rows" in response["data"]: 301 | rows = response["data"]["rows"][:5] 302 | 303 | # Format as a table 304 | if columns and rows: 305 | # Add header 306 | result += "| " + " | ".join(columns) + " |\n" 307 | result += "| " + " | ".join(["---"] * len(columns)) + " |\n" 308 | 309 | # Add rows 310 | for row in rows: 311 | result += "| " + " | ".join([str(cell) for cell in row]) + " |\n" 312 | 313 | # Add row count info 314 | total_row_count = response.get("row_count", len(rows)) 315 | if total_row_count > 5: 316 | result += f"\n*Showing 5 of {total_row_count} rows*\n" 317 | else: 318 | result += "No data returned by the query.\n" 319 | else: 320 | result += "No data structure found in the response.\n" 321 | result += f"Raw response: {response}\n" 322 | except Exception as e: 323 | result += f"Error formatting results: {str(e)}\n" 324 | result += f"Raw response: {response}\n" 325 | 326 | return result 327 | 328 | async def db_overview(database_id: int) -> str: 329 | """ 330 | Get an overview of all tables in a database without detailed field information. 331 | 332 | Args: 333 | database_id: The ID of the database to get the overview for 334 | 335 | Returns: 336 | A formatted string with basic information about all tables in the database. 337 | """ 338 | response = await MetabaseAPI.get_database_schema(database_id) 339 | 340 | if response is None or "error" in response: 341 | return f"Error fetching database schema: {response.get('message', 'Unknown error')}" 342 | 343 | tables = response.get('tables', []) 344 | if not tables: 345 | return "No tables found in this database." 346 | 347 | result = f"## Database Overview: {response.get('name')}\n\n" 348 | 349 | # Add database details 350 | result += f"**ID**: {response.get('id')}\n" 351 | result += f"**Engine**: {response.get('engine')}\n" 352 | result += f"**Is Sample**: {response.get('is_sample', False)}\n\n" 353 | 354 | # Add tables information in a tabular format 355 | result += "### Tables\n\n" 356 | 357 | # Create markdown table header with Table ID 358 | result += "| Table ID | Table Name | Schema | Description | # of Fields |\n" 359 | result += "| -------- | ---------- | ------ | ----------- | ----------- |\n" 360 | 361 | # Add each table as a row 362 | for table in tables: 363 | table_id = table.get('id', 'Unknown') 364 | name = table.get('name', 'Unknown') 365 | schema = table.get('schema', 'N/A') 366 | description = table.get('description', 'No description') 367 | field_count = len(table.get('fields', [])) 368 | 369 | # Add null check before using string methods 370 | if description is None: 371 | description = 'No description' 372 | else: 373 | # Clean up description for table display (remove newlines but keep full text) 374 | description = description.replace('\n', ' ').strip() 375 | 376 | result += f"| {table_id} | {name} | {schema} | {description} | {field_count} |\n" 377 | 378 | return result 379 | 380 | async def table_detail(database_id: int, table_id: int) -> str: 381 | """ 382 | Get detailed information about a specific table. 383 | 384 | Args: 385 | database_id: The ID of the database containing the table 386 | table_id: The ID of the table to get details for 387 | 388 | Returns: 389 | A formatted string with detailed information about the table. 390 | """ 391 | # Directly fetch metadata for the specific table 392 | response = await MetabaseAPI.get_table_metadata(table_id) 393 | 394 | if response is None or "error" in response: 395 | return f"Error fetching table metadata: {response.get('message', 'Unknown error')}" 396 | 397 | # Extract table information 398 | table_name = response.get('name', 'Unknown') 399 | result = f"## Table Details: {table_name}\n\n" 400 | 401 | # Add table details 402 | result += f"**ID**: {response.get('id')}\n" 403 | result += f"**Schema**: {response.get('schema', 'N/A')}\n" 404 | description = response.get('description', 'No description') 405 | if description is None: 406 | description = 'No description' 407 | result += f"**Description**: {description}\n\n" 408 | 409 | # Add fields section 410 | fields = response.get('fields', []) 411 | result += f"### Fields ({len(fields)})\n\n" 412 | 413 | # Create markdown table for fields 414 | result += "| Field ID | Field Name | Type | Description | Special Type |\n" 415 | result += "| -------- | ---------- | ---- | ----------- | ------------ |\n" 416 | 417 | # Track foreign keys for relationship section 418 | foreign_keys = [] 419 | 420 | for field in fields: 421 | field_id = field.get('id', 'Unknown') 422 | name = field.get('name', 'Unknown') 423 | field_type = field.get('base_type', 'Unknown') 424 | description = field.get('description', 'No description') 425 | special_type = field.get('special_type', 'None') 426 | 427 | # Add null check before using string methods 428 | if description is None: 429 | description = 'No description' 430 | else: 431 | # Clean up description for table display (remove newlines but keep full text) 432 | description = description.replace('\n', ' ').strip() 433 | 434 | result += f"| {field_id} | {name} | {field_type} | {description} | {special_type} |\n" 435 | 436 | # Check if this is a foreign key 437 | fk_target_field_id = field.get('fk_target_field_id') 438 | if fk_target_field_id: 439 | foreign_keys.append({ 440 | 'source_field': field.get('name'), 441 | 'source_field_id': field.get('id'), 442 | 'target_field_id': fk_target_field_id 443 | }) 444 | 445 | # Add relationships section if there are foreign keys 446 | if foreign_keys: 447 | result += "\n### Relationships\n\n" 448 | 449 | for fk in foreign_keys: 450 | # We'll need to fetch target field information 451 | target_field = await MetabaseAPI.get_field_metadata(fk['target_field_id']) 452 | 453 | if target_field and not "error" in target_field: 454 | target_field_name = target_field.get('name', 'Unknown field') 455 | target_table_id = target_field.get('table_id') 456 | 457 | # Get target table information 458 | target_table = await MetabaseAPI.get_table_metadata(target_table_id) 459 | target_table_name = target_table.get('name', 'Unknown table') if target_table else 'Unknown table' 460 | 461 | result += f"- **{fk['source_field']}** → **{target_table_name}.{target_field_name}** (Table ID: {target_table_id})\n" 462 | else: 463 | result += f"- **{fk['source_field']}** → **Unknown reference** (Target Field ID: {fk['target_field_id']})\n" 464 | 465 | # For "Referenced By" section, we would need to search through other tables 466 | # For now, let's note that this would require additional API calls 467 | result += "\n### Referenced By\n\n" 468 | result += "*Note: To see all references to this table, use the database visualization tool.*\n" 469 | 470 | return result ``` -------------------------------------------------------------------------------- /templates/config.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <title>Metabase MCP Configuration</title> 5 | <meta charset="UTF-8"> 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/github-markdown.min.css"> 8 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script> 9 | <style> 10 | body { 11 | font-family: Arial, sans-serif; 12 | max-width: 800px; 13 | margin: 0 auto; 14 | padding: 20px; 15 | } 16 | .form-group { 17 | margin-bottom: 15px; 18 | } 19 | label { 20 | display: block; 21 | margin-bottom: 5px; 22 | font-weight: bold; 23 | } 24 | input[type="text"] { 25 | width: 100%; 26 | padding: 8px; 27 | box-sizing: border-box; 28 | } 29 | button { 30 | background-color: #4CAF50; 31 | color: white; 32 | padding: 10px 15px; 33 | border: none; 34 | cursor: pointer; 35 | margin-right: 10px; 36 | } 37 | button:hover { 38 | background-color: #45a049; 39 | } 40 | .test-button { 41 | background-color: #2196F3; 42 | } 43 | .test-button:hover { 44 | background-color: #0b7dda; 45 | } 46 | .message { 47 | margin: 15px 0; 48 | padding: 10px; 49 | border-radius: 5px; 50 | } 51 | .success { 52 | background-color: #d4edda; 53 | color: #155724; 54 | } 55 | .error { 56 | background-color: #f8d7da; 57 | color: #721c24; 58 | } 59 | .result-area { 60 | margin: 20px 0; 61 | padding:.875rem; 62 | background: #f5f5f5; 63 | border-radius: 4px; 64 | border: 1px solid #ddd; 65 | min-height: 100px; 66 | max-height: 400px; 67 | overflow-y: auto; 68 | white-space: pre-wrap; 69 | display: none; 70 | } 71 | .section { 72 | margin-top: 30px; 73 | border-top: 1px solid #ddd; 74 | padding-top: 20px; 75 | } 76 | textarea.form-control { 77 | width: 100%; 78 | padding: 8px; 79 | box-sizing: border-box; 80 | font-family: monospace; 81 | border: 1px solid #ccc; 82 | border-radius: 4px; 83 | } 84 | .markdown-body { 85 | box-sizing: border-box; 86 | min-width: 200px; 87 | max-width: 100%; 88 | padding: 15px; 89 | background-color: #fff; 90 | border-radius: 4px; 91 | border: 1px solid #ddd; 92 | color: #24292e; 93 | font-weight: normal; 94 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; 95 | } 96 | .markdown-body h1, 97 | .markdown-body h2, 98 | .markdown-body h3, 99 | .markdown-body h4 { 100 | color: #24292e; 101 | font-weight: 600; 102 | margin-top: 24px; 103 | margin-bottom: 16px; 104 | } 105 | .markdown-body table { 106 | display: table; 107 | width: 100%; 108 | border-collapse: collapse; 109 | margin-bottom: 16px; 110 | border: 1px solid #ddd; 111 | } 112 | .markdown-body table th { 113 | font-weight: 600; 114 | padding: 8px 13px; 115 | border: 1px solid #ddd; 116 | background-color: #f6f8fa; 117 | color: #24292e; 118 | } 119 | .markdown-body table td { 120 | padding: 8px 13px; 121 | border: 1px solid #ddd; 122 | color: #24292e; 123 | } 124 | .markdown-body table tr { 125 | background-color: #fff; 126 | border-top: 1px solid #c6cbd1; 127 | } 128 | .markdown-body table tr:nth-child(2n) { 129 | background-color: #f6f8fa; 130 | } 131 | .markdown-body p, 132 | .markdown-body ul, 133 | .markdown-body ol, 134 | .markdown-body blockquote { 135 | color: #24292e; 136 | margin-bottom: 16px; 137 | } 138 | .markdown-body pre, 139 | .markdown-body code { 140 | background-color: #f6f8fa; 141 | border-radius: 3px; 142 | padding: 0.2em 0.4em; 143 | color: #24292e; 144 | font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; 145 | } 146 | .result-container { 147 | margin-top: 15px; 148 | } 149 | </style> 150 | </head> 151 | <body> 152 | <h1>Metabase MCP Configuration</h1> 153 | 154 | {% with messages = get_flashed_messages() %} 155 | {% if messages %} 156 | <div class="message {% if 'error' in messages[0].lower() %}error{% else %}success{% endif %}"> 157 | {{ messages[0] }} 158 | </div> 159 | {% endif %} 160 | {% endwith %} 161 | 162 | <form method="post" action="/save_config" id="config-form"> 163 | <div class="form-group"> 164 | <label for="metabase_url">Metabase URL:</label> 165 | <input type="text" id="metabase_url" name="metabase_url" value="{{ metabase_url }}" placeholder="http://localhost:3000"> 166 | </div> 167 | 168 | <div class="form-group"> 169 | <label for="api_key">API Key:</label> 170 | <input type="password" id="api_key" name="api_key" class="form-control" 171 | value="{{ api_key }}" 172 | placeholder="{{ 'API key is set' if api_key_set else 'Enter your Metabase API key' }}" 173 | required> 174 | <small class="form-text text-muted"> 175 | {% if api_key_set %} 176 | Your API key is stored encrypted. Enter a new key to change it. 177 | {% else %} 178 | Your API key will be stored encrypted and never displayed in plain text. 179 | {% endif %} 180 | </small> 181 | </div> 182 | 183 | <div class="form-group"> 184 | <button type="submit">Save Configuration</button> 185 | <button type="button" class="test-button" id="test-connection">Test Connection</button> 186 | </div> 187 | </form> 188 | 189 | <div id="connection-result" class="result-area"></div> 190 | 191 | <div class="section"> 192 | <h2>Test Tools</h2> 193 | 194 | <h3>List Databases</h3> 195 | <button type="button" id="test-list-databases">Test List Databases</button> 196 | <div id="list-databases-result" class="result-area"></div> 197 | 198 | <h3>Get Database Metadata</h3> 199 | <div class="form-group"> 200 | <label for="database_id">Database ID:</label> 201 | <input type="text" id="database_id" placeholder="Enter database ID"> 202 | </div> 203 | <button type="button" id="test-get-metadata">Test Get Metadata</button> 204 | <div id="get-metadata-result" class="result-area"></div> 205 | 206 | <h3>DB Overview</h3> 207 | <div class="form-group"> 208 | <label for="db_overview_database_id">Database ID:</label> 209 | <input type="text" id="db_overview_database_id" placeholder="Enter database ID"> 210 | </div> 211 | <button type="button" id="test-db-overview">Get Database Overview</button> 212 | <div id="db-overview-result" class="result-area"></div> 213 | 214 | <h3>Table Detail</h3> 215 | <div class="form-group"> 216 | <label for="table_detail_database_id">Database ID:</label> 217 | <input type="text" id="table_detail_database_id" placeholder="Enter database ID"> 218 | </div> 219 | <div class="form-group"> 220 | <label for="table_detail_table_id">Table ID:</label> 221 | <input type="text" id="table_detail_table_id" placeholder="Enter table ID (from DB Overview)"> 222 | </div> 223 | <button type="button" id="test-table-detail">Get Table Detail</button> 224 | <div id="table-detail-result" class="result-area"></div> 225 | 226 | <h3>Visualize Database Relationships</h3> 227 | <div class="form-group"> 228 | <label for="relationship_database_id">Database ID:</label> 229 | <input type="text" id="relationship_database_id" placeholder="Enter database ID"> 230 | </div> 231 | <button type="button" id="test-visualize-relationships">Visualize Relationships</button> 232 | <div id="visualize-relationships-result" class="result-area"></div> 233 | 234 | <h3>Run Database Query</h3> 235 | <div class="form-group"> 236 | <label for="query_database_id">Database ID:</label> 237 | <input type="text" id="query_database_id" placeholder="Enter database ID"> 238 | </div> 239 | <div class="form-group"> 240 | <label for="sql_query">SQL Query:</label> 241 | <textarea id="sql_query" placeholder="Enter SQL query" rows="4" class="form-control"></textarea> 242 | <small class="form-text text-muted">Query will be limited to returning 5 rows for safety.</small> 243 | </div> 244 | <button type="button" id="test-run-query">Run Query</button> 245 | <div id="run-query-result" class="result-area"></div> 246 | </div> 247 | 248 | <div class="section"> 249 | <h3>List Actions</h3> 250 | <button type="button" id="test-list-actions">Test List Actions</button> 251 | <div id="list-actions-result" class="result-area"></div> 252 | 253 | <h3>Get Action Details</h3> 254 | <div class="form-group"> 255 | <label for="action_id">Action ID:</label> 256 | <input type="text" id="action_id" placeholder="Enter action ID"> 257 | </div> 258 | <button type="button" id="test-get-action">Test Get Action Details</button> 259 | <div id="get-action-result" class="result-area"></div> 260 | 261 | <h3>Execute Action</h3> 262 | <div class="form-group"> 263 | <label for="exec_action_id">Action ID:</label> 264 | <input type="text" id="exec_action_id" placeholder="Enter action ID"> 265 | </div> 266 | <button type="button" id="load-action-params">Load Parameters</button> 267 | <div id="action-parameters" class="form-group"></div> 268 | <button type="button" id="test-execute-action">Execute Action</button> 269 | <div id="execute-action-result" class="result-area"></div> 270 | </div> 271 | 272 | <script> 273 | // Configure marked.js options 274 | marked.setOptions({ 275 | gfm: true, 276 | breaks: true, 277 | tables: true, 278 | sanitize: false 279 | }); 280 | 281 | // Function to format response data with proper Markdown rendering 282 | function formatResponse(container, data) { 283 | if (data.success) { 284 | // Check if response has result or message property 285 | const content = data.result || data.message || ''; 286 | 287 | // Only try to parse as markdown if it's not empty 288 | if (content) { 289 | container.innerHTML = '<div class="markdown-body">' + marked.parse(content) + '</div>'; 290 | } else { 291 | container.innerHTML = '<div class="markdown-body">Operation completed successfully.</div>'; 292 | } 293 | 294 | // Add success class if not already present 295 | if (!container.className.includes('success')) { 296 | container.className += ' success'; 297 | } 298 | } else { 299 | const errorMsg = data.error || data.message || 'Unknown error'; 300 | container.innerHTML = '<div class="error">' + errorMsg + '</div>'; 301 | 302 | // Add error class if not already present 303 | if (!container.className.includes('error')) { 304 | container.className += ' error'; 305 | } 306 | } 307 | } 308 | 309 | // Test connection 310 | document.getElementById('test-connection').addEventListener('click', function() { 311 | const url = document.getElementById('metabase_url').value; 312 | const apiKey = document.getElementById('api_key').value; 313 | const resultArea = document.getElementById('connection-result'); 314 | 315 | resultArea.style.display = 'block'; 316 | resultArea.innerHTML = 'Testing connection...'; 317 | 318 | fetch('/test_connection', { 319 | method: 'POST', 320 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 321 | body: `metabase_url=${encodeURIComponent(url)}&api_key=${encodeURIComponent(apiKey)}` 322 | }) 323 | .then(response => response.json()) 324 | .then(data => { 325 | formatResponse(resultArea, data); 326 | }) 327 | .catch(error => { 328 | resultArea.className = 'result-area error'; 329 | resultArea.innerHTML = `Error: ${error}`; 330 | }); 331 | }); 332 | 333 | // Test list databases 334 | document.getElementById('test-list-databases').addEventListener('click', function() { 335 | const resultArea = document.getElementById('list-databases-result'); 336 | 337 | resultArea.style.display = 'block'; 338 | resultArea.innerHTML = 'Fetching databases...'; 339 | 340 | fetch('/test_list_databases') 341 | .then(response => response.json()) 342 | .then(data => { 343 | formatResponse(resultArea, data); 344 | }) 345 | .catch(error => { 346 | resultArea.className = 'result-area error'; 347 | resultArea.innerHTML = `Error: ${error}`; 348 | }); 349 | }); 350 | 351 | // Test get metadata 352 | document.getElementById('test-get-metadata').addEventListener('click', function() { 353 | const databaseId = document.getElementById('database_id').value; 354 | const resultArea = document.getElementById('get-metadata-result'); 355 | 356 | if (!databaseId) { 357 | resultArea.style.display = 'block'; 358 | resultArea.className = 'result-area error'; 359 | resultArea.innerHTML = 'Please enter a database ID'; 360 | return; 361 | } 362 | 363 | resultArea.style.display = 'block'; 364 | resultArea.innerHTML = 'Fetching metadata...'; 365 | 366 | fetch('/test_get_metadata', { 367 | method: 'POST', 368 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 369 | body: `database_id=${encodeURIComponent(databaseId)}` 370 | }) 371 | .then(response => response.json()) 372 | .then(data => { 373 | formatResponse(resultArea, data); 374 | }) 375 | .catch(error => { 376 | resultArea.className = 'result-area error'; 377 | resultArea.innerHTML = `Error: ${error}`; 378 | }); 379 | }); 380 | 381 | // Test list actions 382 | document.getElementById('test-list-actions').addEventListener('click', function() { 383 | const resultArea = document.getElementById('list-actions-result'); 384 | 385 | resultArea.style.display = 'block'; 386 | resultArea.innerHTML = 'Fetching actions...'; 387 | 388 | fetch('/test_list_actions') 389 | .then(response => response.json()) 390 | .then(data => { 391 | formatResponse(resultArea, data); 392 | }) 393 | .catch(error => { 394 | resultArea.className = 'result-area error'; 395 | resultArea.innerHTML = `Error: ${error}`; 396 | }); 397 | }); 398 | 399 | // Test get action details 400 | document.getElementById('test-get-action').addEventListener('click', function() { 401 | const actionId = document.getElementById('action_id').value; 402 | const resultArea = document.getElementById('get-action-result'); 403 | 404 | if (!actionId) { 405 | resultArea.style.display = 'block'; 406 | resultArea.className = 'result-area error'; 407 | resultArea.innerHTML = 'Please enter an action ID'; 408 | return; 409 | } 410 | 411 | resultArea.style.display = 'block'; 412 | resultArea.innerHTML = 'Fetching action details...'; 413 | 414 | fetch('/test_get_action_details', { 415 | method: 'POST', 416 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 417 | body: `action_id=${encodeURIComponent(actionId)}` 418 | }) 419 | .then(response => response.json()) 420 | .then(data => { 421 | formatResponse(resultArea, data); 422 | }) 423 | .catch(error => { 424 | resultArea.className = 'result-area error'; 425 | resultArea.innerHTML = `Error: ${error}`; 426 | }); 427 | }); 428 | 429 | // Load action parameters 430 | document.getElementById('load-action-params').addEventListener('click', function() { 431 | const actionId = document.getElementById('exec_action_id').value; 432 | const paramsContainer = document.getElementById('action-parameters'); 433 | 434 | if (!actionId) { 435 | alert('Please enter an action ID'); 436 | return; 437 | } 438 | 439 | paramsContainer.innerHTML = 'Loading parameters...'; 440 | 441 | fetch('/test_get_action_details', { 442 | method: 'POST', 443 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 444 | body: `action_id=${encodeURIComponent(actionId)}` 445 | }) 446 | .then(response => response.json()) 447 | .then(data => { 448 | if (data.success) { 449 | // Parse the markdown to extract parameters 450 | const paramSection = data.result.split('### Parameters')[1]; 451 | if (!paramSection) { 452 | paramsContainer.innerHTML = 'No parameters found for this action.'; 453 | return; 454 | } 455 | 456 | // Clear container 457 | paramsContainer.innerHTML = ''; 458 | 459 | // Extract parameter names from the markdown 460 | const paramLines = paramSection.split('\n'); 461 | let paramCount = 0; 462 | 463 | for (const line of paramLines) { 464 | if (line.startsWith('- **')) { 465 | const paramName = line.split('**:')[0].replace('- **', '').trim(); 466 | 467 | const formGroup = document.createElement('div'); 468 | formGroup.className = 'form-group'; 469 | 470 | const label = document.createElement('label'); 471 | label.setAttribute('for', `param_${paramName}`); 472 | label.textContent = `Parameter: ${paramName}`; 473 | 474 | const input = document.createElement('input'); 475 | input.type = 'text'; 476 | input.id = `param_${paramName}`; 477 | input.name = `param_${paramName}`; 478 | input.placeholder = `Enter value for ${paramName}`; 479 | 480 | formGroup.appendChild(label); 481 | formGroup.appendChild(input); 482 | paramsContainer.appendChild(formGroup); 483 | 484 | paramCount++; 485 | } 486 | } 487 | 488 | if (paramCount === 0) { 489 | paramsContainer.innerHTML = 'No parameters found for this action.'; 490 | } 491 | } else { 492 | paramsContainer.innerHTML = `Error: ${data.error}`; 493 | } 494 | }) 495 | .catch(error => { 496 | paramsContainer.innerHTML = `Error: ${error}`; 497 | }); 498 | }); 499 | 500 | // Execute action 501 | document.getElementById('test-execute-action').addEventListener('click', function() { 502 | const actionId = document.getElementById('exec_action_id').value; 503 | const resultArea = document.getElementById('execute-action-result'); 504 | const paramsContainer = document.getElementById('action-parameters'); 505 | 506 | if (!actionId) { 507 | resultArea.style.display = 'block'; 508 | resultArea.className = 'result-area error'; 509 | resultArea.innerHTML = 'Please enter an action ID'; 510 | return; 511 | } 512 | 513 | // Collect parameters 514 | const formData = new FormData(); 515 | formData.append('action_id', actionId); 516 | 517 | // Add all input fields from the parameters container 518 | const inputs = paramsContainer.querySelectorAll('input'); 519 | inputs.forEach(input => { 520 | if (input.name && input.value) { 521 | formData.append(input.name, input.value); 522 | } 523 | }); 524 | 525 | resultArea.style.display = 'block'; 526 | resultArea.innerHTML = 'Executing action...'; 527 | 528 | fetch('/test_execute_action', { 529 | method: 'POST', 530 | body: formData 531 | }) 532 | .then(response => response.json()) 533 | .then(data => { 534 | formatResponse(resultArea, data); 535 | }) 536 | .catch(error => { 537 | resultArea.className = 'result-area error'; 538 | resultArea.innerHTML = `Error: ${error}`; 539 | }); 540 | }); 541 | 542 | // Test visualize relationships 543 | document.getElementById('test-visualize-relationships').addEventListener('click', function() { 544 | const databaseId = document.getElementById('relationship_database_id').value; 545 | if (!databaseId) { 546 | alert('Please enter a database ID'); 547 | return; 548 | } 549 | 550 | const resultArea = document.getElementById('visualize-relationships-result'); 551 | resultArea.style.display = 'block'; 552 | resultArea.innerHTML = 'Visualizing relationships...'; 553 | 554 | fetch('/test_visualize_relationships', { 555 | method: 'POST', 556 | headers: { 557 | 'Content-Type': 'application/x-www-form-urlencoded', 558 | }, 559 | body: `database_id=${databaseId}` 560 | }) 561 | .then(response => response.json()) 562 | .then(data => { 563 | formatResponse(resultArea, data); 564 | }) 565 | .catch(error => { 566 | resultArea.innerHTML = `<div class="error">Error: ${error.message}</div>`; 567 | }); 568 | }); 569 | 570 | // Test run query 571 | document.getElementById('test-run-query').addEventListener('click', function() { 572 | const databaseId = document.getElementById('query_database_id').value; 573 | const sqlQuery = document.getElementById('sql_query').value; 574 | 575 | if (!databaseId || !sqlQuery) { 576 | alert('Please enter both database ID and SQL query'); 577 | return; 578 | } 579 | 580 | const resultArea = document.getElementById('run-query-result'); 581 | resultArea.style.display = 'block'; 582 | resultArea.innerHTML = 'Executing query...'; 583 | 584 | fetch('/test_run_query', { 585 | method: 'POST', 586 | headers: { 587 | 'Content-Type': 'application/x-www-form-urlencoded', 588 | }, 589 | body: `database_id=${encodeURIComponent(databaseId)}&query=${encodeURIComponent(sqlQuery)}` 590 | }) 591 | .then(response => response.json()) 592 | .then(data => { 593 | formatResponse(resultArea, data); 594 | }) 595 | .catch(error => { 596 | resultArea.innerHTML = `<div class="error">Error: ${error.message}</div>`; 597 | }); 598 | }); 599 | 600 | // Test DB Overview 601 | document.getElementById('test-db-overview').addEventListener('click', function() { 602 | const databaseId = document.getElementById('db_overview_database_id').value; 603 | if (!databaseId) { 604 | alert('Please enter a database ID'); 605 | return; 606 | } 607 | 608 | const resultArea = document.getElementById('db-overview-result'); 609 | resultArea.style.display = 'block'; 610 | resultArea.innerHTML = 'Loading database overview...'; 611 | 612 | fetch('/test_db_overview', { 613 | method: 'POST', 614 | headers: { 615 | 'Content-Type': 'application/x-www-form-urlencoded', 616 | }, 617 | body: `database_id=${databaseId}` 618 | }) 619 | .then(response => response.json()) 620 | .then(data => { 621 | formatResponse(resultArea, data); 622 | }) 623 | .catch(error => { 624 | resultArea.innerHTML = `<div class="error">Error: ${error.message}</div>`; 625 | }); 626 | }); 627 | 628 | // Test Table Detail 629 | document.getElementById('test-table-detail').addEventListener('click', function() { 630 | const databaseId = document.getElementById('table_detail_database_id').value; 631 | const tableId = document.getElementById('table_detail_table_id').value; 632 | 633 | if (!databaseId || !tableId) { 634 | alert('Please enter both database ID and table ID'); 635 | return; 636 | } 637 | 638 | const resultArea = document.getElementById('table-detail-result'); 639 | resultArea.style.display = 'block'; 640 | resultArea.innerHTML = 'Loading table details...'; 641 | 642 | fetch('/test_table_detail', { 643 | method: 'POST', 644 | headers: { 645 | 'Content-Type': 'application/x-www-form-urlencoded', 646 | }, 647 | body: `database_id=${encodeURIComponent(databaseId)}&table_id=${encodeURIComponent(tableId)}` 648 | }) 649 | .then(response => response.json()) 650 | .then(data => { 651 | formatResponse(resultArea, data); 652 | }) 653 | .catch(error => { 654 | resultArea.innerHTML = `<div class="error">Error: ${error.message}</div>`; 655 | }); 656 | }); 657 | </script> 658 | </body> 659 | </html> 660 | ```