kontor_mcp.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. from __future__ import annotations
  2. import json
  3. import uuid
  4. from typing import Any
  5. import httpx
  6. class KontorMCPError(RuntimeError):
  7. """Fehler bei der Kommunikation mit dem Kontor MCP Server."""
  8. class KontorMCPClient:
  9. """
  10. MCP Streamable-HTTP Client für die 3CX-Telefonie-Middleware.
  11. Der Client kapselt die MCP-Kommunikation vollständig.
  12. Die Telefonie-Middleware kennt keine mssRest-Details.
  13. """
  14. def __init__(
  15. self,
  16. url: str,
  17. token: str,
  18. timeout: float = 10.0,
  19. ) -> None:
  20. self.url = url.rstrip("/")
  21. self.token = token
  22. self.timeout = timeout
  23. self.session_id: str | None = None
  24. self._request_id = 0
  25. def _next_id(self) -> int:
  26. self._request_id += 1
  27. return self._request_id
  28. def _headers(self) -> dict[str, str]:
  29. headers = {
  30. "Authorization": f"Bearer {self.token}",
  31. "Content-Type": "application/json",
  32. "Accept": "application/json, text/event-stream",
  33. "X-Request-ID": str(uuid.uuid4()),
  34. }
  35. if self.session_id:
  36. headers["Mcp-Session-Id"] = self.session_id
  37. return headers
  38. async def _post(
  39. self,
  40. payload: dict[str, Any],
  41. ) -> httpx.Response:
  42. try:
  43. async with httpx.AsyncClient(
  44. timeout=self.timeout,
  45. follow_redirects=True,
  46. ) as client:
  47. response = await client.post(
  48. self.url,
  49. headers=self._headers(),
  50. json=payload,
  51. )
  52. except httpx.TimeoutException as exc:
  53. raise KontorMCPError(
  54. "Kontor MCP timeout"
  55. ) from exc
  56. except httpx.HTTPError as exc:
  57. raise KontorMCPError(
  58. f"Kontor MCP HTTP error: {exc}"
  59. ) from exc
  60. # MCP Streamable HTTP liefert die Session-ID beim Initialize.
  61. session_id = response.headers.get("mcp-session-id")
  62. if session_id:
  63. self.session_id = session_id
  64. if response.status_code >= 400:
  65. raise KontorMCPError(
  66. f"Kontor MCP HTTP {response.status_code}: "
  67. f"{response.text[:500]}"
  68. )
  69. return response
  70. @staticmethod
  71. def _extract_result(response: httpx.Response) -> Any:
  72. content_type = response.headers.get(
  73. "content-type",
  74. "",
  75. ).lower()
  76. if "application/json" in content_type:
  77. data = response.json()
  78. if "error" in data:
  79. error = data["error"]
  80. raise KontorMCPError(
  81. f"MCP error {error.get('code')}: "
  82. f"{error.get('message')}"
  83. )
  84. return data.get("result")
  85. # Streamable HTTP / SSE
  86. for line in response.text.splitlines():
  87. line = line.strip()
  88. if not line.startswith("data:"):
  89. continue
  90. raw = line[5:].strip()
  91. if not raw:
  92. continue
  93. try:
  94. data = json.loads(raw)
  95. except json.JSONDecodeError:
  96. continue
  97. if "error" in data:
  98. error = data["error"]
  99. raise KontorMCPError(
  100. f"MCP error {error.get('code')}: "
  101. f"{error.get('message')}"
  102. )
  103. if "result" in data:
  104. return data["result"]
  105. # Manche MCP-Server beantworten Notifications mit 202/leer.
  106. if not response.text.strip():
  107. return None
  108. raise KontorMCPError(
  109. "Kontor MCP lieferte keine verwertbare Antwort"
  110. )
  111. async def initialize(self) -> dict[str, Any]:
  112. """
  113. MCP Handshake.
  114. Wichtig:
  115. 1. initialize ohne Session
  116. 2. Session-ID aus Response übernehmen
  117. 3. notifications/initialized mit Session senden
  118. """
  119. payload = {
  120. "jsonrpc": "2.0",
  121. "id": self._next_id(),
  122. "method": "initialize",
  123. "params": {
  124. "protocolVersion": "2025-03-26",
  125. "capabilities": {},
  126. "clientInfo": {
  127. "name": "3cx-telefonie-middleware",
  128. "version": "1.0.0",
  129. },
  130. },
  131. }
  132. response = await self._post(payload)
  133. if not self.session_id:
  134. raise KontorMCPError(
  135. "MCP Server hat keine Session-ID geliefert"
  136. )
  137. result = self._extract_result(response)
  138. # MCP Notification: keine id, keine Antwort erforderlich.
  139. notification = {
  140. "jsonrpc": "2.0",
  141. "method": "notifications/initialized",
  142. }
  143. await self._post(notification)
  144. return result or {}
  145. async def list_tools(self) -> list[dict[str, Any]]:
  146. result = await self._request(
  147. "tools/list",
  148. {},
  149. )
  150. if not result:
  151. return []
  152. return result.get("tools", [])
  153. async def _request(
  154. self,
  155. method: str,
  156. params: dict[str, Any] | None = None,
  157. ) -> Any:
  158. payload: dict[str, Any] = {
  159. "jsonrpc": "2.0",
  160. "id": self._next_id(),
  161. "method": method,
  162. }
  163. if params is not None:
  164. payload["params"] = params
  165. response = await self._post(payload)
  166. return self._extract_result(response)
  167. async def call_tool(
  168. self,
  169. name: str,
  170. arguments: dict[str, Any] | None = None,
  171. ) -> Any:
  172. return await self._request(
  173. "tools/call",
  174. {
  175. "name": name,
  176. "arguments": arguments or {},
  177. },
  178. )
  179. async def find_customer_by_phone(
  180. self,
  181. phone: str,
  182. ) -> Any:
  183. """
  184. Kundensuche über mehrere deterministische Telefonformate.
  185. Kontor kann Telefonnummern je nach Datenbestand unterschiedlich
  186. gespeichert haben. Wir versuchen deshalb E.164, national,
  187. ohne führende 0 und ohne Plus/Formatierung.
  188. Es wird niemals fuzzy über Telefonnummern gesucht.
  189. """
  190. variants = self._phone_variants(phone)
  191. matches: list[tuple[str, Any]] = []
  192. for variant in variants:
  193. result = await self.call_tool(
  194. "find_customer_by_phone",
  195. {"phone": variant},
  196. )
  197. # Nur tatsächlich gefundene Treffer sammeln.
  198. if self._phone_result_found(result):
  199. matches.append((variant, result))
  200. if not matches:
  201. return {
  202. "status": "not_found",
  203. "input": phone,
  204. "variants_tried": variants,
  205. }
  206. # Doppelte Treffer desselben Kunden zusammenführen.
  207. unique: dict[str, tuple[str, Any]] = {}
  208. for variant, result in matches:
  209. customer_id = self._customer_identity(result)
  210. if customer_id is None:
  211. # Ohne ID nicht künstlich zusammenführen.
  212. unique[f"{variant}:{len(unique)}"] = (
  213. variant,
  214. result,
  215. )
  216. else:
  217. unique[str(customer_id)] = (
  218. variant,
  219. result,
  220. )
  221. if len(unique) > 1:
  222. return {
  223. "status": "ambiguous",
  224. "input": phone,
  225. "variants_tried": variants,
  226. "matches": [
  227. {
  228. "matched_input": variant,
  229. "result": result,
  230. }
  231. for variant, result in unique.values()
  232. ],
  233. }
  234. variant, result = next(iter(unique.values()))
  235. # Bestehenden MCP-Response ergänzen, statt ihn zu verlieren.
  236. if isinstance(result, dict):
  237. result = dict(result)
  238. result["matched_input"] = variant
  239. result["input"] = phone
  240. result["phone_variants_tried"] = variants
  241. return result
  242. @staticmethod
  243. def _phone_variants(phone: str) -> list[str]:
  244. import re
  245. raw = str(phone or "").strip()
  246. if not raw:
  247. return []
  248. digits = re.sub(r"\D", "", raw)
  249. if not digits:
  250. return []
  251. variants: list[str] = []
  252. def add(value: str) -> None:
  253. if value and value not in variants:
  254. variants.append(value)
  255. # Bereits vorhandene Schreibweise zuerst.
  256. add(raw)
  257. # Deutsche Mobil-/Festnetznummern.
  258. if digits.startswith("49"):
  259. national = "0" + digits[2:]
  260. subscriber = digits[2:]
  261. add("+" + digits)
  262. add(digits)
  263. add(national)
  264. add(subscriber)
  265. elif digits.startswith("0"):
  266. subscriber = digits[1:]
  267. add(digits)
  268. add("+49" + subscriber)
  269. add("49" + subscriber)
  270. add(subscriber)
  271. else:
  272. # Für bereits international gespeicherte Nummern
  273. # ohne +.
  274. add("+49" + digits)
  275. add("49" + digits)
  276. add("0" + digits)
  277. add(digits)
  278. return variants
  279. @staticmethod
  280. def _phone_result_found(result: Any) -> bool:
  281. if not isinstance(result, dict):
  282. return False
  283. status = result.get("status")
  284. if status in {
  285. "exact",
  286. "high_confidence",
  287. }:
  288. return True
  289. if result.get("found") is True:
  290. return True
  291. # Manche MCP-Responses liefern den Kunden direkt.
  292. return bool(
  293. result.get("customer_id")
  294. or result.get("customer_number")
  295. )
  296. @staticmethod
  297. def _customer_identity(result: Any) -> Any:
  298. if not isinstance(result, dict):
  299. return None
  300. return (
  301. result.get("customer_id")
  302. or result.get("customer_number")
  303. or result.get("id")
  304. )
  305. async def find_customer_by_email(
  306. self,
  307. email: str,
  308. ) -> Any:
  309. return await self.call_tool(
  310. "find_customer_by_email",
  311. {"email": email},
  312. )
  313. async def find_customer_by_number(
  314. self,
  315. customer_number: str,
  316. ) -> Any:
  317. return await self.call_tool(
  318. "find_customer_by_number",
  319. {"customer_number": customer_number},
  320. )
  321. async def search_products(
  322. self,
  323. query: str,
  324. ) -> Any:
  325. return await self.call_tool(
  326. "search_products",
  327. {"query": query},
  328. )
  329. async def get_product(
  330. self,
  331. product_id: str,
  332. ) -> Any:
  333. return await self.call_tool(
  334. "get_product",
  335. {"product_id": product_id},
  336. )
  337. async def get_product_categories(self) -> Any:
  338. return await self.call_tool(
  339. "get_product_categories",
  340. {},
  341. )
  342. async def get_order(
  343. self,
  344. order_number: str,
  345. ) -> Any:
  346. return await self.call_tool(
  347. "get_order",
  348. {"order_number": order_number},
  349. )
  350. async def get_customer_context(
  351. self,
  352. *,
  353. phone: str | None = None,
  354. customer_number: str | None = None,
  355. ) -> Any:
  356. arguments: dict[str, Any] = {}
  357. if phone:
  358. arguments["phone"] = phone
  359. if customer_number:
  360. arguments["customer_number"] = customer_number
  361. return await self.call_tool(
  362. "get_customer_context",
  363. arguments,
  364. )
  365. async def get_order_context(
  366. self,
  367. order_number: str,
  368. ) -> Any:
  369. return await self.call_tool(
  370. "get_order_context",
  371. {"order_number": order_number},
  372. )
  373. async def get_product_context(
  374. self,
  375. query: str | None = None,
  376. product_id: str | None = None,
  377. ) -> Any:
  378. arguments: dict[str, Any] = {}
  379. if query:
  380. arguments["query"] = query
  381. if product_id:
  382. arguments["product_id"] = product_id
  383. return await self.call_tool(
  384. "get_product_context",
  385. arguments,
  386. )