backend dev: create plan

This commit is contained in:
2026-09-19 23:47:45 +03:00
parent eb84fb96af
commit 012c735ff5
19 changed files with 882 additions and 2 deletions
View File
+187
View File
@@ -0,0 +1,187 @@
import json
import re
from typing import Any
from mcp_types import TextContent
def _as_dict(result: Any) -> dict:
"""Normalize MCP call_tool output to a dict."""
if result is None:
raise ValueError("MCP returned None")
# Direct dict
if isinstance(result, dict):
return result
if not isinstance(result, list):
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
if not result:
raise ValueError("MCP returned an empty list")
# Find the first text block (dict or TextContent object)
text: str | None = None
for block in result:
if isinstance(block, TextContent):
text = block.text
break
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
break
# Last-resort: the block itself is structured data
if isinstance(block, dict) and "id" in block:
return block
if text is None:
raise ValueError(f"No text block found in {repr(result)[:300]}")
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Text block is not JSON: {text[:300]}") from e
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
return parsed[0]
raise ValueError(f"Parsed JSON is neither dict nor list[dict]: {type(parsed)}")
def _as_list(result: Any) -> list[dict]:
if result is None:
return []
if isinstance(result, list) and result and isinstance(result[0], TextContent):
text = result[0].text
elif isinstance(result, list) and result and isinstance(result[0], dict) and "type" in result[0]:
text = result[0].get("text", "")
elif isinstance(result, list):
return result # already a list of dicts
else:
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
parsed = json.loads(text)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict):
for key in ("items", "comments", "issues", "results"):
if isinstance(parsed.get(key), list):
return parsed[key]
return [parsed]
raise ValueError(f"Parsed JSON is not a list: {type(parsed)}")
def _first(d: dict, *keys, default=None):
for k in keys:
if k in d and d[k] is not None:
return d[k]
return default
def _extract_base64(result: Any) -> str | None:
"""Pull a base64 image payload out of an MCP call_tool result.
Handles all the shapes a YouTrack MCP server might return:
- ImageContent block (has .data and .mimeType)
- TextContent block with raw base64 or a JSON wrapper
- dict with {"data": "..."} or {"content": "..."}
- plain string of base64
"""
if result is None:
return None
# Plain string
if isinstance(result, str):
return result if _looks_like_base64(result) else None
# Unwrap CallToolResult-like objects
content = getattr(result, "content", None)
if content is not None:
return _extract_base64(content)
# List of content blocks
if isinstance(result, list):
for block in result:
got = _extract_base64(block)
if got:
return got
return None
# Dict wrapper
if isinstance(result, dict):
# Image content: {"type": "image", "data": "...", "mimeType": "..."}
if result.get("type") == "image" and result.get("data"):
return result["data"]
# Direct data field
for key in ("data", "content", "base64", "image"):
if isinstance(result.get(key), str):
val = result[key]
if _looks_like_base64(val):
return val
# Text wrapper whose text is base64 or JSON containing base64
text = result.get("text")
if isinstance(text, str):
try:
parsed = json.loads(text)
return _extract_base64(parsed)
except json.JSONDecodeError:
return text if _looks_like_base64(text) else None
return None
# SDK TextContent / ImageContent objects (attribute access)
if isinstance(result, TextContent):
text = result.text
try:
return _extract_base64(json.loads(text))
except json.JSONDecodeError:
return text if _looks_like_base64(text) else None
# ImageContent has .data and .mime_type (or .mimeType)
if hasattr(result, "data") and hasattr(result, "type") and getattr(result, "type") == "image":
return getattr(result, "data")
return None
B64_RE = re.compile(r"^[A-Za-z0-9+/=\s]+$")
def _looks_like_base64(s: str) -> bool:
"""Very cheap check — base64 has no whitespace-heavy content and uses a limited alphabet."""
s = s.strip()
if len(s) < 32:
return False
return bool(B64_RE.match(s))
async def _fetch_image_base64(self, issue_id: str, attachment_id: str) -> str:
result = await self.mcp.call_tool(
"issue_attachment_download",
{"issueId": issue_id, "attachmentId": attachment_id, "downloadToFile": False},
)
# Result contains base64 or a URL you can fetch
return result # base64 string
def _extract_text(result: Any) -> str:
"""Pull a single string out of an MCP result, whichever shape it is."""
content = getattr(result, "content", None) or result
if isinstance(content, list) and content:
block = content[0]
if isinstance(block, dict):
return block.get("text") or json.dumps(block)
if isinstance(content, dict):
return content.get("text") or json.dumps(content)
return str(content)
NOT_FOUND_HINTS = (
"not found",
"does not exist",
"doesn't exist",
"no such",
"unknown issue",
"404",
)
def _looks_like_not_found(text: str) -> bool:
lowered = text.lower()
return any(h in lowered for h in NOT_FOUND_HINTS)
+31
View File
@@ -0,0 +1,31 @@
import json
from openai import OpenAI
class LLMClient:
def __init__(self, base_url, api_key: str | None = None, model: str = "gpt-4o-mini"):
self.model = model
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
images = images or []
if images:
content = [{"type": "text", "text": user}]
for b64 in images:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
else:
content = user
kwargs = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": content},
],
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = self.client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
+90
View File
@@ -0,0 +1,90 @@
import asyncio
import logging
import httpx
from contextlib import AsyncExitStack
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client
from common.as_type import _extract_text, _looks_like_not_found
logger = logging.getLogger("youtrack_mcp")
logger.setLevel(logging.DEBUG)
class IssueNotFound(Exception):
"""Raised when a YouTrack issue doesn't exist or isn't accessible."""
class YouTrackMCPClient:
"""Wraps an MCP connection to YouTrack, owning the lifecycle in a dedicated task."""
def __init__(self, endpoint_url: str, token: str, proxy: str | None = None):
self.endpoint_url = endpoint_url
self.token = token
self.proxy = proxy
self._client = None
self._lifecycle_task = None
self._ready = asyncio.Event()
self._stop = asyncio.Event()
async def _run(self):
"""Single task that owns the entire MCP client lifecycle."""
async with AsyncExitStack() as stack:
http = await stack.enter_async_context(
httpx.AsyncClient(
headers={"Authorization": f"Bearer {self.token}"},
proxy=self.proxy,
timeout=httpx.Timeout(30.0, connect=10.0, read=300.0),
follow_redirects=True,
)
)
transport = streamable_http_client(
self.endpoint_url,
http_client=http,
)
self._client = await stack.enter_async_context(Client(transport))
self._ready.set()
await self._stop.wait() # keep the context alive
async def connect(self):
"""Start the lifecycle task and wait until the client is ready."""
self._lifecycle_task = asyncio.create_task(self._run())
await self._ready.wait()
return self
async def close(self):
"""Signal the lifecycle task to exit and wait for it to finish."""
if self._lifecycle_task is None:
return
self._stop.set()
await self._lifecycle_task
self._lifecycle_task = None
async def call_tool(self, name: str, arguments: dict):
logger.debug("MCP CALL: %s with %s", name, arguments)
result = await self._call_tool_or_raise(name, arguments)
logger.debug("MCP RAW RESULT: %s", repr(result)[:500])
return result.structured_content or result.content
async def _call_tool_or_raise(self, name: str, arguments: dict):
try:
result = await self._client.call_tool(name, arguments)
logger.debug("call RESULT: %s", result)
except MCPError as e:
# Drill into cause chain for HTTP status
cause = e.__cause__
while cause:
if isinstance(cause, httpx.HTTPStatusError):
if cause.response.status_code == 404:
raise IssueNotFound(f"{arguments} not found") from e
raise
cause = cause.__cause__
raise
# MCP-level error flag
if getattr(result, "is_error", False):
text = _extract_text(result)
if _looks_like_not_found(text):
raise IssueNotFound(text)
raise RuntimeError(f"MCP tool {name} failed: {text}")
return result