BF-Realtime / warehouse_mcp.py
SamiKoen
Add stateless remote MCP endpoint (/mcp) for warehouse stock
3cee61f
Raw
History Blame Contribute Delete
6.87 kB
"""Stateless MCP server (Streamable HTTP, JSON mode) — warehouse stok.
Tek POST endpoint uzerinden JSON-RPC 2.0 konusur. Oturum (session) ve SSE
stream yok: her istek bagimsiz. Bu sayede telefon/web/desktop Claude Code dahil
tum remote MCP client'lardan tek bir URL ile erisilebilir — Python kurulumu veya
lokal stdio process gerekmez.
Veri kaynagi: ayni Space'in cache'lenmis warehouse XML'i (get_cached_warehouse_xml).
Arama mantigi mcp-warehouse/server.py ile birebir ayni tutulur.
"""
from __future__ import annotations
import asyncio
import json
import xml.etree.ElementTree as ET
from typing import Any
from stock_service import get_cached_warehouse_xml
PROTOCOL_VERSION = "2025-06-18"
MAX_RESULTS = 100
TOOLS = [
{
"name": "search_stock",
"description": (
"Urun adi, SKU/urun kodu veya barkod ile stok arar. "
"Eslesen urunlerin her depodaki adedini ve toplam stogunu doner. "
'Ornek sorgular: "GOBIK forma", "10-01-027", "8434738174740".'
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Aranacak urun adi, SKU/urun kodu veya barkod.",
}
},
"required": ["query"],
},
},
{
"name": "list_warehouse",
"description": (
"Belirli bir deponun urunlerini ve adetlerini listeler. "
'warehouse: depo adi (orn. "CADDEBOSTAN", "ALSANCAK", "BAHCEKOY"; '
"buyuk/kucuk harf onemsiz, kismi eslesir). "
"only_in_stock: True ise sadece stogu 0'dan buyuk urunler."
),
"inputSchema": {
"type": "object",
"properties": {
"warehouse": {
"type": "string",
"description": "Depo adi (kismi eslesme, buyuk/kucuk harf onemsiz).",
},
"only_in_stock": {
"type": "boolean",
"description": "True ise sadece stogu 0'dan buyuk urunler.",
"default": False,
},
},
"required": ["warehouse"],
},
},
]
# ---------- XML parse + arama (mcp-warehouse/server.py ile ayni) ----------
def _parse_products(xml: str) -> list[dict]:
if not xml:
return []
root = ET.fromstring(xml)
products = []
for p in root.findall("Product"):
warehouses = {}
for w in p.findall("Warehouse"):
name = (w.findtext("Name") or "").strip()
try:
stock = int(w.findtext("Stock") or 0)
except ValueError:
stock = 0
if name:
warehouses[name] = stock
products.append({
"code": (p.findtext("ProductCode") or "").strip(),
"name": (p.findtext("ProductName") or "").strip(),
"variant": (p.findtext("ProductVariant") or "").strip(),
"barcode": (p.findtext("Barcode") or "").strip(),
"total_stock": int(p.findtext("TotalStock") or 0),
"warehouses": warehouses,
})
return products
def _search_stock(query: str, products: list[dict]) -> dict:
q = query.strip().lower()
if not q:
return {"error": "Bos sorgu", "results": []}
matches = []
for p in products:
haystack = f"{p['name']} {p['code']} {p['barcode']} {p['variant']}".lower()
if q in haystack:
matches.append(p)
total = len(matches)
return {
"query": query,
"match_count": total,
"truncated": total > MAX_RESULTS,
"results": matches[:MAX_RESULTS],
}
def _list_warehouse(warehouse: str, only_in_stock: bool, products: list[dict]) -> dict:
wq = warehouse.strip().lower()
if not wq:
return {"error": "Bos depo adi", "results": []}
results = []
for p in products:
for wname, stock in p["warehouses"].items():
if wq in wname.lower():
if only_in_stock and stock <= 0:
continue
results.append({
"code": p["code"],
"name": p["name"],
"variant": p["variant"],
"warehouse": wname,
"stock": stock,
})
total = len(results)
return {
"warehouse": warehouse,
"match_count": total,
"truncated": total > MAX_RESULTS,
"results": results[:MAX_RESULTS],
}
async def _run_tool(name: str, args: dict) -> dict:
xml = await asyncio.to_thread(get_cached_warehouse_xml)
products = _parse_products(xml or "")
if name == "search_stock":
return _search_stock(str(args.get("query", "")), products)
if name == "list_warehouse":
return _list_warehouse(
str(args.get("warehouse", "")),
bool(args.get("only_in_stock", False)),
products,
)
raise ValueError(f"Bilinmeyen arac: {name}")
# ---------- JSON-RPC 2.0 dispatch ----------
def _result(id_: Any, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": id_, "result": result}
def _error(id_: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
async def handle_message(msg: dict) -> dict | None:
"""Tek bir JSON-RPC mesajini isler. Notification ise None doner (HTTP 202)."""
if not isinstance(msg, dict):
return _error(None, -32600, "Invalid Request")
method = msg.get("method")
id_ = msg.get("id")
if method == "initialize":
client_ver = (msg.get("params") or {}).get("protocolVersion") or PROTOCOL_VERSION
return _result(id_, {
"protocolVersion": client_ver,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "warehouse", "version": "1.0.0"},
})
if method == "ping":
return _result(id_, {})
if method == "tools/list":
return _result(id_, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params") or {}
name = params.get("name")
args = params.get("arguments") or {}
try:
data = await _run_tool(name, args)
except Exception as e: # noqa: BLE001
return _result(id_, {
"content": [{"type": "text", "text": f"Hata: {e}"}],
"isError": True,
})
return _result(id_, {
"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False)}],
"isError": False,
})
# notifications/* ve diger bildirimlerin id'si yoktur -> cevap verme
if id_ is None:
return None
return _error(id_, -32601, f"Method not found: {method}")