157 lines
5.0 KiB
Python
157 lines
5.0 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Sync MYSQL_* variables for @yclenove/mysql-mcp-server from NEXUS_DATABASE_URL in .env.
|
||
|
|
|
||
|
|
Usage (from project root):
|
||
|
|
python scripts/sync_mysql_mcp_env.py
|
||
|
|
python scripts/sync_mysql_mcp_env.py --env path/to/.env
|
||
|
|
|
||
|
|
Reads NEXUS_DATABASE_URL (mysql+aiomysql://user:pass@host:port/db) and upserts:
|
||
|
|
MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE
|
||
|
|
MYSQL_READONLY (default true; use --writable for local dev)
|
||
|
|
MYSQL_DATABASE_ALLOWLIST=<db>
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.parse import unquote
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parent.parent
|
||
|
|
if str(ROOT) not in sys.path:
|
||
|
|
sys.path.insert(0, str(ROOT))
|
||
|
|
URL_RE = re.compile(
|
||
|
|
r"^mysql\+(?:aiomysql|pymysql)://([^:]+):([^@]+)@([^:/]+)(?::(\d+))?/([^?\s#]+)",
|
||
|
|
re.IGNORECASE,
|
||
|
|
)
|
||
|
|
|
||
|
|
MCP_KEYS = [
|
||
|
|
"MYSQL_HOST",
|
||
|
|
"MYSQL_PORT",
|
||
|
|
"MYSQL_USER",
|
||
|
|
"MYSQL_PASSWORD",
|
||
|
|
"MYSQL_DATABASE",
|
||
|
|
"MYSQL_READONLY",
|
||
|
|
"MYSQL_DATABASE_ALLOWLIST",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def parse_database_url(url: str, *, readonly: bool = True) -> dict[str, str]:
|
||
|
|
m = URL_RE.match(url.strip())
|
||
|
|
if not m:
|
||
|
|
raise ValueError(
|
||
|
|
f"Cannot parse NEXUS_DATABASE_URL: {url!r}\n"
|
||
|
|
"Expected: mysql+aiomysql://user:pass@host:port/database"
|
||
|
|
)
|
||
|
|
user, password, host, port, database = m.groups()
|
||
|
|
return {
|
||
|
|
"MYSQL_HOST": host,
|
||
|
|
"MYSQL_PORT": port or "3306",
|
||
|
|
"MYSQL_USER": unquote(user),
|
||
|
|
"MYSQL_PASSWORD": unquote(password),
|
||
|
|
"MYSQL_DATABASE": unquote(database),
|
||
|
|
"MYSQL_READONLY": "true" if readonly else "false",
|
||
|
|
"MYSQL_DATABASE_ALLOWLIST": unquote(database),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def read_env_lines(path: Path) -> list[str]:
|
||
|
|
from server.utils.text_io import read_utf8_lf
|
||
|
|
|
||
|
|
if not path.exists():
|
||
|
|
return []
|
||
|
|
text = read_utf8_lf(path)
|
||
|
|
if not text:
|
||
|
|
return []
|
||
|
|
return [f"{line}\n" for line in text.splitlines()]
|
||
|
|
|
||
|
|
|
||
|
|
def find_nexus_database_url(lines: list[str]) -> str | None:
|
||
|
|
for line in lines:
|
||
|
|
s = line.strip()
|
||
|
|
if s.startswith("NEXUS_DATABASE_URL=") and not s.startswith("#"):
|
||
|
|
return s.split("=", 1)[1].strip()
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def upsert_mcp_block(lines: list[str], values: dict[str, str]) -> list[str]:
|
||
|
|
"""Remove existing MYSQL_* / MCP section lines, append fresh block."""
|
||
|
|
filtered: list[str] = []
|
||
|
|
skip_mcp_header = False
|
||
|
|
for line in lines:
|
||
|
|
stripped = line.strip()
|
||
|
|
if stripped.startswith("# ── MySQL MCP"):
|
||
|
|
skip_mcp_header = True
|
||
|
|
continue
|
||
|
|
if skip_mcp_header and (
|
||
|
|
stripped.startswith("#")
|
||
|
|
or any(stripped.startswith(f"{k}=") for k in MCP_KEYS)
|
||
|
|
or stripped == ""
|
||
|
|
):
|
||
|
|
if stripped.startswith("# ──") and "MySQL MCP" not in stripped:
|
||
|
|
skip_mcp_header = False
|
||
|
|
filtered.append(line)
|
||
|
|
continue
|
||
|
|
if any(stripped.startswith(f"{k}=") for k in MCP_KEYS):
|
||
|
|
continue
|
||
|
|
skip_mcp_header = False
|
||
|
|
filtered.append(line)
|
||
|
|
|
||
|
|
while filtered and filtered[-1].strip() == "":
|
||
|
|
filtered.pop()
|
||
|
|
if filtered and not filtered[-1].endswith("\n"):
|
||
|
|
filtered[-1] += "\n"
|
||
|
|
|
||
|
|
block = [
|
||
|
|
"\n",
|
||
|
|
"# ── MySQL MCP (@yclenove/mysql-mcp-server — auto-synced, do not commit secrets) ──\n",
|
||
|
|
]
|
||
|
|
for key in MCP_KEYS:
|
||
|
|
block.append(f"{key}={values[key]}\n")
|
||
|
|
filtered.extend(block)
|
||
|
|
return filtered
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description="Sync MYSQL_* for mysql-mcp-server from NEXUS_DATABASE_URL")
|
||
|
|
parser.add_argument("--env", type=Path, default=ROOT / ".env", help="Path to .env file")
|
||
|
|
mode = parser.add_mutually_exclusive_group()
|
||
|
|
mode.add_argument("--writable", action="store_true", help="MYSQL_READONLY=false (local dev)")
|
||
|
|
mode.add_argument("--readonly", action="store_true", help="MYSQL_READONLY=true (default)")
|
||
|
|
args = parser.parse_args()
|
||
|
|
readonly = not args.writable
|
||
|
|
env_path: Path = args.env.resolve()
|
||
|
|
|
||
|
|
lines = read_env_lines(env_path)
|
||
|
|
if not lines:
|
||
|
|
print(f"ERROR: {env_path} not found. Copy .env.example to .env or run install wizard first.", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
url = find_nexus_database_url(lines)
|
||
|
|
if not url:
|
||
|
|
print("ERROR: NEXUS_DATABASE_URL not found in .env", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
try:
|
||
|
|
values = parse_database_url(url, readonly=readonly)
|
||
|
|
except ValueError as e:
|
||
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
from server.utils.text_io import write_utf8_lf
|
||
|
|
|
||
|
|
updated = upsert_mcp_block(lines, values)
|
||
|
|
write_utf8_lf(env_path, "".join(updated))
|
||
|
|
print(f"OK: Updated {env_path}")
|
||
|
|
mode_label = "readonly" if values["MYSQL_READONLY"] == "true" else "writable"
|
||
|
|
print(
|
||
|
|
f" MySQL MCP → {values['MYSQL_USER']}@{values['MYSQL_HOST']}:"
|
||
|
|
f"{values['MYSQL_PORT']}/{values['MYSQL_DATABASE']} ({mode_label})"
|
||
|
|
)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|