Skip to main content
Glama
matthiashuebner

Xentral MCP HTTP Server

server.py17.4 kB
import os import json import asyncio import sys import logging from typing import Any, Dict, Optional import httpx from mcp.server import Server from mcp.server.stdio import stdio_server import mcp.types as types # Setup Logging logging.basicConfig( level=logging.DEBUG, format="[xentral-mcp] %(levelname)s: %(message)s", stream=sys.stderr, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Konfiguration über Umgebungsvariablen # --------------------------------------------------------------------------- XENTRAL_BASE_URL = os.environ.get("XENTRAL_BASE_URL") XENTRAL_PAT = os.environ.get("XENTRAL_PAT") XENTRAL_TIMEOUT = float(os.environ.get("XENTRAL_TIMEOUT", "30.0")) XENTRAL_MAX_RETRIES = int(os.environ.get("XENTRAL_MAX_RETRIES", "3")) if not XENTRAL_BASE_URL or not XENTRAL_PAT: error_msg = "[xentral-mcp] Bitte XENTRAL_BASE_URL und XENTRAL_PAT als Umgebungsvariablen setzen." print(error_msg, file=sys.stderr) logger.error(error_msg) raise RuntimeError( "Umgebungsvariablen XENTRAL_BASE_URL und XENTRAL_PAT sind erforderlich." ) # dafür sorgen, dass genau ein '/' am Ende steht XENTRAL_BASE_URL = XENTRAL_BASE_URL.rstrip("/") + "/" logger.info(f"Xentral MCP initialisiert mit Base-URL: {XENTRAL_BASE_URL}") def _auth_headers() -> Dict[str, str]: """Standard-Header für Xentral API-Calls.""" return { "Authorization": f"Bearer {XENTRAL_PAT}", "Accept": "application/json", "Content-Type": "application/json", } async def _make_request( client: httpx.AsyncClient, method: str, path: str, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, retries: int = 0, ) -> tuple[int, Any]: """ Macht einen HTTP-Request zu Xentral mit Retry-Logik. Gibt (status_code, data) zurück. """ if retries > XENTRAL_MAX_RETRIES: raise RuntimeError( f"Max retries ({XENTRAL_MAX_RETRIES}) exceeded for {method} {path}" ) try: logger.debug(f"{method} {path} (attempt {retries + 1})") resp = await client.request( method=method, url=path, params=params, json=json_body, ) # Versuchen, JSON zu lesen – sonst Text try: data = resp.json() except ValueError: data = resp.text if resp.is_error: logger.warning( f"HTTP {resp.status_code} from Xentral {method} {path}: {data}" ) else: logger.debug(f"Success: HTTP {resp.status_code}") return (resp.status_code, data) except httpx.TimeoutException: logger.warning(f"Timeout for {method} {path}, retrying...") await asyncio.sleep(2 ** retries) # Exponential backoff return await _make_request(client, method, path, params, json_body, retries + 1) except httpx.RequestError as exc: logger.warning(f"Request error for {method} {path}: {exc}, retrying...") await asyncio.sleep(2 ** retries) return await _make_request(client, method, path, params, json_body, retries + 1) # --------------------------------------------------------------------------- # MCP Server # --------------------------------------------------------------------------- app = Server("xentral-mcp") # --------------------------------------------------------------------------- # Tools deklarieren (Claude lernt hier, welche Felder es gibt) # --------------------------------------------------------------------------- @app.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="xentral_list_products", description=( "Listet Produkte aus Xentral. " "Verwende pageNumber und pageSize für Pagination. " "Optional kann nach Name oder SKU gefiltert werden." ), inputSchema={ "type": "object", "properties": { "pageNumber": { "type": "integer", "description": "Seitenzahl ab 1 (wird auf page[number] gemappt).", "minimum": 1, }, "pageSize": { "type": "integer", "description": "Anzahl Produkte pro Seite (wird auf page[size] gemappt).", "minimum": 1, "maximum": 200, }, "nameContains": { "type": "string", "description": "Optional: Filter nach Produktname (Teilstring).", }, "skuEquals": { "type": "string", "description": "Optional: exakte Artikelnummer (SKU).", }, }, }, ), types.Tool( name="xentral_get_product", description="Liest ein einzelnes Produkt aus Xentral per Produkt-ID.", inputSchema={ "type": "object", "properties": { "productId": { "type": "string", "description": "Produkt-ID aus Xentral.", } }, "required": ["productId"], }, ), types.Tool( name="xentral_list_customers", description=( "Listet Kunden aus Xentral. " "Verwende pageNumber und pageSize für Pagination. " "Optional kann nach Name oder E-Mail gefiltert werden." ), inputSchema={ "type": "object", "properties": { "pageNumber": { "type": "integer", "description": "Seitenzahl ab 1 (wird auf page[number] gemappt).", "minimum": 1, }, "pageSize": { "type": "integer", "description": "Anzahl Kunden pro Seite (wird auf page[size] gemappt).", "minimum": 1, "maximum": 200, }, "nameContains": { "type": "string", "description": "Optional: Filter nach Kundenname (Teilstring).", }, "emailContains": { "type": "string", "description": "Optional: Filter nach E-Mail-Adresse (Teilstring).", }, }, }, ), types.Tool( name="xentral_get_customer", description="Liest einen einzelnen Kunden aus Xentral per Kunden-ID.", inputSchema={ "type": "object", "properties": { "customerId": { "type": "string", "description": "Kunden-ID aus Xentral.", } }, "required": ["customerId"], }, ), types.Tool( name="xentral_raw_request", description=( "Low-level Xentral-API-Request. " "Nur verwenden, wenn explizit vom Nutzer gewünscht. " "Für normale Aufgaben besser die spezialisieren Tools nutzen." ), inputSchema={ "type": "object", "properties": { "method": { "type": "string", "enum": ["GET", "POST", "PATCH", "DELETE"], "description": "HTTP-Methode.", }, "path": { "type": "string", "description": "Relativer API-Pfad, z.B. 'products', 'customers/123'.", }, "params": { "type": "object", "description": "Optionale Query-Parameter als Key-Value-Objekt.", "additionalProperties": True, }, "body": { "type": "string", "description": "Optionaler JSON-Body als String.", }, }, "required": ["method", "path"], }, ), ] # --------------------------------------------------------------------------- # Tool-Aufrufe behandeln # --------------------------------------------------------------------------- @app.call_tool() async def call_tool(name: str, arguments: Dict[str, Any]) -> list[types.TextContent]: """Wird vom MCP-Client aufgerufen, wenn Claude ein Tool nutzt.""" logger.info(f"Tool called: {name} with args: {arguments}") async with httpx.AsyncClient( base_url=XENTRAL_BASE_URL, headers=_auth_headers(), timeout=XENTRAL_TIMEOUT, ) as client: # --------------------------------------------------------------- # Produkte: Liste # --------------------------------------------------------------- if name == "xentral_list_products": page_number = int(arguments.get("pageNumber", 1)) page_size = int(arguments.get("pageSize", 20)) name_contains = arguments.get("nameContains") sku_equals = arguments.get("skuEquals") # Validierung if page_number < 1: return [types.TextContent(type="text", text="pageNumber muss >= 1 sein")] if page_size < 1 or page_size > 200: return [types.TextContent(type="text", text="pageSize muss zwischen 1 und 200 liegen")] params: Dict[str, Any] = { "page[number]": page_number, "page[size]": page_size, } # TODO: Filter-Mapping an echte Xentral-API-Doku anpassen if name_contains: params["filter[name][key]"] = "name" params["filter[name][op]"] = "contains" params["filter[name][value]"] = name_contains if sku_equals: params["filter[sku][key]"] = "sku" params["filter[sku][op]"] = "eq" params["filter[sku][value]"] = sku_equals status_code, data = await _make_request(client, "GET", "products", params=params) if status_code >= 400: return [ types.TextContent( type="text", text=f"HTTP-Fehler {status_code} von Xentral bei xentral_list_products: {data}", ) ] text = json.dumps(data, indent=2, ensure_ascii=False) return [types.TextContent(type="text", text=text)] # --------------------------------------------------------------- # Produkte: Einzelnes Produkt # --------------------------------------------------------------- if name == "xentral_get_product": product_id = arguments["productId"] if not product_id or not str(product_id).strip(): return [types.TextContent(type="text", text="productId darf nicht leer sein")] status_code, data = await _make_request(client, "GET", f"products/{product_id}") if status_code >= 400: return [ types.TextContent( type="text", text=f"HTTP-Fehler {status_code} von Xentral bei xentral_get_product: {data}", ) ] text = json.dumps(data, indent=2, ensure_ascii=False) return [types.TextContent(type="text", text=text)] # --------------------------------------------------------------- # Kunden: Liste # --------------------------------------------------------------- if name == "xentral_list_customers": page_number = int(arguments.get("pageNumber", 1)) page_size = int(arguments.get("pageSize", 20)) name_contains = arguments.get("nameContains") email_contains = arguments.get("emailContains") # Validierung if page_number < 1: return [types.TextContent(type="text", text="pageNumber muss >= 1 sein")] if page_size < 1 or page_size > 200: return [types.TextContent(type="text", text="pageSize muss zwischen 1 und 200 liegen")] params: Dict[str, Any] = { "page[number]": page_number, "page[size]": page_size, } # TODO: Filter-Mapping an Xentral-Doku anpassen if name_contains: params["filter[name][key]"] = "name" params["filter[name][op]"] = "contains" params["filter[name][value]"] = name_contains if email_contains: params["filter[email][key]"] = "email" params["filter[email][op]"] = "contains" params["filter[email][value]"] = email_contains status_code, data = await _make_request(client, "GET", "customers", params=params) if status_code >= 400: return [ types.TextContent( type="text", text=f"HTTP-Fehler {status_code} von Xentral bei xentral_list_customers: {data}", ) ] text = json.dumps(data, indent=2, ensure_ascii=False) return [types.TextContent(type="text", text=text)] # --------------------------------------------------------------- # Kunden: Einzelner Kunde # --------------------------------------------------------------- if name == "xentral_get_customer": customer_id = arguments["customerId"] if not customer_id or not str(customer_id).strip(): return [types.TextContent(type="text", text="customerId darf nicht leer sein")] status_code, data = await _make_request(client, "GET", f"customers/{customer_id}") if status_code >= 400: return [ types.TextContent( type="text", text=f"HTTP-Fehler {status_code} von Xentral bei xentral_get_customer: {data}", ) ] text = json.dumps(data, indent=2, ensure_ascii=False) return [types.TextContent(type="text", text=text)] # --------------------------------------------------------------- # Low-level: xentral_raw_request # --------------------------------------------------------------- if name == "xentral_raw_request": method = str(arguments["method"]).upper() path = str(arguments["path"]).lstrip("/") params = arguments.get("params") or {} body_str = arguments.get("body") if method not in ["GET", "POST", "PATCH", "DELETE"]: return [types.TextContent(type="text", text=f"Ungültige HTTP-Methode: {method}")] json_body = None if body_str: try: json_body = json.loads(body_str) except json.JSONDecodeError as exc: return [ types.TextContent( type="text", text=f"Body ist kein gültiges JSON: {exc}", ) ] status_code, data = await _make_request( client, method=method, path=path, params=params, json_body=json_body, ) if status_code >= 400: text = f"HTTP {status_code} Fehler von Xentral:\n\n{json.dumps(data, indent=2, ensure_ascii=False)}" else: text = json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, dict) else str(data) return [types.TextContent(type="text", text=text)] # Fallback bei unbekanntem Tool logger.error(f"Unknown tool called: {name}") return [ types.TextContent( type="text", text=f"Unbekanntes Tool: {name}", ) ] # --------------------------------------------------------------------------- # main / stdio-Transport # --------------------------------------------------------------------------- async def main() -> None: logger.info("Starting Xentral MCP server...") try: async with stdio_server() as (read_stream, write_stream): await app.run( read_stream, write_stream, app.create_initialization_options(), ) except Exception as exc: logger.error(f"Server error: {exc}", exc_info=True) raise if __name__ == "__main__": asyncio.run(main())

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/matthiashuebner/xentral-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server