#
tokens: 24076/50000 15/15 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```