Skip to main content
Glama
README.md
# mcp-flashcards-cloud

A multi-user flashcards MCP server, built to be hosted. Each person signs in with OAuth and sees only their own deck. Built on the official MCP Python SDK 2.x.

> **Status:** all four steps are done:
> - the server, per-user isolation, abuse limits, and tests showing that one user can't read or change another user's cards;
> - real sign-in through an OAuth identity provider, tested with Descope and Claude Code, including a second real account that saw none of the first account's cards, on a local run and, both ways, on the deployed server;
> - Postgres storage, with every store test run on both SQLite and Postgres, and a deployment on Render's free tier with a Neon database, signed in to and used from both Claude Code and claude.ai; since then, the server also keeps using the provider's last keys through a short outage and retries its key endpoint with back-off (see "How sign-in works");
> - a security write-up: the [Security model](#security-model) section below.
>
> See [TEST-LOG.md](TEST-LOG.md) for what has been verified. A live deployment runs on Render's free tier; a demo is available on request.

## Security model

What the server protects, against whom, and how that was checked. The details are in the sections below; the evidence is in [TEST-LOG.md](TEST-LOG.md).

**What is protected, and from whom.** Each user's cards, against other signed-in users, callers without a valid token, and tokens issued for other servers or by other providers. The identity provider is trusted to sign the right person in and to publish the right keys, and Render's edge to end TLS in front of the app. Any client the user approves on the provider's consent screen gets tokens this server accepts: the server doesn't check which client a token was issued to. Clients are not trusted: tool arguments, request metadata and headers other than the bearer token are never used as identity, and Render's `X-Forwarded-*` headers are used for logs only. Signed-in requests to the MCP endpoint must name this server's own host (others get 421), and one carrying an `Origin` header must come from this server's origin or a listed one (others get 403); both checks run after sign-in, so a request without a valid token gets 401 first. The operator, the hosts (Render, which holds the database URL in its settings, and Neon, which stores the cards), and anyone holding the database URL are not kept out (see "Not handled").

**Sign-in.** The server checks every access token itself with the provider's public keys, without asking the provider about each token: signature (RS256 or ES384 only), exact issuer, this server's URL in the audience, expiry, a user (`sub`) and no `dsub`, `token_type` if present, at most a day's validity, and the configured scopes, if any (a token without them gets 403). There are no per-connection sessions, so every request is checked on its own token. See [How sign-in works](#how-sign-in-works).

**Isolation.** Data is keyed by the issuer plus the verified token's `sub`, every query filters on that owner, and someone else's card gets the same error as a missing one. See [How isolation works](#how-isolation-works).

**What is stored.** Per card: the question, answer and topic (plus a trimmed, lower-cased copy of the topic for matching), when it was added and last reviewed, and its review counts. Per user: one row with the owner key (the issuer and the provider's user ID) and the next card number. No email or other profile data is stored: the store receives only the owner key and the card fields. The raw token, which carries an `email` claim, goes to the SDK but is never stored or logged, and the claims the verifier hands on are only `iss` and `sub`. Cards are stored as plain text; the app adds no encryption of its own.

**What is logged, and what never is.**
- Never logged by this repo's code: tokens (not even their signature), the database URL or password, card text, the owner key. Tests cover tokens and the password; for card text and the owner key, no logging call receives them.
- Logged: on an issuer or audience mismatch, what the token claimed (`iss`, `aud`) and what was expected; one line for every other rejected token, giving the reason but never the token; when a key-endpoint outage starts and ends; the database name and host at startup; database driver errors cut to 300 characters, which may name the host or database user.
- uvicorn's access log, which this setup leaves on: one line per request with the client's address (on Render, taken from its `X-Forwarded-For` header), the request line and the status code. With the rejected-token lines, anyone can add log lines at the rate they send requests.
- Apart from that access log, only this repo's own logging was checked, not whether the SDK, uvicorn or psycopg log request bodies or headers.

**Secrets.** Only `FLASHCARDS_DATABASE_URL`. Sign-in needs none, since the issuer, the key URL and the server's own URL are public. The database URL never appears in `repr(settings)` or in errors about it, and a remote database must use `sslmode=require` or stricter.

**Abuse limits and the free tier.**
- Per user: 1,000 cards, 200,000 characters, 120 tool calls a minute, at most 100 cards per page (see [Limits](#limits)).
- For everyone together: POST only, no subscriptions, at most 400 connections at a time (uvicorn's limit; it answers more with 503, which no test here drives).
- Towards the provider: unknown key ids cause a key fetch at most once every 10 seconds, failed fetches back off to a minute, and the last good keys are kept for up to 30 minutes during an outage.
- Render's free instance sleeps after 15 minutes without traffic. One measured wake held the first request for 32 seconds, longer than the clients' default timeouts, so a first connection after a quiet spell may fail. A wake during a provider outage refuses every token until a key fetch succeeds.

**Not handled.**
- Revoking a token before it expires: there is no introspection or revocation list, so a token works until it expires (Descope's last 10 minutes; the server refuses any valid for more than a day). A key the provider revokes is still accepted for up to 5 minutes, or 30 while its key endpoint can't be reached.
- Telling refresh or ID tokens from access tokens when they carry no `token_type`: Descope's access tokens carry it; whether its other tokens for this server do hasn't been checked.
- Deleting an account or a whole deck: only single cards can be deleted, and the user's owner row stays.
- Protecting cards from the operator, the hosts (Render and Neon), or anyone with the database URL.
- Limits on traffic without a token, per IP address, or on signed-in requests other than tool calls (such as listing the tools): the connection cap is the only guard there. Sign-up is open, so many accounts together could use up the free quotas.
- Rate limits across several instances: the limiter lives in memory in one process.
- Requiring a check of the database server's certificate: `sslmode=require`, which the deployment uses, encrypts without one; `verify-ca` and `verify-full` are accepted but not required or tested, and `channel_binding=require` is recommended, not enforced.
- Backups, restore, and how long data and host logs are kept.
- Pinning the packages that the pinned packages depend on.
- More in [TEST-LOG, section 15](TEST-LOG.md#15-known-limitations-and-pending).

**How it was verified.**
- Automated tests drive the real app in-process over HTTP through the SDK's auth middleware, the isolation tests with a stand-in verifier and the end-to-end ones with tokens signed by test key pairs, and run every store test on both SQLite and Postgres: 460 passed on 2026-10-22 (sections [1](TEST-LOG.md#1-per-user-isolation-automated), [4](TEST-LOG.md#4-real-signed-tokens-automated), [8](TEST-LOG.md#8-postgres-storage-automated), [13](TEST-LOG.md#13-signing-keys-automated)). 33 tokens that each break one rule are all refused.
- Deliberately broken variants: `tests/run_mutations.py` reruns the tests against 89 of them, such as a query without its owner filter, an unchecked signature or audience, or a token written to the log. All 89 were caught ([section 14](TEST-LOG.md#14-mutation-check-step-35)).
- Manual tests: real sign-in through Descope from Claude Code, including, on a local run, a second account that saw none of the first account's cards, checked one way by hand ([section 7](TEST-LOG.md#7-real-sign-in-with-descope-and-claude-code-manual)); the deployed server on Render and Neon, used from Claude Code and claude.ai, where two real accounts each saw only their own cards, checked both ways ([section 11](TEST-LOG.md#11-deployment-to-render-with-neon-manual-2026-10-12-to-2026-10-23)).
- Reviews by AI agents at every step, most with another agent re-checking or judging each finding. Findings were fixed, apart from a few recorded as deferred or not reproduced (sections [3](TEST-LOG.md#3-code-review), [6](TEST-LOG.md#6-code-reviews), [10](TEST-LOG.md#10-code-review-2026-10-12), [12](TEST-LOG.md#12-fixes-after-deployment-2026-10-15), [14](TEST-LOG.md#14-mutation-check-step-35)). They are not independent or third-party audits.

## How isolation works

- **Identity comes only from the verified access token.** The server takes the token's subject (`sub`) after the SDK has checked the token, and keys all data by issuer plus subject. The token's own issuer must also match the configured one, so a token from another identity provider is refused even if its `sub` matches a real user's. Tool arguments, request metadata and headers are never used as identity, because a client controls all of them.
- **Isolation is enforced in code, not in prompts.** Every database query filters on the owner. There is no function that reads or changes a card without naming its owner.
- **Replies give no hints about other users.** Card numbers are per user, so they reveal nothing about other decks. A card that belongs to someone else returns exactly the same "No card with id N" error as a card that doesn't exist.
- **Tokens for other servers are rejected.** The server only accepts tokens issued for its own URL (RFC 8707 audience check).

## How sign-in works

The server is an OAuth protected resource. It publishes metadata (RFC 9728) that tells clients such as Claude which identity provider to use, and a request without a valid token gets 401 with a pointer to that metadata. Claude signs the user in with the provider and sends the access token it gets back.

The server checks every token itself, with the provider's public signing keys, so it holds no secrets. A token is accepted only if:

- its signature is valid and made with one of the provider's keys. The allowed algorithms (RS256, ES384) are fixed by the server, never taken from the token;
- its issuer is exactly the configured one;
- its audience includes this server's URL, so a token the provider issued for another server is refused;
- it hasn't expired and isn't dated in the future (a provider clock up to 30 seconds ahead of this server's is tolerated);
- it names a user (`sub`) and has no `dsub` claim. Descope adds `dsub` when a token template has replaced `sub`, so `sub` may no longer be the user's ID;
- if it carries a `token_type` claim, that claim is `access_token`, so a refresh or ID token that says what it is can't stand in for an access token. A token without the claim is judged by the other checks only (Descope's access tokens carry it);
- it is valid for at most a day.

The keys are fetched from the provider when first needed and reused for 5 minutes. If the provider's key endpoint can't be reached, or sends nothing usable, the server keeps using the last keys it fetched, for up to 30 minutes after that fetch, and tries again with growing waits (5 seconds, doubling, at most a minute apart), one fetch at a time, made by the next request after each wait. After those 30 minutes every token is refused until a fetch succeeds. A key the provider no longer publishes stops working at the next successful fetch. A token naming an unknown key id makes the server look again at most once every 10 seconds, so made-up key ids can't make it flood the provider. An outage is logged when it starts (and once more when the kept keys pass their 30 minutes) and when it ends, not on every request. The keys are kept in memory only, so after a restart or a wake from sleep there are none to fall back on until the first fetch succeeds (see TEST-LOG, section 15).

Tokens are never logged.

The MCP endpoint does only what it has to. It accepts POST only (anything else gets 405), runs without per-connection sessions, offers no subscriptions, and takes at most 400 connections at a time across all users (more get 503), so open streams can't exhaust the server's memory.

## Limits

This is meant to run on free-tier hosting with open sign-up, so one account must not be able to exhaust memory, storage, or CPU time:

| Limit | Value |
|---|---|
| Cards per user | 1,000 |
| Text stored per user (question + answer + topic) | 200,000 characters |
| Question / answer / topic length | 500 / 1,000 / 50 characters |
| `list_cards` page size | 50 by default, 100 at most |
| Tool calls per user | 120 per minute |

## Tools

| Tool | What it does |
|---|---|
| `add_card(question, answer, topic)` | Add a card to your deck |
| `list_cards(topic?, limit?, after_id?)` | List your cards a page at a time; pass `next_after_id` back as `after_id` for the next page. Topics match case-insensitively. |
| `review(topic?)` | Draw the card you've gone longest without reviewing. Returns the question, not the answer. |
| `grade_card(card_id, correct)` | Record a right or wrong answer |
| `delete_card(card_id)` | Delete one of your cards |

## Run it

Requires Python 3.12 or newer (tested on 3.14).

Register the server with your identity provider (Descope calls this an MCP server), then set these environment variables. Only `FLASHCARDS_DATABASE_URL` is a secret: it holds the database password, and the server never logs it or repeats it in an error.

| Variable | Meaning |
|---|---|
| `FLASHCARDS_ISSUER` | The provider's issuer, copied exactly from its metadata |
| `FLASHCARDS_JWKS_URL` | Where the provider publishes its public signing keys (`jwks_uri`); must be on the issuer's host |
| `FLASHCARDS_RESOURCE_URL` | This server's MCP URL, exactly as users enter it in Claude and as registered with the provider, e.g. `https://example.onrender.com/mcp` |
| `FLASHCARDS_SCOPES` | Optional: scopes every token must carry, e.g. `flashcards` |
| `FLASHCARDS_DATABASE_URL` | A Postgres connection URL, such as the one Neon gives you. Required on Render; a remote database must use `sslmode=require` or stricter. Characters such as `@ / ? #` in the password must be percent-encoded |
| `FLASHCARDS_DB` | Local use only: a SQLite file instead of Postgres (default `data/cards.db`). Set this or `FLASHCARDS_DATABASE_URL`, not both |
| `FLASHCARDS_ALLOWED_ORIGINS` | Optional: other web origins whose requests are accepted, separated by commas or spaces, e.g. `https://app.example.com` (scheme and host only). Requests without an `Origin` header always are; claude.ai and Claude Code worked without this setting |
| `HOST`, `PORT` | Optional: where to listen (default `127.0.0.1` and `8000`; `0.0.0.0` on Render) |
| `LOG_LEVEL` | Optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR` |

Spaces and line breaks around a value are ignored (copy buttons tend to add a line break, and Render's multi-line value box keeps it).

```
pip install -r requirements.txt
python -m flashcards_cloud
```

Before it starts listening, the server reports every configuration problem at once, refuses plain http except on this machine, and compares the issuer and key URL with the provider's published metadata. A mistyped issuer, such as a wrong project or server ID inside it, then stops the server at startup instead of breaking the first sign-in. If the provider can't be reached, the server logs a warning and starts anyway. It also connects to the database and creates its tables if they don't exist yet.

### Storage

With `FLASHCARDS_DATABASE_URL` set, cards live in Postgres; otherwise in a local SQLite file. Both stores behave the same way and the tests run on both. On Postgres:

- Each store call borrows its own connection from a small pool and runs as one transaction, rolled back if anything fails.
- Card numbers come from an atomic update that also locks the user's row, so one user's simultaneous adds run one at a time and can't get past the card limits together. An add waits at most 10 seconds for that lock.
- The pool checks a connection before handing it out. Neon's free tier closes idle connections when it pauses after 5 minutes, and the next request gets a fresh one. TCP keepalives notice a connection that died without closing within about a minute.
- A database that can't be reached gets the user "temporarily unavailable, try again in a minute"; a full disk gets "out of space" instead.

Keep the `sslmode=require&channel_binding=require` that Neon puts in its URL. `sslmode=require` encrypts the connection without checking the server's certificate; channel binding ties the password exchange to that encrypted connection, so it can't be relayed by someone in between.

On a host without a persistent disk, such as Render, a SQLite file would be wiped on every restart, so the server refuses to start on Render without `FLASHCARDS_DATABASE_URL`.

### On Render

- Create the web service first: its URL (`https://<name>.onrender.com`, unless Render adds a suffix) plus `/mcp` is what you register with the identity provider and set as `FLASHCARDS_RESOURCE_URL`. Until the provider settings are in, deploys fail and the log lists what is missing.
- Build command `pip install -r requirements.txt`, start command `python -m flashcards_cloud`. `.python-version` pins Python 3.14.
- The server runs as one process whatever `WEB_CONCURRENCY` says (Render set it to 1 on the free instance), and it listens on `0.0.0.0` and Render's `PORT` by itself.
- No health check path is needed. If you add one, don't point it at anything that queries the database, or the database never gets to pause.
- Render's free instances sleep after 15 minutes without traffic. Woken once after four days with no known traffic, the first request was held for 32 seconds and then answered normally; Render itself warns of 50 seconds or more (see TEST-LOG). That is longer than the 10 seconds claude.ai allows for discovery and the 30 seconds Claude Code waits for an MCP server by default (`MCP_TIMEOUT`). What either client does then hasn't been tested, so a first connection after a quiet spell may fail. If it does, open `https://<your-service>.onrender.com/.well-known/oauth-protected-resource` in a browser, wait for the JSON, and connect again.
- `python scripts/probe_deployed.py https://<your-service>.onrender.com` makes the requests a client sends to this server (at `/mcp`) before it has a token, and prints what comes back, with timings. It sends no token.

## Run the tests

```
python -m venv .venv
# Windows: .venv\Scripts\activate    macOS/Linux: source .venv/bin/activate
pip install -r requirements-dev.txt
python -m pytest
python tests/run_mutations.py   # checks that the tests catch 89 deliberately broken variants
```

`requirements-dev.txt` includes `postgresql-binaries`, a Postgres server the tests start on `127.0.0.1` for the run and stop afterwards (no admin rights needed). Every test that uses a store then runs once on SQLite and once on Postgres. Without a local Postgres those runs are skipped and listed as skipped; set `FLASHCARDS_REQUIRE_PG=1` to make them fail instead. `FLASHCARDS_TEST_PG_BIN` can point at another Postgres `bin` folder.

## License

MIT - see [LICENSE](LICENSE).

Maintenance

ActivityMaintained
ResponsivenessNo issues