Rust crates for an MCP to connect to REST web services
  • Rust 95.7%
  • HTML 3.7%
  • Just 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
David 8b25ed5e8a
All checks were successful
Check / fmt + clippy + build + tests (push) Successful in 57s
Merge pull request 'chore(common): bump the common submodule to 4de5dfa' (#9) from chore/GOV-45-update-common-submodule into main
Reviewed-on: #9
2026-08-08 02:33:56 +02:00
.cargo ci: add release and crate-publish workflows 2026-07-17 20:31:06 -04:00
.forgejo/workflows ci: run compile jobs on the dev runner 2026-07-31 21:07:35 -04:00
common@4de5dfa607 chore(common): bump the common submodule to 4de5dfa 2026-08-07 18:48:27 -04:00
migrations chore: bootstrap mcp-web crate with crypto, TOTP, password, and DB layer 2026-07-17 15:18:35 -04:00
src docs(keys): use the Nushell key-generation recipe 2026-08-02 10:22:54 -04:00
tests docs(keys): use the Nushell key-generation recipe 2026-08-02 10:22:54 -04:00
.gitignore chore: bootstrap mcp-web crate with crypto, TOTP, password, and DB layer 2026-07-17 15:18:35 -04:00
.gitmodules chore: bootstrap mcp-web crate with crypto, TOTP, password, and DB layer 2026-07-17 15:18:35 -04:00
askama.toml feat: extract the generic web layer into mcp-web 2026-07-17 15:52:06 -04:00
Cargo.lock feat: extract the generic web layer into mcp-web 2026-07-17 15:52:06 -04:00
Cargo.toml ci: add release and crate-publish workflows 2026-07-17 20:31:06 -04:00
CLAUDE.md ci: run compile jobs on the dev runner 2026-07-31 21:07:35 -04:00
justfile chore(just): list recipes when run with no arguments 2026-07-31 20:27:55 -04:00
LICENSE.md chore: bootstrap mcp-web crate with crypto, TOTP, password, and DB layer 2026-07-17 15:18:35 -04:00
README.md ci: add release and crate-publish workflows 2026-07-17 20:31:06 -04:00

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.