use core::fmt;
use std::time::Instant;
use fastmcp_core::{AccessToken, McpRequestCancellation};
pub mod discovery;
pub mod managed;
pub mod oauth;
pub mod rpc;
pub mod sampling;
#[cfg(target_os = "linux")]
pub mod secure_file;
pub mod tool;
pub use fastmcp_core::CanonicalHttpUrl;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BearerBindingError {
CleartextResource,
EmptyToken,
InvalidTokenBytes,
}
impl fmt::Display for BearerBindingError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CleartextResource => {
formatter.write_str("bearer credentials bind only to https resources")
}
Self::EmptyToken => formatter.write_str("bearer token is empty"),
Self::InvalidTokenBytes => formatter
.write_str("bearer token exceeds the byte limit or violates token68 syntax"),
}
}
}
impl std::error::Error for BearerBindingError {}
#[derive(Clone)]
pub struct BoundBearerCredential {
resource: CanonicalHttpUrl,
token: String,
expires_at: Option<Instant>,
revoked: McpRequestCancellation,
owner_cancellation: Option<McpRequestCancellation>,
}
impl fmt::Debug for BoundBearerCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundBearerCredential")
.field("resource", &self.resource.as_str())
.field("token", &"<redacted>")
.finish()
}
}
impl BoundBearerCredential {
pub fn bind(
resource: CanonicalHttpUrl,
token: impl Into<String>,
) -> Result<Self, BearerBindingError> {
if !resource.as_str().starts_with("https://") {
return Err(BearerBindingError::CleartextResource);
}
let token = token.into();
if token.is_empty() {
return Err(BearerBindingError::EmptyToken);
}
if !AccessToken::is_valid_token68(&token) {
return Err(BearerBindingError::InvalidTokenBytes);
}
Ok(Self {
resource,
token,
expires_at: None,
revoked: McpRequestCancellation::new(),
owner_cancellation: None,
})
}
pub fn bind_with_expiry(
resource: CanonicalHttpUrl,
token: impl Into<String>,
expires_at: Instant,
) -> Result<Self, BearerBindingError> {
let mut credential = Self::bind(resource, token)?;
credential.expires_at = Some(expires_at);
Ok(credential)
}
#[must_use]
pub fn for_owner(&self, owner: &McpRequestCancellation) -> Option<Self> {
if self.owner_cancellation.is_some() {
return None;
}
let mut credential = self.clone();
credential.owner_cancellation = Some(owner.clone());
Some(credential)
}
#[must_use]
pub fn resource(&self) -> &CanonicalHttpUrl {
&self.resource
}
#[must_use]
pub fn expires_at(&self) -> Option<Instant> {
self.expires_at
}
pub fn revoke(&self) {
self.revoked.cancel();
}
#[must_use]
pub fn is_revoked(&self) -> bool {
self.revoked.is_cancel_requested()
|| self
.owner_cancellation
.as_ref()
.is_some_and(McpRequestCancellation::is_cancel_requested)
}
#[must_use]
pub fn authorization_for_target(&self, target: &CanonicalHttpUrl) -> Option<String> {
self.authorization_at(target, Instant::now())
}
fn authorization_at(&self, target: &CanonicalHttpUrl, now: Instant) -> Option<String> {
if target.as_str() == self.resource.as_str()
&& self.expires_at.is_none_or(|deadline| now < deadline)
&& !self.is_revoked()
{
let authorization = format!("Bearer {}", self.token);
(!self.is_revoked()).then_some(authorization)
} else {
None
}
}
pub(crate) fn is_reflected_by_error(&self, error: &fastmcp_protocol::JsonRpcError) -> bool {
error.message.contains(&self.token)
|| error
.data
.as_ref()
.is_some_and(|data| self.is_reflected_by_value(data))
}
fn is_reflected_by_value(&self, value: &serde_json::Value) -> bool {
match value {
serde_json::Value::String(text) => text.contains(&self.token),
serde_json::Value::Array(values) => {
values.iter().any(|value| self.is_reflected_by_value(value))
}
serde_json::Value::Object(values) => values
.iter()
.any(|(key, value)| key.contains(&self.token) || self.is_reflected_by_value(value)),
value => value.to_string().contains(&self.token),
}
}
}
#[cfg(test)]
mod tests {
use super::{BearerBindingError, BoundBearerCredential, CanonicalHttpUrl};
fn url(value: &str) -> CanonicalHttpUrl {
CanonicalHttpUrl::parse(value).expect("test URL is canonical")
}
#[test]
fn binds_only_to_https_resources() {
assert!(BoundBearerCredential::bind(url("https://mcp.example/api"), "token-1").is_ok());
for cleartext in [
"http://mcp.example/api",
"http://localhost:8080/api",
"http://127.0.0.1:8080/api",
"http://[::1]:8080/api",
] {
assert_eq!(
BoundBearerCredential::bind(url(cleartext), "token-1").err(),
Some(BearerBindingError::CleartextResource),
"cleartext resource {cleartext:?} must never hold a credential"
);
}
}
#[test]
fn refuses_empty_and_header_hostile_tokens() {
let resource = url("https://mcp.example/api");
assert_eq!(
BoundBearerCredential::bind(resource.clone(), "").err(),
Some(BearerBindingError::EmptyToken)
);
assert_eq!(
BoundBearerCredential::bind(resource.clone(), "to\r\nken").err(),
Some(BearerBindingError::InvalidTokenBytes)
);
assert_eq!(
BoundBearerCredential::bind(resource, "to ken").err(),
Some(BearerBindingError::InvalidTokenBytes)
);
}
#[test]
fn attaches_only_to_the_exact_bound_resource() {
let credential =
BoundBearerCredential::bind(url("https://mcp.example/api"), "token-1").expect("binds");
assert_eq!(
credential.authorization_for_target(&url("https://mcp.example/api")),
Some("Bearer token-1".to_owned())
);
for target in [
"https://mcp.example/other",
"https://other.example/api",
"http://mcp.example/api",
"https://mcp.example/api?extra=1",
] {
assert_eq!(
credential.authorization_for_target(&url(target)),
None,
"target {target:?} must not observe the token"
);
}
}
#[test]
fn debug_output_redacts_the_token() {
let credential =
BoundBearerCredential::bind(url("https://mcp.example/api"), "super-secret-token-value")
.expect("binds");
let debug = format!("{credential:?}");
assert!(debug.contains("<redacted>"));
assert!(
!debug.contains("super-secret-token-value"),
"the token must never appear in diagnostics: {debug}"
);
}
#[test]
fn expiring_credential_clones_withhold_headers_at_the_exact_deadline() {
use std::time::{Duration, Instant};
let resource = url("https://mcp.example/api");
let deadline = Instant::now() + Duration::from_secs(60);
let credential =
BoundBearerCredential::bind_with_expiry(resource.clone(), "expiring-secret", deadline)
.unwrap();
for credential in [credential.clone(), credential] {
assert_eq!(credential.expires_at(), Some(deadline));
assert_eq!(
credential.authorization_at(&resource, deadline - Duration::from_nanos(1)),
Some("Bearer expiring-secret".to_owned()),
);
assert_eq!(credential.authorization_at(&resource, deadline), None);
assert_eq!(
credential.authorization_at(&resource, deadline + Duration::from_nanos(1)),
None
);
assert_eq!(
credential.authorization_at(
&url("https://other.example/api"),
deadline - Duration::from_secs(1)
),
None
);
}
let expired =
BoundBearerCredential::bind_with_expiry(resource.clone(), "expired", Instant::now())
.unwrap();
assert_eq!(expired.authorization_for_target(&resource), None);
}
#[test]
fn revocation_withholds_existing_and_future_clones_without_affecting_another_binding() {
let resource = url("https://mcp.example/api");
let credential = BoundBearerCredential::bind(resource.clone(), "shared-secret").unwrap();
let clone = credential.clone();
let independent = BoundBearerCredential::bind(resource.clone(), "shared-secret").unwrap();
for candidate in [&credential, &clone, &independent] {
assert!(!candidate.is_revoked());
assert_eq!(
candidate.authorization_for_target(&resource),
Some("Bearer shared-secret".to_owned())
);
}
clone.revoke();
clone.revoke();
for candidate in [credential.clone(), clone.clone(), credential, clone] {
assert!(candidate.is_revoked());
assert_eq!(candidate.authorization_for_target(&resource), None);
assert_eq!(candidate.resource(), &resource);
assert_eq!(candidate.expires_at(), None);
assert!(!format!("{candidate:?}").contains("shared-secret"));
}
assert!(!independent.is_revoked());
assert_eq!(
independent.authorization_for_target(&resource),
Some("Bearer shared-secret".to_owned())
);
}
#[test]
fn revocation_does_not_depend_on_expiry_or_clone_drop() {
use std::time::{Duration, Instant};
let resource = url("https://mcp.example/api");
let now = Instant::now();
let deadline = now + Duration::from_secs(60);
let credential =
BoundBearerCredential::bind_with_expiry(resource.clone(), "secret", deadline).unwrap();
drop(credential.clone());
assert_eq!(
credential.authorization_at(&resource, now),
Some("Bearer secret".to_owned())
);
assert_eq!(credential.authorization_at(&resource, deadline), None);
assert!(!credential.is_revoked(), "expiry is not local revocation");
credential.revoke();
assert_eq!(credential.authorization_at(&resource, now), None);
assert_eq!(credential.expires_at(), Some(deadline));
}
#[test]
fn revocation_is_visible_to_a_credential_moved_to_another_thread() {
let resource = url("https://mcp.example/api");
let credential = BoundBearerCredential::bind(resource.clone(), "secret").unwrap();
let worker = credential.clone();
let (revoked, observe) = std::sync::mpsc::channel();
let thread = std::thread::spawn(move || {
observe
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert!(worker.is_revoked());
assert_eq!(worker.authorization_for_target(&resource), None);
});
credential.revoke();
revoked.send(()).unwrap();
thread.join().unwrap();
}
#[test]
fn owner_cancellation_cannot_be_removed_by_cloning_or_reparenting() {
use fastmcp_core::McpRequestCancellation;
let resource = url("https://mcp.example/api");
let source = BoundBearerCredential::bind(resource.clone(), "secret").unwrap();
let owner = McpRequestCancellation::new();
let other = McpRequestCancellation::new();
let owned = source.for_owner(&owner).unwrap();
let clone = owned.clone();
assert!(owned.for_owner(&other).is_none());
assert_eq!(
owned.authorization_for_target(&resource),
Some("Bearer secret".to_owned())
);
owner.cancel();
for candidate in [&owned, &clone] {
assert!(candidate.is_revoked());
assert_eq!(candidate.authorization_for_target(&resource), None);
assert!(candidate.for_owner(&other).is_none());
}
assert!(!source.is_revoked());
assert_eq!(
source.authorization_for_target(&resource),
Some("Bearer secret".to_owned())
);
assert!(source.for_owner(&owner).unwrap().is_revoked());
}
#[test]
fn token_revocation_survives_binding_to_a_new_owner() {
use fastmcp_core::McpRequestCancellation;
let resource = url("https://mcp.example/api");
let source = BoundBearerCredential::bind(resource.clone(), "secret").unwrap();
let owner = McpRequestCancellation::new();
source.revoke();
let owned = source.for_owner(&owner).unwrap();
assert!(owned.is_revoked());
assert_eq!(owned.authorization_for_target(&resource), None);
assert!(!owner.is_cancel_requested());
}
#[test]
fn bearer_alphabet_and_trailing_padding_round_trip_http_admission() {
use std::time::{Duration, Instant};
use fastmcp_core::AccessToken;
let resource = url("https://mcp.example/api");
let now = Instant::now();
for token in ["A", "aZ09-._~+/", "abc=", "abc==", "a==="] {
let candidates = [
BoundBearerCredential::bind(resource.clone(), token).unwrap(),
BoundBearerCredential::bind_with_expiry(
resource.clone(),
token,
now + Duration::from_secs(60),
)
.unwrap(),
];
for credential in candidates {
let header = credential.authorization_at(&resource, now).unwrap();
assert_eq!(header, format!("Bearer {token}"));
let parsed = AccessToken::parse(&header).unwrap();
assert_eq!(parsed.scheme, "Bearer");
assert_eq!(parsed.token, token);
}
}
}
#[test]
fn both_bearer_constructors_refuse_invalid_grammar_without_repair() {
use std::time::Instant;
let resource = url("https://mcp.example/api");
let deadline = Instant::now();
for token in [
"secret:tail",
"secret,tail",
"secret;tail",
"secret\"tail",
"secret\\tail",
"secret(tail)",
"secret%20tail",
"secret=tail",
"=secret",
"=",
"secreté",
"secret\u{a0}tail",
" secret",
"secret ",
"secret\ttail",
"secret\0tail",
] {
for result in [
BoundBearerCredential::bind(resource.clone(), token),
BoundBearerCredential::bind_with_expiry(resource.clone(), token, deadline),
] {
let error = result.expect_err("invalid credentials must fail during binding");
assert_eq!(error, BearerBindingError::InvalidTokenBytes);
assert!(!format!("{error:?} {error}").contains("secret"));
}
}
assert_eq!(
BoundBearerCredential::bind_with_expiry(resource, "", deadline).err(),
Some(BearerBindingError::EmptyToken)
);
}
#[test]
fn bearer_byte_limit_includes_padding_in_both_constructors() {
use std::time::{Duration, Instant};
use fastmcp_core::{AccessToken, MAX_ACCESS_TOKEN_BYTES};
let resource = url("https://mcp.example/api");
let now = Instant::now();
for padding in ["", "=="] {
let exact = format!(
"{}{padding}",
"a".repeat(MAX_ACCESS_TOKEN_BYTES - padding.len())
);
let excessive = format!("a{exact}");
assert_eq!(exact.len(), MAX_ACCESS_TOKEN_BYTES);
assert_eq!(excessive.len(), MAX_ACCESS_TOKEN_BYTES + 1);
for (token, accepted) in [(&exact, true), (&excessive, false)] {
for result in [
BoundBearerCredential::bind(resource.clone(), token.as_str()),
BoundBearerCredential::bind_with_expiry(
resource.clone(),
token.as_str(),
now + Duration::from_secs(60),
),
] {
if accepted {
let credential = result.unwrap();
let header = credential.authorization_at(&resource, now).unwrap();
assert_eq!(AccessToken::parse(&header).unwrap().token, *token);
} else {
assert_eq!(result.err(), Some(BearerBindingError::InvalidTokenBytes));
}
}
}
}
}
#[test]
fn native_request_headers_preserve_bound_tokens_and_resource_isolation() {
use fastmcp_core::{AccessToken, MAX_ACCESS_TOKEN_BYTES};
use fastmcp_protocol::FINAL_PROTOCOL_VERSION;
use crate::http_executor::ModernHttpRequest;
let resource = url("https://mcp.example/api");
for token in [
"aZ09-._~+/==".to_owned(),
"a".repeat(MAX_ACCESS_TOKEN_BYTES),
] {
let credential = BoundBearerCredential::bind(resource.clone(), token.clone()).unwrap();
let make_request = |target| {
ModernHttpRequest::new(
target,
b"{}".to_vec(),
FINAL_PROTOCOL_VERSION,
"tools/call",
None,
)
.unwrap()
.with_authorization(&credential)
};
let request = make_request(resource.as_str());
let mut headers = request
.headers()
.into_iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("authorization"));
let (_, header) = headers
.next()
.expect("bound target receives its credential");
assert!(headers.next().is_none());
assert_eq!(AccessToken::parse(&header).unwrap().token, token);
let other = make_request("https://mcp.example/other");
assert!(
other
.headers()
.iter()
.all(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
);
credential.revoke();
assert!(
make_request(resource.as_str())
.headers()
.iter()
.all(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
);
}
}
}