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,79 @@
1
+ """Approve guests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
8
+ from partiful_api_mcp.core.tools import tool
9
+ from partiful_api_mcp.partiful.client import call
10
+ from partiful_api_mcp.partiful.events import EVENT_ID, GUEST_IDS, hosted
11
+ from partiful_api_mcp.partiful.guests import all_guests
12
+
13
+ if TYPE_CHECKING:
14
+ from partiful_api_mcp.core.context import ToolContext
15
+
16
+ INPUT_SCHEMA: dict[str, Any] = {
17
+ "type": "object",
18
+ "properties": {
19
+ "event_id": EVENT_ID,
20
+ "guest_ids": {**GUEST_IDS, "description": "Pending guest_id values from list_guests (status pending)."},
21
+ "all": {"type": "boolean", "description": "Approve everyone who is pending instead. Default: false."},
22
+ },
23
+ "required": ["event_id"],
24
+ "additionalProperties": False,
25
+ }
26
+
27
+ EXAMPLE_RESPONSE: dict[str, Any] = {"event_id": "pL8wN2aDk4Fh7JsMq9Xe", "approved": 2, "still_pending": 1}
28
+
29
+
30
+ async def _pending(ctx: ToolContext, args: dict[str, Any]) -> list[str]:
31
+ """The guest ids to approve; anything not pending on a Guest Approval event is refused."""
32
+ event = await hosted(ctx, args["event_id"])
33
+ if event.get("guestAction") != "APPLY":
34
+ raise ToolError("This event does not use Guest Approval.", ErrorCode.INVALID_INPUT)
35
+ pending = {doc["id"] for doc in await all_guests(ctx, args["event_id"]) if doc.get("status") == "PENDING_APPROVAL"}
36
+ if args.get("all"):
37
+ if not pending:
38
+ raise ToolError("Nobody is pending on this event.", ErrorCode.INVALID_INPUT)
39
+ return sorted(pending)
40
+ ids = list(dict.fromkeys(args.get("guest_ids") or []))
41
+ if not ids:
42
+ raise ToolError("Give guest_ids or all true.", ErrorCode.INVALID_INPUT)
43
+ if wrong := [guest_id for guest_id in ids if guest_id not in pending]:
44
+ raise ToolError(f"Not pending on this event: {', '.join(wrong)}.", ErrorCode.INVALID_INPUT)
45
+ return ids
46
+
47
+
48
+ async def check_approve(ctx: ToolContext, args: dict[str, Any]) -> None:
49
+ """Before approval: ids that are not pending fail now."""
50
+ await _pending(ctx, args)
51
+
52
+
53
+ @tool(
54
+ name="approve_guests",
55
+ title="Approve guests",
56
+ access="write",
57
+ confirm=True,
58
+ description="Approve pending guests on a Guest Approval event.",
59
+ details=(
60
+ "Moves the given pending guests, or all of them, to Approved, as Approve on the host's guest list "
61
+ "does. Partiful tells each approved guest by text or email. Ids that are not pending are refused "
62
+ "before the call waits for your approval. Returns how many were approved and how many still wait. "
63
+ "Hosts only. By default you confirm each call; you can switch that off for this tool."
64
+ ),
65
+ input_schema=INPUT_SCHEMA,
66
+ route="POST /events/{event_id}/guests/approve",
67
+ example_response=EXAMPLE_RESPONSE,
68
+ check=check_approve,
69
+ )
70
+ async def approve_guests(ctx: ToolContext, args: dict[str, Any]) -> Any:
71
+ ids = await _pending(ctx, args)
72
+ if args.get("all"):
73
+ await call(ctx, "approveAllGuests", {"eventId": args["event_id"], "fromStatus": "PENDING_APPROVAL"})
74
+ else:
75
+ await call(ctx, "updateGuestStatuses", {"eventId": args["event_id"], "guestIds": ids, "toStatus": "APPROVED"})
76
+ # Not getEventInfo's counts: they are cached and still showed the approved guest as pending (21.10.2026).
77
+ after = await all_guests(ctx, args["event_id"])
78
+ left = sum(1 for doc in after if doc.get("status") == "PENDING_APPROVAL")
79
+ return {"event_id": args["event_id"], "approved": len(ids), "still_pending": left}
@@ -0,0 +1,69 @@
1
+ """Cancel event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
8
+ from partiful_api_mcp.core.tools import tool
9
+ from partiful_api_mcp.partiful.client import call
10
+ from partiful_api_mcp.partiful.events import EVENT_ID, event_info, hosted, status
11
+
12
+ if TYPE_CHECKING:
13
+ from partiful_api_mcp.core.context import ToolContext
14
+
15
+ INPUT_SCHEMA: dict[str, Any] = {
16
+ "type": "object",
17
+ "properties": {
18
+ "event_id": EVENT_ID,
19
+ "message": {"type": "string", "maxLength": 480, "description": "Note to guests sent with the cancellation."},
20
+ "notify_guests": {
21
+ "type": "boolean",
22
+ "description": "Let Partiful tell the guests that the event is canceled. Default: true.",
23
+ },
24
+ },
25
+ "required": ["event_id"],
26
+ "additionalProperties": False,
27
+ }
28
+
29
+ EXAMPLE_RESPONSE: dict[str, Any] = {"event_id": "hK3vQ9xLm2Pa7RtYc4Wd", "status": "canceled", "guests_notified": True}
30
+
31
+
32
+ async def check_cancel(ctx: ToolContext, args: dict[str, Any]) -> None:
33
+ """Before approval: someone else's or an already canceled event fails now."""
34
+ event = await hosted(ctx, args["event_id"])
35
+ if event.get("status") == "CANCELED":
36
+ raise ToolError("This event is already canceled.", ErrorCode.INVALID_INPUT)
37
+
38
+
39
+ @tool(
40
+ name="cancel_event",
41
+ title="Cancel event",
42
+ access="write",
43
+ confirm=True,
44
+ description="Cancel an event. Partiful notifies the guests.",
45
+ details=(
46
+ "Cancels the event as Cancel event on its page does: the page stays up marked canceled. By default "
47
+ "Partiful texts the guests on the list with your message, so this reaches real people; notify_guests "
48
+ "false cancels without telling anyone. An event that is already canceled is refused. It cannot be "
49
+ "undone here. Hosts only. By default you confirm each call; you can switch that off for this tool."
50
+ ),
51
+ input_schema=INPUT_SCHEMA,
52
+ route="POST /events/{event_id}/cancel",
53
+ example_response=EXAMPLE_RESPONSE,
54
+ check=check_cancel,
55
+ )
56
+ async def cancel_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
57
+ await check_cancel(ctx, args)
58
+ notify = args.get("notify_guests", True)
59
+ await call(
60
+ ctx,
61
+ "cancelEvent",
62
+ {
63
+ "eventId": args["event_id"],
64
+ "cancellationMessage": (args.get("message") or "").strip(),
65
+ "shouldSkipNotifyGuests": not notify,
66
+ },
67
+ )
68
+ after = await event_info(ctx, args["event_id"])
69
+ return {"event_id": args["event_id"], "status": status(after), "guests_notified": bool(notify)}
@@ -0,0 +1,64 @@
1
+ """Check in guests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
8
+ from partiful_api_mcp.core.tools import tool
9
+ from partiful_api_mcp.partiful.client import call
10
+ from partiful_api_mcp.partiful.events import EVENT_ID, GUEST_IDS, hosted
11
+ from partiful_api_mcp.partiful.guests import all_guests, checked_in
12
+
13
+ if TYPE_CHECKING:
14
+ from partiful_api_mcp.core.context import ToolContext
15
+
16
+ INPUT_SCHEMA: dict[str, Any] = {
17
+ "type": "object",
18
+ "properties": {
19
+ "event_id": EVENT_ID,
20
+ "guest_ids": GUEST_IDS,
21
+ "undo": {"type": "boolean", "description": "Remove the check-in instead. Default: false."},
22
+ },
23
+ "required": ["event_id", "guest_ids"],
24
+ "additionalProperties": False,
25
+ }
26
+
27
+ EXAMPLE_RESPONSE: dict[str, Any] = {
28
+ "event_id": "hK3vQ9xLm2Pa7RtYc4Wd",
29
+ "undo": False,
30
+ "changed": 2,
31
+ "checked_in_total": 14,
32
+ }
33
+
34
+
35
+ @tool(
36
+ name="check_in_guests",
37
+ title="Check in guests",
38
+ access="write",
39
+ confirm=False,
40
+ description="Mark guests as checked in at the door, or undo it.",
41
+ details=(
42
+ "Checks the given guests in, as the host's check-in list does, or takes the check-in back with undo. "
43
+ "Guests get no message. Ids that are not on the guest list are refused. Returns how many guests "
44
+ "changed and how many are checked in now. Hosts only."
45
+ ),
46
+ input_schema=INPUT_SCHEMA,
47
+ route="POST /events/{event_id}/guests/check-in",
48
+ example_response=EXAMPLE_RESPONSE,
49
+ )
50
+ async def check_in_guests(ctx: ToolContext, args: dict[str, Any]) -> Any:
51
+ event_id, undo = args["event_id"], bool(args.get("undo"))
52
+ await hosted(ctx, event_id)
53
+ ids = list(dict.fromkeys(args["guest_ids"]))
54
+ before = {doc["id"]: checked_in(doc) for doc in await all_guests(ctx, event_id)}
55
+ if missing := [guest_id for guest_id in ids if guest_id not in before]:
56
+ raise ToolError(f"Not on this event's guest list: {', '.join(missing)}.", ErrorCode.INVALID_INPUT)
57
+ await call(ctx, "updateCheckInStatus", {"eventId": event_id, "guestIds": ids, "checkIn": not undo})
58
+ after = {doc["id"]: checked_in(doc) for doc in await all_guests(ctx, event_id)}
59
+ return {
60
+ "event_id": event_id,
61
+ "undo": undo,
62
+ "changed": sum(1 for guest_id in ids if before[guest_id] != after.get(guest_id)),
63
+ "checked_in_total": sum(after.values()),
64
+ }
@@ -0,0 +1,96 @@
1
+ """Create event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
8
+ from partiful_api_mcp.core.tools import tool
9
+ from partiful_api_mcp.partiful.client import call
10
+ from partiful_api_mcp.partiful.editing import FIELDS, apply
11
+ from partiful_api_mcp.partiful.events import detail, event_info, moment, utc_iso, zone
12
+ from partiful_api_mcp.partiful.tools.get_event import EXAMPLE_RESPONSE as EVENT
13
+
14
+ if TYPE_CHECKING:
15
+ from partiful_api_mcp.core.context import ToolContext
16
+
17
+ # What the create form saves besides the inputs (web app defaults, 21.10.2026).
18
+ _DEFAULTS: dict[str, Any] = {
19
+ "showHostList": True,
20
+ "showGuestCount": True,
21
+ "showGuestList": True,
22
+ "showActivityTimestamps": True,
23
+ "displayInviteButton": True,
24
+ "allowGuestPhotoUpload": True,
25
+ "enableGuestReminders": True,
26
+ "rsvpsEnabled": True,
27
+ "allowGuestsToInviteMutuals": True,
28
+ }
29
+ # Set by createEvent itself; the rest of the inputs go through the same edit as update_event.
30
+ _ON_CREATE = ("title", "start_at", "end_at", "timezone", "description")
31
+
32
+ INPUT_SCHEMA: dict[str, Any] = {
33
+ "type": "object",
34
+ "properties": {
35
+ **FIELDS,
36
+ "timezone": {
37
+ "type": "string",
38
+ "description": "IANA zone the event is shown in, e.g. America/New_York. Default: the browser's zone.",
39
+ },
40
+ "visibility": {
41
+ "type": "string",
42
+ "enum": ["private", "public"],
43
+ "description": "private: only people with the link (default). public: anyone on or off Partiful.",
44
+ },
45
+ },
46
+ "required": ["title", "start_at"],
47
+ "additionalProperties": False,
48
+ }
49
+
50
+ EXAMPLE_RESPONSE: dict[str, Any] = {
51
+ **EVENT,
52
+ "guests": {"going": 0, "maybe": 0, "cant_go": 0, "invited": 0, "waitlist": 0, "pending": 0},
53
+ }
54
+
55
+
56
+ @tool(
57
+ name="create_event",
58
+ title="Create event",
59
+ access="write",
60
+ confirm=False,
61
+ description="New event page. Nobody is invited until you call invite_guests.",
62
+ details=(
63
+ "Creates and publishes an event page hosted by this account, as Create on partiful.com does, with "
64
+ "Partiful's default theme. It is private (only people with the link) unless visibility is public. "
65
+ "Nobody is invited or texted; share url or call invite_guests. Times without an offset are read in "
66
+ "timezone. Guest Approval and a guest limit exclude each other, so a call with both is refused. "
67
+ "Returns the new event."
68
+ ),
69
+ input_schema=INPUT_SCHEMA,
70
+ route="POST /events",
71
+ example_response=EXAMPLE_RESPONSE,
72
+ )
73
+ async def create_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
74
+ tz = zone(args.get("timezone") or ctx.profile.timezone)
75
+ start = moment(args["start_at"], tz, "start_at")
76
+ event: dict[str, Any] = {
77
+ **_DEFAULTS,
78
+ "title": args["title"].strip(),
79
+ "startDate": utc_iso(start),
80
+ "timezone": tz.key,
81
+ "description": args.get("description") or "",
82
+ "status": "UNSAVED",
83
+ "visibility": args.get("visibility") or "private",
84
+ }
85
+ if args.get("end_at"):
86
+ end = moment(args["end_at"], tz, "end_at")
87
+ if end <= start:
88
+ raise ToolError("end_at must be after start_at.", ErrorCode.INVALID_INPUT)
89
+ event["endDate"] = utc_iso(end)
90
+ event_id = await call(ctx, "createEvent", {"event": event, "cohostIds": []})
91
+ if not isinstance(event_id, str) or not event_id:
92
+ raise ToolError("Partiful did not create the event.", ErrorCode.TARGET_ERROR)
93
+ rest = {key: value for key, value in args.items() if key in FIELDS and key not in _ON_CREATE}
94
+ if rest:
95
+ await apply(ctx, event_id, await event_info(ctx, event_id), rest)
96
+ return detail(await event_info(ctx, event_id))
@@ -0,0 +1,49 @@
1
+ """Delete event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.tools import tool
8
+ from partiful_api_mcp.partiful.client import call
9
+ from partiful_api_mcp.partiful.events import EVENT_ID, hosted
10
+
11
+ if TYPE_CHECKING:
12
+ from partiful_api_mcp.core.context import ToolContext
13
+
14
+ INPUT_SCHEMA: dict[str, Any] = {
15
+ "type": "object",
16
+ "properties": {"event_id": EVENT_ID},
17
+ "required": ["event_id"],
18
+ "additionalProperties": False,
19
+ }
20
+
21
+ EXAMPLE_RESPONSE: dict[str, Any] = {"event_id": "hK3vQ9xLm2Pa7RtYc4Wd", "deleted": True}
22
+
23
+
24
+ async def check_delete(ctx: ToolContext, args: dict[str, Any]) -> None:
25
+ """Before approval: someone else's event fails now."""
26
+ await hosted(ctx, args["event_id"])
27
+
28
+
29
+ @tool(
30
+ name="delete_event",
31
+ title="Delete event",
32
+ access="write",
33
+ confirm=True,
34
+ description="Delete an event you host for everyone, with its guest list.",
35
+ details=(
36
+ "Deletes the event as Delete event in its settings does: the page, guest list, photos and messages "
37
+ "are gone for everyone and cannot be restored. Guests who RSVPd may be told by Partiful. To keep the "
38
+ "page and tell guests, use cancel_event. Hosts only. By default you confirm each call; you can "
39
+ "switch that off for this tool."
40
+ ),
41
+ input_schema=INPUT_SCHEMA,
42
+ route="DELETE /events/{event_id}",
43
+ example_response=EXAMPLE_RESPONSE,
44
+ check=check_delete,
45
+ )
46
+ async def delete_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
47
+ await hosted(ctx, args["event_id"])
48
+ await call(ctx, "deleteEvent", {"eventId": args["event_id"]})
49
+ return {"event_id": args["event_id"], "deleted": True}
@@ -0,0 +1,83 @@
1
+ """Duplicate event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
9
+ from partiful_api_mcp.core.tools import tool
10
+ from partiful_api_mcp.partiful import firestore
11
+ from partiful_api_mcp.partiful.client import call, id_token
12
+ from partiful_api_mcp.partiful.events import EVENT_ID, detail, event_info, hosted, moment, zone
13
+ from partiful_api_mcp.partiful.tools.get_event import EXAMPLE_RESPONSE as EVENT
14
+
15
+ if TYPE_CHECKING:
16
+ from partiful_api_mcp.core.context import ToolContext
17
+
18
+ INPUT_SCHEMA: dict[str, Any] = {
19
+ "type": "object",
20
+ "properties": {
21
+ "event_id": EVENT_ID,
22
+ "start_at": {
23
+ "type": "string",
24
+ "description": "Start of the copy, ISO 8601. Without an offset it is read in the event's time zone.",
25
+ },
26
+ "title": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "maxLength": 200,
30
+ "description": "Title of the copy. Default: the same.",
31
+ },
32
+ },
33
+ "required": ["event_id", "start_at"],
34
+ "additionalProperties": False,
35
+ }
36
+
37
+ EXAMPLE_RESPONSE: dict[str, Any] = {
38
+ "copied_from": "aQ4tX9cEn6Hb2LsVw8Rk",
39
+ **EVENT,
40
+ "event_id": "bK7dR3nMp5Gc9TzXy1Qf",
41
+ "start_at": "2026-12-12T19:00:00-05:00",
42
+ "end_at": "2026-12-12T23:00:00-05:00",
43
+ "guests": {"going": 0, "maybe": 0, "cant_go": 0, "invited": 0, "waitlist": 0, "pending": 0},
44
+ }
45
+
46
+
47
+ @tool(
48
+ name="duplicate_event",
49
+ title="Duplicate event",
50
+ access="write",
51
+ confirm=False,
52
+ description="Copy an event with its settings to a new date. The copy has no guests yet.",
53
+ details=(
54
+ "Makes a copy as Duplicate on the event page does: title, description, location, theme, questionnaire "
55
+ "and RSVP settings, no guests and no messages. The copy starts at start_at and keeps the original's "
56
+ "length; Partiful does not copy the RSVP deadline. Nobody is invited. Returns the new event. Hosts only."
57
+ ),
58
+ input_schema=INPUT_SCHEMA,
59
+ route="POST /events/{event_id}/duplicate",
60
+ example_response=EXAMPLE_RESPONSE,
61
+ )
62
+ async def duplicate_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
63
+ uid, _ = await id_token(ctx)
64
+ source = await hosted(ctx, args["event_id"])
65
+ tz = zone(source.get("timezone") or ctx.profile.timezone)
66
+ start = moment(args["start_at"], tz, "start_at")
67
+ copy_id = await call(ctx, "cloneEvent", {"clonedEventId": args["event_id"], "timezone": tz.key})
68
+ if not isinstance(copy_id, str) or not copy_id:
69
+ raise ToolError("Partiful did not copy the event.", ErrorCode.TARGET_ERROR)
70
+ changes: dict[str, Any] = {"startDate": start, "updatedBy": firestore.Ref(f"users/{uid}")}
71
+ old_start, old_end = _time(source.get("startDate")), _time(source.get("endDate"))
72
+ if old_start and old_end and old_end > old_start:
73
+ changes["endDate"] = start + (old_end - old_start)
74
+ if args.get("title"):
75
+ changes["title"] = args["title"].strip()
76
+ await firestore.update(ctx, f"events/{copy_id}", changes, () if "endDate" in changes else ("endDate",))
77
+ return {"copied_from": args["event_id"], **detail(await event_info(ctx, copy_id))}
78
+
79
+
80
+ def _time(value: Any) -> datetime | None:
81
+ if not isinstance(value, str) or not value[:1].isdigit():
82
+ return None
83
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -0,0 +1,83 @@
1
+ """Export guest list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from partiful_api_mcp.core.errors import ErrorCode, ToolError
10
+ from partiful_api_mcp.core.tools import tool
11
+ from partiful_api_mcp.partiful.client import API, WEB, id_token
12
+ from partiful_api_mcp.partiful.events import EVENT_ID, STATUS_NAMES, STATUSES, hosted
13
+
14
+ if TYPE_CHECKING:
15
+ from partiful_api_mcp.core.context import ToolContext
16
+
17
+ INPUT_SCHEMA: dict[str, Any] = {
18
+ "type": "object",
19
+ "properties": {
20
+ "event_id": EVENT_ID,
21
+ "statuses": {
22
+ "type": "array",
23
+ "items": {"type": "string", "enum": STATUS_NAMES},
24
+ "description": "RSVP statuses to include: going, maybe, cant_go, invited, waitlist, pending. Default: all.",
25
+ },
26
+ },
27
+ "required": ["event_id"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+ EXAMPLE_RESPONSE: dict[str, Any] = {
32
+ "event_id": "hK3vQ9xLm2Pa7RtYc4Wd",
33
+ "statuses": ["going"],
34
+ "rows": 1,
35
+ "filename": "rooftop-dinner-guests.csv",
36
+ "csv": (
37
+ "Name,Status,Checked in,Check-in Time,RSVP date,Dietary restrictions\r\n"
38
+ "Maya Chen,Going,No,,2026-11-02 18:21:18-05:00,Vegetarian\r\n"
39
+ ),
40
+ }
41
+
42
+
43
+ @tool(
44
+ name="export_guest_list",
45
+ title="Export guest list",
46
+ access="read",
47
+ confirm=False,
48
+ description="The guest list as CSV, the same file as the Export CSV button.",
49
+ details=(
50
+ "Returns the file the host's Export CSV button downloads, for the chosen RSVP statuses. Columns are "
51
+ "Partiful's: Name, Status, Checked in, Check-in Time, RSVP date, then one column per Guest Questionnaire "
52
+ "question. Status is Partiful's label, for example Approved on a Guest Approval event. The file text "
53
+ "comes back in csv, not as a link, with rows (guests in the file) and a suggested filename. The file "
54
+ "has no guest ids, emails or phone numbers; for guest ids use list_guests. Hosts only."
55
+ ),
56
+ input_schema=INPUT_SCHEMA,
57
+ route="GET /events/{event_id}/guests/export",
58
+ example_response=EXAMPLE_RESPONSE,
59
+ )
60
+ async def export_guest_list(ctx: ToolContext, args: dict[str, Any]) -> Any:
61
+ event = await hosted(ctx, args["event_id"])
62
+ names = list(dict.fromkeys(args.get("statuses") or STATUS_NAMES))
63
+ query: list[tuple[str, str]] = [("eventId", args["event_id"])]
64
+ query += [("statuses", value) for name in names for value in STATUSES[name]]
65
+ query.append(("questionnaire", "true"))
66
+ uid, token = await id_token(ctx)
67
+ response = await ctx.call(
68
+ "GET",
69
+ f"{API}/getGuestsCsv?" + "&".join(f"{key}={value}" for key, value in query),
70
+ headers={"origin": WEB, "referer": f"{WEB}/", "authorization": f"Bearer {token}", "x-user-id": uid},
71
+ )
72
+ if response.status != 200:
73
+ raise ToolError("Partiful did not export the guest list.", ErrorCode.TARGET_ERROR)
74
+ text = response.text
75
+ rows = max(0, len(list(csv.reader(io.StringIO(text)))) - 1) if text.strip() else 0
76
+ slug = "-".join("".join(c if c.isalnum() else " " for c in (event.get("title") or "event").lower()).split())
77
+ return {
78
+ "event_id": args["event_id"],
79
+ "statuses": names,
80
+ "rows": rows,
81
+ "filename": f"{slug or 'event'}-guests.csv",
82
+ "csv": text,
83
+ }
@@ -0,0 +1,60 @@
1
+ """Get event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from partiful_api_mcp.core.tools import tool
8
+ from partiful_api_mcp.partiful.events import EVENT_ID, detail, event_info
9
+
10
+ if TYPE_CHECKING:
11
+ from partiful_api_mcp.core.context import ToolContext
12
+
13
+ INPUT_SCHEMA: dict[str, Any] = {
14
+ "type": "object",
15
+ "properties": {"event_id": EVENT_ID},
16
+ "required": ["event_id"],
17
+ "additionalProperties": False,
18
+ }
19
+
20
+ EXAMPLE_RESPONSE: dict[str, Any] = {
21
+ "event_id": "hK3vQ9xLm2Pa7RtYc4Wd",
22
+ "title": "Rooftop dinner",
23
+ "start_at": "2026-11-14T19:00:00-05:00",
24
+ "end_at": "2026-11-14T23:00:00-05:00",
25
+ "timezone": "America/New_York",
26
+ "location": "21 Mercer St, New York",
27
+ "status": "active",
28
+ "guests": {"going": 28, "maybe": 9, "cant_go": 4, "invited": 31, "waitlist": 0, "pending": 0},
29
+ "url": "https://partiful.com/e/hK3vQ9xLm2Pa7RtYc4Wd",
30
+ "description": "Bring a drink to share.",
31
+ "rsvp_deadline": "2026-11-12T18:00:00-05:00",
32
+ "max_guests": 60,
33
+ "waitlist": True,
34
+ "guest_approval": False,
35
+ "questions": ["Dietary restrictions"],
36
+ "visibility": "private",
37
+ "short_url": "https://go.partiful.com/aB3dE5fG",
38
+ }
39
+
40
+
41
+ @tool(
42
+ name="get_event",
43
+ title="Get event",
44
+ access="read",
45
+ confirm=False,
46
+ description=(
47
+ "Full event: title, description, start and end, location, RSVP deadline, guest limit, Guest Approval "
48
+ "and waitlist settings, link."
49
+ ),
50
+ details=(
51
+ "Returns one event as its page shows it to the host, with guests per RSVP status and the Guest "
52
+ "Questionnaire questions. Times are in the event's time zone. Works for any event the account can "
53
+ "open; the write tools need an event it hosts."
54
+ ),
55
+ input_schema=INPUT_SCHEMA,
56
+ route="GET /events/{event_id}",
57
+ example_response=EXAMPLE_RESPONSE,
58
+ )
59
+ async def get_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
60
+ return detail(await event_info(ctx, args["event_id"]))