bookingkoala-api-mcp 1.0.2__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.
Files changed (44) hide show
  1. bookingkoala_api_mcp/__init__.py +28 -0
  2. bookingkoala_api_mcp/api.py +213 -0
  3. bookingkoala_api_mcp/app.py +42 -0
  4. bookingkoala_api_mcp/bookingkoala/__init__.py +0 -0
  5. bookingkoala_api_mcp/bookingkoala/auth.py +26 -0
  6. bookingkoala_api_mcp/bookingkoala/client.py +183 -0
  7. bookingkoala_api_mcp/bookingkoala/tools/__init__.py +0 -0
  8. bookingkoala_api_mcp/bookingkoala/tools/_bookings.py +130 -0
  9. bookingkoala_api_mcp/bookingkoala/tools/_people.py +64 -0
  10. bookingkoala_api_mcp/bookingkoala/tools/assign_provider.py +57 -0
  11. bookingkoala_api_mcp/bookingkoala/tools/cancel_booking.py +76 -0
  12. bookingkoala_api_mcp/bookingkoala/tools/create_booking.py +61 -0
  13. bookingkoala_api_mcp/bookingkoala/tools/create_customer.py +114 -0
  14. bookingkoala_api_mcp/bookingkoala/tools/dashboard.py +164 -0
  15. bookingkoala_api_mcp/bookingkoala/tools/get_booking.py +134 -0
  16. bookingkoala_api_mcp/bookingkoala/tools/get_customer.py +138 -0
  17. bookingkoala_api_mcp/bookingkoala/tools/get_provider.py +130 -0
  18. bookingkoala_api_mcp/bookingkoala/tools/list_bookings.py +82 -0
  19. bookingkoala_api_mcp/bookingkoala/tools/list_charges.py +125 -0
  20. bookingkoala_api_mcp/bookingkoala/tools/list_customers.py +84 -0
  21. bookingkoala_api_mcp/bookingkoala/tools/list_drafts.py +82 -0
  22. bookingkoala_api_mcp/bookingkoala/tools/list_invoices.py +154 -0
  23. bookingkoala_api_mcp/bookingkoala/tools/list_providers.py +120 -0
  24. bookingkoala_api_mcp/bookingkoala/tools/reschedule_booking.py +88 -0
  25. bookingkoala_api_mcp/bookingkoala/tools/resend_invoice.py +121 -0
  26. bookingkoala_api_mcp/bookingkoala/tools/send_quote.py +189 -0
  27. bookingkoala_api_mcp/cli.py +201 -0
  28. bookingkoala_api_mcp/core/__init__.py +0 -0
  29. bookingkoala_api_mcp/core/account_guard.py +33 -0
  30. bookingkoala_api_mcp/core/context.py +192 -0
  31. bookingkoala_api_mcp/core/errors.py +78 -0
  32. bookingkoala_api_mcp/core/helpers.py +33 -0
  33. bookingkoala_api_mcp/core/profile.py +28 -0
  34. bookingkoala_api_mcp/core/tools.py +106 -0
  35. bookingkoala_api_mcp/mcp_context.py +126 -0
  36. bookingkoala_api_mcp/mcp_server.py +67 -0
  37. bookingkoala_api_mcp/runner.py +106 -0
  38. bookingkoala_api_mcp/scripting.py +80 -0
  39. bookingkoala_api_mcp/session.py +285 -0
  40. bookingkoala_api_mcp-1.0.2.dist-info/METADATA +461 -0
  41. bookingkoala_api_mcp-1.0.2.dist-info/RECORD +44 -0
  42. bookingkoala_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  43. bookingkoala_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
  44. bookingkoala_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
@@ -0,0 +1,28 @@
1
+ """Self-hosted API and MCP server for one app (see app.py). Personal use only, see LICENSE.
2
+
3
+ From Python, without a server:
4
+
5
+ from bookingkoala_api_mcp import call
6
+ bookings = call("list_bookings", status="upcoming")["bookings"]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ __version__ = "1.0.2"
15
+
16
+
17
+ def call(tool: str, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
18
+ """Run one tool with the saved session and return its answer. Raises ToolError on failure."""
19
+ from bookingkoala_api_mcp.runner import Runner
20
+
21
+ async def go() -> Any:
22
+ runner = Runner()
23
+ try:
24
+ return await runner.run(tool, {**(args or {}), **kwargs})
25
+ finally:
26
+ await runner.close()
27
+
28
+ return asyncio.run(go())
@@ -0,0 +1,213 @@
1
+ """The REST API and the MCP endpoint on one port.
2
+
3
+ - `GET|POST|PUT|PATCH|DELETE <app.API_PREFIX>/<route>`: each tool at its route, the same paths as the
4
+ hosted API at cotlet.dev. Path parts and the query string are arguments; a JSON body adds the rest.
5
+ - `POST <app.API_PREFIX>/tools/{name}`: any tool by name, the JSON body is its arguments.
6
+ - `GET <app.API_PREFIX>/tools`: every tool with its schema. OpenAPI at `/docs` and `/openapi.json`.
7
+ - `/mcp`: the MCP server over streamable HTTP.
8
+
9
+ With <ENV>_MCP_TOKEN set, every request needs `Authorization: Bearer <token>`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import hmac
16
+ import json
17
+ import os
18
+ import re
19
+ from collections.abc import AsyncIterator, Awaitable, Callable
20
+ from typing import Any
21
+
22
+ from fastapi import FastAPI, Request
23
+ from fastapi.responses import JSONResponse
24
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
25
+
26
+ from bookingkoala_api_mcp import __version__, app, mcp_server
27
+ from bookingkoala_api_mcp.core.errors import HTTP_STATUS, ErrorCode, ToolError
28
+ from bookingkoala_api_mcp.core.tools import ToolSpec
29
+ from bookingkoala_api_mcp.runner import Runner, description, tools
30
+
31
+ PREFIX = app.API_PREFIX
32
+ ASGI = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]]
33
+
34
+
35
+ def _error(code: ErrorCode, message: str) -> JSONResponse:
36
+ return JSONResponse({"error": code.value, "message": message}, status_code=HTTP_STATUS.get(code, 500))
37
+
38
+
39
+ def _typed(value: str, schema: dict[str, Any]) -> Any:
40
+ kind = schema.get("type")
41
+ try:
42
+ if kind == "integer":
43
+ return int(value)
44
+ if kind == "number":
45
+ return float(value)
46
+ except ValueError:
47
+ return value
48
+ if kind == "boolean":
49
+ return value.lower() in ("1", "true", "yes", "on")
50
+ if kind == "array":
51
+ return [_typed(v, schema.get("items") or {}) for v in value.split(",") if v]
52
+ if kind == "object":
53
+ with contextlib.suppress(ValueError):
54
+ return json.loads(value)
55
+ return value
56
+
57
+
58
+ def _route_order(spec: ToolSpec) -> list[int]:
59
+ """Literal segments before {placeholders}, so /groups/discover wins over /groups/{group}."""
60
+ path = (spec.route or "").split(" ", 1)[1]
61
+ return [1 if part.startswith("{") else 0 for part in path.strip("/").split("/")]
62
+
63
+
64
+ def _tag(path: str) -> str:
65
+ """OpenAPI group: the part after /groups/{group}/ (posts, members ...), else the first part."""
66
+ parts = path.strip("/").split("/")
67
+ return parts[2] if parts[:2] == ["groups", "{group}"] and len(parts) > 2 else parts[0]
68
+
69
+
70
+ def _openapi(spec: ToolSpec, method: str, path: str) -> dict[str, Any]:
71
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
72
+ required = set(spec.input_schema.get("required") or [])
73
+ in_path = set(re.findall(r"\{(\w+)\}", path))
74
+ parameters = [
75
+ {"name": name, "in": "path", "required": True, "schema": props.get(name, {"type": "string"})}
76
+ for name in in_path
77
+ ]
78
+ rest = {k: v for k, v in props.items() if k not in in_path}
79
+ extra: dict[str, Any] = {}
80
+ if method == "GET":
81
+ parameters += [
82
+ {"name": name, "in": "query", "required": name in required, "schema": schema}
83
+ for name, schema in rest.items()
84
+ ]
85
+ elif rest:
86
+ body = {"type": "object", "properties": rest, "required": sorted(required - in_path)}
87
+ extra["requestBody"] = {"required": bool(body["required"]), "content": {"application/json": {"schema": body}}}
88
+ if spec.example_response:
89
+ extra["responses"] = {
90
+ "200": {"description": "OK", "content": {"application/json": {"example": spec.example_response}}}
91
+ }
92
+ return {"parameters": parameters, **extra}
93
+
94
+
95
+ async def _arguments(request: Request, spec: ToolSpec) -> dict[str, Any]:
96
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
97
+ args: dict[str, Any] = {}
98
+ if request.method != "GET":
99
+ raw = await request.body()
100
+ if raw.strip():
101
+ try:
102
+ body = json.loads(raw)
103
+ except ValueError:
104
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT) from None
105
+ if not isinstance(body, dict):
106
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT)
107
+ args.update(body)
108
+ for name in request.query_params:
109
+ values = request.query_params.getlist(name)
110
+ schema = props.get(name) or {}
111
+ args[name] = (
112
+ [_typed(v, schema.get("items") or {}) for v in values]
113
+ if schema.get("type") == "array" and len(values) > 1
114
+ else _typed(values[-1], schema)
115
+ )
116
+ for name, value in request.path_params.items():
117
+ args[name] = _typed(value, props.get(name) or {})
118
+ return args
119
+
120
+
121
+ def create_app(runner: Runner) -> ASGI:
122
+ manager = StreamableHTTPSessionManager(app=mcp_server.build(runner), json_response=True, stateless=True)
123
+ token = os.environ.get(f"{app.ENV}_MCP_TOKEN", "").strip()
124
+
125
+ @contextlib.asynccontextmanager
126
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
127
+ async with manager.run():
128
+ yield
129
+ await runner.close()
130
+
131
+ api = FastAPI(
132
+ title=f"{app.NAME} API (self-hosted)",
133
+ version=__version__,
134
+ description=f"Your {app.NAME} account as a REST API, run on your machine with your Chrome's session.",
135
+ lifespan=lifespan,
136
+ )
137
+
138
+ async def call(spec: ToolSpec, args: dict[str, Any]) -> JSONResponse:
139
+ try:
140
+ result = await runner.run(spec.name, args)
141
+ except ToolError as exc:
142
+ return _error(exc.code, str(exc))
143
+ return JSONResponse(result)
144
+
145
+ def endpoint(spec: ToolSpec) -> Callable[[Request], Awaitable[JSONResponse]]:
146
+ async def handle(request: Request) -> JSONResponse:
147
+ try:
148
+ args = await _arguments(request, spec)
149
+ except ToolError as exc:
150
+ return _error(exc.code, str(exc))
151
+ return await call(spec, args)
152
+
153
+ return handle
154
+
155
+ @api.get(f"{PREFIX}/tools", summary="Every tool with its route and input schema")
156
+ async def list_tools() -> list[dict[str, Any]]:
157
+ return [
158
+ {
159
+ "name": spec.name,
160
+ "title": spec.title,
161
+ "access": spec.access,
162
+ "route": spec.route,
163
+ "description": description(spec),
164
+ "input_schema": spec.input_schema,
165
+ "example_response": spec.example_response,
166
+ }
167
+ for spec in tools(runner.read_only)
168
+ ]
169
+
170
+ @api.post(f"{PREFIX}/tools/{{name}}", summary="Call any tool by name; the JSON body is its arguments")
171
+ async def call_by_name(name: str, request: Request) -> JSONResponse:
172
+ raw = await request.body()
173
+ try:
174
+ args = json.loads(raw) if raw.strip() else {}
175
+ except ValueError:
176
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
177
+ if not isinstance(args, dict):
178
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
179
+ spec = next((s for s in tools() if s.name == name), None)
180
+ if spec is None:
181
+ return _error(ErrorCode.UNKNOWN_TOOL, f"No tool '{name}'. GET {PREFIX}/tools lists them.")
182
+ return await call(spec, args)
183
+
184
+ for spec in sorted(tools(runner.read_only), key=_route_order):
185
+ method, path = (spec.route or "").split(" ", 1)
186
+ api.add_api_route(
187
+ PREFIX + path,
188
+ endpoint(spec),
189
+ methods=[method],
190
+ name=spec.name,
191
+ operation_id=spec.name,
192
+ summary=spec.title,
193
+ description=description(spec),
194
+ tags=[_tag(path)],
195
+ openapi_extra=_openapi(spec, method, path),
196
+ )
197
+
198
+ async def asgi(
199
+ scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
200
+ ) -> None:
201
+ if scope["type"] == "http" and token:
202
+ sent = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
203
+ if not hmac.compare_digest(sent, f"Bearer {token}"):
204
+ await _error(ErrorCode.AUTH_REQUIRED, f"Send Authorization: Bearer <{app.ENV}_MCP_TOKEN>.")(
205
+ scope, receive, send
206
+ )
207
+ return
208
+ if scope["type"] == "http" and (scope["path"] == "/mcp" or scope["path"].startswith("/mcp/")):
209
+ await manager.handle_request(scope, receive, send)
210
+ return
211
+ await api(scope, receive, send)
212
+
213
+ return asgi
@@ -0,0 +1,42 @@
1
+ """Everything the server knows about BookingKoala outside `bookingkoala/`: names, domains and the session cookie.
2
+
3
+ The rest of the package reads these values and never names the app itself.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ NAME = "BookingKoala"
9
+ SLUG = "bookingkoala"
10
+ # The command and the prefix of every environment variable (BOOKINGKOALA_AUTH_TOKEN, BOOKINGKOALA_MCP_TOKEN ...).
11
+ CLI = "bookingkoala-mcp"
12
+ ENV = "BOOKINGKOALA"
13
+ # Requests may reach only these hosts and their subdomains (every store is <store>.bookingkoala.com).
14
+ # Their cookies are read from Chrome.
15
+ DOMAINS = ("bookingkoala.com",)
16
+ # Present only while signed in. The first is the cookie a pasted token sets. The app writes it on the store's
17
+ # own host at sign-in and deletes it at sign-out.
18
+ SESSION_COOKIES = ("IRON_GATEWAY_CTX",)
19
+ # The host a pasted token's cookie belongs to. {store} is the store name, asked for with the token.
20
+ COOKIE_DOMAIN = "{store}.bookingkoala.com"
21
+ # localStorage keys of the app's pages that also hold the session, read at connect from open tabs of the app.
22
+ LOCAL_STORAGE_KEYS = ("currentUser",)
23
+ LOGIN_URL = "https://www.bookingkoala.com/login"
24
+ # A final URL with one of these means the app signed the session out.
25
+ LOGGED_OUT_MARKERS = ("/login",)
26
+ # Paths the tools may only read: sign-in, sign-out, impersonation, password, the merchant's own billing.
27
+ ACCOUNT_PATHS = (
28
+ r"^/api/v\d+/auth/",
29
+ r"^/api/v\d+/sessions/",
30
+ r"^/api/v\d+/merchant/",
31
+ r"^/api/v\d+/merchant-(setup-intent|coupon)",
32
+ r"^/api/v\d+/users/\d+/password",
33
+ r"^/bkaffiliates/v\d+/promoters/login-token",
34
+ )
35
+ # A 401 or 403 whose body has one of these asks for a fresh sign-in before the action. BookingKoala has none.
36
+ RELOGIN_MARKERS: tuple[str, ...] = ()
37
+ # REST routes live under this path, the same as on cotlet.dev.
38
+ API_PREFIX = "/api/v1/bookingkoala"
39
+ # Prepended to the MCP server's instructions: where an agent starts.
40
+ AGENT_START = (
41
+ "Start with dashboard or list_bookings: their booking, customer and provider ids are what the other tools take."
42
+ )
File without changes
@@ -0,0 +1,26 @@
1
+ """Who is signed in: the store owner's own user record (GET /api/v6/users/{id}), for "Signed in as ..."."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from bookingkoala_api_mcp.bookingkoala.client import api, session
8
+ from bookingkoala_api_mcp.core.errors import AuthExpiredError
9
+
10
+ if TYPE_CHECKING:
11
+ from bookingkoala_api_mcp.core.context import ToolContext
12
+
13
+
14
+ async def whoami(ctx: ToolContext) -> str | None:
15
+ """None only when BookingKoala says signed out; any other failure raises (detection tries again later)."""
16
+ s = session(ctx)
17
+ if s is None:
18
+ return None
19
+ try:
20
+ me = await api(ctx, "GET", f"/api/v6/users/{s.user_id}", s=s) or {}
21
+ except AuthExpiredError:
22
+ return None
23
+ name = " ".join(p for p in (me.get("first_name"), me.get("last_name")) if p).strip()
24
+ email = me.get("email_id")
25
+ who = f"{name} ({email})" if name and email else name or email or None
26
+ return f"{who}, {s.store}" if who else None
@@ -0,0 +1,183 @@
1
+ """BookingKoala's admin API, recorded on the dev account (21.10.2026).
2
+
3
+ Each business is its own host, `<store>.bookingkoala.com`, and the admin app (Angular, `cdn.bookingkoala.com/
4
+ admin-build`) calls `/api/v3`, `/api/v6` and the other `/<service>/v<n>/` paths on that host. A call needs three
5
+ things, else the app answers HTTP 200 with `api_status` 0 and "Unauthorized request":
6
+
7
+ - `authorization: Bearer <token>`: the token from sign-in. The app keeps it in `localStorage.currentUser` and in
8
+ the cookie `IRON_GATEWAY_CTX` (URL-encoded JSON `{Ip, token, id, role}`, 30 days, set by the app's JS at
9
+ sign-in, deleted at sign-out). The other cookies do not authenticate.
10
+ - `auth-session-v2`: base64 of `<csrf>__<store>__<nonce>`, where `<csrf>` is the `data` of
11
+ `GET /bkutility/v1/preload` (no auth; it also sets the httpOnly `iron_csrf_token` cookie the csrf is checked
12
+ against) and `<nonce>` is base64 of 32 random bytes, new for every request (a repeated one answers
13
+ "Request expired"). The app refreshes the csrf every 25 minutes.
14
+ - the `iron_csrf_token` cookie from that preload.
15
+
16
+ The `ip` header the app sends (the browser's address from `/getip/`) is not checked: any value passes, and the
17
+ session is not bound to the exit IP. A bad or expired token answers `api_status` 10, "Session Expired", which is
18
+ the only signed-out answer: the core cannot see it (HTTP 200), so `api` turns it into `auth_expired`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import json
25
+ import secrets
26
+ import time
27
+ from collections.abc import Iterable
28
+ from dataclasses import dataclass
29
+ from typing import TYPE_CHECKING, Any
30
+ from urllib.parse import unquote, urlsplit
31
+
32
+ from bookingkoala_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
33
+
34
+ if TYPE_CHECKING:
35
+ from bookingkoala_api_mcp.core.context import ToolContext
36
+ from bookingkoala_api_mcp.core.profile import Cookie
37
+
38
+ WWW = "https://www.bookingkoala.com"
39
+ SESSION_COOKIE = "IRON_GATEWAY_CTX"
40
+ CSRF_COOKIE = "iron_csrf_token"
41
+ _SUFFIX = ".bookingkoala.com"
42
+ # BookingKoala's own hosts, never a store.
43
+ _NOT_STORES = {"www", "cdn", "help", "files", "api"}
44
+ # api_status in the answer: 1 done, 10 signed out; 99 csrf refused (the app fetches a new one).
45
+ _OK, _SIGNED_OUT, _CSRF_REFUSED = 1, 10, 99
46
+ # "#101-13" (csrf cookie does not match) and "#101-16" (request expired) also mean a stale csrf.
47
+ _STALE_CSRF = ("#101-13", "#101-16")
48
+ # The app renews its csrf every 25 minutes; this one is renewed a little sooner.
49
+ _CSRF_TTL_S = 20 * 60
50
+ # (browser_id, origin) -> (csrf, fetched at epoch seconds). In this process's memory only, never in Redis.
51
+ _csrf: dict[tuple[str, str], tuple[str, float]] = {}
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Session:
56
+ origin: str
57
+ store: str
58
+ token: str
59
+ user_id: int
60
+ role: str
61
+ ip: str
62
+
63
+
64
+ def store_of(host: str) -> str | None:
65
+ """`yourstore` for `yourstore.bookingkoala.com`; None for BookingKoala's own hosts."""
66
+ host = host.lstrip(".").lower()
67
+ if not host.endswith(_SUFFIX):
68
+ return None
69
+ store = host.removesuffix(_SUFFIX)
70
+ return store if store and "." not in store and store not in _NOT_STORES else None
71
+
72
+
73
+ def _parse(raw: str | None) -> dict[str, Any] | None:
74
+ if not raw:
75
+ return None
76
+ try:
77
+ value = json.loads(raw)
78
+ except ValueError:
79
+ return None
80
+ return value if isinstance(value, dict) and value.get("token") and value.get("id") else None
81
+
82
+
83
+ def session_from(cookies: Iterable[Cookie], local_storage: dict[str, dict[str, str]]) -> Session | None:
84
+ """The store and token in these cookies or this localStorage; None when they hold none."""
85
+ found: tuple[str, dict[str, Any]] | None = None
86
+ for cookie in cookies:
87
+ if cookie.name != SESSION_COOKIE or not (store := store_of(cookie.domain)):
88
+ continue
89
+ if user := _parse(unquote(cookie.value)):
90
+ found = (store, user)
91
+ break
92
+ if found is None:
93
+ for origin, values in local_storage.items():
94
+ if (store := store_of(urlsplit(origin).hostname or "")) and (user := _parse(values.get("currentUser"))):
95
+ found = (store, user)
96
+ break
97
+ if found is None:
98
+ return None
99
+ store, user = found
100
+ return Session(
101
+ origin=f"https://{store}{_SUFFIX}",
102
+ store=store,
103
+ token=str(user["token"]),
104
+ user_id=int(user["id"]),
105
+ role=str(user.get("role") or ""),
106
+ ip=str(user.get("Ip") or ""),
107
+ )
108
+
109
+
110
+ def session(ctx: ToolContext) -> Session | None:
111
+ """The store and token the browser is signed in with; None when it holds none.
112
+
113
+ The session is in the cookie, in localStorage, or in both, so both are read.
114
+ """
115
+ return session_from(ctx.profile.cookies, ctx.profile.local_storage)
116
+
117
+
118
+ def require_session(ctx: ToolContext) -> Session:
119
+ s = session(ctx)
120
+ if s is None:
121
+ raise AuthExpiredError("The app signed this account out. Sign in again in Chrome, then reconnect.")
122
+ return s
123
+
124
+
125
+ async def _csrf_token(ctx: ToolContext, s: Session, *, fresh: bool = False) -> str:
126
+ key = (ctx.profile.browser_id, s.origin)
127
+ cached = _csrf.get(key)
128
+ if cached and not fresh and time.time() - cached[1] < _CSRF_TTL_S:
129
+ return cached[0]
130
+ response = await ctx.call("GET", f"{s.origin}/bkutility/v1/preload", headers=_headers(s, None))
131
+ data = (response.json().get("response") or {}).get("data")
132
+ if not isinstance(data, str) or not data:
133
+ raise ToolError("BookingKoala did not answer its preload.", ErrorCode.TARGET_ERROR)
134
+ _csrf[key] = (data, time.time())
135
+ return data
136
+
137
+
138
+ def auth_headers(s: Session, csrf: str) -> dict[str, str]:
139
+ """What an admin call adds to the page's own headers; a new nonce each time. login.py sends them from the tab."""
140
+ nonce = base64.b64encode(secrets.token_bytes(32)).decode()
141
+ signed = base64.b64encode(f"{csrf}__{s.store}__{nonce}".encode()).decode()
142
+ return {"authorization": f"Bearer {s.token}", "ip": s.ip, "auth-session-v2": signed}
143
+
144
+
145
+ async def request_headers(ctx: ToolContext) -> dict[str, str]:
146
+ """Bearer, `ip` and a fresh `auth-session-v2` for one admin call."""
147
+ s = require_session(ctx)
148
+ return auth_headers(s, await _csrf_token(ctx, s))
149
+
150
+
151
+ def _headers(s: Session, csrf: str | None) -> dict[str, str]:
152
+ headers = {
153
+ "accept": "application/json, text/plain, */*",
154
+ "origin": s.origin,
155
+ "referer": f"{s.origin}/admin/dashboard",
156
+ }
157
+ return headers if csrf is None else {**headers, **auth_headers(s, csrf)}
158
+
159
+
160
+ async def api(ctx: ToolContext, method: str, path: str, *, s: Session | None = None, **kwargs: Any) -> Any:
161
+ """One admin API call (`path` from the host root, e.g. `/api/v6/users/1`); returns `response.data`.
162
+
163
+ `AuthExpiredError` when the app says signed out, `ToolError` on any other refusal. A stale csrf gets one
164
+ new preload and one retry.
165
+ """
166
+ s = s or require_session(ctx)
167
+ extra = kwargs.pop("headers", None) or {}
168
+ answer: dict[str, Any] = {}
169
+ for attempt in range(2):
170
+ csrf = await _csrf_token(ctx, s, fresh=attempt > 0)
171
+ response = await ctx.call(method, f"{s.origin}{path}", headers={**_headers(s, csrf), **extra}, **kwargs)
172
+ body = response.json()
173
+ answer = (body.get("response") if isinstance(body, dict) else None) or {}
174
+ status, message = answer.get("api_status"), str(answer.get("message") or "")
175
+ if status == _SIGNED_OUT:
176
+ raise AuthExpiredError("The app signed this account out. Sign in again in Chrome, then reconnect.")
177
+ if status == _OK:
178
+ return answer.get("data")
179
+ if status != _CSRF_REFUSED and not message.startswith(_STALE_CSRF):
180
+ break
181
+ raise ToolError(
182
+ f"BookingKoala refused {method} {path}: {answer.get('message') or 'no answer'}.", ErrorCode.TARGET_ERROR
183
+ )
File without changes
@@ -0,0 +1,130 @@
1
+ """Booking shapes shared by the booking tools.
2
+
3
+ Bookings are small integers (`_id`). A booking's `status` is a number: 0 upcoming, 1 completed, 8 unassigned
4
+ (seen on the team store); the others come from the list filter the panel uses. Times: `booking_date`
5
+ `YYYY-MM-DD`, `arrival_time` an `HHMM` number in the store's time zone.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from typing import Any
12
+
13
+ from bookingkoala_api_mcp.core.errors import ErrorCode, ToolError
14
+
15
+ BOOKING_ID: dict[str, Any] = {"type": "integer", "minimum": 1, "description": "Booking id from list_bookings."}
16
+ PROVIDER_ID: dict[str, Any] = {
17
+ "type": "integer",
18
+ "minimum": 1,
19
+ "description": "Provider or team id (BookingKoala's provider `_id`).",
20
+ }
21
+ # Seen on the team store: 0 upcoming, 1 completed, 8 unassigned. Others pass through as numbers.
22
+ STATUSES = {0: "upcoming", 1: "completed", 2: "cancelled", 8: "unassigned"}
23
+
24
+ EXAMPLE_BOOKING: dict[str, Any] = {
25
+ "booking_id": 3,
26
+ "status": "upcoming",
27
+ "date": "2026-11-16",
28
+ "arrival_time": "11:00",
29
+ "length_minutes": 160,
30
+ "occurrence": "recurring",
31
+ "recurring_id": 1,
32
+ "frequency": "every_4_weeks",
33
+ "customer": {"customer_id": 2, "name": "Dana Whitfield", "email": "dana.whitfield@example.com"},
34
+ "address": "540 W 28th Street, New York, NY 10022",
35
+ "provider_ids": [3],
36
+ "providers": [{"provider_id": 3, "name": "Marcus Lee"}],
37
+ "payment_method": "cash",
38
+ "total": 200.0,
39
+ "charged": False,
40
+ }
41
+
42
+
43
+ def text(value: Any) -> str | None:
44
+ value = str(value).strip() if value is not None else ""
45
+ return value or None
46
+
47
+
48
+ def time_of(value: Any) -> str | None:
49
+ """`1100` -> `11:00`; None when unset."""
50
+ if not isinstance(value, int | float) or value < 0:
51
+ return None
52
+ hours, minutes = divmod(int(value), 100)
53
+ return f"{hours:02d}:{minutes:02d}"
54
+
55
+
56
+ def parse_time(value: str, field: str) -> int:
57
+ """`11:00`, `9:30` or `14` -> the `HHMM` number BookingKoala keeps."""
58
+ raw = value.strip().lower().replace(" ", "")
59
+ pm, am = raw.endswith("pm"), raw.endswith("am")
60
+ raw = raw.removesuffix("pm").removesuffix("am")
61
+ hours_raw, _, minutes_raw = raw.partition(":")
62
+ try:
63
+ hours, minutes = int(hours_raw), int(minutes_raw or 0)
64
+ except ValueError:
65
+ raise ToolError(f"{field} is a time like 09:30.", ErrorCode.INVALID_INPUT) from None
66
+ if pm and hours < 12:
67
+ hours += 12
68
+ if am and hours == 12:
69
+ hours = 0
70
+ if not (0 <= hours < 24 and 0 <= minutes < 60):
71
+ raise ToolError(f"{field} is a time like 09:30.", ErrorCode.INVALID_INPUT)
72
+ return hours * 100 + minutes
73
+
74
+
75
+ def parse_day(value: str, field: str) -> str:
76
+ try:
77
+ return date.fromisoformat(value.strip()).isoformat()
78
+ except ValueError:
79
+ raise ToolError(f"{field} is a date like 2026-11-16.", ErrorCode.INVALID_INPUT) from None
80
+
81
+
82
+ def money(value: Any) -> float | None:
83
+ try:
84
+ return round(float(value), 2)
85
+ except (TypeError, ValueError):
86
+ return None
87
+
88
+
89
+ def status_of(value: Any) -> str | int | None:
90
+ return STATUSES.get(value, value) if isinstance(value, int) else None
91
+
92
+
93
+ def address_text(address: dict[str, Any] | None) -> str | None:
94
+ if not address:
95
+ return None
96
+ line = text(address.get("address"))
97
+ apt = text(address.get("apt"))
98
+ return ", ".join(part for part in (line, f"apt {apt}" if apt else None) if part) or None
99
+
100
+
101
+ def booking_row(
102
+ booking: dict[str, Any], users: dict[str, Any] | None = None, addresses: dict[str, Any] | None = None
103
+ ) -> dict[str, Any]:
104
+ """One booking in list form; `users` and `addresses` are the search answer's `users_info` and `addresses`."""
105
+ users, addresses = users or {}, addresses or {}
106
+ customer = users.get(str(booking.get("uid"))) or {}
107
+ provider_ids = [int(pid) for pid in booking.get("provider_ids") or []]
108
+ return {
109
+ "booking_id": booking.get("_id"),
110
+ "status": status_of(booking.get("status")),
111
+ "date": booking.get("booking_date"),
112
+ "arrival_time": time_of(booking.get("arrival_time")),
113
+ "length_minutes": booking.get("length"),
114
+ "occurrence": booking.get("occurrence"),
115
+ "recurring_id": booking.get("recurring_id") or None,
116
+ "frequency": text(booking.get("frequency_repeat_slug")),
117
+ "customer": {
118
+ "customer_id": booking.get("uid"),
119
+ "name": text(customer.get("full_name")),
120
+ "email": text(customer.get("email_id")),
121
+ },
122
+ "address": address_text(addresses.get(str(booking.get("address_id")))),
123
+ "provider_ids": provider_ids,
124
+ "providers": [
125
+ {"provider_id": pid, "name": text((users.get(str(pid)) or {}).get("full_name"))} for pid in provider_ids
126
+ ],
127
+ "payment_method": text(booking.get("payment_method")),
128
+ "total": money(booking.get("final_amount")),
129
+ "charged": bool(booking.get("is_charged")),
130
+ }