Skip to main content

kontext_dev_core/
lib.rs

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/// Configuration for the Kontext-Dev Rust SDK.
35///
36/// New-style runtime configuration uses `server` with PKCE + token exchange.
37/// Legacy fields (`mcp_url`, `token_url`, `client_secret`) are still accepted
38/// for backward compatibility and migration.
39#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
40pub struct KontextDevConfig {
41    /// Base API origin (for example: https://api.kontext.dev).
42    ///
43    /// If not set, the SDK falls back to deriving the origin from `mcp_url`
44    /// or `token_url`.
45    #[serde(default)]
46    pub server: Option<String>,
47
48    /// Optional explicit MCP gateway URL.
49    /// Legacy field that is still supported.
50    #[serde(default)]
51    pub mcp_url: Option<String>,
52
53    /// Optional explicit OAuth token endpoint URL.
54    /// Legacy field that is still supported.
55    #[serde(default)]
56    pub token_url: Option<String>,
57
58    /// OAuth client id.
59    pub client_id: String,
60
61    /// Optional OAuth client secret (for confidential clients).
62    #[serde(default)]
63    pub client_secret: Option<String>,
64
65    /// Requested OAuth scopes for authorization.
66    #[serde(default = "default_scope")]
67    pub scope: String,
68
69    /// Name used when registering this server in clients.
70    #[serde(default = "default_server_name")]
71    pub server_name: String,
72
73    /// Default resource identifier for RFC 8693 token exchange.
74    #[serde(default = "default_resource")]
75    pub resource: String,
76
77    /// Optional hosted connect UI URL (for example: https://app.kontext.dev).
78    #[serde(default)]
79    pub integration_ui_url: Option<String>,
80
81    /// Optional return URL for per-integration OAuth flows.
82    #[serde(default)]
83    pub integration_return_to: Option<String>,
84
85    /// If true, SDK consumers may open the connect page after login.
86    #[serde(default = "default_open_connect_page_on_login")]
87    pub open_connect_page_on_login: bool,
88
89    /// OAuth callback wait timeout in seconds.
90    #[serde(default = "default_auth_timeout_seconds")]
91    pub auth_timeout_seconds: i64,
92
93    /// Optional token cache path for persisted login state.
94    #[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
248/// Legacy helper that appends `access_key` query parameter to the MCP URL.
249///
250/// Prefer bearer tokens with `resolve_mcp_url` + Authorization header for new integrations.
251pub 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}