Tools reference
This commit is contained in:
+20
-12
@@ -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
@@ -22,4 +22,4 @@ dist/
|
|||||||
# Editors
|
# Editors
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
docs/superpowers
|
tests/
|
||||||
+3
-1
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+539
-80
@@ -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__"
|
||||||
_FIRST_KEYWORD_RE = re.compile(r"--[^\n]*|/\*.*?\*/", re.DOTALL)
|
|
||||||
|
|
||||||
|
|
||||||
def _first_sql_keyword(sql: str) -> str:
|
class CredentialKind(str, Enum):
|
||||||
stripped = _FIRST_KEYWORD_RE.sub("", sql).strip()
|
GLOBAL = "global"
|
||||||
return stripped.split(None, 1)[0].upper() if stripped else ""
|
|
||||||
|
|
||||||
|
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:
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user