backend dev: create plan
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user