# 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 |
```