MiknasTrading/.claude/mcp-server.py

376 lines
16 KiB
Python

"""
OperationModule Database MCP Server
Gives Claude direct read-only access to the SQLite database so queries
don't require reading PHP files or running artisan commands.
"""
import sqlite3
import json
import sys
import os
from pathlib import Path
# ── MCP SDK ──────────────────────────────────────────────────────────────────
from mcp.server.fastmcp import FastMCP
DB_PATH = Path(__file__).parent.parent / "database" / "database.sqlite"
mcp = FastMCP("OperationModule DB")
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _conn() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def _rows(sql: str, params: tuple = ()) -> list[dict]:
with _conn() as conn:
cur = conn.execute(sql, params)
return [dict(r) for r in cur.fetchall()]
def _one(sql: str, params: tuple = ()) -> dict | None:
rows = _rows(sql, params)
return rows[0] if rows else None
# ─────────────────────────────────────────────────────────────────────────────
# Schema & Meta
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def list_tables() -> str:
"""List all tables in the database with row counts."""
rows = _rows("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
result = []
with _conn() as conn:
for r in rows:
t = r["name"]
count = conn.execute(f"SELECT COUNT(*) FROM [{t}]").fetchone()[0]
result.append(f"{t}: {count} rows")
return "\n".join(result)
@mcp.tool()
def describe_table(table: str) -> str:
"""Return the column definitions for a table."""
rows = _rows(f"PRAGMA table_info([{table}])")
if not rows:
return f"Table '{table}' not found."
lines = [f"Table: {table}"]
for c in rows:
nullable = "" if c["notnull"] else " NULL"
default = f" DEFAULT {c['dflt_value']}" if c["dflt_value"] is not None else ""
pk = " PK" if c["pk"] else ""
lines.append(f" {c['name']} {c['type']}{nullable}{default}{pk}")
return "\n".join(lines)
@mcp.tool()
def run_query(sql: str) -> str:
"""
Execute a read-only SQL query and return results as JSON.
Only SELECT statements are allowed. Max 200 rows returned.
"""
stripped = sql.strip().upper()
if not stripped.startswith("SELECT") and not stripped.startswith("WITH"):
return "Error: only SELECT / WITH queries are permitted."
try:
rows = _rows(sql)
if len(rows) > 200:
rows = rows[:200]
suffix = "\n[Truncated to 200 rows]"
else:
suffix = ""
return json.dumps(rows, default=str, indent=2) + suffix
except Exception as e:
return f"Error: {e}"
# ─────────────────────────────────────────────────────────────────────────────
# Purchase Module
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def get_suppliers(active_only: bool = False) -> str:
"""List all suppliers with their contact details."""
where = "WHERE is_active = 1" if active_only else ""
rows = _rows(f"""
SELECT id, name, contact_person, email, phone, address, tax_number,
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
FROM suppliers {where}
ORDER BY name
""")
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_purchase_orders(status: str = "") -> str:
"""
List purchase orders. Optional status filter: draft|sent|partial|received|cancelled
"""
where = "WHERE po.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT po.id, po.po_number, s.name AS supplier, po.po_date,
po.expected_delivery_date, po.total_amount, po.status
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
{where}
ORDER BY po.po_date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_purchase_requests(status: str = "") -> str:
"""
List purchase requests. Optional status: pending|approved|rejected|ordered
"""
where = "WHERE pr.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT pr.id, pr.request_number, pr.date, pr.department,
pr.requested_by_name, pr.required_date_text, pr.status,
COUNT(pri.id) AS item_count
FROM purchase_requests pr
LEFT JOIN purchase_request_items pri ON pri.purchase_request_id = pr.id
{where}
GROUP BY pr.id
ORDER BY pr.date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_supplier_invoices(status: str = "") -> str:
"""List supplier invoices. Optional status: unpaid|partial|paid"""
where = "WHERE si.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT si.id, si.invoice_number, s.name AS supplier,
si.invoice_date, si.due_date, si.total_amount,
si.paid_amount, si.status
FROM supplier_invoices si
JOIN suppliers s ON s.id = si.supplier_id
{where}
ORDER BY si.invoice_date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
# Inventory Module
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def get_items(category: str = "", active_only: bool = False) -> str:
"""
List inventory items.
category: raw_material | wip | finished_good (blank = all)
"""
conditions = []
params: list = []
if category:
conditions.append("category = ?")
params.append(category)
if active_only:
conditions.append("is_active = 1")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = _rows(f"""
SELECT id, item_code, item_name, category, unit_of_measure,
cost_price, minimum_stock_level,
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
FROM items {where}
ORDER BY category, item_name
""", tuple(params))
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_stock_levels(warehouse_id: int = 0, low_stock_only: bool = False) -> str:
"""
Current stock levels, optionally filtered by warehouse or showing only low-stock items.
"""
conditions = []
params: list = []
if warehouse_id:
conditions.append("sl.warehouse_id = ?")
params.append(warehouse_id)
if low_stock_only:
conditions.append("sl.quantity <= i.minimum_stock_level")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = _rows(f"""
SELECT i.item_code, i.item_name, i.category, i.unit_of_measure,
w.name AS warehouse, sl.quantity,
i.minimum_stock_level,
ROUND(sl.quantity * i.cost_price, 2) AS stock_value
FROM stock_levels sl
JOIN items i ON i.id = sl.item_id
JOIN warehouses w ON w.id = sl.warehouse_id
{where}
ORDER BY i.category, i.item_name
""", tuple(params))
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_warehouses() -> str:
"""List all warehouses."""
rows = _rows("""
SELECT w.id, w.code, w.name, w.location,
CASE WHEN w.is_active THEN 'Active' ELSE 'Inactive' END AS status,
COUNT(sl.id) AS item_types,
ROUND(SUM(sl.quantity * i.cost_price), 2) AS total_value
FROM warehouses w
LEFT JOIN stock_levels sl ON sl.warehouse_id = w.id
LEFT JOIN items i ON i.id = sl.item_id
GROUP BY w.id
ORDER BY w.name
""")
return json.dumps(rows, default=str, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
# Production Module
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def get_production_orders(status: str = "") -> str:
"""
List production orders.
status: planned|in_progress|completed|cancelled
"""
where = "WHERE po.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT po.id, po.order_number, i.item_name AS product,
po.quantity_to_produce, po.quantity_produced,
po.production_date, po.completion_date, po.status
FROM production_orders po
JOIN items i ON i.id = po.product_id
{where}
ORDER BY po.production_date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_bill_of_materials(product_id: int = 0) -> str:
"""
Show bill of materials (recipes). Pass product_id to filter to one product.
"""
where = "WHERE bom.product_id = ?" if product_id else ""
params = (product_id,) if product_id else ()
rows = _rows(f"""
SELECT p.item_name AS product, r.item_name AS raw_material,
bom.quantity_required, bom.unit_of_measure
FROM bill_of_materials bom
JOIN items p ON p.id = bom.product_id
JOIN items r ON r.id = bom.raw_material_id
{where}
ORDER BY p.item_name, r.item_name
""", params)
return json.dumps(rows, default=str, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
# Sales Module
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def get_customers(active_only: bool = False) -> str:
"""List all customers with outstanding balances."""
where = "WHERE is_active = 1" if active_only else ""
rows = _rows(f"""
SELECT id, name, contact_person, email, phone,
credit_limit, outstanding_balance,
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
FROM customers {where}
ORDER BY name
""")
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_sales_orders(status: str = "") -> str:
"""
List sales orders.
status: draft|confirmed|dispatched|invoiced|cancelled
"""
where = "WHERE so.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT so.id, so.order_number, c.name AS customer,
so.order_date, so.delivery_date,
so.total_amount, so.status
FROM sales_orders so
JOIN customers c ON c.id = so.customer_id
{where}
ORDER BY so.order_date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
@mcp.tool()
def get_sales_invoices(status: str = "") -> str:
"""List sales invoices. status: unpaid|partial|paid|cancelled"""
where = "WHERE si.status = ?" if status else ""
params = (status,) if status else ()
rows = _rows(f"""
SELECT si.id, si.invoice_number, c.name AS customer,
si.invoice_date, si.due_date, si.total_amount,
si.paid_amount, si.status
FROM sales_invoices si
JOIN customers c ON c.id = si.customer_id
{where}
ORDER BY si.invoice_date DESC
LIMIT 100
""", params)
return json.dumps(rows, default=str, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
# Dashboard / Summary
# ─────────────────────────────────────────────────────────────────────────────
@mcp.tool()
def get_dashboard_summary() -> str:
"""
High-level KPIs: inventory value, open POs, open sales orders,
unpaid supplier invoices, unpaid sales invoices, active items/suppliers.
"""
with _conn() as conn:
def scalar(sql, p=()):
r = conn.execute(sql, p).fetchone()
return r[0] if r else 0
summary = {
"suppliers": scalar("SELECT COUNT(*) FROM suppliers WHERE is_active=1"),
"items_active": scalar("SELECT COUNT(*) FROM items WHERE is_active=1"),
"inventory_value": scalar("SELECT ROUND(SUM(sl.quantity*i.cost_price),2) FROM stock_levels sl JOIN items i ON i.id=sl.item_id"),
"low_stock_items": scalar("SELECT COUNT(*) FROM stock_levels sl JOIN items i ON i.id=sl.item_id WHERE sl.quantity<=i.minimum_stock_level AND i.minimum_stock_level>0"),
"open_purchase_orders": scalar("SELECT COUNT(*) FROM purchase_orders WHERE status IN ('draft','sent','partial')"),
"pending_requests": scalar("SELECT COUNT(*) FROM purchase_requests WHERE status='pending'"),
"open_production_orders": scalar("SELECT COUNT(*) FROM production_orders WHERE status IN ('planned','in_progress')"),
"open_sales_orders": scalar("SELECT COUNT(*) FROM sales_orders WHERE status IN ('draft','confirmed')"),
"unpaid_supplier_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM supplier_invoices WHERE status IN ('unpaid','partial')"),
"unpaid_sales_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM sales_invoices WHERE status IN ('unpaid','partial')"),
}
return json.dumps(summary, default=str, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")