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 ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright 2026 KAITEN INC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Kaiten MCP server."""
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)