1use serde::Deserialize;
2use serde::Serialize;
3use url::Url;
4
5pub const DEFAULT_SCOPE: &str = "openid offline";
6pub const DEFAULT_SERVER_NAME: &str = "kontext-dev";
7pub const DEFAULT_RESOURCE: &str = "mcp-gateway";
8pub const DEFAULT_AUTH_TIMEOUT_SECONDS: i64 = 300;
9pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:3333/callback";
10
11fn default_scope() -> String {
12 DEFAULT_SCOPE.to_string()
13}
14
15fn default_server_name() -> String {
16 DEFAULT_SERVER_NAME.to_string()
17}
18
19fn default_resource() -> String {
20 DEFAULT_RESOURCE.to_string()
21}
22
23fn default_open_connect_page_on_login() -> bool {
24 true
25}
26
27fn default_auth_timeout_seconds() -> i64 {
28 DEFAULT_AUTH_TIMEOUT_SECONDS
29}
30
31fn default_redirect_uri() -> String {
32 DEFAULT_REDIRECT_URI.to_string()
33}
34
35fn default_token_type() -> String {
36 "Bearer".to_string()
37}
38
39#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
45pub struct KontextDevConfig {
46 #[serde(default)]
51 pub server: Option<String>,
52
53 #[serde(default)]
56 pub mcp_url: Option<String>,
57
58 #[serde(default)]
61 pub token_url: Option<String>,
62
63 pub client_id: String,
65
66 #[serde(default)]
68 pub client_secret: Option<String>,
69
70 #[serde(default = "default_scope")]
72 pub scope: String,
73
74 #[serde(default = "default_server_name")]
76 pub server_name: String,
77
78 #[serde(default = "default_resource")]
80 pub resource: String,
81
82 #[serde(default)]
84 pub integration_ui_url: Option<String>,
85
86 #[serde(default)]
88 pub integration_return_to: Option<String>,
89
90 #[serde(default = "default_open_connect_page_on_login")]
92 pub open_connect_page_on_login: bool,
93
94 #[serde(default = "default_auth_timeout_seconds")]
96 pub auth_timeout_seconds: i64,
97
98 #[serde(default)]
100 pub token_cache_path: Option<String>,
101
102 #[serde(default = "default_redirect_uri")]
106 pub redirect_uri: String,
107}
108
109#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
110pub struct AccessToken {
111 pub access_token: String,
112 #[serde(default = "default_token_type")]
113 pub token_type: String,
114 #[serde(default)]
115 pub expires_in: Option<i64>,
116 #[serde(default)]
117 pub refresh_token: Option<String>,
118 #[serde(default)]
119 pub scope: Option<String>,
120}
121
122#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
123pub struct TokenExchangeToken {
124 pub access_token: String,
125 pub issued_token_type: String,
126 pub token_type: String,
127 #[serde(default)]
128 pub expires_in: Option<i64>,
129 #[serde(default)]
130 pub scope: Option<String>,
131 #[serde(default)]
132 pub refresh_token: Option<String>,
133}
134
135#[derive(Debug, thiserror::Error)]
136pub enum KontextDevCoreError {
137 #[error("Kontext-Dev server URL is missing. Set `server` or legacy `mcp_url`/`token_url`.")]
138 MissingServerUrl,
139 #[error("Kontext-Dev access token is empty")]
140 EmptyAccessToken,
141 #[error("failed to parse URL `{url}`")]
142 InvalidUrl {
143 url: String,
144 source: url::ParseError,
145 },
146}
147
148pub fn normalize_server_url(server: &str) -> String {
149 let mut url = server.trim().trim_end_matches('/').to_string();
150
151 if let Some(stripped) = url.strip_suffix("/api/v1") {
152 url = stripped.to_string();
153 }
154
155 if let Some(stripped) = url.strip_suffix("/mcp") {
156 url = stripped.to_string();
157 }
158
159 url.trim_end_matches('/').to_string()
160}
161
162fn parse_url(raw: &str) -> Result<Url, KontextDevCoreError> {
163 Url::parse(raw).map_err(|source| KontextDevCoreError::InvalidUrl {
164 url: raw.to_string(),
165 source,
166 })
167}
168
169fn derive_server_from_token_url(token_url: &str) -> Option<String> {
170 let normalized = token_url.trim().trim_end_matches('/');
171 normalized
172 .strip_suffix("/oauth2/token")
173 .map(ToString::to_string)
174}
175
176pub fn resolve_server_base_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
177 let candidate = if let Some(server) = &config.server {
178 normalize_server_url(server)
179 } else if let Some(mcp_url) = &config.mcp_url {
180 normalize_server_url(mcp_url)
181 } else if let Some(token_url) = &config.token_url {
182 derive_server_from_token_url(token_url)
183 .map(|s| normalize_server_url(&s))
184 .unwrap_or_else(|| normalize_server_url(token_url))
185 } else {
186 return Err(KontextDevCoreError::MissingServerUrl);
187 };
188
189 let parsed = parse_url(&candidate)?;
190 Ok(parsed.to_string().trim_end_matches('/').to_string())
191}
192
193fn join_url(base: &str, suffix: &str) -> Result<String, KontextDevCoreError> {
194 let base = format!("{}/", base.trim_end_matches('/'));
195 let base_url = parse_url(&base)?;
196 let joined = base_url
197 .join(suffix.trim_start_matches('/'))
198 .map_err(|source| KontextDevCoreError::InvalidUrl {
199 url: format!("{base}{suffix}"),
200 source,
201 })?;
202 Ok(joined.to_string())
203}
204
205pub fn resolve_mcp_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
206 if let Some(mcp_url) = &config.mcp_url {
207 let parsed = parse_url(mcp_url)?;
208 return Ok(parsed.to_string());
209 }
210
211 let base = resolve_server_base_url(config)?;
212 join_url(&base, "mcp")
213}
214
215pub fn resolve_token_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
216 if let Some(token_url) = &config.token_url {
217 let parsed = parse_url(token_url)?;
218 return Ok(parsed.to_string());
219 }
220
221 let base = resolve_server_base_url(config)?;
222 join_url(&base, "oauth2/token")
223}
224
225pub fn resolve_authorize_url(config: &KontextDevConfig) -> Result<String, KontextDevCoreError> {
226 let base = resolve_server_base_url(config)?;
227 join_url(&base, "oauth2/authorize")
228}
229
230pub fn resolve_connect_session_url(
231 config: &KontextDevConfig,
232) -> Result<String, KontextDevCoreError> {
233 let base = resolve_server_base_url(config)?;
234 join_url(&base, "mcp/connect-session")
235}
236
237pub fn resolve_integration_oauth_init_url(
238 config: &KontextDevConfig,
239 integration_id: &str,
240) -> Result<String, KontextDevCoreError> {
241 let base = resolve_server_base_url(config)?;
242 join_url(
243 &base,
244 &format!("mcp/integrations/{integration_id}/oauth/init"),
245 )
246}
247
248pub fn resolve_integration_connection_url(
249 config: &KontextDevConfig,
250 integration_id: &str,
251) -> Result<String, KontextDevCoreError> {
252 let base = resolve_server_base_url(config)?;
253 join_url(
254 &base,
255 &format!("mcp/integrations/{integration_id}/oauth/connection"),
256 )
257}
258
259pub fn build_mcp_url(
263 config: &KontextDevConfig,
264 access_token: &str,
265) -> Result<String, KontextDevCoreError> {
266 if access_token.is_empty() {
267 return Err(KontextDevCoreError::EmptyAccessToken);
268 }
269
270 let mcp_url = resolve_mcp_url(config)?;
271 let mut url = parse_url(&mcp_url)?;
272 url.query_pairs_mut()
273 .append_pair("access_key", access_token);
274 Ok(url.to_string())
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use pretty_assertions::assert_eq;
281
282 fn config() -> KontextDevConfig {
283 KontextDevConfig {
284 server: Some("http://localhost:4000".to_string()),
285 mcp_url: None,
286 token_url: None,
287 client_id: "client".to_string(),
288 client_secret: None,
289 scope: default_scope(),
290 server_name: default_server_name(),
291 resource: default_resource(),
292 integration_ui_url: None,
293 integration_return_to: None,
294 open_connect_page_on_login: default_open_connect_page_on_login(),
295 auth_timeout_seconds: default_auth_timeout_seconds(),
296 token_cache_path: None,
297 redirect_uri: default_redirect_uri(),
298 }
299 }
300
301 #[test]
302 fn normalize_server_url_strips_api_and_mcp() {
303 assert_eq!(
304 normalize_server_url("http://localhost:4000"),
305 "http://localhost:4000"
306 );
307 assert_eq!(
308 normalize_server_url("http://localhost:4000/api/v1"),
309 "http://localhost:4000"
310 );
311 assert_eq!(
312 normalize_server_url("http://localhost:4000/mcp"),
313 "http://localhost:4000"
314 );
315 }
316
317 #[test]
318 fn resolve_urls_from_server() {
319 let cfg = config();
320 assert_eq!(
321 resolve_mcp_url(&cfg).expect("mcp"),
322 "http://localhost:4000/mcp"
323 );
324 assert_eq!(
325 resolve_token_url(&cfg).expect("token"),
326 "http://localhost:4000/oauth2/token"
327 );
328 assert_eq!(
329 resolve_authorize_url(&cfg).expect("authorize"),
330 "http://localhost:4000/oauth2/authorize"
331 );
332 }
333
334 #[test]
335 fn resolve_server_from_legacy_mcp_url() {
336 let mut cfg = config();
337 cfg.server = None;
338 cfg.mcp_url = Some("http://localhost:4000/mcp".to_string());
339 assert_eq!(
340 resolve_server_base_url(&cfg).expect("base"),
341 "http://localhost:4000"
342 );
343 }
344
345 #[test]
346 fn resolve_server_from_legacy_token_url() {
347 let mut cfg = config();
348 cfg.server = None;
349 cfg.token_url = Some("http://localhost:4000/oauth2/token".to_string());
350 assert_eq!(
351 resolve_server_base_url(&cfg).expect("base"),
352 "http://localhost:4000"
353 );
354 }
355
356 #[test]
357 fn build_mcp_url_appends_access_key() {
358 let cfg = config();
359 let url = build_mcp_url(&cfg, "token").expect("build_mcp_url failed");
360 assert_eq!(url, "http://localhost:4000/mcp?access_key=token");
361 }
362}