notebookVb
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FotkyBuzalkovi MCP Server
|
||||
Poskytuje nástroje pro dotazování PostgreSQL databáze fotky_buzalkovi.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
# Načtení .env ze stejného adresáře jako tento skript
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "192.168.1.76"),
|
||||
"port": int(os.getenv("DB_PORT", 5432)),
|
||||
"user": os.getenv("DB_USER", "vladimir.buzalka"),
|
||||
"password": os.getenv("DB_PASSWORD", ""),
|
||||
"dbname": os.getenv("DB_NAME", "fotky_buzalkovi"),
|
||||
}
|
||||
|
||||
|
||||
def get_conn():
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
def run_query(sql: str, params=None, limit: int = 500):
|
||||
"""Spustí SELECT dotaz a vrátí výsledek jako seznam diktů."""
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchmany(limit)
|
||||
return [dict(r) for r in rows], cur.description
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
server = Server("fotky-buzalkovi")
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="query",
|
||||
description=(
|
||||
"Spustí libovolný SELECT dotaz na databázi fotky_buzalkovi. "
|
||||
"Vrátí max. 500 řádků. Používej pro průzkum dat."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {
|
||||
"type": "string",
|
||||
"description": "SELECT dotaz (jen čtení, INSERT/UPDATE/DELETE nejsou povoleny)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max. počet vrácených řádků (default 100, max 500)",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": ["sql"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="tables",
|
||||
description="Vrátí seznam všech tabulek v databázi s počty řádků.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="describe_table",
|
||||
description="Vrátí strukturu tabulky — sloupce, typy, nullable, default.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {"type": "string", "description": "Název tabulky"},
|
||||
},
|
||||
"required": ["table"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="stats",
|
||||
description=(
|
||||
"Základní statistiky projektu: počty fotek, stav importu, "
|
||||
"přehled kamer, roky pořízení, chybějící data."
|
||||
),
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
|
||||
# Ochrana — jen SELECT
|
||||
def check_readonly(sql: str):
|
||||
normalized = sql.strip().upper()
|
||||
for bad in ("INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE"):
|
||||
if normalized.startswith(bad) or f"\n{bad}" in normalized:
|
||||
raise ValueError(f"Pouze SELECT dotazy jsou povoleny. Nalezeno: {bad}")
|
||||
|
||||
try:
|
||||
if name == "query":
|
||||
sql = arguments["sql"]
|
||||
check_readonly(sql)
|
||||
limit = min(int(arguments.get("limit", 100)), 500)
|
||||
rows, desc = run_query(sql, limit=limit)
|
||||
result = json.dumps(rows, ensure_ascii=False, default=str, indent=2)
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
elif name == "tables":
|
||||
sql = """
|
||||
SELECT
|
||||
t.table_name,
|
||||
c.reltuples::bigint AS est_rows
|
||||
FROM information_schema.tables t
|
||||
JOIN pg_class c ON c.relname = t.table_name
|
||||
WHERE t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY t.table_name
|
||||
"""
|
||||
rows, _ = run_query(sql, limit=100)
|
||||
return [TextContent(type="text", text=json.dumps(rows, ensure_ascii=False, default=str, indent=2))]
|
||||
|
||||
elif name == "describe_table":
|
||||
table = arguments["table"]
|
||||
sql = """
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
character_maximum_length,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
rows, _ = run_query(sql, params=(table,), limit=200)
|
||||
if not rows:
|
||||
return [TextContent(type="text", text=f"Tabulka '{table}' nenalezena.")]
|
||||
return [TextContent(type="text", text=json.dumps(rows, ensure_ascii=False, default=str, indent=2))]
|
||||
|
||||
elif name == "stats":
|
||||
results = {}
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
|
||||
cur.execute("SELECT COUNT(*) AS total FROM zaloha_obrazku")
|
||||
results["zaloha_obrazku_total"] = cur.fetchone()["total"]
|
||||
|
||||
cur.execute("SELECT COUNT(*) AS total FROM zdrojove_soubory")
|
||||
results["zdrojove_soubory_total"] = cur.fetchone()["total"]
|
||||
|
||||
cur.execute("SELECT COUNT(*) AS total FROM photos")
|
||||
results["photos_total"] = cur.fetchone()["total"]
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS total FROM photos
|
||||
WHERE taken_at IS NOT NULL
|
||||
""")
|
||||
results["photos_with_taken_at"] = cur.fetchone()["total"]
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS total FROM photos
|
||||
WHERE gps_lat IS NOT NULL
|
||||
""")
|
||||
results["photos_with_gps"] = cur.fetchone()["total"]
|
||||
|
||||
cur.execute("""
|
||||
SELECT camera_model, COUNT(*) AS cnt
|
||||
FROM photos
|
||||
WHERE camera_model IS NOT NULL
|
||||
GROUP BY camera_model
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
results["top_cameras"] = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("""
|
||||
SELECT EXTRACT(YEAR FROM taken_at)::int AS rok, COUNT(*) AS cnt
|
||||
FROM photos
|
||||
WHERE taken_at IS NOT NULL
|
||||
GROUP BY rok
|
||||
ORDER BY rok
|
||||
""")
|
||||
results["photos_by_year"] = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("""
|
||||
SELECT processing_status, COUNT(*) AS cnt
|
||||
FROM photos
|
||||
GROUP BY processing_status
|
||||
ORDER BY cnt DESC
|
||||
""")
|
||||
results["processing_status"] = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, default=str, indent=2))]
|
||||
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Neznámý nástroj: {name}")]
|
||||
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Chyba: {e}")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spuštění
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def main():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user