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 +33 -0
- kitsu_mcp/__main__.py +3 -0
- kitsu_mcp/app.py +45 -0
- kitsu_mcp/auth.py +177 -0
- kitsu_mcp/client.py +244 -0
- kitsu_mcp/config.py +77 -0
- kitsu_mcp/custom_fields.py +155 -0
- kitsu_mcp/formatters.py +67 -0
- kitsu_mcp/http.py +78 -0
- kitsu_mcp/links.py +79 -0
- kitsu_mcp/lookups.py +240 -0
- kitsu_mcp/models.py +534 -0
- kitsu_mcp/plans.py +101 -0
- kitsu_mcp/production.py +109 -0
- kitsu_mcp/prompts.py +70 -0
- kitsu_mcp/resources.py +36 -0
- kitsu_mcp/server.py +9 -0
- kitsu_mcp/tools/__init__.py +5 -0
- kitsu_mcp/tools/assets.py +157 -0
- kitsu_mcp/tools/custom_fields.py +34 -0
- kitsu_mcp/tools/meta.py +149 -0
- kitsu_mcp/tools/people.py +143 -0
- kitsu_mcp/tools/previews.py +300 -0
- kitsu_mcp/tools/projects.py +203 -0
- kitsu_mcp/tools/shots.py +196 -0
- kitsu_mcp/tools/tasks.py +191 -0
- kitsu_mcp/tools/video.py +341 -0
- kitsu_mcp/tools/write.py +472 -0
- kitsu_mcp/validation.py +41 -0
- kitsu_mcp/video.py +706 -0
- kitsu_mcp-0.3.0.dist-info/METADATA +213 -0
- kitsu_mcp-0.3.0.dist-info/RECORD +35 -0
- kitsu_mcp-0.3.0.dist-info/WHEEL +4 -0
- kitsu_mcp-0.3.0.dist-info/entry_points.txt +3 -0
- kitsu_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
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
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]))
|