backend dev: create plan
This commit is contained in:
@@ -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