mod stdio;
#[cfg(test)]
mod tests;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::Json;
use axum::Router;
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::http::header::{AUTHORIZATION, WWW_AUTHENTICATE};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use rmcp::ServiceExt;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::tower::StreamableHttpServerConfig;
use tokio_util::sync::CancellationToken;
use crate::catalog::CatalogHandle;
use crate::config::{Config, Secret};
use crate::error::ServeError;
use crate::server::PromptForgeServer;
pub(crate) const MCP_PATH: &str = "/mcp";
pub(crate) const HEALTHZ_PATH: &str = "/healthz";
const SSE_KEEP_ALIVE: Duration = Duration::from_secs(15);
pub(crate) fn build_router(
server: PromptForgeServer,
token: Arc<Secret>,
cancellation: CancellationToken,
allowed_hosts: Vec<String>,
) -> Router {
let service = StreamableHttpService::new(
move || Ok(server.clone()),
Arc::new(LocalSessionManager::default()),
streamable_config(cancellation, allowed_hosts),
);
Router::new()
.nest_service(MCP_PATH, service)
.layer(middleware::from_fn_with_state(token, require_bearer))
.route(HEALTHZ_PATH, get(healthz))
}
fn streamable_config(
cancellation: CancellationToken,
allowed_hosts: Vec<String>,
) -> StreamableHttpServerConfig {
StreamableHttpServerConfig::default()
.with_sse_keep_alive(Some(SSE_KEEP_ALIVE))
.with_legacy_session_mode(true)
.with_cancellation_token(cancellation)
.with_allowed_hosts(allowed_hosts)
}
fn resolve_allowed_hosts(
bind: SocketAddr,
configured: &[String],
) -> Result<Vec<String>, ServeError> {
if !configured.is_empty() {
return Ok(configured.to_vec());
}
if bind.ip().is_loopback() {
return Ok(vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
]);
}
Err(ServeError::allowed_hosts(bind))
}
pub(crate) async fn serve_http(
config: Arc<Config>,
catalog: Arc<CatalogHandle>,
tools: Arc<crate::PreparedTools>,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> Result<(), ServeError> {
let bind = config.server.bind;
let token = Arc::new(
config
.server
.token
.clone()
.ok_or_else(ServeError::missing_token)?,
);
let allowed_hosts = resolve_allowed_hosts(bind, &config.server.allowed_hosts)?;
let server = PromptForgeServer::new(Arc::clone(&config), catalog, tools);
let listener = tokio::net::TcpListener::bind(bind)
.await
.map_err(|source| ServeError::bind(bind, source))?;
tracing::info!("promptforge-mcp-server serving on http://{bind}{MCP_PATH}");
let cancellation = CancellationToken::new();
let router = build_router(server, token, cancellation.clone(), allowed_hosts);
let graceful = async move {
shutdown.await;
cancellation.cancel();
};
axum::serve(listener, router)
.with_graceful_shutdown(graceful)
.await
.map_err(ServeError::http)
}
pub(crate) async fn serve_stdio(
config: Arc<Config>,
catalog: Arc<CatalogHandle>,
tools: Arc<crate::PreparedTools>,
shutdown: impl Future<Output = ()> + Send,
) -> Result<(), ServeError> {
tracing::info!(
"promptforge-mcp-server serving on stdio; [server].bind ({}) and [server].token are not used on this transport",
config.server.bind
);
let server = PromptForgeServer::new(config, catalog, tools);
let (stdin, stdout) = rmcp::transport::stdio();
serve_stdio_on(server, stdin, stdout, shutdown).await
}
async fn serve_stdio_on<R, W>(
server: PromptForgeServer,
read: R,
write: W,
shutdown: impl Future<Output = ()> + Send,
) -> Result<(), ServeError>
where
R: tokio::io::AsyncRead + Send + Unpin + 'static,
W: tokio::io::AsyncWrite + Send + Unpin + 'static,
{
let transport = stdio::BoundedStdioTransport::new(read, write);
let mut shutdown = std::pin::pin!(shutdown);
let serve = server.serve(transport);
let running = {
let mut serve = std::pin::pin!(serve);
tokio::select! {
result = serve.as_mut() => {
result.map_err(ServeError::stdio)?
}
() = shutdown.as_mut() => return Ok(()),
}
};
let cancel = running.cancellation_token();
let mut waiting = std::pin::pin!(running.waiting());
tokio::select! {
result = waiting.as_mut() => {
result.map_err(ServeError::stdio)?;
}
() = shutdown.as_mut() => {
cancel.cancel();
waiting
.as_mut()
.await
.map_err(ServeError::stdio)?;
}
}
Ok(())
}
async fn healthz() -> impl IntoResponse {
Json(serde_json::json!({ "status": "serving" }))
}
async fn require_bearer(
State(token): State<Arc<Secret>>,
request: Request,
next: Next,
) -> Response {
let presented = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(bearer_credential);
let Some(presented) = presented.filter(|credential| !credential.is_empty()) else {
return unauthorized();
};
if constant_time_eq(presented.as_bytes(), token.expose().as_bytes()) {
next.run(request).await
} else {
unauthorized()
}
}
fn bearer_credential(header: &str) -> Option<&str> {
let (scheme, credential) = header.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then_some(credential.trim())
}
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
[(WWW_AUTHENTICATE, "Bearer")],
"unauthorized",
)
.into_response()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}