zerochat 8.0.0__tar.gz → 8.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 8.0.0
3
+ Version: 8.2.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "8.0.0"
7
+ version = "8.2.0"
8
8
  description = "Private local web client for AI models, tools and MCP services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 8.0.0
3
+ Version: 8.2.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -42,7 +42,7 @@ import webbrowser
42
42
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
43
43
  from pathlib import Path
44
44
 
45
- SOURCE_BACKEND_VERSION = "8.0.0"
45
+ SOURCE_BACKEND_VERSION = "8.2.0"
46
46
 
47
47
  def _read_source_version(filename: str) -> str | None:
48
48
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1488,8 +1488,16 @@ def public_tool_name(server_id: str, original: str) -> str:
1488
1488
  return name
1489
1489
 
1490
1490
 
1491
+ def _mcp_trace_text(value: object, max_len: int = 1000) -> str:
1492
+ """Normaliza diagnósticos MCP para consola sin exponer secretos comunes."""
1493
+ text = " ".join(str(value).strip().splitlines())
1494
+ text = re.sub(r"(?i)(\b(?:api[_-]?key|token|secret|password)\b\s*[=:]\s*)\S+", r"\1***", text)
1495
+ text = re.sub(r"(?i)(--(?:api[_-]?key|token|secret|password)\s+)\S+", r"\1***", text)
1496
+ return text[:max_len - 3] + "..." if len(text) > max_len else text
1497
+
1498
+
1491
1499
  class StdioMcpClient:
1492
- def __init__(self, command: str, args: list[str], cwd: str, env: dict[str, str]):
1500
+ def __init__(self, command: str, args: list[str], cwd: str, env: dict[str, str], trace=None):
1493
1501
  self.command = command
1494
1502
  self.args = args
1495
1503
  self.cwd = cwd
@@ -1500,12 +1508,14 @@ class StdioMcpClient:
1500
1508
  self._lock = threading.Lock()
1501
1509
  self._alive = False
1502
1510
  self.tools: list[dict] = []
1511
+ self._trace = trace or (lambda _message: None)
1503
1512
 
1504
1513
  def running(self) -> bool:
1505
1514
  return self._alive and self.process is not None and self.process.poll() is None
1506
1515
 
1507
1516
  def start(self, handshake_timeout: int = 30):
1508
1517
  cmd = [self.command] + self.args
1518
+ self._trace(f"launching: {_mcp_trace_text(' '.join(cmd))}")
1509
1519
  self.process = subprocess.Popen(
1510
1520
  cmd,
1511
1521
  stdin=subprocess.PIPE,
@@ -1530,11 +1540,14 @@ class StdioMcpClient:
1530
1540
  self.notify("notifications/initialized")
1531
1541
  tools_resp = self.request("tools/list", {}, timeout=10)
1532
1542
  self.tools = tools_resp.get("tools", [])
1543
+ self._trace(f"handshake completed; {len(self.tools)} tool(s) available")
1533
1544
 
1534
1545
  def _drain_stderr(self):
1535
1546
  if self.process and self.process.stderr:
1536
- for _ in self.process.stderr:
1537
- pass
1547
+ for line in self.process.stderr:
1548
+ line = _mcp_trace_text(line)
1549
+ if line:
1550
+ self._trace(f"stderr: {line}")
1538
1551
 
1539
1552
  def _read_stdout(self):
1540
1553
  try:
@@ -1556,6 +1569,10 @@ class StdioMcpClient:
1556
1569
  waiter.put(msg)
1557
1570
  finally:
1558
1571
  self._alive = False
1572
+ if self.process:
1573
+ return_code = self.process.poll()
1574
+ if return_code is not None:
1575
+ self._trace(f"process exited with code {return_code}")
1559
1576
  with self._lock:
1560
1577
  pending = list(self._pending.values())
1561
1578
  self._pending.clear()
@@ -1634,6 +1651,10 @@ class McpServiceManager:
1634
1651
  self.services = self._load_services()
1635
1652
  self.preferences = self._load_preferences()
1636
1653
 
1654
+ @staticmethod
1655
+ def _trace(server_id: str, message: str):
1656
+ console_log(f"[{time.strftime('%H:%M:%S')}] [MCP {server_id}] {_mcp_trace_text(message)}", flush=True)
1657
+
1637
1658
  def _ensure_default_services(self):
1638
1659
  dummy_dir = self.services_root / "dummy_mcp"
1639
1660
  dummy_dir.mkdir(parents=True, exist_ok=True)
@@ -1757,6 +1778,9 @@ for raw in sys.stdin:
1757
1778
  "package": "@playwright/mcp",
1758
1779
  "version": "0.0.81",
1759
1780
  "browser": "chromium"
1781
+ },
1782
+ "requirements": {
1783
+ "node": {"minimumMajor": 18}
1760
1784
  }
1761
1785
  }, indent=2), encoding="utf-8")
1762
1786
 
@@ -1796,43 +1820,9 @@ for raw in sys.stdin:
1796
1820
  "product": {
1797
1821
  "package": "@modelcontextprotocol/server-memory",
1798
1822
  "version": "2026.8.31"
1799
- }
1800
- }, indent=2), encoding="utf-8")
1801
-
1802
- # 4. lsp
1803
- lsp_dir = self.services_root / "lsp"
1804
- lsp_dir.mkdir(parents=True, exist_ok=True)
1805
- lsp_service = lsp_dir / "service.json"
1806
- lsp_installer = lsp_dir / "installer.json"
1807
- if not lsp_service.exists():
1808
- lsp_service.write_text(json.dumps({
1809
- "schemaVersion": 1,
1810
- "id": "lsp",
1811
- "displayName": {
1812
- "es": "LSP y Navegación de Código",
1813
- "en": "LSP & Code Intelligence"
1814
- },
1815
- "description": {
1816
- "es": "Servidor de protocolos de lenguaje (LSP): salto a definiciones, búsqueda de símbolos, referencias e inspección de tipos sin sobrecargar el contexto.",
1817
- "en": "Language Server Protocol (LSP) server: jump to definitions, symbol search, references, and type inspection without context overload."
1818
1823
  },
1819
- "enabledByDefault": False,
1820
- "transport": "stdio",
1821
- "launch": {
1822
- "executable": "${nodeExecutable}",
1823
- "args": ["${serviceDir}/node_modules/@axivo/mcp-lsp/dist/index.js"],
1824
- "cwd": "${serviceDir}",
1825
- "env": {},
1826
- "handshakeTimeoutSeconds": 30
1827
- }
1828
- }, indent=2), encoding="utf-8")
1829
- if not lsp_installer.exists():
1830
- lsp_installer.write_text(json.dumps({
1831
- "schemaVersion": 1,
1832
- "type": "npm",
1833
- "product": {
1834
- "package": "@axivo/mcp-lsp",
1835
- "version": "1.0.5"
1824
+ "requirements": {
1825
+ "node": {"minimumMajor": 18}
1836
1826
  }
1837
1827
  }, indent=2), encoding="utf-8")
1838
1828
 
@@ -1904,6 +1894,7 @@ for raw in sys.stdin:
1904
1894
  npm = shutil.which("npm") or "npm"
1905
1895
 
1906
1896
  if installer_file.exists():
1897
+ self._trace(server_id, f"reading installer: {installer_file.name}")
1907
1898
  marker = service_dir / ".installed.json"
1908
1899
  try:
1909
1900
  installer = json.loads(installer_file.read_text(encoding="utf-8"))
@@ -1917,9 +1908,33 @@ for raw in sys.stdin:
1917
1908
  if not package or not version:
1918
1909
  raise RuntimeError("El instalador npm debe definir package y version")
1919
1910
  if not shutil.which("node") or not shutil.which("npm"):
1920
- raise RuntimeError("Node.js 18+ y npm son necesarios para instalar este servicio MCP")
1911
+ raise RuntimeError("Node.js y npm son necesarios para instalar este servicio MCP")
1912
+
1913
+ requirements = installer.get("requirements", {})
1914
+ node_requirement = requirements.get("node")
1915
+ if node_requirement is None:
1916
+ # Compatibilidad con instaladores creados antes de requirements.node.
1917
+ required_node_major = int(product.get("nodeMajor", 18))
1918
+ elif not isinstance(node_requirement, dict):
1919
+ raise RuntimeError("El requisito Node.js debe ser un objeto en installer.json")
1920
+ else:
1921
+ required_node_major = node_requirement.get("minimumMajor")
1922
+ if not isinstance(required_node_major, int) or isinstance(required_node_major, bool) or required_node_major < 1:
1923
+ raise RuntimeError("El requisito Node.js debe definir minimumMajor como un entero positivo")
1924
+ version_result = subprocess.run(
1925
+ [node, "--version"], capture_output=True, text=True, timeout=5
1926
+ )
1927
+ version_match = re.match(r"v(\d+)", version_result.stdout.strip())
1928
+ if version_result.returncode != 0 or not version_match:
1929
+ raise RuntimeError("No se pudo comprobar la versión de Node.js necesaria para este servicio MCP")
1930
+ self._trace(server_id, f"Node.js detected: {version_result.stdout.strip()} (minimum {required_node_major})")
1931
+ if int(version_match.group(1)) < required_node_major:
1932
+ raise RuntimeError(
1933
+ f"{server_id} requiere Node.js {required_node_major}+; se detectó {version_result.stdout.strip()}"
1934
+ )
1921
1935
 
1922
- needs_install = not marker.exists()
1936
+ package_dir = service_dir / "node_modules" / Path(*package.split("/"))
1937
+ needs_install = not marker.exists() or not package_dir.is_dir()
1923
1938
  if not needs_install:
1924
1939
  try:
1925
1940
  installation = json.loads(marker.read_text(encoding="utf-8"))
@@ -1930,19 +1945,32 @@ for raw in sys.stdin:
1930
1945
 
1931
1946
  if needs_install:
1932
1947
  self.states[server_id] = "installing"
1948
+ self._trace(server_id, f"installing npm package: {package}@{version}")
1933
1949
  manifest = service_dir / "package.json"
1934
1950
  manifest.write_text(json.dumps({"private": True, "dependencies": {package: version}}, indent=2), encoding="utf-8")
1935
1951
  res = subprocess.run([npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=service_dir, capture_output=True, text=True, timeout=600)
1936
1952
  if res.returncode != 0:
1937
1953
  raise RuntimeError(f"Fallo instalando dependencias npm: {res.stderr or res.stdout}")
1954
+ self._trace(server_id, f"npm package installed: {package}@{version}")
1938
1955
  installation = {"type": "npm", "package": package, "version": version, "nodeExecutable": node}
1939
1956
  browser = product.get("browser")
1940
1957
  if browser:
1941
1958
  playwright_cli = service_dir / "node_modules" / "playwright" / "cli.js"
1942
- if playwright_cli.is_file():
1943
- subprocess.run([node, str(playwright_cli), "install", browser], cwd=service_dir, capture_output=True, text=True, timeout=600)
1944
- installation["browser"] = browser
1959
+ if not playwright_cli.is_file():
1960
+ raise RuntimeError("No se encontró Playwright después de instalar el servicio MCP")
1961
+ browser_result = subprocess.run(
1962
+ [node, str(playwright_cli), "install", browser], cwd=service_dir,
1963
+ capture_output=True, text=True, timeout=600
1964
+ )
1965
+ if browser_result.returncode != 0:
1966
+ raise RuntimeError(
1967
+ f"Fallo instalando el navegador {browser} de Playwright: "
1968
+ f"{browser_result.stderr or browser_result.stdout}"
1969
+ )
1970
+ installation["browser"] = browser
1945
1971
  marker.write_text(json.dumps(installation, indent=2), encoding="utf-8")
1972
+ else:
1973
+ self._trace(server_id, f"npm package already installed: {package}@{version}")
1946
1974
 
1947
1975
  return {
1948
1976
  "serviceDir": str(service_dir),
@@ -1960,6 +1988,8 @@ for raw in sys.stdin:
1960
1988
  if current and current.running():
1961
1989
  return self.list_servers()
1962
1990
  self.states[server_id] = "starting"
1991
+ client = None
1992
+ self._trace(server_id, "start requested")
1963
1993
  try:
1964
1994
  service_dir = server["_directory"]
1965
1995
  values = self._prepare_service(server_id, server)
@@ -1987,7 +2017,10 @@ for raw in sys.stdin:
1987
2017
  for k, v in launch.get("env", {}).items():
1988
2018
  env[k] = self._expand(v, values)
1989
2019
 
1990
- client = StdioMcpClient(command, args, str(service_dir), env)
2020
+ client = StdioMcpClient(
2021
+ command, args, str(service_dir), env,
2022
+ trace=lambda message: self._trace(server_id, message)
2023
+ )
1991
2024
  client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
1992
2025
  self.clients[server_id] = client
1993
2026
  self.states[server_id] = "running"
@@ -1995,11 +2028,15 @@ for raw in sys.stdin:
1995
2028
  entry = self.preferences.setdefault(server_id, {})
1996
2029
  entry["enabled"] = True
1997
2030
  self._save_preferences()
2031
+ self._trace(server_id, "started successfully")
1998
2032
  except Exception as exc:
1999
2033
  self.states[server_id] = "error"
2000
2034
  self.errors[server_id] = str(exc)
2035
+ self._trace(server_id, f"start failed: {exc}")
2001
2036
  if server_id in self.clients:
2002
2037
  self.clients.pop(server_id).stop()
2038
+ elif client:
2039
+ client.stop()
2003
2040
  return self.list_servers()
2004
2041
 
2005
2042
  def stop(self, server_id: str) -> list[dict]:
@@ -2064,7 +2101,6 @@ for raw in sys.stdin:
2064
2101
 
2065
2102
 
2066
2103
  GLOBAL_MCP_MANAGER = McpServiceManager()
2067
-
2068
2104
  DEFAULT_HEARTBEAT_TIMEOUT = float(os.environ.get("ZEROCHAT_HEARTBEAT_TIMEOUT", "60.0"))
2069
2105
  DEFAULT_HEARTBEAT_GRACE = float(os.environ.get("ZEROCHAT_HEARTBEAT_GRACE", "45.0"))
2070
2106
  DEFAULT_HEARTBEAT_POLL = float(os.environ.get("ZEROCHAT_HEARTBEAT_POLL", "5.0"))
File without changes
File without changes
File without changes