kaitencloud-mcp 0.2.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.
- kaiten_mcp/__init__.py +4 -0
- kaiten_mcp/__main__.py +43 -0
- kaiten_mcp/config.py +50 -0
- kaiten_mcp/sdk.py +111 -0
- kaiten_mcp/server.py +95 -0
- kaiten_mcp/shapes.py +301 -0
- kaiten_mcp/tools/__init__.py +41 -0
- kaiten_mcp/tools/access.py +175 -0
- kaiten_mcp/tools/accounts.py +414 -0
- kaiten_mcp/tools/catalog.py +496 -0
- kaiten_mcp/tools/delete.py +153 -0
- kaiten_mcp/tools/flags.py +395 -0
- kaiten_mcp/tools/overview.py +159 -0
- kaiten_mcp/tools/releases.py +240 -0
- kaiten_mcp/tools/settings.py +187 -0
- kaiten_mcp/tools/usage.py +147 -0
- kaiten_mcp/views.py +193 -0
- kaitencloud_mcp-0.2.0.dist-info/METADATA +149 -0
- kaitencloud_mcp-0.2.0.dist-info/RECORD +23 -0
- kaitencloud_mcp-0.2.0.dist-info/WHEEL +4 -0
- kaitencloud_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- kaitencloud_mcp-0.2.0.dist-info/licenses/LICENSE +202 -0
- kaitencloud_mcp-0.2.0.dist-info/licenses/NOTICE +3 -0
kaiten_mcp/__init__.py
ADDED
kaiten_mcp/__main__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""kaiten-mcp: run the server over stdio (local clients) or Streamable HTTP (remote clients)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from .config import settings
|
|
13
|
+
from .server import build, http_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="kaiten-mcp", description="Kaiten MCP server")
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"transport",
|
|
20
|
+
nargs="?",
|
|
21
|
+
choices=["stdio", "http"],
|
|
22
|
+
default="stdio",
|
|
23
|
+
help="stdio for a local client that spawns the server (uses KAITEN_API_TOKEN); "
|
|
24
|
+
"http to serve Streamable HTTP at /mcp, authenticating each request by its own bearer token",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--host", default=settings.host)
|
|
27
|
+
parser.add_argument("--port", type=int, default=settings.port)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--read-only",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="register read tools only (or KAITEN_MCP_READ_ONLY=true)",
|
|
32
|
+
)
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
mcp = build(writes=False if args.read_only else None)
|
|
36
|
+
if args.transport == "stdio":
|
|
37
|
+
mcp.run(transport="stdio", show_banner=False)
|
|
38
|
+
else:
|
|
39
|
+
uvicorn.run(http_app(mcp), host=args.host, port=args.port, log_level="info")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
kaiten_mcp/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Process configuration, read once from the environment."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _flag(name: str) -> bool:
|
|
13
|
+
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Settings:
|
|
18
|
+
# Where the MCP server sends its API calls. It must be the gateway (Envoy),
|
|
19
|
+
# not the API container: the gateway is what exchanges a ksh_ token for the
|
|
20
|
+
# internal JWT the API trusts, and a client's ksh_ token reaches this
|
|
21
|
+
# server untouched.
|
|
22
|
+
api_base_url: str
|
|
23
|
+
# Used when no request header carries a credential: the stdio transport,
|
|
24
|
+
# or a single-user HTTP deployment.
|
|
25
|
+
api_token: str
|
|
26
|
+
# Register only the tools that read. The credential still bounds what an
|
|
27
|
+
# agent can do; this bounds what it can even attempt.
|
|
28
|
+
read_only: bool
|
|
29
|
+
host: str
|
|
30
|
+
port: int
|
|
31
|
+
timeout: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load() -> Settings:
|
|
35
|
+
base = (
|
|
36
|
+
os.getenv("KAITEN_API_BASE_URL")
|
|
37
|
+
or os.getenv("KAITEN_API_URL")
|
|
38
|
+
or "http://localhost:6060/api"
|
|
39
|
+
)
|
|
40
|
+
return Settings(
|
|
41
|
+
api_base_url=base.rstrip("/"),
|
|
42
|
+
api_token=os.getenv("KAITEN_API_TOKEN", "").strip(),
|
|
43
|
+
read_only=_flag("KAITEN_MCP_READ_ONLY"),
|
|
44
|
+
host=os.getenv("MCP_HOST", "0.0.0.0"),
|
|
45
|
+
port=int(os.getenv("MCP_PORT", "8000")),
|
|
46
|
+
timeout=float(os.getenv("KAITEN_API_TIMEOUT", "30")),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
settings = load()
|
kaiten_mcp/sdk.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Kaiten, reached through the official SDK.
|
|
5
|
+
|
|
6
|
+
The MCP server declares no API contract of its own: paths, request bodies,
|
|
7
|
+
models, enums and the enforcement arithmetic all come from ``kaitencloud``,
|
|
8
|
+
which is generated from Kaiten's OpenAPI document. One client is shared by the
|
|
9
|
+
whole process; its credential is a callable, read before every request, that
|
|
10
|
+
returns the bearer token of whoever is calling the MCP server -- so an agent
|
|
11
|
+
never acts with more than its own token allows.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from fastmcp.exceptions import ToolError
|
|
19
|
+
from fastmcp.server.dependencies import get_http_request
|
|
20
|
+
from fastmcp.server.middleware import Middleware
|
|
21
|
+
from kaitencloud import APIStatusError, AsyncKaitenClient, KaitenError, NotFoundError
|
|
22
|
+
|
|
23
|
+
from .config import settings
|
|
24
|
+
|
|
25
|
+
_client: AsyncKaitenClient | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def caller_token() -> str:
|
|
29
|
+
"""The calling client's token: its Authorization header, else KAITEN_API_TOKEN (stdio)."""
|
|
30
|
+
try:
|
|
31
|
+
header = get_http_request().headers.get("authorization") or ""
|
|
32
|
+
except RuntimeError: # not inside an HTTP request: stdio
|
|
33
|
+
header = ""
|
|
34
|
+
token = header or settings.api_token
|
|
35
|
+
if token.lower().startswith("bearer "):
|
|
36
|
+
token = token[7:]
|
|
37
|
+
if not token.strip():
|
|
38
|
+
raise ToolError(
|
|
39
|
+
"No Kaiten credential: send 'Authorization: Bearer ksh_...' to the MCP server, "
|
|
40
|
+
"or set KAITEN_API_TOKEN for the stdio transport."
|
|
41
|
+
)
|
|
42
|
+
return token.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def kaiten() -> AsyncKaitenClient:
|
|
46
|
+
global _client
|
|
47
|
+
if _client is None:
|
|
48
|
+
_client = AsyncKaitenClient(
|
|
49
|
+
token=caller_token, base_url=settings.api_base_url, timeout=settings.timeout
|
|
50
|
+
)
|
|
51
|
+
return _client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def use(client: AsyncKaitenClient | None) -> None:
|
|
55
|
+
"""Swap the shared client; tests point it at a mocked transport."""
|
|
56
|
+
global _client
|
|
57
|
+
_client = client
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def get_or_none(call: Any) -> Any:
|
|
61
|
+
"""Await an SDK read, mapping 404 to None."""
|
|
62
|
+
try:
|
|
63
|
+
return await call
|
|
64
|
+
except NotFoundError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def require(call: Any, what: str) -> Any:
|
|
69
|
+
"""Await an SDK read, mapping 404 to an error that tells the agent how to recover."""
|
|
70
|
+
try:
|
|
71
|
+
return await call
|
|
72
|
+
except NotFoundError as e:
|
|
73
|
+
raise ToolError(
|
|
74
|
+
f"No {what} found ({e.detail}). Use kaiten_search to find the right slug."
|
|
75
|
+
) from e
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# The SDK wraps every operation an application calls; the targeting authoring
|
|
79
|
+
# endpoints and OFREP evaluation are left to the console and to OpenFeature.
|
|
80
|
+
# These go through the SDK's own transport (credential, retries, typed errors)
|
|
81
|
+
# and decode to plain JSON, until sdk-python grows methods for them.
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def call_unwrapped(
|
|
85
|
+
method: str, path: str, body: Any = None, *, idempotent: bool | None = None
|
|
86
|
+
) -> Any:
|
|
87
|
+
api = kaiten()._api
|
|
88
|
+
response = await api.request(method, path, body=body, idempotent=idempotent)
|
|
89
|
+
return api.decode(response)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def describe(e: APIStatusError) -> str:
|
|
93
|
+
message = f"Kaiten API {e.status_code} {e.code or ''}: {e.detail or e.title or ''}".replace(
|
|
94
|
+
" ", " "
|
|
95
|
+
)
|
|
96
|
+
for err in e.errors or []:
|
|
97
|
+
where = getattr(err, "location", None) or ""
|
|
98
|
+
message += f"\n - {where}: {getattr(err, 'message', err)}"
|
|
99
|
+
return message
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class KaitenErrors(Middleware):
|
|
103
|
+
"""Turn SDK errors into tool errors that carry the API's code and detail, the parts an agent can act on."""
|
|
104
|
+
|
|
105
|
+
async def on_call_tool(self, context, call_next):
|
|
106
|
+
try:
|
|
107
|
+
return await call_next(context)
|
|
108
|
+
except APIStatusError as e:
|
|
109
|
+
raise ToolError(describe(e)) from e
|
|
110
|
+
except KaitenError as e:
|
|
111
|
+
raise ToolError(f"Kaiten: {e}") from e
|
kaiten_mcp/server.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The Kaiten MCP server: intent-level tools over Kaiten's API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse
|
|
15
|
+
|
|
16
|
+
from .config import settings
|
|
17
|
+
from .sdk import KaitenErrors
|
|
18
|
+
from .tools import register_all
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
VERSION = version("kaitencloud-mcp")
|
|
22
|
+
except PackageNotFoundError: # running from a source tree
|
|
23
|
+
VERSION = "0.0.0"
|
|
24
|
+
|
|
25
|
+
INSTRUCTIONS = """\
|
|
26
|
+
Kaiten is the control plane of a B2B SaaS vendor: what each customer bought, what they may use, how much
|
|
27
|
+
they used, and where it runs. Everything belongs to one organization (the vendor), and is named by slug.
|
|
28
|
+
|
|
29
|
+
The model, top down:
|
|
30
|
+
- Customer: a client of the vendor. It runs one or more Instances (a deployment of the product for it).
|
|
31
|
+
- Instance: sits on one exact License version, optionally in a Deployment Zone, with a license term
|
|
32
|
+
(start/end), an operational status and a commercial lifecycle stage.
|
|
33
|
+
- Plan = license family (e.g. 'premium'). Its versions are Licenses (e.g. 'premium-v3'), each DRAFT,
|
|
34
|
+
PUBLISHED or ARCHIVED. A plan serves its default version, else its highest published one.
|
|
35
|
+
- A License grants Entitlements: BOOLEAN features, NUMBER quotas (and AI credits) metered by usage
|
|
36
|
+
reports, and CONFIG values. A NUMBER grant is unlimited (-1), a hard limit, or soft with an overage %.
|
|
37
|
+
- Feature flags are evaluated through OpenFeature, with CEL targeting rules reading the customer's
|
|
38
|
+
license and entitlements, and the instance, customer and zone (under __kaiten).
|
|
39
|
+
- Deployment zones run immutable Releases, each a set of versioned Components.
|
|
40
|
+
|
|
41
|
+
How to work:
|
|
42
|
+
- Start with kaiten_overview, find things with kaiten_search, and read with the kaiten_get_* tools.
|
|
43
|
+
- Writes merge: only what you pass changes. Every write answers with status created / updated /
|
|
44
|
+
unchanged / rejected / deleted and exactly what changed; 'unchanged' means nothing was written.
|
|
45
|
+
- Look before you change something shared: kaiten_get_catalog(plan=...) shows the instances on each
|
|
46
|
+
license version, and kaiten_delete without confirm previews what a deletion touches.
|
|
47
|
+
- Prefer a new license version (kaiten_save_license new_version_of=...) to editing a published version
|
|
48
|
+
in place: editing changes every instance on it immediately.
|
|
49
|
+
- Usage reports are not idempotent; never repeat one that answered.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build(*, writes: bool | None = None) -> FastMCP:
|
|
54
|
+
mcp = FastMCP(
|
|
55
|
+
"Kaiten",
|
|
56
|
+
instructions=INSTRUCTIONS,
|
|
57
|
+
version=VERSION,
|
|
58
|
+
website_url="https://kaiten.sh",
|
|
59
|
+
middleware=[KaitenErrors()],
|
|
60
|
+
)
|
|
61
|
+
register_all(mcp, writes=not settings.read_only if writes is None else writes)
|
|
62
|
+
|
|
63
|
+
@mcp.custom_route("/health", methods=["GET"], include_in_schema=False)
|
|
64
|
+
async def health(_: Request) -> JSONResponse:
|
|
65
|
+
try:
|
|
66
|
+
async with httpx.AsyncClient(timeout=5.0) as c:
|
|
67
|
+
r = await c.get(f"{settings.api_base_url}/healthz")
|
|
68
|
+
# A Community Edition gateway serves /healthz to anyone; Kaiten Cloud's asks for a
|
|
69
|
+
# token and answers 401. Either way, Kaiten answered at that address.
|
|
70
|
+
api = {"reachable": r.status_code in (200, 401), "status": r.status_code}
|
|
71
|
+
except httpx.HTTPError as e:
|
|
72
|
+
api = {"reachable": False, "error": str(e)}
|
|
73
|
+
return JSONResponse(
|
|
74
|
+
{
|
|
75
|
+
"status": "ok" if api["reachable"] else "degraded",
|
|
76
|
+
"version": VERSION,
|
|
77
|
+
"read_only": settings.read_only,
|
|
78
|
+
"api": {"url": settings.api_base_url, **api},
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return mcp
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def http_app(mcp: FastMCP):
|
|
86
|
+
"""The Streamable HTTP app, stateless so any replica can serve any request."""
|
|
87
|
+
allowed_hosts = [h for h in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if h.strip()]
|
|
88
|
+
allowed_origins = [o for o in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if o.strip()]
|
|
89
|
+
return mcp.http_app(
|
|
90
|
+
path="/mcp",
|
|
91
|
+
stateless_http=True,
|
|
92
|
+
host_origin_protection="auto",
|
|
93
|
+
allowed_hosts=allowed_hosts or None,
|
|
94
|
+
allowed_origins=allowed_origins or None,
|
|
95
|
+
)
|
kaiten_mcp/shapes.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""How Kaiten resources are shown to an agent, and how its inputs are read.
|
|
5
|
+
|
|
6
|
+
The SDK's models carry everything a console needs: ids, audit users,
|
|
7
|
+
timestamps. An agent reasons better over less, so every tool projects a model
|
|
8
|
+
onto the fields that carry meaning and names it by slug -- the identifier
|
|
9
|
+
every tool accepts back. The enforcement arithmetic is the SDK's own
|
|
10
|
+
(``kaitencloud.usage``), so a limit reads here exactly as the server applies it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from datetime import UTC, date, datetime, timedelta
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastmcp.exceptions import ToolError
|
|
20
|
+
from kaitencloud import usage as enforcement
|
|
21
|
+
|
|
22
|
+
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$")
|
|
23
|
+
UNLIMITED = enforcement.UNLIMITED
|
|
24
|
+
METERED = ("NUMBER", "NUMBER_AI_CREDIT")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def slug(value: str, what: str = "slug") -> str:
|
|
28
|
+
value = (value or "").strip()
|
|
29
|
+
if not SLUG.match(value) or len(value) > 100:
|
|
30
|
+
raise ToolError(
|
|
31
|
+
f"{what} {value!r} is not a Kaiten slug: lowercase letters, digits and dashes, "
|
|
32
|
+
"2-100 characters, starting and ending with a letter or digit."
|
|
33
|
+
)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def drop_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- time -------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def now() -> datetime:
|
|
45
|
+
return datetime.now(UTC)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rfc3339(t: datetime) -> str:
|
|
49
|
+
return t.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_time(value: str, what: str = "date") -> datetime:
|
|
53
|
+
"""Accept '2027-01-31', '2027-01-31T12:00:00Z' or any ISO 8601 timestamp."""
|
|
54
|
+
raw = value.strip()
|
|
55
|
+
try:
|
|
56
|
+
if len(raw) == 10:
|
|
57
|
+
d = date.fromisoformat(raw)
|
|
58
|
+
return datetime(d.year, d.month, d.day, tzinfo=UTC)
|
|
59
|
+
t = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
60
|
+
return t if t.tzinfo else t.replace(tzinfo=UTC)
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
raise ToolError(f"{what} {value!r} is not an ISO 8601 date or timestamp") from e
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def as_time(value: datetime | str | None) -> datetime | None:
|
|
66
|
+
if value is None or isinstance(value, datetime):
|
|
67
|
+
return value
|
|
68
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def iso(value: datetime | str | None) -> str | None:
|
|
72
|
+
t = as_time(value)
|
|
73
|
+
return rfc3339(t) if t else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def days_until(value: datetime | str | None) -> int | None:
|
|
77
|
+
t = as_time(value)
|
|
78
|
+
return None if t is None else (t - now()).days
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def plus_days(t: datetime, days: int) -> datetime:
|
|
82
|
+
return t + timedelta(days=days)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- values and limits ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def plain(value: Any) -> Any:
|
|
89
|
+
"""Unwrap a typed entitlement value ({type, value[, event_count]}) to the value itself."""
|
|
90
|
+
if value is None:
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return value.get("value", value)
|
|
94
|
+
return getattr(value, "value", value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def grant_value(raw: Any) -> Any:
|
|
98
|
+
"""Read a grant value an agent wrote: 'unlimited' is the SDK's UNLIMITED sentinel."""
|
|
99
|
+
return UNLIMITED if raw == "unlimited" else raw
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def usage_state(
|
|
103
|
+
used: float | None,
|
|
104
|
+
limit: float | None,
|
|
105
|
+
overage_percent: int | None = None,
|
|
106
|
+
warning_percent: int | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Where a metered usage stands against its grant.
|
|
109
|
+
|
|
110
|
+
States: ok; warning (at or past the entitlement's warning threshold);
|
|
111
|
+
at_limit (exactly the included value); overage (past the value, within the
|
|
112
|
+
grant's allowance); over (past what the server accepts); unlimited.
|
|
113
|
+
"""
|
|
114
|
+
used = used or 0
|
|
115
|
+
if limit is None:
|
|
116
|
+
return {"used": used, "limit": None, "state": "not_granted"}
|
|
117
|
+
if enforcement.is_unlimited(limit) or overage_percent == UNLIMITED:
|
|
118
|
+
return {"used": used, "limit": "unlimited", "state": "unlimited"}
|
|
119
|
+
out: dict[str, Any] = {"used": used, "limit": limit}
|
|
120
|
+
pct = enforcement.percentage_used(limit, used)
|
|
121
|
+
out["percent"] = round(pct, 1) if pct is not None else None
|
|
122
|
+
if enforcement.is_soft_limit(limit, overage_percent):
|
|
123
|
+
out["hard_ceiling"] = enforcement.maximum_allowed_usage(limit, overage_percent)
|
|
124
|
+
if not enforcement.accepts(limit, used, overage_percent):
|
|
125
|
+
state = "over"
|
|
126
|
+
elif used > limit:
|
|
127
|
+
state = "overage"
|
|
128
|
+
elif used == limit:
|
|
129
|
+
state = "at_limit"
|
|
130
|
+
elif warning_percent and pct is not None and pct >= warning_percent:
|
|
131
|
+
state = "warning"
|
|
132
|
+
else:
|
|
133
|
+
state = "ok"
|
|
134
|
+
out["state"] = state
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ATTENTION_STATES = {"warning", "at_limit", "overage", "over"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- projections ------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def customer(c: Any) -> dict:
|
|
145
|
+
return drop_none(
|
|
146
|
+
{
|
|
147
|
+
"slug": c.slug,
|
|
148
|
+
"name": c.name,
|
|
149
|
+
"domain": c.domain,
|
|
150
|
+
"external_customer_id": c.external_customer_id,
|
|
151
|
+
"integrations": {
|
|
152
|
+
k: getattr(v, "external_id", v) for k, v in (c.integrations or {}).items()
|
|
153
|
+
}
|
|
154
|
+
or None,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def instance(i: Any) -> dict:
|
|
160
|
+
return drop_none(
|
|
161
|
+
{
|
|
162
|
+
"slug": i.slug,
|
|
163
|
+
"name": i.name,
|
|
164
|
+
"description": i.description or None,
|
|
165
|
+
"customer": i.customer_slug,
|
|
166
|
+
"license": i.license_slug,
|
|
167
|
+
"deployment_zone": i.deployment_zone_slug,
|
|
168
|
+
"status": i.status,
|
|
169
|
+
"lifecycle_stage": i.lifecycle_stage,
|
|
170
|
+
"license_start": iso(i.start_license_date),
|
|
171
|
+
"license_end": iso(i.end_license_date),
|
|
172
|
+
"license_days_left": days_until(i.end_license_date),
|
|
173
|
+
"metadata": i.metadata or None,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def license_(lic: Any, plan: str | None = None) -> dict:
|
|
179
|
+
return drop_none(
|
|
180
|
+
{
|
|
181
|
+
"slug": lic.slug,
|
|
182
|
+
"name": lic.name,
|
|
183
|
+
"plan": plan or lic.family_slug,
|
|
184
|
+
"version": lic.version,
|
|
185
|
+
"version_name": lic.version_name,
|
|
186
|
+
"type": lic.type,
|
|
187
|
+
"state": lic.lifecycle_state,
|
|
188
|
+
"is_default": lic.is_default,
|
|
189
|
+
"description": lic.description or None,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def entitlement(e: Any) -> dict:
|
|
195
|
+
return drop_none(
|
|
196
|
+
{
|
|
197
|
+
"slug": e.slug,
|
|
198
|
+
"name": e.name,
|
|
199
|
+
"type": e.type,
|
|
200
|
+
"description": e.description or None,
|
|
201
|
+
"aggregation": e.aggregation_method,
|
|
202
|
+
"reset_period": e.reset_period,
|
|
203
|
+
"reset_anchor": e.reset_anchor,
|
|
204
|
+
"unit": e.unit_plural or e.unit_singular,
|
|
205
|
+
"warning_threshold_percent": e.warning_threshold_percent or None,
|
|
206
|
+
"user_facing": e.user_facing,
|
|
207
|
+
"groups": [g.slug for g in e.entitlement_groups or []] or None,
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def grant(g: Any) -> dict:
|
|
213
|
+
value = plain(g.value)
|
|
214
|
+
out: dict[str, Any] = {
|
|
215
|
+
"entitlement": g.entitlement_slug,
|
|
216
|
+
"type": g.entitlement_type,
|
|
217
|
+
"value": value,
|
|
218
|
+
}
|
|
219
|
+
pct = g.limit_cap_exceeded_overage_percent
|
|
220
|
+
if g.entitlement_type in METERED:
|
|
221
|
+
if enforcement.is_unlimited(value):
|
|
222
|
+
out["value"] = "unlimited"
|
|
223
|
+
elif enforcement.is_soft_limit(value, pct):
|
|
224
|
+
out["overage_percent"] = pct
|
|
225
|
+
else:
|
|
226
|
+
out["limit"] = "hard"
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def zone(z: Any) -> dict:
|
|
231
|
+
return drop_none(
|
|
232
|
+
{
|
|
233
|
+
"slug": z.slug,
|
|
234
|
+
"name": z.name,
|
|
235
|
+
"type": z.type,
|
|
236
|
+
"description": z.description or None,
|
|
237
|
+
"metadata": z.metadata or None,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def release(r: Any) -> dict:
|
|
243
|
+
return drop_none(
|
|
244
|
+
{
|
|
245
|
+
"slug": r.slug,
|
|
246
|
+
"version": r.version,
|
|
247
|
+
"description": r.description or None,
|
|
248
|
+
"created_at": iso(r.created_at),
|
|
249
|
+
"components": [f"{c.name}@{c.version}" for c in r.components or []] or None,
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --- write reports ----------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def changes(before: dict, after: dict) -> dict[str, dict[str, Any]]:
|
|
258
|
+
"""Field-by-field difference between two flat projections."""
|
|
259
|
+
return {
|
|
260
|
+
key: {"from": before.get(key), "to": after.get(key)}
|
|
261
|
+
for key in sorted(set(before) | set(after))
|
|
262
|
+
if before.get(key) != after.get(key)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def merge_map(current: dict | None, patch: dict | None) -> dict:
|
|
267
|
+
"""Merge patch into current; a key set to null is removed."""
|
|
268
|
+
merged = dict(current or {})
|
|
269
|
+
for key, value in (patch or {}).items():
|
|
270
|
+
if value is None:
|
|
271
|
+
merged.pop(key, None)
|
|
272
|
+
else:
|
|
273
|
+
merged[key] = value
|
|
274
|
+
return merged
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def report(
|
|
278
|
+
status: str,
|
|
279
|
+
kind: str,
|
|
280
|
+
resource: dict | None,
|
|
281
|
+
*,
|
|
282
|
+
changes: dict | None = None,
|
|
283
|
+
notes: list[str] | None = None,
|
|
284
|
+
**extra: Any,
|
|
285
|
+
) -> dict:
|
|
286
|
+
"""The answer every write gives: what happened, to what, and what changed.
|
|
287
|
+
|
|
288
|
+
status is created, updated, unchanged, recorded, deleted, rejected or
|
|
289
|
+
preview. 'unchanged' is a real outcome, not a failure: the resource was
|
|
290
|
+
already in the requested state and nothing was written. 'rejected' means
|
|
291
|
+
Kaiten (or a pre-flight check) refused, and nothing was written either.
|
|
292
|
+
"""
|
|
293
|
+
out: dict[str, Any] = {"status": status, "kind": kind}
|
|
294
|
+
if resource is not None:
|
|
295
|
+
out[kind] = resource
|
|
296
|
+
if changes:
|
|
297
|
+
out["changes"] = changes
|
|
298
|
+
out.update(drop_none(extra))
|
|
299
|
+
if notes:
|
|
300
|
+
out["notes"] = notes
|
|
301
|
+
return out
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Copyright 2026 KAITEN INC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The tool catalogue, one module per area of Kaiten.
|
|
5
|
+
|
|
6
|
+
Every module registers its read tools always, and its write tools only when
|
|
7
|
+
the server is not read-only. Annotations tell the client which is which.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
# Kaiten is the only world these tools touch, so none is open-world.
|
|
15
|
+
READ = {"readOnlyHint": True, "openWorldHint": False}
|
|
16
|
+
WRITE = {
|
|
17
|
+
"readOnlyHint": False,
|
|
18
|
+
"destructiveHint": False,
|
|
19
|
+
"idempotentHint": True,
|
|
20
|
+
"openWorldHint": False,
|
|
21
|
+
}
|
|
22
|
+
# A write whose repetition is not a no-op: a usage report counts every time.
|
|
23
|
+
APPEND = {
|
|
24
|
+
"readOnlyHint": False,
|
|
25
|
+
"destructiveHint": False,
|
|
26
|
+
"idempotentHint": False,
|
|
27
|
+
"openWorldHint": False,
|
|
28
|
+
}
|
|
29
|
+
DESTRUCTIVE = {
|
|
30
|
+
"readOnlyHint": False,
|
|
31
|
+
"destructiveHint": True,
|
|
32
|
+
"idempotentHint": True,
|
|
33
|
+
"openWorldHint": False,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_all(mcp: FastMCP, *, writes: bool) -> None:
|
|
38
|
+
from . import access, accounts, catalog, delete, flags, overview, releases, settings, usage
|
|
39
|
+
|
|
40
|
+
for module in (overview, accounts, usage, catalog, flags, releases, access, settings, delete):
|
|
41
|
+
module.register(mcp, writes=writes)
|