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