schoology-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@schoology-mcpshow my current grades"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Schoology MCP
An MCP server for PAUSD Schoology (https://pausd.schoology.com). It drives
a headless browser that logs in automatically through the ClassLink portal
and exposes tools to fetch grades, upcoming assignments and recent posts.
It builds on the scraping approach from dajun666/schoology-get (Playwright +
BeautifulSoup), and adds the missing piece: automated login — no more
hand-exporting cookies.json.
How login works
PAUSD students reach Schoology through ClassLink. The server:
Opens
https://login.classlink.com/my/pausdand fills the student ID + password.Clicks the Schoology tile, which performs SAML SSO into Schoology.
Saves the session to
storage_state.jsonso later runs skip the login until it expires (then it logs in again automatically).
Related MCP server: canvas-parent-mcp
Setup
python3 -m venv .venv
source .venv/bin/activate # do this in every new shell
pip install -r requirements.txt
playwright install chromium
cp .env.example .env
# edit .env: set SCHOOLOGY_USERNAME (your 8-digit student ID)
# store the password in the OS keychain -- not in any file:
python scripts/set_credentials.pyThe python … commands below assume this .venv is activated. The MCP server
itself runs outside any shell, so it is registered with the venv's Python by
absolute path — see Register with Claude Code.
Credentials & password storage
The password is never stored in a plaintext file. set_credentials.py
saves it to a keyring (encrypted at rest) via the keyring library, and the
server reads it from there at runtime. The backend is chosen automatically per
platform:
Platform | Backend | Setup |
macOS | Keychain | works out of the box |
Windows desktop | Credential Manager | works out of the box |
Linux desktop | Secret Service (GNOME Keyring / KWallet) | install |
Headless Linux (server, WSL, Docker, cron) | AES-encrypted file ( | set a master passphrase — see below |
Update it later: re-run
python scripts/set_credentials.py.Remove it:
python scripts/set_credentials.py --delete.macOS/Windows may ask once to allow access to the stored item — choose Always Allow so the unattended server isn't blocked.
SCHOOLOGY_USERNAME(the student ID) stays in.env; it is the keyring lookup key, not a secret.Fallback: if you set the
SCHOOLOGY_PASSWORDenvironment variable, it is used instead of the keyring (handy for throwaway/CI use).
Headless Linux (no OS keychain)
There is no OS secret store on a headless box, so use the encrypted-file backend. A master passphrase both selects it and unlocks it:
export SCHOOLOGY_KEYRING_PASS='your-master-passphrase'
python scripts/set_credentials.py # stores to ~/.local/share/schoology-mcp/credentials.cfg (AES)Then run the server with the same SCHOOLOGY_KEYRING_PASS in its
environment — e.g. a systemd EnvironmentFile (perms 600) or the MCP client's
env block. Without the passphrase the server cannot decrypt the file and will
fail with a clear error rather than falling back to plaintext.
Caveat: a keyring protects the password at rest and keeps it out of dotfiles,
backups and git — but any process running as your user can still read it (and
for the encrypted file, anyone who has both the file and the passphrase). It is
strictly better than a plaintext .env, not a sandbox.
Verify login (do this first)
python scripts/login_check.py --show-browserThis logs in, saves storage_state.json, and dumps page HTML into dumps/.
All four parsers are verified against a real PAUSD account (grades, courses,
upcoming assignments, recent posts). If Schoology changes its markup later,
re-run this and re-check the selectors in schoology_mcp/parsers.py against
the fresh dumps/.
Tools
Tool | Description |
| Current grades for every course (periods, categories, assignments). |
| Enrolled courses. |
| Upcoming / due-soon assignments and events. Pass |
| Full details (title, course, due, description, attachments) of one assignment. Takes a URL, |
| Latest posts from the activity feed, with embedded images downloaded. |
| All materials in a course (folders, assignments, docs, pages, links). |
| Open one material by URL; dispatches on type. Google Docs links are exported to Markdown (see below). |
| Inbox / sent list: subject, sender, date, body preview. Does not open anything. |
| Full text of one thread. Marks it read — see below. |
| School calendar (holidays, no-school days, deadlines) via the iCal feed. |
| What changed since last time. See Reminders below. |
| Download any Drive or Schoology link; size-checked before transferring. |
| Is the scraper working, or is the account just empty? |
Infinite Campus: schedule and room numbers (optional, off by default)
Schoology knows the coursework; the district SIS knows where and when a class
meets. Set CAMPUS_ENABLED=true to add get_schedule:
period time room teacher course
1 09:00-09:50 A-12 Doe, Jane Intro to Engineering
2 09:57-10:42 B-07 Roe, Richard Studio Art
3 10:57-11:42 C-03 Poe, Pat BiologyIt rides the same ClassLink SSO — a different app tile, no extra credentials. The student portal is a single-page app, so this reads its JSON API rather than scraping rendered HTML: structured data, and no CSS selectors to go stale.
Two things the portal models that the output flattens for you:
Bell schedules. Each class carries a placement per schedule variant —
Full(the regular day) plusM/T/W/R/Fand one-off dates. Expanding all of them turns 13 classes into 54 near-duplicate rows, soFullis used by default. Passschedule="R"to see a block day, where the same class can move by hours — a mid-morning period becoming an early-afternoon block.Terms. A year-long course is placed in both semesters, so pass
term="S1"unless you want each class twice.
Off by default because the host and the tile name are district-specific; when disabled the tool is not registered at all, so it costs nothing. Only the roster endpoint is read — the portal also exposes the student's legal name, district number and state ID, and nothing here touches it.
Images in feed posts
School notices are routinely posted as a picture with no words at all. Six of the ten posts in a real feed sample carried an image, and five of those six had no text whatsoever — to a text-only reader they were blank.
get_recent_posts therefore returns an images list per post and downloads
each one by default, so every picture comes back with a path you can open:
{"author": "Student Activities Office", "text": "",
"images": [{"url": "https://pausd.schoology.com/system/files/.../notice.gif",
"path": "/tmp/schoology-mcp-materials/schoology/notice_6a05f62e.gif",
"bytes": 2250950, "content_type": "image/gif"}]}Three kinds of <img> appear in a post and only one is content: profile
avatars are ignored, emoji (served as images by Google Fonts) are folded back
into the text as characters, and the actual embedded picture is fetched with
the session cookie.
Images are cached by URL, so re-reading the feed costs nothing — the sample
above is ~11 MB the first time and 0 ms after. Pass download_images=False for
URLs only. check_updates never downloads them; scheduled runs stay light.
Google Docs materials
Teachers frequently attach a Google Doc rather than uploading a file, so a
Schoology "link" material is often the actual content. get_material exports
those to Markdown and returns them under google_doc:
{"type": "link", "title": "...", "url": "https://docs.google.com/document/d/...",
"google_doc": {"content": "# ...", "images": [...], "image_count": 15,
"path": "/tmp/schoology-mcp-materials/<id>/<title>.md"}}Embedded images are written out as real image files and the Markdown points at them, so a reader can actually open them. This matters more than it sounds: Google inlines every picture as a base64 data URI, and on a real document that was 157,879 characters of unreadable text — 1,695 after extraction, with 15 usable PNGs alongside.
Requires the project-local rclone setup (tools/rclone, tools/rclone.conf,
Drive remotes scoped read-only). Exports go to a temp directory
(SCHOOLOGY_EXPORT_DIR, default <tmp>/schoology-mcp-materials/), cached per
document and pruned after 24h. Pass export_google_docs=False to skip it.
Downloading anything else
Teachers also paste Drive links inside assignment descriptions, where they are
not attachments and nothing auto-exports them. Rather than have the server
chase every link it finds, download_file lets the agent decide:
download_file("https://drive.google.com/file/d/<id>/view")
→ {"filename": "Ch7-Photosynthesis-Lab-Demo.mp4", "size_mb": 118.4,
"declined": "too_large",
"error": "118.4 MB, over the 25 MB limit. Not downloaded -- raise max_mb to fetch it."}For Drive, size is checked before any transfer. rclone backend copyid --dry-run reports the name and size while writing nothing, which is the only
pre-flight check available — --max-size is silently ignored by copyid. A
native Google Doc reports no size at all (it has none until exported), and that
absence is how the two are told apart.
For Schoology-hosted attachments there is no equivalent: Playwright has no
streaming response, so the body is already buffered by the time Content-Length
can be read. There max_mb keeps an oversized file out of the cache rather than
off the wire.
Accepts Google Drive links and Schoology-hosted attachments (fetched with the logged-in session). Not a general web downloader.
What gets exported, by link shape:
Link | Result |
| Markdown + extracted images |
|
|
|
|
| reported, not downloaded |
| not exportable — a Form is not a file |
Uploaded files are skipped by default because their size is unknown until the
transfer is already happening — the first one encountered in a real assignment
was a 110 MB video — and rclone's --max-size is ignored by backend copyid,
so there is no way to abort mid-flight. Pass allow_binary=True when you
actually want one.
Documents whose owner disabled "viewers can download, print, copy" cannot be
exported by any API; those report google_doc.export_error instead of failing
the call. For bulk export, use scripts/backup_gdocs.sh /
scripts/get_gdoc.sh instead.
Read-only
Every tool issues reads. There is no send-message, post or submit endpoint, and none is planned for now.
One caveat worth stating plainly: get_message_thread marks that message read
on Schoology's side, exactly as if the student had clicked it. It is the only
server-visible side effect in the whole MCP. check_updates never calls it, so
scheduled checks never touch anyone's unread badges. Use get_messages to
browse; use get_message_thread only when you actually need the full text.
Reminders (grades, assignments, messages)
check_updates answers "what changed since last time?" — the basis for an
agent that tells you when a grade gets posted.
The server is stateless. It stores nothing about previous runs. You pass in
the baseline from the last call, and it hands you a fresh one:
result = check_updates(baseline=<your stored baseline>)
# -> {"alerts": [...], "changed": ["grades"], "baseline": {...}, ...}The baseline is ~5KB of ids and content hashes. Grades, assignments and messages
are tracked per item, so an alert can name the assignment that got a score. The
calendar is tracked as one whole-feed digest — 709 events would otherwise
dominate the baseline. Pass detailed_sources=["calendar"] if you need
per-event precision.
Keeping the LLM out of the quiet path (recommended)
Most checks find nothing — grades post in bursts, not continuously. Waking an agent to discover that costs ~3k tokens every time, and the agent adds nothing on a run with no news.
scripts/watch_once.py does the polling without a model. It prints nothing and
exits 0 when there is no change, and only on a real change does it emit a
payload and exit 10:
python scripts/watch_once.py --json > /tmp/sgy.json
[ $? -eq 10 ] && <wake your agent with the contents of /tmp/sgy.json>Quiet run | Run with news | |
Agent woken | no | yes |
Tokens | 0 | one turn, alerts already in hand |
Wall clock | ~25s (4 sources) / ~12s ( | same |
Because quiet runs cost no tokens, you can poll far more often than you could
afford to wake an agent — the only real budget is page loads against Schoology.
The emitted payload already contains the alerts, so the woken agent does not
need to call check_updates again to find out what happened.
It is not a reimplementation: it calls the same check_updates and plays the
caller role described below, just holding the baseline in a file
(~/.local/share/schoology-mcp/watch-state.json, override with --state or
SCHOOLOGY_WATCH_STATE) instead of in a conversation. The MCP server stays
stateless — that file belongs to the caller, which here is the script.
Exit codes: 0 quiet, 10 changes on stdout, 1 error (state left untouched,
nothing reported). Use --dry-run to report without committing state.
Use the agent-driven flow below instead when you want the agent to judge what matters ("is this worth interrupting me for?") on every check rather than only after the script has already found something.
Wiring it to a schedule
Any scheduler works (cron, launchd, a Claude Code scheduled task). The agent it invokes should do exactly this:
Read your stored baseline, call
check_updates(baseline=...).Notify from
alerts.Then store the returned
baseline.
Storing after notifying is deliberate: a crash in between costs you a duplicate notification, never a missed one. Because change detection is content hashing rather than a timestamp cursor, replaying the same baseline twice produces an identical result, and a missed run just makes the next diff bigger. Nothing can slip between two runs.
Suggested layout for the agent's own memory:
File | Contents | Written |
| the | every run |
| consecutive-failure counter | on error |
Registering with an external agent (OpenClaw shown)
openclaw mcp add schoology \
--command /path/to/schoology-mcp/.venv/bin/python \
--arg /path/to/schoology-mcp/server.py \
--env SCHOOLOGY_USERNAME=950XXXXX \
--env SCHOOLOGY_KEEPALIVE=false \
--include 'check_updates,get_health,get_messages,get_grades,get_calendar_events,get_material,download_file'The tool whitelist is doing two jobs. It keeps get_message_thread out of reach
of an unattended agent — that tool marks mail read, the one server-visible side
effect here — and it holds down cost, since every listed tool's schema is
re-read on every single run: 7 tools is ~2,110 tokens against ~3,170 for all 13.
Note that get_material normally takes a URL discovered via
get_course_materials. Alerts already carry url, so the whitelist above is
enough to follow up on something that changed; add get_course_materials
(+~230 tokens/run) only if the agent should browse a course on its own.
Also note get_material can shell out to rclone and write to the export cache.
That is fine on demand, but it means a scheduled agent that decides to open a
material will spawn a subprocess unattended. check_updates never does.
What it will and will not wake you for
Reports: a score posted or changed, a new assignment, a new message, a calendar change. A teacher fixing a typo in an assignment title is classified as
metadataand raises no alert.Stays silent: the first run (no baseline means nothing can be "new"); any source that failed to scrape; assignments or events simply expiring off a list.
If a lot changes at once (new grading period, or a partly broken scrape) you get one aggregate alert instead of hundreds.
Failure handling for the calling agent
status: "error"→ do not touch your stored baseline. Stay quiet for the first couple of failures; if it persists, report it and callget_health().status: "suspect"on a source means "parsed 0 items but your baseline had many" — treated as a broken scrape, not as vanished grades. Alerts are suppressed and that source's baseline is echoed back unchanged.Sources you did not request are passed through untouched, so a cheap
sources=["messages"]run will not damage your grades baseline.
Token cost
A quiet run (nothing changed) costs roughly 3.7k tokens, measured:
Component | Tokens |
Tool schemas, 7-tool whitelist | ~2,110 |
Baseline passed in | ~1,235 |
Response | ~340 |
Model output | ~15 |
Two things keep it there. When nothing moved the reply says baseline_unchanged
instead of resending an identical 1.2k-token baseline (so the model never has to
echo it back either), and per-source health collapses to a one-word status
unless you pass verbose=True or something is actually wrong.
Polling frequency is by far the biggest lever — it is pure multiplication:
Schedule | Per day | Per month |
every 30 min | ~177k | ~5.3M |
hourly | ~88k | ~2.7M |
every 2 hours | ~44k | ~1.3M |
hourly, school hours only ( | ~37k | ~1.1M |
Grades post in bursts, not continuously, so hourly during school hours catches essentially everything for a fifth of the cost of 30-minute polling. Restrict the tool whitelist too — every unused tool's schema is re-read on every single run.
All of which is moot if you use watch_once.py above: a quiet run there costs
nothing at all, and these numbers apply only when there is actually something to
report.
Cheaper still: split the schedule. sources=["messages","upcoming"] skips both
the gradebook page and the calendar feed, so run that often and the full check
a few times a day.
Scheduling notes
Set
SCHOOLOGY_KEEPALIVE=falsefor cron-style runs. Each run is a fresh ~30s process, so the 8-minute keep-alive never fires and only competes for the browser lock.Schoology sessions are short, so an hourly job usually pays a full ClassLink login (tens of seconds). If you want frequent polling, run the server long-lived instead so the session stays warm.
Don't poll more often than about every 30 minutes. Grades post in bursts, each check is several headless page loads, and this is an SSO'd student account.
Verifying it
python scripts/check_watch.py --fixtures # offline, no login, no network
python scripts/check_watch.py --live # against the real accountThe fixture suite is the one to run after touching a parser: it covers the false-alarm regressions (time-drift, empty-vs-broken, baseline poisoning, first-run silence). Run it before trusting any scheduled job.
Run
python server.py # speaks MCP over stdioTest interactively with the MCP Inspector:
npx @modelcontextprotocol/inspector python server.pyRegister with Claude Code
claude mcp add schoology -- /your/path/to/schoology-mcp/.venv/bin/python /your/path/to/schoology-mcp/server.pyOr add it to an MCP client config (e.g. Claude Desktop claude_desktop_config.json):
{
"mcpServers": {
"schoology": {
"command": "/your/path/to/schoology-mcp/.venv/bin/python",
"args": ["/your/path/to/schoology-mcp/server.py"],
"env": {
"SCHOOLOGY_USERNAME": "950XXXXX"
}
}
}
}No password appears in the config — it is read from the OS keychain (set once
via scripts/set_credentials.py). SCHOOLOGY_USERNAME can come from .env or
the env block above.
Sessions & auto-refresh
Schoology sessions expire quickly. The server handles this automatically:
Detect-and-retry — every tool call checks (by page content, not just URL) whether the page it got back is really logged in. If the session died, it re-logs in via ClassLink and retries — a call never silently returns logged-out data.
Keep-alive — a background task re-visits Schoology every
SCHOOLOGY_KEEPALIVE_MINUTES(default 8) to keep the session warm, so interactive calls rarely wait for a fresh login.Persistence — the refreshed session is saved to
storage_state.json, so restarting the server reuses it instead of logging in again.
Set SCHOOLOGY_KEEPALIVE=false to disable the background task; detect-and-retry
still applies.
Notes
.envandstorage_state.jsonhold credentials/session — they are git-ignored. Never commit them.Set
SCHOOLOGY_HEADLESS=falsein.envto watch the browser while debugging.
Contributing
Feature requests and issues are always welcomed — open one on the issue tracker or send a PR.
Forking for your own district
This repo is hard-wired to PAUSD (pausd.schoology.com + the ClassLink
/pausd tenant). It is intentionally easy to retarget: fork the repo and
edit the two URLs (and, if your district doesn't use ClassLink SSO,
schoology_mcp/auth.py).
What to change | Where |
Schoology base URL |
|
ClassLink tenant URL |
|
Login flow (if not ClassLink) |
|
If you ship a working fork for another district, open an issue with a link — we can list known-good forks here.
License
MIT — fork it, ship it, no warranty.
Star History
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA FastMCP server for PowerSchool that enables students to check grades, assignments, grade history, and attendance through the Model Context Protocol.2
- AlicenseAqualityAmaintenanceMCP server for Canvas LMS enabling parent observers and students to access courses, assignments, grades, and more. Supports multiple authentication methods including token, OAuth, and a convenient fetchproxy fallback.18165MIT
- AlicenseAqualityDmaintenanceAn MCP server for accessing Dutch school schedules from Magister. Enables Claude and other MCP-compatible AI assistants to query school schedules, drop-off times, and pick-up times.4143MIT
- AlicenseNot gradedqualityDmaintenanceA local MCP server that enables LLMs like Claude to access Schulmanager Online data including schedules, homework, exams, grades, and parental letters.The Unlicense
Related MCP Connectors
Read-only MCP server for ClassQuill, a tutoring-business-management platform.
A MCP server built for developers enabling Git based project management with project and personal…
An MCP server for deep research or task groups
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/dajun666/schoology-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server