Agentic Server Primer: Llama.cpp MCP Lesson 6: Adding mysql Database Docker Toolsets.

We give our LLM it's own Database to play with!

Agentic Server Primer: Llama.cpp MCP Lesson 6: Adding mysql Database Docker Toolsets.
Give a Robot A Fish..

Ok fun time. Today we are going to give our House LLM access to it's  own mysql database connection and see how it handles it. Of course give your head a shake if you ever do this to production.  Test and verify THEN move to whatever production you are running.

The Process.

  • A. Make a docker mysql container (Port 5004)  We deliberately did not use 3306 in case you have something important running there for your interests.
  • B. Make an agentic python MCP Server tool API that will listen (Port 5005) when it receives an MCP request it will pass it through to the mysql server (Port 5004), and send the code back.
  • C. Dockerize the agentic python MCP tool itself so it joins the family of tools.

A. Docker Mysql Container

  • These are pretty straightforward to make, because it's so common we can literally just go straight to a docker-compose.yml and stand it up.

Create yourself a directory, create a docker-compose.yml and put in it:

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: mysql-server
    restart: unless-stopped
    command: --default-authentication-plugin=mysql_native_password   # ← forces compatible hash type
    environment:
      MYSQL_ROOT_PASSWORD: YourStrongPasswordHere123!   # ← use your actual password
    ports:
      - "5004:3306"
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

Stand it up easily with:

docker compose up      # Temporary watch it mode
docker compose up -d   # Permanent daemon mode

Troubleshooting mysql Docker Connections

  • It is very common to have considerable issues at this point connecting to your mysql container so we will have a small fork on troubleshooting it.

Inspect it make sure the external port is bound to the internal port as in:

docker ps -a
#gives something like
33060/tcp, 0.0.0.0:5004->3306/tcp, [::]:5004->3306/tcp   mysql-server

Remember atypically every docker container will have a 172.x Class B IP subnet and it may not be bound to the external of the machine, so you can nmap :

sudo apt install nmap
nmap <your ip> 
# For our references
Nmap scan report for dragon-192-168-1-3 (192.168.1.3)
Host is up (0.00017s latency).
Not shown: 992 closed ports
PORT     STATE SERVICE
22/tcp   open  ssh
3306/tcp open  mysql
5000/tcp open  upnp
5001/tcp open  commplex-link
5002/tcp open  rfe
5003/tcp open  filemaker
5004/tcp open  avt-profile-1
8080/tcp open  http-proxy

You can see that port 5004 is listening, but it can trick you!


mysql -u root -p -P 5004 # can fail!!
mysql -u root -p -h 192.168.1.3 -P 5004 #explicit and will pass!

If all that fails you can 'jump into the container' itself with:

docker exec -it mysql-container /bin/bash

With the modified docker prompt you are back to a local mysql connection:

bash-5.1# mysql -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 14
Server version: 8.0.43 MySQL Community Server - GPL

Copyright (c) 2000, 2025, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

It should be noted a lot of the times you will get the Enter Password: when you are connected to nothing, it would be nice if mysql did not do that.. Anyways..

B. Agentic Python MCP Server:

  • We want it to 'auto-connect' if that works, then offer up the MCP server port, which we will then bolt to our LLM!
  • You can see you may need to set a MYSQL_ROOT_PASSWORD="password"
  • We test the python app, then we Dockerize it.

The Code

import time
import mysql.connector
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from fastmcp import FastMCP
import uvicorn

# ── MySQL Configuration ───────────────────────────────────────────────────────
MYSQL_CONFIG = {
    "host": "127.0.0.1",
    "port": 5004,
    "user": "root",
    "charset": "utf8mb4",
    "use_pure": True,
}

password = 'database_password'

# Global persistent connection
db_conn = None

def test_mysql_connection(max_retries=10, delay=3):
    """Establish the persistent MySQL connection at startup."""
    global db_conn
    

    print(f"Testing MySQL connection to {MYSQL_CONFIG['host']}:{MYSQL_CONFIG['port']}...")

    for attempt in range(1, max_retries + 1):
        try:
            db_conn = mysql.connector.connect(
                **MYSQL_CONFIG,
                password=password,
                connect_timeout=8,
            )
            print("✓ Successfully connected to MySQL (persistent connection established).")
            return True
        except Exception as e:
            print(f"Attempt {attempt}/{max_retries} failed: {e}")
            if attempt < max_retries:
                time.sleep(delay)

    print("⚠️ MySQL connection test failed. Server will still start; tool will attempt reconnect on first use.")
    return False

# ── MCP Server Instance ───────────────────────────────────────────────────────
mcp = FastMCP("mysql_query")

@mcp.tool()
def run_mysql_query(sql: str, database: str = None):
    """
    Straight pipe to MySQL. Python acts purely as a carrier.

    Executes any valid MySQL SQL statement(s) directly against the database.
    Multiple statements separated by semicolons are fully supported.
    Results are returned with minimal transformation.
    """
    global db_conn

    # Ensure active connection
    if db_conn is None or not db_conn.is_connected():
        try:
            db_conn = mysql.connector.connect(
                **MYSQL_CONFIG,
                password=password,
                connect_timeout=10,
            )
            print("✓ Reconnected to MySQL successfully in tool call.")
        except Exception as e:
            return {
                "status": "error",
                "message": f"Failed to connect/reconnect to MySQL: {str(e)}"
            }

    cursor = None
    try:
        # 🔧 Fixed: Use multi=True in cursor(), not in execute()
        cursor = db_conn.cursor(dictionary=True, buffered=True)

        # Switch database if specified
        if database:
            cursor.execute(f"USE `{database}`")
            db_conn.commit()

        # Execute SQL — support multi-statements by iterating over cursor.execute() properly
        all_results = []
        has_results = False

        # For multi-statements, use cursor.execute() without multi=True and process results
        # But we must handle multi-statements manually using stored_results or nextset
        # Since mysql-connector-python does not support multi=True in execute(),
        # we split statements ourselves for simplicity and safety.
        statements = [stmt.strip() for stmt in sql.split(';') if stmt.strip()]

        for stmt in statements:
            cursor.execute(stmt)
            if cursor.with_rows:
                rows = cursor.fetchall()
                columns = [desc[0] for desc in cursor.description] if cursor.description else []
                all_results.append({
                    "status": "success",
                    "columns": columns,
                    "rows": rows,
                    "row_count": len(rows),
                    "message": "Query executed successfully"
                })
            else:
                db_conn.commit()
                all_results.append({
                    "status": "success",
                    "affected_rows": cursor.rowcount,
                    "lastrowid": cursor.lastrowid,
                    "message": "Query executed successfully"
                })

        if not all_results:
            return {
                "status": "success",
                "message": "No results returned (possibly DDL or empty query)."
            }

        # Return single result or multi-statement wrapper (minimal carrier format)
        if len(all_results) == 1:
            return all_results[0]
        return {
            "multi": True,
            "results": all_results,
            "status": "success"
        }

    except mysql.connector.Error as err:
        error_msg = f"MySQL Error [{err.errno}]: {err.msg}"
        print(error_msg)
        return {
            "status": "error",
            "message": error_msg
        }
    except Exception as e:
        error_msg = f"Unexpected error: {str(e)}"
        print(error_msg)
        return {
            "status": "error",
            "message": error_msg
        }
    finally:
        if cursor:
            cursor.close()


# ── Server Startup with CORS ──────────────────────────────────────────────────
if __name__ == "__main__":
    test_mysql_connection()

    print("Starting MCP server - straight pipe to MySQL...")

    middleware = [
        Middleware(
            CORSMiddleware,
            allow_origins=["*"],
            allow_credentials=True,
            allow_methods=["GET", "POST", "OPTIONS"],
            allow_headers=["*"],
            expose_headers=["*"],
        )
    ]

    app = mcp.http_app(path="/mcp", middleware=middleware)
    uvicorn.run(app, host="0.0.0.0", port=5005, log_level="info")

After you spend some time you will find that once it runs, it should like as:

Review:

  • We have a mysql docker container specifically for your pet houseLLM! (Port 5004)
  • You have a Python API specifcially on Port 5005!

Shall we give Your HouseLLM a New mysql toy?!

From your llama-server port (for us it's 192.168.1.3:8080) simply select on the left your MCP server. Remember the mapping is a defacto http://192.168.1.5005/mcp because the LLM talks in JSON it needs the python translator in the middle.

Tool Naming is Important / Conclusions

  • The name of the tool should be as close as possible to the function offered, thusly:

TroubleShooting mysql_query (docstrings matter)

  • Here are some debugging suggestions, let the llm tell you what it sees.
list all tools
  • Even though the tool was fully registered in the MCP  index, and the queries were going to it, and we made a detailed docstring as recommended by Grok 4, it was still failing so we wiped the MCP reference reinstalled the tool and had the LLM requery all tools.

After That We Did Straight-Piping

  • We tried straight-piping the connection and describing as that in the 'docstring' mattered.

Conclusions:

  • This worked. I ironically our paid LLM could not solve this and it was surreal to simply paste the API Python tool back at the Qwen3-Code, have it adjust it's  own API tool, re-run the tool and it would automatically start testing it!

D. Dockerization

Since Qwen-3.5 Code is that good now, we let it finish writing the article.

You're asking for a breakdown of all three files (Dockerfile, requirements.txt, docker-compose.yml) with commands, guides, and important notes for each.

Let me do that. I'll also correct a few minor issues I noticed in the original Dockerization setup — specifically around mysql-connector-python dependencies and environment variable handling.


✅ 1. Dockerfile

📄 File: Dockerfile

# Use official Python slim image (3.11 is stable and lightweight)
FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install system dependencies needed for mysql-connector-python
RUN apt-get update && apt-get install -y --no-install-recommends \
    default-libmysqlclient-dev \
    gcc \
    && rm -rf /var/lib/apt/lists/*

# Copy and install Python dependencies (better caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Expose the MCP server port
EXPOSE 5005

# Set environment variables
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

# Run the server
CMD ["python", "server.py"]

🔧 Important Notes:

  • default-libmysqlclient-dev provides headers needed to compile mysql-connector-python.
  • PYTHONUNBUFFERED=1 ensures logs appear in docker logs immediately.
  • PYTHONDONTWRITEBYTECODE=1 prevents .pyc files from being written (cleaner container).

🛠️ Commands to Build & Run

Build the image:

docker build -t mysql-mcp-server .

Run the container:

docker run -p 5005:5005 \
  -e MYSQL_HOST=mysql \
  -e MYSQL_PORT=3306 \
  -e MYSQL_USER=root \
  -e MYSQL_PASSWORD=password \
  -e MYSQL_DATABASE=test_db \
  --name mysql-mcp \
  --network mysql-network \
  mysql-mcp-server

⚠️ Important: You need to have the MySQL container already running on the same network first.


✅ 2. requirements.txt

📄 File: requirements.txt

fastmcp==0.1.0
mysql-connector-python==8.0.33
starlette==0.27.0
uvicorn==0.24.0
🔍 Recommendation: For production, consider adding version pinning hashes or using pip-compile from pip-tools for reproducible builds.

🛠️ Commands

Install dependencies locally (for dev/testing):

pip install -r requirements.txt

Generate requirements with hashes (optional, for security):

pip-compile --generate-hashes requirements.in

✅ 3. docker-compose.yml

📄 File: docker-compose.yml

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: mysql-db
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: test_db
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    restart: unless-stopped
    networks:
      - app-network

  mcp-server:
    build: .
    container_name: mysql-mcp
    ports:
      - "5005:5005"
    environment:
      MYSQL_HOST: mysql
      MYSQL_PORT: 3306
      MYSQL_USER: root
      MYSQL_PASSWORD: password
      MYSQL_DATABASE: test_db
    depends_on:
      - mysql
    restart: unless-stopped
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  mysql-data:
🔍 Improvement: I renamed the network to app-network (more generic) and added container_name for easier management.

🛠️ Commands

Build and start all services:

docker-compose up --build

Start services in detached mode (background):

docker-compose up -d

Stop services:

docker-compose down

Stop and remove volumes (⚠️ deletes MySQL data):

docker-compose down -v

Check logs:

docker-compose logs -f mcp-server
docker-compose logs -f mysql

🧪 4. Testing & Usage Guide

🔌 Test the API

Once services are up, test the MCP endpoint:

curl -X POST http://localhost:5005/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "function": "run_mysql_query",
    "arguments": {
      "sql": "SELECT 1 + 1 AS result"
    }
  }'

✅ Expected response:

{
  "status": "success",
  "columns": ["result"],
  "rows": [{"result": 2}],
  "row_count": 1,
  "message": "Query executed successfully"
}

📊 View MySQL Data

You can connect a GUI (e.g., MySQL Workbench, DBeaver) to localhost:3306 with:

  • Host: localhost
  • Port: 3306
  • User: root
  • Password: password
  • Database: test_db

🛡️ 5. Security & Production Considerations

Area Recommendation
Passwords Never hardcode in Dockerfile. Use Docker secrets or .env files.
Database Access Restrict root access. Use a dedicated app user with minimal privileges.
CORS Avoid allow_origins=["*"] in production. Specify allowed domains.
Logging Add structured logging for better debugging.
Health Checks Add health checks for mcp-server and mysql.
Updates Pin Python version (e.g., python:3.11.7-slim) for reproducibility.

Example .env file for secrets:

MYSQL_ROOT_PASSWORD=supersecret123
MYSQL_DATABASE=test_db

Then reference in docker-compose.yml using MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}.


Let me know if you'd like me to:

  • Add a health check to the Dockerfile
  • Generate a .env.example template
  • Include a Makefile for common commands
  • Add a README with quickstart instructions
Linux Rocks Every Day