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"