mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
Python 3.14 stdlib package wiring git to the todo MCP server: - closer (post-commit): runs the done_check of each Todo: trailer task and close_task's it only on exit 0. It FAILS OPEN: the commit already happened, so a down server, bad token or no gh prints one warning and exits 0 instead of failing the hook. - sweep: nightly drift report (systemd units shipped, not enabled). - import-pr: a merged PR's '## Todo:' items become tasks, deduped. - A done_check runs only if its sha256 is in ~/.config/todo-hooks/trusted-checks; 'Check: manual' notes are never auto-closed. - Hooks exposed in .pre-commit-hooks.yaml (post-commit, post-merge, post-rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
6.2 KiB
Python
190 lines
6.2 KiB
Python
"""MCP-over-HTTP JSON-RPC client for the todo server (stdlib only)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import http.client
|
|
import json
|
|
from typing import TYPE_CHECKING, Any, Protocol, Self
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
DEFAULT_URL = "http://127.0.0.1:8735/mcp"
|
|
DEFAULT_TIMEOUT = 5.0
|
|
_PROTOCOL = "2025-03-26"
|
|
_HTTP_AUTH = (401, 403)
|
|
_HTTP_SERVER_ERROR = 500
|
|
|
|
|
|
class McpError(Exception):
|
|
"""Any failure talking to the todo MCP server."""
|
|
|
|
|
|
class McpUnavailable(McpError):
|
|
"""Server down, timed out, or answering 5xx."""
|
|
|
|
|
|
class McpAuthError(McpError):
|
|
"""No token, or the server rejected it."""
|
|
|
|
|
|
class McpToolError(McpError):
|
|
"""The tool ran but reported an error."""
|
|
|
|
|
|
class TaskClient(Protocol):
|
|
"""What the hooks need from a client; tests substitute a fake."""
|
|
|
|
def call(self, tool: str, arguments: dict[str, str] | None = None) -> str:
|
|
"""Call a tool and return its text."""
|
|
...
|
|
|
|
def close(self) -> None:
|
|
"""Release the server session."""
|
|
...
|
|
|
|
|
|
def load_token(env: Mapping[str, str], token_file: Path) -> str:
|
|
"""Return ``TODO_MCP_TOKEN`` from env, else the token file's content."""
|
|
token = env.get("TODO_MCP_TOKEN", "").strip()
|
|
if not token:
|
|
try:
|
|
token = token_file.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
token = ""
|
|
if not token:
|
|
msg = f"no token: set TODO_MCP_TOKEN or create {token_file}"
|
|
raise McpAuthError(msg)
|
|
return token
|
|
|
|
|
|
def _parse_reply(content_type: str, body: str) -> dict[str, Any]:
|
|
candidates = [body]
|
|
if "text/event-stream" in content_type:
|
|
candidates = [
|
|
row.removeprefix("data:").strip()
|
|
for row in body.splitlines()
|
|
if row.startswith("data:")
|
|
]
|
|
for raw in candidates:
|
|
try:
|
|
message = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(message, dict):
|
|
return message
|
|
msg = f"unparseable response: {body[:80]!r}"
|
|
raise McpError(msg)
|
|
|
|
|
|
class McpClient:
|
|
"""One lazily initialised MCP session; ``close`` it (or use ``with``)."""
|
|
|
|
def __init__(self, url: str, token: str, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
if not url.startswith(("http://", "https://")):
|
|
msg = f"unsupported MCP url: {url}"
|
|
raise McpError(msg)
|
|
self.url = url
|
|
self._token = token
|
|
self._timeout = timeout
|
|
self._session: str | None = None
|
|
self._ready = False
|
|
self._next_id = 0
|
|
|
|
@classmethod
|
|
def from_env(
|
|
cls, env: Mapping[str, str], token_file: Path, timeout: float = DEFAULT_TIMEOUT
|
|
) -> McpClient:
|
|
"""Build from ``TODO_MCP_URL`` and the token (env or file)."""
|
|
url = env.get("TODO_MCP_URL") or DEFAULT_URL
|
|
return cls(url, load_token(env, token_file), timeout)
|
|
|
|
def __enter__(self) -> Self:
|
|
return self
|
|
|
|
def __exit__(self, *_exc: object) -> None:
|
|
self.close()
|
|
|
|
def _send(
|
|
self, payload: dict[str, Any] | None, method: str = "POST"
|
|
) -> tuple[str, str, Mapping[str, str]]:
|
|
headers = {
|
|
"Authorization": f"Bearer {self._token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
}
|
|
if self._session:
|
|
headers["Mcp-Session-Id"] = self._session
|
|
data = json.dumps(payload).encode() if payload is not None else None
|
|
request = urllib.request.Request(
|
|
self.url, data=data, headers=headers, method=method
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self._timeout) as reply:
|
|
body = reply.read().decode("utf-8", "replace")
|
|
return reply.headers.get("Content-Type", ""), body, reply.headers
|
|
except urllib.error.HTTPError as err:
|
|
err.close()
|
|
if err.code in _HTTP_AUTH:
|
|
msg = f"server rejected the token (HTTP {err.code})"
|
|
raise McpAuthError(msg) from err
|
|
msg = f"HTTP {err.code}"
|
|
if err.code >= _HTTP_SERVER_ERROR:
|
|
raise McpUnavailable(msg) from err
|
|
raise McpError(msg) from err
|
|
except (urllib.error.URLError, OSError, http.client.HTTPException) as err:
|
|
msg = f"cannot reach {self.url}: {getattr(err, 'reason', err)}"
|
|
raise McpUnavailable(msg) from err
|
|
|
|
def _rpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
self._next_id += 1
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": self._next_id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
ctype, body, headers = self._send(payload)
|
|
if method == "initialize":
|
|
self._session = headers.get("Mcp-Session-Id")
|
|
message = _parse_reply(ctype, body)
|
|
if "error" in message:
|
|
raise McpError(str(message["error"].get("message", message["error"])))
|
|
return message.get("result", {})
|
|
|
|
def _handshake(self) -> None:
|
|
info = {"name": "todo-hooks", "version": "1"}
|
|
self._rpc(
|
|
"initialize",
|
|
{"protocolVersion": _PROTOCOL, "capabilities": {}, "clientInfo": info},
|
|
)
|
|
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
self._ready = True
|
|
|
|
def call(self, tool: str, arguments: dict[str, str] | None = None) -> str:
|
|
"""Call `tool` and return its text; ``McpToolError`` if it reports one."""
|
|
if not self._ready:
|
|
self._handshake()
|
|
result = self._rpc("tools/call", {"name": tool, "arguments": arguments or {}})
|
|
text = "\n".join(
|
|
part.get("text", "")
|
|
for part in result.get("content", [])
|
|
if part.get("type") == "text"
|
|
)
|
|
if result.get("isError"):
|
|
raise McpToolError(text)
|
|
return text
|
|
|
|
def close(self) -> None:
|
|
"""End the server session (best effort, never raises)."""
|
|
if self._session is None:
|
|
return
|
|
with contextlib.suppress(McpError):
|
|
self._send(None, method="DELETE")
|
|
self._session = None
|
|
self._ready = False
|