Skip to main content

systemprompt_models/mcp/
external_auth.rs

1//! Per-user bearer resolution for an `external` MCP server.
2//!
3//! [`ExternalAuth`] names the accessor core calls for the caller's
4//! third-party bearer and the header it is injected on.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use serde::{Deserialize, Serialize};
10
11use crate::errors::ServicesValidationError;
12
13/// Per-user bearer resolution for an `external` MCP server.
14///
15/// The MCP gateway exposes no token vault of its own; instead an extension
16/// banks the calling user's third-party token and serves it from
17/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
18/// user's systemprompt JWT and injects the returned bearer onto `header` (as
19/// `{scheme} {token}`), replacing the systemprompt credential so nothing
20/// internal reaches the third party.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ExternalAuth {
23    pub token_endpoint: String,
24    #[serde(default = "default_auth_header")]
25    pub header: String,
26    #[serde(default = "default_auth_scheme")]
27    pub scheme: String,
28}
29
30fn default_auth_header() -> String {
31    "Authorization".to_owned()
32}
33
34fn default_auth_scheme() -> String {
35    "Bearer".to_owned()
36}
37
38impl ExternalAuth {
39    pub fn header_value(&self, bearer: &str) -> String {
40        if self.scheme.trim().is_empty() {
41            bearer.to_owned()
42        } else {
43            format!("{} {bearer}", self.scheme)
44        }
45    }
46}
47
48pub(super) fn validate_external_auth(
49    name: &str,
50    ext: &ExternalAuth,
51) -> Result<(), ServicesValidationError> {
52    if ext.token_endpoint.starts_with("http://") || ext.token_endpoint.starts_with("https://") {
53        return Err(ServicesValidationError::invalid_field(format!(
54            "MCP server '{name}': external_auth.token_endpoint must be a relative \
55                 path (e.g. /api/public/<provider>/token); the host is derived from \
56                 server.api_external_url. Remove the scheme+host prefix."
57        )));
58    }
59    if !ext.token_endpoint.starts_with('/') {
60        return Err(ServicesValidationError::invalid_field(format!(
61            "MCP server '{name}': external_auth.token_endpoint must be an absolute \
62                 path beginning with '/'."
63        )));
64    }
65    if ext.header.trim().is_empty() {
66        return Err(ServicesValidationError::invalid_field(format!(
67            "MCP server '{name}': external_auth.header must not be empty."
68        )));
69    }
70    Ok(())
71}