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.
- bookingkoala_api_mcp/__init__.py +28 -0
- bookingkoala_api_mcp/api.py +213 -0
- bookingkoala_api_mcp/app.py +42 -0
- bookingkoala_api_mcp/bookingkoala/__init__.py +0 -0
- bookingkoala_api_mcp/bookingkoala/auth.py +26 -0
- bookingkoala_api_mcp/bookingkoala/client.py +183 -0
- bookingkoala_api_mcp/bookingkoala/tools/__init__.py +0 -0
- bookingkoala_api_mcp/bookingkoala/tools/_bookings.py +130 -0
- bookingkoala_api_mcp/bookingkoala/tools/_people.py +64 -0
- bookingkoala_api_mcp/bookingkoala/tools/assign_provider.py +57 -0
- bookingkoala_api_mcp/bookingkoala/tools/cancel_booking.py +76 -0
- bookingkoala_api_mcp/bookingkoala/tools/create_booking.py +61 -0
- bookingkoala_api_mcp/bookingkoala/tools/create_customer.py +114 -0
- bookingkoala_api_mcp/bookingkoala/tools/dashboard.py +164 -0
- bookingkoala_api_mcp/bookingkoala/tools/get_booking.py +134 -0
- bookingkoala_api_mcp/bookingkoala/tools/get_customer.py +138 -0
- bookingkoala_api_mcp/bookingkoala/tools/get_provider.py +130 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_bookings.py +82 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_charges.py +125 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_customers.py +84 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_drafts.py +82 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_invoices.py +154 -0
- bookingkoala_api_mcp/bookingkoala/tools/list_providers.py +120 -0
- bookingkoala_api_mcp/bookingkoala/tools/reschedule_booking.py +88 -0
- bookingkoala_api_mcp/bookingkoala/tools/resend_invoice.py +121 -0
- bookingkoala_api_mcp/bookingkoala/tools/send_quote.py +189 -0
- bookingkoala_api_mcp/cli.py +201 -0
- bookingkoala_api_mcp/core/__init__.py +0 -0
- bookingkoala_api_mcp/core/account_guard.py +33 -0
- bookingkoala_api_mcp/core/context.py +192 -0
- bookingkoala_api_mcp/core/errors.py +78 -0
- bookingkoala_api_mcp/core/helpers.py +33 -0
- bookingkoala_api_mcp/core/profile.py +28 -0
- bookingkoala_api_mcp/core/tools.py +106 -0
- bookingkoala_api_mcp/mcp_context.py +126 -0
- bookingkoala_api_mcp/mcp_server.py +67 -0
- bookingkoala_api_mcp/runner.py +106 -0
- bookingkoala_api_mcp/scripting.py +80 -0
- bookingkoala_api_mcp/session.py +285 -0
- bookingkoala_api_mcp-1.0.2.dist-info/METADATA +461 -0
- bookingkoala_api_mcp-1.0.2.dist-info/RECORD +44 -0
- bookingkoala_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- bookingkoala_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|