mcpway 0.1.1

Run MCP stdio servers over SSE, WebSocket, and Streamable HTTP transports.
use std::net::SocketAddr;
use std::sync::Arc;

use crate::runtime::store::{RuntimeArgsStore, RuntimeArgsUpdate};
use crate::runtime::{RuntimeApplyResult, RuntimeScope, RuntimeUpdate};
use axum::extract::{ConnectInfo, Path, State};
use axum::http::StatusCode;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures::future::BoxFuture;

#[derive(Clone)]
pub struct AdminState {
    runtime: RuntimeArgsStore,
    handler: Arc<dyn Fn(RuntimeUpdate) -> BoxFuture<'static, RuntimeApplyResult> + Send + Sync>,
}

pub async fn spawn_admin_server(
    addr: SocketAddr,
    runtime: RuntimeArgsStore,
    handler: Arc<dyn Fn(RuntimeUpdate) -> BoxFuture<'static, RuntimeApplyResult> + Send + Sync>,
) {
    let state = AdminState { runtime, handler };

    let router = Router::new()
        .route("/runtime/defaults", post(update_defaults))
        .route("/runtime/session/:id", post(update_session))
        .route("/runtime/sessions", get(list_sessions))
        .with_state(state)
        .layer(middleware::from_fn(only_loopback));

    tracing::info!("Runtime admin endpoint listening on http://{addr}");

    let listener = match tokio::net::TcpListener::bind(addr).await {
        Ok(listener) => listener,
        Err(err) => {
            tracing::error!("Runtime admin bind error: {err}");
            return;
        }
    };
    let server = axum::serve(
        listener,
        router.into_make_service_with_connect_info::<SocketAddr>(),
    );
    if let Err(err) = server.await {
        tracing::error!("Runtime admin server error: {err}");
    }
}

async fn only_loopback(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    req: axum::http::Request<axum::body::Body>,
    next: Next,
) -> Response {
    if !addr.ip().is_loopback() {
        return StatusCode::FORBIDDEN.into_response();
    }
    next.run(req).await
}

async fn update_defaults(
    State(state): State<AdminState>,
    Json(update): Json<RuntimeArgsUpdate>,
) -> impl IntoResponse {
    let update_msg = RuntimeUpdate {
        scope: RuntimeScope::Global,
        update,
    };
    Json((state.handler)(update_msg).await)
}

async fn update_session(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    Json(update): Json<RuntimeArgsUpdate>,
) -> impl IntoResponse {
    let update_msg = RuntimeUpdate {
        scope: RuntimeScope::Session(id),
        update,
    };
    Json((state.handler)(update_msg).await)
}

async fn list_sessions(State(state): State<AdminState>) -> impl IntoResponse {
    let sessions = state.runtime.list_sessions().await;
    Json(sessions)
}