1use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18use aws_lc_rs::digest::{SHA256, digest};
19use futures_util::future::BoxFuture;
20use reqwest::Url;
21use serde_json::Value;
22use tokio_util::sync::CancellationToken;
23use yapi_ai::auth::AuthError;
24use yapi_ai::auth::callback::{Reply, Server, error_page, query, success_page};
25use yapi_ai::auth::lock::FileLock;
26use yapi_types::config::ConfigFile;
27use yapi_types::sync::lock;
28
29use super::config::{OAuthConfig, namespace};
30use super::connection::resolve_value;
31use super::jsonrpc::McpError;
32use super::oauth::{
33 Challenge, Fetch, FlowOptions, FlowResult, OAuthError, Object, Provider, authorize,
34 parse_www_authenticate, text,
35};
36use crate::config::APP_NAME;
37
38const CALLBACK_HOST: &str = "127.0.0.1";
39const FALLBACK_REDIRECT_URL: &str = "http://127.0.0.1/callback";
42const REFRESH_SKEW_MS: f64 = 30_000.0;
44const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
46const CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
48
49fn mcp_error(error: OAuthError) -> McpError {
50 match error {
51 OAuthError::Network(message) => McpError::Network(message),
52 error => McpError::Other(error.to_string()),
53 }
54}
55
56fn failed(error: impl std::fmt::Display) -> AuthError {
57 AuthError::Failed(error.to_string())
58}
59
60#[derive(Clone, Debug)]
67pub struct CredentialStore {
68 path: PathBuf,
69}
70
71fn store_keys(name: &str, server_url: &str) -> (String, String) {
74 let legacy = Url::parse(server_url).map_or_else(|_| server_url.to_owned(), String::from);
75 (format!("{}|{legacy}", namespace(name)), legacy)
76}
77
78fn parse_states(content: &str) -> Result<Object, AuthError> {
79 if content.trim().is_empty() {
80 return Ok(Object::new());
81 }
82 match serde_json::from_str::<Value>(content) {
83 Ok(Value::Object(states)) => Ok(states),
84 Ok(_) => Ok(Object::new()),
85 Err(error) => Err(failed(error)),
86 }
87}
88
89impl CredentialStore {
90 pub fn new(agent_dir: &Path) -> CredentialStore {
92 CredentialStore {
93 path: agent_dir.join(ConfigFile::McpAuth.file_name()),
94 }
95 }
96
97 pub fn for_server(&self, name: &str, server_url: &str) -> ServerStore {
99 let (key, legacy_key) = store_keys(name, server_url);
100 ServerStore {
101 store: self.clone(),
102 key,
103 legacy_key,
104 }
105 }
106
107 async fn with_states<T>(
110 &self,
111 change: impl FnOnce(&mut Object) -> (T, bool),
112 ) -> Result<T, McpError> {
113 yapi_ai::auth::store::with_file_lock(&self.path, |current| {
114 let mut states = parse_states(current)?;
115 let (result, write) = change(&mut states);
116 let next = match write {
117 true => Some(ConfigFile::McpAuth.render(&states).map_err(failed)?),
118 false => None,
119 };
120 Ok((result, next))
121 })
122 .await
123 .map_err(|error| McpError::Other(error.to_string()))
124 }
125
126 pub async fn tokens(&self, name: &str, server_url: &str) -> Result<Option<Value>, McpError> {
129 let (key, legacy) = store_keys(name, server_url);
130 self.with_states(|states| {
131 let state = states.get(&key).or_else(|| states.get(&legacy));
132 (state.and_then(|state| state.get("tokens")).cloned(), false)
133 })
134 .await
135 }
136
137 pub async fn remove(&self, name: &str, server_url: &str) -> Result<bool, McpError> {
139 let (key, legacy) = store_keys(name, server_url);
140 self.with_states(|states| {
141 let removed =
142 states.shift_remove(&key).is_some() || states.shift_remove(&legacy).is_some();
143 (removed, removed)
144 })
145 .await
146 }
147}
148
149#[derive(Clone, Debug)]
151pub struct ServerStore {
152 store: CredentialStore,
153 key: String,
154 legacy_key: String,
155}
156
157impl ServerStore {
158 pub async fn load(&self) -> Result<Option<Object>, McpError> {
160 self.store
161 .with_states(|states| {
162 if states.contains_key(&self.key) || !states.contains_key(&self.legacy_key) {
163 return (
164 states.get(&self.key).and_then(Value::as_object).cloned(),
165 false,
166 );
167 }
168 let state = states.shift_remove(&self.legacy_key).unwrap_or_default();
169 states.insert(self.key.clone(), state.clone());
170 (state.as_object().cloned(), true)
171 })
172 .await
173 }
174
175 pub async fn save(&self, state: Object) -> Result<(), McpError> {
177 self.store
178 .with_states(|states| {
179 states.insert(self.key.clone(), Value::Object(state));
180 ((), true)
181 })
182 .await
183 }
184
185 async fn with_refresh_lock<T>(
189 &self,
190 run: impl Future<Output = Result<T, McpError>>,
191 ) -> Result<T, McpError> {
192 let digest = digest(&SHA256, self.key.as_bytes());
193 let hash = yapi_types::time::hex(&digest.as_ref()[..8]);
194 let _lock = FileLock::acquire(
195 &self
196 .store
197 .path
198 .with_file_name(format!("mcp-auth-refresh-{hash}")),
199 &CancellationToken::new(),
200 )
201 .await
202 .map_err(|error| McpError::Other(error.to_string()))?;
203 run.await
204 }
205}
206
207struct CallbackSettings {
213 host: String,
215 redirect_host: String,
217 port: Option<u16>,
218 path: String,
219 fixed_redirect_url: Option<String>,
221}
222
223fn callback_settings(settings: &OAuthConfig) -> CallbackSettings {
224 let configured = settings.callback_url.as_deref();
225 let url = Url::parse(configured.unwrap_or(FALLBACK_REDIRECT_URL)).ok();
226 let address = url
227 .as_ref()
228 .and_then(Url::host_str)
229 .unwrap_or(CALLBACK_HOST)
230 .trim_start_matches('[')
231 .trim_end_matches(']')
232 .to_owned();
233 let url_port = url.as_ref().and_then(Url::port);
234 let port = url_port.or(settings.callback_port);
235 let path = url
236 .as_ref()
237 .map_or_else(|| "/callback".to_owned(), |url| url.path().to_owned());
238 let fixed_redirect_url = match (url_port, port, url) {
240 (Some(_), _, _) => configured.map(str::to_owned),
241 (None, Some(port), Some(mut url)) => {
242 let _ = url.set_port(Some(port));
243 Some(url.to_string())
244 }
245 _ => None,
246 };
247 CallbackSettings {
248 host: if address == "localhost" {
250 CALLBACK_HOST.to_owned()
251 } else {
252 address.clone()
253 },
254 redirect_host: address,
255 port,
256 path,
257 fixed_redirect_url,
258 }
259}
260
261fn merge_scopes(lists: &[Option<&str>]) -> Option<String> {
263 let mut scopes: Vec<&str> = Vec::new();
264 for word in lists
265 .iter()
266 .flatten()
267 .flat_map(|list| list.split_whitespace())
268 {
269 if !scopes.contains(&word) {
270 scopes.push(word);
271 }
272 }
273 (!scopes.is_empty()).then(|| scopes.join(" "))
274}
275
276fn registered_redirect_urls(state: Option<&Object>) -> Vec<String> {
277 state
278 .and_then(|state| state.get("clientInformation"))
279 .and_then(|client| client.get("redirect_uris"))
280 .and_then(Value::as_array)
281 .map(|urls| {
282 urls.iter()
283 .filter_map(Value::as_str)
284 .map(str::to_owned)
285 .collect()
286 })
287 .unwrap_or_default()
288}
289
290fn tokens(state: Option<&Object>) -> Option<&Object> {
291 state?.get("tokens")?.as_object()
292}
293
294fn access_token(state: Option<&Object>) -> Option<String> {
295 tokens(state)
296 .and_then(|tokens| text(tokens, "access_token"))
297 .map(str::to_owned)
298}
299
300fn has_refresh_token(state: Option<&Object>) -> bool {
301 tokens(state)
302 .and_then(|tokens| text(tokens, "refresh_token"))
303 .is_some_and(|token| !token.is_empty())
304}
305
306pub struct SignInPrompt {
308 pub show_authorization_url: Box<dyn Fn(&str) + Send + Sync>,
310 pub redirect_url:
315 Box<dyn Fn(CancellationToken) -> BoxFuture<'static, Option<String>> + Send + Sync>,
316}
317
318pub struct McpAuth {
328 name: String,
329 server_url: String,
330 store: ServerStore,
331 config: OAuthConfig,
332 challenge: Mutex<Option<Challenge>>,
335 refreshing: tokio::sync::Mutex<()>,
336}
337
338impl McpAuth {
339 pub fn new(
342 name: &str,
343 server_url: &str,
344 credentials: &CredentialStore,
345 config: OAuthConfig,
346 ) -> McpAuth {
347 McpAuth {
348 name: name.to_owned(),
349 server_url: server_url.to_owned(),
350 store: credentials.for_server(name, server_url),
351 config,
352 challenge: Mutex::new(None),
353 refreshing: tokio::sync::Mutex::new(()),
354 }
355 }
356
357 pub async fn settings(&self) -> Result<OAuthConfig, McpError> {
359 let mut settings = self.config.clone();
360 if let Some(secret) = &self.config.client_secret {
361 let description = format!("MCP server \"{}\" oauth.clientSecret", self.name);
362 settings.client_secret = Some(resolve_value(secret, &description).await?);
363 }
364 Ok(settings)
365 }
366
367 fn challenge(&self) -> Option<Challenge> {
369 lock(&self.challenge).clone()
370 }
371
372 pub async fn token(&self) -> Result<Option<String>, McpError> {
375 drop(self.refreshing.lock().await);
376 let state = self.store.load().await?;
377 let token = access_token(state.as_ref());
378 let expired = state
379 .as_ref()
380 .and_then(|state| state.get("tokensExpireAt"))
381 .and_then(Value::as_f64)
382 .is_some_and(|at| at - REFRESH_SKEW_MS <= yapi_types::time::now_ms() as f64);
383 if !expired || !has_refresh_token(state.as_ref()) {
384 return Ok(token);
385 }
386 let _ = self.refresh(token.as_deref(), None).await;
387 Ok(access_token(self.store.load().await?.as_ref()))
388 }
389
390 pub async fn on_unauthorized(
393 &self,
394 www_authenticate: Option<&str>,
395 token: Option<&str>,
396 ) -> Result<(), McpError> {
397 let challenge = parse_www_authenticate(www_authenticate);
398 *lock(&self.challenge) = Some(challenge.clone());
399 if challenge.error.as_deref() == Some("insufficient_scope") {
401 return Err(McpError::SignInRequired);
402 }
403 self.refresh(token, Some(&challenge)).await
404 }
405
406 pub async fn settled(&self) {
409 drop(self.refreshing.lock().await);
410 }
411
412 async fn refresh(
414 &self,
415 stale: Option<&str>,
416 challenge: Option<&Challenge>,
417 ) -> Result<(), McpError> {
418 let _running = self.refreshing.lock().await;
419 self.store
420 .with_refresh_lock(async {
421 let state = self.store.load().await?;
422 if access_token(state.as_ref()).as_deref() != stale {
423 return Ok(());
424 }
425 if !has_refresh_token(state.as_ref()) {
426 return Err(McpError::SignInRequired);
427 }
428 let settings = self.settings().await?;
429 let redirect_url = callback_settings(&settings)
430 .fixed_redirect_url
431 .or_else(|| registered_redirect_urls(state.as_ref()).into_iter().next())
432 .unwrap_or_else(|| FALLBACK_REDIRECT_URL.to_owned());
433 let provider = self.provider(&settings, &redirect_url).map_err(mcp_error)?;
434 let options = FlowOptions {
435 server_url: self.server_url.clone(),
436 resource_metadata_url: challenge
437 .and_then(|challenge| challenge.resource_metadata_url.clone()),
438 authorization_server_metadata_url: settings.auth_server_metadata_url.clone(),
439 scope: challenge.and_then(|challenge| challenge.scope.clone()),
440 fetch: Fetch {
441 timeout: Some(REFRESH_REQUEST_TIMEOUT),
442 },
443 ..FlowOptions::default()
444 };
445 match authorize(&provider, &options).await.map_err(mcp_error)? {
446 FlowResult::Authorized => Ok(()),
447 FlowResult::Redirect(_) => Err(McpError::SignInRequired),
448 }
449 })
450 .await
451 }
452
453 fn provider<'a>(
454 &'a self,
455 settings: &OAuthConfig,
456 redirect_url: &str,
457 ) -> Result<Provider<'a>, OAuthError> {
458 Provider::new(
459 &self.server_url,
460 redirect_url,
461 settings.client_name.as_deref().unwrap_or(APP_NAME),
462 settings.client_id.as_deref(),
463 settings.client_secret.as_deref(),
464 &self.store,
465 )
466 }
467
468 pub async fn sign_in(&self, prompt: &SignInPrompt) -> Result<(), AuthError> {
473 let settings = self.settings().await.map_err(failed)?;
474 let challenge = self.challenge();
475 let stored = self.store.load().await.map_err(failed)?;
476 let step_up = challenge
477 .as_ref()
478 .is_some_and(|challenge| challenge.error.as_deref() == Some("insufficient_scope"));
479 let callback = callback_settings(&settings);
480 let registered = registered_redirect_urls(stored.as_ref());
482 let preferred_port = callback.port.or_else(|| {
483 registered
484 .first()
485 .and_then(|url| Url::parse(url).ok())
486 .and_then(|url| url.port())
487 });
488 let expected = Arc::new(Mutex::new(None));
489 let mut server = listen(&callback, preferred_port, Arc::clone(&expected)).await?;
490 let redirect_host = if callback.redirect_host.contains(':') {
491 format!("[{}]", callback.redirect_host)
492 } else {
493 callback.redirect_host.clone()
494 };
495 let redirect_url = callback.fixed_redirect_url.clone().unwrap_or_else(|| {
496 format!("http://{redirect_host}:{}{}", server.port(), callback.path)
497 });
498 if let Some(stored) = &stored {
499 let mut next = stored.clone();
500 next.shift_remove("oauthState");
502 if settings.client_id.as_deref().is_none_or(str::is_empty)
504 && !registered.contains(&redirect_url)
505 {
506 for key in ["clientInformation", "tokens", "tokensExpireAt"] {
507 next.shift_remove(key);
508 }
509 }
510 self.store.save(next).await.map_err(failed)?;
511 }
512 let provider = self.provider(&settings, &redirect_url).map_err(failed)?;
513 let challenged = challenge
516 .as_ref()
517 .and_then(|challenge| challenge.scope.as_deref());
518 let granted = (step_up && challenged.is_some())
519 .then(|| tokens(stored.as_ref()).and_then(|tokens| text(tokens, "scope")))
520 .flatten();
521 let flow = FlowOptions {
522 server_url: self.server_url.clone(),
523 resource_metadata_url: challenge
524 .as_ref()
525 .and_then(|challenge| challenge.resource_metadata_url.clone()),
526 authorization_server_metadata_url: settings.auth_server_metadata_url.clone(),
527 scope: merge_scopes(&[settings.scope.as_deref(), granted, challenged]),
528 skip_refresh: step_up,
530 ..FlowOptions::default()
531 };
532 if let FlowResult::Redirect(url) = authorize(&provider, &flow).await.map_err(failed)? {
533 let state = provider.state().await.map_err(failed)?;
534 (prompt.show_authorization_url)(&url);
535 let (code, iss) = wait_for_response(&mut server, &expected, &state, prompt).await?;
536 let exchange = FlowOptions {
537 authorization_code: Some(code),
538 iss,
539 skip_refresh: false,
540 ..flow
541 };
542 authorize(&provider, &exchange).await.map_err(failed)?;
543 }
544 *lock(&self.challenge) = None;
545 Ok(())
546 }
547}
548
549type Callback = (String, Option<String>);
550
551async fn listen(
554 settings: &CallbackSettings,
555 port: Option<u16>,
556 expected: Arc<Mutex<Option<String>>>,
557) -> Result<Server<Callback>, AuthError> {
558 let cancel = CancellationToken::new();
559 let start = |port: u16| {
560 let expected = Arc::clone(&expected);
561 let path = settings.path.clone();
562 let handler = move |_method: &str, url: &Url, _handled: bool| -> Reply<Callback> {
563 if url.path() != path {
564 return Reply::page(404, error_page("Not found", None));
565 }
566 let state = query(url, "state");
567 let mut pending = lock(&expected);
568 if state.is_none() || *pending != state {
569 return Reply::page(400, error_page("Invalid or expired OAuth state", None));
570 }
571 *pending = None;
572 if let Some(error) = query(url, "error") {
573 let description = query(url, "error_description").unwrap_or(error);
574 return Reply {
575 status: 200,
576 html: error_page(
577 "Authorization failed. You may close this window.",
578 Some(&description),
579 ),
580 outcome: Some(Err(AuthError::Failed(description))),
581 };
582 }
583 let Some(code) = query(url, "code") else {
584 return Reply {
585 status: 400,
586 html: error_page("Missing authorization code", None),
587 outcome: Some(Err(failed(
588 "OAuth callback did not include an authorization code",
589 ))),
590 };
591 };
592 let iss = query(url, "iss").filter(|iss| !iss.is_empty());
593 Reply {
594 status: 200,
595 html: success_page("Signed in to the MCP server. You may now close this page."),
596 outcome: Some(Ok((code, iss))),
597 }
598 };
599 Server::start(
600 &settings.host,
601 port,
602 &settings.path,
603 &cancel,
604 Box::new(handler),
605 )
606 };
607 match start(port.unwrap_or(0)).await {
608 Ok(server) => Ok(server),
609 Err(error) if settings.port.is_some() || port.is_none() => Err(failed(error)),
610 Err(_) => start(0).await.map_err(failed),
611 }
612}
613
614fn response_from_redirect_url(input: &str, state: &str) -> Result<Callback, AuthError> {
615 let url = Url::parse(input.trim())
616 .map_err(|_| failed("Expected the full redirect URL from the browser address bar"))?;
617 if let Some(error) = query(&url, "error") {
618 return Err(AuthError::Failed(
619 query(&url, "error_description").unwrap_or(error),
620 ));
621 }
622 if query(&url, "state").as_deref() != Some(state) {
623 return Err(failed("The redirect URL belongs to a different sign-in"));
624 }
625 let code = query(&url, "code")
626 .ok_or_else(|| failed("The redirect URL does not contain an authorization code"))?;
627 Ok((code, query(&url, "iss")))
628}
629
630async fn wait_for_response(
632 server: &mut Server<Callback>,
633 expected: &Mutex<Option<String>>,
634 state: &str,
635 prompt: &SignInPrompt,
636) -> Result<Callback, AuthError> {
637 *lock(expected) = Some(state.to_owned());
638 let withdraw = CancellationToken::new();
639 let _withdraw_on_return = withdraw.clone().drop_guard();
640 let from_user = (prompt.redirect_url)(withdraw);
641 tokio::select! {
642 received = tokio::time::timeout(CALLBACK_TIMEOUT, server.wait()) => {
643 received.unwrap_or_else(|_| Err(failed("OAuth callback timed out")))
644 }
645 input = from_user => match input {
646 Some(input) if !input.trim().is_empty() => response_from_redirect_url(&input, state),
647 _ => Err(AuthError::Cancelled),
648 },
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use serde_json::json;
656
657 #[test]
658 fn derives_callback_settings() {
659 let settings = |url: Option<&str>, port: Option<u16>| {
660 callback_settings(&OAuthConfig {
661 callback_url: url.map(str::to_owned),
662 callback_port: port,
663 ..OAuthConfig::default()
664 })
665 };
666 let default = settings(None, None);
667 assert_eq!(default.host, "127.0.0.1");
668 assert_eq!(default.path, "/callback");
669 assert_eq!(default.fixed_redirect_url, None);
670 let fixed = settings(None, Some(8080));
671 assert_eq!(
672 fixed.fixed_redirect_url.as_deref(),
673 Some("http://127.0.0.1:8080/callback")
674 );
675 let localhost = settings(Some("http://localhost:9000/oauth/cb"), None);
676 assert_eq!(localhost.host, "127.0.0.1");
677 assert_eq!(localhost.redirect_host, "localhost");
678 assert_eq!(localhost.port, Some(9000));
679 assert_eq!(
680 localhost.fixed_redirect_url.as_deref(),
681 Some("http://localhost:9000/oauth/cb")
682 );
683 assert_eq!(settings(Some("http://[::1]/cb"), None).host, "::1");
684 }
685
686 #[test]
687 fn merges_scopes() {
688 assert_eq!(
689 merge_scopes(&[Some("a b"), None, Some("b c a")]).as_deref(),
690 Some("a b c")
691 );
692 assert_eq!(merge_scopes(&[None, Some("")]), None);
693 }
694
695 #[test]
696 fn reads_pasted_redirects() {
697 assert_eq!(
698 response_from_redirect_url(" http://127.0.0.1:1/callback?code=c&state=s&iss=i ", "s"),
699 Ok(("c".into(), Some("i".into())))
700 );
701 let error = |input: &str| {
702 response_from_redirect_url(input, "s")
703 .err()
704 .map(|error| error.to_string())
705 };
706 assert_eq!(
707 error("code=c").as_deref(),
708 Some("Expected the full redirect URL from the browser address bar")
709 );
710 assert_eq!(
711 error("http://x/?error=denied&error_description=No").as_deref(),
712 Some("No")
713 );
714 assert_eq!(
715 error("http://x/?code=c&state=t").as_deref(),
716 Some("The redirect URL belongs to a different sign-in")
717 );
718 assert_eq!(
719 error("http://x/?state=s").as_deref(),
720 Some("The redirect URL does not contain an authorization code")
721 );
722 }
723
724 fn temp_store(name: &str) -> (PathBuf, CredentialStore) {
725 let dir = std::env::temp_dir().join(format!("yapi-mcp-auth-{name}-{}", std::process::id()));
726 let _ = std::fs::remove_dir_all(&dir);
727 let store = CredentialStore::new(&dir);
728 (dir, store)
729 }
730
731 const URL: &str = "https://mcp.example.com/mcp";
732
733 fn state(token: &str) -> Object {
734 json!({"serverUrl": URL, "tokens": {"access_token": token, "token_type": "Bearer"}})
735 .as_object()
736 .cloned()
737 .unwrap_or_default()
738 }
739
740 #[tokio::test]
741 async fn shared_url_separate_credentials() {
742 let (dir, store) = temp_store("shared");
743 store
744 .for_server("work", URL)
745 .save(state("work-token"))
746 .await
747 .unwrap();
748 store
749 .for_server("personal", URL)
750 .save(state("personal-token"))
751 .await
752 .unwrap();
753 let token = |name: &'static str| {
754 let store = store.clone();
755 async move { access_token(store.for_server(name, URL).load().await.unwrap().as_ref()) }
756 };
757 assert_eq!(token("work").await.as_deref(), Some("work-token"));
758 assert_eq!(token("personal").await.as_deref(), Some("personal-token"));
759 assert!(store.remove("work", URL).await.unwrap());
760 assert_eq!(store.for_server("work", URL).load().await.unwrap(), None);
761 assert_eq!(
762 store.tokens("personal", URL).await.unwrap(),
763 Some(json!({"access_token": "personal-token", "token_type": "Bearer"}))
764 );
765 let text = std::fs::read_to_string(dir.join("mcp-auth.json")).unwrap();
766 assert!(text.starts_with(
767 "{\n \"mcp__personal|https://mcp.example.com/mcp\": {\n \"serverUrl\""
768 ));
769 assert!(text.ends_with("}\n"));
770 let _ = std::fs::remove_dir_all(dir);
771 }
772
773 #[tokio::test]
774 async fn migrates_legacy_credentials() {
775 let (dir, store) = temp_store("legacy");
776 std::fs::create_dir_all(&dir).unwrap();
777 let legacy = json!({URL: state("legacy-token")});
778 std::fs::write(dir.join("mcp-auth.json"), legacy.to_string()).unwrap();
779 assert!(store.tokens("work", URL).await.unwrap().is_some());
781 let loaded = store.for_server("my_work", URL).load().await.unwrap();
782 assert_eq!(
783 access_token(loaded.as_ref()).as_deref(),
784 Some("legacy-token")
785 );
786 assert!(store.tokens("my-work", URL).await.unwrap().is_some());
788 assert_eq!(
789 store.for_server("personal", URL).load().await.unwrap(),
790 None
791 );
792 let text = std::fs::read_to_string(dir.join("mcp-auth.json")).unwrap();
793 let keys: Vec<String> = serde_json::from_str::<Object>(&text)
794 .unwrap()
795 .keys()
796 .cloned()
797 .collect();
798 assert_eq!(keys, [format!("mcp__my_work|{URL}")]);
799 assert!(store.remove("my-work", URL).await.unwrap());
800 assert!(!store.remove("my-work", URL).await.unwrap());
801 let _ = std::fs::remove_dir_all(dir);
802 }
803}