Skip to main content

cookie_agent_engine/
mcp.rs

1#![allow(deprecated)]
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    path::{Path, PathBuf},
6    sync::{
7        Arc, Mutex, Weak,
8        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
9    },
10    time::Duration,
11};
12
13#[cfg(unix)]
14use std::{
15    fs::{self, OpenOptions},
16    io::{Read as _, Write as _},
17};
18
19use async_trait::async_trait;
20use base64::Engine as _;
21use cookie_agent_config::{LoadedMcpServer, McpOAuthSettings, McpServerConfig};
22#[cfg(windows)]
23use cookie_agent_models::secure_store::{DEFAULT_LOCK_BUDGET, SecureDirectory};
24#[cfg(unix)]
25use cookie_agent_models::secure_store::{DEFAULT_LOCK_BUDGET, lock_within_file, unlock};
26use cookie_agent_protocol::{
27    ApprovalBoundary, ApprovalCapability, ApprovalResourceSource, PermissionAction,
28    PersistedToolResult as ToolResult, PreparedApprovalResource, PreparedBindingLifetime,
29    PreparedCapabilityOperation, PreparedOperationIdentity, PreparedResourceDigest,
30    PreparedResourceIdentity, SafeDisplayText, Sha256Digest, ToolAttachment, ToolCallId,
31    ToolEmittedContent, ToolEmittedMessage, ToolEmittedMessageRole,
32};
33use futures_util::StreamExt as _;
34use process_wrap::tokio::CommandWrap;
35#[cfg(windows)]
36use process_wrap::tokio::JobObject;
37#[cfg(unix)]
38use process_wrap::tokio::ProcessGroup;
39use rmcp::transport::auth::{AuthorizationCallback, OAuthClientConfig, OAuthTokenResponse};
40use rmcp::{
41    ClientHandler, ClientLifecycleMode, ClientServiceExt, Peer,
42    model::{
43        CallToolRequestParams, ContentBlock, CreateMessageRequestParams, CreateMessageResult,
44        ElicitRequestParams, ElicitResult, ErrorCode, ErrorData, ListRootsResult,
45        ProgressNotificationParam, ProtocolVersion, ResourceContents, Tool,
46    },
47    service::{
48        NotificationContext, RequestContext, RoleClient, RunningService, RxJsonRpcMessage,
49        TxJsonRpcMessage,
50    },
51    transport::{
52        AuthClient, AuthorizationManager, AuthorizationRequest, AuthorizationSession,
53        CredentialStore, OAuthHttpClient, OAuthHttpClientFuture, OAuthHttpRedirectPolicy,
54        StoredCredentials, StreamableHttpClientTransport, TokioChildProcess, Transport,
55    },
56};
57use serde::{Deserialize, Serialize};
58use serde_json::{Map, Value};
59use sha2::{Digest as _, Sha256};
60use tokio::sync::Notify;
61use tokio::{
62    io::{AsyncReadExt as _, AsyncWriteExt as _},
63    net::{TcpListener, TcpStream},
64    sync::Mutex as AsyncMutex,
65};
66use tokio_util::sync::CancellationToken;
67
68use crate::{
69    PreparedExecutor, PreparedTool, SessionToolContext, ToolCall, ToolConcurrency, ToolError,
70    ToolExecutionContext, ToolPreparationContext, ToolProgress, ToolProvider, ToolSpec,
71};
72
73const DEFAULT_TIMEOUT_MS: u64 = 30_000;
74const TOOL_LIST_DEBOUNCE: Duration = Duration::from_millis(750);
75#[cfg(not(test))]
76const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
77#[cfg(test)]
78const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_millis(200);
79#[cfg(any(unix, test))]
80const OAUTH_STORE_FILE: &str = "mcp-oauth.json";
81const OAUTH_STORE_LOCK_FILE: &str = "mcp-oauth.lock";
82const OAUTH_CALLBACK_MAX_BYTES: usize = 16 * 1024;
83const OAUTH_STORE_MAX_BYTES: u64 = 1024 * 1024;
84const REDACTED_AUTHORIZATION_CODE: &str = "cookie-agent-redacted-authorization-code";
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum McpServerState {
89    Disabled,
90    Disconnected,
91    Connecting,
92    Connected,
93    NeedsAuth,
94    Failed,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
98pub struct McpServerStatus {
99    pub server: String,
100    pub state: McpServerState,
101    pub message: Option<String>,
102    pub tools: Vec<String>,
103    pub auth_in_progress: bool,
104}
105
106#[derive(Clone)]
107struct CachedTool {
108    raw_name: String,
109    spec: ToolSpec,
110}
111
112type ClientService = RunningService<RoleClient, McpClientHandler>;
113
114struct ServerRuntime {
115    name: String,
116    sanitized_name: String,
117    loaded: LoadedMcpServer,
118    status: Mutex<McpServerStatus>,
119    tools: Mutex<Vec<CachedTool>>,
120    list_generation: AtomicU64,
121    publication: Mutex<()>,
122    tool_refresh: Mutex<ToolRefreshDebounce>,
123    connect_lock: AsyncMutex<()>,
124    service: AsyncMutex<Option<ClientService>>,
125    superseded: CancellationToken,
126    registry: Weak<RegistryInner>,
127    progress: AsyncMutex<HashMap<ToolCallId, crate::ProgressSink>>,
128    auth_challenge: Mutex<Option<String>>,
129    oauth_response_error: Arc<Mutex<Option<String>>>,
130    auth_flow: Mutex<Option<OAuthFlowState>>,
131    auth_generation: AtomicU64,
132    auth_lock: AsyncMutex<()>,
133}
134
135struct ToolRefreshDebounce {
136    peer: Option<Peer<RoleClient>>,
137    cancellation: CancellationToken,
138    notify: Arc<Notify>,
139    worker: Option<tokio::task::JoinHandle<()>>,
140    #[cfg(test)]
141    armed: Arc<AtomicU64>,
142}
143
144impl Default for ToolRefreshDebounce {
145    fn default() -> Self {
146        Self {
147            peer: None,
148            cancellation: CancellationToken::new(),
149            notify: Arc::new(Notify::new()),
150            worker: None,
151            #[cfg(test)]
152            armed: Arc::new(AtomicU64::new(0)),
153        }
154    }
155}
156
157impl ToolRefreshDebounce {
158    fn cancel(&mut self) {
159        self.peer = None;
160        self.cancellation.cancel();
161        if let Some(worker) = self.worker.take() {
162            worker.abort();
163        }
164    }
165}
166
167impl Drop for ServerRuntime {
168    fn drop(&mut self) {
169        self.tool_refresh
170            .get_mut()
171            .unwrap_or_else(|poisoned| poisoned.into_inner())
172            .cancel();
173    }
174}
175
176struct RegistryInner {
177    servers: Mutex<BTreeMap<String, Arc<ServerRuntime>>>,
178    reserved_names: Mutex<HashSet<String>>,
179    claimed_names: Mutex<HashMap<String, String>>,
180    plugin_collision_handler: Mutex<Option<PluginCollisionHandler>>,
181    oauth_credentials: OAuthCredentialFile,
182    shutdown: CancellationToken,
183    connection_tasks: Mutex<Vec<tokio::task::JoinHandle<()>>>,
184    active_connects: AtomicUsize,
185    connects_idle: Notify,
186    eager_pending: AtomicUsize,
187    eager_ready: Notify,
188}
189
190type PluginCollisionHandler = Arc<dyn Fn(&str, &str, Option<&str>) + Send + Sync>;
191
192struct OAuthFlowState {
193    generation: u64,
194    authorization_url: String,
195    cancellation: CancellationToken,
196}
197
198#[derive(Clone)]
199struct OAuthCredentialFile {
200    inner: Arc<OAuthCredentialFileInner>,
201}
202
203struct OAuthCredentialFileInner {
204    path: PathBuf,
205    transaction: Mutex<()>,
206}
207
208#[cfg(unix)]
209struct OAuthStoreLock {
210    file: fs::File,
211}
212
213#[derive(Clone)]
214struct ServerCredentialStore {
215    file: OAuthCredentialFile,
216    key: String,
217    binding: OAuthCredentialBinding,
218}
219
220#[derive(Clone, Deserialize, Serialize)]
221#[serde(deny_unknown_fields)]
222struct PersistedOAuthCredential {
223    binding: OAuthCredentialBinding,
224    credentials: StrictStoredCredentials,
225}
226
227#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
228#[serde(deny_unknown_fields)]
229struct OAuthCredentialBinding {
230    resource_url: String,
231    configured_client_id: Option<String>,
232    client_metadata_url: Option<String>,
233    client_secret_sha256: Option<String>,
234    scopes: Vec<String>,
235}
236
237#[derive(Clone, Deserialize, Serialize)]
238#[serde(deny_unknown_fields)]
239struct StrictStoredCredentials {
240    client_id: String,
241    token_response: Option<OAuthTokenResponse>,
242    granted_scopes: Vec<String>,
243    token_received_at: Option<u64>,
244    issuer: Option<String>,
245}
246
247struct McpOAuthHttpClient {
248    follow_redirects: reqwest::Client,
249    stop_redirects: reqwest::Client,
250    exchange: OAuthExchangeState,
251    store: ServerCredentialStore,
252}
253
254#[derive(Clone, Default)]
255struct OAuthExchangeState {
256    code: Arc<Mutex<Option<zeroize::Zeroizing<String>>>>,
257    response_error: Arc<Mutex<Option<String>>>,
258}
259
260#[derive(Debug)]
261struct OAuthHttpFailure;
262
263impl std::fmt::Display for OAuthHttpFailure {
264    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        formatter.write_str("OAuth HTTP operation failed")
266    }
267}
268
269impl std::error::Error for OAuthHttpFailure {}
270
271#[derive(Clone)]
272pub struct McpRegistry {
273    inner: Arc<RegistryInner>,
274}
275
276impl std::fmt::Debug for McpRegistry {
277    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        formatter
279            .debug_struct("McpRegistry")
280            .finish_non_exhaustive()
281    }
282}
283
284#[derive(Clone)]
285struct McpClientHandler {
286    server: Weak<ServerRuntime>,
287}
288
289struct ActiveConnect(Arc<RegistryInner>);
290
291impl ActiveConnect {
292    fn new(registry: Arc<RegistryInner>) -> Self {
293        registry.active_connects.fetch_add(1, Ordering::AcqRel);
294        Self(registry)
295    }
296}
297
298impl Drop for ActiveConnect {
299    fn drop(&mut self) {
300        if self.0.active_connects.fetch_sub(1, Ordering::AcqRel) == 1 {
301            self.0.connects_idle.notify_waiters();
302        }
303    }
304}
305
306struct EagerConnect(Arc<RegistryInner>);
307
308impl Drop for EagerConnect {
309    fn drop(&mut self) {
310        if self.0.eager_pending.fetch_sub(1, Ordering::AcqRel) == 1 {
311            self.0.eager_ready.notify_waiters();
312        }
313    }
314}
315
316#[derive(Clone)]
317struct ChildCleanup(Arc<ChildCleanupInner>);
318
319struct ChildCleanupInner {
320    done: AtomicBool,
321    notify: Notify,
322}
323
324impl ChildCleanup {
325    fn new() -> Self {
326        Self(Arc::new(ChildCleanupInner {
327            done: AtomicBool::new(false),
328            notify: Notify::new(),
329        }))
330    }
331
332    fn finish(&self) {
333        self.0.done.store(true, Ordering::Release);
334        self.0.notify.notify_waiters();
335    }
336
337    async fn wait(&self) {
338        while !self.0.done.load(Ordering::Acquire) {
339            let notified = self.0.notify.notified();
340            if self.0.done.load(Ordering::Acquire) {
341                break;
342            }
343            notified.await;
344        }
345    }
346}
347
348struct ReapingChildTransport {
349    inner: Option<TokioChildProcess>,
350    cleanup: ChildCleanup,
351}
352
353impl ReapingChildTransport {
354    fn new(inner: TokioChildProcess, cleanup: ChildCleanup) -> Self {
355        Self {
356            inner: Some(inner),
357            cleanup,
358        }
359    }
360}
361
362impl Transport<RoleClient> for ReapingChildTransport {
363    type Error = std::io::Error;
364
365    fn send(
366        &mut self,
367        item: TxJsonRpcMessage<RoleClient>,
368    ) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
369        self.inner
370            .as_mut()
371            .expect("open child transport")
372            .send(item)
373    }
374
375    fn receive(&mut self) -> impl Future<Output = Option<RxJsonRpcMessage<RoleClient>>> + Send {
376        self.inner.as_mut().expect("open child transport").receive()
377    }
378
379    fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
380        let inner = self.inner.take();
381        let cleanup = self.cleanup.clone();
382        async move {
383            let result = match inner {
384                Some(mut inner) => inner.graceful_shutdown().await,
385                None => Ok(()),
386            };
387            cleanup.finish();
388            result
389        }
390    }
391}
392
393impl Drop for ReapingChildTransport {
394    fn drop(&mut self) {
395        let Some(mut inner) = self.inner.take() else {
396            return;
397        };
398        let cleanup = self.cleanup.clone();
399        tokio::spawn(async move {
400            let _ = inner.graceful_shutdown().await;
401            cleanup.finish();
402        });
403    }
404}
405
406impl ClientHandler for McpClientHandler {
407    fn create_message(
408        &self,
409        _params: CreateMessageRequestParams,
410        _context: RequestContext<RoleClient>,
411    ) -> impl Future<Output = Result<CreateMessageResult, ErrorData>> + Send + '_ {
412        std::future::ready(Err(unsupported_server_request("sampling/createMessage")))
413    }
414
415    fn list_roots(
416        &self,
417        _context: RequestContext<RoleClient>,
418    ) -> impl Future<Output = Result<ListRootsResult, ErrorData>> + Send + '_ {
419        std::future::ready(Err(unsupported_server_request("roots/list")))
420    }
421
422    fn create_elicitation(
423        &self,
424        _request: ElicitRequestParams,
425        _context: RequestContext<RoleClient>,
426    ) -> impl Future<Output = Result<ElicitResult, ErrorData>> + Send + '_ {
427        std::future::ready(Err(unsupported_server_request("elicitation/create")))
428    }
429
430    async fn on_progress(
431        &self,
432        params: ProgressNotificationParam,
433        _context: NotificationContext<RoleClient>,
434    ) {
435        let Some(server) = self.server.upgrade() else {
436            return;
437        };
438        let message = params.message.unwrap_or_else(|| match params.total {
439            Some(total) => format!("MCP progress: {} / {total}", params.progress),
440            None => format!("MCP progress: {}", params.progress),
441        });
442        let sinks = server
443            .progress
444            .lock()
445            .await
446            .iter()
447            .map(|(id, sink)| (*id, sink.clone()))
448            .collect::<Vec<_>>();
449        for (tool_call_id, sink) in sinks {
450            let _ = sink
451                .send(ToolProgress {
452                    output: Vec::new(),
453                    tool_call_id,
454                    message: message.clone(),
455                    display: Some(safe_title(&message).to_string()),
456                })
457                .await;
458        }
459    }
460
461    async fn on_tool_list_changed(&self, context: NotificationContext<RoleClient>) {
462        let Some(server) = self.server.upgrade() else {
463            return;
464        };
465        server.schedule_tool_refresh(context.peer);
466    }
467}
468
469fn unsupported_server_request(method: &str) -> ErrorData {
470    ErrorData::new(
471        ErrorCode::METHOD_NOT_FOUND,
472        format!("unsupported MCP server request `{method}`"),
473        None,
474    )
475}
476
477fn oauth_enabled_config(config: &McpServerConfig) -> bool {
478    config.url.is_some()
479        && config.oauth.enabled()
480        && !config
481            .headers
482            .keys()
483            .any(|name| name.eq_ignore_ascii_case("authorization"))
484}
485
486fn auth_challenge(error: &(dyn std::error::Error + 'static)) -> Option<String> {
487    use rmcp::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError};
488
489    let mut source = Some(error);
490    while let Some(current) = source {
491        if let Some(required) = current.downcast_ref::<AuthRequiredError>() {
492            return Some(required.www_authenticate_header.clone());
493        }
494        if let Some(scope) = current.downcast_ref::<InsufficientScopeError>() {
495            return Some(scope.www_authenticate_header.clone());
496        }
497        source = current.source();
498    }
499    None
500}
501
502fn oauth_requires_auth(error: &(dyn std::error::Error + 'static)) -> bool {
503    if auth_challenge(error).is_some() {
504        return true;
505    }
506    let mut source = Some(error);
507    while let Some(current) = source {
508        if let Some(auth) = current.downcast_ref::<rmcp::transport::AuthError>()
509            && matches!(
510                auth,
511                rmcp::transport::AuthError::AuthorizationRequired
512                    | rmcp::transport::AuthError::TokenExpired
513                    | rmcp::transport::AuthError::TokenRefreshFailed(_)
514                    | rmcp::transport::AuthError::TokenRefreshRejected(_)
515            )
516        {
517            return true;
518        }
519        source = current.source();
520    }
521    false
522}
523
524fn initialize_error_requires_auth(error: &rmcp::service::ClientInitializeError) -> bool {
525    error.is_authorization_required()
526        || match error {
527            rmcp::service::ClientInitializeError::TransportError { error, .. } => {
528                oauth_requires_auth(error.error.as_ref())
529            }
530            _ => false,
531        }
532}
533
534fn service_error_requires_auth(error: &rmcp::service::ServiceError) -> bool {
535    match error {
536        rmcp::service::ServiceError::TransportSend(error) => {
537            oauth_requires_auth(error.error.as_ref())
538        }
539        _ => oauth_requires_auth(error),
540    }
541}
542
543impl OAuthCredentialFile {
544    fn open(path: PathBuf) -> Result<Self, ToolError> {
545        #[cfg(windows)]
546        {
547            load_oauth_store_windows(&path).map_err(|()| oauth_store_startup_error(&path))?;
548            Ok(Self {
549                inner: Arc::new(OAuthCredentialFileInner {
550                    path,
551                    transaction: Mutex::new(()),
552                }),
553            })
554        }
555        #[cfg(unix)]
556        {
557            load_oauth_store(&path).map_err(|()| oauth_store_startup_error(&path))?;
558            Ok(Self {
559                inner: Arc::new(OAuthCredentialFileInner {
560                    path,
561                    transaction: Mutex::new(()),
562                }),
563            })
564        }
565    }
566
567    fn scoped(&self, server: &str, binding: OAuthCredentialBinding) -> ServerCredentialStore {
568        ServerCredentialStore {
569            file: self.clone(),
570            key: oauth_credential_key(server, &binding.resource_url),
571            binding,
572        }
573    }
574
575    fn remove(&self, server: &str, binding: &OAuthCredentialBinding) -> Result<(), ToolError> {
576        let key = oauth_credential_key(server, &binding.resource_url);
577        self.update(|credentials| {
578            credentials.remove(&key);
579        })
580        .map_err(|()| oauth_store_runtime_error(&self.inner.path))
581    }
582
583    fn update(
584        &self,
585        update: impl FnOnce(&mut BTreeMap<String, PersistedOAuthCredential>),
586    ) -> Result<(), ()> {
587        #[cfg(windows)]
588        {
589            let _transaction = self
590                .inner
591                .transaction
592                .lock()
593                .unwrap_or_else(|poisoned| poisoned.into_inner());
594            let (directory, name) = oauth_store_directory_windows(&self.inner.path)?;
595            let lock = directory
596                .lock_within(OAUTH_STORE_LOCK_FILE, DEFAULT_LOCK_BUDGET)
597                .map_err(|_| ())?;
598            let mut candidate = load_oauth_store_from_lock_windows(&lock, &name)?;
599            update(&mut candidate);
600            let bytes = serde_json::to_vec(&candidate).map_err(|_| ())?;
601            lock.atomic_replace(&name, &bytes).map_err(|_| ())
602        }
603        #[cfg(unix)]
604        {
605            let _transaction = self
606                .inner
607                .transaction
608                .lock()
609                .unwrap_or_else(|poisoned| poisoned.into_inner());
610            let _file_lock = OAuthStoreLock::acquire(&self.inner.path)?;
611            let mut candidate = load_oauth_store(&self.inner.path)?;
612            update(&mut candidate);
613            persist_oauth_store(&self.inner.path, &candidate)?;
614            Ok(())
615        }
616    }
617
618    /// Lock-free read of one credential entry (D6).
619    ///
620    /// The store is only ever mutated via temp-file plus atomic replace, so a
621    /// reader always observes a complete old-or-new document; there is no torn
622    /// state to guard. On Windows the general data reader opens with
623    /// `FILE_SHARE_DELETE`, so a lock-free reader cannot block the writer's
624    /// replace.
625    fn get(&self, key: &str) -> Result<Option<PersistedOAuthCredential>, ()> {
626        #[cfg(windows)]
627        {
628            Ok(load_oauth_store_windows(&self.inner.path)?
629                .get(key)
630                .cloned())
631        }
632        #[cfg(unix)]
633        {
634            Ok(load_oauth_store(&self.inner.path)?.get(key).cloned())
635        }
636    }
637
638    /// Bounded-lock read used by the refresh-claim check (D6-correctness).
639    ///
640    /// Unlike [`Self::get`] this takes the exclusive lock so the pre-flight
641    /// compare against disk is coherent; any error (including contention) is
642    /// reported to the caller, which skips the optimization and proceeds with
643    /// the live request.
644    fn get_for_claim(&self, key: &str) -> Result<Option<PersistedOAuthCredential>, ()> {
645        #[cfg(windows)]
646        {
647            let _transaction = self
648                .inner
649                .transaction
650                .lock()
651                .unwrap_or_else(|poisoned| poisoned.into_inner());
652            let (directory, name) = oauth_store_directory_windows(&self.inner.path)?;
653            let lock = directory
654                .lock_within(OAUTH_STORE_LOCK_FILE, DEFAULT_LOCK_BUDGET)
655                .map_err(|_| ())?;
656            Ok(load_oauth_store_from_lock_windows(&lock, &name)?
657                .get(key)
658                .cloned())
659        }
660        #[cfg(unix)]
661        {
662            let _transaction = self
663                .inner
664                .transaction
665                .lock()
666                .unwrap_or_else(|poisoned| poisoned.into_inner());
667            let _file_lock = OAuthStoreLock::acquire(&self.inner.path)?;
668            Ok(load_oauth_store(&self.inner.path)?.get(key).cloned())
669        }
670    }
671}
672
673#[async_trait]
674impl CredentialStore for ServerCredentialStore {
675    async fn load(&self) -> Result<Option<StoredCredentials>, rmcp::transport::AuthError> {
676        let stored = self
677            .file
678            .get(&self.key)
679            .map_err(|()| oauth_store_auth_error())?;
680        let Some(stored) = stored else {
681            return Ok(None);
682        };
683        if stored.binding != self.binding {
684            self.clear().await?;
685            return Ok(None);
686        }
687        Ok(Some(stored.credentials.into_rmcp()))
688    }
689
690    async fn save(&self, credentials: StoredCredentials) -> Result<(), rmcp::transport::AuthError> {
691        let stored = PersistedOAuthCredential {
692            binding: self.binding.clone(),
693            credentials: StrictStoredCredentials::from_rmcp(credentials),
694        };
695        self.file
696            .update(|all| {
697                if let Some(existing) = all.get(&self.key)
698                    && credential_superseded(&existing.credentials, &stored.credentials)
699                {
700                    // Compare-before-write CAS (D6-correctness): the on-disk
701                    // entry is newer (or a same-timestamp rotation), so the
702                    // caller's credentials are superseded. The DISK wins; skip
703                    // the write without error.
704                    return;
705                }
706                all.insert(self.key.clone(), stored);
707            })
708            .map_err(|()| oauth_store_auth_error())
709    }
710
711    async fn clear(&self) -> Result<(), rmcp::transport::AuthError> {
712        self.file
713            .update(|all| {
714                all.remove(&self.key);
715            })
716            .map_err(|()| oauth_store_auth_error())
717    }
718}
719
720impl ServerCredentialStore {
721    /// Bounded-lock disk read for the refresh-claim check.
722    ///
723    /// Any error (including lock contention) yields `None`: the optimization is
724    /// skipped and the live request proceeds. This is never a failure mode.
725    fn claim_credential(&self) -> Option<PersistedOAuthCredential> {
726        self.file.get_for_claim(&self.key).ok().flatten()
727    }
728}
729
730/// Compare-before-write CAS predicate (D6-correctness).
731///
732/// Returns `true` when the freshly re-read on-disk entry supersedes the
733/// candidate being saved, in which case the write is skipped. The disk wins
734/// when it is strictly newer by `token_received_at`, or carries the same
735/// timestamp with a different refresh token. Equal-refresh-token idempotent
736/// writes and `token_response: None` administrative downgrades are allowed.
737fn credential_superseded(
738    existing: &StrictStoredCredentials,
739    candidate: &StrictStoredCredentials,
740) -> bool {
741    if candidate.token_response.is_none() {
742        return false;
743    }
744    let strictly_newer = existing.token_received_at > candidate.token_received_at;
745    let same_timestamp = existing.token_received_at == candidate.token_received_at;
746    strictly_newer || (same_timestamp && existing.refresh_token() != candidate.refresh_token())
747}
748
749fn refresh_token_of(response: &OAuthTokenResponse) -> Option<String> {
750    serde_json::to_value(response)
751        .ok()?
752        .get("refresh_token")?
753        .as_str()
754        .map(str::to_owned)
755}
756
757impl OAuthCredentialBinding {
758    fn from_config(config: &McpServerConfig) -> Option<Self> {
759        let resource_url = canonical_oauth_resource_url(config.url.as_deref()?);
760        let settings = config.oauth.settings();
761        let client_secret_sha256 = settings.client_secret.as_ref().map(|secret| {
762            let digest = Sha256::digest(secret.as_bytes());
763            digest
764                .iter()
765                .map(|byte| format!("{byte:02x}"))
766                .collect::<String>()
767        });
768        Some(Self {
769            resource_url,
770            configured_client_id: settings.client_id,
771            client_metadata_url: settings.client_metadata_url,
772            client_secret_sha256,
773            scopes: settings.scopes,
774        })
775    }
776}
777
778fn canonical_oauth_resource_url(resource_url: &str) -> String {
779    let Ok(mut url) = url::Url::parse(resource_url) else {
780        // Invalid URLs stay fully distinct instead of falling back to a broader key.
781        return resource_url.to_owned();
782    };
783    if matches!(
784        (url.scheme(), url.port()),
785        ("http", Some(80)) | ("https", Some(443))
786    ) {
787        let _ = url.set_port(None);
788    }
789    url.into()
790}
791
792fn oauth_credential_key(server: &str, canonical_resource_url: &str) -> String {
793    let digest = Sha256::digest(canonical_resource_url.as_bytes());
794    let hash = digest
795        .iter()
796        .map(|byte| format!("{byte:02x}"))
797        .collect::<String>();
798    format!("{server}:{hash}")
799}
800
801impl StrictStoredCredentials {
802    fn from_rmcp(credentials: StoredCredentials) -> Self {
803        Self {
804            client_id: credentials.client_id,
805            token_response: credentials.token_response,
806            granted_scopes: credentials.granted_scopes,
807            token_received_at: credentials.token_received_at,
808            issuer: credentials.issuer,
809        }
810    }
811
812    fn into_rmcp(self) -> StoredCredentials {
813        StoredCredentials::new(
814            self.client_id,
815            self.token_response,
816            self.granted_scopes,
817            self.token_received_at,
818        )
819        .with_issuer(self.issuer)
820    }
821
822    fn refresh_token(&self) -> Option<String> {
823        self.token_response.as_ref().and_then(refresh_token_of)
824    }
825}
826
827impl McpOAuthHttpClient {
828    fn new(exchange: OAuthExchangeState, store: ServerCredentialStore) -> Result<Self, ToolError> {
829        let base = || {
830            reqwest::Client::builder()
831                .timeout(Duration::from_secs(30))
832                .pool_max_idle_per_host(0)
833        };
834        let follow_redirects = base()
835            .build()
836            .map_err(|_| ToolError::execution("MCP OAuth HTTP client setup failed"))?;
837        let stop_redirects = base()
838            .redirect(reqwest::redirect::Policy::none())
839            .build()
840            .map_err(|_| ToolError::execution("MCP OAuth HTTP client setup failed"))?;
841        Ok(Self {
842            follow_redirects,
843            stop_redirects,
844            exchange,
845            store,
846        })
847    }
848
849    /// Pre-flight refresh claim (D6-correctness).
850    ///
851    /// Compares the refresh token about to be presented to the IdP against the
852    /// on-disk credential under a bounded lock. When disk has rotated ahead,
853    /// another process already refreshed: synthesize a 200 adopting the disk
854    /// credentials instead of making a doomed IdP call. Any read error or
855    /// contention skips the check so the live request proceeds.
856    fn adopted_success(&self, requested: Option<&str>) -> Option<http::Response<Vec<u8>>> {
857        let requested = requested?;
858        let disk = self.store.claim_credential()?;
859        if disk.binding != self.store.binding {
860            return None;
861        }
862        let token_response = disk.credentials.token_response.as_ref()?;
863        let disk_refresh = refresh_token_of(token_response)?;
864        if disk_refresh == requested {
865            return None;
866        }
867        let body = serde_json::to_vec(token_response).ok()?;
868        http::Response::builder()
869            .status(http::StatusCode::OK)
870            .header(http::header::CONTENT_TYPE, "application/json")
871            .body(body)
872            .ok()
873    }
874}
875
876/// Extracts the refresh-token grant's token from an OAuth form body.
877///
878/// Returns `Some(token)` only for `grant_type=refresh_token` requests; `None`
879/// otherwise. This sniffing is the fragile part of the refresh claim and is
880/// covered by a debug test that fails loudly if rmcp stops routing refreshes
881/// through this client.
882fn refresh_grant_token(body: &[u8]) -> Option<String> {
883    let mut grant_type = None;
884    let mut refresh_token = None;
885    for (name, value) in url::form_urlencoded::parse(body) {
886        match name.as_ref() {
887            "grant_type" => grant_type = Some(value.into_owned()),
888            "refresh_token" => refresh_token = Some(value.into_owned()),
889            _ => {}
890        }
891    }
892    if grant_type.as_deref() != Some("refresh_token") {
893        return None;
894    }
895    refresh_token
896}
897
898/// Whether a token-endpoint response is the `invalid_grant` rejection.
899fn response_is_invalid_grant(response: &http::Response<Vec<u8>>) -> bool {
900    if response.status() != http::StatusCode::BAD_REQUEST {
901        return false;
902    }
903    if let Ok(value) = serde_json::from_slice::<serde_json::Value>(response.body())
904        && value.get("error").and_then(serde_json::Value::as_str) == Some("invalid_grant")
905    {
906        return true;
907    }
908    response
909        .body()
910        .windows(b"invalid_grant".len())
911        .any(|window| window == b"invalid_grant")
912}
913
914impl OAuthExchangeState {
915    fn diagnostic(&self, error: &(dyn std::error::Error + 'static)) -> String {
916        self.response_error
917            .lock()
918            .unwrap_or_else(|p| p.into_inner())
919            .clone()
920            .unwrap_or_else(|| cookie_agent_protocol::diagnostics::error_chain(error))
921    }
922
923    fn install(&self, code: String) {
924        *self
925            .code
926            .lock()
927            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(zeroize::Zeroizing::new(code));
928    }
929
930    fn restore_in_request(&self, request: &mut http::Request<Vec<u8>>) {
931        let parameters = url::form_urlencoded::parse(request.body())
932            .into_owned()
933            .collect::<Vec<_>>();
934        if !parameters
935            .iter()
936            .any(|(name, value)| name == "code" && value == REDACTED_AUTHORIZATION_CODE)
937        {
938            return;
939        }
940        let Some(code) = self
941            .code
942            .lock()
943            .unwrap_or_else(|poisoned| poisoned.into_inner())
944            .take()
945        else {
946            return;
947        };
948        let mut encoded = url::form_urlencoded::Serializer::new(String::new());
949        for (name, value) in parameters {
950            encoded.append_pair(
951                &name,
952                if name == "code" && value == REDACTED_AUTHORIZATION_CODE {
953                    code.as_str()
954                } else {
955                    &value
956                },
957            );
958        }
959        *request.body_mut() = encoded.finish().into_bytes();
960    }
961}
962
963impl OAuthHttpClient for McpOAuthHttpClient {
964    fn execute(&self, operation: rmcp::transport::OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
965        Box::pin(async move {
966            let client = match operation.redirect_policy {
967                OAuthHttpRedirectPolicy::Follow => &self.follow_redirects,
968                OAuthHttpRedirectPolicy::Stop => &self.stop_redirects,
969                _ => &self.stop_redirects,
970            };
971            let timeout = operation.timeout;
972            let mut oauth_request = operation.request;
973            self.exchange.restore_in_request(&mut oauth_request);
974            // D6-correctness pre-flight: for a refresh grant, adopt a rotation
975            // that already landed on disk instead of making a doomed IdP call.
976            let refresh_token = refresh_grant_token(oauth_request.body());
977            if let Some(response) = self.adopted_success(refresh_token.as_deref()) {
978                return Ok(response);
979            }
980            let mut request = reqwest::Request::try_from(oauth_request)
981                .map_err(|_| Box::new(OAuthHttpFailure) as rmcp::transport::OAuthHttpClientError)?;
982            *request.timeout_mut() = timeout;
983            let response = client
984                .execute(request)
985                .await
986                .map_err(|_| Box::new(OAuthHttpFailure) as rmcp::transport::OAuthHttpClientError)?;
987            let status = response.status();
988            let version = response.version();
989            let headers = response.headers().clone();
990            let mut body = Vec::new();
991            let mut stream = response.bytes_stream();
992            while let Some(chunk) = stream.next().await {
993                let chunk = chunk.map_err(|_| {
994                    Box::new(OAuthHttpFailure) as rmcp::transport::OAuthHttpClientError
995                })?;
996                if chunk.len() > OAUTH_STORE_MAX_BYTES as usize - body.len() {
997                    return Err(Box::new(OAuthHttpFailure) as rmcp::transport::OAuthHttpClientError);
998                }
999                body.extend_from_slice(&chunk);
1000            }
1001            // Keep the original response for OAuth error classification. Retain a bounded
1002            // display copy because rmcp can discard the body on parse failures.
1003            *self
1004                .exchange
1005                .response_error
1006                .lock()
1007                .unwrap_or_else(|p| p.into_inner()) = (!status.is_success()).then(|| {
1008                cookie_agent_protocol::diagnostics::detail(&format!(
1009                    "OAuth HTTP {status}\nResponse body:\n{}",
1010                    String::from_utf8_lossy(&body)
1011                ))
1012                .to_string()
1013            });
1014            let mut response = http::Response::builder().status(status).version(version);
1015            for (name, value) in &headers {
1016                response = response.header(name, value);
1017            }
1018            let response = response
1019                .body(body)
1020                .map_err(|_| Box::new(OAuthHttpFailure) as rmcp::transport::OAuthHttpClientError)?;
1021            // D6-correctness recovery: a refresh the IdP rejected with
1022            // `invalid_grant` may have lost a cross-process race. Re-check disk
1023            // once; if a sibling landed a rotation, adopt it instead of
1024            // surfacing re-login.
1025            if refresh_token.is_some()
1026                && response_is_invalid_grant(&response)
1027                && let Some(adopted) = self.adopted_success(refresh_token.as_deref())
1028            {
1029                return Ok(adopted);
1030            }
1031            Ok(response)
1032        })
1033    }
1034}
1035
1036fn parse_oauth_callback(url: &str) -> Result<AuthorizationCallback, String> {
1037    AuthorizationCallback::from_redirect_url(url).map_err(|error| {
1038        let mut message = error.to_string();
1039        if let Ok(url) = url::Url::parse(url) {
1040            for (key, value) in url
1041                .query_pairs()
1042                .filter(|(key, _)| matches!(key.as_ref(), "error" | "error_description"))
1043            {
1044                message.push_str(&format!("\n{key}: {value}"));
1045            }
1046        }
1047        cookie_agent_protocol::diagnostics::detail(&message).to_string()
1048    })
1049}
1050
1051fn oauth_store_startup_error(path: &Path) -> ToolError {
1052    ToolError::execution(format!(
1053        "invalid MCP OAuth credential store `{}`; fix its contents or remove the file to reset user MCP OAuth credentials",
1054        path.display()
1055    ))
1056}
1057
1058#[cfg(windows)]
1059fn oauth_store_directory_windows(path: &Path) -> Result<(SecureDirectory, String), ()> {
1060    let parent = path.parent().ok_or(())?;
1061    let name = path
1062        .file_name()
1063        .and_then(|name| name.to_str())
1064        .ok_or(())?
1065        .to_owned();
1066    let directory = SecureDirectory::open(parent).map_err(|_| ())?;
1067    Ok((directory, name))
1068}
1069
1070#[cfg(windows)]
1071fn load_oauth_store_windows(path: &Path) -> Result<BTreeMap<String, PersistedOAuthCredential>, ()> {
1072    // D6: reads are lock-free on Windows too. The general data reader opens
1073    // with `FILE_SHARE_DELETE`, so this cannot block a concurrent atomic
1074    // replace, and the replace is atomic (old-or-new bytes, never torn).
1075    let (directory, name) = oauth_store_directory_windows(path)?;
1076    let Some(bytes) = directory
1077        .read(&name, OAUTH_STORE_MAX_BYTES)
1078        .map_err(|_| ())?
1079    else {
1080        return Ok(BTreeMap::new());
1081    };
1082    serde_json::from_slice(&bytes).map_err(|_| ())
1083}
1084
1085#[cfg(windows)]
1086fn load_oauth_store_from_lock_windows(
1087    lock: &cookie_agent_models::secure_store::SecureDirectoryLock<'_>,
1088    name: &str,
1089) -> Result<BTreeMap<String, PersistedOAuthCredential>, ()> {
1090    let Some(bytes) = lock.read(name, OAUTH_STORE_MAX_BYTES).map_err(|_| ())? else {
1091        return Ok(BTreeMap::new());
1092    };
1093    serde_json::from_slice(&bytes).map_err(|_| ())
1094}
1095
1096fn oauth_store_runtime_error(path: &Path) -> ToolError {
1097    ToolError::execution(format!(
1098        "MCP OAuth credential storage failed at `{}`",
1099        path.display()
1100    ))
1101}
1102
1103fn oauth_store_auth_error() -> rmcp::transport::AuthError {
1104    rmcp::transport::AuthError::InternalError("OAuth credential storage failed".into())
1105}
1106
1107#[cfg(unix)]
1108impl OAuthStoreLock {
1109    fn acquire(store_path: &Path) -> Result<Self, ()> {
1110        let parent = store_path.parent().ok_or(())?;
1111        if !parent.exists() {
1112            use std::os::unix::fs::DirBuilderExt as _;
1113
1114            let mut builder = fs::DirBuilder::new();
1115            builder.recursive(true).mode(0o700);
1116            builder.create(parent).map_err(|_| ())?;
1117        }
1118        let path = parent.join(OAUTH_STORE_LOCK_FILE);
1119        let mut options = OpenOptions::new();
1120        options.read(true).write(true).create(true);
1121        #[cfg(unix)]
1122        {
1123            use std::os::unix::fs::OpenOptionsExt as _;
1124            options.mode(0o600).custom_flags(libc::O_CLOEXEC);
1125        }
1126        let file = options.open(&path).map_err(|_| ())?;
1127        if !lock_within_file(&file, DEFAULT_LOCK_BUDGET).map_err(|_| ())? {
1128            return Err(());
1129        }
1130        Ok(Self { file })
1131    }
1132}
1133
1134#[cfg(unix)]
1135impl Drop for OAuthStoreLock {
1136    fn drop(&mut self) {
1137        let _ = unlock(&self.file);
1138    }
1139}
1140
1141#[cfg(unix)]
1142fn load_oauth_store(path: &Path) -> Result<BTreeMap<String, PersistedOAuthCredential>, ()> {
1143    let Some(mut file) = open_oauth_store(path)? else {
1144        return Ok(BTreeMap::new());
1145    };
1146    let mut bytes = Vec::new();
1147    (&mut file)
1148        .take(OAUTH_STORE_MAX_BYTES + 1)
1149        .read_to_end(&mut bytes)
1150        .map_err(|_| ())?;
1151    if bytes.len() as u64 > OAUTH_STORE_MAX_BYTES {
1152        return Err(());
1153    }
1154    serde_json::from_slice(&bytes).map_err(|_| ())
1155}
1156
1157#[cfg(unix)]
1158fn open_oauth_store(path: &Path) -> Result<Option<fs::File>, ()> {
1159    match OpenOptions::new().read(true).open(path) {
1160        Ok(file) => Ok(Some(file)),
1161        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1162        Err(_) => Err(()),
1163    }
1164}
1165
1166#[cfg(unix)]
1167fn persist_oauth_store(
1168    path: &Path,
1169    credentials: &BTreeMap<String, PersistedOAuthCredential>,
1170) -> Result<(), ()> {
1171    let parent = path.parent().ok_or(())?;
1172    if !parent.exists() {
1173        use std::os::unix::fs::DirBuilderExt as _;
1174
1175        let mut builder = fs::DirBuilder::new();
1176        builder.recursive(true).mode(0o700);
1177        builder.create(parent).map_err(|_| ())?;
1178    }
1179    let bytes = serde_json::to_vec(credentials).map_err(|_| ())?;
1180    let temporary = parent.join(format!(".{OAUTH_STORE_FILE}.{}.tmp", uuid::Uuid::now_v7()));
1181    let mut options = OpenOptions::new();
1182    options.write(true).create_new(true);
1183    {
1184        use std::os::unix::fs::OpenOptionsExt as _;
1185        options
1186            .mode(0o600)
1187            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
1188    }
1189    let mut file = options.open(&temporary).map_err(|_| ())?;
1190    {
1191        use std::os::unix::fs::PermissionsExt as _;
1192        file.set_permissions(fs::Permissions::from_mode(0o600))
1193            .map_err(|_| ())?;
1194    }
1195    let result = (|| {
1196        file.write_all(&bytes).map_err(|_| ())?;
1197        file.sync_all().map_err(|_| ())?;
1198        drop(file);
1199        fs::rename(&temporary, path).map_err(|_| ())?;
1200        sync_oauth_directory(parent).map_err(|_| ())?;
1201        Ok(())
1202    })();
1203    if result.is_err() {
1204        let _ = fs::remove_file(temporary);
1205    }
1206    result
1207}
1208
1209#[cfg(unix)]
1210fn sync_oauth_directory(path: &Path) -> std::io::Result<()> {
1211    fs::File::open(path)?.sync_all()
1212}
1213
1214fn apply_oauth_settings(
1215    mut request: AuthorizationRequest,
1216    settings: &McpOAuthSettings,
1217) -> AuthorizationRequest {
1218    if !settings.scopes.is_empty() {
1219        request = request.with_scopes(settings.scopes.clone());
1220    }
1221    if let Some(client_id) = &settings.client_id {
1222        request = request.with_preregistered_client(client_id);
1223    }
1224    if let Some(client_secret) = &settings.client_secret {
1225        request = request.with_client_secret(client_secret);
1226    }
1227    if let Some(client_metadata_url) = &settings.client_metadata_url {
1228        request = request.with_client_metadata_url(client_metadata_url);
1229    }
1230    request
1231}
1232
1233async fn receive_oauth_callback(listener: TcpListener) -> Result<(TcpStream, String), ()> {
1234    loop {
1235        let (mut stream, _) = listener.accept().await.map_err(|_| ())?;
1236        let mut request = Vec::new();
1237        loop {
1238            let mut buffer = [0_u8; 1024];
1239            let read = stream.read(&mut buffer).await.map_err(|_| ())?;
1240            if read == 0 {
1241                break;
1242            }
1243            request.extend_from_slice(&buffer[..read]);
1244            if request.windows(4).any(|window| window == b"\r\n\r\n") {
1245                break;
1246            }
1247            if request.len() > OAUTH_CALLBACK_MAX_BYTES {
1248                break;
1249            }
1250        }
1251        if request.len() > OAUTH_CALLBACK_MAX_BYTES {
1252            let _ = write_oauth_browser_response(&mut stream, false).await;
1253            continue;
1254        }
1255        let Ok(request) = std::str::from_utf8(&request) else {
1256            let _ = write_oauth_browser_response(&mut stream, false).await;
1257            continue;
1258        };
1259        let Some(target) = request
1260            .lines()
1261            .next()
1262            .and_then(|line| line.strip_prefix("GET "))
1263            .and_then(|line| line.split_once(' '))
1264            .map(|(target, _)| target)
1265        else {
1266            let _ = write_oauth_browser_response(&mut stream, false).await;
1267            continue;
1268        };
1269        let Ok(url) = url::Url::parse(&format!("http://127.0.0.1{target}")) else {
1270            let _ = write_oauth_browser_response(&mut stream, false).await;
1271            continue;
1272        };
1273        if url.path() != "/callback" {
1274            let _ = write_oauth_browser_response(&mut stream, false).await;
1275            continue;
1276        }
1277        return Ok((stream, url.to_string()));
1278    }
1279}
1280
1281async fn write_oauth_browser_response(
1282    stream: &mut TcpStream,
1283    success: bool,
1284) -> std::io::Result<()> {
1285    let (status, body) = if success {
1286        (
1287            "200 OK",
1288            "Authorization complete. You can close this window.",
1289        )
1290    } else {
1291        (
1292            "400 Bad Request",
1293            "Authorization failed. Return to Cookie Agent and try again.",
1294        )
1295    };
1296    let response = format!(
1297        "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1298        body.len()
1299    );
1300    stream.write_all(response.as_bytes()).await?;
1301    stream.shutdown().await
1302}
1303
1304impl McpRegistry {
1305    pub(crate) fn set_plugin_collision_handler(&self, handler: PluginCollisionHandler) {
1306        *self
1307            .inner
1308            .plugin_collision_handler
1309            .lock()
1310            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(handler);
1311    }
1312
1313    pub(crate) fn claim_plugin_tools(
1314        &self,
1315        plugin: &str,
1316        names: &[String],
1317    ) -> Result<(), ToolError> {
1318        let reserved = self
1319            .inner
1320            .reserved_names
1321            .lock()
1322            .unwrap_or_else(|poisoned| poisoned.into_inner());
1323        let mut claimed = self
1324            .inner
1325            .claimed_names
1326            .lock()
1327            .unwrap_or_else(|poisoned| poisoned.into_inner());
1328        let owner = format!("plugin:{plugin}");
1329        let mut local = HashSet::new();
1330        let mut displaced = Vec::new();
1331        for name in names {
1332            if reserved.contains(name) || !local.insert(name) {
1333                return Err(ToolError::execution(format!(
1334                    "plugin `{plugin}` declared colliding tool name `{name}`"
1335                )));
1336            }
1337            if let Some(current) = claimed.get(name) {
1338                if let Some(previous) = current.strip_prefix("plugin:") {
1339                    if previous != plugin {
1340                        displaced.push((previous.to_owned(), name.clone()));
1341                    }
1342                } else {
1343                    return Err(ToolError::execution(format!(
1344                        "plugin `{plugin}` declared colliding tool name `{name}`"
1345                    )));
1346                }
1347            }
1348        }
1349        for name in names {
1350            claimed.insert(name.clone(), owner.clone());
1351        }
1352        drop(claimed);
1353        drop(reserved);
1354        let collision_handler = self
1355            .inner
1356            .plugin_collision_handler
1357            .lock()
1358            .unwrap_or_else(|poisoned| poisoned.into_inner())
1359            .clone();
1360        if let Some(handler) = collision_handler {
1361            for (previous, tool) in displaced {
1362                handler(&previous, &tool, Some(plugin));
1363            }
1364        }
1365        Ok(())
1366    }
1367
1368    pub(crate) fn plugin_owns_tool(&self, plugin: &str, tool: &str) -> bool {
1369        self.inner
1370            .claimed_names
1371            .lock()
1372            .unwrap_or_else(|poisoned| poisoned.into_inner())
1373            .get(tool)
1374            .is_some_and(|owner| owner == &format!("plugin:{plugin}"))
1375    }
1376
1377    pub(crate) fn release_plugin_tools(&self, plugin: &str) {
1378        let owner = format!("plugin:{plugin}");
1379        self.inner
1380            .claimed_names
1381            .lock()
1382            .unwrap_or_else(|poisoned| poisoned.into_inner())
1383            .retain(|_, current| current != &owner);
1384    }
1385
1386    pub(crate) fn new(
1387        servers: BTreeMap<String, LoadedMcpServer>,
1388        oauth_path: std::path::PathBuf,
1389    ) -> Result<Self, ToolError> {
1390        let oauth_credentials = OAuthCredentialFile::open(oauth_path)?;
1391        let inner = Arc::new(RegistryInner {
1392            servers: Mutex::new(BTreeMap::new()),
1393            reserved_names: Mutex::new(HashSet::from([
1394                "read".into(),
1395                "write".into(),
1396                "edit".into(),
1397                "bash".into(),
1398                "delegate_subagent".into(),
1399                "get_subagent_result".into(),
1400                "cancel_subagent".into(),
1401                "skill".into(),
1402            ])),
1403            claimed_names: Mutex::new(HashMap::new()),
1404            plugin_collision_handler: Mutex::new(None),
1405            oauth_credentials,
1406            shutdown: CancellationToken::new(),
1407            connection_tasks: Mutex::new(Vec::new()),
1408            active_connects: AtomicUsize::new(0),
1409            connects_idle: Notify::new(),
1410            eager_pending: AtomicUsize::new(0),
1411            eager_ready: Notify::new(),
1412        });
1413        let mut sanitized_servers = HashMap::new();
1414        let mut runtimes = BTreeMap::new();
1415        for (name, loaded) in servers {
1416            let sanitized_name = sanitize_name(&name);
1417            if let Some(existing) = sanitized_servers.insert(sanitized_name.clone(), name.clone()) {
1418                return Err(ToolError::execution(format!(
1419                    "MCP server `{name}` collides with server `{existing}` after name sanitization"
1420                )));
1421            }
1422            let state = if !loaded.config.enabled {
1423                McpServerState::Disabled
1424            } else {
1425                McpServerState::Disconnected
1426            };
1427            let runtime = Arc::new(ServerRuntime {
1428                name: name.clone(),
1429                sanitized_name,
1430                loaded,
1431                status: Mutex::new(McpServerStatus {
1432                    server: name.clone(),
1433                    state,
1434                    message: None,
1435                    tools: Vec::new(),
1436                    auth_in_progress: false,
1437                }),
1438                tools: Mutex::new(Vec::new()),
1439                list_generation: AtomicU64::new(0),
1440                publication: Mutex::new(()),
1441                tool_refresh: Mutex::new(ToolRefreshDebounce::default()),
1442                connect_lock: AsyncMutex::new(()),
1443                service: AsyncMutex::new(None),
1444                superseded: CancellationToken::new(),
1445                registry: Arc::downgrade(&inner),
1446                progress: AsyncMutex::new(HashMap::new()),
1447                auth_challenge: Mutex::new(None),
1448                oauth_response_error: Arc::default(),
1449                auth_flow: Mutex::new(None),
1450                auth_generation: AtomicU64::new(0),
1451                auth_lock: AsyncMutex::new(()),
1452            });
1453            runtimes.insert(name, runtime);
1454        }
1455        *inner.servers.lock().unwrap_or_else(|p| p.into_inner()) = runtimes;
1456        Ok(Self { inner })
1457    }
1458
1459    pub(crate) fn reserve_provider(&self, provider: &dyn ToolProvider) -> Result<(), ToolError> {
1460        let Ok(tools) = provider.tools_for_session(&SessionToolContext::new(
1461            cookie_agent_protocol::SessionId::new_v7(),
1462        )) else {
1463            // Session-dependent providers are checked when they publish tools.
1464            return Ok(());
1465        };
1466        let mut reserved = self
1467            .inner
1468            .reserved_names
1469            .lock()
1470            .unwrap_or_else(|p| p.into_inner());
1471        let claimed = self
1472            .inner
1473            .claimed_names
1474            .lock()
1475            .unwrap_or_else(|p| p.into_inner());
1476        for tool in tools {
1477            if let Some(server) = claimed.get(&tool.name) {
1478                return Err(ToolError::execution(format!(
1479                    "MCP server `{server}` generated tool name `{}` which collides with a registered tool",
1480                    tool.name
1481                )));
1482            }
1483            reserved.insert(tool.name);
1484        }
1485        Ok(())
1486    }
1487
1488    pub(crate) fn start_eager(&self, runtime: &tokio::runtime::Handle) {
1489        let servers = self
1490            .servers()
1491            .into_iter()
1492            .filter(|server| {
1493                server.loaded.config.enabled
1494                    && !server.loaded.config.lazy
1495                    && server.current_state() == McpServerState::Disconnected
1496            })
1497            .collect::<Vec<_>>();
1498        self.inner
1499            .eager_pending
1500            .store(servers.len(), Ordering::Release);
1501        for server in servers {
1502            let registry = Arc::clone(&self.inner);
1503            let task_registry = Arc::clone(&registry);
1504            let eager = EagerConnect(Arc::clone(&task_registry));
1505            let task = runtime.spawn(async move {
1506                let _eager = eager;
1507                if let Err(error) = server.connect().await
1508                    && !task_registry.shutdown.is_cancelled()
1509                {
1510                    server.fail(error.to_string());
1511                }
1512            });
1513            registry
1514                .connection_tasks
1515                .lock()
1516                .unwrap_or_else(|p| p.into_inner())
1517                .push(task);
1518        }
1519    }
1520
1521    pub(crate) async fn await_eager_ready(&self) {
1522        while self.inner.eager_pending.load(Ordering::Acquire) != 0 {
1523            let notified = self.inner.eager_ready.notified();
1524            if self.inner.eager_pending.load(Ordering::Acquire) == 0 {
1525                break;
1526            }
1527            notified.await;
1528        }
1529    }
1530
1531    pub async fn shutdown(&self) {
1532        self.inner.shutdown.cancel();
1533        for server in self.servers() {
1534            server.cancel_tool_refresh();
1535        }
1536        let tasks = self
1537            .inner
1538            .connection_tasks
1539            .lock()
1540            .map(|mut tasks| tasks.drain(..).collect::<Vec<_>>())
1541            .unwrap_or_default();
1542        for mut task in tasks {
1543            if tokio::time::timeout(Duration::from_secs(5), &mut task)
1544                .await
1545                .is_err()
1546            {
1547                task.abort();
1548                let _ = task.await;
1549            }
1550        }
1551        for server in self.servers() {
1552            let mut service = server.service.lock().await;
1553            if let Some(service) = service.as_mut() {
1554                let _ = service.close_with_timeout(Duration::from_secs(4)).await;
1555            }
1556            *service = None;
1557        }
1558        while self.inner.active_connects.load(Ordering::Acquire) != 0 {
1559            let notified = self.inner.connects_idle.notified();
1560            if self.inner.active_connects.load(Ordering::Acquire) == 0 {
1561                break;
1562            }
1563            notified.await;
1564        }
1565    }
1566
1567    #[must_use]
1568    pub fn statuses(&self) -> Vec<McpServerStatus> {
1569        self.servers()
1570            .into_iter()
1571            .map(|server| {
1572                server
1573                    .status
1574                    .lock()
1575                    .unwrap_or_else(|p| p.into_inner())
1576                    .clone()
1577            })
1578            .collect()
1579    }
1580
1581    pub(crate) async fn upsert_server(
1582        &self,
1583        name: String,
1584        loaded: LoadedMcpServer,
1585    ) -> Result<(), ToolError> {
1586        let sanitized_name = sanitize_name(&name);
1587        let previous = {
1588            let servers = self
1589                .inner
1590                .servers
1591                .lock()
1592                .unwrap_or_else(|poisoned| poisoned.into_inner());
1593            if let Some(existing) = servers
1594                .values()
1595                .find(|server| server.name != name && server.sanitized_name == sanitized_name)
1596            {
1597                return Err(ToolError::execution(format!(
1598                    "MCP server `{name}` collides with server `{}` after name sanitization",
1599                    existing.name
1600                )));
1601            }
1602            servers.get(&name).cloned()
1603        };
1604        let previous_binding = previous
1605            .as_ref()
1606            .and_then(|server| OAuthCredentialBinding::from_config(&server.loaded.config));
1607        let new_binding = OAuthCredentialBinding::from_config(&loaded.config);
1608        let binding_changed = previous.is_some() && previous_binding != new_binding;
1609        let _previous_auth = if let Some(previous) = &previous {
1610            let auth = previous.auth_lock.lock().await;
1611            if binding_changed && let Some(binding) = &previous_binding {
1612                self.inner.oauth_credentials.remove(&name, binding)?;
1613            }
1614            previous.supersede();
1615            Some(auth)
1616        } else {
1617            None
1618        };
1619        let needs_auth_after_edit = binding_changed && oauth_enabled_config(&loaded.config);
1620        let state = if !loaded.config.enabled {
1621            McpServerState::Disabled
1622        } else if needs_auth_after_edit {
1623            McpServerState::NeedsAuth
1624        } else {
1625            McpServerState::Disconnected
1626        };
1627        let message = (state == McpServerState::NeedsAuth).then(|| {
1628            "OAuth credentials invalidated by MCP server configuration change; authenticate to connect".into()
1629        });
1630        let runtime = Arc::new(ServerRuntime {
1631            name: name.clone(),
1632            sanitized_name,
1633            loaded,
1634            status: Mutex::new(McpServerStatus {
1635                server: name.clone(),
1636                state,
1637                message,
1638                tools: Vec::new(),
1639                auth_in_progress: false,
1640            }),
1641            tools: Mutex::new(Vec::new()),
1642            list_generation: AtomicU64::new(0),
1643            publication: Mutex::new(()),
1644            tool_refresh: Mutex::new(ToolRefreshDebounce::default()),
1645            connect_lock: AsyncMutex::new(()),
1646            service: AsyncMutex::new(None),
1647            superseded: CancellationToken::new(),
1648            registry: Arc::downgrade(&self.inner),
1649            progress: AsyncMutex::new(HashMap::new()),
1650            auth_challenge: Mutex::new(None),
1651            oauth_response_error: Arc::default(),
1652            auth_flow: Mutex::new(None),
1653            auth_generation: AtomicU64::new(0),
1654            auth_lock: AsyncMutex::new(()),
1655        });
1656        {
1657            let mut servers = self
1658                .inner
1659                .servers
1660                .lock()
1661                .unwrap_or_else(|poisoned| poisoned.into_inner());
1662            servers.insert(name, Arc::clone(&runtime));
1663        }
1664        drop(_previous_auth);
1665        if let Some(previous) = previous
1666            && let Some(mut service) = previous.service.lock().await.take()
1667        {
1668            let _ = service.close_with_timeout(Duration::from_secs(4)).await;
1669        }
1670        if runtime.loaded.config.enabled
1671            && !runtime.loaded.config.lazy
1672            && runtime.current_state() == McpServerState::Disconnected
1673        {
1674            let handle = tokio::runtime::Handle::try_current()
1675                .map_err(|_| ToolError::execution("MCP runtime is unavailable"))?;
1676            self.spawn_connection(runtime, &handle, None)?;
1677        }
1678        Ok(())
1679    }
1680
1681    pub(crate) async fn remove_server(&self, name: &str) -> Result<(), ToolError> {
1682        let server = self.server(name)?;
1683        let _auth = server.auth_lock.lock().await;
1684        if let Some(binding) = OAuthCredentialBinding::from_config(&server.loaded.config) {
1685            self.inner.oauth_credentials.remove(name, &binding)?;
1686        }
1687        server.supersede();
1688        let removed = {
1689            let mut servers = self
1690                .inner
1691                .servers
1692                .lock()
1693                .unwrap_or_else(|poisoned| poisoned.into_inner());
1694            let removed = servers
1695                .get(name)
1696                .cloned()
1697                .ok_or_else(|| ToolError::execution(format!("unknown MCP server `{name}`")))?;
1698            servers.remove(name);
1699            removed
1700        };
1701        if let Some(mut service) = removed.service.lock().await.take() {
1702            let _ = service.close_with_timeout(Duration::from_secs(4)).await;
1703        }
1704        if let Some(flow) = removed
1705            .auth_flow
1706            .lock()
1707            .unwrap_or_else(|poisoned| poisoned.into_inner())
1708            .take()
1709        {
1710            flow.cancellation.cancel();
1711        }
1712        Ok(())
1713    }
1714
1715    pub(crate) async fn reconnect_server(&self, name: &str) -> Result<(), ToolError> {
1716        let server = self.server(name)?;
1717        if !server.loaded.config.enabled {
1718            return Err(ToolError::execution(format!(
1719                "MCP server `{name}` is disabled"
1720            )));
1721        }
1722        server.cancel_tool_refresh();
1723        server.clear_claims();
1724        server
1725            .tools
1726            .lock()
1727            .unwrap_or_else(|poisoned| poisoned.into_inner())
1728            .clear();
1729        if let Some(mut service) = server.service.lock().await.take() {
1730            let _ = service.close_with_timeout(Duration::from_secs(4)).await;
1731        }
1732        server.set_status(McpServerState::Disconnected, None);
1733        match server.connect().await {
1734            Ok(()) => Ok(()),
1735            Err(error) => {
1736                server.fail(error.to_string());
1737                Err(error)
1738            }
1739        }
1740    }
1741
1742    pub(crate) async fn begin_auth(&self, name: &str) -> Result<String, ToolError> {
1743        let server = self.server(name)?;
1744        server.begin_auth().await
1745    }
1746
1747    pub(crate) fn cancel_auth(&self, name: &str) -> Result<(), ToolError> {
1748        let server = self.server(name)?;
1749        let flow = server
1750            .auth_flow
1751            .lock()
1752            .unwrap_or_else(|poisoned| poisoned.into_inner())
1753            .take()
1754            .ok_or_else(|| {
1755                ToolError::execution(format!(
1756                    "MCP server `{name}` has no OAuth authorization in progress"
1757                ))
1758            })?;
1759        flow.cancellation.cancel();
1760        server.set_auth_in_progress(false);
1761        server.set_status(
1762            McpServerState::NeedsAuth,
1763            Some("OAuth authorization cancelled; authenticate to connect".into()),
1764        );
1765        Ok(())
1766    }
1767
1768    fn spawn_connection(
1769        &self,
1770        server: Arc<ServerRuntime>,
1771        runtime: &tokio::runtime::Handle,
1772        readiness: Option<EagerConnect>,
1773    ) -> Result<(), ToolError> {
1774        let registry = Arc::clone(&self.inner);
1775        let mut tasks = registry
1776            .connection_tasks
1777            .lock()
1778            .unwrap_or_else(|p| p.into_inner());
1779        if registry.shutdown.is_cancelled() {
1780            return Err(ToolError::execution("MCP registry is shutting down"));
1781        }
1782        let task_registry = Arc::clone(&registry);
1783        let task = runtime.spawn(async move {
1784            let _readiness = readiness;
1785            if let Err(error) = server.connect().await
1786                && !task_registry.shutdown.is_cancelled()
1787            {
1788                server.fail(error.to_string());
1789            }
1790        });
1791        tasks.push(task);
1792        Ok(())
1793    }
1794
1795    fn servers(&self) -> Vec<Arc<ServerRuntime>> {
1796        self.inner
1797            .servers
1798            .lock()
1799            .unwrap_or_else(|p| p.into_inner())
1800            .values()
1801            .cloned()
1802            .collect()
1803    }
1804
1805    fn server(&self, name: &str) -> Result<Arc<ServerRuntime>, ToolError> {
1806        self.inner
1807            .servers
1808            .lock()
1809            .unwrap_or_else(|p| p.into_inner())
1810            .get(name)
1811            .cloned()
1812            .ok_or_else(|| ToolError::execution(format!("unknown MCP server `{name}`")))
1813    }
1814
1815    async fn resolve_tool(
1816        &self,
1817        tool_name: &str,
1818    ) -> Result<(Arc<ServerRuntime>, String), ToolError> {
1819        for server in self.servers() {
1820            if let Some(raw) = server.raw_tool_name(tool_name) {
1821                return Ok((server, raw));
1822            }
1823        }
1824        for server in self.servers() {
1825            if server.loaded.config.lazy
1826                && tool_name.starts_with(&format!("{}_", server.sanitized_name))
1827            {
1828                server.connect().await?;
1829                if let Some(raw) = server.raw_tool_name(tool_name) {
1830                    return Ok((server, raw));
1831                }
1832            }
1833        }
1834        Err(ToolError::execution(format!(
1835            "unknown MCP tool `{tool_name}`"
1836        )))
1837    }
1838}
1839
1840impl ServerRuntime {
1841    fn timeout(&self) -> Duration {
1842        Duration::from_millis(self.loaded.config.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
1843    }
1844
1845    fn current_state(&self) -> McpServerState {
1846        self.status
1847            .lock()
1848            .unwrap_or_else(|p| p.into_inner())
1849            .state
1850            .clone()
1851    }
1852
1853    fn set_status(&self, state: McpServerState, message: Option<String>) {
1854        let mut status = self.status.lock().unwrap_or_else(|p| p.into_inner());
1855        status.state = state;
1856        status.message = message;
1857        status.tools = self
1858            .tools
1859            .lock()
1860            .unwrap_or_else(|p| p.into_inner())
1861            .iter()
1862            .map(|tool| tool.spec.name.clone())
1863            .collect();
1864    }
1865
1866    fn set_auth_in_progress(&self, active: bool) {
1867        self.status
1868            .lock()
1869            .unwrap_or_else(|poisoned| poisoned.into_inner())
1870            .auth_in_progress = active;
1871    }
1872
1873    fn fail(&self, message: String) {
1874        let message = cookie_agent_protocol::diagnostics::sanitize(&message, 4096);
1875        if self.superseded.is_cancelled() {
1876            return;
1877        }
1878        if self.current_state() == McpServerState::NeedsAuth {
1879            return;
1880        }
1881        self.clear_claims();
1882        self.tools.lock().unwrap_or_else(|p| p.into_inner()).clear();
1883        self.set_status(McpServerState::Failed, Some(message));
1884    }
1885
1886    fn mark_needs_auth(&self, challenge: Option<String>, reason: &str) {
1887        if self.superseded.is_cancelled() {
1888            return;
1889        }
1890        *self
1891            .auth_challenge
1892            .lock()
1893            .unwrap_or_else(|poisoned| poisoned.into_inner()) = challenge;
1894        self.clear_claims();
1895        self.tools
1896            .lock()
1897            .unwrap_or_else(|poisoned| poisoned.into_inner())
1898            .clear();
1899        let response_error = self
1900            .oauth_response_error
1901            .lock()
1902            .unwrap_or_else(|p| p.into_inner())
1903            .take();
1904        let reason = response_error.as_deref().unwrap_or(reason);
1905        self.set_status(
1906            McpServerState::NeedsAuth,
1907            Some(
1908                cookie_agent_protocol::diagnostics::detail(&format!(
1909                    "OAuth authorization required; authenticate to connect\n{reason}"
1910                ))
1911                .to_string(),
1912            ),
1913        );
1914    }
1915
1916    fn supersede(&self) {
1917        self.superseded.cancel();
1918        self.cancel_tool_refresh_task();
1919        if let Some(flow) = self
1920            .auth_flow
1921            .lock()
1922            .unwrap_or_else(|poisoned| poisoned.into_inner())
1923            .take()
1924        {
1925            flow.cancellation.cancel();
1926        }
1927        self.set_auth_in_progress(false);
1928        self.next_list_generation();
1929        self.clear_claims();
1930    }
1931
1932    fn clear_claims(&self) {
1933        if let Some(registry) = self.registry.upgrade() {
1934            registry
1935                .claimed_names
1936                .lock()
1937                .unwrap_or_else(|p| p.into_inner())
1938                .retain(|_, owner| owner != &self.name);
1939        }
1940    }
1941
1942    fn raw_tool_name(&self, generated: &str) -> Option<String> {
1943        self.tools
1944            .lock()
1945            .unwrap_or_else(|p| p.into_inner())
1946            .iter()
1947            .find(|tool| tool.spec.name == generated)
1948            .map(|tool| tool.raw_name.clone())
1949    }
1950
1951    fn oauth_enabled(&self) -> bool {
1952        oauth_enabled_config(&self.loaded.config)
1953    }
1954
1955    fn oauth_binding(&self) -> Result<OAuthCredentialBinding, ToolError> {
1956        OAuthCredentialBinding::from_config(&self.loaded.config)
1957            .ok_or_else(|| ToolError::execution("OAuth is available only for remote MCP servers"))
1958    }
1959
1960    fn oauth_store(&self) -> Result<ServerCredentialStore, ToolError> {
1961        self.registry
1962            .upgrade()
1963            .map(|registry| {
1964                self.oauth_binding()
1965                    .map(|binding| registry.oauth_credentials.scoped(&self.name, binding))
1966            })
1967            .transpose()?
1968            .ok_or_else(|| ToolError::execution("MCP registry is unavailable"))
1969    }
1970
1971    async fn authorization_manager(
1972        &self,
1973    ) -> Result<(AuthorizationManager, OAuthExchangeState), ToolError> {
1974        let url = self.loaded.config.url.as_ref().ok_or_else(|| {
1975            ToolError::execution("OAuth is available only for remote MCP servers")
1976        })?;
1977        *self
1978            .oauth_response_error
1979            .lock()
1980            .unwrap_or_else(|p| p.into_inner()) = None;
1981        let authorization_code = OAuthExchangeState {
1982            response_error: self.oauth_response_error.clone(),
1983            ..Default::default()
1984        };
1985        // The HTTP client needs the same credential store the manager uses so
1986        // the refresh claim can compare the outgoing refresh token against
1987        // disk (D6-correctness).
1988        let store = self.oauth_store()?;
1989        let http_client = McpOAuthHttpClient::new(authorization_code.clone(), store.clone())?;
1990        let mut manager =
1991            AuthorizationManager::new_with_oauth_http_client(url, Arc::new(http_client))
1992                .await
1993                .map_err(|error| ToolError::execution(authorization_code.diagnostic(&error)))?;
1994        manager.set_credential_store(store);
1995        Ok((manager, authorization_code))
1996    }
1997
1998    async fn begin_auth(self: &Arc<Self>) -> Result<String, ToolError> {
1999        if !self.oauth_enabled() {
2000            return Err(ToolError::execution(format!(
2001                "MCP server `{}` does not use OAuth (stdio, oauth=false, or a static Authorization header takes precedence)",
2002                self.name
2003            )));
2004        }
2005        if self.current_state() != McpServerState::NeedsAuth {
2006            return Err(ToolError::execution(format!(
2007                "MCP server `{}` does not currently require OAuth authorization",
2008                self.name
2009            )));
2010        }
2011        if let Some(flow) = self
2012            .auth_flow
2013            .lock()
2014            .unwrap_or_else(|poisoned| poisoned.into_inner())
2015            .as_ref()
2016        {
2017            return Ok(flow.authorization_url.clone());
2018        }
2019
2020        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
2021            .await
2022            .map_err(|_| ToolError::execution("MCP OAuth callback listener failed"))?;
2023        let port = listener
2024            .local_addr()
2025            .map_err(|_| ToolError::execution("MCP OAuth callback listener failed"))?
2026            .port();
2027        let redirect_uri = format!("http://127.0.0.1:{port}/callback");
2028        let store = self.oauth_store()?;
2029        store
2030            .clear()
2031            .await
2032            .map_err(|_| ToolError::execution("MCP OAuth credential reset failed"))?;
2033        let (mut manager, authorization_code) = self.authorization_manager().await?;
2034        let settings = self.loaded.config.oauth.settings();
2035        let mut request = AuthorizationRequest::new(&redirect_uri)
2036            .with_client_name("Cookie Agent")
2037            .with_application_type("native");
2038        let challenge = self
2039            .auth_challenge
2040            .lock()
2041            .unwrap_or_else(|poisoned| poisoned.into_inner())
2042            .clone();
2043        if let Some(challenge) = &challenge {
2044            request = request.with_challenge(challenge);
2045        }
2046        let metadata = manager
2047            .resolve_metadata_from_challenge(challenge.as_deref())
2048            .await
2049            .map_err(|error| ToolError::execution(authorization_code.diagnostic(&error)))?;
2050        manager.set_metadata(metadata.metadata);
2051        request = apply_oauth_settings(request, &settings);
2052        let session = AuthorizationSession::new(manager, request)
2053            .await
2054            .map_err(|(_, error)| ToolError::execution(authorization_code.diagnostic(&error)))?;
2055        let authorization_url = session.get_authorization_url().to_owned();
2056        let cancellation = CancellationToken::new();
2057        let generation = self.auth_generation.fetch_add(1, Ordering::AcqRel) + 1;
2058        {
2059            let mut flow = self
2060                .auth_flow
2061                .lock()
2062                .unwrap_or_else(|poisoned| poisoned.into_inner());
2063            if let Some(existing) = flow.as_ref() {
2064                return Ok(existing.authorization_url.clone());
2065            }
2066            *flow = Some(OAuthFlowState {
2067                generation,
2068                authorization_url: authorization_url.clone(),
2069                cancellation: cancellation.clone(),
2070            });
2071        }
2072        self.set_auth_in_progress(true);
2073        self.set_status(
2074            McpServerState::NeedsAuth,
2075            Some("waiting for OAuth browser callback".into()),
2076        );
2077
2078        let registry = self
2079            .registry
2080            .upgrade()
2081            .ok_or_else(|| ToolError::execution("MCP registry is unavailable"))?;
2082        let server = Arc::clone(self);
2083        let task_registry = Arc::clone(&registry);
2084        let task = tokio::spawn(async move {
2085            let callback = tokio::select! {
2086                result = tokio::time::timeout(OAUTH_CALLBACK_TIMEOUT, receive_oauth_callback(listener)) => {
2087                    match result {
2088                        Ok(result) => result,
2089                        Err(_) => {
2090                            if server.finish_auth_flow(generation) {
2091                                server.set_status(
2092                                    McpServerState::NeedsAuth,
2093                                    Some("OAuth authorization timed out; authenticate to try again".into()),
2094                                );
2095                            }
2096                            return;
2097                        }
2098                    }
2099                }
2100                () = cancellation.cancelled() => return,
2101                () = task_registry.shutdown.cancelled() => return,
2102                () = server.superseded.cancelled() => return,
2103            };
2104            let Ok((mut browser, callback_url)) = callback else {
2105                if server.finish_auth_flow(generation) {
2106                    server.set_status(
2107                        McpServerState::NeedsAuth,
2108                        Some("invalid OAuth callback; authenticate to try again".into()),
2109                    );
2110                }
2111                return;
2112            };
2113            let auth_guard = tokio::select! {
2114                lock = server.auth_lock.lock() => lock,
2115                () = cancellation.cancelled() => return,
2116                () = task_registry.shutdown.cancelled() => return,
2117                () = server.superseded.cancelled() => return,
2118            };
2119            let exchanged = match parse_oauth_callback(&callback_url) {
2120                Ok(callback) => {
2121                    authorization_code.install(callback.code);
2122                    tokio::select! {
2123                        result = session.handle_callback_with_issuer(
2124                            REDACTED_AUTHORIZATION_CODE,
2125                            &callback.csrf_token,
2126                            callback.issuer.as_deref(),
2127                        ) => result.map_err(|error| authorization_code.diagnostic(&error)),
2128                        () = cancellation.cancelled() => return,
2129                        () = task_registry.shutdown.cancelled() => return,
2130                        () = server.superseded.cancelled() => return,
2131                    }
2132                }
2133                Err(error) => Err(error),
2134            };
2135            drop(auth_guard);
2136            let _ = write_oauth_browser_response(&mut browser, exchanged.is_ok()).await;
2137            if !server.finish_auth_flow(generation) {
2138                return;
2139            }
2140            if let Err(message) = exchanged {
2141                server.set_status(
2142                    McpServerState::NeedsAuth,
2143                    Some(
2144                        cookie_agent_protocol::diagnostics::detail(&format!(
2145                            "OAuth authorization failed; authenticate to try again\n{message}"
2146                        ))
2147                        .to_string(),
2148                    ),
2149                );
2150                return;
2151            }
2152            *server
2153                .auth_challenge
2154                .lock()
2155                .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
2156            server.cancel_tool_refresh();
2157            if let Some(mut service) = server.service.lock().await.take() {
2158                let _ = service.close_with_timeout(Duration::from_secs(4)).await;
2159            }
2160            server.set_status(McpServerState::Disconnected, None);
2161            if let Err(error) = server.connect().await {
2162                server.fail(error.to_string());
2163            }
2164        });
2165        registry
2166            .connection_tasks
2167            .lock()
2168            .unwrap_or_else(|poisoned| poisoned.into_inner())
2169            .push(task);
2170        Ok(authorization_url)
2171    }
2172
2173    fn finish_auth_flow(&self, generation: u64) -> bool {
2174        let mut flow = self
2175            .auth_flow
2176            .lock()
2177            .unwrap_or_else(|poisoned| poisoned.into_inner());
2178        let finished = if flow
2179            .as_ref()
2180            .is_some_and(|flow| flow.generation == generation)
2181        {
2182            *flow = None;
2183            true
2184        } else {
2185            false
2186        };
2187        drop(flow);
2188        if finished {
2189            self.set_auth_in_progress(false);
2190        }
2191        finished
2192    }
2193
2194    async fn connect(self: &Arc<Self>) -> Result<(), ToolError> {
2195        let registry = self
2196            .registry
2197            .upgrade()
2198            .ok_or_else(|| ToolError::execution("MCP registry is unavailable"))?;
2199        if registry.shutdown.is_cancelled() {
2200            return Err(ToolError::execution("MCP registry is shutting down"));
2201        }
2202        if self.superseded.is_cancelled() {
2203            return Err(self.superseded_error());
2204        }
2205        let _active = ActiveConnect::new(Arc::clone(&registry));
2206        let _connect = tokio::select! {
2207            lock = self.connect_lock.lock() => lock,
2208            () = registry.shutdown.cancelled() => {
2209                return Err(ToolError::execution("MCP registry is shutting down"));
2210            }
2211            () = self.superseded.cancelled() => {
2212                return Err(self.superseded_error());
2213            }
2214        };
2215        if registry.shutdown.is_cancelled() {
2216            return Err(ToolError::execution("MCP registry is shutting down"));
2217        }
2218        if self.superseded.is_cancelled() {
2219            return Err(self.superseded_error());
2220        }
2221        match self.current_state() {
2222            McpServerState::Connected => return Ok(()),
2223            McpServerState::Disabled => {
2224                return Err(ToolError::execution(format!(
2225                    "MCP server `{}` is disabled",
2226                    self.name
2227                )));
2228            }
2229            McpServerState::NeedsAuth => {
2230                return Err(ToolError::execution(format!(
2231                    "MCP server `{}` requires OAuth authorization",
2232                    self.name
2233                )));
2234            }
2235            McpServerState::Disconnected | McpServerState::Connecting | McpServerState::Failed => {}
2236        }
2237        self.reset_tool_refresh();
2238        self.set_status(McpServerState::Connecting, None);
2239        let handler = McpClientHandler {
2240            server: Arc::downgrade(self),
2241        };
2242        let lifecycle = ClientLifecycleMode::Auto {
2243            preferred_versions: vec![ProtocolVersion::V_2026_07_28],
2244            legacy_version: Some(ProtocolVersion::V_2025_11_25),
2245        };
2246        let timeout = self.timeout();
2247        let mut service = if let Some(command) = &self.loaded.config.command {
2248            let mut process = tokio::process::Command::new(command);
2249            process
2250                .args(&self.loaded.config.args)
2251                .envs(&self.loaded.config.env);
2252            if let Some(cwd) = &self.loaded.config.cwd {
2253                process.current_dir(cwd);
2254            }
2255            let mut wrapped = CommandWrap::from(process);
2256            #[cfg(unix)]
2257            wrapped.wrap(ProcessGroup::leader());
2258            #[cfg(windows)]
2259            wrapped.wrap(JobObject);
2260            let transport = TokioChildProcess::new(wrapped).map_err(|error| {
2261                ToolError::execution(format!(
2262                    "MCP server `{}` failed to spawn command `{command}`: {error}",
2263                    self.name,
2264                ))
2265            })?;
2266            let cleanup = ChildCleanup::new();
2267            let transport = ReapingChildTransport::new(transport, cleanup.clone());
2268            let outcome = tokio::select! {
2269                result = tokio::time::timeout(timeout, handler.serve_with_lifecycle(transport, lifecycle)) => Some(result),
2270                () = registry.shutdown.cancelled() => None,
2271                () = self.superseded.cancelled() => None,
2272            };
2273            match outcome {
2274                Some(Ok(Ok(service))) => service,
2275                Some(Ok(Err(error))) => {
2276                    cleanup.wait().await;
2277                    return Err(ToolError::execution(format!(
2278                        "MCP server `{}` connect failed: {error}",
2279                        self.name
2280                    )));
2281                }
2282                Some(Err(_)) => {
2283                    cleanup.wait().await;
2284                    return Err(ToolError::execution(format!(
2285                        "MCP server `{}` connect timed out",
2286                        self.name
2287                    )));
2288                }
2289                None => {
2290                    cleanup.wait().await;
2291                    return if self.superseded.is_cancelled() {
2292                        Err(self.superseded_error())
2293                    } else {
2294                        Err(ToolError::execution("MCP registry is shutting down"))
2295                    };
2296                }
2297            }
2298        } else {
2299            let url = self
2300                .loaded
2301                .config
2302                .url
2303                .as_ref()
2304                .expect("validated MCP transport");
2305            let mut headers = HashMap::new();
2306            for (name, value) in &self.loaded.config.headers {
2307                headers.insert(
2308                    http::HeaderName::try_from(name)
2309                        .map_err(|error| ToolError::execution(error.to_string()))?,
2310                    http::HeaderValue::try_from(value)
2311                        .map_err(|error| ToolError::execution(error.to_string()))?,
2312                );
2313            }
2314            let config = rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(url.clone())
2315                .custom_headers(headers);
2316            let result = if self.oauth_enabled() {
2317                let (mut manager, _authorization_code) = self.authorization_manager().await?;
2318                let restored = manager
2319                    .initialize_from_store()
2320                    .await
2321                    .map_err(|_| ToolError::execution("MCP OAuth credential loading failed"))?;
2322                let settings = self.loaded.config.oauth.settings();
2323                if restored && let Some(client_id) = settings.client_id {
2324                    let mut client = OAuthClientConfig::new(client_id, "http://127.0.0.1")
2325                        .with_scopes(settings.scopes);
2326                    if let Some(client_secret) = settings.client_secret {
2327                        client = client.with_client_secret(client_secret);
2328                    }
2329                    manager.configure_client(client).map_err(|_| {
2330                        ToolError::execution("MCP OAuth stored-client setup failed")
2331                    })?;
2332                }
2333                let client = reqwest::Client::builder()
2334                    .pool_max_idle_per_host(0)
2335                    .redirect(reqwest::redirect::Policy::none())
2336                    .build()
2337                    .map_err(|_| ToolError::execution("MCP HTTP client setup failed"))?;
2338                let transport = StreamableHttpClientTransport::with_client(
2339                    AuthClient::new(client, manager),
2340                    config,
2341                );
2342                tokio::select! {
2343                    result = tokio::time::timeout(timeout, handler.serve_with_lifecycle(transport, lifecycle)) => result,
2344                    () = registry.shutdown.cancelled() => {
2345                        return Err(ToolError::execution("MCP registry is shutting down"));
2346                    }
2347                    () = self.superseded.cancelled() => {
2348                        return Err(self.superseded_error());
2349                    }
2350                }
2351            } else {
2352                let transport = StreamableHttpClientTransport::from_config(config);
2353                tokio::select! {
2354                    result = tokio::time::timeout(timeout, handler.serve_with_lifecycle(transport, lifecycle)) => result,
2355                    () = registry.shutdown.cancelled() => {
2356                        return Err(ToolError::execution("MCP registry is shutting down"));
2357                    }
2358                    () = self.superseded.cancelled() => {
2359                        return Err(self.superseded_error());
2360                    }
2361                }
2362            };
2363            match result {
2364                Err(_) => {
2365                    return Err(ToolError::execution(format!(
2366                        "MCP server `{}` connect timed out",
2367                        self.name
2368                    )));
2369                }
2370                Ok(Err(error))
2371                    if self.oauth_enabled() && initialize_error_requires_auth(&error) =>
2372                {
2373                    self.mark_needs_auth(
2374                        error.auth_challenge().map(str::to_owned),
2375                        &cookie_agent_protocol::diagnostics::error_chain(&error),
2376                    );
2377                    return Err(ToolError::execution(format!(
2378                        "MCP server `{}` requires OAuth authorization",
2379                        self.name
2380                    )));
2381                }
2382                Ok(Err(error)) => {
2383                    return Err(ToolError::execution(format!(
2384                        "MCP server `{}` connect failed: {error}",
2385                        self.name
2386                    )));
2387                }
2388                Ok(Ok(service)) => service,
2389            }
2390        };
2391        let generation = self.next_list_generation();
2392        let tools = tokio::select! {
2393            result = tokio::time::timeout(timeout, service.peer().list_all_tools()) => result,
2394            () = registry.shutdown.cancelled() => {
2395                let _ = service.close_with_timeout(Duration::from_secs(4)).await;
2396                return Err(ToolError::execution("MCP registry is shutting down"));
2397            }
2398            () = self.superseded.cancelled() => {
2399                let _ = service.close_with_timeout(Duration::from_secs(4)).await;
2400                return Err(self.superseded_error());
2401            }
2402        }
2403        .map_err(|_| {
2404            ToolError::execution(format!("MCP server `{}` tools/list timed out", self.name))
2405        })?;
2406        let tools = match tools {
2407            Ok(tools) => tools,
2408            Err(error) if self.oauth_enabled() && service_error_requires_auth(&error) => {
2409                self.mark_needs_auth(
2410                    auth_challenge(&error),
2411                    &cookie_agent_protocol::diagnostics::error_chain(&error),
2412                );
2413                let _ = service.close_with_timeout(Duration::from_secs(4)).await;
2414                return Err(ToolError::execution(format!(
2415                    "MCP server `{}` requires OAuth authorization",
2416                    self.name
2417                )));
2418            }
2419            Err(error) => {
2420                return Err(ToolError::execution(format!(
2421                    "MCP server `{}` tools/list failed: {error}",
2422                    self.name
2423                )));
2424            }
2425        };
2426        self.publish_tools(generation, tools);
2427        if self.current_state() == McpServerState::Failed {
2428            return Err(ToolError::execution(
2429                self.status
2430                    .lock()
2431                    .unwrap_or_else(|p| p.into_inner())
2432                    .message
2433                    .clone()
2434                    .unwrap_or_default(),
2435            ));
2436        }
2437        let mut installed = self.service.lock().await;
2438        if registry.shutdown.is_cancelled() || self.superseded.is_cancelled() {
2439            drop(installed);
2440            let _ = service.close_with_timeout(Duration::from_secs(4)).await;
2441            return if self.superseded.is_cancelled() {
2442                Err(self.superseded_error())
2443            } else {
2444                Err(ToolError::execution("MCP registry is shutting down"))
2445            };
2446        }
2447        *installed = Some(service);
2448        drop(installed);
2449        self.set_status(McpServerState::Connected, None);
2450        Ok(())
2451    }
2452
2453    fn next_list_generation(&self) -> u64 {
2454        let _publication = self.publication.lock().unwrap_or_else(|p| p.into_inner());
2455        self.list_generation.fetch_add(1, Ordering::AcqRel) + 1
2456    }
2457
2458    fn schedule_tool_refresh(self: &Arc<Self>, peer: Peer<RoleClient>) {
2459        let Some(registry) = self.registry.upgrade() else {
2460            return;
2461        };
2462        let mut refresh = self
2463            .tool_refresh
2464            .lock()
2465            .unwrap_or_else(|poisoned| poisoned.into_inner());
2466        if refresh.cancellation.is_cancelled() {
2467            return;
2468        }
2469        refresh.peer = Some(peer);
2470        if refresh.worker.is_none() {
2471            let server = Arc::downgrade(self);
2472            let notify = Arc::clone(&refresh.notify);
2473            let cancellation = refresh.cancellation.clone();
2474            let shutdown = registry.shutdown.clone();
2475            let superseded = self.superseded.clone();
2476            #[cfg(test)]
2477            let armed = Arc::clone(&refresh.armed);
2478            refresh.worker = Some(tokio::spawn(async move {
2479                loop {
2480                    tokio::select! {
2481                        () = notify.notified() => {}
2482                        () = cancellation.cancelled() => return,
2483                        () = shutdown.cancelled() => return,
2484                        () = superseded.cancelled() => return,
2485                    }
2486                    loop {
2487                        #[cfg(test)]
2488                        armed.fetch_add(1, Ordering::Release);
2489                        tokio::select! {
2490                            () = tokio::time::sleep(TOOL_LIST_DEBOUNCE) => break,
2491                            () = notify.notified() => {}
2492                            () = cancellation.cancelled() => return,
2493                            () = shutdown.cancelled() => return,
2494                            () = superseded.cancelled() => return,
2495                        }
2496                    }
2497
2498                    let Some(server) = server.upgrade() else {
2499                        return;
2500                    };
2501                    if cancellation.is_cancelled() {
2502                        return;
2503                    }
2504                    let peer = {
2505                        let refresh = server
2506                            .tool_refresh
2507                            .lock()
2508                            .unwrap_or_else(|poisoned| poisoned.into_inner());
2509                        if !Arc::ptr_eq(&refresh.notify, &notify) {
2510                            return;
2511                        }
2512                        refresh.peer.clone()
2513                    };
2514                    if let Some(peer) = peer
2515                        && !peer.is_transport_closed()
2516                    {
2517                        server.refresh_tools(peer, &cancellation, &shutdown).await;
2518                    }
2519                }
2520            }));
2521        }
2522        refresh.notify.notify_one();
2523    }
2524
2525    async fn refresh_tools(
2526        &self,
2527        peer: Peer<RoleClient>,
2528        cancellation: &CancellationToken,
2529        shutdown: &CancellationToken,
2530    ) {
2531        let generation = self.next_list_generation();
2532        let timeout = self.timeout();
2533        let result = tokio::select! {
2534            result = tokio::time::timeout(timeout, peer.list_all_tools()) => result,
2535            () = cancellation.cancelled() => return,
2536            () = shutdown.cancelled() => return,
2537            () = self.superseded.cancelled() => return,
2538        };
2539        if cancellation.is_cancelled() || shutdown.is_cancelled() {
2540            return;
2541        }
2542        match result {
2543            Ok(Ok(tools)) => self.publish_tools(generation, tools),
2544            Ok(Err(error)) if self.oauth_enabled() && service_error_requires_auth(&error) => {
2545                self.mark_needs_auth(
2546                    auth_challenge(&error),
2547                    &cookie_agent_protocol::diagnostics::error_chain(&error),
2548                );
2549            }
2550            Ok(Err(error)) => self
2551                .publish_refresh_failure(generation, format!("tools/list refresh failed: {error}")),
2552            Err(_) => {
2553                self.publish_refresh_failure(generation, "tools/list refresh timed out".into());
2554            }
2555        }
2556    }
2557
2558    fn reset_tool_refresh(&self) {
2559        let mut refresh = self
2560            .tool_refresh
2561            .lock()
2562            .unwrap_or_else(|poisoned| poisoned.into_inner());
2563        refresh.cancel();
2564        *refresh = ToolRefreshDebounce::default();
2565    }
2566
2567    fn cancel_tool_refresh_task(&self) {
2568        let mut refresh = self
2569            .tool_refresh
2570            .lock()
2571            .unwrap_or_else(|poisoned| poisoned.into_inner());
2572        refresh.cancel();
2573    }
2574
2575    fn cancel_tool_refresh(&self) {
2576        self.cancel_tool_refresh_task();
2577        self.next_list_generation();
2578    }
2579
2580    fn superseded_error(&self) -> ToolError {
2581        ToolError::execution(format!("MCP server `{}` was superseded", self.name))
2582    }
2583
2584    fn publish_tools(&self, generation: u64, tools: Vec<Tool>) {
2585        self.publish_tools_before_commit(generation, tools, || {});
2586    }
2587
2588    fn publish_refresh_failure(&self, generation: u64, message: String) {
2589        self.publish_refresh_failure_before_commit(generation, message, || {});
2590    }
2591
2592    fn publish_refresh_failure_before_commit(
2593        &self,
2594        generation: u64,
2595        message: String,
2596        before_commit: impl FnOnce(),
2597    ) {
2598        before_commit();
2599        let _publication = self.publication.lock().unwrap_or_else(|p| p.into_inner());
2600        if self.list_generation.load(Ordering::Acquire) == generation {
2601            self.fail(message);
2602        }
2603    }
2604
2605    fn publish_tools_before_commit(
2606        &self,
2607        generation: u64,
2608        tools: Vec<Tool>,
2609        before_commit: impl FnOnce(),
2610    ) {
2611        let converted = tools
2612            .into_iter()
2613            .map(|tool| convert_tool(&self.name, &self.sanitized_name, tool))
2614            .collect::<Result<Vec<_>, _>>();
2615        before_commit();
2616        let publication = self.publication.lock().unwrap_or_else(|p| p.into_inner());
2617        if self.superseded.is_cancelled()
2618            || self.list_generation.load(Ordering::Acquire) != generation
2619        {
2620            return;
2621        }
2622        let converted = match converted {
2623            Ok(tools) => tools,
2624            Err(error) => {
2625                self.fail(error.to_string());
2626                return;
2627            }
2628        };
2629        let unchanged = {
2630            let current = self
2631                .tools
2632                .lock()
2633                .unwrap_or_else(|poisoned| poisoned.into_inner());
2634            current.len() == converted.len()
2635                && current.iter().zip(&converted).all(|(current, refreshed)| {
2636                    current.raw_name == refreshed.raw_name
2637                        && current.spec.name == refreshed.spec.name
2638                        && current.spec.description == refreshed.spec.description
2639                        && current.spec.parameters == refreshed.spec.parameters
2640                })
2641        };
2642        if unchanged {
2643            return;
2644        }
2645        let Some(registry) = self.registry.upgrade() else {
2646            return;
2647        };
2648        let reserved = registry
2649            .reserved_names
2650            .lock()
2651            .unwrap_or_else(|p| p.into_inner());
2652        let mut claimed = registry
2653            .claimed_names
2654            .lock()
2655            .unwrap_or_else(|p| p.into_inner());
2656        claimed.retain(|_, owner| owner != &self.name);
2657        let mut local = HashSet::new();
2658        let mut preempted_plugins = Vec::new();
2659        for tool in &converted {
2660            let claimed_by_plugin = claimed
2661                .get(&tool.spec.name)
2662                .and_then(|owner| owner.strip_prefix("plugin:"));
2663            if reserved.contains(&tool.spec.name) || !local.insert(tool.spec.name.clone()) {
2664                drop(claimed);
2665                drop(reserved);
2666                self.fail(format!(
2667                    "MCP server `{}` generated colliding tool name `{}`",
2668                    self.name, tool.spec.name
2669                ));
2670                return;
2671            }
2672            if let Some(plugin) = claimed_by_plugin {
2673                preempted_plugins.push((plugin.to_owned(), tool.spec.name.clone()));
2674            } else if claimed.contains_key(&tool.spec.name) {
2675                drop(claimed);
2676                drop(reserved);
2677                self.fail(format!(
2678                    "MCP server `{}` generated colliding tool name `{}`",
2679                    self.name, tool.spec.name
2680                ));
2681                return;
2682            }
2683        }
2684        for tool in &converted {
2685            claimed.insert(tool.spec.name.clone(), self.name.clone());
2686        }
2687        drop(claimed);
2688        drop(reserved);
2689        let collision_handler = registry
2690            .plugin_collision_handler
2691            .lock()
2692            .unwrap_or_else(|poisoned| poisoned.into_inner())
2693            .clone();
2694        if let Some(handler) = collision_handler {
2695            for (plugin, tool) in preempted_plugins {
2696                handler(&plugin, &tool, None);
2697            }
2698        }
2699        *self.tools.lock().unwrap_or_else(|p| p.into_inner()) = converted;
2700        drop(publication);
2701        if self.current_state() == McpServerState::Connected {
2702            self.set_status(McpServerState::Connected, None);
2703        }
2704    }
2705}
2706
2707#[async_trait]
2708impl ToolProvider for McpRegistry {
2709    fn provider_id(&self) -> &'static str {
2710        "mcp"
2711    }
2712
2713    fn tools_for_session(&self, _ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
2714        Ok(self
2715            .servers()
2716            .into_iter()
2717            .flat_map(|server| {
2718                server
2719                    .tools
2720                    .lock()
2721                    .unwrap_or_else(|p| p.into_inner())
2722                    .iter()
2723                    .map(|tool| tool.spec.clone())
2724                    .collect::<Vec<_>>()
2725            })
2726            .collect())
2727    }
2728
2729    fn get_permission_name(_tool_name: &str) -> Result<&'static str, ToolError> {
2730        Ok("mcp")
2731    }
2732
2733    fn permission_for_unlisted_tool(
2734        &self,
2735        tool_name: &str,
2736    ) -> Result<Option<&'static str>, ToolError> {
2737        Ok(self
2738            .servers()
2739            .into_iter()
2740            .any(|server| tool_name.starts_with(&format!("{}_", server.sanitized_name)))
2741            .then_some("mcp"))
2742    }
2743
2744    fn get_permission_resource(
2745        &self,
2746        tool_name: &str,
2747        _arguments: &Value,
2748    ) -> Result<(&'static str, Option<String>), ToolError> {
2749        let owned = self
2750            .servers()
2751            .into_iter()
2752            .any(|server| tool_name.starts_with(&format!("{}_", server.sanitized_name)));
2753        if !owned {
2754            return Err(ToolError::execution(format!(
2755                "unknown MCP tool `{tool_name}`"
2756            )));
2757        }
2758        Ok(("mcp", Some(tool_name.to_owned())))
2759    }
2760
2761    fn get_display_argument(&self, name: &str, _arguments: &Value) -> Result<String, ToolError> {
2762        Ok(name.to_owned())
2763    }
2764
2765    async fn prepare(
2766        &self,
2767        ctx: ToolPreparationContext,
2768        call: ToolCall,
2769    ) -> Result<PreparedTool, ToolError> {
2770        let (server, raw_name) = self.resolve_tool(&call.name).await?;
2771        let arguments = call
2772            .arguments
2773            .as_object()
2774            .cloned()
2775            .ok_or_else(|| ToolError::execution("MCP tool arguments must be an object"))?;
2776        let normalized = Value::Object(arguments.clone());
2777        let argument_bytes = serde_json::to_vec(&normalized)
2778            .map_err(|error| ToolError::execution(error.to_string()))?;
2779        let label = call.name.clone();
2780        let resource = PreparedApprovalResource {
2781            capability: PermissionAction::Mcp,
2782            canonical: PreparedResourceIdentity::new(format!(
2783                "mcp-tool:{}",
2784                Sha256Digest::of_bytes(label.as_bytes())
2785            ))
2786            .map_err(|error| ToolError::execution(error.to_string()))?,
2787            binding_digest: PreparedResourceDigest::from_canonical_binding_bytes(label.as_bytes()),
2788            binding_lifetime: PreparedBindingLifetime::RestartStable,
2789            boundary: ApprovalBoundary::Exact,
2790            source: ApprovalResourceSource::PrimaryOperation,
2791        };
2792        let operation = PreparedOperationIdentity::new(
2793            Sha256Digest::of_bytes(&argument_bytes),
2794            vec![ApprovalCapability {
2795                action: PermissionAction::Mcp,
2796                operation: PreparedCapabilityOperation::new(format!("{}:call", call.name))
2797                    .map_err(|error| ToolError::execution(error.to_string()))?,
2798            }],
2799            vec![resource],
2800            Sha256Digest::of_bytes(ctx.workspace_root.to_string_lossy().as_bytes()),
2801        )
2802        .map_err(|error| ToolError::execution(error.to_string()))?;
2803        PreparedTool::new(
2804            operation,
2805            normalized,
2806            None,
2807            Box::new(McpExecutor {
2808                server,
2809                generated_name: call.name,
2810                raw_name,
2811                arguments,
2812                call_id: call.id,
2813            }),
2814        )
2815    }
2816}
2817
2818struct McpExecutor {
2819    server: Arc<ServerRuntime>,
2820    generated_name: String,
2821    raw_name: String,
2822    arguments: Map<String, Value>,
2823    call_id: ToolCallId,
2824}
2825
2826#[async_trait]
2827impl PreparedExecutor for McpExecutor {
2828    async fn revalidate(&self) -> Result<(), ToolError> {
2829        if self.server.raw_tool_name(&self.generated_name).as_deref() != Some(&self.raw_name) {
2830            return Err(ToolError::operation_changed(
2831                "MCP tool list changed after approval",
2832            ));
2833        }
2834        Ok(())
2835    }
2836
2837    async fn execute(
2838        self: Box<Self>,
2839        context: ToolExecutionContext,
2840    ) -> Result<crate::ToolCompletion, ToolError> {
2841        let result: Result<ToolResult, ToolError> = async move {
2842        self.revalidate().await?;
2843        let params = CallToolRequestParams::new(self.raw_name.clone())
2844            .with_arguments(self.arguments.clone());
2845        let timeout = self.server.timeout();
2846        let service = self.server.service.lock().await;
2847        let Some(service) = service.as_ref() else {
2848            return Err(ToolError::execution("MCP server is not connected"));
2849        };
2850        self.server
2851            .progress
2852            .lock()
2853            .await
2854            .insert(self.call_id, context.progress.clone());
2855        let call = service.call_tool(params);
2856        tokio::pin!(call);
2857        let result = tokio::select! {
2858            result = &mut call => match result {
2859                Err(error)
2860                    if self.server.oauth_enabled() && service_error_requires_auth(&error) =>
2861                {
2862                    self.server.mark_needs_auth(auth_challenge(&error), &cookie_agent_protocol::diagnostics::error_chain(&error));
2863                    Err(ToolError::execution(format!(
2864                        "MCP server `{}` requires OAuth authorization",
2865                        self.server.name
2866                    )))
2867                }
2868                Err(error) => Err(ToolError::execution(error.to_string())),
2869                Ok(result) => Ok(result),
2870            },
2871            _ = context.cancellation.cancelled() => Err(ToolError::execution("MCP tool call cancelled")),
2872            _ = tokio::time::sleep(timeout) => Err(ToolError::execution("MCP tool call timed out")),
2873        };
2874        self.server.progress.lock().await.remove(&self.call_id);
2875        map_tool_result(&context, &self.generated_name, result?)
2876}.await;
2877        result.map(|result| {
2878            let failed = result.metadata["mcp"]["is_error"] == true;
2879            let mut completion = crate::ToolCompletion::single(result);
2880            completion.failed = failed;
2881            completion
2882        })
2883    }
2884}
2885
2886fn convert_tool(server: &str, sanitized_server: &str, tool: Tool) -> Result<CachedTool, ToolError> {
2887    let generated = format!("{}_{}", sanitized_server, sanitize_name(tool.name.as_ref()));
2888    let description = format!(
2889        "[{server}] {} (untrusted MCP output)",
2890        tool.description.as_deref().unwrap_or("MCP tool")
2891    );
2892    let mut schema = tool.input_schema.as_ref().clone();
2893    schema.insert("type".into(), Value::String("object".into()));
2894    schema.insert("additionalProperties".into(), Value::Bool(false));
2895    Ok(CachedTool {
2896        raw_name: tool.name.into_owned(),
2897        spec: ToolSpec {
2898            output: Default::default(),
2899            concurrency: ToolConcurrency::Parallel,
2900            // External opt-out requires a future extension-protocol capability.
2901            result_truncation: Default::default(),
2902            name: generated,
2903            permission_name: "mcp".into(),
2904            description,
2905            parameters: Value::Object(schema),
2906        },
2907    })
2908}
2909
2910fn map_tool_result(
2911    context: &ToolExecutionContext,
2912    tool_name: &str,
2913    result: rmcp::model::CallToolResult,
2914) -> Result<ToolResult, ToolError> {
2915    let mut output = Vec::new();
2916    let mut attachments = Vec::new();
2917    let mut emitted_attachments = Vec::new();
2918    for block in result.content {
2919        match block {
2920            ContentBlock::Text(text) => output.push(text.text),
2921            ContentBlock::Image(image) => {
2922                if !mcp_attachment_budget_remaining(&attachments, &emitted_attachments) {
2923                    output.push(
2924                        "[MCP image attachment omitted: combined attachment limit reached]".into(),
2925                    );
2926                    continue;
2927                }
2928                match retain_base64_attachment(context, image.mime_type, &image.data) {
2929                    Ok(retained) => {
2930                        output.push(
2931                            if route_mcp_attachment(
2932                                retained,
2933                                &mut attachments,
2934                                &mut emitted_attachments,
2935                            ) {
2936                                "[MCP image attachment]".into()
2937                            } else {
2938                                "[MCP image attachment omitted: combined attachment limit reached]"
2939                                    .into()
2940                            },
2941                        );
2942                    }
2943                    Err(McpAttachmentError::Invalid(error)) => {
2944                        output.push(format!("[Invalid MCP image content: {error}]"));
2945                    }
2946                    Err(McpAttachmentError::Gated(error)) => {
2947                        return Err(ToolError::Failed(error));
2948                    }
2949                }
2950            }
2951            ContentBlock::Audio(audio) => {
2952                if !mcp_attachment_budget_remaining(&attachments, &emitted_attachments) {
2953                    output.push(
2954                        "[MCP audio attachment omitted: combined attachment limit reached]".into(),
2955                    );
2956                    continue;
2957                }
2958                match retain_base64_attachment(context, audio.mime_type, &audio.data) {
2959                    Ok(retained) => {
2960                        output.push(
2961                            if route_mcp_attachment(
2962                                retained,
2963                                &mut attachments,
2964                                &mut emitted_attachments,
2965                            ) {
2966                                "[MCP audio attachment]".into()
2967                            } else {
2968                                "[MCP audio attachment omitted: combined attachment limit reached]"
2969                                    .into()
2970                            },
2971                        );
2972                    }
2973                    Err(McpAttachmentError::Invalid(error)) => {
2974                        output.push(format!("[Invalid MCP audio content: {error}]"));
2975                    }
2976                    Err(McpAttachmentError::Gated(error)) => {
2977                        return Err(ToolError::Failed(error));
2978                    }
2979                }
2980            }
2981            ContentBlock::Resource(resource) => match resource.resource {
2982                ResourceContents::TextResourceContents { uri, text, .. } => {
2983                    output.push(format!("[{uri}]\n{text}"));
2984                }
2985                ResourceContents::BlobResourceContents {
2986                    uri,
2987                    mime_type,
2988                    blob,
2989                    ..
2990                } => {
2991                    if !mcp_attachment_budget_remaining(&attachments, &emitted_attachments) {
2992                        output.push(format!(
2993                            "[MCP embedded resource unavailable: {uri}: combined attachment limit reached]"
2994                        ));
2995                        continue;
2996                    }
2997                    match retain_base64_attachment(
2998                        context,
2999                        mime_type.unwrap_or_else(|| "application/octet-stream".into()),
3000                        &blob,
3001                    ) {
3002                        Ok(retained) => {
3003                            let routed = route_mcp_attachment(
3004                                retained,
3005                                &mut attachments,
3006                                &mut emitted_attachments,
3007                            );
3008                            output.push(if routed {
3009                                format!("[MCP embedded resource attachment: {uri}]")
3010                            } else {
3011                                format!(
3012                                    "[MCP embedded resource unavailable: {uri}: combined attachment limit reached]"
3013                                )
3014                            });
3015                        }
3016                        Err(McpAttachmentError::Invalid(error)) => output.push(format!(
3017                            "[MCP embedded resource unavailable: {uri}: {error}]"
3018                        )),
3019                        Err(McpAttachmentError::Gated(error)) => {
3020                            return Err(ToolError::Failed(error));
3021                        }
3022                    }
3023                }
3024                _ => output.push("[Unsupported MCP embedded resource]".into()),
3025            },
3026            ContentBlock::ResourceLink(link) => {
3027                output.push(format!("[MCP resource link: {} ({})]", link.name, link.uri))
3028            }
3029            _ => output.push("[Unsupported MCP content block]".into()),
3030        }
3031    }
3032    let text_output = output.join("\n");
3033    let structured_content = result.structured_content.filter(|structured| {
3034        !output
3035            .iter()
3036            .chain(std::iter::once(&text_output))
3037            .any(|text| serde_json::from_str::<Value>(text).ok().as_ref() == Some(structured))
3038    });
3039    let mut metadata = serde_json::json!({
3040        "mcp": {
3041            "is_error": result.is_error.unwrap_or(false),
3042        }
3043    });
3044    if let Some(structured) = structured_content {
3045        metadata["mcp"]["structured_content"] = structured;
3046    }
3047    let additional_messages = if emitted_attachments.is_empty() {
3048        Vec::new()
3049    } else {
3050        vec![
3051            ToolEmittedMessage::new(
3052                ToolEmittedMessageRole::User,
3053                emitted_attachments
3054                    .into_iter()
3055                    .map(ToolEmittedContent::File)
3056                    .collect(),
3057            )
3058            .map_err(|error| ToolError::execution(error.to_string()))?,
3059        ]
3060    };
3061    Ok(ToolResult {
3062        display: None,
3063        retained_output: None,
3064        title: safe_title(tool_name),
3065        output: text_output,
3066        metadata,
3067        truncation: None,
3068        attachments,
3069        additional_messages,
3070    })
3071}
3072
3073#[derive(Debug)]
3074enum McpAttachmentError {
3075    /// Malformed or undecodable content; reported inline, the call still succeeds.
3076    Invalid(String),
3077    /// Rejected by the media gate; fails the tool result like `read` does.
3078    Gated(String),
3079}
3080
3081#[derive(Debug)]
3082struct RetainedMcpAttachment {
3083    attachment: ToolAttachment,
3084    delivery: crate::media::AttachmentGate,
3085}
3086
3087fn route_mcp_attachment(
3088    retained: RetainedMcpAttachment,
3089    attachments: &mut Vec<ToolAttachment>,
3090    emitted_attachments: &mut Vec<ToolAttachment>,
3091) -> bool {
3092    if !mcp_attachment_budget_remaining(attachments, emitted_attachments) {
3093        return false;
3094    }
3095    match retained.delivery {
3096        crate::media::AttachmentGate::AttachToolResult => attachments.push(retained.attachment),
3097        crate::media::AttachmentGate::DeliverViaUserTurn => {
3098            emitted_attachments.push(retained.attachment);
3099        }
3100        crate::media::AttachmentGate::RejectUnsupportedModel
3101        | crate::media::AttachmentGate::RejectUnsupportedFamily
3102        | crate::media::AttachmentGate::RejectTooLarge { .. } => {
3103            unreachable!("rejected MCP attachment gates return an error")
3104        }
3105    }
3106    true
3107}
3108
3109fn mcp_attachment_budget_remaining(
3110    attachments: &[ToolAttachment],
3111    emitted_attachments: &[ToolAttachment],
3112) -> bool {
3113    attachments.len().saturating_add(emitted_attachments.len()) < ToolResult::MAX_ATTACHMENTS
3114}
3115
3116fn retain_base64_attachment(
3117    context: &ToolExecutionContext,
3118    mime_type: String,
3119    data: &str,
3120) -> Result<RetainedMcpAttachment, McpAttachmentError> {
3121    let bytes = base64::engine::general_purpose::STANDARD
3122        .decode(data)
3123        .map_err(|error| McpAttachmentError::Invalid(error.to_string()))?;
3124    let mut delivery = crate::media::AttachmentGate::AttachToolResult;
3125    let sniffed = crate::media::approved_media_type(std::path::Path::new(""), &bytes)
3126        .map_err(|error| McpAttachmentError::Invalid(error.to_string()))?;
3127    let retained_mime = if mime_type.is_empty() || mime_type == "application/octet-stream" {
3128        sniffed.unwrap_or(mime_type.as_str())
3129    } else {
3130        mime_type.as_str()
3131    };
3132    let attachment = if let Some(mime) = sniffed {
3133        if crate::media::canonical_video_mime_type(mime)
3134            != crate::media::canonical_video_mime_type(retained_mime)
3135        {
3136            return Err(McpAttachmentError::Invalid(format!(
3137                "attachment MIME mismatch: declared {retained_mime}, validated {mime}"
3138            )));
3139        }
3140        let gate = crate::media::gate_attachment(
3141            context.turn_context.adapter_family,
3142            &context.turn_context.capabilities,
3143            mime,
3144            &bytes,
3145        );
3146        if let Some(error) = crate::media::attachment_gate_error(
3147            gate,
3148            mime,
3149            &context.turn_context.model,
3150            context.turn_context.adapter,
3151        ) {
3152            return Err(McpAttachmentError::Gated(error));
3153        }
3154        delivery = gate;
3155        context.retain_validated_attachment(retained_mime, None, &bytes)
3156    } else {
3157        context.retain_attachment(retained_mime, None, &bytes)
3158    }
3159    .map_err(|error| McpAttachmentError::Invalid(error.to_string()))?;
3160    Ok(RetainedMcpAttachment {
3161        attachment,
3162        delivery,
3163    })
3164}
3165
3166fn sanitize_name(value: &str) -> String {
3167    value
3168        .chars()
3169        .map(|character| {
3170            if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
3171                character
3172            } else {
3173                '_'
3174            }
3175        })
3176        .collect()
3177}
3178
3179fn safe_title(value: &str) -> SafeDisplayText {
3180    let mut title = String::new();
3181    for character in value.chars() {
3182        let character = if character.is_control() {
3183            ' '
3184        } else {
3185            character
3186        };
3187        if title.len() + character.len_utf8() > SafeDisplayText::MAX_BYTES {
3188            break;
3189        }
3190        title.push(character);
3191    }
3192    SafeDisplayText::new(title).expect("sanitized MCP title")
3193}
3194
3195#[cfg(test)]
3196mod tests;
3197
3198#[cfg(test)]
3199#[cfg(unix)]
3200#[path = "mcp/oauth_tests.rs"]
3201mod oauth_tests;
3202
3203#[cfg(all(test, windows))]
3204mod windows_oauth_tests {
3205    use super::OAuthCredentialFile;
3206
3207    #[test]
3208    fn oauth_store_is_acl_protected_on_windows() {
3209        let temporary = tempfile::tempdir().expect("temporary root");
3210        let path = temporary.path().join("oauth").join("mcp-oauth.json");
3211        let store = OAuthCredentialFile::open(path.clone()).expect("OAuth store");
3212        store.update(|_| {}).expect("persist empty store");
3213        cookie_agent_models::secure_store::verify_windows_private_creation(&path)
3214            .expect("store ACL");
3215        cookie_agent_models::secure_store::verify_windows_private_creation(
3216            &path.parent().unwrap().join("mcp-oauth.lock"),
3217        )
3218        .expect("lock ACL");
3219    }
3220}