use serde::Deserialize;
use serde::Serialize;
use url::Url;
pub mod types;
pub use types::*;
pub const DEFAULT_SCOPE: &str = "";
pub const DEFAULT_SERVER: &str = "https://api.kontext.dev";
pub const DEFAULT_SERVER_NAME: &str = "kontext-dev";
pub const DEFAULT_RESOURCE: &str = "mcp-gateway";
pub const DEFAULT_AUTH_TIMEOUT_SECONDS: i64 = 300;
pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:3333/callback";
fn default_scope() -> String {
DEFAULT_SCOPE.to_string()
}
fn default_server() -> String {
DEFAULT_SERVER.to_string()
}
fn default_server_name() -> String {
DEFAULT_SERVER_NAME.to_string()
}
fn default_resource() -> String {
DEFAULT_RESOURCE.to_string()
}
fn default_open_connect_page_on_login() -> bool {
true
}
fn default_auth_timeout_seconds() -> i64 {
DEFAULT_AUTH_TIMEOUT_SECONDS
}
fn default_redirect_uri() -> String {
DEFAULT_REDIRECT_URI.to_string()
}
fn default_token_type() -> String {
"Bearer".to_string()
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct KontextDevConfig {
#[serde(default = "default_server")]
pub server: String,
pub client_id: String,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default = "default_scope")]
pub scope: String,
#[serde(default = "default_server_name")]
pub server_name: String,
#[serde(default = "default_resource")]
pub resource: String,
#[serde(default)]
pub integration_ui_url: Option<String>,
#[serde(default)]
pub integration_return_to: Option<String>,
#[serde(default = "default_open_connect_page_on_login")]
pub open_connect_page_on_login: bool,
#[serde(default = "default_auth_timeout_seconds")]
pub auth_timeout_seconds: i64,
#[serde(default)]
pub token_cache_path: Option<String>,
#[serde(default = "default_redirect_uri")]
pub redirect_uri: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct AccessToken {
pub access_token: String,
#[serde(default = "default_token_type")]
pub token_type: String,
#[serde(default)]
pub expires_in: Option<i64>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub scope: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct TokenExchangeToken {
pub access_token: String,
pub issued_token_type: String,
pub token_type: String,
#[serde(default)]
pub expires_in: Option<i64>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum KontextDevCoreError {
#[error("Kontext-Dev server URL is missing. Set `server`.")]
MissingServerUrl,
#[error("Kontext-Dev access token is empty")]
EmptyAccessToken,
#[error("failed to parse URL `{url}`")]
InvalidUrl {
url: String,
source: url::ParseError,
},
}
pub fn normalize_server_url(server: &str) -> String {
let mut url = server.trim().trim_end_matches('/').to_string();
if let Some(stripped) = url.strip_suffix("/api/v1") {
url = stripped.to_string();
}
if let Some(stripped) = url.strip_suffix("/mcp") {
url = stripped.to_string();
}
url.trim_end_matches('/').to_string()
}
fn parse_url(raw: &str) -> Result<Url, KontextDevCoreError> {
Url::parse(raw).map_err(|source| KontextDevCoreError::InvalidUrl {
url: raw.to_string(),
source,
})
}
pub fn resolve_server_base_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
let candidate = normalize_server_url(&config.server);
if candidate.is_empty() {
return Err(KontextDevCoreError::MissingServerUrl);
}
let parsed = parse_url(&candidate)?;
Ok(parsed.to_string().trim_end_matches('/').to_string())
}
fn join_url(base: &str, suffix: &str) -> Result<String, KontextDevCoreError> {
let base = format!("{}/", base.trim_end_matches('/'));
let base_url = parse_url(&base)?;
let joined = base_url
.join(suffix.trim_start_matches('/'))
.map_err(|source| KontextDevCoreError::InvalidUrl {
url: format!("{base}{suffix}"),
source,
})?;
Ok(joined.to_string())
}
pub fn resolve_mcp_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(&base, "mcp")
}
pub fn resolve_token_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(&base, "oauth2/token")
}
pub fn resolve_authorize_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(&base, "oauth2/authorize")
}
pub fn resolve_connect_session_url(
config: &KontextDevConfig,
) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(&base, "mcp/connect-session")
}
pub fn resolve_integration_oauth_init_url(
config: &KontextDevConfig,
integration_id: &str,
) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(
&base,
&format!("mcp/integrations/{integration_id}/oauth/init"),
)
}
pub fn resolve_integration_connection_url(
config: &KontextDevConfig,
integration_id: &str,
) -> Result<String, KontextDevCoreError> {
let base = resolve_server_base_url(config)?;
join_url(
&base,
&format!("mcp/integrations/{integration_id}/oauth/connection"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn config() -> KontextDevConfig {
KontextDevConfig {
server: "http://localhost:4000".to_string(),
client_id: "client".to_string(),
client_secret: None,
scope: default_scope(),
server_name: default_server_name(),
resource: default_resource(),
integration_ui_url: None,
integration_return_to: None,
open_connect_page_on_login: default_open_connect_page_on_login(),
auth_timeout_seconds: default_auth_timeout_seconds(),
token_cache_path: None,
redirect_uri: default_redirect_uri(),
}
}
#[test]
fn normalize_server_url_strips_api_and_mcp() {
assert_eq!(
normalize_server_url("http://localhost:4000"),
"http://localhost:4000"
);
assert_eq!(
normalize_server_url("http://localhost:4000/api/v1"),
"http://localhost:4000"
);
assert_eq!(
normalize_server_url("http://localhost:4000/mcp"),
"http://localhost:4000"
);
}
#[test]
fn default_scope_is_empty() {
assert_eq!(DEFAULT_SCOPE, "");
assert_eq!(default_scope(), "");
}
#[test]
fn default_server_is_api_origin() {
assert_eq!(DEFAULT_SERVER, "https://api.kontext.dev");
assert_eq!(default_server(), DEFAULT_SERVER);
}
#[test]
fn deserialize_without_server_uses_default_server() {
let cfg: KontextDevConfig = serde_json::from_value(serde_json::json!({
"client_id": "client",
"redirect_uri": "http://localhost:3000/callback"
}))
.expect("config should deserialize");
assert_eq!(cfg.server, DEFAULT_SERVER);
assert_eq!(cfg.client_id, "client");
assert_eq!(cfg.redirect_uri, "http://localhost:3000/callback");
}
#[test]
fn resolve_urls_from_server() {
let cfg = config();
assert_eq!(
resolve_mcp_url(&cfg).expect("mcp"),
"http://localhost:4000/mcp"
);
assert_eq!(
resolve_token_url(&cfg).expect("token"),
"http://localhost:4000/oauth2/token"
);
assert_eq!(
resolve_authorize_url(&cfg).expect("authorize"),
"http://localhost:4000/oauth2/authorize"
);
}
#[test]
fn reject_empty_server() {
let mut cfg = config();
cfg.server = " ".to_string();
assert!(matches!(
resolve_server_base_url(&cfg),
Err(KontextDevCoreError::MissingServerUrl)
));
}
}