@@ -20,6 +20,10 @@ pub struct McpToken {
2020 pub expires_at : Option < u64 > ,
2121 pub scope : Option < String > ,
2222 pub server_name : String ,
23+ /// Normalized MCP server URL this token was minted for.
24+ /// `None` means a legacy stored token is unbound and must re-authenticate.
25+ #[ serde( default ) ]
26+ pub server_url : Option < String > ,
2327}
2428
2529impl McpToken {
@@ -36,6 +40,13 @@ impl McpToken {
3640 pub fn expiry_datetime ( & self ) -> Option < chrono:: DateTime < chrono:: Utc > > {
3741 token_expiry_datetime ( self . expires_at )
3842 }
43+
44+ pub fn is_bound_to_server_url ( & self , server_url : & str ) -> bool {
45+ self . server_url
46+ . as_deref ( )
47+ . map ( normalized_server_url)
48+ == Some ( normalized_server_url ( server_url) )
49+ }
3950}
4051
4152/// Path to the token store for a given MCP server.
@@ -97,6 +108,7 @@ pub struct McpOAuthMetadata {
97108#[ derive( Debug , Clone ) ]
98109pub struct McpAuthSession {
99110 pub server_name : String ,
111+ pub server_url : String ,
100112 pub auth_url : String ,
101113 pub redirect_uri : String ,
102114 pub verifier : String ,
@@ -115,6 +127,10 @@ fn normalized_server_url(server_url: &str) -> &str {
115127 server_url. trim_end_matches ( '/' )
116128}
117129
130+ fn normalize_server_url_owned ( server_url : & str ) -> String {
131+ normalized_server_url ( server_url) . to_string ( )
132+ }
133+
118134fn fallback_oauth_metadata ( server_url : & str ) -> McpOAuthMetadata {
119135 let base_url = normalized_server_url ( server_url) ;
120136 McpOAuthMetadata {
@@ -186,6 +202,7 @@ pub async fn begin_mcp_auth(
186202
187203 Ok ( McpAuthSession {
188204 server_name : server_name. to_string ( ) ,
205+ server_url : normalize_server_url_owned ( server_url) ,
189206 auth_url,
190207 redirect_uri,
191208 verifier,
@@ -295,6 +312,7 @@ pub async fn run_mcp_auth_session(session: McpAuthSession) -> anyhow::Result<Mcp
295312 )
296313 . await ?;
297314 token. server_name = session. server_name . clone ( ) ;
315+ token. server_url = Some ( session. server_url . clone ( ) ) ;
298316 store_mcp_token ( & token) . map_err ( |e| {
299317 anyhow:: anyhow!(
300318 "Failed to store MCP token for '{}': {}" ,
@@ -327,6 +345,10 @@ pub async fn get_valid_mcp_token(
327345 return Ok ( None ) ;
328346 } ;
329347
348+ if !token. is_bound_to_server_url ( server_url) {
349+ return Ok ( None ) ;
350+ }
351+
330352 if !token. is_expired ( 60 ) {
331353 return Ok ( Some ( token) ) ;
332354 }
@@ -336,7 +358,7 @@ pub async fn get_valid_mcp_token(
336358 }
337359
338360 let metadata = fetch_oauth_metadata ( server_url) . await ?;
339- refresh_mcp_token ( server_name, & metadata. token_endpoint )
361+ refresh_mcp_token ( server_name, server_url , & metadata. token_endpoint )
340362 . await
341363 . map ( Some )
342364}
@@ -497,13 +519,32 @@ pub async fn exchange_code(
497519 expires_at,
498520 scope : tr. scope ,
499521 server_name : String :: new ( ) , // caller should set this
522+ server_url : None , // caller should set this
500523 } )
501524}
502525
503526/// Refresh an existing MCP token using the stored refresh token.
504- pub async fn refresh_mcp_token ( server_name : & str , token_endpoint : & str ) -> anyhow:: Result < McpToken > {
527+ pub async fn refresh_mcp_token (
528+ server_name : & str ,
529+ server_url : & str ,
530+ token_endpoint : & str ,
531+ ) -> anyhow:: Result < McpToken > {
505532 let existing = get_mcp_token ( server_name)
506533 . ok_or_else ( || anyhow:: anyhow!( "No stored token for {}" , server_name) ) ?;
534+ if !existing. is_bound_to_server_url ( server_url) {
535+ let stored_binding = existing
536+ . server_url
537+ . as_deref ( )
538+ . map ( normalized_server_url)
539+ . unwrap_or ( "<unbound legacy token>" ) ;
540+ anyhow:: bail!(
541+ "Stored token for '{}' is not bound to requested MCP server URL (expected: {}, stored: {})" ,
542+ server_name,
543+ normalized_server_url( server_url) ,
544+ stored_binding
545+ ) ;
546+ }
547+
507548 let refresh = existing
508549 . refresh_token
509550 . as_deref ( )
@@ -548,6 +589,7 @@ pub async fn refresh_mcp_token(server_name: &str, token_endpoint: &str) -> anyho
548589 expires_at,
549590 scope : existing. scope ,
550591 server_name : server_name. to_string ( ) ,
592+ server_url : Some ( normalize_server_url_owned ( server_url) ) ,
551593 } ;
552594
553595 store_mcp_token ( & new_token) ?;
@@ -575,10 +617,66 @@ mod tests {
575617 expires_at : Some ( 1 ) , // expired long ago
576618 scope : None ,
577619 server_name : "test" . to_string ( ) ,
620+ server_url : Some ( "https://example.com/mcp" . to_string ( ) ) ,
578621 } ;
579622 assert ! ( t. is_expired( 0 ) ) ;
580623 }
581624
625+ #[ test]
626+ fn token_server_url_binding_matches_normalized_url ( ) {
627+ let t = McpToken {
628+ access_token : "tok" . to_string ( ) ,
629+ refresh_token : None ,
630+ expires_at : None ,
631+ scope : None ,
632+ server_name : "test" . to_string ( ) ,
633+ server_url : Some ( "https://example.com/mcp" . to_string ( ) ) ,
634+ } ;
635+ assert ! ( t. is_bound_to_server_url( "https://example.com/mcp/" ) ) ;
636+ assert ! ( !t. is_bound_to_server_url( "https://attacker.example/mcp" ) ) ;
637+ }
638+
639+ #[ test]
640+ fn token_without_server_url_is_not_bound ( ) {
641+ let t = McpToken {
642+ access_token : "tok" . to_string ( ) ,
643+ refresh_token : None ,
644+ expires_at : None ,
645+ scope : None ,
646+ server_name : "test" . to_string ( ) ,
647+ server_url : None ,
648+ } ;
649+ assert ! ( !t. is_bound_to_server_url( "https://example.com/mcp" ) ) ;
650+ }
651+
652+ #[ tokio:: test]
653+ async fn refresh_reports_unbound_legacy_token_binding ( ) {
654+ let server_name = format ! ( "legacy-unbound-{}" , std:: process:: id( ) ) ;
655+ let _ = remove_mcp_token ( & server_name) ;
656+ let token = McpToken {
657+ access_token : "tok" . to_string ( ) ,
658+ refresh_token : Some ( "refresh" . to_string ( ) ) ,
659+ expires_at : None ,
660+ scope : None ,
661+ server_name : server_name. clone ( ) ,
662+ server_url : None ,
663+ } ;
664+ store_mcp_token ( & token) . expect ( "store legacy token" ) ;
665+
666+ let err = refresh_mcp_token (
667+ & server_name,
668+ "https://example.com/mcp/" ,
669+ "https://example.com/oauth/token" ,
670+ )
671+ . await
672+ . expect_err ( "unbound token should not refresh" ) ;
673+ let message = err. to_string ( ) ;
674+
675+ assert ! ( message. contains( "expected: https://example.com/mcp" ) ) ;
676+ assert ! ( message. contains( "stored: <unbound legacy token>" ) ) ;
677+ let _ = remove_mcp_token ( & server_name) ;
678+ }
679+
582680 #[ test]
583681 fn fallback_metadata_uses_default_oauth_paths ( ) {
584682 let metadata = fallback_oauth_metadata ( "https://example.com/mcp/" ) ;
0 commit comments