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