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.
- parentsquare_mcp-0.1.0/.gitignore +12 -0
- parentsquare_mcp-0.1.0/CLAUDE.md +89 -0
- parentsquare_mcp-0.1.0/LICENSE +21 -0
- parentsquare_mcp-0.1.0/PKG-INFO +13 -0
- parentsquare_mcp-0.1.0/README.md +100 -0
- parentsquare_mcp-0.1.0/pyproject.toml +23 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/__init__.py +0 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/auth.py +273 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/client.py +248 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/config.py +29 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/download.py +35 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/export_cookies.py +85 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/models.py +197 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/__init__.py +0 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/calendar.py +47 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/directory.py +79 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/feeds.py +422 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/groups.py +80 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/links.py +39 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/media.py +130 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/messages.py +169 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/notices.py +61 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/payments.py +99 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/polls.py +106 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/schools.py +41 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/students.py +89 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/parsers/volunteer.py +49 -0
- parentsquare_mcp-0.1.0/src/parentsquare_mcp/server.py +1135 -0
- parentsquare_mcp-0.1.0/uv.lock +780 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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)
|