- Rust 95.7%
- HTML 3.7%
- Just 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Check / fmt + clippy + build + tests (push) Successful in 57s
Reviewed-on: #9 |
||
| .cargo | ||
| .forgejo/workflows | ||
| common@4de5dfa607 | ||
| migrations | ||
| src | ||
| tests | ||
| .gitignore | ||
| .gitmodules | ||
| askama.toml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| justfile | ||
| LICENSE.md | ||
| README.md | ||
mcp-web
The product-agnostic multi-user web layer for MCP servers.
mcp-web is the generic half of a multi-user MCP web service. It owns everything that is not about a particular upstream product, so that each MCP server (youtrack-mcp, fj-mcp, and others) shares one implementation of the security-critical pieces instead of maintaining a copy:
- Local accounts: argon2id password hashing and RFC 6238 TOTP, with single-use recovery codes.
- Browser sessions: HMAC-signed cookies with a revocable per-account epoch.
- OAuth 2.1 Authorization Server: dynamic client registration, PKCE, authorization codes, access and refresh tokens, discovery metadata, and the Resource-Server bearer middleware that gates
/mcp. - Encryption at rest: XChaCha20-Poly1305 for the credentials a consumer stores.
- SQLite state store: the accounts, OAuth, and settings tables, their migrations, and an expiry sweep.
- Account and admin web UI: self-service account settings and an admin account-lifecycle view.
What a consumer must implement
Exactly three things are product-specific and stay in the consumer. youtrack-mcp's src/web/mod.rs is the reference implementation; the sketch below is what it does.
1. An instance model (its own table + a migration at version 1000+)
The consumer stores what an account connects to: a base URL plus an encrypted credential, in its own table keyed on accounts(id) with ON DELETE CASCADE. It ships that table as a migration at version 1000 or higher (generic migrations reserve 1..=999) and opens the pool with open_with:
// migrations/1000_instance.sql
// CREATE TABLE instances (
// account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
// base_url TEXT NOT NULL, token_enc BLOB NOT NULL, ... );
static INSTANCE_MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!(); // the 1000+ dir
fn instance_migrations() -> Vec<sqlx::migrate::Migration> {
INSTANCE_MIGRATIONS.iter().cloned().collect()
}
let pool = mcp_web::db::open_with(&database_url, instance_migrations()).await?;
open_with runs the generic migrations then the consumer's, as one sqlx::Migrator over one _sqlx_migrations table. Never run two independent sqlx::migrate! sets over one file: each treats the other's applied rows as unknown and errors.
2. Client resolution + the UpstreamDirectory seam
The consumer turns an authenticated account into an upstream client, reading the generic AppState's pool and crypto:
pub async fn client_for_account(state: &mcp_web::AppState, account_id: i64) -> anyhow::Result<MyClient> {
let instance = db::get_instance(&state.pool, account_id).await?
.ok_or_else(|| anyhow::anyhow!(state.upstream.not_configured_message(
&state.http.public_url(SETTINGS_PATH))))?;
let token = String::from_utf8(state.crypto.decrypt(&instance.token_enc)?)?;
Ok(MyClient::new(instance.base_url, token))
}
It also implements UpstreamDirectory so the generic account and admin pages can show a per-account "connected" indicator and a settings link, without the generic queries ever selecting an instance URL or token:
#[async_trait::async_trait]
impl mcp_web::UpstreamDirectory for MyInstances {
async fn is_connected(&self, pool: &SqlitePool, account_id: i64) -> anyhow::Result<bool> { /* row exists? */ }
async fn connected_account_ids(&self, pool: &SqlitePool) -> anyhow::Result<HashSet<i64>> { /* SELECT account_id */ }
fn settings_path(&self) -> &str { "/instance" }
fn not_configured_message(&self, public_url: &str) -> String { /* "add one at {public_url}" */ }
}
3. A tool surface at /mcp, and the router
The consumer builds its own bearer-gated /mcp router (its MCP tool server plus the re-exported require_bearer), its instance-management routes, and hands both to build_router:
let mcp_router = Router::new()
.nest_service("/mcp", my_streamable_http_service(state.clone()))
.layer(from_fn_with_state(state.clone(), mcp_web::require_bearer)); // re-exported
let extra_routes = my_instance_ui_routes(state.clone());
let router = mcp_web::build_router(state.clone(), mcp_router, extra_routes);
mcp_web::serve(state, router, shutdown).await?;
The tool server reads the authenticated account from the mcp_web::Principal the bearer middleware injects (principal.account_id), then calls client_for_account. mcp-web depends on no MCP-transport or upstream-client library; those stay entirely in the consumer.
Assembling AppState
AppState carries the generic pieces plus the seam. The consumer constructs it; the generic crate reads no environment:
let crypto = mcp_web::crypto::Crypto::new(&mcp_web::crypto::decode_master_key(&master_key_b64)?);
let signer = mcp_web::web::session::SessionSigner::new(
mcp_web::web::session::decode_session_key(&session_key_b64)?);
let http = mcp_web::HttpConfig::new(bind_addr, database_url, public_base_url, admin_email);
let state = Arc::new(mcp_web::AppState::new(pool, crypto, signer, http, Arc::new(MyInstances)));
Because the consumer supplies these, it owns the deployment vocabulary: read the master and session keys from whatever env vars you like (youtrack-mcp keeps YOUTRACK_MCP_MASTER_KEY / YOUTRACK_MCP_SESSION_KEY). Crypto::from_env / SessionSigner::from_env are conveniences that read MCP_WEB_MASTER_KEY / MCP_WEB_SESSION_KEY if you want the generic names. Never generate either key at boot: a fresh key on restart silently invalidates every session and orphans every encrypted credential.
Migration composition
Generic tables and the consumer's tables share one SQLite file under one _sqlx_migrations table. Generic migrations reserve versions 1..=999; a consumer's own use 1000+. Use open_with(url, extra_migrations) (or concatenate generic_migrations() with your own and run one sqlx::Migrator). A DB created before adopting this crate is not upgradable in place, because the generic migration history is fresh; a new deployment starts on an empty database.
Development
cargo build
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --all --check
Releasing
Published to the Pandora's Box Forgejo Cargo registry on dev.a8n.run. A consumer depends on it with mcp-web = { version = "0.1", registry = "pandoras-box-cargo" } after defining that registry's sparse index in its own .cargo/config.toml.
Maintainers cut a release with just create-release <major|minor|hotfix>, which opens a release/vX.Y.Z PR. Merging it tags the repo and publishes the crate via .forgejo/workflows/create-release.yml and .forgejo/workflows/publish-crates.yml.
License
MIT. See LICENSE.md.