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