powerschool-mcp
README.md
# powerschool-mcp
A **read-only** [MCP](https://modelcontextprotocol.io) server that lets Claude read a student's PowerSchool guardian portal: current grades, assignments and missing work, attendance, schedule, grade history, teacher comments, plus a few computed extras (what-if grades and an estimated GPA).
It cannot change anything in PowerSchool. Every request goes through a strict allowlist (see [Read-only allowlist](#read-only-allowlist)).
## How it works
PowerSchool sign-in is district SSO, which a server can't do. So login happens on your PC:
1. `ps-login` opens a real browser, you sign in, and it extracts only the two session cookies (`JSESSIONID`, `psaid`).
2. It either saves them to `.auth/session.json` (local stdio mode) or POSTs them to your deployed server.
3. The server uses plain HTTPS requests with those cookies and keeps the session alive with a page request every 10 minutes (idle timeout is about 40 minutes).
When the session dies, tools return `session_expired` and tell you to run `ps-login` again.
## Tools
| Tool | What it does |
|---|---|
| `session_status` | Logged in or not, seconds of idle time left, active student. Does not extend the session. |
| `list_students` | Students on the account and the index to pass as `student`. |
| `get_grades` | Current letter grade and percent per class (home page), absences, tardies. |
| `get_class_scores` | Assignments for one class: score, points, percent, due date, category, flags (missing, late, exempt, collected). `course` matches by fuzzy name or period. |
| `get_missing` | All missing or late assignments across classes. |
| `get_attendance` | Absences and tardies from the attendance plugin, with an HTML fallback. Optional `from` / `to`. |
| `get_schedule` | Weekly bell schedule plus the period matrix (teacher, room, term). |
| `get_grade_history` | Stored final grades for every term on file. |
| `get_teacher_comments` | Teacher comments per class for a term. |
| `estimate_gpa` | **Estimated** GPA on a 4.0 scale from grade history; `weighted=true` adds +1 for course names containing AP or Honors. Always labelled as an estimate, not the official GPA. |
| `what_if_grade` | Recomputes a class percent from the real assignment data after hypothetical scores. Points-based; states its assumptions. |
Every tool takes an optional `student` (name or index). It defaults to `PS_DEFAULT_STUDENT`, then to the student PowerSchool is showing.
> **Multiple students:** grade pages only ever show the student PowerSchool has active, and the portal's student-switch mechanism could not be found on the account used for recon (see [docs/auth-notes.md](docs/auth-notes.md)). Asking for a different student returns a clear error instead of guessing.
## Setup (local, stdio, Claude Desktop)
Needs Node 20+.
```powershell
npm install
npx playwright install chromium # one-time, for ps-login only
copy .env.example .env # set PS_BASE_URL, optionally PS_DEFAULT_STUDENT
npm run build
npm run login # opens a browser, sign in via SSO; saves .auth/session.json
```
Claude Desktop config (Settings -> Developer -> Edit Config), then restart Claude Desktop fully:
```json
{
"mcpServers": {
"powerschool": {
"command": "node",
"args": ["C:\\path\\to\\powerschoolMCP\\dist\\main.js"],
"env": {
"PS_BASE_URL": "https://ps.yourdistrict.org",
"PS_DEFAULT_STUDENT": ""
}
}
}
}
```
The stdio server reads `.auth/session.json` relative to its working directory. Claude Desktop starts servers from its own directory, so pass an absolute path with `"--session-file"`:
```json
"args": ["C:\\path\\to\\powerschoolMCP\\dist\\main.js", "--session-file", "C:\\path\\to\\powerschoolMCP\\.auth\\session.json"]
```
It re-reads the file whenever `ps-login --local` rewrites it, so you don't need to restart anything after logging in again.
## Deploy (Coolify / Docker)
```bash
openssl rand -hex 24 # run twice: one for MCP_SECRET, one for SESSION_PUSH_SECRET
```
1. Coolify: **New Resource -> Dockerfile** from this repo (or any Docker host). No Playwright in the image.
2. Environment variables:
| Variable | Value |
|---|---|
| `PS_BASE_URL` | `https://ps.yourdistrict.org` |
| `MCP_SECRET` | random, at least 24 chars. Becomes the MCP URL path. |
| `SESSION_PUSH_SECRET` | random, at least 24 chars, different from `MCP_SECRET` |
| `PS_DEFAULT_STUDENT` | optional |
| `PORT` | `3417` (default) |
3. The container listens on port **3417**. Health check: `GET /health` always returns `200`, even with no PowerSchool session, so a logged-out server never fails its deploy.
4. Expose it on a subdomain. Don't put it behind Cloudflare Access or Basic Auth (claude.ai can't pass those); the secret path is the protection.
5. Log in from your PC: `npm run login:server -- --server https://ps-mcp.yourdomain.com --secret <SESSION_PUSH_SECRET>` (or set `PUBLIC_URL` and `SESSION_PUSH_SECRET` in `.env` and run `npm run login:server`).
6. In claude.ai: Settings -> Connectors -> add custom connector -> `https://ps-mcp.yourdomain.com/mcp/<MCP_SECRET>`.
The session lives in server memory only. If the container restarts, run `ps-login` again.
## ps-login
```
npx tsx src/cli/ps-login.ts [--local] [--server https://...] [--secret ...]
```
- Opens headed Chromium with the persistent `.auth/profile` profile and waits up to 5 minutes for `/guardian/home.html`.
- Extracts only `JSESSIONID` and `psaid`; cookie values are never printed.
- `--local` writes `.auth/session.json`. `--server` POSTs `{jsessionid, psaid}` to `<server>/session` with the `X-Session-Secret` header; the server checks them against PowerSchool (`seconds-remaining`) before accepting and answers ok or fail.
- During login the browser only reaches PowerSchool's sign-in pages; afterwards the same read-only allowlist applies.
`npm run login` is `--local`; `npm run login:server` pushes to a server.
## Configuration
| Env var | Flag | Default |
|---|---|---|
| `PS_BASE_URL` | `--url` | (required) |
| `PS_DEFAULT_STUDENT` | `--student` | the active student |
| `MCP_SECRET` | `--secret` | required for `--http`, 24+ chars |
| `SESSION_PUSH_SECRET` | `--push-secret` | required for `--http`, 24+ chars |
| `PORT` / `HOST` | `--port` / `--host` | 3417 / 127.0.0.1 (the Docker image sets `HOST=0.0.0.0`) |
| `PS_KEEPALIVE_MIN` | `--keepalive-min` | 10 (0 disables) |
| `PS_CACHE_SEC` | `--cache-sec` | 300 |
| `PUBLIC_URL` | | used by `ps-login --server` when `--server` is omitted |
## Security notes
- **Secrets only in `.env` and `.auth/`**, both gitignored. Nothing in this repo contains cookies, tokens, grades, names or student identifiers; test fixtures are synthetic.
- **Remote auth:** the MCP endpoint is `/mcp/<MCP_SECRET>` (same approach as canvas-mcp). `POST /session` needs the `X-Session-Secret` header. Both compare in constant time, both are rate-limited after repeated failures, and the two secrets must differ. `/health` is the only unauthenticated route and reveals nothing.
- Treat the MCP URL like a password. Anyone with it can read the student's grades. If it leaks, change `MCP_SECRET` and redeploy.
- Cookies are held in memory on the server and never logged or returned by any tool. `list_students` hides student identifiers.
- Run it over HTTPS only. `ps-login` refuses a non-HTTPS `--server` unless it is localhost.
- The session cookies are as powerful as your portal login: keep `.auth/` private and don't share it.
- Use it for the student's own family account. Respect your district's acceptable-use policy.
## Read-only allowlist
Every PowerSchool request, from the server, from `ps-login` and from the tests, goes through `assertAllowed()` in [src/ps/allowlist.ts](src/ps/allowlist.ts). It is an allowlist, not a blocklist: anything not listed throws **before any network call**.
Allowed:
- `GET` of `/guardian/` `home`, `scores`, `termgrades`, `teachercomments`, `myschedule*`, `attendance*` pages (home also serves as the keep-alive request)
- `POST /ws/xte/assignment/lookup` (the read-only lookup the portal's own score pages send)
- `GET /ws/xte/section/id/<n>/setting_enabled/`
- `GET /ws/session/seconds-remaining` (reports idle time, does not extend it)
- `POST /guardian/mba_attendance_monitor/json/*.json` (attendance plugin reads)
Always rejected, even if a path would otherwise match: any URL (path, query or fragment, also URL-encoded) containing `logoff`, `logout`, `signout`, `signoff`, `singlelogout` or `password`, and any `ac=` query parameter. Requests to other hosts and credentials in URLs are rejected too. This exists because the Phase 1 crawl once followed `home.html?ac=logoff` and ended its own session. `PUT /ws/session/last-hit` is deliberately not on the list.
## Development
```bash
npm run dev # stdio, from source
npm run dev:http # HTTP, from source
npm run typecheck
npm test # vitest: allowlist, parsers, session expiry, MCP over HTTP
```
Tests use synthetic fixtures in `test/fixtures/`. Extra parser tests run against `recon-output/raw` when that folder exists and are skipped otherwise.
## Limitations
- HTML pages are scraped, so a PowerSchool template change can break a parser.
- Category weights are not exposed by PowerSchool; `what_if_grade` is points-based unless you pass weights.
- Stored-only terms (E1, E2, closed S2) usually have no assignment data.
- Only the active student's grade pages can be read (see above).
- The attendance plugin is third-party and district-installed; the HTML grid is used if it fails.
MIT licensed.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues