booksy-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 (55) hide show
  1. booksy_api_mcp/__init__.py +28 -0
  2. booksy_api_mcp/api.py +212 -0
  3. booksy_api_mcp/app.py +41 -0
  4. booksy_api_mcp/booksy/__init__.py +0 -0
  5. booksy_api_mcp/booksy/auth.py +33 -0
  6. booksy_api_mcp/booksy/calendar.py +247 -0
  7. booksy_api_mcp/booksy/client.py +100 -0
  8. booksy_api_mcp/booksy/clients.py +172 -0
  9. booksy_api_mcp/booksy/engagement.py +191 -0
  10. booksy_api_mcp/booksy/tools/__init__.py +0 -0
  11. booksy_api_mcp/booksy/tools/add_client.py +59 -0
  12. booksy_api_mcp/booksy/tools/add_client_note.py +46 -0
  13. booksy_api_mcp/booksy/tools/add_time_off.py +170 -0
  14. booksy_api_mcp/booksy/tools/cancel_appointment.py +73 -0
  15. booksy_api_mcp/booksy/tools/create_appointment.py +155 -0
  16. booksy_api_mcp/booksy/tools/delete_client.py +53 -0
  17. booksy_api_mcp/booksy/tools/estimate_message_blast.py +80 -0
  18. booksy_api_mcp/booksy/tools/find_client.py +49 -0
  19. booksy_api_mcp/booksy/tools/find_open_slots.py +209 -0
  20. booksy_api_mcp/booksy/tools/get_appointment.py +65 -0
  21. booksy_api_mcp/booksy/tools/get_business_hours.py +64 -0
  22. booksy_api_mcp/booksy/tools/get_business_profile.py +105 -0
  23. booksy_api_mcp/booksy/tools/get_client.py +72 -0
  24. booksy_api_mcp/booksy/tools/get_report.py +114 -0
  25. booksy_api_mcp/booksy/tools/get_settings.py +77 -0
  26. booksy_api_mcp/booksy/tools/list_appointments.py +92 -0
  27. booksy_api_mcp/booksy/tools/list_clients.py +94 -0
  28. booksy_api_mcp/booksy/tools/list_message_blasts.py +98 -0
  29. booksy_api_mcp/booksy/tools/list_notifications.py +71 -0
  30. booksy_api_mcp/booksy/tools/list_reviews.py +102 -0
  31. booksy_api_mcp/booksy/tools/list_services.py +102 -0
  32. booksy_api_mcp/booksy/tools/list_staff.py +107 -0
  33. booksy_api_mcp/booksy/tools/list_time_off.py +107 -0
  34. booksy_api_mcp/booksy/tools/remove_time_off.py +47 -0
  35. booksy_api_mcp/booksy/tools/reply_to_review.py +59 -0
  36. booksy_api_mcp/booksy/tools/reschedule_appointment.py +106 -0
  37. booksy_api_mcp/booksy/tools/send_message_blast.py +100 -0
  38. booksy_api_mcp/booksy/tools/update_client.py +50 -0
  39. booksy_api_mcp/cli.py +204 -0
  40. booksy_api_mcp/core/__init__.py +0 -0
  41. booksy_api_mcp/core/account_guard.py +33 -0
  42. booksy_api_mcp/core/context.py +199 -0
  43. booksy_api_mcp/core/errors.py +78 -0
  44. booksy_api_mcp/core/helpers.py +33 -0
  45. booksy_api_mcp/core/tools.py +106 -0
  46. booksy_api_mcp/mcp_context.py +126 -0
  47. booksy_api_mcp/mcp_server.py +67 -0
  48. booksy_api_mcp/runner.py +109 -0
  49. booksy_api_mcp/scripting.py +73 -0
  50. booksy_api_mcp/session.py +257 -0
  51. booksy_api_mcp-1.0.2.dist-info/METADATA +477 -0
  52. booksy_api_mcp-1.0.2.dist-info/RECORD +55 -0
  53. booksy_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  54. booksy_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
  55. booksy_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 booksy_api_mcp import call
6
+ services = call("list_services")["services"]
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 booksy_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())
booksy_api_mcp/api.py ADDED
@@ -0,0 +1,212 @@
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 booksy_api_mcp import __version__, app, mcp_server
27
+ from booksy_api_mcp.core.errors import HTTP_STATUS, ErrorCode, ToolError
28
+ from booksy_api_mcp.core.tools import ToolSpec
29
+ from booksy_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 /clients/search wins over /clients/{client_id}."""
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 first part of the path (clients, appointments ...)."""
66
+ return path.strip("/").split("/")[0]
67
+
68
+
69
+ def _openapi(spec: ToolSpec, method: str, path: str) -> dict[str, Any]:
70
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
71
+ required = set(spec.input_schema.get("required") or [])
72
+ in_path = set(re.findall(r"\{(\w+)\}", path))
73
+ parameters = [
74
+ {"name": name, "in": "path", "required": True, "schema": props.get(name, {"type": "string"})}
75
+ for name in in_path
76
+ ]
77
+ rest = {k: v for k, v in props.items() if k not in in_path}
78
+ extra: dict[str, Any] = {}
79
+ if method == "GET":
80
+ parameters += [
81
+ {"name": name, "in": "query", "required": name in required, "schema": schema}
82
+ for name, schema in rest.items()
83
+ ]
84
+ elif rest:
85
+ body = {"type": "object", "properties": rest, "required": sorted(required - in_path)}
86
+ extra["requestBody"] = {"required": bool(body["required"]), "content": {"application/json": {"schema": body}}}
87
+ if spec.example_response:
88
+ extra["responses"] = {
89
+ "200": {"description": "OK", "content": {"application/json": {"example": spec.example_response}}}
90
+ }
91
+ return {"parameters": parameters, **extra}
92
+
93
+
94
+ async def _arguments(request: Request, spec: ToolSpec) -> dict[str, Any]:
95
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
96
+ args: dict[str, Any] = {}
97
+ if request.method != "GET":
98
+ raw = await request.body()
99
+ if raw.strip():
100
+ try:
101
+ body = json.loads(raw)
102
+ except ValueError:
103
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT) from None
104
+ if not isinstance(body, dict):
105
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT)
106
+ args.update(body)
107
+ for name in request.query_params:
108
+ values = request.query_params.getlist(name)
109
+ schema = props.get(name) or {}
110
+ args[name] = (
111
+ [_typed(v, schema.get("items") or {}) for v in values]
112
+ if schema.get("type") == "array" and len(values) > 1
113
+ else _typed(values[-1], schema)
114
+ )
115
+ for name, value in request.path_params.items():
116
+ args[name] = _typed(value, props.get(name) or {})
117
+ return args
118
+
119
+
120
+ def create_app(runner: Runner) -> ASGI:
121
+ manager = StreamableHTTPSessionManager(app=mcp_server.build(runner), json_response=True, stateless=True)
122
+ token = os.environ.get(f"{app.ENV}_MCP_TOKEN", "").strip()
123
+
124
+ @contextlib.asynccontextmanager
125
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
126
+ async with manager.run():
127
+ yield
128
+ await runner.close()
129
+
130
+ api = FastAPI(
131
+ title=f"{app.NAME} API (self-hosted)",
132
+ version=__version__,
133
+ description=f"Your {app.NAME} account as a REST API, run on your machine with your Chrome's session.",
134
+ lifespan=lifespan,
135
+ )
136
+
137
+ async def call(spec: ToolSpec, args: dict[str, Any]) -> JSONResponse:
138
+ try:
139
+ result = await runner.run(spec.name, args)
140
+ except ToolError as exc:
141
+ return _error(exc.code, str(exc))
142
+ return JSONResponse(result)
143
+
144
+ def endpoint(spec: ToolSpec) -> Callable[[Request], Awaitable[JSONResponse]]:
145
+ async def handle(request: Request) -> JSONResponse:
146
+ try:
147
+ args = await _arguments(request, spec)
148
+ except ToolError as exc:
149
+ return _error(exc.code, str(exc))
150
+ return await call(spec, args)
151
+
152
+ return handle
153
+
154
+ @api.get(f"{PREFIX}/tools", summary="Every tool with its route and input schema")
155
+ async def list_tools() -> list[dict[str, Any]]:
156
+ return [
157
+ {
158
+ "name": spec.name,
159
+ "title": spec.title,
160
+ "access": spec.access,
161
+ "route": spec.route,
162
+ "description": description(spec),
163
+ "input_schema": spec.input_schema,
164
+ "example_response": spec.example_response,
165
+ }
166
+ for spec in tools(runner.read_only)
167
+ ]
168
+
169
+ @api.post(f"{PREFIX}/tools/{{name}}", summary="Call any tool by name; the JSON body is its arguments")
170
+ async def call_by_name(name: str, request: Request) -> JSONResponse:
171
+ raw = await request.body()
172
+ try:
173
+ args = json.loads(raw) if raw.strip() else {}
174
+ except ValueError:
175
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
176
+ if not isinstance(args, dict):
177
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
178
+ spec = next((s for s in tools() if s.name == name), None)
179
+ if spec is None:
180
+ return _error(ErrorCode.UNKNOWN_TOOL, f"No tool '{name}'. GET {PREFIX}/tools lists them.")
181
+ return await call(spec, args)
182
+
183
+ for spec in sorted(tools(runner.read_only), key=_route_order):
184
+ method, path = (spec.route or "").split(" ", 1)
185
+ api.add_api_route(
186
+ PREFIX + path,
187
+ endpoint(spec),
188
+ methods=[method],
189
+ name=spec.name,
190
+ operation_id=spec.name,
191
+ summary=spec.title,
192
+ description=description(spec),
193
+ tags=[_tag(path)],
194
+ openapi_extra=_openapi(spec, method, path),
195
+ )
196
+
197
+ async def asgi(
198
+ scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
199
+ ) -> None:
200
+ if scope["type"] == "http" and token:
201
+ sent = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
202
+ if not hmac.compare_digest(sent, f"Bearer {token}"):
203
+ await _error(ErrorCode.AUTH_REQUIRED, f"Send Authorization: Bearer <{app.ENV}_MCP_TOKEN>.")(
204
+ scope, receive, send
205
+ )
206
+ return
207
+ if scope["type"] == "http" and (scope["path"] == "/mcp" or scope["path"].startswith("/mcp/")):
208
+ await manager.handle_request(scope, receive, send)
209
+ return
210
+ await api(scope, receive, send)
211
+
212
+ return asgi
booksy_api_mcp/app.py ADDED
@@ -0,0 +1,41 @@
1
+ """Everything the server knows about Booksy outside `booksy/`: names, domains and where the session lives.
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 = "Booksy"
9
+ SLUG = "booksy"
10
+ # The command and the prefix of every environment variable (BOOKSY_MCP_HOME, BOOKSY_MCP_TOKEN ...).
11
+ CLI = "booksy-mcp"
12
+ ENV = "BOOKSY"
13
+ # Requests may reach only these hosts and their subdomains. Their cookies are read from Chrome.
14
+ DOMAINS = ("booksy.com",)
15
+ # Cookies present only while signed in. Booksy has none that carries the session, so a token cannot be pasted.
16
+ SESSION_COOKIES: tuple[str, ...] = ()
17
+ COOKIE_DOMAIN = ".booksy.com"
18
+ # localStorage read from Chrome at `connect`, by origin. The session is the `_<country>_identity` key of the
19
+ # web-gateway.booksy.com frame the panel embeds; booksy.com's `app.country` says which country the panel has open.
20
+ STORAGE_ORIGINS = ("https://web-gateway.booksy.com", "https://booksy.com")
21
+ # Present only while signed in: origin -> key pattern (regex).
22
+ SESSION_STORAGE = {"https://web-gateway.booksy.com": r"^_[a-z]{2}_identity$"}
23
+ LOGIN_URL = "https://booksy.com/pro/en-us/onboarding/Login"
24
+ # A final URL with one of these means the app signed the session out.
25
+ LOGGED_OUT_MARKERS: tuple[str, ...] = ()
26
+ # Text in a 403 body that means "sign in again": Booksy never answers 401, it names the token field that failed.
27
+ RELOGIN_MARKERS = ('"field":"auth_header_token"', '"field":"access_token"')
28
+ # URL path patterns of the account itself (sign-in, password, deletion): only GET may reach them.
29
+ ACCOUNT_PATHS = (
30
+ r"/business_api/account(/|$)",
31
+ r"/business_api/me/?$",
32
+ r"/business_api/me/(businesses/\d+/)?account_deletion",
33
+ r"/utils/(password_check|phone_check)",
34
+ )
35
+ # REST routes live under this path, the same as on cotlet.dev.
36
+ API_PREFIX = "/api/v1/booksy"
37
+ # Prepended to the MCP server's instructions: where an agent starts.
38
+ AGENT_START = (
39
+ "Start with list_services and list_staff: their ids are what the calendar tools take. "
40
+ "Times are local to the business."
41
+ )
File without changes
@@ -0,0 +1,33 @@
1
+ """Who is signed in: Booksy's own answer for the account (business_api/me) and its businesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from booksy_api_mcp.booksy.client import headers, session
8
+ from booksy_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
9
+
10
+ if TYPE_CHECKING:
11
+ from booksy_api_mcp.core.context import ToolContext
12
+
13
+
14
+ async def whoami(ctx: ToolContext) -> str | None:
15
+ """None when the gateway holds no identity or Booksy says signed out; any other failure raises."""
16
+ s = session(ctx)
17
+ if s is None:
18
+ return None
19
+ sent = headers(ctx, s)
20
+ try:
21
+ me = await ctx.call("GET", f"{s.api}/business_api/me/", headers=sent)
22
+ if me.status != 200:
23
+ raise ToolError(f"Booksy answered {me.status} to who am I.", ErrorCode.TARGET_ERROR)
24
+ owned = await ctx.call("GET", f"{s.api}/business_api/me/businesses/", headers=sent)
25
+ except AuthExpiredError:
26
+ return None
27
+ account = me.json().get("account") or {}
28
+ name = f"{account.get('first_name') or ''} {account.get('last_name') or ''}".strip() or account.get("email")
29
+ businesses = (owned.json().get("businesses") or []) if owned.status == 200 else []
30
+ # The identity's businessId is the one the panel has open; name it first.
31
+ businesses.sort(key=lambda b: b.get("id") != s.business_id)
32
+ names = ", ".join(b["name"] for b in businesses if b.get("name"))
33
+ return f"{name} ({names})" if name and names else name or None
@@ -0,0 +1,247 @@
1
+ """Calendar, appointments, open slots and time off: requests and shapes shared by those tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timedelta
6
+ from typing import TYPE_CHECKING, Any
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from booksy_api_mcp.booksy.client import headers, session
10
+ from booksy_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
11
+
12
+ if TYPE_CHECKING:
13
+ from booksy_api_mcp.core.context import HttpResponse, ToolContext
14
+
15
+
16
+ async def request(
17
+ ctx: ToolContext,
18
+ method: str,
19
+ path: str,
20
+ *,
21
+ params: dict[str, Any] | None = None,
22
+ body: Any = None,
23
+ business: bool = True,
24
+ ) -> HttpResponse:
25
+ """One API call; `path` is under the open business (`~`) unless `business` is False."""
26
+ s = session(ctx)
27
+ if s is None or s.business_id is None:
28
+ raise AuthExpiredError("Booksy is signed out.")
29
+ base = f"{s.api}/business_api/me/businesses/{s.business_id}" if business else f"{s.api}/business_api/me"
30
+ kwargs: dict[str, Any] = {"headers": headers(ctx, s)}
31
+ if params:
32
+ kwargs["params"] = params
33
+ if body is not None:
34
+ kwargs["json"] = body
35
+ return await ctx.call(method, f"{base}{path}", **kwargs)
36
+
37
+
38
+ def problem(response: HttpResponse) -> str | None:
39
+ """Booksy's own words for a refused request, or None when it went through."""
40
+ if response.status < 400:
41
+ return None
42
+ try:
43
+ data = response.json()
44
+ except ToolError:
45
+ return f"Booksy refused the request ({response.status})."
46
+ if isinstance(data, dict):
47
+ errors = data.get("errors")
48
+ if isinstance(errors, list) and errors:
49
+ texts = [_error_text(e) for e in errors if isinstance(e, dict)]
50
+ if any(texts):
51
+ return " ".join(t for t in texts if t)
52
+ if data.get("detail"):
53
+ return str(data["detail"])
54
+ for field, value in data.items():
55
+ if isinstance(value, list) and value and isinstance(value[0], str):
56
+ return f"{field}: {value[0]}"
57
+ return f"Booksy refused the request ({response.status})."
58
+
59
+
60
+ def _error_text(error: dict[str, Any]) -> str:
61
+ text = str(error.get("description") or error.get("code") or "")
62
+ field = error.get("field")
63
+ return f"{field}: {text}" if field and field != "non_field_errors" and text else text
64
+
65
+
66
+ async def ok_json(ctx: ToolContext, method: str, path: str, **kwargs: Any) -> Any:
67
+ response = await request(ctx, method, path, **kwargs)
68
+ if response.status == 404:
69
+ raise ToolError("Booksy has no such record.", ErrorCode.TARGET_ERROR)
70
+ if (text := problem(response)) is not None:
71
+ raise ToolError(text, ErrorCode.TARGET_ERROR)
72
+ return response.json() if response.text.strip() else {}
73
+
74
+
75
+ async def business_zone(ctx: ToolContext) -> ZoneInfo:
76
+ """The business's time zone; times in the API are local to it."""
77
+ cached = await ctx.cache_get(f"tz:{_business_key(ctx)}")
78
+ if cached:
79
+ return ZoneInfo(cached)
80
+ data = await ok_json(ctx, "GET", "")
81
+ business = data.get("business") or data
82
+ name = ((business.get("location") or {}).get("time_zone_name")) or business.get("timezone_name") or "UTC"
83
+ await ctx.cache_set(f"tz:{_business_key(ctx)}", name, 3600)
84
+ return ZoneInfo(name)
85
+
86
+
87
+ def _business_key(ctx: ToolContext) -> str:
88
+ s = session(ctx)
89
+ return f"{s.country}:{s.business_id}" if s else "none"
90
+
91
+
92
+ def iso(local: str | None, zone: ZoneInfo) -> str | None:
93
+ """`YYYY-MM-DDTHH:MM` local to the business as ISO 8601 with its offset."""
94
+ if not local:
95
+ return None
96
+ return datetime.fromisoformat(local).replace(tzinfo=zone).isoformat()
97
+
98
+
99
+ def local(value: str, zone: ZoneInfo) -> datetime:
100
+ """An ISO 8601 input as naive business-local time; a time without a zone is taken as local."""
101
+ try:
102
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
103
+ except ValueError as exc:
104
+ raise ToolError(f"Not an ISO 8601 date-time: {value}.", ErrorCode.INVALID_INPUT) from exc
105
+ if moment.tzinfo is not None:
106
+ moment = moment.astimezone(zone).replace(tzinfo=None)
107
+ return moment
108
+
109
+
110
+ def stamp(moment: datetime) -> str:
111
+ return moment.strftime("%Y-%m-%dT%H:%M")
112
+
113
+
114
+ def day(value: str) -> date:
115
+ try:
116
+ return date.fromisoformat(value)
117
+ except ValueError as exc:
118
+ raise ToolError(f"Not a date (YYYY-MM-DD): {value}.", ErrorCode.INVALID_INPUT) from exc
119
+
120
+
121
+ def days(start: date, end: date) -> list[date]:
122
+ return [start + timedelta(days=n) for n in range((end - start).days + 1)]
123
+
124
+
125
+ def int_id(value: Any, what: str) -> int:
126
+ try:
127
+ return int(str(value).strip())
128
+ except ValueError as exc:
129
+ raise ToolError(f"{what} must be a number from Booksy.", ErrorCode.INVALID_INPUT) from exc
130
+
131
+
132
+ async def service_variant(ctx: ToolContext, service_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
133
+ """The service and its first variant (duration, price, staffers); a variant id also finds its service."""
134
+ wanted = int_id(service_id, "service_id")
135
+ for service in (await ok_json(ctx, "GET", "/services")).get("services") or []:
136
+ variants = service.get("variants") or []
137
+ if service.get("id") == wanted and variants:
138
+ return service, variants[0]
139
+ for variant in variants:
140
+ if variant.get("id") == wanted:
141
+ return service, variant
142
+ raise ToolError("No service with this ID in Booksy. Take one from list_services.", ErrorCode.INVALID_INPUT)
143
+
144
+
145
+ # Booksy's appointment status letters, as the calendar legend names them.
146
+ STATUSES = {
147
+ "A": "confirmed",
148
+ "W": "pending",
149
+ "C": "cancelled",
150
+ "D": "declined",
151
+ "N": "no_show",
152
+ "F": "finished",
153
+ "P": "payment_pending",
154
+ "V": "pending_payment",
155
+ "R": "rejected",
156
+ "M": "modified",
157
+ }
158
+
159
+
160
+ def status_name(code: Any) -> str:
161
+ return STATUSES.get(str(code), str(code or "unknown").lower())
162
+
163
+
164
+ def appointment_view(appointment: dict[str, Any], customer: dict[str, Any] | None, zone: ZoneInfo) -> dict[str, Any]:
165
+ """One appointment as the tools return it."""
166
+ subs = appointment.get("subbookings") or []
167
+ merged = (customer or {}).get("merged_data") or {}
168
+ client = None
169
+ if merged or (appointment.get("customer") or {}).get("id"):
170
+ client = {
171
+ "client_id": str(merged.get("id") or (appointment.get("customer") or {}).get("id") or ""),
172
+ "name": merged.get("full_name") or (appointment.get("customer") or {}).get("name") or "",
173
+ }
174
+ services = [
175
+ {
176
+ "service_id": str((sub.get("service") or {}).get("id") or ""),
177
+ "name": (sub.get("service") or {}).get("name") or "",
178
+ "price": ((sub.get("service") or {}).get("variant") or {}).get("price"),
179
+ "duration_minutes": ((sub.get("service") or {}).get("variant") or {}).get("duration"),
180
+ "staff_id": str(sub.get("staffer_id") or ""),
181
+ "staff": (sub.get("staffer") or {}).get("name") or "",
182
+ "start": iso(sub.get("booked_from"), zone),
183
+ "end": iso(sub.get("booked_till"), zone),
184
+ }
185
+ for sub in subs
186
+ ]
187
+ actions = appointment.get("actions") or {}
188
+ return {
189
+ "appointment_id": str(appointment.get("appointment_uid") or ""),
190
+ "status": status_name(appointment.get("status")),
191
+ "client": client,
192
+ "services": services,
193
+ "staff": services[0]["staff"] if services else "",
194
+ "start": iso(appointment.get("booked_from"), zone),
195
+ "end": iso(appointment.get("booked_till"), zone),
196
+ "total": appointment.get("total_value"),
197
+ "total_text": appointment.get("total"),
198
+ "note": appointment.get("business_secret_note") or "",
199
+ "client_note": appointment.get("business_note") or "",
200
+ "can": sorted(name for name, allowed in actions.items() if allowed),
201
+ }
202
+
203
+
204
+ async def read_appointment(ctx: ToolContext, appointment_id: str) -> dict[str, Any]:
205
+ """The raw answer of GET appointments/{uid}/: `appointment` and `customer`."""
206
+ uid = int_id(appointment_id, "appointment_id")
207
+ return await ok_json(ctx, "GET", f"/appointments/{uid}/")
208
+
209
+
210
+ MAX_DAYS = 31
211
+
212
+
213
+ async def calendar(ctx: ToolContext, start: date, end: date) -> dict[str, Any]:
214
+ """The calendar grid for a day range: every resource, booking, block and time off."""
215
+ if end < start:
216
+ raise ToolError("The last day is before the first day.", ErrorCode.INVALID_INPUT)
217
+ if (end - start).days >= MAX_DAYS:
218
+ raise ToolError(f"Ask for at most {MAX_DAYS} days at a time.", ErrorCode.INVALID_INPUT)
219
+ params = {
220
+ "start_date": start.isoformat(),
221
+ "end_date": end.isoformat(),
222
+ "include_unconfirmed": "true",
223
+ "version": "3",
224
+ "resources_per_page": "500",
225
+ }
226
+ return await ok_json(ctx, "GET", "/calendar", params=params)
227
+
228
+
229
+ def staff_names(grid: dict[str, Any]) -> dict[int, str]:
230
+ return {r["id"]: r.get("name") or "" for r in grid.get("resources") or [] if isinstance(r.get("id"), int)}
231
+
232
+
233
+ # Time off is Shifts' whole-day absence, a block is the calendar's time reservation; one id names both.
234
+ TIME_OFF_REASONS = ("vacation", "sick_day", "personal_day", "training", "early", "late", "no_show", "other")
235
+
236
+
237
+ def time_off_id(kind: str, raw: Any) -> str:
238
+ return f"{'off' if kind == 'time_off' else 'block'}-{raw}"
239
+
240
+
241
+ def parse_time_off_id(value: str) -> tuple[str, int]:
242
+ kind, _, raw = value.strip().partition("-")
243
+ if kind not in ("off", "block") or not raw.isdigit():
244
+ raise ToolError(
245
+ "time_off_id comes from add_time_off or list_time_off (off-… or block-…).", ErrorCode.INVALID_INPUT
246
+ )
247
+ return ("time_off" if kind == "off" else "block"), int(raw)
@@ -0,0 +1,100 @@
1
+ """Booksy Biz's session and API, recorded 21.10.2026.
2
+
3
+ The session is not a cookie. The panel on booksy.com keeps its identity in the `web-gateway.booksy.com` iframe:
4
+ localStorage key `_<country>_identity` (`_pl_identity`), JSON `{accessToken, loginType, profileType, profile}`.
5
+ The gateway script builds the key as `_${country}_identity` and deletes it on sign-out (CLEAR_IDENTITY).
6
+ booksy.com's own storage has no token. Every API call carries the token as `X-Access-Token`, the public
7
+ `X-Api-Key` of the web panel and `X-Fingerprint` (the `_bf` cookie on .booksy.com).
8
+
9
+ The API host is the country: `https://<country>.booksy.com/core/v2/...` (the bundle's `core` subdomain is
10
+ `{country}`), so the country comes from the identity key and a US account goes to us.booksy.com.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from booksy_api_mcp.core.context import ToolContext
22
+
23
+ WEB = "https://booksy.com"
24
+ GATEWAY = "https://web-gateway.booksy.com"
25
+ # Public key of the web panel (frontdesk), the same in every country.
26
+ API_KEY = "frontdesk-76661e2b-25f0-49b4-b33a-9d78957a58e3"
27
+ _IDENTITY_KEY = re.compile(r"^_([a-z]{2})_identity$")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Session:
32
+ country: str
33
+ token: str
34
+ profile: dict[str, Any]
35
+
36
+ @property
37
+ def api(self) -> str:
38
+ return f"https://{self.country}.booksy.com/core/v2"
39
+
40
+ @property
41
+ def business_id(self) -> int | None:
42
+ return self.profile.get("businessId")
43
+
44
+
45
+ def _identity(raw: str | None) -> dict[str, Any] | None:
46
+ if not raw:
47
+ return None
48
+ try:
49
+ value = json.loads(raw)
50
+ except ValueError:
51
+ return None
52
+ if not isinstance(value, dict) or not isinstance(value.get("accessToken"), str):
53
+ return None
54
+ return value if value["accessToken"] and isinstance(value.get("profile"), dict) else None
55
+
56
+
57
+ def session_of(local_storage: dict[str, dict[str, str]]) -> Session | None:
58
+ """The signed-in identity in a jar's or a tab's `local_storage`; None when the gateway holds none.
59
+
60
+ One browser can hold identities of several countries; the panel's last country (`app.country` on
61
+ booksy.com) wins, else the first one found.
62
+ """
63
+ stored = local_storage.get(GATEWAY) or {}
64
+ found: dict[str, dict[str, Any]] = {}
65
+ for key, raw in stored.items():
66
+ match = _IDENTITY_KEY.match(key)
67
+ if match and (identity := _identity(raw)) is not None:
68
+ found[match.group(1)] = identity
69
+ if not found:
70
+ return None
71
+ last = (local_storage.get(WEB) or {}).get("app.country", "").strip('"').lower()
72
+ country = last if last in found else sorted(found)[0]
73
+ return Session(country=country, token=found[country]["accessToken"], profile=found[country]["profile"])
74
+
75
+
76
+ def session(ctx: ToolContext) -> Session | None:
77
+ return session_of(ctx.profile.local_storage)
78
+
79
+
80
+ def headers(ctx: ToolContext, s: Session) -> dict[str, str]:
81
+ """What the panel sends with every API call."""
82
+ found = {
83
+ "x-api-key": API_KEY,
84
+ "x-access-token": s.token,
85
+ "x-app-version": "3.0",
86
+ "accept": "application/json",
87
+ "origin": WEB,
88
+ "referer": f"{WEB}/",
89
+ }
90
+ fingerprint = next((c.value for c in ctx.profile.cookies if c.name == "_bf" and c.value), None)
91
+ if fingerprint:
92
+ found["x-fingerprint"] = fingerprint
93
+ return found
94
+
95
+
96
+ def request_headers(ctx: ToolContext) -> dict[str, str]:
97
+ """The tools' identity headers for one request; only the public
98
+ api key when the gateway holds no identity, so Booksy answers its signed-out 403."""
99
+ s = session(ctx)
100
+ return headers(ctx, s) if s is not None else {"x-api-key": API_KEY}