1use serde::Deserialize;
2use serde::Serialize;
3use url::Url;
4
5pub mod types;
6pub use types::*;
7
8pub const DEFAULT_SCOPE: &str = "";
9pub const DEFAULT_SERVER: &str = "https://api.kontext.dev";
10pub const DEFAULT_SERVER_NAME: &str = "kontext-dev";
11pub const DEFAULT_RESOURCE: &str = "mcp-gateway";
12pub const DEFAULT_AUTH_TIMEOUT_SECONDS: i64 = 300;
13pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:3333/callback";
14
15fn default_scope() -> String {
16 DEFAULT_SCOPE.to_string()
17}
18
19fn default_server() -> String {
20 DEFAULT_SERVER.to_string()
21}
22
23fn default_server_name() -> String {
24 DEFAULT_SERVER_NAME.to_string()
25}
26
27fn default_resource() -> String {
28 DEFAULT_RESOURCE.to_string()
29}
30
31fn default_open_connect_page_on_login() -> bool {
32 true
33}
34
35fn default_auth_timeout_seconds() -> i64 {
36 DEFAULT_AUTH_TIMEOUT_SECONDS
37}
38
39fn default_redirect_uri() -> String {
40 DEFAULT_REDIRECT_URI.to_string()
41}
42
43fn default_token_type() -> String {
44 "Bearer".to_string()
45}
46
47#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
51#[serde(deny_unknown_fields)]
52pub struct KontextDevConfig {
53 #[serde(default = "default_server")]
55 pub server: String,
56
57 pub client_id: String,
59
60 #[serde(default)]
62 pub client_secret: Option<String>,
63
64 #[serde(default = "default_scope")]
69 pub scope: String,
70
71 #[serde(default = "default_server_name")]
73 pub server_name: String,
74
75 #[serde(default = "default_resource")]
77 pub resource: String,
78
79 #[serde(default)]
81 pub integration_ui_url: Option<String>,
82
83 #[serde(default)]
85 pub integration_return_to: Option<String>,
86
87 #[serde(default = "default_open_connect_page_on_login")]
89 pub open_connect_page_on_login: bool,
90
91 #[serde(default = "default_auth_timeout_seconds")]
93 pub auth_timeout_seconds: i64,
94
95 #[serde(default)]
97 pub token_cache_path: Option<String>,
98
99 #[serde(default = "default_redirect_uri")]
103 pub redirect_uri: String,
104}
105
106#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
107pub struct AccessToken {
108 pub access_token: String,
109 #[serde(default = "default_token_type")]
110 pub token_type: String,
111 #[serde(default)]
112 pub expires_in: Option<i64>,
113 #[serde(default)]
114 pub refresh_token: Option<String>,
115 #[serde(default)]
116 pub scope: Option<String>,
117}
118
119#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
120pub struct TokenExchangeToken {
121 pub access_token: String,
122 pub issued_token_type: String,
123 pub token_type: String,
124 #[serde(default)]
125 pub expires_in: Option<i64>,
126 #[serde(default)]
127 pub scope: Option<String>,
128 #[serde(default)]
129 pub refresh_token: Option<String>,
130}
131
132#[derive(Debug, thiserror::Error)]
133pub enum KontextDevCoreError {
134 #[error("Kontext-Dev server URL is missing. Set `server`.")]
135 MissingServerUrl,
136 #[error("Kontext-Dev access token is empty")]
137 EmptyAccessToken,
138 #[error("failed to parse URL `{url}`")]
139 InvalidUrl {
140 url: String,
141 source: url::ParseError,
142 },
143}
144
145pub fn normalize_server_url(server: &str) -> String {
146 let mut url = server.trim().trim_end_matches('/').to_string();
147
148 if let Some(stripped) = url.strip_suffix("/api/v1") {
149 url = stripped.to_string();
150 }
151
152 if let Some(stripped) = url.strip_suffix("/mcp") {
153 url = stripped.to_string();
154 }
155
156 url.trim_end_matches('/').to_string()
157}
158
159fn parse_url(raw: &str) -> Result<Url, KontextDevCoreError> {
160 Url::parse(raw).map_err(|source| KontextDevCoreError::InvalidUrl {
161 url: raw.to_string(),
162 source,
163 })
164}
165
166pub fn resolve_server_base_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
167 let candidate = normalize_server_url(&config.server);
168
169 if candidate.is_empty() {
170 return Err(KontextDevCoreError::MissingServerUrl);
171 }
172
173 let parsed = parse_url(&candidate)?;
174 Ok(parsed.to_string().trim_end_matches('/').to_string())
175}
176
177fn join_url(base: &str, suffix: &str) -> Result<String, KontextDevCoreError> {
178 let base = format!("{}/", base.trim_end_matches('/'));
179 let base_url = parse_url(&base)?;
180 let joined = base_url
181 .join(suffix.trim_start_matches('/'))
182 .map_err(|source| KontextDevCoreError::InvalidUrl {
183 url: format!("{base}{suffix}"),
184 source,
185 })?;
186 Ok(joined.to_string())
187}
188
189pub fn resolve_mcp_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
190 let base = resolve_server_base_url(config)?;
191 join_url(&base, "mcp")
192}
193
194pub fn resolve_token_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
195 let base = resolve_server_base_url(config)?;
196 join_url(&base, "oauth2/token")
197}
198
199pub fn resolve_authorize_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
200 let base = resolve_server_base_url(config)?;
201 join_url(&base, "oauth2/authorize")
202}
203
204pub fn resolve_connect_session_url(
205 config: &KontextDevConfig,
206) -> Result<String, KontextDevCoreError> {
207 let base = resolve_server_base_url(config)?;
208 join_url(&base, "mcp/connect-session")
209}
210
211pub fn resolve_integration_oauth_init_url(
212 config: &KontextDevConfig,
213 integration_id: &str,
214) -> Result<String, KontextDevCoreError> {
215 let base = resolve_server_base_url(config)?;
216 join_url(
217 &base,
218 &format!("mcp/integrations/{integration_id}/oauth/init"),
219 )
220}
221
222pub fn resolve_integration_connection_url(
223 config: &KontextDevConfig,
224 integration_id: &str,
225) -> Result<String, KontextDevCoreError> {
226 let base = resolve_server_base_url(config)?;
227 join_url(
228 &base,
229 &format!("mcp/integrations/{integration_id}/oauth/connection"),
230 )
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use pretty_assertions::assert_eq;
237
238 fn config() -> KontextDevConfig {
239 KontextDevConfig {
240 server: "http://localhost:4000".to_string(),
241 client_id: "client".to_string(),
242 client_secret: None,
243 scope: default_scope(),
244 server_name: default_server_name(),
245 resource: default_resource(),
246 integration_ui_url: None,
247 integration_return_to: None,
248 open_connect_page_on_login: default_open_connect_page_on_login(),
249 auth_timeout_seconds: default_auth_timeout_seconds(),
250 token_cache_path: None,
251 redirect_uri: default_redirect_uri(),
252 }
253 }
254
255 #[test]
256 fn normalize_server_url_strips_api_and_mcp() {
257 assert_eq!(
258 normalize_server_url("http://localhost:4000"),
259 "http://localhost:4000"
260 );
261 assert_eq!(
262 normalize_server_url("http://localhost:4000/api/v1"),
263 "http://localhost:4000"
264 );
265 assert_eq!(
266 normalize_server_url("http://localhost:4000/mcp"),
267 "http://localhost:4000"
268 );
269 }
270
271 #[test]
272 fn default_scope_is_empty() {
273 assert_eq!(DEFAULT_SCOPE, "");
274 assert_eq!(default_scope(), "");
275 }
276
277 #[test]
278 fn default_server_is_api_origin() {
279 assert_eq!(DEFAULT_SERVER, "https://api.kontext.dev");
280 assert_eq!(default_server(), DEFAULT_SERVER);
281 }
282
283 #[test]
284 fn deserialize_without_server_uses_default_server() {
285 let cfg: KontextDevConfig = serde_json::from_value(serde_json::json!({
286 "client_id": "client",
287 "redirect_uri": "http://localhost:3000/callback"
288 }))
289 .expect("config should deserialize");
290
291 assert_eq!(cfg.server, DEFAULT_SERVER);
292 assert_eq!(cfg.client_id, "client");
293 assert_eq!(cfg.redirect_uri, "http://localhost:3000/callback");
294 }
295
296 #[test]
297 fn resolve_urls_from_server() {
298 let cfg = config();
299 assert_eq!(
300 resolve_mcp_url(&cfg).expect("mcp"),
301 "http://localhost:4000/mcp"
302 );
303 assert_eq!(
304 resolve_token_url(&cfg).expect("token"),
305 "http://localhost:4000/oauth2/token"
306 );
307 assert_eq!(
308 resolve_authorize_url(&cfg).expect("authorize"),
309 "http://localhost:4000/oauth2/authorize"
310 );
311 }
312
313 #[test]
314 fn reject_empty_server() {
315 let mut cfg = config();
316 cfg.server = " ".to_string();
317 assert!(matches!(
318 resolve_server_base_url(&cfg),
319 Err(KontextDevCoreError::MissingServerUrl)
320 ));
321 }
322}