From 959fbcccb20b7d5f803f8f93c71ea380e702cd7d Mon Sep 17 00:00:00 2001
From: "Chen, Li"
Date: Mon, 21 Sep 2026 18:08:31 +0800
Subject: [PATCH] Tools reference
---
.env.example | 34 +-
.gitignore | 2 +-
pyproject.toml | 4 +-
src/mini_db_mcp/config.py | 15 +-
src/mini_db_mcp/connection.py | 627 +++++++++++++++---
src/mini_db_mcp/db.py | 93 +++
src/mini_db_mcp/domain/domain_catalog.py | 171 +++++
src/mini_db_mcp/domain/domain_handler.py | 72 ++
.../domain/domain_handler_factory.py | 15 +
src/mini_db_mcp/domain/domain_manifest.py | 167 +++++
src/mini_db_mcp/domain/domain_model.py | 140 +++-
.../domain/handler/metadata_handler.py | 25 +
.../domain/handler/query_handler.py | 0
.../domain/runtime/metadata_runtime.py | 44 ++
src/mini_db_mcp/main.py | 29 +-
src/mini_db_mcp/security.py | 159 +++++
src/mini_db_mcp/server.py | 105 ++-
src/mini_db_mcp/tools.py | 114 ++++
src/mini_db_mcp/utils/json_util.py | 31 +
src/mini_db_mcp/utils/pagination.py | 38 ++
uv.lock | 122 ++++
21 files changed, 1861 insertions(+), 146 deletions(-)
create mode 100644 src/mini_db_mcp/db.py
create mode 100644 src/mini_db_mcp/domain/domain_catalog.py
create mode 100644 src/mini_db_mcp/domain/domain_handler.py
create mode 100644 src/mini_db_mcp/domain/domain_handler_factory.py
create mode 100644 src/mini_db_mcp/domain/domain_manifest.py
create mode 100644 src/mini_db_mcp/domain/handler/metadata_handler.py
create mode 100644 src/mini_db_mcp/domain/handler/query_handler.py
create mode 100644 src/mini_db_mcp/domain/runtime/metadata_runtime.py
create mode 100644 src/mini_db_mcp/security.py
create mode 100644 src/mini_db_mcp/tools.py
create mode 100644 src/mini_db_mcp/utils/json_util.py
create mode 100644 src/mini_db_mcp/utils/pagination.py
diff --git a/.env.example b/.env.example
index 7b59b4e..ae1fd0f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,23 +1,31 @@
# Override the .env file path (default: .env in working directory)
-# Database
-DB_HOST=127.0.0.1
-DB_PORT=3306
-DB_USER=root
+# --- Database ---
+DB_HOST=
+DB_PORT=
+DB_USER=
DB_PASSWORD=
-DB_DATABASE=
-DB_POOL_MIN=1
-DB_POOL_MAX=10
-DB_CONNECT_TIMEOUT=10.0
+DB_SCHEMA=
+DB_CONNECT_TIMEOUT=30
+DB_POOL_SIZE=5
+DB_MAX_OVERFLOW=10
+DB_POOL_TIMEOUT=30
+# Recycle before the server-side wait_timeout kicks in
+DB_POOL_RECYCLE=1800
+DB_POOL_PRE_PING=true
+# Connections opened eagerly right after an engine is created
+DB_WARMUP_SIZE=1
+DB_MAX_TOTAL_CONNECTIONS=20
+DB_HEALTH_CHECK_INTERVAL=30
-# Query
+# --- Query limits ---
QUERY_MAX_ROWS=1000
-QUERY_TIMEOUT_SECONDS=30.0
-# Set to true to allow INSERT / UPDATE / DELETE / DDL
+QUERY_MAX_BYTES=4000000
+QUERY_TIMEOUT_SECONDS=30
QUERY_ALLOW_WRITE=false
-# Server
+# --- MCP server ---
MCP_TRANSPORT=stdio
MCP_HOST=127.0.0.1
MCP_PORT=8000
-LOG_LEVEL=INFO
+LOG_LEVEL=INFO
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 24d331d..c3c2af4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,4 +22,4 @@ dist/
# Editors
.idea/
-docs/superpowers
\ No newline at end of file
+tests/
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 444c494..72e24f8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,13 +6,15 @@ build-backend = "hatchling.build"
name = "mini-db-mcp"
version = "0.1.0"
description = "A minimal database MCP server"
-readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"mcp>=2.0.0,<2.1.0",
"pydantic>=2.11,<3",
+ "sqlalchemy[asyncio]>=2.0.30",
"aiomysql>=0.3.0",
"pydantic-settings>=2.15.0",
+ "sqlglot>=27.0",
+ "uvicorn>=0.30,<1",
]
[project.scripts]
diff --git a/src/mini_db_mcp/config.py b/src/mini_db_mcp/config.py
index 6e05d93..d9af592 100644
--- a/src/mini_db_mcp/config.py
+++ b/src/mini_db_mcp/config.py
@@ -13,16 +13,25 @@ class DatabaseConfig(BaseSettings):
port: int = 3306
user: str = "root"
password: str = ""
- database: str = ""
- pool_min: int = 1
- pool_max: int = 10
+ schema: str = ""
connect_timeout: int = 30
+ pool_size: int = 5
+ max_overflow: int = 10
+ pool_timeout: int = 30
+ pool_recycle: int = 1800
+ pool_pre_ping: bool = True
+ # Physical connections opened eagerly right after an engine is created
+ warmup_size: int = 1
+ max_total_connections: int = 20
+ health_check_interval: int = 30
+
class QueryConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="QUERY_", env_file=_ENV_FILE, extra="ignore")
max_rows: int = 1000
+ max_bytes: int = 4_000_000
timeout_seconds: int = 30
allow_write: bool = False
diff --git a/src/mini_db_mcp/connection.py b/src/mini_db_mcp/connection.py
index eb50da1..16ed9a2 100644
--- a/src/mini_db_mcp/connection.py
+++ b/src/mini_db_mcp/connection.py
@@ -1,110 +1,569 @@
-"""Async MySQL connection manager with connection pool.
-
-Design mirrors DorisConnectionManager: a single shared pool, acquired via
-an async context manager, with an explicit ``close()`` for clean shutdown.
-"""
-
-from __future__ import annotations
-
+import asyncio
+import hashlib
import logging
-import re
-from collections.abc import AsyncGenerator, AsyncIterator
+import time
+from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager
-from typing import Any
+from dataclasses import dataclass, replace
+from enum import Enum
+from typing import Any, Sequence
-import aiomysql
-from aiomysql import Pool
+from sqlalchemy import URL, text
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
+
+from mini_db_mcp.config import Config, DatabaseConfig
+from mini_db_mcp.security import PermissionPolicy, SqlGuard, SqlValidationError, returns_rows
-from .config import Config
logger = logging.getLogger(__name__)
-# Only these SQL keywords are permitted when write access is disabled
-_READ_ONLY_KEYWORDS = frozenset(
- {"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"}
-)
-_FIRST_KEYWORD_RE = re.compile(r"--[^\n]*|/\*.*?\*/", re.DOTALL)
+
+HEALTH_CHECK_SQL = "SELECT 1"
+GLOBAL_PRINCIPAL = "__global__"
+DEFAULT_SESSION = "__anonymous__"
-def _first_sql_keyword(sql: str) -> str:
- stripped = _FIRST_KEYWORD_RE.sub("", sql).strip()
- return stripped.split(None, 1)[0].upper() if stripped else ""
+class CredentialKind(str, Enum):
+ GLOBAL = "global"
+
+
+class PoolUnavailableError(RuntimeError):
+ """Raised when the configured host cannot serve the request."""
+
+
+class StaleConnectionError(RuntimeError):
+ """Raised when a leased connection outlives its pool generation."""
+
+
+class CredentialError(RuntimeError):
+ """Base for credential resolution failures.
+
+ Deliberately not an OSError subclass: the leasing path turns OSError into a
+ pool invalidation, which must not be triggered by an auth problem.
+ """
+
+
+class UnknownSessionError(CredentialError):
+ """Raised when a session has no credential bound to it."""
+
+
+class CredentialExpiredError(CredentialError):
+ """Raised when the resolved credential is past its expiry."""
+
+
+@dataclass(frozen=True)
+class PoolKey:
+ kind: CredentialKind
+ principal: str
+ database: str
+
+ def __str__(self) -> str:
+ return f"{self.kind.value}:{self.principal}:{self.database or '-'}"
+
+
+@dataclass(frozen=True)
+class Credential:
+ """Identity used to build and isolate a pool."""
+
+ kind: CredentialKind
+ principal: str
+ username: str
+ secret: str
+ database: str | None = None
+ expires_at: float | None = None
+
+ @property
+ def pool_key(self) -> PoolKey:
+ return PoolKey(self.kind, self.principal, self.database or "")
+
+ def is_expired(self, now: float | None = None) -> bool:
+ return self.expires_at is not None and (now or time.time()) >= self.expires_at
+
+
+@dataclass
+class EngineEntry:
+ key: PoolKey
+ engine: AsyncEngine
+ generation: int
+ secret_fingerprint: str
+ created_at: float
+ last_used_at: float
+ last_healthy_at: float
+ expires_at: float | None = None
+
+
+@dataclass
+class QueryResult:
+ columns: list[str]
+ rows: list[dict[str, Any]]
+ row_count: int
+ truncated: bool = False
+ session_id: str = DEFAULT_SESSION
+ elapsed_ms: float = 0.0
+
+
+def bind_parameters(
+ sql: str, params: Mapping[str, Any] | Sequence[Any] | None
+) -> tuple[str, dict[str, Any]]:
+ """Normalise ``?``/``%s`` placeholders to the named binds SQLAlchemy wants."""
+ if params is None:
+ return sql, {}
+ if isinstance(params, Mapping):
+ return sql, dict(params)
+
+ values = tuple(params)
+ out: list[str] = []
+ bound: dict[str, Any] = {}
+ index = 0
+ cursor = 0
+ end = len(sql)
+ while cursor < end:
+ char = sql[cursor]
+ if char in "'\"`":
+ cursor = _copy_quoted(sql, cursor, out)
+ continue
+ if sql.startswith(("--", "#"), cursor):
+ stop = sql.find("\n", cursor)
+ stop = end if stop < 0 else stop
+ out.append(sql[cursor:stop])
+ cursor = stop
+ continue
+ if sql.startswith("/*", cursor):
+ stop = sql.find("*/", cursor)
+ stop = end if stop < 0 else stop + 2
+ out.append(sql[cursor:stop])
+ cursor = stop
+ continue
+ if char == "?" or sql.startswith("%s", cursor):
+ if index >= len(values):
+ raise SqlValidationError("More placeholders than parameters.")
+ name = f"p{index}"
+ bound[name] = values[index]
+ out.append(f":{name}")
+ index += 1
+ cursor += 1 if char == "?" else 2
+ continue
+ out.append(char)
+ cursor += 1
+
+ if index != len(values):
+ raise SqlValidationError(
+ f"Expected {index} parameters, received {len(values)}."
+ )
+ return "".join(out), bound
+
+
+def _copy_quoted(sql: str, start: int, out: list[str]) -> int:
+ quote = sql[start]
+ out.append(quote)
+ cursor = start + 1
+ end = len(sql)
+ while cursor < end:
+ char = sql[cursor]
+ if char == "\\" and quote != "`":
+ out.append(sql[cursor : cursor + 2])
+ cursor += 2
+ continue
+ out.append(char)
+ cursor += 1
+ if char == quote:
+ if cursor < end and sql[cursor] == quote:
+ out.append(quote)
+ cursor += 1
+ continue
+ break
+ return cursor
+
+
+def _row_bytes(row: Mapping[str, Any]) -> int:
+ return sum(len(str(value).encode("utf-8", "replace")) for value in row.values())
+
+
+def _fingerprint(secret: str) -> str:
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:16]
class ConnectionManager:
- """Holds the aiomysql connection pool and enforces write policy."""
+ """Owns one AsyncEngine per (credential kind, principal, database).
- def __init__(self, config: Config) -> None:
+ Every lease is tagged with the pool generation so a rebuilt pool can never
+ hand back a connection from the previous generation.
+ """
+
+ def __init__(
+ self,
+ config: Config,
+ *,
+ guard: SqlGuard | None = None,
+ policy: PermissionPolicy | None = None,
+ ) -> None:
self.config = config
- self._pool: Pool | None = None
+ self.db: DatabaseConfig = config.database
+ self.guard = guard or SqlGuard(
+ allow_write=config.query.allow_write,
+ max_rows=config.query.max_rows
+ )
+ self.policy = policy or PermissionPolicy(allow_write=config.query.allow_write)
+ self._entries: dict[PoolKey, EngineEntry] = {}
+ self._generations: dict[PoolKey, int] = {}
+ self._locks: dict[PoolKey, asyncio.Lock] = {}
+ self._sessions: dict[str, Credential] = {}
+ self._registry_lock = asyncio.Lock()
+ self._semaphore = asyncio.Semaphore(self.db.max_total_connections)
+ self._health_task: asyncio.Task[None] | None = None
+ self._closed = False
+
+
+ def global_credential(self) -> Credential:
+ return Credential(
+ kind=CredentialKind.GLOBAL,
+ principal=GLOBAL_PRINCIPAL,
+ username=self.db.user,
+ secret=self.db.password,
+ database=self.db.schema or None,
+ )
+
async def start(self) -> None:
- db = self.config.database
- self._pool = await aiomysql.create_pool(
- host=db.host,
- port=db.port,
- user=db.user,
- password=db.password,
- db=db.database or None,
- minsize=db.pool_min,
- maxsize=db.pool_max,
- connect_timeout=db.connect_timeout,
- autocommit=True,
- charset="utf8mb4",
- )
- logger.info(
- "Connection pool started: %s:%d db=%s",
- db.host,
- db.port,
- db.database or "(none)",
- )
+ await self._get_entry(self.global_credential())
+ if self.db.health_check_interval > 0:
+ self._health_task = asyncio.create_task(self._health_loop())
+
+
+ def bind_session(self, session_id: str, credential: Credential) -> None:
+ self._sessions[session_id] = credential
+
+
+ def unbind_session(self, session_id: str) -> None:
+ self._sessions.pop(session_id, None)
+
+
+ def _effective_credential(
+ self, session_id: str, auth_context: Credential | None
+ ) -> Credential:
+ credential = auth_context or self._sessions.get(session_id)
+ if credential is None:
+ raise UnknownSessionError(
+ f"Session {session_id} is not bound to a credential."
+ )
+ if credential.is_expired():
+ if self._sessions.get(session_id) is credential:
+ self.unbind_session(session_id)
+ raise CredentialExpiredError(
+ f"Credential for session {session_id} has expired."
+ )
+ return credential
async def close(self) -> None:
- if self._pool is not None:
- self._pool.close()
- await self._pool.wait_closed()
- self._pool = None
- logger.info("Connection pool closed")
+ self._closed = True
+ if self._health_task is not None:
+ self._health_task.cancel()
+ try:
+ await self._health_task
+ except asyncio.CancelledError:
+ pass
+ self._health_task = None
+ async with self._registry_lock:
+ entries = list(self._entries.values())
+ self._entries.clear()
+ for entry in entries:
+ await self._dispose(entry)
+
+ async def __aenter__(self) -> "ConnectionManager":
+ await self.start()
+ return self
+
+ async def __aexit__(self, *_exc: object) -> None:
+ await self.close()
+
+
+ def _build_url(self, credential: Credential) -> URL:
+ return URL.create(
+ "mysql+aiomysql",
+ username=credential.username,
+ password=credential.secret,
+ host=self.db.url,
+ port=self.db.port,
+ database=credential.database or None,
+ )
+
+ def _create_engine(self, credential: Credential) -> AsyncEngine:
+ return create_async_engine(
+ self._build_url(credential),
+ pool_size=self.db.pool_size,
+ max_overflow=self.db.max_overflow,
+ pool_timeout=self.db.pool_timeout,
+ pool_recycle=self.db.pool_recycle,
+ pool_pre_ping=self.db.pool_pre_ping,
+ # Lazy by design: pool_size is capacity, physical connections open on demand.
+ connect_args={"connect_timeout": self.db.connect_timeout},
+ )
+
+ async def _build_entry(self, credential: Credential) -> EngineEntry:
+ key = credential.pool_key
+ engine = self._create_engine(credential)
+ try:
+ async with engine.connect() as conn:
+ await conn.execute(text(HEALTH_CHECK_SQL))
+ except (SQLAlchemyError, OSError, asyncio.TimeoutError) as exc:
+ await engine.dispose()
+ raise PoolUnavailableError(
+ f"Database {self.db.url} did not accept connections for {key}: {exc}"
+ ) from exc
+
+ generation = self._generations.get(key, 0) + 1
+ self._generations[key] = generation
+ now = time.time()
+ logger.info("Pool ready key=%s node=%s generation=%s", key, self.db.url, generation)
+ return EngineEntry(
+ key=key,
+ engine=engine,
+ generation=generation,
+ secret_fingerprint=_fingerprint(credential.secret),
+ created_at=now,
+ last_used_at=now,
+ last_healthy_at=now,
+ expires_at=credential.expires_at,
+ )
+
+ async def _warmup(self, entry: EngineEntry) -> None:
+ count = min(self.db.warmup_size, self.db.pool_size)
+ if count <= 0:
+ return
+
+ async def _open() -> None:
+ async with entry.engine.connect() as conn:
+ await conn.execute(text(HEALTH_CHECK_SQL))
+
+ results = await asyncio.gather(
+ *(_open() for _ in range(count)), return_exceptions=True
+ )
+ for result in results:
+ if isinstance(result, BaseException):
+ logger.warning("Warmup connection failed for %s: %s", entry.key, result)
+
+ def _lock_for(self, key: PoolKey) -> asyncio.Lock:
+ lock = self._locks.get(key)
+ if lock is None:
+ lock = self._locks.setdefault(key, asyncio.Lock())
+ return lock
+
+ async def _get_entry(self, credential: Credential) -> EngineEntry:
+ if self._closed:
+ raise PoolUnavailableError("ConnectionManager is closed.")
+ if credential.is_expired():
+ raise CredentialExpiredError("Credential has expired.")
+
+ key = credential.pool_key
+ fingerprint = _fingerprint(credential.secret)
+ entry = self._entries.get(key)
+ if entry is not None and entry.secret_fingerprint == fingerprint:
+ entry.last_used_at = time.time()
+ return entry
+
+ async with self._lock_for(key):
+ entry = self._entries.get(key)
+ if entry is not None:
+ if entry.secret_fingerprint == fingerprint:
+ entry.last_used_at = time.time()
+ return entry
+ # The secret rotated behind an unchanged pool key.
+ logger.info("Rebuilding pool key=%s after secret rotation", key)
+ await self.invalidate(key)
+ entry = await self._build_entry(credential)
+ await self._warmup(entry)
+ self._entries[key] = entry
+ return entry
+
+ async def _dispose(self, entry: EngineEntry) -> None:
+ try:
+ await entry.engine.dispose()
+ except Exception as exc: # pragma: no cover - dispose is best effort
+ logger.warning("Failed to dispose pool %s: %s", entry.key, exc)
+
+ async def invalidate(self, key: PoolKey) -> None:
+ """Bump the generation and drop the pool; the next lease rebuilds it."""
+ async with self._registry_lock:
+ entry = self._entries.pop(key, None)
+ if entry is None:
+ return
+ self._generations[key] = entry.generation + 1
+ logger.info("Invalidated pool key=%s generation=%s", key, entry.generation)
+ await self._dispose(entry)
- def _assert_write_allowed(self, sql: str) -> None:
- keyword = _first_sql_keyword(sql)
- if not self.config.query.allow_write and keyword not in _READ_ONLY_KEYWORDS:
- raise PermissionError(
- f"Write operations are disabled (first keyword: {keyword!r}). "
- "Set QUERY_ALLOW_WRITE=true to enable DML/DDL."
- )
@asynccontextmanager
- async def acquire(self) -> AsyncIterator[aiomysql.Connection]:
- if self._pool is None:
- raise RuntimeError("Connection pool is not started")
- async with self._pool.acquire() as conn:
- yield conn
+ async def connect(self, credential: Credential) -> AsyncGenerator[AsyncConnection]:
+ entry = await self._get_entry(credential)
+ generation = entry.generation
+ async with self._semaphore:
+ try:
+ async with entry.engine.connect() as conn:
+ if self._generations.get(entry.key) != generation:
+ raise StaleConnectionError(
+ f"Connection for {entry.key} belongs to generation "
+ f"{generation}, current is {self._generations.get(entry.key)}."
+ )
+ entry.last_used_at = time.time()
+ yield conn
+ except (SQLAlchemyError, OSError) as exc:
+ await self.invalidate(entry.key)
+ raise PoolUnavailableError(
+ f"Query failed on {self.db.url}: {exc}"
+ ) from exc
+
+ @staticmethod
+ async def _stream_rows(
+ conn: AsyncConnection,
+ statement: str,
+ bound: dict[str, Any],
+ row_cap: int,
+ byte_cap: int,
+ ) -> tuple[list[str], list[dict[str, Any]], bool]:
+ """Read through a server-side cursor, stopping as soon as a budget is hit."""
+ result = await conn.stream(text(statement), bound)
+ columns = list(result.keys())
+ rows: list[dict[str, Any]] = []
+ truncated = False
+ total = 0
+ try:
+ async for row in result:
+ if len(rows) >= row_cap:
+ truncated = True
+ break
+ item = dict(zip(columns, row))
+ total += _row_bytes(item)
+ if total > byte_cap:
+ truncated = True
+ break
+ rows.append(item)
+ finally:
+ await result.close()
+ return columns, rows, truncated
+
+
+ async def execute_query(
+ self,
+ session_id: str,
+ sql: str,
+ params: Mapping[str, Any] | Sequence[Any] | None = None,
+ auth_context: Credential | None = None,
+ *,
+ max_rows: int | None = None,
+ max_bytes: int | None = None,
+ ) -> QueryResult:
+ """Validate, route and run one statement; the lease is always released."""
+ credential = self._effective_credential(session_id, auth_context)
+ limits = self.config.query
+ row_cap = min(max_rows or limits.max_rows, limits.max_rows)
+ byte_cap = min(max_bytes or limits.max_bytes, limits.max_bytes)
+
+ statement, bound = bind_parameters(sql, params)
+ guard = (
+ self.guard
+ if row_cap == self.guard.max_rows
+ else replace(self.guard, max_rows=row_cap)
+ )
+ statement = guard.validate(statement)
+ self.policy.check(statement, credential.database or self.db.schema)
+
+ started = time.perf_counter()
+ try:
+ async with self.connect(credential) as conn:
+ async with asyncio.timeout(limits.timeout_seconds):
+ if not returns_rows(statement):
+ result = await conn.execute(text(statement), bound)
+ await conn.commit()
+ return QueryResult(
+ columns=[],
+ rows=[],
+ row_count=result.rowcount,
+ session_id=session_id,
+ elapsed_ms=(time.perf_counter() - started) * 1000,
+ )
+ columns, rows, truncated = await self._stream_rows(
+ conn, statement, bound, row_cap, byte_cap
+ )
+ except Exception as exc:
+ logger.error(
+ "Query failed session=%s principal=%s: %s",
+ session_id,
+ credential.principal,
+ exc,
+ )
+ raise
+
+ return QueryResult(
+ columns=columns,
+ rows=rows,
+ row_count=len(rows),
+ truncated=truncated,
+ session_id=session_id,
+ elapsed_ms=(time.perf_counter() - started) * 1000,
+ )
async def execute(
self,
sql: str,
- args: tuple[Any, ...] | None = None,
+ params: Mapping[str, Any] | Sequence[Any] | None = None,
*,
- database: str | None = None,
- max_rows: int | None = None,
- ) -> dict[str, Any]:
- """Execute *sql* and return a structured result dict."""
- self._assert_write_allowed(sql)
- limit = max_rows if max_rows is not None else self.config.query.max_rows
- async with self.acquire() as conn:
- if database:
- await conn.select_db(database)
- async with conn.cursor(aiomysql.DictCursor) as cur:
- await cur.execute(sql, args)
- rows = await cur.fetchmany(limit + 1)
- truncated = len(rows) > limit
- if truncated:
- rows = rows[:limit]
- return {
- "columns": [d[0] for d in cur.description] if cur.description else [],
- "rows": [dict(r) for r in rows],
- "row_count": len(rows),
- "truncated": truncated,
- "max_rows": limit,
- }
+ credential: Credential | None = None,
+ ) -> QueryResult:
+ return await self.execute_query(
+ DEFAULT_SESSION, sql, params, credential or self.global_credential()
+ )
+
+ # ------------------------------------------------------------ health checks
+
+ async def health_check(self, entry: EngineEntry) -> bool:
+ try:
+ async with entry.engine.connect() as conn:
+ await asyncio.wait_for(
+ conn.execute(text(HEALTH_CHECK_SQL)),
+ timeout=self.db.connect_timeout,
+ )
+ except (SQLAlchemyError, OSError, asyncio.TimeoutError) as exc:
+ logger.warning("Health check failed key=%s: %s", entry.key, exc)
+ return False
+ entry.last_healthy_at = time.time()
+ return True
+
+ async def _health_loop(self) -> None:
+ while not self._closed:
+ try:
+ await asyncio.sleep(self.db.health_check_interval)
+ await self._sweep()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc: # pragma: no cover - loop must survive
+ logger.exception("Health sweep failed: %s", exc)
+
+
+ async def _sweep(self) -> None:
+ now = time.time()
+ for session_id in [
+ sid for sid, cred in self._sessions.items() if cred.is_expired(now)
+ ]:
+ self.unbind_session(session_id)
+
+ async with self._registry_lock:
+ entries = list(self._entries.values())
+
+ for entry in entries:
+ if entry.expires_at is not None and now >= entry.expires_at:
+ reason = "credential expired"
+ elif not await self.health_check(entry):
+ reason = "unhealthy"
+ else:
+ continue
+ logger.info("Recycling pool key=%s reason=%s", entry.key, reason)
+ await self.invalidate(entry.key)
+
+ # Keep the global pool hot instead of waiting for the next request.
+ global_credential = self.global_credential()
+ if global_credential.pool_key not in self._entries and not self._closed:
+ try:
+ await self._get_entry(global_credential)
+ except PoolUnavailableError as exc:
+ logger.error("Global pool rebuild failed: %s", exc)
diff --git a/src/mini_db_mcp/db.py b/src/mini_db_mcp/db.py
new file mode 100644
index 0000000..20d7f4c
--- /dev/null
+++ b/src/mini_db_mcp/db.py
@@ -0,0 +1,93 @@
+from typing import Any
+
+from sqlalchemy import URL, inspect
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+from mini_db_mcp.config import Config
+
+
+class Database:
+ def __init__(self, settings: Config):
+ self.settings = settings
+ self.database = settings.database
+ url = URL.create(
+ "mysql+aiomysql",
+ username=self.database.user,
+ password=self.database.password,
+ host=self.database.host,
+ port=self.database.port,
+ database=self.database.schema or None,
+ )
+ self.engine: AsyncEngine = create_async_engine(
+ url,
+ pool_size=self.database.pool_size,
+ max_overflow=self.database.max_overflow,
+ pool_timeout=self.database.pool_timeout,
+ pool_recycle=self.database.pool_recycle,
+ pool_pre_ping=self.database.pool_pre_ping,
+ connect_args={"connect_timeout": self.database.connect_timeout},
+ )
+
+ async def close(self) -> None:
+ await self.engine.dispose()
+
+
+ async def list_tables(
+ self,
+ *,
+ keyword: str | None,
+ limit: int,
+ cursor: str | None,
+ ) -> tuple[list[dict[str, Any]], str | None]:
+ """Return one name-sorted page of tables and views.
+
+ ``keyword`` filters object names case-insensitively. ``cursor`` must be
+ the last object name returned by the previous page. The returned cursor
+ is ``None`` when no additional objects remain.
+
+ Raises:
+ ValueError: If the cursor is not present in the filtered result.
+ """
+ schema = self.settings.database.schema or None
+
+ def inspect_tables(sync_connection):
+ inspector = inspect(sync_connection)
+ table_names = set(inspector.get_table_names(schema=schema))
+ try:
+ view_names = set(inspector.get_view_names(schema=schema))
+ except NotImplementedError:
+ view_names = set()
+ return table_names, view_names
+
+ async with self.engine.connect() as connection:
+ table_names, view_names = await connection.run_sync(inspect_tables)
+
+ keyword_cf = keyword.casefold() if keyword else None
+ names = sorted(
+ name
+ for name in table_names | view_names
+ if keyword_cf is None or keyword_cf in name.casefold()
+ )
+
+ start = 0
+ if cursor is not None:
+ try:
+ start = names.index(cursor) + 1
+ except ValueError as exc:
+ raise ValueError("invalid pagination cursor") from exc
+
+ page_names = names[start : start + limit]
+ next_cursor = page_names[-1] if start + len(page_names) < len(names) else None
+
+ objects: list[dict[str, Any]] = []
+ for name in page_names:
+ object_type = "view" if name in view_names else "table"
+ objects.append(
+ {
+ "schema_name": schema,
+ "object_name": name,
+ "object_type": object_type,
+ "description": None,
+ }
+ )
+ return objects, next_cursor
diff --git a/src/mini_db_mcp/domain/domain_catalog.py b/src/mini_db_mcp/domain/domain_catalog.py
new file mode 100644
index 0000000..9b744f5
--- /dev/null
+++ b/src/mini_db_mcp/domain/domain_catalog.py
@@ -0,0 +1,171 @@
+from typing import Annotated, Any
+from pydantic import Field
+
+from mini_db_mcp.domain.domain_model import DomainBaseModel, DomainDefinition, ChildToolDefinition
+
+
+class DomainCatalog(DomainBaseModel):
+ """Catalog for top-domains and child tools."""
+
+ domains: Annotated[tuple[DomainDefinition, ...], Field(min_length=1, max_length=12)]
+
+
+def _input_schema(
+ properties: dict[str, Any],
+ *,
+ required: tuple[str, ...] = (),
+ any_of: tuple[dict[str, Any], ...] = (),
+ one_of: tuple[dict[str, Any], ...] = (),
+) -> dict[str, Any]:
+ schema: dict[str, Any] = {
+ "type": "object",
+ "properties": properties,
+ "additionalProperties": False,
+ }
+ if required:
+ schema["required"] = list(required)
+ if any_of:
+ schema["anyOf"] = list(any_of)
+ if one_of:
+ schema["oneOf"] = list(one_of)
+ return schema
+
+
+
+_DETAIL_OUTPUT = {"type": "object", "additionalProperties": True}
+
+_LIST_TABLES_OUTPUT = {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "schema_name": {"type": ["string", "null"]},
+ "object_name": {"type": "string"},
+ "object_type": {"type": "string", "enum": ["table", "view"]},
+ "description": {"type": ["string", "null"]},
+ },
+ "required": [
+ "schema_name",
+ "object_name",
+ "object_type",
+ "description",
+ ],
+ "additionalProperties": False,
+ },
+ },
+ "next_cursor": {
+ "type": ["string", "null"],
+ "description": "Cursor for the next page, or null when no more objects remain.",
+ },
+ "truncated": {"type": "boolean"},
+ },
+ "required": ["items", "next_cursor", "truncated"],
+ "additionalProperties": False,
+}
+
+
+def _domain(
+ *,
+ name: str,
+ title: str,
+ description: str,
+ children: tuple[ChildToolDefinition, ...],
+) -> DomainDefinition:
+ return DomainDefinition(
+ domain_name=name,
+ domain_title=title,
+ domain_description=description,
+ children=children
+ )
+
+
+def _child(
+ *,
+ domain: str,
+ name: str,
+ title: str,
+ description: str,
+ input_schema: dict[str, Any],
+ output_schema: dict[str, Any] = _DETAIL_OUTPUT,
+) -> ChildToolDefinition:
+ return ChildToolDefinition(
+ tool_name=name,
+ tool_title=title,
+ tool_description=description,
+ input_schema=input_schema,
+ output_schema=output_schema,
+ )
+
+
+def _string(
+ description: str,
+ *,
+ enum: tuple[str, ...] | None = None,
+ pattern: str | None = None,
+ max_length: int | None = None,
+) -> dict[str, Any]:
+ schema: dict[str, Any] = {"type": "string", "description": description}
+ if enum is not None:
+ schema["enum"] = list(enum)
+ if pattern is not None:
+ schema["pattern"] = pattern
+ if max_length is not None:
+ schema["maxLength"] = max_length
+ return schema
+
+
+def _string_array(
+ description: str,
+ *,
+ enum: tuple[str, ...] | None = None,
+ min_items: int = 1,
+) -> dict[str, Any]:
+ item_schema: dict[str, Any] = {"type": "string"}
+ if enum is not None:
+ item_schema["enum"] = list(enum)
+ return {
+ "type": "array",
+ "description": description,
+ "items": item_schema,
+ "minItems": min_items,
+ "uniqueItems": True,
+ }
+
+
+DOMAIN_DEFINITIONS = (
+ _domain(
+ name="metadata",
+ title="Metadata",
+ description="Provides semantic metadata and a catalog of available business entities.",
+ children=(
+ _child(
+ domain="metadata",
+ name="list_tables",
+ title="List Tables",
+ description="Lists authorized database tables and views available for querying.",
+ input_schema=_input_schema(
+ {
+ "keyword": _string("Optional case-insensitive object-name filter."),
+ "cursor": {
+ "type": ["string", "null"],
+ "description": "Cursor returned by the previous page; omit for the first page.",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of matching objects to return.",
+ "minimum": 1,
+ "default": 100,
+ },
+ },
+ ),
+ output_schema=_LIST_TABLES_OUTPUT,
+ ),
+ ),
+ ),
+)
+
+
+DOMAIN_CATALOG = DomainCatalog(domains=DOMAIN_DEFINITIONS)
\ No newline at end of file
diff --git a/src/mini_db_mcp/domain/domain_handler.py b/src/mini_db_mcp/domain/domain_handler.py
new file mode 100644
index 0000000..c495b41
--- /dev/null
+++ b/src/mini_db_mcp/domain/domain_handler.py
@@ -0,0 +1,72 @@
+from collections.abc import Awaitable, Callable, Iterable
+from typing import Any, Protocol
+
+
+ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
+
+
+class HandlerRegistryError(ValueError):
+ """Raised when handler providers contain an invalid binding."""
+
+
+class HandlerArgumentsError(ValueError):
+ """Raised when a handler cannot represent validated domain arguments."""
+
+
+class HandlerProvider(Protocol):
+ """Expose the child tools owned by one domain."""
+
+ domain_name: str
+
+ @property
+ def handler_names(self) -> frozenset[str]: ...
+
+ def resolve_handler(self, name: str) -> ToolHandler | None: ...
+
+
+class DomainHandlerRegistry:
+ """Resolve a domain first, then one of its child tools."""
+
+ def __init__(
+ self,
+ providers: Iterable[HandlerProvider] = (),
+ ) -> None:
+ providers_by_domain: dict[str, HandlerProvider] = {}
+ self._providers = tuple(providers)
+ for provider in self._providers:
+ if provider.domain_name in providers_by_domain:
+ raise HandlerRegistryError(
+ f"Duplicate domain handler: {provider.domain_name}"
+ )
+ for name in provider.handler_names:
+ handler = provider.resolve_handler(name)
+ if handler is None:
+ raise HandlerRegistryError(
+ f"Domain {provider.domain_name} declares missing handler: {name}"
+ )
+ providers_by_domain[provider.domain_name] = provider
+ self._providers_by_domain = providers_by_domain
+
+ def resolve_domain(self, domain_name: str) -> HandlerProvider | None:
+ return self._providers_by_domain.get(domain_name)
+
+ def resolve_handler(
+ self,
+ domain_name: str,
+ child_tool: str,
+ ) -> ToolHandler | None:
+ provider = self.resolve_domain(domain_name)
+ return provider.resolve_handler(child_tool) if provider is not None else None
+
+ def has_handler(self, domain_name: str, child_tool: str) -> bool:
+ """Return whether the current runtime can resolve one handler."""
+ return self.resolve_handler(domain_name, child_tool) is not None
+
+
+__all__ = [
+ "ToolHandler",
+ "HandlerRegistryError",
+ "HandlerArgumentsError",
+ "HandlerProvider",
+ "DomainHandlerRegistry"
+]
diff --git a/src/mini_db_mcp/domain/domain_handler_factory.py b/src/mini_db_mcp/domain/domain_handler_factory.py
new file mode 100644
index 0000000..40f625b
--- /dev/null
+++ b/src/mini_db_mcp/domain/domain_handler_factory.py
@@ -0,0 +1,15 @@
+from mini_db_mcp.domain.domain_handler import DomainHandlerRegistry
+from mini_db_mcp.domain.handler.metadata_handler import MetadataHandler
+from mini_db_mcp.domain.runtime.metadata_runtime import MetadataRuntime, MetadataStore
+
+
+def create_domain_registry(*,
+ metadata_store: MetadataStore
+ ) -> DomainHandlerRegistry:
+ """Build the domain runtimes, handlers, and registry."""
+ metadata_runtime = MetadataRuntime(metadata_store)
+ return DomainHandlerRegistry(
+ [
+ MetadataHandler(metadata_runtime)
+ ]
+ )
diff --git a/src/mini_db_mcp/domain/domain_manifest.py b/src/mini_db_mcp/domain/domain_manifest.py
new file mode 100644
index 0000000..1540600
--- /dev/null
+++ b/src/mini_db_mcp/domain/domain_manifest.py
@@ -0,0 +1,167 @@
+from typing import Any
+from mcp.types import Tool, ToolAnnotations
+
+from mini_db_mcp.domain.domain_catalog import DomainCatalog
+from mini_db_mcp.domain.domain_model import DomainDefinition, DomainErrorCode
+
+
+DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX = (
+ " Choose one matching domain; do not speculatively open others. Call with "
+ "an empty object to discover its exact child tools and schemas, then call "
+ "it again with the returned child_tool and child_arguments."
+)
+
+
+_DOMAIN_MANIFEST_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string", "const": "manifest"},
+ "domain": {"type": "string"},
+ "generated_at": {"type": "string", "format": "date-time"},
+ "children": {
+ "type": "array",
+ "items": {"type": "object"},
+ },
+ },
+ "required": [
+ "mode",
+ "domain",
+ "generated_at",
+ "children",
+ ],
+ "additionalProperties": False,
+}
+
+
+_DOMAIN_EXECUTION_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string", "const": "result"},
+ "domain": {"type": "string"},
+ "child_tool": {"type": "string"},
+ "data": {"type": "object"},
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "request_id": {"type": "string"},
+ "duration_ms": {"type": "number", "minimum": 0},
+ "source": {"type": "string"},
+ "truncated": {"type": "boolean"},
+ },
+ "required": [
+ "request_id",
+ "duration_ms",
+ "source",
+ "truncated",
+ ],
+ "additionalProperties": False,
+ },
+ "warnings": {
+ "type": "array",
+ "items": {"type": "string"},
+ },
+ },
+ "required": [
+ "mode",
+ "domain",
+ "child_tool",
+ "data",
+ "metadata",
+ "warnings",
+ ],
+ "additionalProperties": False,
+}
+
+
+DOMAIN_ERROR_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string", "const": "error"},
+ "domain": {"type": "string", "minLength": 1},
+ "child_tool": {"type": "string", "minLength": 1},
+ "error": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "enum": [code.value for code in DomainErrorCode],
+ },
+ "message": {"type": "string", "minLength": 1},
+ "retryable": {"type": "boolean"},
+ "details": {
+ "type": "object",
+ "additionalProperties": True,
+ },
+ },
+ "required": ["code", "message", "retryable", "details"],
+ "additionalProperties": False,
+ },
+ },
+ "required": ["mode", "domain", "child_tool", "error"],
+ "additionalProperties": False,
+}
+
+
+DOMAIN_TOOL_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "child_tool": {
+ "type": "string",
+ "minLength": 1,
+ "description": (
+ "Exact child tool name returned by this domain manifest."
+ ),
+ },
+ "child_arguments": {
+ "type": "object",
+ "description": (
+ "Arguments validated against the selected child tool schema."
+ ),
+ },
+ },
+ "additionalProperties": False,
+}
+
+
+DOMAIN_TOOL_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "oneOf": [
+ _DOMAIN_MANIFEST_OUTPUT_SCHEMA,
+ _DOMAIN_EXECUTION_OUTPUT_SCHEMA,
+ DOMAIN_ERROR_OUTPUT_SCHEMA,
+ ],
+}
+
+
+class DomainManifest():
+ """Expose top-level domain."""
+
+ def __init__(self,
+ *,
+ catalog: DomainCatalog
+ ) -> None:
+ self._catalog = catalog
+ self._domain_names = frozenset(
+ domain.domain_name for domain in self._catalog.domains
+ )
+ self._domain_tools = tuple(
+ _build_top_level_tool(domain) for domain in self._catalog.domains
+ )
+
+
+ async def list_tools(self) -> list[Tool]:
+ return list(self._domain_tools)
+
+
+def _build_top_level_tool(domain: DomainDefinition) -> Tool:
+ return Tool(
+ name=domain.domain_name,
+ title=domain.domain_title,
+ description=domain.domain_description + DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX,
+ input_schema=DOMAIN_TOOL_INPUT_SCHEMA,
+ output_schema=DOMAIN_TOOL_OUTPUT_SCHEMA,
+ annotations=ToolAnnotations(
+ title=domain.domain_title,
+ read_only_hint=True
+ ),
+ )
\ No newline at end of file
diff --git a/src/mini_db_mcp/domain/domain_model.py b/src/mini_db_mcp/domain/domain_model.py
index 0df4a51..372e61a 100644
--- a/src/mini_db_mcp/domain/domain_model.py
+++ b/src/mini_db_mcp/domain/domain_model.py
@@ -1,13 +1,55 @@
+from enum import StrEnum
import json
-from typing import Annotated, Any, Literal, Self, cast, TypedDict
-
+from typing import Annotated, Any, Literal, Self, cast
from pydantic import (
BaseModel,
ConfigDict,
- JsonValue
+ JsonValue,
+ StringConstraints,
+ Field,
+ model_validator,
+ field_validator,
+ field_serializer
)
-JsonObject = dict[str, JsonValue]
+from mini_db_mcp.utils.json_util import JsonObject, to_json_value
+
+
+MAX_DOMAIN_MANIFEST_BYTES = 16 * 1024
+
+
+Identity = Annotated[
+ str,
+ StringConstraints(
+ min_length=1,
+ max_length=128,
+ pattern=r"^[a-z][a-z0-9_]*$",
+ strip_whitespace=True,
+ ),
+]
+
+NonEmptyText = Annotated[
+ str,
+ StringConstraints(
+ min_length=1,
+ max_length=128,
+ strip_whitespace=True,
+ ),
+]
+
+
+class DomainErrorCode(StrEnum):
+ """Stable errors returned by hierarchical domain tools."""
+
+ DOMAIN_DISCOVERY_DENIED = "DOMAIN_DISCOVERY_DENIED"
+ CHILD_TOOL_NOT_FOUND = "CHILD_TOOL_NOT_FOUND"
+ CHILD_MANIFEST_STALE = "CHILD_MANIFEST_STALE"
+ CHILD_ARGUMENTS_INVALID = "CHILD_ARGUMENTS_INVALID"
+ CHILD_RESULT_INVALID = "CHILD_RESULT_INVALID"
+ CHILD_CAPABILITY_UNAVAILABLE = "CHILD_CAPABILITY_UNAVAILABLE"
+ CHILD_CONFIRMATION_REQUIRED = "CHILD_CONFIRMATION_REQUIRED"
+ CHILD_EXECUTION_TIMEOUT = "CHILD_EXECUTION_TIMEOUT"
+ CHILD_EXECUTION_FAILED = "CHILD_EXECUTION_FAILED"
class DomainBaseModel(BaseModel):
@@ -20,8 +62,9 @@ class DomainBaseModel(BaseModel):
validate_default=True,
)
- def to_wire(self) -> JsonObject:
+ def to_json_object(self) -> JsonObject:
"""Return a JSON-compatible representation with protocol aliases."""
+
return cast(
JsonObject,
self.model_dump(
@@ -33,10 +76,95 @@ class DomainBaseModel(BaseModel):
def to_canonical_json(self) -> str:
"""Serialize deterministically for manifests, tests, and hashing."""
+
return json.dumps(
- self.to_wire(),
+ self.to_json_object(),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
+
+class DomainDefinition(DomainBaseModel):
+ """Top-level domain definition."""
+
+ domain_name: Identity
+ domain_title: NonEmptyText
+ domain_description: NonEmptyText
+ children: Annotated[
+ tuple[ChildToolDefinition, ...],
+ Field(min_length=1, max_length=12),
+ ]
+ max_manifest_bytes: Annotated[
+ int,
+ Field(ge=1, le=MAX_DOMAIN_MANIFEST_BYTES),
+ ] = MAX_DOMAIN_MANIFEST_BYTES
+
+ @model_validator(mode="after")
+ def _validate_domain(self) -> Self:
+ _require_unique(
+ tuple(child.tool_name for child in self.children),
+ "child tool names",
+ )
+ return self
+
+
+class ChildToolDefinition(DomainBaseModel):
+ """Exact child tool of a domain."""
+
+ tool_name: Identity
+ tool_title: NonEmptyText
+ tool_description: NonEmptyText
+ input_schema: JsonObject
+ output_schema: JsonObject
+
+
+class DomainToolRequest(DomainBaseModel):
+ """Common request shape for discovery and exact child execution."""
+
+ child_tool: Identity | None = None
+ child_arguments: JsonObject | None = None
+
+ @field_validator("child_arguments", mode="before")
+ @classmethod
+ def _normalize_arguments_input(cls, value: Any) -> JsonValue:
+ _reject_tuples(value)
+ return to_json_value(value)
+
+ @field_serializer("child_arguments")
+ def _serialize_arguments(self, value: JsonObject | None) -> JsonObject | None:
+ return cast(JsonObject | None, to_json_value(value))
+
+ @model_validator(mode="after")
+ def _validate_request_mode(self) -> Self:
+ if self.child_tool is None and self.child_arguments is not None:
+ raise ValueError("Arguments require child_tool")
+ return self
+
+ @property
+ def is_discovery(self) -> bool:
+ return self.child_tool is None
+
+ @property
+ def execution_arguments(self) -> JsonObject:
+ return cast(
+ JsonObject,
+ to_json_value(self.child_arguments or {}),
+ )
+
+
+def _require_unique(values: tuple[str, ...], field_name: str) -> None:
+ if len(values) != len(set(values)):
+ raise ValueError(f"{field_name} must not contain duplicates")
+
+
+def _reject_tuples(value: Any) -> None:
+ if isinstance(value, tuple):
+ raise ValueError("JSON arrays must be provided as lists")
+ if isinstance(value, dict):
+ for item in value.values():
+ _reject_tuples(item)
+ elif isinstance(value, list):
+ for item in value:
+ _reject_tuples(item)
+
diff --git a/src/mini_db_mcp/domain/handler/metadata_handler.py b/src/mini_db_mcp/domain/handler/metadata_handler.py
new file mode 100644
index 0000000..c3002b8
--- /dev/null
+++ b/src/mini_db_mcp/domain/handler/metadata_handler.py
@@ -0,0 +1,25 @@
+from typing import Mapping
+from types import MappingProxyType
+
+from mini_db_mcp.domain.domain_handler import ToolHandler
+from mini_db_mcp.domain.runtime.metadata_runtime import MetadataRuntime
+
+
+class MetadataHandler:
+
+ domain_name = "metadata"
+
+
+ def __init__(self, runtime: MetadataRuntime) -> None:
+ _handlers = {
+ "list_tables": runtime.list_tables,
+ }
+ self._handlers: Mapping[str, ToolHandler] = MappingProxyType(_handlers)
+
+
+ @property
+ def handler_names(self) -> frozenset[str]:
+ return frozenset(self._handlers)
+
+ def resolve_handler(self, name: str) -> ToolHandler | None:
+ return self._handlers.get(name)
\ No newline at end of file
diff --git a/src/mini_db_mcp/domain/handler/query_handler.py b/src/mini_db_mcp/domain/handler/query_handler.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/mini_db_mcp/domain/runtime/metadata_runtime.py b/src/mini_db_mcp/domain/runtime/metadata_runtime.py
new file mode 100644
index 0000000..9305de0
--- /dev/null
+++ b/src/mini_db_mcp/domain/runtime/metadata_runtime.py
@@ -0,0 +1,44 @@
+from typing import Any, Protocol
+
+
+class MetadataStore(Protocol):
+ """Database capabilities required by the metadata runtime."""
+
+ async def list_tables(
+ self,
+ *,
+ keyword: str | None,
+ limit: int,
+ cursor: str | None,
+ ) -> tuple[list[dict[str, Any]], str | None]: ...
+
+
+class MetadataRuntime:
+ """Runtime for metadata domain."""
+
+ def __init__(self, store: MetadataStore) -> None:
+ self._store = store
+
+ async def list_tables(self, arguments: dict[str, Any]) -> dict[str, Any]:
+ keyword = arguments.get("keyword")
+ if keyword is not None and not isinstance(keyword, str):
+ raise ValueError("keyword must be a string or null")
+
+ cursor = arguments.get("cursor")
+ if cursor is not None and not isinstance(cursor, str):
+ raise ValueError("cursor must be a string or null")
+
+ limit = arguments.get("limit", 100)
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1:
+ raise ValueError("limit must be a positive integer")
+
+ items, next_cursor = await self._store.list_tables(
+ keyword=keyword,
+ limit=limit,
+ cursor=cursor,
+ )
+ return {
+ "items": items,
+ "next_cursor": next_cursor,
+ "truncated": next_cursor is not None,
+ }
\ No newline at end of file
diff --git a/src/mini_db_mcp/main.py b/src/mini_db_mcp/main.py
index 939a655..c9a3fd4 100644
--- a/src/mini_db_mcp/main.py
+++ b/src/mini_db_mcp/main.py
@@ -1,30 +1,13 @@
import logging
import sys
-import asyncio
-from contextlib import asynccontextmanager
-from typing import Any
+import asyncio
-from mcp.server import Server
+from mini_db_mcp.config import Config
+from mini_db_mcp.server import MiniServer
+from mini_db_mcp import __version__
-from mcp.types import (
- INTERNAL_ERROR,
- INVALID_PARAMS,
- CallToolResult,
- GetPromptResult,
- ListPromptsResult,
- ListResourcesResult,
- ListToolsResult,
- ReadResourceResult,
- TextContent,
- TextResourceContents,
-)
-
-from .config import Config
-from .prompts import MissingPromptArgumentError, PromptsManager, UnknownPromptError
-from .server import MiniServer
-from . import __version__
logger = logging.getLogger(__name__)
-
+
def _setup_logging(level: str) -> None:
logging.basicConfig(
@@ -41,6 +24,8 @@ def main() -> None:
transport = config.server.transport
if transport == "stdio":
asyncio.run(server.start_stdio())
+ elif transport == "http":
+ asyncio.run(server.start_http())
else:
logger.error("Unsupported transport: %s", transport)
diff --git a/src/mini_db_mcp/security.py b/src/mini_db_mcp/security.py
new file mode 100644
index 0000000..1fa1026
--- /dev/null
+++ b/src/mini_db_mcp/security.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+
+import sqlglot
+from sqlglot import exp
+from sqlglot.errors import ParseError, TokenError
+
+
+class SecurityError(Exception):
+ """Base class for all query rejections."""
+
+
+class SqlValidationError(SecurityError):
+ pass
+
+
+class PermissionDeniedError(SecurityError):
+ pass
+
+
+MAX_SQL_LENGTH = 200_000
+# Doris first, MySQL as a fallback for constructs the Doris dialect misses.
+PARSE_DIALECTS = ("doris", "mysql")
+
+# exp.Query covers SELECT, set operations and subqueries.
+_READ_ONLY_NODES = (exp.Query, exp.Show, exp.Describe, exp.Use)
+_WRITE_NODES = (
+ exp.Insert,
+ exp.Update,
+ exp.Delete,
+ exp.Drop,
+ exp.Create,
+ exp.Alter,
+ exp.TruncateTable,
+)
+# Rejected regardless of allow_write: they read files, block workers or leak timing.
+_FORBIDDEN_FUNCTIONS = frozenset({"load_file", "sleep", "benchmark", "outfile"})
+
+
+@lru_cache(maxsize=256)
+def parse_statement(sql: str) -> exp.Expression:
+ """Parse a single statement, rejecting anything the dialects cannot model.
+
+ The returned tree is shared through the cache and must never be mutated.
+ """
+ if not sql or not sql.strip():
+ raise SqlValidationError("Empty SQL statement.")
+ if len(sql) > MAX_SQL_LENGTH:
+ raise SqlValidationError(f"Statement exceeds {MAX_SQL_LENGTH} characters.")
+ if "\x00" in sql:
+ raise SqlValidationError("Statement contains a NUL byte.")
+
+ last_error: Exception | None = None
+ for dialect in PARSE_DIALECTS:
+ try:
+ trees = sqlglot.parse(sql, dialect=dialect)
+ except (ParseError, TokenError, RecursionError) as exc:
+ last_error = exc
+ continue
+
+ statements = [tree for tree in trees if tree is not None]
+ if not statements:
+ raise SqlValidationError("Empty SQL statement.")
+ if len(statements) > 1:
+ raise SqlValidationError("Multiple statements per request are not allowed.")
+
+ root = statements[0]
+ # sqlglot degrades DCL/ADMIN and other unmodelled syntax to Command.
+ if isinstance(root, exp.Command) or root.find(exp.Command) is not None:
+ raise SqlValidationError("Administrative or unsupported statement.")
+ return root
+
+ raise SqlValidationError(f"Statement could not be parsed: {last_error}")
+
+
+def table_names(root: exp.Expression) -> set[str]:
+ """Physical tables referenced by the tree, with CTE aliases removed."""
+ ctes = {cte.alias_or_name.lower() for cte in root.find_all(exp.CTE)}
+ names: set[str] = set()
+ for table in root.find_all(exp.Table):
+ name = table.name.lower()
+ if not name:
+ continue
+ parts = [part.lower() for part in (table.catalog, table.db) if part]
+ if not parts and name in ctes:
+ continue
+ names.add(".".join([*parts, name]))
+ return names
+
+
+def extract_tables(sql: str) -> set[str]:
+ return table_names(parse_statement(sql))
+
+
+def returns_rows(sql: str) -> bool:
+ return isinstance(parse_statement(sql), _READ_ONLY_NODES)
+
+
+@dataclass(frozen=True)
+class SqlGuard:
+ """Statement level validation"""
+
+ allow_write: bool = False
+ max_rows: int = 1000
+
+ def validate(self, sql: str) -> str:
+ statement = sql.strip()
+ if statement.endswith(";"):
+ statement = statement[:-1].strip()
+ root = parse_statement(statement)
+ self._reject_dangerous(root)
+
+ if isinstance(root, _WRITE_NODES):
+ if not self.allow_write:
+ raise PermissionDeniedError(
+ f"{type(root).__name__.upper()} is rejected in read-only mode."
+ )
+ return statement
+ if isinstance(root, _READ_ONLY_NODES):
+ return self._apply_row_limit(statement, root)
+ raise SqlValidationError(f"Unsupported statement type: {type(root).__name__}.")
+
+ def _reject_dangerous(self, root: exp.Expression) -> None:
+ if root.find(exp.Into) is not None:
+ raise SqlValidationError("SELECT ... INTO is not allowed.")
+ for func in root.find_all(exp.Anonymous):
+ if func.name.lower() in _FORBIDDEN_FUNCTIONS:
+ raise SqlValidationError(f"Function '{func.name}' is not allowed.")
+
+ def _apply_row_limit(self, statement: str, root: exp.Expression) -> str:
+ """Append a limit to the original text; the AST is only used to decide."""
+ if not isinstance(root, exp.Query) or root.args.get("limit") is not None:
+ return statement
+ return f"{statement} LIMIT {self.max_rows}"
+
+
+@dataclass(frozen=True)
+class PermissionPolicy:
+ """Per-principal access rules, resolved before the statement runs."""
+
+ allowed_databases: frozenset[str] = frozenset()
+ denied_tables: frozenset[str] = frozenset()
+ allow_write: bool = False
+
+ def check(self, sql: str, default_database: str | None) -> None:
+ root = parse_statement(sql.strip().rstrip(";").strip())
+ if isinstance(root, _WRITE_NODES) and not self.allow_write:
+ raise PermissionDeniedError("Principal is not allowed to write.")
+
+ for table in table_names(root):
+ database, _, name = table.rpartition(".")
+ database = database or (default_database or "").lower()
+ if self.allowed_databases and database not in self.allowed_databases:
+ raise PermissionDeniedError(f"Database '{database}' is not accessible.")
+ qualified = f"{database}.{name}" if database else name
+ if name in self.denied_tables or qualified in self.denied_tables:
+ raise PermissionDeniedError(f"Table '{qualified}' is not accessible.")
diff --git a/src/mini_db_mcp/server.py b/src/mini_db_mcp/server.py
index 6576d5b..e069a03 100644
--- a/src/mini_db_mcp/server.py
+++ b/src/mini_db_mcp/server.py
@@ -1,31 +1,36 @@
-from __future__ import annotations
+from . import __version__
+import json
import logging
from typing import Any, Protocol
-
from mcp.server import Server
from mcp.types import (
CallToolResult,
GetPromptResult,
ListPromptsResult,
- ListResourcesResult,
ListToolsResult,
- ReadResourceResult,
+ PaginatedRequestParams,
TextContent,
- TextResourceContents,
Resource,
Tool,
Prompt
)
-from .config import Config
-from .prompts import PromptsManager
-from . import __version__
+from mini_db_mcp.config import Config
+from mini_db_mcp.db import Database
+from mini_db_mcp.domain.domain_catalog import DOMAIN_CATALOG
+from mini_db_mcp.domain.domain_handler_factory import create_domain_registry
+from mini_db_mcp.utils.pagination import paginate_list
+from mini_db_mcp.prompts import PromptsManager
+from mini_db_mcp.tools import ToolsManager
+
NAME = "mini-db-mcp"
+
logger = logging.getLogger(__name__)
+
class ResourcesProtocol(Protocol):
async def list_resources(self) -> list[Resource]: ...
@@ -35,7 +40,7 @@ class ResourcesProtocol(Protocol):
class ToolsProtocol(Protocol):
async def list_tools(self) -> list[Tool]: ...
- async def call_tool(self, name: str, arguments: dict[str, Any]) -> str: ...
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: ...
class PromptsProtocol(Protocol):
@@ -51,25 +56,61 @@ class MiniServer():
def __init__(self, config: Config) -> None:
self.config = config
self.prompts_manager = PromptsManager()
+ self.database = Database(config)
+ self.handler_registry = create_domain_registry(
+ metadata_store=self.database,
+ )
+ self.tools_manager = ToolsManager(
+ catalog=DOMAIN_CATALOG,
+ registry=self.handler_registry,
+ )
self.server = _create_mcp_server(
- prompts_manager=self.prompts_manager
+ prompts_manager=self.prompts_manager,
+ tools_manager=self.tools_manager,
)
async def start_stdio(self) -> None:
from mcp.server.stdio import stdio_server
- async with stdio_server() as streams:
- read_stream, write_stream = streams
- await self.server.run(
- read_stream,
- write_stream,
- self.server.create_initialization_options(),
+ try:
+ async with stdio_server() as streams:
+ read_stream, write_stream = streams
+ await self.server.run(
+ read_stream,
+ write_stream,
+ self.server.create_initialization_options(),
+ )
+ finally:
+ await self.database.close()
+
+ async def start_http(self) -> None:
+ """Serve MCP over stateless Streamable HTTP at ``/mcp``."""
+ import uvicorn
+
+ app = self.server.streamable_http_app(
+ streamable_http_path="/mcp",
+ json_response=True,
+ stateless_http=True,
+ host=self.config.server.host,
+ )
+ server = uvicorn.Server(
+ uvicorn.Config(
+ app,
+ host=self.config.server.host,
+ port=self.config.server.port,
+ log_level=self.config.server.log_level.lower(),
)
+ )
+ try:
+ await server.serve()
+ finally:
+ await self.database.close()
def _create_mcp_server(
*,
prompts_manager: PromptsProtocol | None = None,
+ tools_manager: ToolsProtocol | None = None,
) -> Server:
async def on_list_prompts(ctx, params):
@@ -78,11 +119,43 @@ def _create_mcp_server(
async def on_get_prompt(ctx, params):
return await prompts_manager.get_prompt(params.name, dict(params.arguments or {}))
+ async def list_tools(
+ ctx,
+ params: PaginatedRequestParams | None,
+ ) -> ListToolsResult:
+ tools = await tools_manager.list_tools()
+ page_items, next_cursor = paginate_list(
+ tools,
+ cursor=params.cursor if params else None,
+ page_size=10,
+ identifier=lambda tool: tool.name,
+ )
+ logger.info("Returning %d of %d tools", len(page_items), len(tools))
+ return ListToolsResult(
+ tools=page_items,
+ next_cursor=next_cursor,
+ )
+
+ async def call_tool(ctx, params):
+ # ToolsManager returns either a manifest (discovery) or a child result.
+ result = await tools_manager.call_tool(
+ params.name,
+ dict(params.arguments or {}),
+ )
+ # Publish the same payload as structured data and backward-compatible text.
+ return CallToolResult(
+ content=[TextContent(type="text", text=json.dumps(result, ensure_ascii=False))],
+ structuredContent=result,
+ isError=result.get("mode") == "error",
+ )
+
server = Server(
name=NAME,
version=__version__,
on_list_prompts=on_list_prompts,
on_get_prompt=on_get_prompt,
+ on_list_tools=list_tools,
+ on_call_tool=call_tool,
)
return server
diff --git a/src/mini_db_mcp/tools.py b/src/mini_db_mcp/tools.py
new file mode 100644
index 0000000..be3a7f5
--- /dev/null
+++ b/src/mini_db_mcp/tools.py
@@ -0,0 +1,114 @@
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from mcp.types import Tool
+from mini_db_mcp.domain.domain_manifest import DomainManifest
+from mini_db_mcp.domain.domain_catalog import DomainCatalog
+from mini_db_mcp.domain.domain_handler import DomainHandlerRegistry
+from mini_db_mcp.domain.domain_model import DomainErrorCode, DomainToolRequest
+
+
+class ToolsManager:
+ """Expose domain tools through a two-call discovery and execution flow.
+
+ The first call uses empty arguments to return the selected domain's child
+ manifest. The second call supplies ``child_tool`` and ``child_arguments``
+ to execute exactly one child through the domain registry.
+ """
+
+ def __init__(
+ self,
+ *,
+ catalog: DomainCatalog,
+ registry: DomainHandlerRegistry,
+ ) -> None:
+ self._catalog = catalog
+ self._domains = {domain.domain_name: domain for domain in catalog.domains}
+ self._domain_manifest_service = DomainManifest(catalog=catalog)
+ self._registry = registry
+
+ async def list_tools(self) -> list[Tool]:
+ return await self._domain_manifest_service.list_tools()
+
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
+ domain = self._domains.get(name)
+ if domain is None:
+ raise ValueError(f"Unknown domain tool: {name}")
+
+ request = DomainToolRequest(**arguments)
+ # First call: expose only this domain's child schemas to the model.
+ if request.is_discovery:
+ return {
+ "mode": "manifest",
+ "domain": name,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "children": [child.to_json_object() for child in domain.children],
+ }
+
+ child_tool = request.child_tool
+ assert child_tool is not None
+ # Second call: route the selected child to its owning domain handler.
+ handler = self._registry.resolve_handler(name, child_tool)
+ if handler is None:
+ return self._error_result(
+ name,
+ child_tool,
+ DomainErrorCode.CHILD_TOOL_NOT_FOUND,
+ f"Unknown child tool {child_tool!r} for domain {name!r}.",
+ )
+
+ started_at = time.perf_counter()
+ try:
+ data = await handler(dict(request.execution_arguments))
+ except ValueError as exc:
+ return self._error_result(
+ name,
+ child_tool,
+ DomainErrorCode.CHILD_ARGUMENTS_INVALID,
+ str(exc),
+ )
+ except Exception as exc:
+ return self._error_result(
+ name,
+ child_tool,
+ DomainErrorCode.CHILD_EXECUTION_FAILED,
+ str(exc),
+ retryable=True,
+ )
+
+ return {
+ "mode": "result",
+ "domain": name,
+ "child_tool": child_tool,
+ "data": data,
+ "metadata": {
+ "request_id": uuid.uuid4().hex,
+ "duration_ms": (time.perf_counter() - started_at) * 1000,
+ "source": "database",
+ "truncated": bool(data.get("truncated", False)),
+ },
+ "warnings": [],
+ }
+
+ @staticmethod
+ def _error_result(
+ domain: str,
+ child_tool: str,
+ code: DomainErrorCode,
+ message: str,
+ *,
+ retryable: bool = False,
+ ) -> dict[str, Any]:
+ return {
+ "mode": "error",
+ "domain": domain,
+ "child_tool": child_tool,
+ "error": {
+ "code": code.value,
+ "message": message,
+ "retryable": retryable,
+ "details": {},
+ },
+ }
diff --git a/src/mini_db_mcp/utils/json_util.py b/src/mini_db_mcp/utils/json_util.py
new file mode 100644
index 0000000..02d3025
--- /dev/null
+++ b/src/mini_db_mcp/utils/json_util.py
@@ -0,0 +1,31 @@
+from collections.abc import Mapping
+from math import isfinite
+
+from pydantic import JsonValue
+
+
+type JsonObject = dict[str, JsonValue]
+
+
+def to_json_value(value: object) -> JsonValue:
+
+ if value is None or isinstance(value, str | bool | int):
+ return value
+ if isinstance(value, float):
+ if not isfinite(value):
+ raise ValueError("JSON numbers must be finite")
+ return value
+ if isinstance(value, Mapping):
+ result: JsonObject = {}
+ for key, item in value.items():
+ if not isinstance(key, str):
+ raise TypeError(
+ f"JSON object keys must be strings, got {type(key).__name__}"
+ )
+ result[key] = to_json_value(item)
+ return result
+ if isinstance(value, list | tuple):
+ return [to_json_value(item) for item in value]
+ raise TypeError(
+ f"{type(value).__name__} cannot be converted to a JSON value"
+ )
\ No newline at end of file
diff --git a/src/mini_db_mcp/utils/pagination.py b/src/mini_db_mcp/utils/pagination.py
new file mode 100644
index 0000000..ceba7bb
--- /dev/null
+++ b/src/mini_db_mcp/utils/pagination.py
@@ -0,0 +1,38 @@
+from collections.abc import Callable, Sequence
+from typing import TypeVar
+
+
+T = TypeVar("T")
+MAX_PAGE_SIZE = 10
+
+
+def paginate_list(
+ items: Sequence[T],
+ *,
+ cursor: str | None,
+ page_size: int,
+ identifier: Callable[[T], str],
+) -> tuple[list[T], str | None]:
+ """Return a deterministic page using the prior item's identifier as cursor."""
+
+ if not 1 <= page_size <= MAX_PAGE_SIZE:
+ raise ValueError(f"page_size must be in the range 1-{MAX_PAGE_SIZE}")
+
+ ordered = sorted(items, key=identifier)
+ identifiers = [identifier(item) for item in ordered]
+ if len(identifiers) != len(set(identifiers)):
+ raise ValueError("list contains duplicate identifiers")
+
+ start = 0
+ if cursor is not None:
+ try:
+ start = identifiers.index(cursor) + 1
+ except ValueError as exc:
+ raise ValueError("invalid pagination cursor") from exc
+ if start >= len(ordered):
+ raise ValueError("invalid pagination cursor")
+
+ page_items = ordered[start : start + page_size]
+ end = start + len(page_items)
+ next_cursor = identifiers[end - 1] if end < len(ordered) else None
+ return page_items, next_cursor
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index a176daa..664fc7c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -205,6 +205,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
+[[package]]
+name = "greenlet"
+version = "3.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" },
+ { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" },
+ { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" },
+ { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" },
+ { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" },
+ { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" },
+ { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" },
+ { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
+ { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
+ { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
+ { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
+ { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
+ { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
+ { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -345,6 +412,9 @@ dependencies = [
{ name = "mcp" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
+ { name = "sqlalchemy", extra = ["asyncio"] },
+ { name = "sqlglot" },
+ { name = "uvicorn" },
]
[package.dev-dependencies]
@@ -359,6 +429,9 @@ requires-dist = [
{ name = "mcp", specifier = ">=2.0.0,<2.1.0" },
{ name = "pydantic", specifier = ">=2.11,<3" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
+ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.30" },
+ { name = "sqlglot", specifier = ">=27.0" },
+ { name = "uvicorn", specifier = ">=0.30,<1" },
]
[package.metadata.requires-dev]
@@ -718,6 +791,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
+[[package]]
+name = "sqlalchemy"
+version = "2.0.52"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" },
+ { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" },
+ { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" },
+ { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" },
+ { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" },
+ { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" },
+ { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" },
+]
+
+[package.optional-dependencies]
+asyncio = [
+ { name = "greenlet" },
+]
+
+[[package]]
+name = "sqlglot"
+version = "30.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/73/5b5ce3e23b3ded3ea1986c2c2991217460d5fd5161b3e00a8425f5dcb8a3/sqlglot-30.18.0.tar.gz", hash = "sha256:e57e1b205e341979d1df5b1212c1435c598a0437e4619e3f428b15d5bc3a5cc6", size = 6018496, upload-time = "2026-09-03T13:12:58.206Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/9f/3dd2ec8dd84a33e0092f1c815267bc055073f9e833b93e389c59c10f62cc/sqlglot-30.18.0-py3-none-any.whl", hash = "sha256:ee0f9a9f3e2193e763c326e52dfb377b96fdb4292f6305c3ff2d9a220e71c601", size = 748788, upload-time = "2026-09-03T13:12:56.467Z" },
+]
+
[[package]]
name = "sse-starlette"
version = "3.4.8"