parentsquare-mcp 0.1.0__tar.gz

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 (29) hide show
  1. parentsquare_mcp-0.1.0/.gitignore +12 -0
  2. parentsquare_mcp-0.1.0/CLAUDE.md +89 -0
  3. parentsquare_mcp-0.1.0/LICENSE +21 -0
  4. parentsquare_mcp-0.1.0/PKG-INFO +13 -0
  5. parentsquare_mcp-0.1.0/README.md +100 -0
  6. parentsquare_mcp-0.1.0/pyproject.toml +23 -0
  7. parentsquare_mcp-0.1.0/src/parentsquare_mcp/__init__.py +0 -0
  8. parentsquare_mcp-0.1.0/src/parentsquare_mcp/auth.py +273 -0
  9. parentsquare_mcp-0.1.0/src/parentsquare_mcp/client.py +248 -0
  10. parentsquare_mcp-0.1.0/src/parentsquare_mcp/config.py +29 -0
  11. parentsquare_mcp-0.1.0/src/parentsquare_mcp/download.py +35 -0
  12. parentsquare_mcp-0.1.0/src/parentsquare_mcp/export_cookies.py +85 -0
  13. parentsquare_mcp-0.1.0/src/parentsquare_mcp/models.py +197 -0
  14. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/__init__.py +0 -0
  15. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/calendar.py +47 -0
  16. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/directory.py +79 -0
  17. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/feeds.py +422 -0
  18. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/groups.py +80 -0
  19. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/links.py +39 -0
  20. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/media.py +130 -0
  21. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/messages.py +169 -0
  22. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/notices.py +61 -0
  23. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/payments.py +99 -0
  24. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/polls.py +106 -0
  25. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/schools.py +41 -0
  26. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/students.py +89 -0
  27. parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/volunteer.py +49 -0
  28. parentsquare_mcp-0.1.0/src/parentsquare_mcp/server.py +1135 -0
  29. parentsquare_mcp-0.1.0/uv.lock +780 -0
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .eggs/
9
+ .idea/
10
+
11
+ # Local Claude Code settings (contains project-specific permissions with personal IDs)
12
+ .claude/settings.local.json
@@ -0,0 +1,89 @@
1
+ # ParentSquare MCP Server
2
+
3
+ ## Architecture
4
+
5
+ MCP server that scrapes ParentSquare's web UI. Runs as stdio transport. While there's no documented public API, ParentSquare has an internal JSON:API at `/api/v2/` that some tools use (e.g. directory).
6
+
7
+ ```
8
+ server.py — MCP tool definitions, inline image/PDF fetching
9
+ client.py — HTTP client with auto-relogin on session expiry
10
+ auth.py — Cookie persistence (~/.parentsquare_cookies.json), 1Password credential loading, MFA flow
11
+ config.py — URL templates and constants (no personal data — auto-discovered at runtime)
12
+ models.py — Dataclasses for all parsed entities
13
+ download.py — File download with conflict handling
14
+ parsers/ — One module per page type (feeds, calendar, media, messages, etc.)
15
+ export_cookies.py — CLI helper to bootstrap cookies from browser DevTools
16
+ ```
17
+
18
+ ## Key Patterns
19
+
20
+ ### Authentication
21
+ - Cookies are lazy-loaded from `~/.parentsquare_cookies.json` on startup (no network call)
22
+ - On session expiry (detected by redirect to `/signin`), credentials are fetched from 1Password CLI (`op item get Parentsquare` — item must be named "Parentsquare" with fields labeled `username` and `password`)
23
+ - MFA state persists to disk (`.parentsquare_mfa_state.json`) so it survives server restarts
24
+ - The server supports MCP elicitation for inline MFA code entry
25
+ - **User-Agent must include "Chrome"** — ParentSquare returns 403 `browser_unsupported` otherwise. The server sets this in `app_lifespan`.
26
+ - The `ps_s` session cookie is **httpOnly** — it can't be read via `document.cookie`, which is why `export_cookies` requires the Network tab in DevTools
27
+ - `ps_s` rotates on every request. `PSClient` calls `_save_cookies_if_changed()` after each successful request to persist the latest value.
28
+ - GraphQL requests (used by `list_groups`) require a CSRF token extracted from a page's `<meta name="csrf-token">` tag. MFA submit also requires a CSRF token from the MFA page.
29
+
30
+ ### JSON:API (`/api/v2/`)
31
+ ParentSquare has an internal JSON:API (not publicly documented). Discovered by inspecting JS bundle XHR calls:
32
+ - **`/api/v2/schools/{id}`** — school info (name, phone, address, timezone). Used by `get_directory`.
33
+ - **`/api/v2/schools/{id}/directory`** — staff directory (JSON:API format with `included` array containing staff records). Used by `get_directory`.
34
+ - **`/api/v2/schools/{id}/users/{user_id}`** — individual staff details (email, photo, virtual phone, office hours). Used by `get_staff_member`.
35
+ - **`/api/v2/users/{id}/virtual_phone_search`** — POST with `{"staff_ids": [...]}` to batch-fetch virtual phone numbers. Used by `get_directory`.
36
+ - **`/api/v2/sections/{id}/staff`** and **`/api/v2/sections/{id}/students`** — per-section (class) directory lookups.
37
+ - Use `client.get_json()` for GET and `client.post_json()` for POST (handles CSRF tokens automatically).
38
+ - Many pages that appear empty in HTML are actually shell pages that load data via this API. If an HTML parser returns no data, check the JS bundle for `/api/v2/` XHR calls.
39
+
40
+ ### HTML Parsing
41
+ - All parsing uses BeautifulSoup with `html.parser`
42
+ - Two distinct image patterns exist in the DOM:
43
+ - `img.feed-image-thumbnail` — gallery/attached images (outside description div)
44
+ - `<img>` inside `.description` div — inline embedded images
45
+ - S3/CloudFront download links carry original filenames in `response-content-disposition` query params
46
+ - URL deduplication via `_url_path_key()` prevents returning the same image as both thumbnail and full-size
47
+
48
+ ### Response Formats
49
+ - **Structured JSON** (`-> dict`): `list_schools`, `get_calendar_events`, `get_directory`, `get_student_dashboard` — return dicts that FastMCP serializes as JSON. Better for data-lookup where Claude filters/extracts fields.
50
+ - **Mixed list** (`-> list`): `get_post`, `get_staff_member` — return a list of text + MCP `Image` objects for inline media.
51
+ - **Markdown text** (`-> str`): all other tools — formatted markdown for content-rich responses.
52
+
53
+ ### Inline Content
54
+ - `get_post`: images downloaded as MCP `Image` objects (5 MB per image, 10 MB total cap), PDFs text-extracted via pymupdf
55
+ - `get_staff_member`: profile photo returned as inline `Image`
56
+ - This lets Claude "see" attached calendars, flyers, staff photos etc. without extra tool calls
57
+
58
+ ## Known Gotchas
59
+
60
+ ### Schools Without ICS Calendars
61
+ Some schools don't use the ICS calendar feature. Instead, monthly calendars are posted as **image attachments** in feed posts (e.g. weekly update posts). When `get_calendar_events` returns empty, Claude should:
62
+ 1. Browse feeds looking for posts with calendar-like attachment names or body text mentioning "calendar"
63
+ 2. Open those posts to view the inline calendar images
64
+ 3. Read the calendar image content to answer date questions
65
+
66
+ ### Feed Text: Expanded vs Truncated
67
+ ParentSquare renders both a truncated and expanded (full) version of each post's text in the feed HTML. The expanded version is hidden via `display: none` CSS. The feed parser prefers the expanded version, giving Claude full post text without extra HTTP requests. This is critical — key phrases like "review the attached calendar" or "February Break" are often past the truncation boundary.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ uv run parentsquare-mcp # Run the MCP server
73
+ uv run parentsquare-export-cookies # Bootstrap cookies from browser
74
+ ```
75
+
76
+ ### Adding a New Parser
77
+ 1. Create `parsers/<name>.py` with a `parse_*` function that takes `BeautifulSoup` and returns dataclass(es)
78
+ 2. Add dataclass(es) to `models.py`
79
+ 3. Add the tool in `server.py` using the `@mcp.tool` decorator
80
+ 4. Wire through `_with_mfa_retry` for auth handling
81
+ 5. Add the URL template to `config.py` if needed
82
+
83
+ ### Account Discovery
84
+ Schools, students, and user ID are auto-discovered at runtime from ParentSquare pages (`gon.*` script variables, sidebar student links, and the school switcher AJAX endpoint). School names are fetched via `/api/v2/schools/{id}`. No config file needed.
85
+
86
+ ## Open Improvement Areas
87
+
88
+ - **Feed search**: No keyword search/filter on `get_feeds` — Claude must paginate and scan titles/summaries manually. A search tool or keyword parameter would help.
89
+ - **CloudFront URL expiry**: S3/CloudFront signed URLs expire. Cached attachment URLs from older sessions may 403.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Mohr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: parentsquare-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for ParentSquare school communication platform
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: beautifulsoup4>=4.13.0
9
+ Requires-Dist: icalendar>=6.0.0
10
+ Requires-Dist: mcp>=1.26.0
11
+ Requires-Dist: requests>=2.32.0
12
+ Provides-Extra: pdf
13
+ Requires-Dist: pymupdf>=1.27.0; extra == 'pdf'
@@ -0,0 +1,100 @@
1
+ # ParentSquare MCP Server
2
+
3
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that gives Claude access to [ParentSquare](https://www.parentsquare.com), a school-parent communication platform. Since ParentSquare has no public API, this server scrapes the web interface using saved session cookies.
4
+
5
+ ## Features
6
+
7
+ ### Feed & Posts
8
+ - **`get_feeds`** — Browse paginated school feed with titles, authors, summaries, and attachment names
9
+ - **`get_post`** — Full post details with body text, comments, poll results, signup items, and **inline image/PDF content** (Claude can "see" attached calendars, flyers, etc.)
10
+ - **`get_group_feed`** — Posts from a specific group
11
+
12
+ ### Calendar
13
+ - **`get_calendar_events`** — Events from ICS calendar as structured JSON (title, start/end, location, description)
14
+ - Falls back to guiding Claude to search feed posts for image/PDF calendars when ICS is empty
15
+
16
+ ### Communication
17
+ - **`list_conversations`** / **`get_conversation`** — Read message threads
18
+ - **`get_directory`** — Staff directory as structured JSON (name, role, phone, user_id)
19
+ - **`get_staff_member`** — Full staff details with email, office hours, and **inline profile photo**
20
+
21
+ ### Media & Files
22
+ - **`list_photos`** — Photo gallery with URLs
23
+ - **`list_files`** — Document files
24
+ - **`download_file`** — Download any attachment to local disk
25
+
26
+ ### Participate
27
+ - **`list_signups`** — Sign-up and RSVP posts with progress tracking (e.g. "53/103 Items")
28
+ - **`list_notices`** — Alerts and secure documents
29
+ - **`list_polls`** — Polls with vote counts and winning options
30
+ - **`list_forms`** — Permission slips and signable forms
31
+ - **`list_payments`** — Payment items with prices and summary stats
32
+ - **`list_volunteer_hours`** — Logged volunteer hours with totals
33
+
34
+ ### Groups & Discovery
35
+ - **`list_schools`** — Schools and students as structured JSON
36
+ - **`list_school_features`** — Available sections per school (parsed from sidebar)
37
+ - **`list_groups`** — Groups with member counts, descriptions, and membership status
38
+ - **`list_links`** — Quick-access links (Google Drive, external sites)
39
+
40
+ ### Student
41
+ - **`get_student_dashboard`** — School, grade, classes, and teachers as structured JSON
42
+
43
+ ### Authentication
44
+ - **`submit_mfa_code`** — Complete MFA verification with a 6-digit code
45
+ - Supports MCP elicitation for inline MFA prompts
46
+ - Session cookies persisted to `~/.parentsquare_cookies.json`
47
+ - Credentials loaded from 1Password CLI on session expiry
48
+
49
+ ## Setup
50
+
51
+ ### Prerequisites
52
+ - [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) with a "Parentsquare" item containing `username` and `password` fields
53
+
54
+ ### Install in Claude Code
55
+
56
+ ```bash
57
+ claude mcp add --transport stdio parentsquare -- uvx --from git+https://github.com/thehesiod/psquare-mcp parentsquare-mcp
58
+ ```
59
+
60
+ To enable PDF text extraction for post attachments (optional, AGPL-3.0 licensed):
61
+
62
+ ```bash
63
+ claude mcp add --transport stdio parentsquare -- uvx --from "psquare-mcp[pdf] @ git+https://github.com/thehesiod/psquare-mcp" parentsquare-mcp
64
+ ```
65
+
66
+ ### That's It
67
+
68
+ No further configuration needed. The server **auto-discovers** your schools, students, and user ID from ParentSquare on first use. Authentication is handled automatically via 1Password CLI — when the session expires, the server loads your credentials from 1Password and re-authenticates (including MFA if needed).
69
+
70
+ ## How It Works
71
+
72
+ The server uses `requests` + `BeautifulSoup` to scrape ParentSquare's server-rendered HTML pages. Each tool follows the pattern:
73
+
74
+ 1. **Fetch** the HTML page via `PSClient.get_page()` or JSON via `PSClient.get_json()` (auto-relogins on session expiry)
75
+ 2. **Parse** with a dedicated parser in `parsers/` that extracts structured data into dataclasses
76
+ 3. **Return** results as either structured JSON dicts (for data-lookup tools) or markdown text (for content-rich tools)
77
+
78
+ Data-lookup tools (`list_schools`, `get_directory`, `get_calendar_events`, `get_student_dashboard`, `get_staff_member`) return structured JSON for easy programmatic access. Content tools (`get_post`, `get_feeds`, `get_conversation`) return markdown.
79
+
80
+ On first use, the server auto-discovers your schools, students, and user ID from ParentSquare (no config file needed).
81
+
82
+ For `get_post`, image attachments are downloaded and returned as MCP `Image` objects (so Claude can see them), and PDF attachments have their text extracted via pymupdf. `get_staff_member` also returns inline profile photos.
83
+
84
+ Groups use a GraphQL endpoint (`/graphql`) instead of HTML scraping. The directory and staff details use the internal `/api/v2/` JSON:API.
85
+
86
+ ## Dependencies
87
+
88
+ | Package | Purpose | License |
89
+ |---------|---------|---------|
90
+ | `mcp` | Model Context Protocol SDK | MIT |
91
+ | `requests` | HTTP client | Apache 2.0 |
92
+ | `beautifulsoup4` | HTML parsing | MIT |
93
+ | `icalendar` | ICS calendar parsing | BSD |
94
+ | `pymupdf` | PDF text extraction (optional) | AGPL-3.0 |
95
+
96
+ ## License
97
+
98
+ MIT — see [LICENSE](LICENSE). Note: the optional `pymupdf` dependency is AGPL-3.0 licensed.
99
+
100
+ <!-- mcp-name: io.github.thehesiod/psquare -->
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "parentsquare-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for ParentSquare school communication platform"
5
+ license = "MIT"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "mcp>=1.26.0",
9
+ "requests>=2.32.0",
10
+ "beautifulsoup4>=4.13.0",
11
+ "icalendar>=6.0.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ pdf = ["pymupdf>=1.27.0"]
16
+
17
+ [project.scripts]
18
+ parentsquare-mcp = "parentsquare_mcp.server:main"
19
+ parentsquare-export-cookies = "parentsquare_mcp.export_cookies:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from urllib.parse import parse_qs, urlparse
10
+
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+
14
+ from parentsquare_mcp.config import BASE_URL
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ COOKIE_FILE = Path(os.environ.get("PS_COOKIE_FILE", "~/.parentsquare_cookies.json")).expanduser()
19
+ MFA_STATE_FILE = COOKIE_FILE.with_name(".parentsquare_mfa_state.json")
20
+
21
+
22
+ @dataclass
23
+ class MFAState:
24
+ """Stores state needed to complete MFA verification."""
25
+
26
+ contact_value: str # masked email/phone from redirect
27
+ contact_method: str # "email" or "phone"
28
+ email: str # the actual email used to login
29
+ csrf_token: str = "" # CSRF token from the MFA page — required for /mfa/submit
30
+
31
+ def save(self) -> None:
32
+ """Persist MFA state to disk so it survives server restarts."""
33
+ MFA_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ MFA_STATE_FILE.write_text(json.dumps({
35
+ "contact_value": self.contact_value,
36
+ "contact_method": self.contact_method,
37
+ "email": self.email,
38
+ "csrf_token": self.csrf_token,
39
+ }))
40
+ logger.info(f"Saved MFA state to {MFA_STATE_FILE}")
41
+
42
+ @classmethod
43
+ def load(cls) -> MFAState | None:
44
+ """Load persisted MFA state from disk. Returns None if not found."""
45
+ if not MFA_STATE_FILE.exists():
46
+ return None
47
+ try:
48
+ data = json.loads(MFA_STATE_FILE.read_text())
49
+ return cls(
50
+ contact_value=data["contact_value"],
51
+ contact_method=data["contact_method"],
52
+ email=data["email"],
53
+ csrf_token=data.get("csrf_token", ""),
54
+ )
55
+ except (json.JSONDecodeError, KeyError) as e:
56
+ logger.warning(f"Failed to load MFA state: {e}")
57
+ return None
58
+
59
+ @staticmethod
60
+ def clear() -> None:
61
+ """Remove persisted MFA state file."""
62
+ if MFA_STATE_FILE.exists():
63
+ MFA_STATE_FILE.unlink()
64
+ logger.info("Cleared MFA state file")
65
+
66
+
67
+ class MFARequiredError(Exception):
68
+ """Raised when login succeeds but MFA verification is needed."""
69
+
70
+ def __init__(self, mfa_state: MFAState):
71
+ self.mfa_state = mfa_state
72
+ masked = mfa_state.contact_value
73
+ method = mfa_state.contact_method
74
+ super().__init__(
75
+ f"MFA verification required. A 6-digit code was sent to your {method} ({masked}). "
76
+ f"Use the submit_mfa_code tool to provide the code."
77
+ )
78
+
79
+
80
+ def load_credentials_from_1password() -> tuple[str, str]:
81
+ """Load ParentSquare credentials from 1Password via CLI."""
82
+ result = subprocess.run(
83
+ ["op", "item", "get", "Parentsquare", "--fields", "label=username,label=password", "--format", "json"],
84
+ capture_output=True,
85
+ text=True,
86
+ check=True,
87
+ )
88
+ fields = json.loads(result.stdout)
89
+ creds: dict[str, str] = {}
90
+ for field_obj in fields:
91
+ label = field_obj.get("label", "")
92
+ value = field_obj.get("value", "")
93
+ if label in ("username", "password"):
94
+ creds[label] = value
95
+ if "username" not in creds or "password" not in creds:
96
+ raise RuntimeError(f"Could not find username/password in 1Password. Got fields: {list(creds.keys())}")
97
+ return creds["username"], creds["password"]
98
+
99
+
100
+ def save_cookies(session: requests.Session) -> None:
101
+ """Persist session cookies to disk for reuse across server restarts."""
102
+ cookies = {}
103
+ for cookie in session.cookies:
104
+ cookies[cookie.name] = {
105
+ "value": cookie.value,
106
+ "domain": cookie.domain,
107
+ "path": cookie.path,
108
+ "secure": cookie.secure,
109
+ }
110
+ COOKIE_FILE.write_text(json.dumps(cookies, indent=2))
111
+ logger.info(f"Saved {len(cookies)} cookies to {COOKIE_FILE}")
112
+
113
+
114
+ def load_cookies(session: requests.Session) -> bool:
115
+ """Load previously saved cookies. Returns True if cookies were loaded."""
116
+ if not COOKIE_FILE.exists():
117
+ return False
118
+ try:
119
+ cookies = json.loads(COOKIE_FILE.read_text())
120
+ for name, data in cookies.items():
121
+ session.cookies.set(
122
+ name,
123
+ data["value"],
124
+ domain=data.get("domain", ".parentsquare.com"),
125
+ path=data.get("path", "/"),
126
+ )
127
+ logger.info(f"Loaded {len(cookies)} cookies from {COOKIE_FILE}")
128
+ return True
129
+ except (json.JSONDecodeError, KeyError) as e:
130
+ logger.warning(f"Failed to load cookies: {e}")
131
+ return False
132
+
133
+
134
+ def extract_csrf_token(session: requests.Session) -> str:
135
+ """GET /signin and extract CSRF token from <meta name='csrf-token'> tag."""
136
+ resp = session.get(f"{BASE_URL}/signin")
137
+ resp.raise_for_status()
138
+ soup = BeautifulSoup(resp.text, "html.parser")
139
+ meta = soup.find("meta", attrs={"name": "csrf-token"})
140
+ if not meta or not meta.get("content"):
141
+ raise RuntimeError("Could not find CSRF token on signin page")
142
+ return meta["content"]
143
+
144
+
145
+ def login(session: requests.Session, email: str, password: str) -> None:
146
+ """Perform full login flow: extract CSRF, POST credentials, verify success.
147
+
148
+ If 2FA is required, raises MFARequiredError with state needed to complete
149
+ verification via submit_mfa_code().
150
+ """
151
+ logger.info("Logging in to ParentSquare...")
152
+ csrf = extract_csrf_token(session)
153
+ resp = session.post(
154
+ f"{BASE_URL}/sessions",
155
+ data={
156
+ "utf8": "✓",
157
+ "authenticity_token": csrf,
158
+ "session[email]": email,
159
+ "session[password]": password,
160
+ "commit": "Sign In",
161
+ },
162
+ headers={
163
+ "Content-Type": "application/x-www-form-urlencoded",
164
+ "Referer": f"{BASE_URL}/signin",
165
+ },
166
+ allow_redirects=True,
167
+ )
168
+
169
+ # After successful login, should redirect away from /signin
170
+ if "mfa_required" in resp.url:
171
+ # Parse MFA redirect params: /signin?mfa_required=true&contact_value=...&contact_method=email
172
+ parsed = urlparse(resp.url)
173
+ params = parse_qs(parsed.query)
174
+ contact_value = params.get("contact_value", [""])[0]
175
+ contact_method = params.get("contact_method", ["email"])[0]
176
+ logger.info(f"MFA required — code sent to {contact_method}: {contact_value}")
177
+
178
+ # Extract CSRF token from the MFA page — Rails requires this for /mfa/submit
179
+ mfa_soup = BeautifulSoup(resp.text, "html.parser")
180
+ csrf_meta = mfa_soup.find("meta", attrs={"name": "csrf-token"})
181
+ csrf_token = csrf_meta["content"] if csrf_meta and csrf_meta.get("content") else ""
182
+ if csrf_token:
183
+ logger.info("Captured CSRF token from MFA page")
184
+ else:
185
+ logger.warning("No CSRF token found on MFA page")
186
+
187
+ # Save cookies from login attempt — needed for /mfa/submit
188
+ save_cookies(session)
189
+
190
+ mfa_state = MFAState(
191
+ contact_value=contact_value,
192
+ contact_method=contact_method,
193
+ email=email,
194
+ csrf_token=csrf_token,
195
+ )
196
+ # Persist MFA state so it survives server restarts
197
+ mfa_state.save()
198
+ raise MFARequiredError(mfa_state)
199
+
200
+ if "/signin" in resp.url:
201
+ raise RuntimeError("Login failed — redirected back to signin. Check credentials.")
202
+
203
+ logger.info("Successfully logged in to ParentSquare")
204
+ save_cookies(session)
205
+
206
+
207
+ def submit_mfa(session: requests.Session, mfa_state: MFAState, code: str) -> None:
208
+ """Submit a 6-digit MFA verification code to complete login.
209
+
210
+ Args:
211
+ session: The requests session (must already have cookies from login attempt)
212
+ mfa_state: MFA state from the login redirect
213
+ code: The 6-digit verification code from email/phone
214
+ """
215
+ logger.info("Submitting MFA verification code...")
216
+ payload: dict[str, str] = {
217
+ "data_value": mfa_state.contact_value,
218
+ "code": code,
219
+ }
220
+ if mfa_state.contact_method == "email":
221
+ payload["email"] = mfa_state.email
222
+ else:
223
+ payload["phone"] = mfa_state.contact_value
224
+
225
+ headers = {
226
+ "Content-Type": "application/json",
227
+ "Accept": "application/json",
228
+ "X-Requested-With": "XMLHttpRequest",
229
+ "Referer": f"{BASE_URL}/signin",
230
+ }
231
+ if mfa_state.csrf_token:
232
+ headers["X-CSRF-Token"] = mfa_state.csrf_token
233
+
234
+ resp = session.post(
235
+ f"{BASE_URL}/mfa/submit",
236
+ json=payload,
237
+ headers=headers,
238
+ )
239
+
240
+ if resp.status_code == 401:
241
+ raise RuntimeError(
242
+ "MFA verification failed — invalid or expired code. "
243
+ "Check your email for the latest code and try submit_mfa_code again."
244
+ )
245
+ resp.raise_for_status()
246
+
247
+ data = resp.json()
248
+ redirect_url = data.get("redirect_url", "")
249
+ if redirect_url:
250
+ # Follow the redirect to establish the full session
251
+ if redirect_url.startswith("/"):
252
+ redirect_url = f"{BASE_URL}{redirect_url}"
253
+ session.get(redirect_url)
254
+
255
+ logger.info("MFA verification successful")
256
+ save_cookies(session)
257
+ MFAState.clear()
258
+
259
+
260
+ def is_session_valid(session: requests.Session) -> bool:
261
+ """Quick check: does a request to the root page redirect to /signin?"""
262
+ resp = session.get(f"{BASE_URL}/", allow_redirects=False)
263
+ if resp.status_code == 302:
264
+ location = resp.headers.get("Location", "")
265
+ return "/signin" not in location
266
+ return resp.status_code == 200
267
+
268
+
269
+ def ensure_session(session: requests.Session, email: str, password: str) -> None:
270
+ """Re-login if session has expired."""
271
+ if not is_session_valid(session):
272
+ logger.info("Session expired, re-authenticating...")
273
+ login(session, email, password)