use axum::{response::Html, routing::get, Json, Router};
use imcp2::{auth_callbacks_router, Agent, IiInstance, McpConfig, McpServer, SharedClients, IC_URL};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn bind_address() -> String {
let port = std::env::var("PORT").unwrap_or_else(|_| "8000".to_string());
format!("0.0.0.0:{port}")
}
fn public_url() -> String {
std::env::var("PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8000".to_string())
}
fn state_dir() -> std::path::PathBuf {
std::env::var_os("IMCP2_STATE_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn require_resource() -> bool {
match std::env::var("OAUTH_REQUIRE_RESOURCE") {
Ok(v) => !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"),
Err(_) => true,
}
}
fn serve_beta() -> bool {
std::env::var("MCP_SERVE_BETA")
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
fn serve_metrics() -> bool {
std::env::var("MCP_SERVE_METRICS")
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
const INDEX_HTML: &str = include_str!("assets/index.html");
fn metrics_router(registry: prometheus::Registry, metrics: imcp2::metrics::Metrics) -> Router {
Router::new().route(
"/metrics",
get(move || {
let (registry, metrics) = (registry.clone(), metrics.clone());
async move {
let started = std::time::Instant::now();
metrics.refresh().await;
let encoded = {
use prometheus::Encoder;
let mut buf = Vec::new();
prometheus::TextEncoder::new()
.encode(®istry.gather(), &mut buf)
.map_err(|e| e.to_string())
.and_then(|()| String::from_utf8(buf).map_err(|e| e.to_string()))
};
metrics.observe_scrape(started.elapsed().as_secs_f64());
match encoded {
Ok(body) => (
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
),
Err(e) => {
tracing::error!(error = %e, "failed to encode metrics");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
String::from("failed to encode metrics\n"),
)
}
}
}
}),
)
}
const PUBLIC_PAGES: &[&str] = &["/", "/privacy-policy", "/terms", "/support"];
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn sitemap_xml(public_url: &str) -> String {
let origin = xml_escape(public_url.trim_end_matches('/'));
let urls: String = PUBLIC_PAGES
.iter()
.map(|p| format!(" <url><loc>{origin}{}</loc></url>\n", if *p == "/" { "/" } else { p }))
.collect();
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n\
{urls}</urlset>\n"
)
}
fn robots_txt(public_url: &str) -> String {
let origin = public_url.trim_end_matches('/');
format!(
"User-agent: *\n\
Allow: /\n\
Disallow: /mcp\n\
Disallow: /mcp-beta\n\
Disallow: /version\n\
Disallow: /status/\n\
Disallow: /.well-known/\n\
\n\
Sitemap: {origin}/sitemap.xml\n"
)
}
fn site_metadata_router(public_url: &str) -> Router {
let sitemap = sitemap_xml(public_url);
let robots = robots_txt(public_url);
Router::new()
.route(
"/sitemap.xml",
get(move || {
let sitemap = sitemap.clone();
async move { ([(axum::http::header::CONTENT_TYPE, "application/xml")], sitemap) }
}),
)
.route(
"/robots.txt",
get(move || {
let robots = robots.clone();
async move {
([(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], robots)
}
}),
)
}
fn openai_apps_challenge_router(token: Option<String>) -> Router {
let token = token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
Router::new().route(
"/.well-known/openai-apps-challenge",
get(move || {
let token = token.clone();
async move {
use axum::response::IntoResponse;
match token {
Some(t) => (axum::http::StatusCode::OK, t).into_response(),
None => axum::http::StatusCode::NOT_FOUND.into_response(),
}
}
}),
)
}
const PRIVACY_POLICY_HTML: &str = include_str!("assets/privacy-policy.html");
const DFINITY_LOGO_SVG: &str = include_str!("assets/dfinity-logo.svg");
fn privacy_policy_page() -> &'static str {
static PAGE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
PAGE.get_or_init(|| PRIVACY_POLICY_HTML.replace("__LOGO__", DFINITY_LOGO_SVG))
}
const SUPPORT_HTML: &str = include_str!("assets/support.html");
fn support_page() -> &'static str {
static PAGE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
PAGE.get_or_init(|| SUPPORT_HTML.replace("__LOGO__", DFINITY_LOGO_SVG))
}
const TERMS_HTML: &str = include_str!("assets/terms.html");
fn terms_page() -> &'static str {
static PAGE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
PAGE.get_or_init(|| TERMS_HTML.replace("__LOGO__", DFINITY_LOGO_SVG))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".to_string().into()),
)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
let agent = Agent::builder().with_url(IC_URL).build()?;
tracing::info!("built ic-agent against {IC_URL}");
let public_url = public_url();
let state_dir = state_dir();
let clients = SharedClients::load(&state_dir);
let prod = McpServer::new(McpConfig {
agent: agent.clone(),
instance: IiInstance::prod().map_err(anyhow::Error::msg)?,
public_url: public_url.clone(),
mcp_path: "/mcp".into(),
clients: clients.clone(),
state_dir: state_dir.clone(),
require_resource: require_resource(),
});
prod.spawn_session_reaper();
let beta = if serve_beta() {
let beta = McpServer::new(McpConfig {
agent,
instance: IiInstance::beta().map_err(anyhow::Error::msg)?,
public_url: public_url.clone(),
mcp_path: "/mcp-beta".into(),
clients,
state_dir,
require_resource: require_resource(),
});
beta.spawn_session_reaper();
Some(beta)
} else {
None
};
let (ver_prod, ver_beta) = (prod.clone(), beta.clone());
let servers: Vec<&McpServer> = std::iter::once(&prod).chain(beta.as_ref()).collect();
let registry = prometheus::Registry::new();
let metrics = imcp2::metrics::Metrics::new(
®istry,
env!("CARGO_PKG_VERSION"),
option_env!("GIT_SHA").unwrap_or("unknown"),
started_at,
&servers,
)?;
imcp2::metrics::register_process_collector(®istry)?;
let instances = serde_json::Value::Array(
std::iter::once(&prod)
.chain(beta.as_ref())
.map(|s| {
let i = s.instance();
serde_json::json!({
"name": i.name,
"mcp_path": s.mcp_path(),
"ii_origin": i.ii_url,
"ii_canister": i.ii_canister.to_text(),
})
})
.collect(),
);
let mut app = Router::new()
.route("/", get(|| async { Html(INDEX_HTML) }))
.route("/privacy-policy", get(|| async { Html(privacy_policy_page()) }))
.route("/support", get(|| async { Html(support_page()) }))
.route("/terms", get(|| async { Html(terms_page()) }))
.route(
"/version",
get(move || {
let ver_prod = ver_prod.clone();
let ver_beta = ver_beta.clone();
let instances = instances.clone();
async move {
let prod = ver_prod.session_gauges().await;
let beta = match &ver_beta {
Some(b) => Some(b.session_gauges().await),
None => None,
};
let (beta_live, beta_active) =
beta.map(|g| (g.live, g.active)).unwrap_or((0, 0));
Json(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"commit": option_env!("GIT_SHA").unwrap_or("unknown"),
"built_at": option_env!("BUILD_TIME").and_then(|s| s.parse::<u64>().ok()),
"started_at": started_at,
"instances": instances,
"live_sessions": { "prod": prod.live, "beta": beta_live },
"active_sessions": { "prod": prod.active, "beta": beta_active },
}))
}
}),
)
.nest_service(prod.mcp_path(), prod.mcp_router())
.merge(prod.well_known_router())
.merge(prod.root_well_known_router())
.merge(openai_apps_challenge_router(
std::env::var("OPENAI_APPS_CHALLENGE_TOKEN").ok(),
))
.merge(site_metadata_router(&public_url));
if serve_metrics() {
app = app.merge(metrics_router(registry, metrics.clone()));
} else {
tracing::info!(
"/metrics not served; set MCP_SERVE_METRICS=1 to enable (do not expose it publicly)"
);
}
if let Some(beta) = &beta {
app = app
.nest_service(beta.mcp_path(), beta.mcp_router())
.merge(beta.well_known_router());
}
let app = app
.merge(auth_callbacks_router(&servers))
.layer(axum::middleware::from_fn_with_state(
metrics.clone(),
imcp2::metrics::write_request_metrics,
))
.layer(axum::middleware::from_fn(
imcp2::metrics::write_request_logs,
));
let bind = bind_address();
let listener = tokio::net::TcpListener::bind(&bind).await?;
match beta.as_ref() {
Some(beta) => tracing::info!(
"listening on http://{bind} (MCP at {} and {}, OAuth under each mount)",
prod.mcp_path(),
beta.mcp_path(),
),
None => tracing::info!(
"listening on http://{bind} (MCP at {}, OAuth under it)",
prod.mcp_path(),
),
}
let serve_result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
prod.shutdown();
if let Some(beta) = &beta {
beta.shutdown();
}
serve_result?;
Ok(())
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut term) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
Err(e) => {
tracing::warn!("could not install SIGTERM handler ({e}); draining on SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
tracing::info!("shutdown signal received; draining in-flight requests");
}
#[cfg(test)]
mod tests {
use super::{
metrics_router, openai_apps_challenge_router, serve_metrics, site_metadata_router,
sitemap_xml, PUBLIC_PAGES,
};
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[test]
fn the_metrics_endpoint_is_off_unless_explicitly_enabled() {
std::env::remove_var("MCP_SERVE_METRICS");
assert!(!serve_metrics(), "unset must mean off");
for on in ["1", "true", "yes", "on", "TRUE", " on "] {
std::env::set_var("MCP_SERVE_METRICS", on);
assert!(serve_metrics(), "{on:?} should enable the endpoint");
}
for off in ["", "0", "false", "no", "off", "please", "2", "-1"] {
std::env::set_var("MCP_SERVE_METRICS", off);
assert!(!serve_metrics(), "{off:?} must not enable the endpoint");
}
std::env::remove_var("MCP_SERVE_METRICS");
}
#[tokio::test]
async fn metrics_endpoint_renders_the_registry_and_times_the_previous_scrape() {
let registry = prometheus::Registry::new();
let metrics =
imcp2::metrics::Metrics::new(®istry, "1.2.3", "abc1234", 1_700_000_000, &[])
.unwrap();
let app = metrics_router(registry, metrics);
let scrape = |app: axum::Router| async move {
let resp = app
.oneshot(Request::get("/metrics").body(axum::body::Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let content_type =
resp.headers().get("content-type").map(|v| v.to_str().unwrap().to_string());
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(body.to_vec()).unwrap(), content_type)
};
let (status, body, content_type) = scrape(app.clone()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(content_type.as_deref(), Some("text/plain; version=0.0.4; charset=utf-8"));
assert!(body.contains(r#"imcp2_build_info{commit="abc1234",version="1.2.3"} 1"#), "{body}");
assert!(body.contains("imcp2_process_start_time_seconds 1700000000"), "{body}");
assert!(body.contains("imcp2_metrics_scrape_duration_seconds_count 0"), "{body}");
let (_, body, _) = scrape(app).await;
assert!(body.contains("imcp2_metrics_scrape_duration_seconds_count 1"), "{body}");
}
async fn fetch(public_url: &str, path: &str) -> (StatusCode, String, Option<String>) {
let resp = site_metadata_router(public_url)
.oneshot(Request::get(path).body(axum::body::Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let content_type =
resp.headers().get("content-type").map(|v| v.to_str().unwrap().to_string());
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(body.to_vec()).unwrap(), content_type)
}
#[tokio::test]
async fn sitemap_lists_every_public_page_as_an_absolute_url() {
let (status, body, content_type) =
fetch("https://mcp.internetcomputer.org", "/sitemap.xml").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(content_type.as_deref(), Some("application/xml"));
for page in PUBLIC_PAGES {
let loc = format!("<loc>https://mcp.internetcomputer.org{page}</loc>");
assert_eq!(body.matches(&loc).count(), 1, "{page} should appear once in:\n{body}");
}
assert_eq!(body.matches("<loc>").count(), PUBLIC_PAGES.len());
for hidden in ["/version", "/status/", "/.well-known", "/mcp<", "/mcp-beta"] {
assert!(!body.contains(hidden), "sitemap must not list {hidden}:\n{body}");
}
}
#[tokio::test]
async fn sitemap_normalizes_a_trailing_slash_on_the_public_url() {
let body = sitemap_xml("https://example.test/");
assert!(body.contains("<loc>https://example.test/</loc>"));
assert!(body.contains("<loc>https://example.test/terms</loc>"));
assert!(!body.contains("//terms"));
}
#[tokio::test]
async fn sitemap_escapes_xml_metacharacters_in_the_origin() {
let body = sitemap_xml("https://example.test/?a=1&b=2");
assert!(body.contains("&b=2"), "{body}");
assert!(!body.contains("&b=2"));
}
#[tokio::test]
async fn robots_points_at_the_sitemap_and_excludes_the_machine_surface() {
let (status, body, content_type) =
fetch("https://mcp.internetcomputer.org/", "/robots.txt").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(content_type.as_deref(), Some("text/plain; charset=utf-8"));
assert!(body.contains("Sitemap: https://mcp.internetcomputer.org/sitemap.xml"), "{body}");
for path in ["/mcp", "/mcp-beta", "/version", "/status/", "/.well-known/"] {
assert!(body.contains(&format!("Disallow: {path}\n")), "{path} missing:\n{body}");
}
}
async fn challenge(token: Option<&str>) -> (StatusCode, String, Option<String>) {
let app = openai_apps_challenge_router(token.map(str::to_string));
let resp = app
.oneshot(
Request::get("/.well-known/openai-apps-challenge")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.map(|v| v.to_str().unwrap().to_string());
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(body.to_vec()).unwrap(), content_type)
}
#[tokio::test]
async fn openai_challenge_serves_the_bare_token_when_configured() {
let (status, body, content_type) = challenge(Some(" tok-123\n")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "tok-123");
assert!(content_type.unwrap().starts_with("text/plain"));
}
#[tokio::test]
async fn openai_challenge_is_404_when_unset_or_blank() {
for token in [None, Some(""), Some(" \n")] {
let (status, body, _) = challenge(token).await;
assert_eq!(status, StatusCode::NOT_FOUND, "token {token:?}");
assert_eq!(body, "", "token {token:?}");
}
}
}