Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
name: test (py${{ matrix.python-version }} / ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.12", "3.14"]
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install package and test deps
run: uv pip install --system -e . pytest

- name: Install OpenSSL 3 (Windows)
# _lbug.pyd links OpenSSL dynamically (libssl-3-x64.dll,
# libcrypto-3-x64.dll) and the wheel deliberately does not vendor
# those DLLs, so install the OpenSSL Light runtime.
# See https://github.com/LadybugDB/mcp-server-ladybug/issues/3
if: runner.os == 'Windows'
shell: pwsh
run: |
choco install openssl.light -y --no-progress
$bin = "C:\Program Files\OpenSSL\bin"
if (-not (Test-Path "$bin\libssl-3-x64.dll")) { throw "OpenSSL DLLs not found at $bin" }
$env:PATH = "$bin;$env:PATH"
Add-Content -Path $env:GITHUB_PATH -Value $bin
python -c "import ladybug._lbug; print('pybind backend loads OK')"

- name: Run tests
run: python -m pytest tests/ -v
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ description = "A MCP server for LadybugDB - an embedded graph database with Cyph
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.9.4",
"mcp>=1.9.4,<2",
"tabulate>=0.9.0",
"click>=8.1.8",
"starlette>=0.46.1",
"uvicorn>=0.34.0",
"anyio>=4.8.0",
"real-ladybug>=0.14.0",
"ladybug>=0.20.4",
]

[project.scripts]
Expand Down
63 changes: 48 additions & 15 deletions src/mcp_server_ladybug/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,51 @@ def __init__(
self._max_rows = max_rows
self._max_chars = max_chars
self.db_path = db_path
self.db = None
self.conn = self._initialize_connection()

def _initialize_connection(self):
"""Initialize connection to the Ladybug database"""
try:
import real_ladybug as lb
import ladybug as lb

logger.info(f"Connecting to Ladybug database at: {self.db_path}")
db = lb.Database(self.db_path)
conn = lb.Connection(db)
self.db = lb.Database(self.db_path)
conn = lb.Connection(self.db)

logger.info("Loading DuckDB extensions: json")
conn.execute("INSTALL json")
conn.execute("LOAD json")

logger.info("Loading DuckDB extensions: duckdb")
conn.execute("INSTALL duckdb")
conn.execute("LOAD duckdb")
self._load_extension(conn, "json")
self._load_extension(conn, "duckdb")

logger.info("Successfully connected to Ladybug database")
return conn
except ImportError:
raise ImportError(
"real_ladybug package is not available. "
"Please install it with: pip install real-ladybug"
"ladybug package is not available. "
"Please install it with: pip install ladybug"
)

def _load_extension(self, conn, name: str) -> None:
"""Best-effort INSTALL/LOAD of an optional extension.

Extension loading can fail (e.g. UnicodeDecodeError from `LOAD duckdb`
on Windows, no network for INSTALL, extension dropped for old
versions). These extensions are optional, so log a warning and let the
server start instead of crashing. See issue #3.
"""
for stmt in (f"INSTALL {name}", f"LOAD {name}"):
try:
conn.execute(stmt)
except Exception as e:
logger.warning(
"Optional extension '%s' not loaded (%s failed: %s). "
"Continuing without it.",
name,
stmt,
e,
)
return
logger.info("Extension '%s' loaded", name)

def _execute(self, query: str) -> str:
"""Execute a Cypher query and format results"""
logger.info(f"Executing query: {query[:100]}...")
Expand All @@ -58,7 +76,12 @@ def _execute(self, query: str) -> str:
if hasattr(res, "get_all"):
rows = res.get_all()
if rows:
if hasattr(res, "columns"):
if hasattr(res, "get_column_names"):
try:
headers = res.get_column_names()
except Exception:
headers = []
elif hasattr(res, "columns"):
headers = res.columns
else:
headers = []
Expand Down Expand Up @@ -109,5 +132,15 @@ def get_schema(self) -> str:

def close(self):
"""Close the database connection"""
if self.conn:
self.conn.close()
if getattr(self, "conn", None):
try:
self.conn.close()
except Exception:
pass
self.conn = None
if getattr(self, "db", None):
try:
self.db.close()
except Exception:
pass
self.db = None
107 changes: 107 additions & 0 deletions tests/test_database_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Regression tests for https://github.com/LadybugDB/mcp-server-ladybug/issues/3.

`LOAD duckdb` can fail (UnicodeDecodeError on Windows with older
real_ladybug, no network for INSTALL, extension dropped, ...). The json and
duckdb extensions are optional, so DatabaseClient must still start and serve
queries when they fail to load.
"""

import pathlib

import ladybug as lb

from mcp_server_ladybug.database import DatabaseClient

RealConnection = lb.Connection


class _FailingExtensionConnection:
"""Wraps the real Connection but fails the given statements."""

fail_statements: set = set()

def __init__(self, db):
self._real = RealConnection(db)

def execute(self, query):
if query.strip().upper() in self.fail_statements:
# Reproduce issue #3's Windows failure mode.
raise UnicodeDecodeError(
"utf-8", b"\xd5", 163, 164, "invalid continuation byte"
)
return self._real.execute(query)

def close(self):
return self._real.close()


def _make_failing_connection(*statements):
upper = {s.strip().upper() for s in statements}

class FailingConnection(_FailingExtensionConnection):
fail_statements = upper

return FailingConnection


def test_client_starts_when_load_duckdb_fails(monkeypatch):
"""Exact issue #3 scenario: INSTALL ok, LOAD duckdb raises UnicodeDecodeError."""
monkeypatch.setattr(
lb, "Connection", _make_failing_connection("LOAD duckdb")
)
client = DatabaseClient(db_path=":memory:")
try:
out = client.query(
"CREATE NODE TABLE Person (id INT64 PRIMARY KEY, name STRING);"
)
assert "Person" in out
out = client.query("MATCH (p:Person) RETURN p.id;")
assert "No rows" in out
finally:
client.close()


def test_client_starts_when_all_extension_installs_fail(monkeypatch):
"""Offline / dropped-extension scenario: every INSTALL/LOAD raises."""
monkeypatch.setattr(
lb,
"Connection",
_make_failing_connection(
"INSTALL json", "LOAD json", "INSTALL duckdb", "LOAD duckdb"
),
)
client = DatabaseClient(db_path=":memory:")
try:
assert "Person" in client.query(
"CREATE NODE TABLE Person (id INT64 PRIMARY KEY, name STRING);"
)
finally:
client.close()


def test_load_extension_helper_swallows_errors(caplog):
"""_load_extension logs a warning and returns instead of raising."""
client = DatabaseClient.__new__(DatabaseClient)

class Boom:
def execute(self, query):
raise RuntimeError("boom")

with caplog.at_level("WARNING", logger="mcp_server_ladybug"):
client._load_extension(Boom(), "duckdb") # must not raise

assert any(
"duckdb" in rec.message and "Continuing without it" in rec.message
for rec in caplog.records
)


def test_no_real_ladybug_references():
"""The deprecated real_ladybug package must not be referenced anymore."""
src = pathlib.Path(__file__).resolve().parents[1]
offenders = []
for path in list((src / "src").rglob("*.py")) + [src / "pyproject.toml"]:
text = path.read_text(encoding="utf-8")
if "real_ladybug" in text or "real-ladybug" in text:
offenders.append(str(path))
assert not offenders, f"real_ladybug references remain: {offenders}"
Loading