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,73 @@
|
|
|
1
|
+
"""Helpers for scripts: call a tool, follow pages, save CSV or JSON. The scripts in examples/ use them.
|
|
2
|
+
|
|
3
|
+
from partiful_api_mcp.scripting import call, paginate
|
|
4
|
+
|
|
5
|
+
for guest in paginate("list_guests", {"event_id": "hK3vQ9xLm2Pa7RtYc4Wd"}, "guests"):
|
|
6
|
+
print(guest["name"], guest["status"])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from partiful_api_mcp import call as _call
|
|
19
|
+
from partiful_api_mcp.core.errors import ToolError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def call(tool: str, params: dict[str, Any] | None = None) -> Any:
|
|
23
|
+
"""Run one tool; arguments set to None are left out."""
|
|
24
|
+
return _call(tool, {k: v for k, v in (params or {}).items() if v is not None})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def paginate(tool: str, params: dict[str, Any], key: str, max_items: int | None = None) -> Iterator[dict[str, Any]]:
|
|
28
|
+
"""Yield every item of a list tool, following next_cursor."""
|
|
29
|
+
params = dict(params)
|
|
30
|
+
seen = 0
|
|
31
|
+
while True:
|
|
32
|
+
page = call(tool, params)
|
|
33
|
+
for item in page.get(key, []):
|
|
34
|
+
yield item
|
|
35
|
+
seen += 1
|
|
36
|
+
if max_items and seen >= max_items:
|
|
37
|
+
return
|
|
38
|
+
cursor = page.get("next_cursor")
|
|
39
|
+
if not cursor:
|
|
40
|
+
return
|
|
41
|
+
params["cursor"] = cursor
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_rows(rows: list[dict[str, Any]], out: str | None) -> None:
|
|
45
|
+
"""Print JSON to stdout, or save .csv / .json when a path is given."""
|
|
46
|
+
if not out:
|
|
47
|
+
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
48
|
+
return
|
|
49
|
+
path = Path(out)
|
|
50
|
+
if path.suffix.lower() == ".csv":
|
|
51
|
+
fields: list[str] = []
|
|
52
|
+
for row in rows:
|
|
53
|
+
fields += [k for k in row if k not in fields]
|
|
54
|
+
with path.open("w", newline="", encoding="utf-8") as fh:
|
|
55
|
+
writer = csv.DictWriter(fh, fieldnames=fields)
|
|
56
|
+
writer.writeheader()
|
|
57
|
+
for row in rows:
|
|
58
|
+
writer.writerow(
|
|
59
|
+
{k: json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v for k, v in row.items()}
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
63
|
+
print(f"Saved {len(rows)} rows to {path}", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(main: Callable[[], None]) -> None:
|
|
67
|
+
"""Run a script's main() and turn tool errors into a readable message."""
|
|
68
|
+
try:
|
|
69
|
+
main()
|
|
70
|
+
except ToolError as err:
|
|
71
|
+
sys.exit(f"{err.code}: {err}")
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
sys.exit(130)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Your session of the app: its cookies from your own Chrome, saved on this machine.
|
|
2
|
+
|
|
3
|
+
`<cli> connect` reads them from Chrome over the Chrome DevTools Protocol, in one of two ways:
|
|
4
|
+
|
|
5
|
+
- Chrome 144+ with remote debugging switched on at chrome://inspect/#remote-debugging. Chrome writes
|
|
6
|
+
the port to `DevToolsActivePort` in its profile folder and asks you to allow each connection.
|
|
7
|
+
- Any Chrome started with `--remote-debugging-port=9222 --user-data-dir=<folder>`: pass `--cdp http://127.0.0.1:9222`.
|
|
8
|
+
|
|
9
|
+
An app that keeps its sign-in in the browser's storage (`app.STORAGE_KEYS`) also gets those keys, read in a tab
|
|
10
|
+
of `app.STORAGE_ORIGIN`: localStorage, or the Firebase user in IndexedDB. Nothing else from storage is read.
|
|
11
|
+
|
|
12
|
+
The session file holds cookies and those keys, never your password, and is readable by your user alone (0600).
|
|
13
|
+
Names, domains and the session cookie come from `app.py`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
from partiful_api_mcp import app
|
|
31
|
+
from partiful_api_mcp.core.errors import ErrorCode, ToolError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def home() -> Path:
|
|
35
|
+
"""Where the session is kept: $<ENV>_MCP_HOME, or ~/.config/<slug>-api-mcp."""
|
|
36
|
+
return Path(os.environ.get(f"{app.ENV}_MCP_HOME") or Path.home() / ".config" / f"{app.SLUG}-api-mcp")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def session_path() -> Path:
|
|
40
|
+
return home() / "session.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Session:
|
|
45
|
+
cookies: list[dict[str, Any]] = field(default_factory=list)
|
|
46
|
+
user_agent: str | None = None
|
|
47
|
+
chrome_major: int | None = None
|
|
48
|
+
locale: str | None = None
|
|
49
|
+
# Where the cookies came from, so a signed-out session can be read again: "auto" or a CDP URL.
|
|
50
|
+
source: str | None = None
|
|
51
|
+
proxy: str | None = None
|
|
52
|
+
# Origin -> {key: value} of the keys in app.STORAGE_KEYS, what tools read as `ctx.profile.local_storage`.
|
|
53
|
+
local_storage: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
54
|
+
# The browser's IANA time zone, for tools that take local times.
|
|
55
|
+
timezone: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def signed_in(self) -> bool:
|
|
59
|
+
cookies = all(any(c["name"] == name and c.get("value") for c in self.cookies) for name in app.SESSION_COOKIES)
|
|
60
|
+
stored = self.local_storage.get(app.STORAGE_ORIGIN or "", {})
|
|
61
|
+
return cookies and all(stored.get(key) for key in app.STORAGE_KEYS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load() -> Session:
|
|
65
|
+
"""The saved session, with <ENV>_PROXY from the environment on top."""
|
|
66
|
+
session = Session()
|
|
67
|
+
path = session_path()
|
|
68
|
+
if path.exists():
|
|
69
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
session = Session(**{k: v for k, v in data.items() if k in Session.__dataclass_fields__})
|
|
71
|
+
if proxy := os.environ.get(f"{app.ENV}_PROXY", "").strip():
|
|
72
|
+
session.proxy = proxy
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save(session: Session) -> Path:
|
|
77
|
+
path = session_path()
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
81
|
+
json.dump(asdict(session), fh, indent=2)
|
|
82
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
83
|
+
return path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def chrome_profile_dirs() -> list[Path]:
|
|
87
|
+
"""Default Chrome profile folders of this OS, stable channel first."""
|
|
88
|
+
system = platform.system()
|
|
89
|
+
if system == "Darwin":
|
|
90
|
+
base = Path.home() / "Library" / "Application Support" / "Google"
|
|
91
|
+
return [base / "Chrome", base / "Chrome Beta", base / "Chrome Canary"]
|
|
92
|
+
if system == "Windows":
|
|
93
|
+
base = Path(os.environ.get("LOCALAPPDATA", "")) / "Google"
|
|
94
|
+
return [base / "Chrome" / "User Data", base / "Chrome Beta" / "User Data", base / "Chrome SxS" / "User Data"]
|
|
95
|
+
base = Path.home() / ".config"
|
|
96
|
+
return [base / "google-chrome", base / "google-chrome-beta", base / "chromium"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def auto_endpoint(profile_dir: str | None = None) -> str:
|
|
100
|
+
"""The browser WebSocket URL from DevToolsActivePort, written while remote debugging is on."""
|
|
101
|
+
dirs = [Path(profile_dir).expanduser()] if profile_dir else chrome_profile_dirs()
|
|
102
|
+
for folder in dirs:
|
|
103
|
+
port_file = folder / "DevToolsActivePort"
|
|
104
|
+
if port_file.exists():
|
|
105
|
+
lines = port_file.read_text(encoding="utf-8").split()
|
|
106
|
+
if len(lines) >= 2 and lines[0].isdigit():
|
|
107
|
+
return f"ws://127.0.0.1:{lines[0]}{lines[1]}"
|
|
108
|
+
raise ToolError(
|
|
109
|
+
"Chrome remote debugging is off. Open chrome://inspect/#remote-debugging in Chrome, switch it on, "
|
|
110
|
+
"and run this again. Or start Chrome with --remote-debugging-port=9222 and pass --cdp http://127.0.0.1:9222.",
|
|
111
|
+
ErrorCode.AUTH_REQUIRED,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _endpoint(cdp: str) -> str:
|
|
116
|
+
"""A ws:// URL as given, or the browser WebSocket URL behind an http:// DevTools address."""
|
|
117
|
+
if cdp.startswith(("ws://", "wss://")):
|
|
118
|
+
return cdp
|
|
119
|
+
with urllib.request.urlopen(cdp.rstrip("/") + "/json/version", timeout=10) as resp:
|
|
120
|
+
return json.loads(resp.read())["webSocketDebuggerUrl"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Cdp:
|
|
124
|
+
"""A tiny synchronous CDP client: one WebSocket to the browser target."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, url: str) -> None:
|
|
127
|
+
from websockets.sync.client import connect
|
|
128
|
+
|
|
129
|
+
# Chrome shows "Allow remote debugging?" for this connection; give the user time to click it.
|
|
130
|
+
self._ws = connect(url, open_timeout=60, max_size=64 * 1024 * 1024)
|
|
131
|
+
self._id = 0
|
|
132
|
+
|
|
133
|
+
def send(self, method: str, params: dict[str, Any] | None = None, session_id: str | None = None) -> dict[str, Any]:
|
|
134
|
+
self._id += 1
|
|
135
|
+
message: dict[str, Any] = {"id": self._id, "method": method, "params": params or {}}
|
|
136
|
+
if session_id:
|
|
137
|
+
message["sessionId"] = session_id
|
|
138
|
+
self._ws.send(json.dumps(message))
|
|
139
|
+
while True:
|
|
140
|
+
message = json.loads(self._ws.recv(timeout=60))
|
|
141
|
+
if message.get("id") != self._id:
|
|
142
|
+
continue
|
|
143
|
+
if "error" in message:
|
|
144
|
+
raise ToolError(f"Chrome refused {method}: {message['error'].get('message')}", ErrorCode.INTERNAL_ERROR)
|
|
145
|
+
return message.get("result") or {}
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
self._ws.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Run in a tab of app.STORAGE_ORIGIN: the wanted keys from localStorage, then from the Firebase user store in
|
|
152
|
+
# IndexedDB (records {fbase_key, value}), and the browser's time zone. Values are JSON strings.
|
|
153
|
+
_STORAGE_JS = """async (keys) => {
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const key of keys) { const v = localStorage.getItem(key); if (v) out[key] = v; }
|
|
156
|
+
const missing = keys.filter((key) => !(key in out));
|
|
157
|
+
const names = indexedDB.databases ? (await indexedDB.databases()).map((d) => d.name) : [];
|
|
158
|
+
if (missing.length && names.includes("firebaseLocalStorageDb")) {
|
|
159
|
+
const db = await new Promise((ok, no) => {
|
|
160
|
+
const open = indexedDB.open("firebaseLocalStorageDb");
|
|
161
|
+
open.onsuccess = () => ok(open.result);
|
|
162
|
+
open.onerror = () => no(open.error);
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
if (db.objectStoreNames.contains("firebaseLocalStorage")) {
|
|
166
|
+
const rows = await new Promise((ok, no) => {
|
|
167
|
+
const read = db.transaction("firebaseLocalStorage").objectStore("firebaseLocalStorage").getAll();
|
|
168
|
+
read.onsuccess = () => ok(read.result);
|
|
169
|
+
read.onerror = () => no(read.error);
|
|
170
|
+
});
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
if (row && missing.includes(row.fbase_key)) out[row.fbase_key] = JSON.stringify(row.value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
db.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {storage: out, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone};
|
|
180
|
+
}"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _system_timezone() -> str | None:
|
|
184
|
+
"""This machine's IANA zone from $TZ or the /etc/localtime link; None when neither names one."""
|
|
185
|
+
if (tz := os.environ.get("TZ", "").lstrip(":")) and "/" in tz:
|
|
186
|
+
return tz
|
|
187
|
+
try:
|
|
188
|
+
target = os.path.realpath("/etc/localtime")
|
|
189
|
+
except OSError:
|
|
190
|
+
return None
|
|
191
|
+
return target.split("zoneinfo/", 1)[1] if "zoneinfo/" in target else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _read_storage(client: _Cdp) -> tuple[dict[str, dict[str, str]], str | None]:
|
|
195
|
+
"""The app's sign-in keys and the time zone, from an open tab of the app or a background tab opened for it."""
|
|
196
|
+
origin = app.STORAGE_ORIGIN
|
|
197
|
+
targets = client.send("Target.getTargets").get("targetInfos") or []
|
|
198
|
+
page = next(
|
|
199
|
+
(t for t in targets if t.get("type") == "page" and (t.get("url") or "").startswith(origin + "/")),
|
|
200
|
+
None,
|
|
201
|
+
)
|
|
202
|
+
opened = None
|
|
203
|
+
if page is None:
|
|
204
|
+
opened = client.send("Target.createTarget", {"url": app.STORAGE_PAGE, "background": True})["targetId"]
|
|
205
|
+
page = {"targetId": opened}
|
|
206
|
+
try:
|
|
207
|
+
session_id = client.send("Target.attachToTarget", {"targetId": page["targetId"], "flatten": True})["sessionId"]
|
|
208
|
+
expression = f"({_STORAGE_JS})({json.dumps(list(app.STORAGE_KEYS))})"
|
|
209
|
+
answer: dict[str, Any] = {}
|
|
210
|
+
# A tab opened just now may not have its origin yet; ask again while it loads, for up to 20 s.
|
|
211
|
+
for _ in range(20):
|
|
212
|
+
result = client.send(
|
|
213
|
+
"Runtime.evaluate",
|
|
214
|
+
{"expression": expression, "awaitPromise": True, "returnByValue": True},
|
|
215
|
+
session_id,
|
|
216
|
+
)
|
|
217
|
+
answer = (result.get("result") or {}).get("value") or {}
|
|
218
|
+
if answer.get("storage") or opened is None:
|
|
219
|
+
break
|
|
220
|
+
time.sleep(1)
|
|
221
|
+
finally:
|
|
222
|
+
if opened:
|
|
223
|
+
client.send("Target.closeTarget", {"targetId": opened})
|
|
224
|
+
storage = answer.get("storage") or {}
|
|
225
|
+
return ({origin: storage} if storage else {}), answer.get("timezone")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def from_chrome(cdp: str | None = None, profile_dir: str | None = None) -> Session:
|
|
229
|
+
"""Read the app's cookies of a running Chrome. Nothing is changed in the browser."""
|
|
230
|
+
url = _endpoint(cdp) if cdp else auto_endpoint(profile_dir)
|
|
231
|
+
try:
|
|
232
|
+
client = _Cdp(url)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
raise ToolError(
|
|
235
|
+
f"Could not connect to Chrome at {urlsplit(url).netloc} ({type(exc).__name__}). "
|
|
236
|
+
"Is Chrome open, and did you click Allow in its remote debugging prompt?",
|
|
237
|
+
ErrorCode.AUTH_REQUIRED,
|
|
238
|
+
) from exc
|
|
239
|
+
try:
|
|
240
|
+
version = client.send("Browser.getVersion")
|
|
241
|
+
cookies = client.send("Storage.getCookies").get("cookies") or []
|
|
242
|
+
local_storage, timezone = _read_storage(client) if app.STORAGE_KEYS else ({}, None)
|
|
243
|
+
finally:
|
|
244
|
+
client.close()
|
|
245
|
+
mine = [
|
|
246
|
+
{"name": c["name"], "value": c["value"], "domain": c["domain"], "path": c.get("path") or "/"}
|
|
247
|
+
for c in cookies
|
|
248
|
+
if any(c.get("domain", "").lstrip(".").endswith(domain) for domain in app.DOMAINS)
|
|
249
|
+
]
|
|
250
|
+
user_agent = version.get("userAgent")
|
|
251
|
+
match = re.search(r"Chrome/(\d+)", user_agent or "") or re.search(r"/(\d+)\.", version.get("product", ""))
|
|
252
|
+
session = Session(
|
|
253
|
+
cookies=mine,
|
|
254
|
+
user_agent=user_agent,
|
|
255
|
+
chrome_major=int(match.group(1)) if match else None,
|
|
256
|
+
source=cdp or "auto",
|
|
257
|
+
local_storage=local_storage,
|
|
258
|
+
timezone=timezone or _system_timezone(),
|
|
259
|
+
)
|
|
260
|
+
if not session.signed_in:
|
|
261
|
+
raise ToolError(
|
|
262
|
+
f"This Chrome is not signed in to {app.NAME}. Sign in at {app.LOGIN_URL}, then run this again.",
|
|
263
|
+
ErrorCode.AUTH_REQUIRED,
|
|
264
|
+
)
|
|
265
|
+
return session
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def refresh(session: Session) -> Session | None:
|
|
269
|
+
"""Read the session again from where it came from; None when there is no source."""
|
|
270
|
+
if not session.source:
|
|
271
|
+
return None
|
|
272
|
+
fresh = from_chrome(None if session.source == "auto" else session.source)
|
|
273
|
+
fresh.proxy = session.proxy
|
|
274
|
+
save(fresh)
|
|
275
|
+
return fresh
|