backend / mbprep-admin-mcp
MBPrep admin MCP server
A remote MCP server that lets AI agents run MBPrep's operations (students, payments, programmes, question-bank publishing, the Hypatia content engine) behind OAuth 2.1 with scoped, audited access.
2026 · live · private repository
- [*]LanguagesTypeScript
- [*]Frameworks@modelcontextprotocol/server · agents (createMcpHandler) · @cloudflare/workers-oauth-provider · Zod 4
- [*]StoresD1 (shared with the API) · Workers KV (OAuth grants, tokens, rate limits) · R2
- [*]AsyncCloudflare Workflows (PublishBank: durable steps, sleepUntil) · cron (daily OAuth purge)
- [*]InfraCloudflare Workers · custom domain, workers.dev and preview URLs off · service bindings · Cloudflare Email Service
- [*]Third partiesHypatia (service binding) · Bachs (through the API's billing service) · Google OAuth (sign-in, currently off; emailed codes instead) · SMTP / Cloudflare Email Service
apps/mcp is a separate Cloudflare Worker at mcp.mbprep.app. It serves Streamable HTTP at /mcp behind @cloudflare/workers-oauth-provider: authorization code with S256 PKCE, dynamic client registration rate-limited to 20 per IP per hour in KV, client ID metadata documents, RFC 8414 and RFC 9728 discovery, one-hour access tokens and 30-day refresh tokens. An unauthenticated request gets 401 with a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/mcp, which answers.
It defines no business logic of its own. The tools import the API's services as a workspace package and run them on the same D1, so an MCP call and the admin dashboard execute the same code and write to the same admin_audit log. A fresh McpServer is built for each request, and it lists only the tools the caller's scopes allow: 103 admin tools in tools.ts and 31 Hypatia tools that call the content engine over a service binding, 134 in all.
Access has two routes in. A team member signs in through OAuth and ticks scopes and programmes on a consent page, or a script presents a named mbk_ API key, stored as a salted hash. Nine scopes split read, write, send, programme and content rights. Approve and publish are founder-only and are removed from any key even if its stored scopes were edited by hand. The caller's address is re-checked against ADMIN_EMAILS on every call, so removing someone cuts off their refresh tokens and keys immediately.
The worker also hosts PublishBank, the Cloudflare Workflow that publishes an approved question-bank draft. The API worker starts and stops it through a cross-script binding.
How it holds up
- Auth
- OAuth 2.1 (PKCE S256, DCR, CIMD, resource-bound tokens), with the team allowlist re-checked on every call, or named mbk_<id>_<secret> API keys stored as salted SHA-256. Nine scopes are checked twice: once when the tool list is built and again in runTool. Programme-scoped grants refuse tools aimed at other schools (outside_programmes), and content:approve and content:publish are never given to a key.
- Data
- No migrations of its own: it binds the API's D1 with migrations_dir ../api/migrations and writes the same tables, including mcp_keys, admin_audit, bank_drafts, bank_publishes and bank_versions. OAuth state lives in KV.
- Consistency
- Writes need a reason string, which is stored in the audit log under the caller's identity (mcp:<key name> or email (mcp: client)). Emails preview first and send only when the call is repeated with confirm: true. A publish runs as Workflow steps that are each safe to repeat (batch installs use INSERT OR REPLACE in one transaction). The final flip is one D1 batch in which every statement is guarded by EXISTS(... status='waiting'), so a cancel that lands first wins outright.
- Failure
- Workflow steps retry 5 times with exponential backoff from 15 seconds and time out after 10 minutes. A run whose Worker dies resumes at the step it was in. A step that keeps failing ends in fail(), which leaves the old bank live, returns the draft to approved and opens an alert. Tool errors come back as typed codes (insufficient_scope, invalid_input, internal) rather than raw exceptions.
- Testing
- 3 test files with 72 cases (1,041 lines): tools.test.ts (52) covers scope gating, key handling and that MCP_BASE_URL and ADMIN_EMAILS match the API's wrangler config; hypatia.test.ts (16); pages.test.ts (4). The publish steps themselves are tested in apps/api (publish.test.ts) with a Steps double that remembers finished steps the way Workflows do.
Techniques
- Scope-filtered, per-request MCP serverEach request builds a fresh stateless McpServer that registers only the tools the caller's scopes allow, and runTool checks the scope again and parses input with the tool's Zod schema. An agent never sees a tool it cannot call.
apps/mcp/src/server.ts:28apps/mcp/src/tools.ts:1833 - Live revocation through the allowlistEvery tool call re-checks the identity in the grant or key against ADMIN_EMAILS, so removing someone ends their refresh tokens and every key they made at once rather than at expiry. Keys also have founder-only scopes stripped when they are parsed.
apps/mcp/src/caller.ts:19apps/mcp/src/caller.ts:32apps/mcp/src/index.ts:47 - Programme-scoped grantsConsent can narrow a grant to particular school/course/exam programmes. Ticking 'only these' with nothing selected limits the grant to none rather than to everything, and every tool that touches a programme refuses callers outside it.
apps/mcp/src/auth.ts:33apps/mcp/src/tools.ts:35 - Durable bank publish with a cancel-safe flipPublishing a reviewed question bank is a Cloudflare Workflow: prepare, one transactional batch per draft chunk using INSERT OR REPLACE, finish with a count check, sleepUntil the publish time without holding a Worker, then flip. The flip is a single D1 batch, and every statement in it requires the run to still be 'waiting', so a cancel and a flip that race cannot both apply. The previous version is kept for rollback.
apps/mcp/src/publish-workflow.ts:13apps/api/src/services/publish.ts:361apps/api/src/services/publish.ts:321 - Hardened OAuth surfaceThe worker serves one issuer with workers.dev and preview URLs disabled so tokens and discovery never split across hosts. global_fetch_strictly_public is on for client ID metadata fetches, dynamic registration is rate-limited per IP in KV, and expired grants are purged daily in batches of 500.
apps/mcp/wrangler.jsonc:14apps/mcp/src/index.ts:39apps/mcp/src/index.ts:61
Counted
- 75Commits touching apps/mcp
git rev-list --count HEAD -- apps/mcp - 134 (103 admin + 31 Hypatia)MCP tools registered
grep -c 'tool({' tools.ts; grep -c 'name: "hypatia_' hypatia.ts - 9 + offline_accessOAuth scopes
MCP_SCOPES in apps/api/src/services/mcpkeys.ts - 3 / 72Test files / cases
find apps/mcp -name '*.test.ts'; grep -c 'test(\|it(' - 3,027Source LOC (non-test)
git ls-files 'apps/mcp/*.ts' | grep -v test | xargs wc -l - 5 retries, 15 s exponential, 10 min timeoutWorkflow retry policy
apps/mcp/src/publish-workflow.ts - access 3600 s, refresh 30 daysToken TTLs
apps/mcp/src/index.ts - 2026-10-01First commit under apps/mcp
git log --format=%cd -- apps/mcp | tail -1