kitsu-mcp 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kitsu_mcp/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """MCP server for the Kitsu (CGWire) production tracker."""
2
+
3
+ import argparse
4
+ import os
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser(prog="kitsu-mcp", description=__doc__)
9
+ parser.add_argument("--http", action="store_true", help="serve over HTTP (hosted connector) instead of stdio")
10
+ parser.add_argument("--host", default=os.environ.get("KITSU_HTTP_HOST", "127.0.0.1"))
11
+ parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8000")))
12
+ parser.add_argument(
13
+ "--behind-tls",
14
+ action="store_true",
15
+ default=os.environ.get("KITSU_BEHIND_TLS", "").lower() in ("1", "true", "yes"),
16
+ help="confirm HTTPS terminates in front of the server (required to bind a non-loopback host)",
17
+ )
18
+ args = parser.parse_args()
19
+
20
+ from kitsu_mcp.config import get_settings, require_login
21
+ from kitsu_mcp.server import mcp
22
+
23
+ settings = get_settings()
24
+ if not args.http:
25
+ require_login(settings)
26
+ if settings.allow_write:
27
+ mcp.enable(tags={"write"})
28
+ if args.http:
29
+ from kitsu_mcp import http
30
+
31
+ http.run(args.host, args.port, args.behind_tls)
32
+ else:
33
+ mcp.run()
kitsu_mcp/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from kitsu_mcp import main
2
+
3
+ main()
kitsu_mcp/app.py ADDED
@@ -0,0 +1,45 @@
1
+ """The shared FastMCP instance. Tool modules register on it when imported."""
2
+
3
+ import inspect
4
+
5
+ from fastmcp import FastMCP
6
+
7
+ mcp = FastMCP(
8
+ "kitsu",
9
+ instructions=(
10
+ "Access to a Kitsu production tracker. Tools accept project, task type, status and "
11
+ "person names as well as IDs (case-insensitive). Status filters use task status short "
12
+ "names (e.g. wip, wfa, done); list_task_statuses shows them and the role each one plays here "
13
+ "(done, omitted, on hold, waiting review, ...), which every count and filter follows. Durations "
14
+ 'and estimates are in minutes. List tools return compact rows by default (detail="full" for '
15
+ "more). Use kitsu_get for any read the other tools don't cover. Write tools "
16
+ "(comments, status changes, assignments, schedule, time logs, playlists) only exist when the server "
17
+ "runs with KITSU_ALLOW_WRITE=true. For write tools, call with dry_run=True first and show the user the "
18
+ "plan before writing; then confirm with the user before calling them for real."
19
+ ),
20
+ )
21
+
22
+ READ_ONLY = {"readOnlyHint": True, "openWorldHint": True}
23
+
24
+
25
+ # One small schema for every data tool instead of a detailed one per tool: clients still get structured
26
+ # results (fastmcp's `.data`, `structured_content`), while the tool list stays about half the size.
27
+ _GENERIC_OUTPUT = {"type": "object", "properties": {"result": {}}, "x-fastmcp-wrap-result": True}
28
+
29
+
30
+ def tool(**kwargs):
31
+ """Register a tool on `mcp` with a generic output schema instead of a detailed one.
32
+
33
+ Detailed output schemas were half of what every session paid in context before any call; the field
34
+ notes that matter are in the docstrings. Tools that can return images keep no schema at all, since
35
+ images cannot be structured content. The decorated function itself is returned unchanged, so other
36
+ modules (resources) can still call it directly.
37
+ """
38
+
39
+ def register(fn):
40
+ returns = str(inspect.signature(fn).return_annotation)
41
+ schema = None if "Image" in returns else _GENERIC_OUTPUT
42
+ mcp.tool(output_schema=schema, **kwargs)(fn)
43
+ return fn
44
+
45
+ return register
kitsu_mcp/auth.py ADDED
@@ -0,0 +1,177 @@
1
+ """Per-caller Kitsu credentials for the HTTP transport.
2
+
3
+ Over stdio the server logs in with KITSU_USER / KITSU_PASSWORD. Over HTTP (a hosted server used as a Le Chat or
4
+ AI Studio connector) each caller sends their own Kitsu login in the `Authorization` header, and every tool call
5
+ runs as that person. The server's own login is never used for HTTP calls.
6
+ """
7
+
8
+ import base64
9
+ import binascii
10
+ import hashlib
11
+ import hmac
12
+ import secrets
13
+ import threading
14
+ import time
15
+ from collections import OrderedDict
16
+ from dataclasses import dataclass
17
+
18
+ from fastmcp.exceptions import ToolError
19
+ from fastmcp.server.dependencies import get_http_request
20
+
21
+ AUTH_HELP = (
22
+ "Kitsu login missing: set the connector's authentication to Basic with your Kitsu email and password "
23
+ "(or a Bearer token of the form email:password)."
24
+ )
25
+
26
+
27
+ # Keys derived from credentials are HMACs under a secret that only lives in this process, so a key seen
28
+ # elsewhere (a cache folder name, a dump) cannot be checked against password guesses offline.
29
+ _SECRET = secrets.token_bytes(32)
30
+
31
+
32
+ def keyed_hash(value: str) -> str:
33
+ return hmac.new(_SECRET, value.encode(), hashlib.sha256).hexdigest()
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Credentials:
38
+ user: str
39
+ password: str
40
+
41
+ @property
42
+ def key(self) -> str:
43
+ """Cache key for this login (user and password), so passwords never sit in the cache as dict keys."""
44
+ return keyed_hash(f"{self.user}\0{self.password}")
45
+
46
+ @property
47
+ def user_key(self) -> str:
48
+ """Key of the user alone, e.g. for their video cache folder (Kitsu authorizes every cache hit)."""
49
+ return keyed_hash(self.user.lower())
50
+
51
+ def __repr__(self) -> str:
52
+ return f"Credentials(user={self.user!r}, password='***')"
53
+
54
+
55
+ def parse_authorization(header: str) -> Credentials | None:
56
+ """Kitsu email and password from `Basic base64(email:password)` or `Bearer email:password`."""
57
+ scheme, _, value = header.strip().partition(" ")
58
+ value = value.strip()
59
+ if scheme.lower() == "basic":
60
+ try:
61
+ value = base64.b64decode(value, validate=True).decode("utf-8")
62
+ except (binascii.Error, UnicodeDecodeError):
63
+ return None
64
+ elif scheme.lower() != "bearer":
65
+ return None
66
+ user, sep, password = value.partition(":")
67
+ if not sep or not user or not password:
68
+ return None
69
+ return Credentials(user.strip(), password)
70
+
71
+
72
+ def request_credentials() -> Credentials | None:
73
+ """The caller's credentials when the current call came over HTTP; None over stdio (no HTTP request).
74
+
75
+ Raises when the call came over HTTP without usable credentials, so it can never fall back to the
76
+ server's own login.
77
+ """
78
+ try:
79
+ request = get_http_request()
80
+ except RuntimeError:
81
+ return None
82
+ credentials = parse_authorization(request.headers.get("authorization", ""))
83
+ if credentials is None:
84
+ raise ToolError(AUTH_HELP)
85
+ return credentials
86
+
87
+
88
+ class TTLCache[T]:
89
+ """A small thread-safe LRU cache whose entries expire `ttl` seconds after they were last used."""
90
+
91
+ def __init__(self, ttl: float, max_size: int) -> None:
92
+ self.ttl = ttl
93
+ self.max_size = max_size
94
+ self._items: OrderedDict[str, tuple[float, T]] = OrderedDict()
95
+ self._lock = threading.Lock()
96
+
97
+ def get_or_create(self, key: str, create) -> T:
98
+ found = self.get(key)
99
+ if found is not None:
100
+ return found
101
+ value = create()
102
+ self.put(key, value)
103
+ return value
104
+
105
+ def get(self, key: str) -> T | None:
106
+ now = time.monotonic()
107
+ with self._lock:
108
+ self._expire(now)
109
+ if key not in self._items:
110
+ return None
111
+ value = self._items.pop(key)[1]
112
+ self._items[key] = (now, value)
113
+ return value
114
+
115
+ def put(self, key: str, value: T) -> None:
116
+ now = time.monotonic()
117
+ with self._lock:
118
+ self._expire(now)
119
+ self._items.pop(key, None)
120
+ self._items[key] = (now, value)
121
+ while len(self._items) > self.max_size:
122
+ self._items.popitem(last=False)
123
+
124
+ def _expire(self, now: float) -> None:
125
+ for stale in [k for k, (used, _) in self._items.items() if now - used > self.ttl]:
126
+ del self._items[stale]
127
+
128
+ def clear(self) -> None:
129
+ with self._lock:
130
+ self._items.clear()
131
+
132
+ def __len__(self) -> int:
133
+ return len(self._items)
134
+
135
+
136
+ class LoginThrottle:
137
+ """Slows down repeated failed logins per user, so the server is not an unthrottled password relay to Kitsu.
138
+
139
+ Only rejected credentials count (an unreachable Kitsu does not). A login Kitsu accepted in the last
140
+ `remember` seconds skips the check, so guessing at someone's email cannot lock them out. Spraying many
141
+ emails from one address is not throttled here. The first `free` failures in a row cost nothing; after
142
+ that, the next attempt must wait 2**(failures - free) seconds (capped) since the last failure. A success
143
+ resets the count. Bounded: the oldest users are forgotten past `max_users`.
144
+ """
145
+
146
+ def __init__(self, free: int = 5, cap: float = 60, max_users: int = 4096, remember: float = 24 * 3600) -> None:
147
+ self.free = free
148
+ self.cap = cap
149
+ self.max_users = max_users
150
+ self._failures: OrderedDict[str, tuple[int, float]] = OrderedDict()
151
+ # Keys (HMACs) of logins Kitsu accepted in the last `remember` seconds: they bypass the throttle.
152
+ self._good: TTLCache[bool] = TTLCache(ttl=remember, max_size=max_users)
153
+ self._lock = threading.Lock()
154
+
155
+ def known_good(self, login_key: str) -> bool:
156
+ return self._good.get(login_key) is not None
157
+
158
+ def check(self, user_key: str) -> None:
159
+ with self._lock:
160
+ count, last = self._failures.get(user_key, (0, 0.0))
161
+ if count <= self.free:
162
+ return
163
+ wait = min(self.cap, 2 ** (count - self.free)) - (time.monotonic() - last)
164
+ if wait > 0:
165
+ raise ToolError(f"Too many failed Kitsu logins for this account; try again in {wait:.0f} s")
166
+
167
+ def failed(self, user_key: str) -> None:
168
+ with self._lock:
169
+ count, _ = self._failures.pop(user_key, (0, 0.0))
170
+ self._failures[user_key] = (count + 1, time.monotonic())
171
+ while len(self._failures) > self.max_users:
172
+ self._failures.popitem(last=False)
173
+
174
+ def succeeded(self, user_key: str, login_key: str) -> None:
175
+ with self._lock:
176
+ self._failures.pop(user_key, None)
177
+ self._good.put(login_key, True)
kitsu_mcp/client.py ADDED
@@ -0,0 +1,244 @@
1
+ """Thin wrapper over gazu's raw HTTP client: lazy login, re-login on expiry, retries."""
2
+
3
+ import threading
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import gazu
9
+ import requests
10
+ from fastmcp.exceptions import ToolError
11
+ from gazu import client as raw
12
+ from gazu.exception import (
13
+ AuthFailedException,
14
+ GazuException,
15
+ NotAllowedException,
16
+ NotAuthenticatedException,
17
+ ParameterException,
18
+ PreviewFileProcessingException,
19
+ RouteNotFoundException,
20
+ ServerErrorException,
21
+ ValidationException,
22
+ )
23
+
24
+ from kitsu_mcp.auth import Credentials, LoginThrottle, TTLCache, request_credentials
25
+ from kitsu_mcp.config import Settings, get_settings
26
+
27
+ # requests.JSONDecodeError: a proxy answered 503/504 with an HTML page (gazu only raises on 500/502).
28
+ _TRANSIENT = (ServerErrorException, requests.ConnectionError, requests.Timeout, requests.JSONDecodeError)
29
+
30
+
31
+ class NotFoundError(ToolError):
32
+ """Kitsu answered 404: wrong ID, or a route this instance does not have."""
33
+
34
+
35
+ class LoginRejected(ToolError):
36
+ """Kitsu refused the email/password (not an outage): the only failure the login throttle counts."""
37
+
38
+
39
+ class FileTooLargeError(ToolError):
40
+ """A download went past its size cap and was stopped."""
41
+
42
+
43
+ def size_guard(path: str, max_bytes: int | None):
44
+ """Progress callback for downloads that stops them once they exceed `max_bytes`."""
45
+
46
+ def check(bytes_read: int, total: int) -> None:
47
+ if max_bytes is not None and max(bytes_read, total) > max_bytes:
48
+ raise FileTooLargeError(f"{path} is larger than the {max_bytes / 2**20:.0f} MB download cap")
49
+
50
+ return check
51
+
52
+
53
+ class KitsuClient:
54
+ def __init__(self, settings: Settings, credentials: Credentials | None = None) -> None:
55
+ """`credentials`: log in as this person instead of KITSU_USER / KITSU_PASSWORD (HTTP callers)."""
56
+ self.settings = settings
57
+ self.credentials = credentials
58
+ self._raw: raw.KitsuClient | None = None
59
+ self._lock = threading.Lock()
60
+ self._relogging = threading.local()
61
+
62
+ def _connect(self) -> raw.KitsuClient:
63
+ with self._lock:
64
+ if self._raw is None:
65
+ client = raw.create_client(
66
+ self.settings.host,
67
+ use_refresh_token=True,
68
+ callback_not_authenticated=self._relogin,
69
+ )
70
+ _apply_timeout(client.session, self.settings.timeout)
71
+ # Mark the first login as in progress too, so a 401 on it does not trigger a second attempt.
72
+ self._relogging.active = True
73
+ try:
74
+ self._log_in(client)
75
+ finally:
76
+ self._relogging.active = False
77
+ self._raw = client
78
+ return self._raw
79
+
80
+ def connect(self) -> None:
81
+ """Log in now (instead of on the first request), reporting a rejected login as a ToolError."""
82
+ try:
83
+ self._connect()
84
+ except (NotAuthenticatedException, AuthFailedException) as error:
85
+ raise LoginRejected(str(_tool_error(error, "POST", "auth/login"))) from error
86
+ except (requests.ConnectionError, requests.Timeout, requests.JSONDecodeError, ServerErrorException) as error:
87
+ raise ToolError(
88
+ f"Could not log in: Kitsu is unreachable or unavailable ({error.__class__.__name__})"
89
+ ) from error
90
+ except (GazuException, requests.RequestException) as error:
91
+ raise _tool_error(error, "POST", "auth/login") from error
92
+
93
+ def _log_in(self, client: raw.KitsuClient) -> None:
94
+ if self.credentials is not None:
95
+ gazu.log_in(self.credentials.user, self.credentials.password, client=client)
96
+ return
97
+ if not self.settings.user or self.settings.password is None:
98
+ raise RuntimeError("Missing configuration: KITSU_USER, KITSU_PASSWORD")
99
+ gazu.log_in(self.settings.user, self.settings.password.get_secret_value(), client=client)
100
+
101
+ def _relogin(self, client: raw.KitsuClient, _path: str) -> bool:
102
+ """gazu callback when the access and refresh tokens are both rejected.
103
+
104
+ The login request goes through the same client, so a 401 on `auth/login` would call back
105
+ here again; the guard makes that nested call give up instead of recursing.
106
+ """
107
+ if getattr(self._relogging, "active", False):
108
+ return False
109
+ self._relogging.active = True
110
+ try:
111
+ self._log_in(client)
112
+ except GazuException:
113
+ return False
114
+ finally:
115
+ self._relogging.active = False
116
+ return True
117
+
118
+ def _call(self, method: str, path: str, fn, *args, retry: bool = True, **kwargs) -> Any:
119
+ """Run a gazu request; reads retry transient failures, writes never do (they may have landed)."""
120
+ attempts = self.settings.max_retries if retry else 1
121
+ for attempt in range(attempts):
122
+ try:
123
+ return fn(*args, client=self._connect(), **kwargs)
124
+ except _TRANSIENT as error:
125
+ if attempt == attempts - 1:
126
+ raise _tool_error(error, method, path) from error
127
+ time.sleep(0.5 * 2**attempt)
128
+ except (GazuException, requests.RequestException) as error:
129
+ raise _tool_error(error, method, path) from error
130
+ raise AssertionError("unreachable: max_retries >= 1")
131
+
132
+ def get(self, path: str, params: dict | None = None) -> Any:
133
+ return self._call("GET", path, raw.get, path, params=params)
134
+
135
+ def get_bytes(self, path: str) -> bytes:
136
+ return self._call("GET", path, raw.get_file_data_from_url, path)
137
+
138
+ def download(self, path: str, dest: Path, max_bytes: int | None = None) -> int:
139
+ """Stream a file to `dest` (waiting for previews Kitsu is still processing); returns its size."""
140
+ self._call("GET", path, raw.download, path, str(dest), progress_callback=size_guard(path, max_bytes))
141
+ return dest.stat().st_size
142
+
143
+ def post(self, path: str, data: Any) -> Any:
144
+ return self._call("POST", path, raw.post, path, data, retry=False)
145
+
146
+ def put(self, path: str, data: dict) -> Any:
147
+ return self._call("PUT", path, raw.put, path, data, retry=False)
148
+
149
+ def user_id(self) -> str:
150
+ return self.get("auth/authenticated")["user"]["id"]
151
+
152
+
153
+ def _apply_timeout(session: requests.Session, timeout: float) -> None:
154
+ request = session.request
155
+
156
+ def request_with_timeout(method, url, **kwargs):
157
+ kwargs.setdefault("timeout", timeout)
158
+ return request(method, url, **kwargs)
159
+
160
+ session.request = request_with_timeout # type: ignore[method-assign]
161
+
162
+
163
+ def _tool_error(error: Exception, method: str, path: str) -> ToolError:
164
+ if isinstance(error, RouteNotFoundException):
165
+ return NotFoundError(f"Not found: {path} (wrong ID, or the route does not exist)")
166
+ if isinstance(error, PreviewFileProcessingException):
167
+ return ToolError(f"Kitsu is still processing {path}; try again in a minute")
168
+ if isinstance(error, NotAllowedException):
169
+ return ToolError(f"Permission denied for {method} {path}")
170
+ if isinstance(error, (NotAuthenticatedException, AuthFailedException)):
171
+ return ToolError("Kitsu rejected the credentials; check the Kitsu email and password")
172
+ if isinstance(error, (ParameterException, ValidationException)):
173
+ return ToolError(f"Kitsu rejected {method} {path}: {error}")
174
+ if isinstance(error, requests.JSONDecodeError | requests.ConnectionError | requests.Timeout) and method != "GET":
175
+ # The request may have reached Zou before the proxy or connection gave up.
176
+ return ToolError(
177
+ f"No usable answer from Kitsu on {method} {path} ({error.__class__.__name__}); the change MAY have "
178
+ "been applied. Check the current state (e.g. get_task) before retrying."
179
+ )
180
+ if isinstance(error, requests.JSONDecodeError):
181
+ return ToolError(f"Kitsu returned a non-JSON response on {method} {path} (server or proxy unavailable?)")
182
+ if isinstance(error, (requests.ConnectionError, requests.Timeout)):
183
+ return ToolError(f"Could not reach Kitsu on {method} {path}: {error.__class__.__name__}")
184
+ return ToolError(f"Kitsu error on {method} {path}: {error.__class__.__name__} {error}")
185
+
186
+
187
+ _override: KitsuClient | None = None
188
+ _server_client: KitsuClient | None = None
189
+ _server_lock = threading.Lock()
190
+ _user_clients: TTLCache[KitsuClient] = TTLCache(ttl=3600, max_size=256)
191
+ _logins = LoginThrottle()
192
+ _login_locks: TTLCache[threading.Lock] = TTLCache(ttl=3600, max_size=4096)
193
+ _login_locks_guard = threading.Lock()
194
+
195
+
196
+ def get_client() -> KitsuClient:
197
+ """The Kitsu client for the current call.
198
+
199
+ Over HTTP: the caller's own, from their `Authorization` header (never the server's login). Over stdio:
200
+ the server's, logged in as KITSU_USER.
201
+ """
202
+ global _server_client
203
+ if _override is not None:
204
+ return _override
205
+ credentials = request_credentials()
206
+ if credentials is not None:
207
+ return _user_client(credentials)
208
+ with _server_lock:
209
+ if _server_client is None:
210
+ _server_client = KitsuClient(get_settings())
211
+ return _server_client
212
+
213
+
214
+ def _user_client(credentials: Credentials) -> KitsuClient:
215
+ """A caller's logged-in client. Only successful logins are cached, so wrong passwords cannot push real
216
+ users out of the cache; repeated failures for one account are slowed down (LoginThrottle)."""
217
+ cached = _user_clients.get(credentials.key)
218
+ if cached is not None:
219
+ return cached
220
+ with _login_locks_guard:
221
+ lock = _login_locks.get_or_create(credentials.user_key, threading.Lock)
222
+ with lock: # one login at a time per account; concurrent first calls reuse the winner's client
223
+ cached = _user_clients.get(credentials.key)
224
+ if cached is not None:
225
+ return cached
226
+ # A login Kitsu accepted recently skips the throttle: someone who only knows the email cannot lock
227
+ # out the person who knows the password.
228
+ if not _logins.known_good(credentials.key):
229
+ _logins.check(credentials.user_key)
230
+ client = KitsuClient(get_settings(), credentials)
231
+ try:
232
+ client.connect()
233
+ except LoginRejected:
234
+ _logins.failed(credentials.user_key)
235
+ raise
236
+ _logins.succeeded(credentials.user_key, credentials.key)
237
+ _user_clients.put(credentials.key, client)
238
+ return client
239
+
240
+
241
+ def set_client(client: KitsuClient | None) -> None:
242
+ """Swap the shared client (used by tests); None goes back to the normal per-call choice."""
243
+ global _override
244
+ _override = client
kitsu_mcp/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """Server settings, read from environment variables or a `.env` in the working directory."""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from urllib.parse import urlsplit, urlunsplit
6
+
7
+ from pydantic import Field, SecretStr
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ model_config = SettingsConfigDict(env_prefix="KITSU_", env_file=".env", extra="ignore")
13
+
14
+ host: str
15
+ """Zou API URL, e.g. https://example.cg-wire.com/api."""
16
+ user: str | None = None
17
+ """Kitsu login for stdio. Over HTTP each caller sends their own, and these are not used."""
18
+ password: SecretStr | None = None
19
+ allow_write: bool = False
20
+ """Register tools that modify Kitsu (comments, status changes, assignments, ...)."""
21
+ cache_ttl: float = Field(300, ge=0)
22
+ """Seconds to keep reference data (task types, statuses, people, ...) before refetching."""
23
+ timeout: float = Field(30, gt=0)
24
+ """Per-request timeout in seconds."""
25
+ max_retries: int = Field(3, ge=1)
26
+ """Attempts for idempotent requests that fail with a server or network error."""
27
+ video_cache_dir: Path | None = None
28
+ """Where downloaded movies are cached (default: a kitsu-mcp-videos folder in the system temp dir)."""
29
+ video_cache_mb: int = Field(1024, ge=0)
30
+ """Size of the movie cache; the least recently used movies are deleted past it."""
31
+ video_max_mb: int = Field(500, gt=0)
32
+ """Largest movie the video tools will download."""
33
+ # Status roles. Each is a comma-separated list of task status short names or names (case-insensitive).
34
+ # A listed status takes that role; any other status gets its role from Kitsu's flags. Checked in this
35
+ # order, so a status in two lists takes the first.
36
+ omitted_statuses: str = "omit,na,n/a,omitted,cancelled,canceled"
37
+ """Dropped, not finished: left out of progress and open work (Kitsu flags them done)."""
38
+ done_statuses: str = ""
39
+ """Finished, even if Kitsu flags them otherwise, e.g. "cbb" for a "could be better" status flagged retake."""
40
+ on_hold_statuses: str = "stand by,standby,pause,paused,blocked,on hold"
41
+ """Open but paused: never counted as overdue or in progress."""
42
+ review_statuses: str = ""
43
+ """Waiting for review/approval, in addition to statuses Kitsu flags as feedback requests."""
44
+ retake_statuses: str = ""
45
+ """Sent back for changes, in addition to statuses Kitsu flags as retakes."""
46
+ dry_run_log: Path | None = None
47
+ """JSONL file that every write-tool dry run (dry_run=True) is appended to."""
48
+
49
+
50
+ @lru_cache
51
+ def get_settings() -> Settings:
52
+ try:
53
+ return Settings() # type: ignore[call-arg]
54
+ except Exception as error:
55
+ missing = [
56
+ f"KITSU_{e['loc'][0]}".upper() for e in getattr(error, "errors", lambda: [])() if e.get("type") == "missing"
57
+ ]
58
+ if missing:
59
+ raise RuntimeError(f"Missing configuration: {', '.join(missing)}") from error
60
+ raise
61
+
62
+
63
+ def require_login(settings: Settings) -> None:
64
+ """Over stdio the server logs in as KITSU_USER; fail at startup, not on the first tool call."""
65
+ missing = [
66
+ name for name, value in (("KITSU_USER", settings.user), ("KITSU_PASSWORD", settings.password)) if not value
67
+ ]
68
+ if missing:
69
+ raise RuntimeError(f"Missing configuration: {', '.join(missing)}")
70
+
71
+
72
+ def without_userinfo(url: str) -> str:
73
+ """A host URL without any user:password@ part (a proxy login must never reach callers)."""
74
+ parts = urlsplit(url)
75
+ if "@" not in parts.netloc:
76
+ return url
77
+ return urlunsplit(parts._replace(netloc=parts.netloc.rsplit("@", 1)[1]))