partiful-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 (42) hide show
  1. partiful_api_mcp/__init__.py +28 -0
  2. partiful_api_mcp/api.py +213 -0
  3. partiful_api_mcp/app.py +37 -0
  4. partiful_api_mcp/cli.py +192 -0
  5. partiful_api_mcp/core/__init__.py +0 -0
  6. partiful_api_mcp/core/account_guard.py +35 -0
  7. partiful_api_mcp/core/context.py +191 -0
  8. partiful_api_mcp/core/errors.py +78 -0
  9. partiful_api_mcp/core/helpers.py +33 -0
  10. partiful_api_mcp/core/tools.py +106 -0
  11. partiful_api_mcp/mcp_context.py +126 -0
  12. partiful_api_mcp/mcp_server.py +67 -0
  13. partiful_api_mcp/partiful/__init__.py +0 -0
  14. partiful_api_mcp/partiful/auth.py +22 -0
  15. partiful_api_mcp/partiful/client.py +118 -0
  16. partiful_api_mcp/partiful/editing.py +123 -0
  17. partiful_api_mcp/partiful/events.py +158 -0
  18. partiful_api_mcp/partiful/firestore.py +135 -0
  19. partiful_api_mcp/partiful/guests.py +77 -0
  20. partiful_api_mcp/partiful/tools/__init__.py +0 -0
  21. partiful_api_mcp/partiful/tools/approve_guests.py +79 -0
  22. partiful_api_mcp/partiful/tools/cancel_event.py +69 -0
  23. partiful_api_mcp/partiful/tools/check_in_guests.py +64 -0
  24. partiful_api_mcp/partiful/tools/create_event.py +96 -0
  25. partiful_api_mcp/partiful/tools/delete_event.py +49 -0
  26. partiful_api_mcp/partiful/tools/duplicate_event.py +83 -0
  27. partiful_api_mcp/partiful/tools/export_guest_list.py +83 -0
  28. partiful_api_mcp/partiful/tools/get_event.py +60 -0
  29. partiful_api_mcp/partiful/tools/invite_guests.py +107 -0
  30. partiful_api_mcp/partiful/tools/list_events.py +81 -0
  31. partiful_api_mcp/partiful/tools/list_guests.py +81 -0
  32. partiful_api_mcp/partiful/tools/list_past_guests.py +115 -0
  33. partiful_api_mcp/partiful/tools/send_text_blast.py +100 -0
  34. partiful_api_mcp/partiful/tools/update_event.py +50 -0
  35. partiful_api_mcp/runner.py +117 -0
  36. partiful_api_mcp/scripting.py +73 -0
  37. partiful_api_mcp/session.py +275 -0
  38. partiful_api_mcp-1.0.2.dist-info/METADATA +422 -0
  39. partiful_api_mcp-1.0.2.dist-info/RECORD +42 -0
  40. partiful_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  41. partiful_api_mcp-1.0.2.dist-info/entry_points.txt +3 -0
  42. partiful_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
@@ -0,0 +1,118 @@
1
+ """Partiful's API, recorded on the dev account (21.10.2026).
2
+
3
+ Every call is a Firebase callable: POST `api.partiful.com/<function>` with `{"data": {"params": {...}}}`,
4
+ answered with `{"result": {"data": ...}}`, and the account's Firebase ID token as `Authorization: Bearer`.
5
+ The `_partiful_session` cookie does not authenticate it (401 without the token) and no page on partiful.com
6
+ renders the user on the server, so the cookie only tells the core that the browser has signed in.
7
+
8
+ The ID token lives an hour. The web app keeps the Firebase user, with its refresh token, in IndexedDB
9
+ (`firebaseLocalStorageDb`, key `AUTH_USER_KEY`); `id_token` reads that record from the profile's storage for
10
+ partiful.com and trades the refresh token at securetoken.googleapis.com. Google answers with the same
11
+ refresh token, so nothing has to be written back. Tokens stay in this process's memory, never in Redis.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from typing import TYPE_CHECKING, Any
19
+ from urllib.parse import urlencode
20
+
21
+ from partiful_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
22
+
23
+ if TYPE_CHECKING:
24
+ from partiful_api_mcp.core.context import ToolContext
25
+
26
+ WEB = "https://partiful.com"
27
+ API = "https://api.partiful.com"
28
+ # Public web key of Firebase project getpartiful; Google refuses it without a partiful.com referer.
29
+ FIREBASE_KEY = "AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k"
30
+ SECURETOKEN = f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_KEY}"
31
+ AUTH_USER_KEY = f"firebase:authUser:{FIREBASE_KEY}:[DEFAULT]"
32
+ _HEADERS = {"origin": WEB, "referer": f"{WEB}/"}
33
+ # A token this close to its end is refreshed first (seconds).
34
+ _MARGIN_S = 300
35
+ # (browser_id, uid) -> (ID token, expiry as epoch seconds)
36
+ _tokens: dict[tuple[str, str], tuple[str, float]] = {}
37
+
38
+
39
+ def firebase_user(ctx: ToolContext) -> dict[str, Any] | None:
40
+ """The signed-in Firebase user from the browser's storage for partiful.com; None when there is none."""
41
+ return auth_user(ctx.profile.local_storage)
42
+
43
+
44
+ def auth_user(local_storage: dict[str, dict[str, str]]) -> dict[str, Any] | None:
45
+ """The Firebase user in a jar's or a tab's `local_storage`; None without a uid and a refresh token."""
46
+ raw = (local_storage.get(WEB) or {}).get(AUTH_USER_KEY)
47
+ if not raw:
48
+ return None
49
+ try:
50
+ user = json.loads(raw) if isinstance(raw, str) else raw
51
+ except ValueError:
52
+ return None
53
+ if not isinstance(user, dict) or not user.get("uid") or not (user.get("stsTokenManager") or {}).get("refreshToken"):
54
+ return None
55
+ return user
56
+
57
+
58
+ async def id_token(ctx: ToolContext) -> tuple[str, str]:
59
+ """(uid, ID token) of the account. `AuthExpiredError` when the browser holds no usable Firebase session."""
60
+ user = firebase_user(ctx)
61
+ if user is None:
62
+ raise AuthExpiredError("The app signed this account out. Reconnect it in the console.")
63
+ uid, tokens = user["uid"], user["stsTokenManager"]
64
+ key = (ctx.profile.browser_id, uid)
65
+ now = time.time()
66
+ cached = _tokens.get(key)
67
+ if cached and cached[1] - now > _MARGIN_S:
68
+ return uid, cached[0]
69
+ stored, expires_ms = tokens.get("accessToken"), tokens.get("expirationTime") or 0
70
+ if stored and expires_ms / 1000 - now > _MARGIN_S:
71
+ _tokens[key] = (stored, expires_ms / 1000)
72
+ return uid, stored
73
+ response = await ctx.call(
74
+ "POST",
75
+ SECURETOKEN,
76
+ data=urlencode({"grant_type": "refresh_token", "refresh_token": tokens["refreshToken"]}),
77
+ headers={**_HEADERS, "content-type": "application/x-www-form-urlencoded"},
78
+ )
79
+ answer = response.json()
80
+ if response.status == 400:
81
+ # INVALID_REFRESH_TOKEN, TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND: the session is over.
82
+ raise AuthExpiredError("The app signed this account out. Reconnect it in the console.")
83
+ if response.status != 200 or not answer.get("id_token"):
84
+ raise ToolError("Partiful's sign-in service did not renew the session.", ErrorCode.TARGET_ERROR)
85
+ token = answer["id_token"]
86
+ _tokens[key] = (token, now + int(answer.get("expires_in") or 3600))
87
+ return uid, token
88
+
89
+
90
+ async def call(
91
+ ctx: ToolContext,
92
+ function: str,
93
+ params: dict[str, Any] | None = None,
94
+ paging: dict[str, Any] | None = None,
95
+ *,
96
+ whole: bool = False,
97
+ ) -> Any:
98
+ """One callable on api.partiful.com; returns `result.data` (`result` with `whole`, for its `paging`).
99
+
100
+ `paging` (`cursor`, `maxResults`) goes next to `params`, as the web app sends it. A 401 raises
101
+ `AuthExpiredError` in `ctx.call`.
102
+ """
103
+ _, token = await id_token(ctx)
104
+ data: dict[str, Any] = {"params": params or {}}
105
+ if paging:
106
+ data["paging"] = paging
107
+ response = await ctx.call(
108
+ "POST",
109
+ f"{API}/{function}",
110
+ json={"data": data},
111
+ headers={**_HEADERS, "authorization": f"Bearer {token}"},
112
+ )
113
+ answer = response.json()
114
+ if response.status != 200 or "result" not in answer:
115
+ message = (answer.get("error") or {}).get("message") if isinstance(answer, dict) else None
116
+ raise ToolError(f"Partiful refused {function}: {message or response.status}.", ErrorCode.TARGET_ERROR)
117
+ result = answer["result"] or {}
118
+ return result if whole else result.get("data")
@@ -0,0 +1,123 @@
1
+ """Event edits as the web app makes them (recorded 21.10.2026): a Firestore update of `events/{eventId}` with
2
+ `updatedBy`, and the location through the callable `setEventLocation`, which keeps it out of that update.
3
+
4
+ Guest Approval and a guest limit exclude each other: turning approval on clears `maxCapacity` and the
5
+ waitlist, and the first guest limit turns the waitlist on, as the settings panel does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
15
+ from partiful_api_mcp.partiful import firestore
16
+ from partiful_api_mcp.partiful.client import call, id_token
17
+ from partiful_api_mcp.partiful.events import moment, zone
18
+
19
+ if TYPE_CHECKING:
20
+ from partiful_api_mcp.core.context import ToolContext
21
+
22
+ # The inputs create_event and update_event share; create_event requires title and start_at.
23
+ FIELDS: dict[str, Any] = {
24
+ "title": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Event title."},
25
+ "start_at": {
26
+ "type": "string",
27
+ "description": "Start, ISO 8601. Without an offset it is read in the event's time zone.",
28
+ },
29
+ "end_at": {"type": "string", "description": "End, ISO 8601. Optional; must be after start_at."},
30
+ "timezone": {"type": "string", "description": "IANA zone the event is shown in, e.g. America/New_York."},
31
+ "location": {"type": "string", "maxLength": 300, "description": "Address, place name or link."},
32
+ "description": {"type": "string", "maxLength": 5000, "description": "Event details shown on the page."},
33
+ "rsvp_deadline": {"type": "string", "description": "Last moment guests can RSVP, ISO 8601."},
34
+ "max_guests": {
35
+ "type": ["integer", "null"],
36
+ "minimum": 1,
37
+ "description": "Guest limit; RSVPs over it go to the waitlist. null removes the limit.",
38
+ },
39
+ "guest_approval": {
40
+ "type": "boolean",
41
+ "description": "Guests ask to join and you approve them (Guest Approval). Clears max_guests.",
42
+ },
43
+ "questions": {
44
+ "type": "array",
45
+ "items": {"type": "string", "minLength": 1, "maxLength": 200},
46
+ "maxItems": 10,
47
+ "description": "Guest Questionnaire, short-answer questions asked at RSVP. An empty list turns it off.",
48
+ },
49
+ }
50
+
51
+
52
+ async def apply(ctx: ToolContext, event_id: str, old: dict[str, Any], args: dict[str, Any]) -> list[str]:
53
+ """Writes the given inputs to the event `old` (getEventInfo's event); returns the input names changed."""
54
+ uid, _ = await id_token(ctx)
55
+ tz_name = args.get("timezone") or old.get("timezone") or ctx.profile.timezone
56
+ tz = zone(tz_name)
57
+ changes: dict[str, Any] = {}
58
+ remove: list[str] = []
59
+ append: dict[str, list[Any]] = {}
60
+ if "timezone" in args:
61
+ changes["timezone"] = tz.key
62
+ for name, field in (("title", "title"), ("description", "description")):
63
+ if name in args:
64
+ changes[field] = args[name].strip() if name == "title" else args[name]
65
+ start = moment(args["start_at"], tz, "start_at") if args.get("start_at") else _stored(old.get("startDate"))
66
+ end = moment(args["end_at"], tz, "end_at") if args.get("end_at") else _stored(old.get("endDate"))
67
+ if args.get("start_at"):
68
+ changes["startDate"] = start
69
+ if args.get("end_at"):
70
+ changes["endDate"] = end
71
+ if start and end and end <= start and (args.get("start_at") or args.get("end_at")):
72
+ raise ToolError("end_at must be after start_at.", ErrorCode.INVALID_INPUT)
73
+ if args.get("rsvp_deadline"):
74
+ changes["rsvpDeadline"] = moment(args["rsvp_deadline"], tz, "rsvp_deadline")
75
+ approval = args.get("guest_approval")
76
+ if approval is True and args.get("max_guests") is not None:
77
+ raise ToolError("Guest Approval events have no guest limit: give one of the two.", ErrorCode.INVALID_INPUT)
78
+ if approval is not None:
79
+ changes["guestAction"] = "APPLY" if approval else "RSVP"
80
+ if approval:
81
+ remove.append("maxCapacity")
82
+ changes["enableWaitlist"] = False
83
+ if "max_guests" in args:
84
+ if old.get("guestAction") == "APPLY" and approval is not False and args["max_guests"] is not None:
85
+ raise ToolError("This event uses Guest Approval, which has no guest limit.", ErrorCode.INVALID_INPUT)
86
+ if args["max_guests"] is None:
87
+ remove.append("maxCapacity")
88
+ changes["enableWaitlist"] = False
89
+ else:
90
+ changes["maxCapacity"] = args["max_guests"]
91
+ if old.get("maxCapacity") is None:
92
+ changes["enableWaitlist"] = True
93
+ if "questions" in args:
94
+ if args["questions"]:
95
+ base = int(time.time() * 1000)
96
+ version = {
97
+ "questions": [
98
+ {"id": str(base + i), "text": text.strip(), "required": False, "type": "short_answer"}
99
+ for i, text in enumerate(args["questions"])
100
+ ],
101
+ "createdAt": datetime.now(UTC),
102
+ "createdBy": firestore.Ref(f"users/{uid}"),
103
+ }
104
+ append["questionnaireVersions"] = [version]
105
+ changes["questionnaireEnabled"] = True
106
+ else:
107
+ changes["questionnaireEnabled"] = False
108
+ if changes or remove or append:
109
+ changes["updatedBy"] = firestore.Ref(f"users/{uid}")
110
+ await firestore.update(ctx, f"events/{event_id}", changes, tuple(remove), append)
111
+ if "location" in args:
112
+ await call(
113
+ ctx,
114
+ "setEventLocation",
115
+ {"eventId": event_id, "locationInfo": {"type": "freeform", "value": args["location"].strip()}},
116
+ )
117
+ return [name for name in FIELDS if name in args]
118
+
119
+
120
+ def _stored(value: Any) -> datetime | None:
121
+ if not isinstance(value, str) or not value[:1].isdigit():
122
+ return None
123
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -0,0 +1,158 @@
1
+ """Events and guests as the tools return them (recorded 21.10.2026, MAP.md).
2
+
3
+ Partiful keeps start, end and RSVP deadline in UTC with the event's IANA zone next to them; a cloned event
4
+ has `startDate: "TBD"` until it gets a date. Guest statuses are Partiful's own, mapped to the catalog's
5
+ names below.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+ from typing import TYPE_CHECKING, Any
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
13
+
14
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
15
+ from partiful_api_mcp.partiful.client import WEB, call, id_token
16
+
17
+ if TYPE_CHECKING:
18
+ from partiful_api_mcp.core.context import ToolContext
19
+
20
+ # Catalog status -> Partiful's guest statuses (getEventInfo `guestStatusCounts`, guest `status`).
21
+ STATUSES: dict[str, tuple[str, ...]] = {
22
+ "going": ("GOING", "APPROVED"),
23
+ "maybe": ("MAYBE",),
24
+ "cant_go": ("DECLINED",),
25
+ "invited": ("READY_TO_SEND", "SENDING", "SENT", "SEND_ERROR", "DELIVERY_ERROR"),
26
+ "waitlist": ("WAITLIST", "WAITLISTED_FOR_APPROVAL"),
27
+ "pending": ("PENDING_APPROVAL",),
28
+ }
29
+ # The rest, shown in list_guests but not counted in `guests`.
30
+ _OTHER = {
31
+ "INTERESTED": "interested",
32
+ "WITHDRAWN": "withdrawn",
33
+ "REJECTED": "rejected",
34
+ "RESPONDED_TO_FIND_A_TIME": "responded_to_poll",
35
+ }
36
+ STATUS_NAMES = list(STATUSES)
37
+
38
+ EVENT_ID: dict[str, Any] = {"type": "string", "description": "event_id from list_events."}
39
+ GUEST_IDS: dict[str, Any] = {
40
+ "type": "array",
41
+ "items": {"type": "string"},
42
+ "minItems": 1,
43
+ "maxItems": 500,
44
+ "description": "guest_id values from list_guests.",
45
+ }
46
+
47
+
48
+ def status_name(status: str | None) -> str:
49
+ """Partiful's guest status as the catalog name."""
50
+ for name, values in STATUSES.items():
51
+ if status in values:
52
+ return name
53
+ return _OTHER.get(status or "", (status or "unknown").lower())
54
+
55
+
56
+ def counts(item: dict[str, Any]) -> dict[str, int]:
57
+ """Guests per catalog status from `guestStatusCounts`."""
58
+ raw = item.get("guestStatusCounts") or {}
59
+ return {name: sum(int(raw.get(value) or 0) for value in values) for name, values in STATUSES.items()}
60
+
61
+
62
+ def zone(name: str | None) -> ZoneInfo:
63
+ try:
64
+ return ZoneInfo(name or "UTC")
65
+ except (ZoneInfoNotFoundError, ValueError) as exc:
66
+ raise ToolError("timezone must be an IANA name such as America/New_York.", ErrorCode.INVALID_INPUT) from exc
67
+
68
+
69
+ def moment(value: str, tz: ZoneInfo, field: str) -> datetime:
70
+ """An input time as an aware datetime; without an offset it is read in the event's zone."""
71
+ try:
72
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
73
+ except ValueError as exc:
74
+ raise ToolError(f"{field} must be ISO 8601, e.g. 2026-11-14T19:00-05:00.", ErrorCode.INVALID_INPUT) from exc
75
+ return parsed.replace(tzinfo=tz) if parsed.tzinfo is None else parsed
76
+
77
+
78
+ def utc_iso(value: datetime) -> str:
79
+ """The form createEvent takes: UTC with milliseconds."""
80
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
81
+
82
+
83
+ def local(value: Any, tz_name: str | None) -> str | None:
84
+ """A stored UTC time in the event's zone with its offset; None for none or TBD."""
85
+ if not isinstance(value, str) or not value[:1].isdigit():
86
+ return None
87
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
88
+ try:
89
+ tz = ZoneInfo(tz_name or "UTC")
90
+ except (ZoneInfoNotFoundError, ValueError):
91
+ tz = ZoneInfo("UTC")
92
+ return parsed.astimezone(tz).isoformat(timespec="seconds")
93
+
94
+
95
+ def url(event_id: str) -> str:
96
+ return f"{WEB}/e/{event_id}"
97
+
98
+
99
+ def status(item: dict[str, Any]) -> str:
100
+ return {"PUBLISHED": "active", "CANCELED": "canceled", "UNSAVED": "draft"}.get(item.get("status") or "", "active")
101
+
102
+
103
+ def summary(item: dict[str, Any]) -> dict[str, Any]:
104
+ """One event in list_events."""
105
+ tz = item.get("timezone")
106
+ return {
107
+ "event_id": item.get("id"),
108
+ "title": item.get("title"),
109
+ "start_at": local(item.get("startDate"), tz),
110
+ "end_at": local(item.get("endDate"), tz),
111
+ "timezone": tz,
112
+ "location": item.get("location"),
113
+ "status": status(item),
114
+ "guests": counts(item),
115
+ "url": url(item.get("id") or ""),
116
+ }
117
+
118
+
119
+ def questions(item: dict[str, Any]) -> list[dict[str, Any]]:
120
+ """The current Guest Questionnaire: `questionnaire`, else the last of `questionnaireVersions`."""
121
+ current = item.get("questionnaire")
122
+ if not current and item.get("questionnaireVersions") and item.get("questionnaireEnabled", True):
123
+ current = item["questionnaireVersions"][-1]
124
+ return [q for q in (current or {}).get("questions") or [] if isinstance(q, dict)]
125
+
126
+
127
+ def detail(item: dict[str, Any]) -> dict[str, Any]:
128
+ """One event in get_event and the write tools' answers."""
129
+ tz = item.get("timezone")
130
+ return {
131
+ **summary(item),
132
+ "description": item.get("description"),
133
+ "rsvp_deadline": local(item.get("rsvpDeadline"), tz),
134
+ "max_guests": item.get("maxCapacity"),
135
+ "waitlist": bool(item.get("enableWaitlist")),
136
+ "guest_approval": item.get("guestAction") == "APPLY",
137
+ "questions": [q.get("text") for q in questions(item)],
138
+ "visibility": item.get("visibility"),
139
+ "short_url": item.get("publicShortUrl"),
140
+ }
141
+
142
+
143
+ async def event_info(ctx: ToolContext, event_id: str) -> dict[str, Any]:
144
+ """getEventInfo's event, with its id."""
145
+ data = await call(ctx, "getEventInfo", {"eventId": event_id})
146
+ item = (data or {}).get("event")
147
+ if not item:
148
+ raise ToolError("Partiful has no such event.", ErrorCode.INVALID_INPUT)
149
+ return {**item, "id": item.get("id") or event_id}
150
+
151
+
152
+ async def hosted(ctx: ToolContext, event_id: str) -> dict[str, Any]:
153
+ """The event, refused unless this account hosts it."""
154
+ uid, _ = await id_token(ctx)
155
+ item = await event_info(ctx, event_id)
156
+ if uid not in (item.get("ownerIds") or []):
157
+ raise ToolError("Only a host of this event can do that.", ErrorCode.APP_PERMISSION_DENIED)
158
+ return item
@@ -0,0 +1,135 @@
1
+ """Firestore REST on project getpartiful, with the same Firebase ID token as the callables (21.10.2026).
2
+
3
+ The web app reads the host's guest list and writes event edits straight to Firestore; no callable does
4
+ either. Security rules let the host read `events/{eventId}` with its `guests` and update the event.
5
+ Documents come in Firestore's typed form (`{"stringValue": ...}`): `decode` and `encode` convert.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import TYPE_CHECKING, Any
13
+ from urllib.parse import urlencode
14
+
15
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
16
+ from partiful_api_mcp.partiful.client import WEB, id_token
17
+
18
+ if TYPE_CHECKING:
19
+ from partiful_api_mcp.core.context import ToolContext
20
+
21
+ DATABASE = "projects/getpartiful/databases/(default)"
22
+ DOCUMENTS = f"https://firestore.googleapis.com/v1/{DATABASE}/documents"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Ref:
27
+ """A document reference to write, as the web app writes `updatedBy: users/{uid}`."""
28
+
29
+ path: str
30
+
31
+
32
+ def decode(value: dict[str, Any]) -> Any:
33
+ """One typed Firestore value as plain JSON; references become their `collection/id` path."""
34
+ if "mapValue" in value:
35
+ return fields(value["mapValue"])
36
+ if "arrayValue" in value:
37
+ return [decode(item) for item in value["arrayValue"].get("values") or []]
38
+ if "integerValue" in value:
39
+ return int(value["integerValue"])
40
+ if "referenceValue" in value:
41
+ return value["referenceValue"].split("/documents/", 1)[-1]
42
+ if "nullValue" in value:
43
+ return None
44
+ for kind in ("stringValue", "booleanValue", "doubleValue", "timestampValue"):
45
+ if kind in value:
46
+ return value[kind]
47
+ return None
48
+
49
+
50
+ def fields(document: dict[str, Any]) -> dict[str, Any]:
51
+ """A document's (or map's) `fields` as a plain dict."""
52
+ return {name: decode(value) for name, value in (document.get("fields") or {}).items()}
53
+
54
+
55
+ def encode(value: Any) -> dict[str, Any]:
56
+ """A plain value in Firestore's typed form. datetime goes as a timestamp."""
57
+ if value is None:
58
+ return {"nullValue": None}
59
+ if isinstance(value, Ref):
60
+ return {"referenceValue": f"{DATABASE}/documents/{value.path}"}
61
+ if isinstance(value, bool):
62
+ return {"booleanValue": value}
63
+ if isinstance(value, int):
64
+ return {"integerValue": str(value)}
65
+ if isinstance(value, float):
66
+ return {"doubleValue": value}
67
+ if isinstance(value, datetime):
68
+ return {"timestampValue": value.isoformat()}
69
+ if isinstance(value, dict):
70
+ return {"mapValue": {"fields": {key: encode(item) for key, item in value.items()}}}
71
+ if isinstance(value, list):
72
+ return {"arrayValue": {"values": [encode(item) for item in value]}}
73
+ return {"stringValue": str(value)}
74
+
75
+
76
+ async def _request(ctx: ToolContext, method: str, url: str, body: Any = None) -> Any:
77
+ _, token = await id_token(ctx)
78
+ response = await ctx.call(
79
+ method,
80
+ url,
81
+ json=body,
82
+ headers={"origin": WEB, "referer": f"{WEB}/", "authorization": f"Bearer {token}"},
83
+ )
84
+ answer = response.json()
85
+ if response.status == 404:
86
+ raise ToolError("Partiful has no such event.", ErrorCode.INVALID_INPUT)
87
+ if response.status == 403:
88
+ raise ToolError("Only a host of this event can do that.", ErrorCode.APP_PERMISSION_DENIED)
89
+ if response.status != 200:
90
+ raise ToolError("Partiful did not answer the request.", ErrorCode.TARGET_ERROR)
91
+ return answer
92
+
93
+
94
+ async def get(ctx: ToolContext, path: str) -> dict[str, Any]:
95
+ """One document's fields, with `id`."""
96
+ document = await _request(ctx, "GET", f"{DOCUMENTS}/{path}")
97
+ return {"id": document["name"].rsplit("/", 1)[-1], **fields(document)}
98
+
99
+
100
+ async def list_page(
101
+ ctx: ToolContext, path: str, size: int, token: str | None
102
+ ) -> tuple[list[dict[str, Any]], str | None]:
103
+ """One page of a collection: documents with `id`, and the next page token."""
104
+ query = {"pageSize": size, **({"pageToken": token} if token else {})}
105
+ answer = await _request(ctx, "GET", f"{DOCUMENTS}/{path}?{urlencode(query)}")
106
+ docs = [{"id": doc["name"].rsplit("/", 1)[-1], **fields(doc)} for doc in answer.get("documents") or []]
107
+ return docs, answer.get("nextPageToken") or None
108
+
109
+
110
+ async def update(
111
+ ctx: ToolContext,
112
+ path: str,
113
+ changes: dict[str, Any],
114
+ remove: tuple[str, ...] = (),
115
+ append: dict[str, list[Any]] | None = None,
116
+ ) -> None:
117
+ """Sets top-level fields of an existing document in one `commit`, as the web app's `Write` does.
118
+
119
+ A name in `remove` is in the mask but not in the body, which deletes the field. `append` adds array
120
+ items server-side (`arrayUnion`), so the stored items are never read back and rewritten.
121
+ """
122
+ write: dict[str, Any] = {
123
+ "update": {
124
+ "name": f"{DATABASE}/documents/{path}",
125
+ "fields": {name: encode(value) for name, value in changes.items()},
126
+ },
127
+ "updateMask": {"fieldPaths": [*changes, *remove]},
128
+ "currentDocument": {"exists": True},
129
+ }
130
+ if append:
131
+ write["updateTransforms"] = [
132
+ {"fieldPath": name, "appendMissingElements": {"values": [encode(item) for item in items]}}
133
+ for name, items in append.items()
134
+ ]
135
+ await _request(ctx, "POST", f"{DOCUMENTS}:commit", {"writes": [write]})
@@ -0,0 +1,77 @@
1
+ """The host's guest list: Firestore `events/{eventId}/guests`, one document per guest (recorded 21.10.2026).
2
+
3
+ No callable gives the host guest ids (`getGuests` refuses a host), so list_guests, approve_guests and
4
+ check_in_guests start here. Questionnaire answers sit in `questionnaireResponse.answers`, keyed by
5
+ question id of the version the guest answered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from partiful_api_mcp.partiful import firestore
13
+ from partiful_api_mcp.partiful.events import local, questions, status_name
14
+
15
+ if TYPE_CHECKING:
16
+ from partiful_api_mcp.core.context import ToolContext
17
+
18
+ # Firestore's page size for the guests collection; an event holds at most about 1,000 invites.
19
+ _PAGE = 300
20
+ _MAX_PAGES = 10
21
+
22
+
23
+ async def all_guests(ctx: ToolContext, event_id: str) -> list[dict[str, Any]]:
24
+ """Every guest document of the event, oldest RSVP first."""
25
+ out: list[dict[str, Any]] = []
26
+ token: str | None = None
27
+ for _ in range(_MAX_PAGES):
28
+ docs, token = await firestore.list_page(ctx, f"events/{event_id}/guests", _PAGE, token)
29
+ out.extend(docs)
30
+ if not token:
31
+ break
32
+ return sorted(out, key=lambda doc: str(doc.get("rsvpDate") or doc.get("createdAt") or ""))
33
+
34
+
35
+ def checked_in(doc: dict[str, Any]) -> bool:
36
+ """`checkIn` is `{checkedInBy, date}` while the guest is checked in."""
37
+ value = doc.get("checkIn")
38
+ return bool(value.get("date") or value.get("checkedInBy")) if isinstance(value, dict) else value is True
39
+
40
+
41
+ def _answer(value: Any) -> str:
42
+ if isinstance(value, dict):
43
+ value = value.get("value", value.get("text", ""))
44
+ if isinstance(value, list):
45
+ return ", ".join(str(item) for item in value)
46
+ return "" if value is None else str(value)
47
+
48
+
49
+ def guest(doc: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
50
+ """One guest as list_guests returns it."""
51
+ response = doc.get("questionnaireResponse") or {}
52
+ versions = event.get("questionnaireVersions") or []
53
+ version = response.get("questionnaireVersion")
54
+ asked = (
55
+ versions[version].get("questions") or []
56
+ if isinstance(version, int) and 0 <= version < len(versions)
57
+ else questions(event)
58
+ )
59
+ texts = {q.get("id"): q.get("text") for q in asked if isinstance(q, dict)}
60
+ answers = response.get("answers") or {}
61
+ user = doc.get("user")
62
+ return {
63
+ "guest_id": doc["id"],
64
+ "name": doc.get("name"),
65
+ "status": status_name(doc.get("status")),
66
+ "count": int(doc.get("count") or 1),
67
+ "plus_ones": [p.get("name") for p in doc.get("plusOnes") or [] if isinstance(p, dict) and p.get("name")],
68
+ "checked_in": checked_in(doc),
69
+ "rsvp_at": local(doc.get("rsvpDate"), event.get("timezone")),
70
+ "message": doc.get("rsvpMessage") or None,
71
+ "user_id": user.rsplit("/", 1)[-1] if isinstance(user, str) else None,
72
+ "answers": [
73
+ {"question": texts.get(key, key), "answer": _answer(value)}
74
+ for key, value in answers.items()
75
+ if _answer(value)
76
+ ],
77
+ }
File without changes