Skip to main content

kontext_dev_core/
lib.rs

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/// 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    /// Optional OAuth scopes for authorization (space-delimited).
71    ///
72    /// Empty by default to avoid requesting scopes that are not allowed
73    /// for a given OAuth client configuration.
74    #[serde(default = "default_scope")]
75    pub scope: String,
76
77    /// Name used when registering this server in clients.
78    #[serde(default = "default_server_name")]
79    pub server_name: String,
80
81    /// Default resource identifier for RFC 8693 token exchange.
82    #[serde(default = "default_resource")]
83    pub resource: String,
84
85    /// Optional hosted connect UI URL (for example: https://app.kontext.dev).
86    #[serde(default)]
87    pub integration_ui_url: Option<String>,
88
89    /// Optional return URL for per-integration OAuth flows.
90    #[serde(default)]
91    pub integration_return_to: Option<String>,
92
93    /// If true, SDK consumers may open the connect page after login.
94    #[serde(default = "default_open_connect_page_on_login")]
95    pub open_connect_page_on_login: bool,
96
97    /// OAuth callback wait timeout in seconds.
98    #[serde(default = "default_auth_timeout_seconds")]
99    pub auth_timeout_seconds: i64,
100
101    /// Optional token cache path for persisted login state.
102    #[serde(default)]
103    pub token_cache_path: Option<String>,
104
105    /// OAuth redirect URI used by PKCE browser login.
106    ///
107    /// Must match one of the application's pre-registered redirect URIs.
108    #[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
262/// Legacy helper that appends `access_key` query parameter to the MCP URL.
263///
264/// Prefer bearer tokens with `resolve_mcp_url` + Authorization header for new integrations.
265pub 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}