Fix MiniMax MCP startup handling

This commit is contained in:
2026-05-18 15:46:44 +02:00
parent 7356a1812e
commit aab630661f
2 changed files with 102 additions and 16 deletions

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from collections import deque
from contextlib import suppress
import json
import os
from dataclasses import dataclass
@@ -107,6 +109,9 @@ class MinimaxImageUnderstandClient:
self._request_id = 0
self._stdout: asyncio.StreamReader | None = None
self._stdin: asyncio.StreamWriter | None = None
self._stderr: asyncio.StreamReader | None = None
self._stderr_task: asyncio.Task[None] | None = None
self._stderr_tail: deque[str] = deque(maxlen=20)
self._lock = asyncio.Lock()
async def __aenter__(self) -> "MinimaxImageUnderstandClient":
@@ -129,7 +134,7 @@ class MinimaxImageUnderstandClient:
*self.config.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=env,
)
except FileNotFoundError as exc:
@@ -138,25 +143,32 @@ class MinimaxImageUnderstandClient:
) from exc
if self._process.stdin is None or self._process.stdout is None:
await self._terminate_process()
raise MCPError("MiniMax MCP process did not expose stdio pipes")
self._stdin = self._process.stdin
self._stdout = self._process.stdout
self._stderr = self._process.stderr
self._stderr_task = asyncio.create_task(self._drain_stderr())
request_timeout = self.config.timeout if self.config.timeout is not None else 120.0
await self._request(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "multi-bot-tg",
"version": "1.0.0",
try:
await self._request(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "multi-bot-tg",
"version": "1.0.0",
},
},
},
timeout=request_timeout,
)
await self._notify("notifications/initialized", {})
timeout=request_timeout,
)
await self._notify("notifications/initialized", {})
except Exception:
await self._terminate_process()
raise
async def close(self) -> None:
process = self._process
@@ -186,6 +198,8 @@ class MinimaxImageUnderstandClient:
self._stdin = None
self._stdout = None
self._stderr = None
await self._stop_stderr_task()
async def list_tools(self) -> list[dict]:
if self._process is None:
@@ -274,7 +288,13 @@ class MinimaxImageUnderstandClient:
if timeout is None:
return await _wait_for_response()
return await asyncio.wait_for(_wait_for_response(), timeout=timeout)
try:
return await asyncio.wait_for(_wait_for_response(), timeout=timeout)
except TimeoutError as exc:
raise MCPError(
f"Timed out waiting for MiniMax MCP '{method}' response after {timeout:.0f}s"
f"{self._format_stderr_tail()}"
) from exc
async def _write_message(self, payload: dict[str, Any]) -> None:
if self._stdin is None:
@@ -289,10 +309,11 @@ class MinimaxImageUnderstandClient:
if self._stdout is None:
raise MCPError("MiniMax MCP stdout is not available")
header_block = await self._stdout.readuntil(b"\r\n\r\n")
header_block = await self._read_header_block()
headers = header_block.decode("ascii", errors="ignore").split("\r\n")
content_length = None
for header in headers:
header = header.strip()
if header.lower().startswith("content-length:"):
content_length = int(header.split(":", 1)[1].strip())
break
@@ -302,6 +323,70 @@ class MinimaxImageUnderstandClient:
payload = await self._stdout.readexactly(content_length)
return json.loads(payload.decode("utf-8"))
async def _read_header_block(self) -> bytes:
if self._stdout is None:
raise MCPError("MiniMax MCP stdout is not available")
buffer = bytearray()
while True:
chunk = await self._stdout.read(1)
if not chunk:
raise MCPError(f"MiniMax MCP stdout closed before a response{self._format_stderr_tail()}")
buffer.extend(chunk)
if buffer.endswith(b"\r\n\r\n"):
return bytes(buffer)
if buffer.endswith(b"\n\n"):
return bytes(buffer).replace(b"\n", b"\r\n")
if len(buffer) > 64 * 1024:
raise MCPError("MiniMax MCP response headers exceeded 64KB")
async def _drain_stderr(self) -> None:
if self._stderr is None:
return
while True:
line = await self._stderr.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").strip()
if text:
self._stderr_tail.append(text)
def _format_stderr_tail(self) -> str:
if not self._stderr_tail:
return ""
return "\nMiniMax MCP stderr tail:\n" + "\n".join(self._stderr_tail)
async def _stop_stderr_task(self) -> None:
task = self._stderr_task
self._stderr_task = None
if task is None:
return
task.cancel()
with suppress(asyncio.CancelledError):
await task
async def _terminate_process(self) -> None:
process = self._process
self._process = None
if process is None:
return
try:
process.terminate()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(process.wait(), timeout=5)
except Exception:
try:
process.kill()
except ProcessLookupError:
pass
await self._stop_stderr_task()
self._stdin = None
self._stdout = None
self._stderr = None
def _extract_text(self, result: Any) -> str:
if isinstance(result, dict):
content = result.get("content")

View File

@@ -336,16 +336,17 @@ class AIService:
async def _fetch_mcp_tool_schemas(self) -> list[dict]:
if self._mcp_tools_fetched:
return self._mcp_tool_schemas or []
self._mcp_tools_fetched = True
if self.config.image_understand is None:
log.info("MCP tools: image_understand not configured, skipping")
self._mcp_tool_schemas = []
self._mcp_tools_fetched = True
return []
log.info("MCP tools: fetching tool list from %s", self.config.image_understand.command)
try:
async with MinimaxImageUnderstandClient(self.config.image_understand) as mcp_client:
raw = await mcp_client.list_tools()
self._mcp_tool_schemas = mcp_tools_to_openai_schemas(raw)
self._mcp_tools_fetched = True
names = [s["function"]["name"] for s in self._mcp_tool_schemas]
log.info("MCP tools: loaded %d tool(s): %s", len(names), ", ".join(names) or "(none)")
except Exception: