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.
- partiful_api_mcp/__init__.py +28 -0
- partiful_api_mcp/api.py +213 -0
- partiful_api_mcp/app.py +37 -0
- partiful_api_mcp/cli.py +192 -0
- partiful_api_mcp/core/__init__.py +0 -0
- partiful_api_mcp/core/account_guard.py +35 -0
- partiful_api_mcp/core/context.py +191 -0
- partiful_api_mcp/core/errors.py +78 -0
- partiful_api_mcp/core/helpers.py +33 -0
- partiful_api_mcp/core/tools.py +106 -0
- partiful_api_mcp/mcp_context.py +126 -0
- partiful_api_mcp/mcp_server.py +67 -0
- partiful_api_mcp/partiful/__init__.py +0 -0
- partiful_api_mcp/partiful/auth.py +22 -0
- partiful_api_mcp/partiful/client.py +118 -0
- partiful_api_mcp/partiful/editing.py +123 -0
- partiful_api_mcp/partiful/events.py +158 -0
- partiful_api_mcp/partiful/firestore.py +135 -0
- partiful_api_mcp/partiful/guests.py +77 -0
- partiful_api_mcp/partiful/tools/__init__.py +0 -0
- partiful_api_mcp/partiful/tools/approve_guests.py +79 -0
- partiful_api_mcp/partiful/tools/cancel_event.py +69 -0
- partiful_api_mcp/partiful/tools/check_in_guests.py +64 -0
- partiful_api_mcp/partiful/tools/create_event.py +96 -0
- partiful_api_mcp/partiful/tools/delete_event.py +49 -0
- partiful_api_mcp/partiful/tools/duplicate_event.py +83 -0
- partiful_api_mcp/partiful/tools/export_guest_list.py +83 -0
- partiful_api_mcp/partiful/tools/get_event.py +60 -0
- partiful_api_mcp/partiful/tools/invite_guests.py +107 -0
- partiful_api_mcp/partiful/tools/list_events.py +81 -0
- partiful_api_mcp/partiful/tools/list_guests.py +81 -0
- partiful_api_mcp/partiful/tools/list_past_guests.py +115 -0
- partiful_api_mcp/partiful/tools/send_text_blast.py +100 -0
- partiful_api_mcp/partiful/tools/update_event.py +50 -0
- partiful_api_mcp/runner.py +117 -0
- partiful_api_mcp/scripting.py +73 -0
- partiful_api_mcp/session.py +275 -0
- partiful_api_mcp-1.0.2.dist-info/METADATA +422 -0
- partiful_api_mcp-1.0.2.dist-info/RECORD +42 -0
- partiful_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- partiful_api_mcp-1.0.2.dist-info/entry_points.txt +3 -0
- partiful_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Invite 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, counts, event_info, hosted
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
14
|
+
|
|
15
|
+
# Partiful's limits per event (help.partiful.com, 21.10.2026).
|
|
16
|
+
_MAX_INVITES = 1000
|
|
17
|
+
_MAX_EMAILS = 500
|
|
18
|
+
|
|
19
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"event_id": EVENT_ID,
|
|
23
|
+
"user_ids": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {"type": "string"},
|
|
26
|
+
"maxItems": _MAX_INVITES,
|
|
27
|
+
"description": "user_id values from list_past_guests.",
|
|
28
|
+
},
|
|
29
|
+
"emails": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"maxItems": _MAX_EMAILS,
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"email": {"type": "string", "format": "email"},
|
|
36
|
+
"name": {"type": "string", "minLength": 1, "description": "Shown on the invite."},
|
|
37
|
+
},
|
|
38
|
+
"required": ["email", "name"],
|
|
39
|
+
"additionalProperties": False,
|
|
40
|
+
},
|
|
41
|
+
"description": "People to invite by email, with the name the invite greets.",
|
|
42
|
+
},
|
|
43
|
+
"message": {"type": "string", "maxLength": 480, "description": "Personal note sent with the invites."},
|
|
44
|
+
},
|
|
45
|
+
"required": ["event_id"],
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
50
|
+
"event_id": "bK7dR3nMp5Gc9TzXy1Qf",
|
|
51
|
+
"invited": 2,
|
|
52
|
+
"by_email": 1,
|
|
53
|
+
"invited_total": 31,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def check_invite(ctx: ToolContext, args: dict[str, Any]) -> None:
|
|
58
|
+
"""Before approval: nobody to invite or someone else's event fails now."""
|
|
59
|
+
if not args.get("user_ids") and not args.get("emails"):
|
|
60
|
+
raise ToolError("Give user_ids or emails.", ErrorCode.INVALID_INPUT)
|
|
61
|
+
event = await hosted(ctx, args["event_id"])
|
|
62
|
+
if event.get("status") == "CANCELED":
|
|
63
|
+
raise ToolError("This event is canceled.", ErrorCode.INVALID_INPUT)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@tool(
|
|
67
|
+
name="invite_guests",
|
|
68
|
+
title="Invite guests",
|
|
69
|
+
access="write",
|
|
70
|
+
confirm=True,
|
|
71
|
+
description="Invite past guests and mutuals through Partiful, or anyone by email.",
|
|
72
|
+
details=(
|
|
73
|
+
"Sends invites as the host's Invite screen does, so they reach real people: Partiful users from "
|
|
74
|
+
"list_past_guests get Partiful's invite, people in emails get an email with the event link and your "
|
|
75
|
+
"note. There is no way to invite a phone number: Partiful takes phone contacts only in its app. "
|
|
76
|
+
"Email invitees still sign in with a phone number to RSVP. Partiful allows 1,000 invites per event, "
|
|
77
|
+
"500 of them by email. A canceled event is refused. invited_total is the event's Invited count after "
|
|
78
|
+
"the call. Hosts only. By default you confirm each call; you can switch that off for this tool."
|
|
79
|
+
),
|
|
80
|
+
input_schema=INPUT_SCHEMA,
|
|
81
|
+
route="POST /events/{event_id}/guests/invite",
|
|
82
|
+
example_response=EXAMPLE_RESPONSE,
|
|
83
|
+
check=check_invite,
|
|
84
|
+
)
|
|
85
|
+
async def invite_guests(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
86
|
+
await check_invite(ctx, args)
|
|
87
|
+
users = list(dict.fromkeys(args.get("user_ids") or []))
|
|
88
|
+
emails = [{"guestName": e["name"].strip(), "emailAddress": e["email"].strip()} for e in args.get("emails") or []]
|
|
89
|
+
await call(
|
|
90
|
+
ctx,
|
|
91
|
+
"addInvitedGuestsAsHost",
|
|
92
|
+
{
|
|
93
|
+
"eventId": args["event_id"],
|
|
94
|
+
"userIdsToInvite": users,
|
|
95
|
+
"invitationMessage": (args.get("message") or "").strip(),
|
|
96
|
+
"otherMutualsCount": 0,
|
|
97
|
+
"phoneContactsToInvite": [],
|
|
98
|
+
"emailsToInvite": emails,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
after = await event_info(ctx, args["event_id"])
|
|
102
|
+
return {
|
|
103
|
+
"event_id": args["event_id"],
|
|
104
|
+
"invited": len(users) + len(emails),
|
|
105
|
+
"by_email": len(emails),
|
|
106
|
+
"invited_total": counts(after)["invited"],
|
|
107
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""List events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from partiful_api_mcp.core.helpers import limit, page_cursor
|
|
8
|
+
from partiful_api_mcp.core.tools import tool
|
|
9
|
+
from partiful_api_mcp.partiful.client import call, id_token
|
|
10
|
+
from partiful_api_mcp.partiful.events import summary
|
|
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
|
+
"when": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"enum": ["upcoming", "past", "all"],
|
|
21
|
+
"description": "upcoming, past or all. Default: upcoming.",
|
|
22
|
+
},
|
|
23
|
+
"limit": {"type": "integer", "description": "1 to 50. Default: 20."},
|
|
24
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous call."},
|
|
25
|
+
},
|
|
26
|
+
"required": [],
|
|
27
|
+
"additionalProperties": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
31
|
+
"events": [
|
|
32
|
+
{
|
|
33
|
+
"event_id": "hK3vQ9xLm2Pa7RtYc4Wd",
|
|
34
|
+
"title": "Rooftop dinner",
|
|
35
|
+
"start_at": "2026-11-14T19:00:00-05:00",
|
|
36
|
+
"end_at": "2026-11-14T23:00:00-05:00",
|
|
37
|
+
"timezone": "America/New_York",
|
|
38
|
+
"location": "21 Mercer St, New York",
|
|
39
|
+
"status": "active",
|
|
40
|
+
"guests": {"going": 28, "maybe": 9, "cant_go": 4, "invited": 31, "waitlist": 0, "pending": 0},
|
|
41
|
+
"url": "https://partiful.com/e/hK3vQ9xLm2Pa7RtYc4Wd",
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"next_cursor": None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@tool(
|
|
49
|
+
name="list_events",
|
|
50
|
+
title="List events",
|
|
51
|
+
access="read",
|
|
52
|
+
confirm=False,
|
|
53
|
+
description="Events you host, with date, status and guest counts.",
|
|
54
|
+
details=(
|
|
55
|
+
"Returns the events this account hosts or cohosts, as the Hosting tab of the home page lists them: "
|
|
56
|
+
"start and end in the event's time zone, active or canceled, and guests per RSVP status. Events you "
|
|
57
|
+
"were only invited to are left out. Upcoming come soonest first, past most recent first. Use event_id "
|
|
58
|
+
"in the other tools."
|
|
59
|
+
),
|
|
60
|
+
input_schema=INPUT_SCHEMA,
|
|
61
|
+
route="GET /events",
|
|
62
|
+
example_response=EXAMPLE_RESPONSE,
|
|
63
|
+
)
|
|
64
|
+
async def list_events(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
65
|
+
uid, _ = await id_token(ctx)
|
|
66
|
+
when = args.get("when") or "upcoming"
|
|
67
|
+
items: list[dict[str, Any]] = []
|
|
68
|
+
if when in ("upcoming", "all"):
|
|
69
|
+
data = await call(ctx, "getMyUpcomingEventsForHomePage")
|
|
70
|
+
items += sorted((data or {}).get("upcomingEvents") or [], key=lambda e: str(e.get("startDate") or ""))
|
|
71
|
+
if when in ("past", "all"):
|
|
72
|
+
data = await call(ctx, "getMyPastEventsForHomePage")
|
|
73
|
+
past = (data or {}).get("pastEvents") or []
|
|
74
|
+
items += sorted(past, key=lambda e: str(e.get("startDate") or ""), reverse=True)
|
|
75
|
+
mine = [item for item in items if uid in (item.get("ownerIds") or [])]
|
|
76
|
+
page, size = page_cursor(args.get("cursor")), limit(args, 20, 50)
|
|
77
|
+
chunk = mine[(page - 1) * size : page * size]
|
|
78
|
+
return {
|
|
79
|
+
"events": [summary(item) for item in chunk],
|
|
80
|
+
"next_cursor": str(page + 1) if page * size < len(mine) else None,
|
|
81
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""List guests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from partiful_api_mcp.core.helpers import limit, page_cursor
|
|
8
|
+
from partiful_api_mcp.core.tools import tool
|
|
9
|
+
from partiful_api_mcp.partiful.events import EVENT_ID, STATUS_NAMES, hosted
|
|
10
|
+
from partiful_api_mcp.partiful.guests import all_guests, guest
|
|
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
|
+
"status": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"enum": ["all", *STATUS_NAMES],
|
|
22
|
+
"description": "all, going, maybe, cant_go, invited, waitlist or pending. Default: all.",
|
|
23
|
+
},
|
|
24
|
+
"checked_in": {"type": "boolean", "description": "Only guests who are checked in (true) or not (false)."},
|
|
25
|
+
"limit": {"type": "integer", "description": "1 to 200. Default: 100."},
|
|
26
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous call."},
|
|
27
|
+
},
|
|
28
|
+
"required": ["event_id"],
|
|
29
|
+
"additionalProperties": False,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
33
|
+
"guests": [
|
|
34
|
+
{
|
|
35
|
+
"guest_id": "zQ4mR8tLw2Ya6NcPe3Kb",
|
|
36
|
+
"name": "Maya Chen",
|
|
37
|
+
"status": "going",
|
|
38
|
+
"count": 2,
|
|
39
|
+
"plus_ones": ["Leo Park"],
|
|
40
|
+
"checked_in": False,
|
|
41
|
+
"rsvp_at": "2026-11-02T18:21:18-05:00",
|
|
42
|
+
"message": "Can't wait!",
|
|
43
|
+
"user_id": "Hn7bV2xQ9kLm4TrW8sYc3Pd5Fg61",
|
|
44
|
+
"answers": [{"question": "Dietary restrictions", "answer": "Vegetarian"}],
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"total": 1,
|
|
48
|
+
"next_cursor": None,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@tool(
|
|
53
|
+
name="list_guests",
|
|
54
|
+
title="List guests",
|
|
55
|
+
access="read",
|
|
56
|
+
confirm=False,
|
|
57
|
+
description="Guests of an event with RSVP status, check-in and Guest Questionnaire answers.",
|
|
58
|
+
details=(
|
|
59
|
+
"Returns the guest list the host sees, oldest RSVP first: name, RSVP status, party size with plus-one "
|
|
60
|
+
"names, check-in, RSVP note and questionnaire answers. Hosts only. going includes guests approved on a "
|
|
61
|
+
"Guest Approval event. guest_id goes to approve_guests and check_in_guests; total counts the guests "
|
|
62
|
+
"that match the filters."
|
|
63
|
+
),
|
|
64
|
+
input_schema=INPUT_SCHEMA,
|
|
65
|
+
route="GET /events/{event_id}/guests",
|
|
66
|
+
example_response=EXAMPLE_RESPONSE,
|
|
67
|
+
)
|
|
68
|
+
async def list_guests(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
69
|
+
event = await hosted(ctx, args["event_id"])
|
|
70
|
+
rows = [guest(doc, event) for doc in await all_guests(ctx, args["event_id"])]
|
|
71
|
+
wanted = args.get("status") or "all"
|
|
72
|
+
if wanted != "all":
|
|
73
|
+
rows = [row for row in rows if row["status"] == wanted]
|
|
74
|
+
if "checked_in" in args:
|
|
75
|
+
rows = [row for row in rows if row["checked_in"] is args["checked_in"]]
|
|
76
|
+
page, size = page_cursor(args.get("cursor")), limit(args, 100, 200)
|
|
77
|
+
return {
|
|
78
|
+
"guests": rows[(page - 1) * size : page * size],
|
|
79
|
+
"total": len(rows),
|
|
80
|
+
"next_cursor": str(page + 1) if page * size < len(rows) else None,
|
|
81
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""List past guests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from partiful_api_mcp.core.helpers import limit, page_cursor
|
|
8
|
+
from partiful_api_mcp.core.tools import tool
|
|
9
|
+
from partiful_api_mcp.partiful.client import call, id_token
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
13
|
+
|
|
14
|
+
# getContacts pages as the web app asks for them; past this many pages the rest is left out.
|
|
15
|
+
_PAGE = 1000
|
|
16
|
+
_MAX_PAGES = 5
|
|
17
|
+
|
|
18
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"not_on_event_id": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Leave out people already on this event (event_id), as its Invite list does.",
|
|
24
|
+
},
|
|
25
|
+
"query": {"type": "string", "description": "Only names containing this text."},
|
|
26
|
+
"limit": {"type": "integer", "description": "1 to 200. Default: 50."},
|
|
27
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous call."},
|
|
28
|
+
},
|
|
29
|
+
"required": [],
|
|
30
|
+
"additionalProperties": False,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
34
|
+
"people": [
|
|
35
|
+
{
|
|
36
|
+
"user_id": "Hn7bV2xQ9kLm4TrW8sYc3Pd5Fg61",
|
|
37
|
+
"name": "Maya Chen",
|
|
38
|
+
"past_guest": True,
|
|
39
|
+
"shared_events": 3,
|
|
40
|
+
"last_shared_event": {"event_id": "hK3vQ9xLm2Pa7RtYc4Wd", "title": "Rooftop dinner"},
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"total": 1,
|
|
44
|
+
"next_cursor": None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@tool(
|
|
49
|
+
name="list_past_guests",
|
|
50
|
+
title="List past guests",
|
|
51
|
+
access="read",
|
|
52
|
+
confirm=False,
|
|
53
|
+
description="People you can invite: your past guests and mutuals.",
|
|
54
|
+
details=(
|
|
55
|
+
"Returns the people Partiful offers on an event's Invite screen: past guests of your events and "
|
|
56
|
+
"mutuals from events you went to, with how many events you shared. Give not_on_event_id to leave "
|
|
57
|
+
"out people already on that event; there is no filter by one past event. query matches part of the "
|
|
58
|
+
"name. user_id goes to invite_guests. A new account has nobody yet, and shared_events or "
|
|
59
|
+
"last_shared_event stay 0 or null when Partiful leaves them out."
|
|
60
|
+
),
|
|
61
|
+
input_schema=INPUT_SCHEMA,
|
|
62
|
+
route="GET /past-guests",
|
|
63
|
+
example_response=EXAMPLE_RESPONSE,
|
|
64
|
+
)
|
|
65
|
+
async def list_past_guests(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
66
|
+
uid, _ = await id_token(ctx)
|
|
67
|
+
contacts: list[Any] = []
|
|
68
|
+
if event_id := args.get("not_on_event_id"):
|
|
69
|
+
data = await call(ctx, "getContactsFilteredByEvent", {"eventId": event_id})
|
|
70
|
+
contacts = _items(data)
|
|
71
|
+
else:
|
|
72
|
+
cursor: str | None = None
|
|
73
|
+
for _ in range(_MAX_PAGES):
|
|
74
|
+
paging: dict[str, Any] = {"maxResults": _PAGE, **({"cursor": cursor} if cursor else {})}
|
|
75
|
+
result = await call(ctx, "getContacts", {}, paging, whole=True)
|
|
76
|
+
contacts += _items(result.get("data"))
|
|
77
|
+
cursor = (result.get("paging") or {}).get("nextCursor")
|
|
78
|
+
if not cursor:
|
|
79
|
+
break
|
|
80
|
+
people = [p for p in (_person(item) for item in contacts) if p and p["user_id"] != uid]
|
|
81
|
+
if text := (args.get("query") or "").strip().lower():
|
|
82
|
+
people = [p for p in people if text in (p["name"] or "").lower()]
|
|
83
|
+
page, size = page_cursor(args.get("cursor")), limit(args, 50, 200)
|
|
84
|
+
return {
|
|
85
|
+
"people": people[(page - 1) * size : page * size],
|
|
86
|
+
"total": len(people),
|
|
87
|
+
"next_cursor": str(page + 1) if page * size < len(people) else None,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _items(data: Any) -> list[Any]:
|
|
92
|
+
"""getContacts answers a list, getContactsFilteredByEvent `{contacts: [...]}`."""
|
|
93
|
+
if isinstance(data, dict):
|
|
94
|
+
data = data.get("contacts") or data.get("users") or []
|
|
95
|
+
return data if isinstance(data, list) else []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _person(item: Any) -> dict[str, Any] | None:
|
|
99
|
+
"""One contact, shaped like getContactsFilteredByEvent's; getContacts was only seen empty, so all is optional."""
|
|
100
|
+
if not isinstance(item, dict):
|
|
101
|
+
return None
|
|
102
|
+
nested = item.get("user")
|
|
103
|
+
user: dict[str, Any] = nested if isinstance(nested, dict) else {}
|
|
104
|
+
user_id = item.get("id") or item.get("userId") or user.get("id")
|
|
105
|
+
if not user_id:
|
|
106
|
+
return None
|
|
107
|
+
event = item.get("sharedEvent")
|
|
108
|
+
shared: dict[str, Any] | None = event if isinstance(event, dict) else None
|
|
109
|
+
return {
|
|
110
|
+
"user_id": user_id,
|
|
111
|
+
"name": item.get("name") or user.get("name"),
|
|
112
|
+
"past_guest": bool(item.get("isPastGuest")),
|
|
113
|
+
"shared_events": int(item.get("sharedEventCount") or 0),
|
|
114
|
+
"last_shared_event": {"event_id": shared.get("id"), "title": shared.get("title")} if shared else None,
|
|
115
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Send Text Blast."""
|
|
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, STATUSES, 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
|
+
# Who a blast can go to, as the Send Text Blast modal groups guests.
|
|
17
|
+
_GROUPS = ["going", "maybe", "cant_go", "invited", "waitlist", "pending"]
|
|
18
|
+
# Text length the modal allows by SMS; email only allows more, but SMS is always on here.
|
|
19
|
+
_MAX_TEXT = 480
|
|
20
|
+
|
|
21
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"event_id": EVENT_ID,
|
|
25
|
+
"message": {"type": "string", "minLength": 1, "maxLength": _MAX_TEXT, "description": "Message text."},
|
|
26
|
+
"statuses": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {"type": "string", "enum": _GROUPS},
|
|
29
|
+
"minItems": 1,
|
|
30
|
+
"description": "Who receives it: going, maybe, cant_go, invited, waitlist, pending.",
|
|
31
|
+
},
|
|
32
|
+
"show_on_event_page": {
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"description": "Also show the blast in the event's activity feed. Default: true.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"required": ["event_id", "message", "statuses"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
42
|
+
"event_id": "hK3vQ9xLm2Pa7RtYc4Wd",
|
|
43
|
+
"recipients": 37,
|
|
44
|
+
"by_status": {"going": 28, "maybe": 9},
|
|
45
|
+
"sent": True,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _recipients(ctx: ToolContext, args: dict[str, Any]) -> dict[str, int]:
|
|
50
|
+
await hosted(ctx, args["event_id"])
|
|
51
|
+
docs = await all_guests(ctx, args["event_id"])
|
|
52
|
+
by_status = {name: sum(1 for doc in docs if doc.get("status") in STATUSES[name]) for name in args["statuses"]}
|
|
53
|
+
if not sum(by_status.values()):
|
|
54
|
+
raise ToolError("No guest has the chosen statuses, so the blast would reach nobody.", ErrorCode.INVALID_INPUT)
|
|
55
|
+
return by_status
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def check_blast(ctx: ToolContext, args: dict[str, Any]) -> None:
|
|
59
|
+
"""Before approval: a blast to nobody or on someone else's event fails now."""
|
|
60
|
+
await _recipients(ctx, args)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@tool(
|
|
64
|
+
name="send_text_blast",
|
|
65
|
+
title="Send Text Blast",
|
|
66
|
+
access="write",
|
|
67
|
+
confirm=True,
|
|
68
|
+
description="Text a message to guests by RSVP status. Partiful allows up to 10 Text Blasts per event.",
|
|
69
|
+
details=(
|
|
70
|
+
"Sends a Text Blast as the host's Send Text Blast does: by text and email to every guest with the "
|
|
71
|
+
"chosen RSVP statuses, push for guests with the app, and shown in the event's feed unless "
|
|
72
|
+
"show_on_event_page is false. Real people get it at once and it cannot be taken back. Up to 480 "
|
|
73
|
+
"characters. recipients and by_status count the guests with those statuses at the moment of sending; "
|
|
74
|
+
"a choice that reaches nobody is refused before you confirm. Partiful allows 10 blasts per event and "
|
|
75
|
+
"refuses a blast to Invited when that group holds more than 100 guests. The answer does not say how "
|
|
76
|
+
"many blasts are left. Hosts only. By default you confirm each call; you can switch that off for this "
|
|
77
|
+
"tool."
|
|
78
|
+
),
|
|
79
|
+
input_schema=INPUT_SCHEMA,
|
|
80
|
+
route="POST /events/{event_id}/text-blasts",
|
|
81
|
+
example_response=EXAMPLE_RESPONSE,
|
|
82
|
+
check=check_blast,
|
|
83
|
+
)
|
|
84
|
+
async def send_text_blast(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
85
|
+
by_status = await _recipients(ctx, args)
|
|
86
|
+
to = [value for name in args["statuses"] for value in STATUSES[name]]
|
|
87
|
+
await call(
|
|
88
|
+
ctx,
|
|
89
|
+
"createTextBlast",
|
|
90
|
+
{
|
|
91
|
+
"eventId": args["event_id"],
|
|
92
|
+
"message": {
|
|
93
|
+
"text": args["message"].strip(),
|
|
94
|
+
"to": to,
|
|
95
|
+
"showOnEventPage": args.get("show_on_event_page", True),
|
|
96
|
+
"notificationChannels": ["Sms", "Email"],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
return {"event_id": args["event_id"], "recipients": sum(by_status.values()), "by_status": by_status, "sent": True}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Update 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.editing import FIELDS, apply
|
|
10
|
+
from partiful_api_mcp.partiful.events import EVENT_ID, detail, event_info, hosted
|
|
11
|
+
from partiful_api_mcp.partiful.tools.get_event import EXAMPLE_RESPONSE as EVENT
|
|
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": {"event_id": EVENT_ID, **FIELDS},
|
|
19
|
+
"required": ["event_id"],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {"updated": ["location"], **EVENT, "location": "88 Bleecker St, New York"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@tool(
|
|
27
|
+
name="update_event",
|
|
28
|
+
title="Update event",
|
|
29
|
+
access="write",
|
|
30
|
+
confirm=False,
|
|
31
|
+
description="Change the details or settings of an event you host.",
|
|
32
|
+
details=(
|
|
33
|
+
"Changes only the fields you give, as the event's Edit page and settings panel do: title, times, time "
|
|
34
|
+
"zone, location, description, RSVP deadline, guest limit, Guest Approval and the Guest Questionnaire. "
|
|
35
|
+
"Fields left out keep their value; a call with no field is refused. Turning Guest Approval on removes "
|
|
36
|
+
"the guest limit and the waitlist, and the first guest limit turns the waitlist on. New questions "
|
|
37
|
+
"replace the questionnaire for guests who RSVP from then on. Returns the event after the change, with "
|
|
38
|
+
"updated listing the fields you gave. Hosts only."
|
|
39
|
+
),
|
|
40
|
+
input_schema=INPUT_SCHEMA,
|
|
41
|
+
route="PATCH /events/{event_id}",
|
|
42
|
+
example_response=EXAMPLE_RESPONSE,
|
|
43
|
+
)
|
|
44
|
+
async def update_event(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
45
|
+
changes = {key: value for key, value in args.items() if key != "event_id"}
|
|
46
|
+
if not changes:
|
|
47
|
+
raise ToolError("Give at least one field to change.", ErrorCode.INVALID_INPUT)
|
|
48
|
+
old = await hosted(ctx, args["event_id"])
|
|
49
|
+
updated = await apply(ctx, args["event_id"], old, changes)
|
|
50
|
+
return {"updated": updated, **detail(await event_info(ctx, args["event_id"]))}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""All tools of the app in one registry, and the one way to call them: validate, run, map errors.
|
|
2
|
+
|
|
3
|
+
The REST API, the MCP server, the CLI and `partiful_api_mcp.call()` all go through `Runner.run`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import importlib
|
|
10
|
+
import pkgutil
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import jsonschema
|
|
14
|
+
|
|
15
|
+
from partiful_api_mcp import app
|
|
16
|
+
from partiful_api_mcp import session as sessions
|
|
17
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
18
|
+
from partiful_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
|
|
19
|
+
from partiful_api_mcp.core.tools import ToolSpec
|
|
20
|
+
from partiful_api_mcp.partiful import tools as tools_package
|
|
21
|
+
|
|
22
|
+
_TOOLS: dict[str, ToolSpec] | None = None
|
|
23
|
+
# One message for a signed-out session, whatever the tool raised.
|
|
24
|
+
_SIGNED_OUT = f"{app.NAME} signed this session out. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def all_tools() -> dict[str, ToolSpec]:
|
|
28
|
+
"""Every tool in the app's tools/ package, by name, in file order."""
|
|
29
|
+
global _TOOLS
|
|
30
|
+
if _TOOLS is None:
|
|
31
|
+
found: dict[str, ToolSpec] = {}
|
|
32
|
+
for module_info in sorted(pkgutil.iter_modules(tools_package.__path__), key=lambda m: m.name):
|
|
33
|
+
module = importlib.import_module(f"{tools_package.__name__}.{module_info.name}")
|
|
34
|
+
for value in vars(module).values():
|
|
35
|
+
if isinstance(value, ToolSpec) and value.route:
|
|
36
|
+
found[value.name] = value
|
|
37
|
+
_TOOLS = found
|
|
38
|
+
return _TOOLS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tools(read_only: bool = False) -> list[ToolSpec]:
|
|
42
|
+
return [spec for spec in all_tools().values() if not read_only or spec.access == "read"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def description(spec: ToolSpec) -> str:
|
|
46
|
+
"""What an agent reads: the one-liner, then the details."""
|
|
47
|
+
return f"{spec.description}\n\n{spec.details}" if spec.details else spec.description
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Runner:
|
|
51
|
+
"""One session for the life of the process; reconnects to Chrome once when the app signs it out."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, read_only: bool = False) -> None:
|
|
54
|
+
self.read_only = read_only
|
|
55
|
+
self._ctx: ToolContext | None = None
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
def _context(self) -> ToolContext:
|
|
59
|
+
if self._ctx is None:
|
|
60
|
+
session = sessions.load()
|
|
61
|
+
if not session.signed_in:
|
|
62
|
+
raise ToolError(
|
|
63
|
+
f"No {app.NAME} session yet. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect",
|
|
64
|
+
ErrorCode.AUTH_REQUIRED,
|
|
65
|
+
)
|
|
66
|
+
self._ctx = ToolContext(session=session)
|
|
67
|
+
return self._ctx
|
|
68
|
+
|
|
69
|
+
async def run(self, name: str, args: dict[str, Any] | None = None) -> Any:
|
|
70
|
+
spec = all_tools().get(name)
|
|
71
|
+
if spec is None:
|
|
72
|
+
raise ToolError(f"No tool '{name}'. List them with: {app.CLI} tools", ErrorCode.UNKNOWN_TOOL)
|
|
73
|
+
if self.read_only and spec.access != "read":
|
|
74
|
+
raise ToolError(f"{name} changes {app.NAME} and this server runs read-only.", ErrorCode.SCOPE_DENIED)
|
|
75
|
+
args = {k: v for k, v in (args or {}).items() if v is not None}
|
|
76
|
+
try:
|
|
77
|
+
jsonschema.validate(args, spec.input_schema)
|
|
78
|
+
except jsonschema.ValidationError as exc:
|
|
79
|
+
where = ".".join(str(p) for p in exc.absolute_path)
|
|
80
|
+
raise ToolError(f"{where + ': ' if where else ''}{exc.message}", ErrorCode.INVALID_INPUT) from None
|
|
81
|
+
ctx = self._context()
|
|
82
|
+
try:
|
|
83
|
+
return await asyncio.wait_for(spec.handler(ctx, args), timeout=spec.timeout_s or 120)
|
|
84
|
+
except TimeoutError:
|
|
85
|
+
raise ToolError(f"{app.NAME} took too long to answer.", ErrorCode.TIMEOUT) from None
|
|
86
|
+
except ToolError as exc:
|
|
87
|
+
if exc.code is not ErrorCode.AUTH_EXPIRED:
|
|
88
|
+
raise
|
|
89
|
+
if not await self._reconnect():
|
|
90
|
+
raise AuthExpiredError(_SIGNED_OUT) from exc
|
|
91
|
+
# The app signed the old session out: read it again from Chrome and try once more.
|
|
92
|
+
try:
|
|
93
|
+
return await spec.handler(self._context(), args)
|
|
94
|
+
except AuthExpiredError as again:
|
|
95
|
+
raise AuthExpiredError(_SIGNED_OUT) from again
|
|
96
|
+
|
|
97
|
+
async def _reconnect(self) -> bool:
|
|
98
|
+
async with self._lock:
|
|
99
|
+
old = self._context()
|
|
100
|
+
try:
|
|
101
|
+
fresh = await asyncio.to_thread(sessions.refresh, old.session)
|
|
102
|
+
except ToolError:
|
|
103
|
+
return False
|
|
104
|
+
same = fresh is not None and (fresh.cookies, fresh.local_storage) == (
|
|
105
|
+
old.session.cookies,
|
|
106
|
+
old.session.local_storage,
|
|
107
|
+
)
|
|
108
|
+
if fresh is None or same:
|
|
109
|
+
return False
|
|
110
|
+
await old.close()
|
|
111
|
+
self._ctx = ToolContext(session=fresh)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
if self._ctx is not None:
|
|
116
|
+
await self._ctx.close()
|
|
117
|
+
self._ctx = None
|