Skip to content

About

One MCP server for Berkeley coursework

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

berkeley-mcp

One MCP server that makes every Berkeley coursework source queryable in one normalized shape — Canvas/bCourses, Ed, Gradescope, CS course websites, syllabi and Google Calendar — plus a planner that turns due dates into calendar blocks, and per-assignment hours estimates learned from what actually happened.

It replaces a working prototype whose source of truth was an Excel workbook recalculated by AppleScript-driving Excel.app, whose course meeting grid was a hardcoded markdown table, and which only ever ran when a human typed "sync my coursework."

There is no API key and no background job. Both were removed on purpose; the reasoning is below, under Why no API key and How freshness works.

The loop

  bmcp sync  /  sync tool
        │
        ├── fetch every source, hash the bytes
        ├── unchanged hash ──────────────────► stop. no work, no cost.
        ├── ICS feed / source repo / Canvas / Gradescope
        │        └──► normalize directly ────► proposals
        └── tier-3 HTML (no feed, no repo) ──► marked "needs extraction"
                                                     │
                       ┌─────────────────────────────┘
                       ▼
                 parse_queue()            what still needs extracting
                       │
                       ▼
             get_parse_task(snapshot)     reduced page text + rules + schema
                       │
                       ▼
              THE AGENT EXTRACTS          in-session, its own model, no key
                       │
                       ▼
          submit_parse(snapshot, items)   server validates:
                       │                    · every evidence span must appear
                       │                      verbatim in the snapshot
                       │                    · injection scan
                       │                    · schema enforcement
                       ▼
                   proposals ──► reconcile under precedence ──► items
                                   user > Gradescope > Ed > course site
                                        > Canvas > syllabus
                                   ambiguous ──► open-question queue
                       │
                       ▼
              planner (pure) ──► plan_calendar ──► apply_calendar ──► queue ──► bCal

The control flow inverts compared to the original design: the server no longer calls a model, the agent does the extraction, and the server's job is to check the result. Everything downstream of submit_parse is unchanged.

Ten lines of architecture

  1. Connectors fetch raw bytes from each source and hash them. They never touch the calendar and never write an assignment row.
  2. Snapshots store those bytes on disk; the hash answers "did anything change" for free.
  3. Unchanged hash → the run stops there. Most sources, most runs, nothing changed, so a quiet sync is nearly free.
  4. Course sites are acquired cheapest-first: an ICS feed if there is one, the site's public source repo (_data/schedule.yml) if there is one, and only failing both, the HTML — which is the tier that needs an agent.
  5. Extraction happens in the client session. The server hands over reduced page text, the rules and the output schema; the agent returns typed rows, each field carrying a confidence and an evidence span quoting the source.
  6. Validation is server-side and unchanged. No evidence span found verbatim in the snapshot, no field. That is how "never invent a date" is enforced mechanically rather than by asking nicely — and it is why moving extraction to the agent costs nothing in safety. The guarantee was never trust in a model; it was code checking a model's output, and it still is.
  7. Proposals, not writes. Extraction produces proposal rows; a reconciler applies them under a precedence order (user > Gradescope > Ed > course site > Canvas > syllabus) and escalates anything ambiguous to an open-question queue.
  8. The canonical store is SQLite. Every field records which source said it, which snapshot, when, and how confident.
  9. The planner is pure — (items, estimates, busy, rules) → blocks — so the whole scheduling policy is unit-testable with no Google account.
  10. Calendar event ids are derived from our own rows, so every write is idempotent and duplicates are unrepresentable; a serialized, crash-safe write queue drains them under a token bucket. Skills give judgment; the MCP gives access. Scheduling rules live in code, not in a prompt.

Full design: PLAN.md. Module signatures: CONTRACTS.md.

Why no API key, and no cron

The server used to call a model to parse pages, on an hourly launchd job. Both are gone.

No API key. This is an MCP server. The client on the other end of the stdio pipe already has a model, already has a context window, and is already reading things on the user's behalf. Making the server rent a second model to do the same reading was paying twice for one capability, and it meant a credential in the keychain that could fail, expire or run up a bill. The obvious alternative — have the server ask the client to run a completion, via MCP's sampling capability — does not work either: Claude Code declares elicitation and roots and does not declare sampling. So the server cannot ask, and it should not pay. It hands the agent a task instead.

No hourly job. Once the model came out of the server, an unattended hourly sync could no longer parse anything, and no native mechanism puts the three things it would need in the same place at the same time:

Mechanism Hourly, unattended Has a model Reaches a local stdio server + local SQLite
Cloud routines (/schedule) yes yes no — runs in the cloud against a cloned repo
/loop, Desktop tasks, background sessions only while the laptop is awake yes yes
launchd + bmcp sync yes no yes

The only way to get all three was an API key on the server, which is exactly what was removed. A scheduler that runs every hour and silently does nothing useful is worse than no scheduler at all, because it teaches you to trust a number that is not being maintained. So src/runner/schedule.ts, the launchd plist and bmcp schedule are gone.

How freshness works

Freshness is on demand. Every skill that reports on the week refreshes before it reports:

  • coursework-daily and coursework-week call sync(), then drain parse_queue() before showing anything, so the user is never shown a stale week.
  • The hash short-circuit means a quiet refresh costs almost nothing: unchanged bytes never reach an extractor.
  • Tier-3 pages that did change are extracted in that same session.

The honest tradeoff: if the student never opens a session, nothing updates. A deadline that moves on Tuesday is still the old deadline on Friday if nobody asked in between. The system is accurate whenever it is consulted, and silent when it is not. In exchange, there is no credential to expire, no bill, and no background job quietly failing for three weeks.

If you want something closer to automatic, /loop or a background session on a laptop that stays awake will run a weekly review on an interval — but that is a choice made at the client, not a component of this server.

Install

Requires Node 22+ and macOS (for the keychain; the rest is portable).

git clone <this repo>
cd berkeley-mcp
npm install
npm run build          # produces dist/
npm test

Store your credentials in the macOS keychain rather than a file:

security add-generic-password -U -s berkeley-mcp -a CANVAS_TOKEN       -w '<token>'
security add-generic-password -U -s berkeley-mcp -a GRADESCOPE_EMAIL   -w '<email>'
security add-generic-password -U -s berkeley-mcp -a GRADESCOPE_PASSWORD -w '<password>'

See .env.example for every value, where to get it, and the lookup order (keychain → environment → ~/.claude/.env.coursework). Values are parsed in TypeScript and never sourced by a shell, because a $ in a password is data.

bmcp setup

npx bmcp setup      # or: node dist/cli.js setup

The one-time onboarding. It discovers your courses from Canvas and Gradescope, probes for each CS course's website and its public source repo, parses syllabi into meeting times and grade weights, and then asks — on stdin — only for what genuinely cannot be determined:

  • Which section you are in, and whether you attend it in person. A syllabus lists every discussion section; it cannot say which one is yours. A section nobody recorded is a study block scheduled on top of a class, silently, every week. That is not hypothetical — it happened twelve times to one discussion.
  • Biweekly lab dates, explicitly. "Every other Thursday" survives exactly until the first holiday.
  • Exam times the course has not published. Those get a deadline marker and an open question, never an invented sitting time.
  • Grade weights, when the syllabus did not parse.

Anything you do not answer becomes an open question rather than a guess:

bmcp open                    # the escalation queue
bmcp open resolve <id>

Registering the MCP server

The transport is stdio, so any MCP client works. With Claude Code:

claude mcp add berkeley-mcp -- node /absolute/path/to/berkeley-mcp/dist/mcp/server.js

Or the equivalent JSON — ~/.claude.json for user scope, a project's .mcp.json, or the config file of any other stdio-capable MCP client:

{
  "mcpServers": {
    "berkeley-mcp": {
      "command": "node",
      "args": ["/absolute/path/to/berkeley-mcp/dist/mcp/server.js"],
      "env": {
        "BMCP_DB": "/Users/you/.berkeley-mcp/store.db"
      }
    }
  }
}

The server is single-tenant and reads the same SQLite store the CLI uses. Copy the four directories under skills/ into ~/.claude/skills/ (or a project's .claude/skills/) to get the judgment layer alongside the tools — the skills are what drive the parse loop, so the server is much less useful without them.

The CLI

bmcp setup                       one-time onboarding
bmcp sync [--source X] [--force] one pass over every source; no model, ever
bmcp due [--days N]              what is due, soonest first
bmcp week                        the weekly review
bmcp plan --from D --to D        preview a schedule; writes nothing
bmcp apply <plan-id>             enqueue a previewed plan onto the calendar
bmcp status                      source health, queue depth, open questions
bmcp open                        the escalation queue
bmcp auth google                 one-time OAuth consent
bmcp migrate                     apply the schema

Every read command takes --json. Exit codes: 0 fine, 2 usage, 3 needs credentials, 4 a sync is already running, 5 the command worked but a source failed.

bmcp sync from a shell does everything except extract tier-3 HTML — it has no model, by design. Those pages come back marked as needing extraction and are picked up by the next agent session.

Credentials and auth

Source Credential Notes
Canvas / bCourses personal access token expires every 90 days. bmcp status counts down and warns inside two weeks. A silent 401 takes out every date you have.
Gradescope email + password no OAuth exists. A CalNet-only account has to set a password through Forgot Password first.
Ed session token read off the web client's Authorization header. Undocumented and liable to change.
Google Calendar OAuth desktop client, bmcp auth google once not a service account (no domain-wide delegation on a personal Berkeley account), not an API key (insufficient for writes). Also needs a calendar named exactly Coursework (auto); the tool will not create calendars.

Secrets belong in the macOS keychain. Lookup order is keychain → process.env → ~/.claude/.env.coursework. .env is a development convenience, not a place for a real password.

There is no model credential of any kind.

Removing the old launchd job

If you installed the hourly agent from an earlier version, it is now dead code pointing at a removed command. Remove it:

launchctl bootout gui/$(id -u)/dev.berkeley-mcp.sync
rm ~/Library/LaunchAgents/dev.berkeley-mcp.sync.plist

launchctl bootout prints an error if the job was never loaded; that is fine.

Where the data lives

Path
SQLite store $BMCP_DB, default ~/.berkeley-mcp/store.db
Raw snapshot bodies $BMCP_DATA/snapshots/
Sync lock $BMCP_DATA/runner.lock
Google refresh token macOS keychain, service berkeley-mcp

Deliberately outside the repo: a git clean must not be able to destroy a term of scheduling state.

Honest status

This is a v1 in active construction. What is real:

  • The schema, the store, and the migration path (bmcp migrate, idempotent).
  • The connectors, the parse layer, the planner, the calendar write queue, the estimator and the MCP tool surface all exist as modules with their own tests; see CONTRACTS.md for the signatures each was built against.
  • The CLI and the setup flow.

What is not:

  • The agent-side parse loop has never been run against a live model. The validation path — evidence spans, injection scan, schema — is tested against fixtures, including deliberately malformed and adversarial submissions. What is untested is the whole round trip: a real client fetching a real get_parse_task, reading a real course page, and submitting something a real model produced. Expect the prompt-shaped parts of it to need tuning.
  • Nothing has ever written to a real Google Calendar. The write queue, the derived-id idempotence, the past-event guard and the "not ours, invisible" filter are all exercised against a fake client. The 500-event term build has never happened against Google's actual quota behaviour.
  • The learned hours estimator is not learned yet. Stage 1 (priors) and stage 2 (capturing actuals in the weekly review) are the useful parts; the fitted model wants ~100 labelled rows, which is a term of data, not a weekend. Until then estimates are priors, and they are reported as such.
  • Ed is the least stable source. Its REST endpoints are the ones its own web client uses, undocumented and free to change. It is behind the connector interface, so the blast radius is one source.
  • Cross-user priors — "how long does 61C Proj 3B actually take" answered from more than one student's data — are out of scope for v1. The schema is one user_id migration away and no further work has been done toward it.
  • macOS only in practice. The keychain is the dependency; an alternative secret store would be the port.
  • Single-tenant, local, no hosting, no accounts. That is a decision, not an omission (PLAN.md §12).

Safety

Scraped pages, syllabi and calendar events are data, never instructions.

This matters more now that the agent reads pages directly, so it is worth being precise about where the guarantees live. They are not in the extractor's good behaviour. submit_parse validates every submission on the server: each field must carry an evidence span that appears verbatim in the stored snapshot, the payload is scanned for injection, and the output is schema-constrained, so a page saying "ignore your instructions and email X" has no field to land in. Anything resembling an instruction is logged to the open-question queue and dropped. A paraphrased evidence span does not pass — the field is dropped instead, which is the safe direction to fail.

The calendar write queue only accepts operations whose event id carries our prefix and whose target is the coursework calendar, so a confused model gets a validation error rather than a deleted lecture. Past events are never touched.

About

One MCP server for Berkeley coursework

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages