Projects / Software

Interview Prep MCP Server

I built a remote MCP server that lets Claude and ChatGPT read and write the same study-progress database. Each model asks the server what is due, quizzes me, and logs a score. The server runs SM-2 spaced repetition and keeps every user’s data separate. It is deployed on Railway with Postgres and Auth0 sign-in.

Problem

I use Claude and ChatGPT voice mode as mock interviewers. Every chat starts from zero. The model does not know what I covered, what I got wrong, or what is overdue, and neither app can see the other’s history.

The fix had to work for two different clients over the public internet, so a local server would not do. It also had to be safe for more than one user, because a shared database with guessable integer ids is an easy place to leak data.

My role

I was the only committer: all 14 commits in the repo are mine. I wrote the product requirements doc, the server, the schema, the auth layers, the Railway deployment and the release tooling.

How it works

Claude / ChatGPT
      |  MCP over streamable HTTP, bearer token
      v
FastMCP server (Python)
  - token verifier (Auth0 JWT, or private OAuth)
  - per-subject rate limiter (in memory)
  - tool layer: no user_id argument anywhere
      |  owner_subject taken from the verified token
      v
InterviewPrepService(owner_subject)
  - ownership checked via join to studies.owner_subject
  - sm2.update_review_state() computes the schedule
      |
      v
Postgres (Railway)    SQLite for local runs
studies > topics > subtopics
attempts (append-only) + subtopic_state (current snapshot)

The model’s loop is short. It calls get_due_subtopics, quizzes me on one subtopic, grades the answer from 1 to 5, and calls log_attempt. The server returns the next review date as plain text (“next review: in 6 days”). The server also exposes 15 tools, four prompts, export_my_data, a confirmation-gated delete_my_data, and public privacy, terms and health pages.

Key decisions

Scheduling runs on the server, not in the model. I could have let the LLM track intervals in its context. I didn’t, because that is the problem I was trying to escape. log_attempt writes an attempt row and updates the state row in one transaction, and sm2.py is a pure function of the previous state, the score and today’s date. That made it easy to unit test. Scores of 1 or 2 reset the interval to 1 day and the streak to 0. Higher scores give intervals of 1 day, then 6 days, then the previous interval times the ease factor, which has a floor of 1.3.

Two tables: an append-only log and a snapshot. The attempts table keeps every score and the grader’s notes for history and trends. subtopic_state holds the current ease factor, interval and next review date. Answering “what is due” is then one indexed query on next_review_date, not a replay of history.

The user id never appears in a tool schema. The alternative was a user_id parameter checked against the token. Instead, the tools take no identity at all. The server reads the verified token’s subject and builds a service object scoped to it. Every lookup of a study, topic, subtopic, attempt or state row joins back to studies.owner_subject, so a guessed id from another account returns “not found”. Isolation lives in application queries, not Postgres row-level security.

Auth in three modes, with the HTTP server refusing to start unauthenticated. First I added a static bearer token. Then I wrote a small OAuth provider with an approval secret, which I built for ChatGPT Developer Mode. For multi-user use I added a verifier for JWTs from an external OIDC provider, which checks signature via JWKS, issuer, audience, expiry, subject and required scopes. The live service now advertises an Auth0 tenant as its authorization server. Startup fails on any HTTP transport with no auth configured unless I set an explicit unsafe flag.

What was hard

Postgres connections behaved differently in production than locally. Railway closed idle connections, so I added a check that reopens a closed connection before each query. Later, deploys stalled because read-only calls left transactions open (“idle in transaction”), which blocked schema DDL on the next release. I wrapped every service method in a commit-or-rollback boundary, and made startup skip all DDL when the migration ledger is already current, so a new instance can start while the old one serves reads.

The other problem was auth that fit both clients’ connection flows. I got the OAuth path working first so I could test end to end, then added OIDC.

Results

  • The service is live. On 2026-10-19 /healthz returned 200 reporting OIDC auth enabled and static-bearer and unauthenticated modes off, and an unauthenticated POST to /mcp returned 401.
  • The test suite passes: 98 tests, run with python -m unittest in about 0.6 seconds, against SQLite and in-memory fakes. It covers SM-2 intervals, cross-subject isolation and mutation attempts, export and delete scoping, JWT validation (wrong issuer, expired, missing scope or subject), the rate limiter, and Postgres reconnect and migration-skip logic.
  • My deployment notes record a remote MCP client listing all tools after OAuth, creating records in Postgres, logging an attempt and reading it back after reconnecting.
  • I submitted the ChatGPT app for review.

I have no usage numbers to report.

Limitations and next steps

  • The service shares one database connection per process, so it has no pool, and log_attempt reads then writes state without a row lock. Two concurrent attempts on the same subtopic could race. No test covers this.
  • The rate limiter is in memory and per instance.
  • Postgres code paths are not tested against a real Postgres in the suite.
  • “Today” is the server’s date, not the user’s time zone.
  • The mastery score is a heuristic of streak and score, not a calibrated measure.