mod auth;
mod calls;
pub mod metrics;
mod discover;
mod identities;
mod management;
mod skills;
mod tools;
#[cfg(all(test, feature = "e2e"))]
mod e2e_handshake;
use std::path::{Path, PathBuf};
use axum::{
middleware,
routing::{get, post},
Router,
};
use rmcp::transport::{
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
StreamableHttpServerConfig,
};
use tokio_util::sync::CancellationToken;
pub use auth::SharedClients;
pub use identities::{IiInstance, SessionGauges};
pub use ic_agent::{self, Agent};
pub const IC_URL: &str = "https://icp-api.io";
pub struct McpConfig {
pub agent: Agent,
pub instance: IiInstance,
pub public_url: String,
pub mcp_path: String,
pub clients: SharedClients,
pub state_dir: PathBuf,
pub require_resource: bool,
}
#[derive(Clone)]
pub struct McpServer {
agent: Agent,
identities: identities::Identities,
skills: skills::SkillsCatalog,
store: auth::AuthStore,
public_url: String,
mcp_path: String,
ct: CancellationToken,
}
impl McpServer {
pub fn new(config: McpConfig) -> Self {
assert_eq!(
config.clients.state_dir(),
config.state_dir,
"McpConfig.clients must be loaded from McpConfig.state_dir \
(SharedClients::load(&state_dir))",
);
let public_url = normalize_public_url(&config.public_url);
let mcp_path = normalize_mount_path(&config.mcp_path);
let identities =
identities::Identities::new(config.instance, public_url.clone(), config.agent.clone());
let store = auth::AuthStore::new(
identities.clone(),
config.clients,
public_url.clone(),
mcp_path.clone(),
config.require_resource,
);
Self {
agent: config.agent,
identities,
skills: skills::SkillsCatalog::new(),
store,
public_url,
mcp_path,
ct: CancellationToken::new(),
}
}
pub fn state_dir(&self) -> &Path {
self.store.state_dir()
}
pub fn mcp_path(&self) -> &str {
&self.mcp_path
}
pub fn instance(&self) -> &IiInstance {
self.identities.instance()
}
pub fn mcp_router(&self) -> Router {
let mcp_service = {
let (agent, identities, skills) =
(self.agent.clone(), self.identities.clone(), self.skills.clone());
StreamableHttpService::new(
move || Ok(tools::IcTools::new(agent.clone(), identities.clone(), skills.clone())),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true)
.with_cancellation_token(self.ct.child_token())
.with_allowed_hosts(self.allowed_hosts()),
)
};
let mcp_cors = tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
axum::http::header::ACCEPT,
axum::http::HeaderName::from_static("mcp-protocol-version"),
axum::http::HeaderName::from_static("mcp-session-id"),
axum::http::HeaderName::from_static("last-event-id"),
])
.expose_headers([
axum::http::header::WWW_AUTHENTICATE,
axum::http::HeaderName::from_static("mcp-session-id"),
]);
let gated_mcp = Router::new()
.fallback_service(mcp_service)
.layer(middleware::from_fn_with_state(
self.store.clone(),
auth::require_token,
))
.layer(mcp_cors);
let oauth = Router::new()
.route("/authorize", get(auth::authorize))
.route("/connect/callback", get(auth::connect_callback_page))
.route("/connect/redeem", post(auth::connect_redeem))
.route("/token", post(auth::token))
.route("/register", post(auth::register))
.with_state(self.store.clone())
.layer(permissive_cors());
let oidc_alternate = Router::new()
.route(
"/.well-known/oauth-authorization-server",
get(auth::authorization_server_metadata),
)
.with_state(self.store.clone())
.layer(permissive_cors());
Router::new()
.nest("/oauth", oauth)
.merge(oidc_alternate)
.fallback_service(gated_mcp)
}
pub fn well_known_router(&self) -> Router {
Router::new()
.route(
&format!("/.well-known/oauth-authorization-server{}", self.mcp_path),
get(auth::authorization_server_metadata),
)
.route(
&format!("/.well-known/oauth-protected-resource{}", self.mcp_path),
get(auth::protected_resource_metadata),
)
.with_state(self.store.clone())
.layer(permissive_cors())
}
pub fn root_well_known_router(&self) -> Router {
Router::new()
.route(
"/.well-known/oauth-authorization-server",
get(auth::authorization_server_metadata),
)
.route(
"/.well-known/oauth-protected-resource",
get(auth::protected_resource_metadata),
)
.with_state(self.store.clone())
.layer(permissive_cors())
}
pub fn spawn_session_reaper(&self) {
let ids = self.identities.clone();
let store = self.store.clone();
let reap_ct = self.ct.child_token();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = reap_ct.cancelled() => break,
_ = tick.tick() => {
ids.reap_expired_sessions().await;
store.reap_expired().await;
}
}
}
});
}
pub async fn session_gauges(&self) -> SessionGauges {
self.identities.session_gauges().await
}
pub fn shutdown(&self) {
self.ct.cancel();
}
fn allowed_hosts(&self) -> Vec<String> {
allowed_hosts_for(&self.public_url)
}
}
fn allowed_hosts_for(public_url: &str) -> Vec<String> {
let mut hosts = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
"[::1]".to_string(),
];
let public_url = public_url.trim();
let candidate = if public_url.contains("://") {
public_url.to_string()
} else {
format!("https://{public_url}")
};
if let Ok(url) = url::Url::parse(&candidate) {
if let Some(host) = url.host_str() {
if !hosts.iter().any(|h| h == host) {
hosts.push(host.to_string());
}
if let Some(port) = url.port() {
hosts.push(format!("{host}:{port}"));
}
}
}
hosts
}
pub fn auth_callbacks_router(servers: &[&McpServer]) -> Router {
let stores: Vec<auth::AuthStore> = servers.iter().map(|s| s.store.clone()).collect();
Router::new()
.route(auth::AUTH_CALLBACKS_WELL_KNOWN, get(auth::auth_callbacks))
.with_state(stores)
.layer(permissive_cors())
}
fn permissive_cors() -> tower_http::cors::CorsLayer {
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
}
fn normalize_mount_path(path: &str) -> String {
let path = path.trim().trim_end_matches('/');
if path.is_empty() {
String::new()
} else if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn normalize_public_url(raw: &str) -> String {
let raw = raw.trim();
let candidate = if raw.contains("://") {
raw.to_string()
} else {
format!("https://{raw}")
};
if let Ok(url) = url::Url::parse(&candidate) {
if let Some(host) = url.host_str() {
let scheme = url.scheme();
return match url.port() {
Some(port) => format!("{scheme}://{host}:{port}"),
None => format!("{scheme}://{host}"),
};
}
}
raw.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod lib_tests {
use super::{
allowed_hosts_for, normalize_mount_path, normalize_public_url, Agent, IiInstance, McpConfig,
McpServer, SharedClients, IC_URL,
};
use std::path::PathBuf;
fn server_with_dir(dir: PathBuf) -> McpServer {
McpServer::new(McpConfig {
agent: Agent::builder().with_url(IC_URL).build().expect("agent"),
instance: IiInstance::prod().expect("prod instance"),
public_url: "https://mcp.example.com".into(),
mcp_path: "/mcp".into(),
clients: SharedClients::load(&dir),
state_dir: dir,
require_resource: true,
})
}
#[test]
fn state_dir_reports_the_store_directory() {
let dir = std::env::temp_dir().join("imcp2-lib-state-dir-test");
let server = server_with_dir(dir.clone());
assert_eq!(server.state_dir(), dir);
}
#[test]
#[should_panic(expected = "must be loaded from McpConfig.state_dir")]
fn mismatched_state_dir_and_clients_panics() {
let _ = McpServer::new(McpConfig {
agent: Agent::builder().with_url(IC_URL).build().expect("agent"),
instance: IiInstance::prod().expect("prod instance"),
public_url: "https://mcp.example.com".into(),
mcp_path: "/mcp".into(),
clients: SharedClients::load(std::env::temp_dir().join("dir-a")),
state_dir: std::env::temp_dir().join("dir-b"),
require_resource: true,
});
}
#[test]
fn public_url_normalizes_to_an_origin() {
assert_eq!(normalize_public_url("https://mcp.example.com"), "https://mcp.example.com");
assert_eq!(normalize_public_url("https://mcp.example.com/"), "https://mcp.example.com");
assert_eq!(normalize_public_url("https://mcp.example.com/mcp"), "https://mcp.example.com");
assert_eq!(normalize_public_url("mcp.example.com"), "https://mcp.example.com");
assert_eq!(normalize_public_url("HTTPS://mcp.example.com"), "https://mcp.example.com");
assert_eq!(normalize_public_url("https://mcp.example.com:443"), "https://mcp.example.com");
assert_eq!(normalize_public_url("https://mcp.example.com:8443"), "https://mcp.example.com:8443");
assert_eq!(normalize_public_url("http://localhost:8000"), "http://localhost:8000");
assert_eq!(normalize_public_url("http://[::1]:8080"), "http://[::1]:8080");
}
#[test]
fn mount_paths_are_normalized() {
assert_eq!(normalize_mount_path("/mcp"), "/mcp");
assert_eq!(normalize_mount_path("mcp"), "/mcp");
assert_eq!(normalize_mount_path("/mcp/"), "/mcp");
assert_eq!(normalize_mount_path("/api/v1/mcp"), "/api/v1/mcp");
assert_eq!(normalize_mount_path("/"), "");
assert_eq!(normalize_mount_path(""), "");
}
#[test]
fn allowed_hosts_cover_public_url_shapes() {
let has = |hosts: &[String], h: &str| hosts.iter().any(|x| x == h);
let hosts = allowed_hosts_for("https://mcp.example.com");
assert!(has(&hosts, "mcp.example.com"), "{hosts:?}");
assert!(has(&hosts, "localhost") && has(&hosts, "127.0.0.1"));
assert!(has(&hosts, "::1") && has(&hosts, "[::1]"), "{hosts:?}");
let hosts = allowed_hosts_for("https://mcp.example.com:8443");
assert!(has(&hosts, "mcp.example.com") && has(&hosts, "mcp.example.com:8443"), "{hosts:?}");
let hosts = allowed_hosts_for("mcp.example.com");
assert!(has(&hosts, "mcp.example.com"), "{hosts:?}");
let hosts = allowed_hosts_for("http://[2001:db8::1]:8080");
assert!(has(&hosts, "[2001:db8::1]") && has(&hosts, "[2001:db8::1]:8080"), "{hosts:?}");
let hosts = allowed_hosts_for("http://localhost:8000");
assert_eq!(hosts.iter().filter(|h| *h == "localhost").count(), 1);
assert!(has(&hosts, "localhost:8000"), "{hosts:?}");
}
}