Tools reference

This commit is contained in:
Chen, Li
2026-10-09 11:03:25 +08:00
parent e08f6c0525
commit 959fbcccb2
21 changed files with 1861 additions and 146 deletions
+20 -12
View File
@@ -1,22 +1,30 @@
# Override the .env file path (default: .env in working directory) # Override the .env file path (default: .env in working directory)
# Database # --- Database ---
DB_HOST=127.0.0.1 DB_HOST=
DB_PORT=3306 DB_PORT=
DB_USER=root DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_DATABASE= DB_SCHEMA=
DB_POOL_MIN=1 DB_CONNECT_TIMEOUT=30
DB_POOL_MAX=10 DB_POOL_SIZE=5
DB_CONNECT_TIMEOUT=10.0 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_MAX_ROWS=1000
QUERY_TIMEOUT_SECONDS=30.0 QUERY_MAX_BYTES=4000000
# Set to true to allow INSERT / UPDATE / DELETE / DDL QUERY_TIMEOUT_SECONDS=30
QUERY_ALLOW_WRITE=false QUERY_ALLOW_WRITE=false
# Server # --- MCP server ---
MCP_TRANSPORT=stdio MCP_TRANSPORT=stdio
MCP_HOST=127.0.0.1 MCP_HOST=127.0.0.1
MCP_PORT=8000 MCP_PORT=8000
+1 -1
View File
@@ -22,4 +22,4 @@ dist/
# Editors # Editors
.idea/ .idea/
docs/superpowers tests/
+3 -1
View File
@@ -6,13 +6,15 @@ build-backend = "hatchling.build"
name = "mini-db-mcp" name = "mini-db-mcp"
version = "0.1.0" version = "0.1.0"
description = "A minimal database MCP server" description = "A minimal database MCP server"
readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"mcp>=2.0.0,<2.1.0", "mcp>=2.0.0,<2.1.0",
"pydantic>=2.11,<3", "pydantic>=2.11,<3",
"sqlalchemy[asyncio]>=2.0.30",
"aiomysql>=0.3.0", "aiomysql>=0.3.0",
"pydantic-settings>=2.15.0", "pydantic-settings>=2.15.0",
"sqlglot>=27.0",
"uvicorn>=0.30,<1",
] ]
[project.scripts] [project.scripts]
+12 -3
View File
@@ -13,16 +13,25 @@ class DatabaseConfig(BaseSettings):
port: int = 3306 port: int = 3306
user: str = "root" user: str = "root"
password: str = "" password: str = ""
database: str = "" schema: str = ""
pool_min: int = 1
pool_max: int = 10
connect_timeout: int = 30 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): class QueryConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="QUERY_", env_file=_ENV_FILE, extra="ignore") model_config = SettingsConfigDict(env_prefix="QUERY_", env_file=_ENV_FILE, extra="ignore")
max_rows: int = 1000 max_rows: int = 1000
max_bytes: int = 4_000_000
timeout_seconds: int = 30 timeout_seconds: int = 30
allow_write: bool = False allow_write: bool = False
+538 -79
View File
@@ -1,110 +1,569 @@
"""Async MySQL connection manager with connection pool. import asyncio
import hashlib
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 logging import logging
import re import time
from collections.abc import AsyncGenerator, AsyncIterator from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager 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 sqlalchemy import URL, text
from aiomysql import Pool 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__) logger = logging.getLogger(__name__)
# Only these SQL keywords are permitted when write access is disabled
_READ_ONLY_KEYWORDS = frozenset( HEALTH_CHECK_SQL = "SELECT 1"
{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"} GLOBAL_PRINCIPAL = "__global__"
DEFAULT_SESSION = "__anonymous__"
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)}."
) )
_FIRST_KEYWORD_RE = re.compile(r"--[^\n]*|/\*.*?\*/", re.DOTALL) return "".join(out), bound
def _first_sql_keyword(sql: str) -> str: def _copy_quoted(sql: str, start: int, out: list[str]) -> int:
stripped = _FIRST_KEYWORD_RE.sub("", sql).strip() quote = sql[start]
return stripped.split(None, 1)[0].upper() if stripped else "" 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: 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.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: async def start(self) -> None:
db = self.config.database await self._get_entry(self.global_credential())
self._pool = await aiomysql.create_pool( if self.db.health_check_interval > 0:
host=db.host, self._health_task = asyncio.create_task(self._health_loop())
port=db.port,
user=db.user,
password=db.password, def bind_session(self, session_id: str, credential: Credential) -> None:
db=db.database or None, self._sessions[session_id] = credential
minsize=db.pool_min,
maxsize=db.pool_max,
connect_timeout=db.connect_timeout, def unbind_session(self, session_id: str) -> None:
autocommit=True, self._sessions.pop(session_id, None)
charset="utf8mb4",
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."
) )
logger.info( if credential.is_expired():
"Connection pool started: %s:%d db=%s", if self._sessions.get(session_id) is credential:
db.host, self.unbind_session(session_id)
db.port, raise CredentialExpiredError(
db.database or "(none)", f"Credential for session {session_id} has expired."
) )
return credential
async def close(self) -> None: async def close(self) -> None:
if self._pool is not None: self._closed = True
self._pool.close() if self._health_task is not None:
await self._pool.wait_closed() self._health_task.cancel()
self._pool = None try:
logger.info("Connection pool closed") 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)
def _assert_write_allowed(self, sql: str) -> None: async def __aenter__(self) -> "ConnectionManager":
keyword = _first_sql_keyword(sql) await self.start()
if not self.config.query.allow_write and keyword not in _READ_ONLY_KEYWORDS: return self
raise PermissionError(
f"Write operations are disabled (first keyword: {keyword!r}). " async def __aexit__(self, *_exc: object) -> None:
"Set QUERY_ALLOW_WRITE=true to enable DML/DDL." 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)
@asynccontextmanager @asynccontextmanager
async def acquire(self) -> AsyncIterator[aiomysql.Connection]: async def connect(self, credential: Credential) -> AsyncGenerator[AsyncConnection]:
if self._pool is None: entry = await self._get_entry(credential)
raise RuntimeError("Connection pool is not started") generation = entry.generation
async with self._pool.acquire() as conn: 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 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( async def execute(
self, self,
sql: str, sql: str,
args: tuple[Any, ...] | None = None, params: Mapping[str, Any] | Sequence[Any] | None = None,
*, *,
database: str | None = None, credential: Credential | None = None,
max_rows: int | None = None, ) -> QueryResult:
) -> dict[str, Any]: return await self.execute_query(
"""Execute *sql* and return a structured result dict.""" DEFAULT_SESSION, sql, params, credential or self.global_credential()
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: # ------------------------------------------------------------ health checks
if database:
await conn.select_db(database) async def health_check(self, entry: EngineEntry) -> bool:
async with conn.cursor(aiomysql.DictCursor) as cur: try:
await cur.execute(sql, args) async with entry.engine.connect() as conn:
rows = await cur.fetchmany(limit + 1) await asyncio.wait_for(
truncated = len(rows) > limit conn.execute(text(HEALTH_CHECK_SQL)),
if truncated: timeout=self.db.connect_timeout,
rows = rows[:limit] )
return { except (SQLAlchemyError, OSError, asyncio.TimeoutError) as exc:
"columns": [d[0] for d in cur.description] if cur.description else [], logger.warning("Health check failed key=%s: %s", entry.key, exc)
"rows": [dict(r) for r in rows], return False
"row_count": len(rows), entry.last_healthy_at = time.time()
"truncated": truncated, return True
"max_rows": limit,
} 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)
+93
View File
@@ -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
+171
View File
@@ -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)
+72
View File
@@ -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"
]
@@ -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)
]
)
+167
View File
@@ -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
),
)
+134 -6
View File
@@ -1,13 +1,55 @@
from enum import StrEnum
import json import json
from typing import Annotated, Any, Literal, Self, cast, TypedDict from typing import Annotated, Any, Literal, Self, cast
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, 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): class DomainBaseModel(BaseModel):
@@ -20,8 +62,9 @@ class DomainBaseModel(BaseModel):
validate_default=True, validate_default=True,
) )
def to_wire(self) -> JsonObject: def to_json_object(self) -> JsonObject:
"""Return a JSON-compatible representation with protocol aliases.""" """Return a JSON-compatible representation with protocol aliases."""
return cast( return cast(
JsonObject, JsonObject,
self.model_dump( self.model_dump(
@@ -33,10 +76,95 @@ class DomainBaseModel(BaseModel):
def to_canonical_json(self) -> str: def to_canonical_json(self) -> str:
"""Serialize deterministically for manifests, tests, and hashing.""" """Serialize deterministically for manifests, tests, and hashing."""
return json.dumps( return json.dumps(
self.to_wire(), self.to_json_object(),
ensure_ascii=False, ensure_ascii=False,
separators=(",", ":"), separators=(",", ":"),
sort_keys=True, 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)
@@ -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)
@@ -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,
}
+5 -20
View File
@@ -1,28 +1,11 @@
import logging import logging
import sys import sys
import asyncio import asyncio
from contextlib import asynccontextmanager
from typing import Any
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__) logger = logging.getLogger(__name__)
@@ -41,6 +24,8 @@ def main() -> None:
transport = config.server.transport transport = config.server.transport
if transport == "stdio": if transport == "stdio":
asyncio.run(server.start_stdio()) asyncio.run(server.start_stdio())
elif transport == "http":
asyncio.run(server.start_http())
else: else:
logger.error("Unsupported transport: %s", transport) logger.error("Unsupported transport: %s", transport)
+159
View File
@@ -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.")
+83 -10
View File
@@ -1,31 +1,36 @@
from __future__ import annotations from . import __version__
import json
import logging import logging
from typing import Any, Protocol from typing import Any, Protocol
from mcp.server import Server from mcp.server import Server
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
GetPromptResult, GetPromptResult,
ListPromptsResult, ListPromptsResult,
ListResourcesResult,
ListToolsResult, ListToolsResult,
ReadResourceResult, PaginatedRequestParams,
TextContent, TextContent,
TextResourceContents,
Resource, Resource,
Tool, Tool,
Prompt Prompt
) )
from .config import Config from mini_db_mcp.config import Config
from .prompts import PromptsManager from mini_db_mcp.db import Database
from . import __version__ 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" NAME = "mini-db-mcp"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ResourcesProtocol(Protocol): class ResourcesProtocol(Protocol):
async def list_resources(self) -> list[Resource]: ... async def list_resources(self) -> list[Resource]: ...
@@ -35,7 +40,7 @@ class ResourcesProtocol(Protocol):
class ToolsProtocol(Protocol): class ToolsProtocol(Protocol):
async def list_tools(self) -> list[Tool]: ... 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): class PromptsProtocol(Protocol):
@@ -51,13 +56,23 @@ class MiniServer():
def __init__(self, config: Config) -> None: def __init__(self, config: Config) -> None:
self.config = config self.config = config
self.prompts_manager = PromptsManager() 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( 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: async def start_stdio(self) -> None:
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
try:
async with stdio_server() as streams: async with stdio_server() as streams:
read_stream, write_stream = streams read_stream, write_stream = streams
await self.server.run( await self.server.run(
@@ -65,11 +80,37 @@ class MiniServer():
write_stream, write_stream,
self.server.create_initialization_options(), 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( def _create_mcp_server(
*, *,
prompts_manager: PromptsProtocol | None = None, prompts_manager: PromptsProtocol | None = None,
tools_manager: ToolsProtocol | None = None,
) -> Server: ) -> Server:
async def on_list_prompts(ctx, params): async def on_list_prompts(ctx, params):
@@ -78,11 +119,43 @@ def _create_mcp_server(
async def on_get_prompt(ctx, params): async def on_get_prompt(ctx, params):
return await prompts_manager.get_prompt(params.name, dict(params.arguments or {})) 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( server = Server(
name=NAME, name=NAME,
version=__version__, version=__version__,
on_list_prompts=on_list_prompts, on_list_prompts=on_list_prompts,
on_get_prompt=on_get_prompt, on_get_prompt=on_get_prompt,
on_list_tools=list_tools,
on_call_tool=call_tool,
) )
return server return server
+114
View File
@@ -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": {},
},
}
+31
View File
@@ -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"
)
+38
View File
@@ -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
Generated
+122
View File
@@ -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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -345,6 +412,9 @@ dependencies = [
{ name = "mcp" }, { name = "mcp" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "sqlglot" },
{ name = "uvicorn" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -359,6 +429,9 @@ requires-dist = [
{ name = "mcp", specifier = ">=2.0.0,<2.1.0" }, { name = "mcp", specifier = ">=2.0.0,<2.1.0" },
{ name = "pydantic", specifier = ">=2.11,<3" }, { name = "pydantic", specifier = ">=2.11,<3" },
{ name = "pydantic-settings", specifier = ">=2.15.0" }, { 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] [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" }, { 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]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.4.8" version = "3.4.8"