Skip to main content

mcp_multiplexer/
upstream.rs

1use crate::cache::Cache;
2use crate::config::{Config, ServerConfig};
3use crate::model::ToolInfo;
4use crate::oauth::{OAuth, TokenStore};
5use anyhow::{anyhow, bail};
6use rmcp::ServiceExt;
7use rmcp::model::CallToolResult;
8use std::collections::BTreeMap;
9use std::sync::Arc;
10use tokio::sync::{Mutex, OnceCell, RwLock};
11
12struct Entry {
13    cfg: ServerConfig,
14    // RwLock so a dead mid-session client can be taken out; OnceCell keeps single-connect semantics
15    client: RwLock<OnceCell<Arc<rmcp::service::RunningService<rmcp::service::RoleClient, ()>>>>,
16    failed: Mutex<Option<String>>,
17    tools: Mutex<Vec<ToolInfo>>,
18    instructions: Mutex<Option<String>>,
19    oauth: Option<Arc<OAuth>>,
20    token_store: Option<TokenStore>,
21}
22
23pub struct Upstreams {
24    entries: BTreeMap<String, Arc<Entry>>,
25    cache: Mutex<Cache>,
26    config_hash: u64,
27}
28
29const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
30
31async fn connect(
32    e: &Entry,
33) -> anyhow::Result<rmcp::service::RunningService<rmcp::service::RoleClient, ()>> {
34    let cfg = &e.cfg;
35    let fut = async {
36        if let Some(cmd) = &cfg.command {
37            let mut c = tokio::process::Command::new(cmd);
38            c.args(&cfg.args).envs(&cfg.env);
39            let (transport, stderr) = rmcp::transport::TokioChildProcess::builder(c)
40                .stderr(std::process::Stdio::piped())
41                .spawn()?;
42            if let Some(mut err) = stderr {
43                use tokio::io::AsyncBufReadExt;
44                tokio::spawn(async move {
45                    let mut lines = tokio::io::BufReader::new(&mut err).lines();
46                    while let Ok(Some(line)) = lines.next_line().await {
47                        tracing::debug!(target: "upstream-stderr", "{line}");
48                    }
49                });
50            }
51            Ok(().serve(transport).await?)
52        } else if let Some(url) = &cfg.url {
53            let mut config = rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(url.clone());
54            for (k, v) in &cfg.headers {
55                config.custom_headers.insert(
56                    http::HeaderName::from_bytes(k.as_bytes())?,
57                    http::HeaderValue::from_str(v)?,
58                );
59            }
60            if let Some(oauth) = &e.oauth {
61                let store = e.token_store.as_ref().unwrap();
62                match oauth.access_token(cfg, store).await? {
63                    Some(token) => {
64                        config.custom_headers.insert(
65                            http::header::AUTHORIZATION,
66                            http::HeaderValue::from_str(&format!("Bearer {token}"))?,
67                        );
68                    }
69                    None => {
70                        let auth_url = oauth.begin_flow(cfg, store).await?;
71                        bail!(
72                            "server requires OAuth authorization. Open in a browser: {auth_url} — then retry. Headless? Call authorize_server with the final redirect URL as pasted_url."
73                        );
74                    }
75                }
76            }
77            let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
78            Ok(().serve(transport).await?)
79        } else {
80            bail!("server has neither command nor url")
81        }
82    };
83    let timeout =
84        std::time::Duration::from_secs(cfg.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT_SECS));
85    match tokio::time::timeout(timeout, fut).await {
86        Ok(r) => r,
87        Err(_) => Err(anyhow!("connect timed out after {}s", timeout.as_secs())),
88    }
89}
90
91impl Upstreams {
92    pub fn new(cfg: Config, cache: Cache, config_hash: u64) -> Upstreams {
93        let token_lock = Arc::new(Mutex::new(()));
94        let entries = cfg
95            .mcp_servers
96            .iter()
97            .map(|(name, sc)| {
98                let tools = cache.servers.get(name).cloned().unwrap_or_default();
99                let instr = cache.instructions.get(name).cloned();
100                (
101                    name.clone(),
102                    Arc::new(Entry {
103                        cfg: sc.clone(),
104                        client: RwLock::new(OnceCell::new()),
105                        failed: Mutex::new(None),
106                        tools: Mutex::new(tools),
107                        instructions: Mutex::new(instr),
108                        oauth: sc.oauth.then(OAuth::new),
109                        token_store: sc.oauth.then(|| TokenStore::new(name, token_lock.clone())),
110                    }),
111                )
112            })
113            .collect();
114        Upstreams {
115            entries,
116            cache: Mutex::new(cache),
117            config_hash,
118        }
119    }
120
121    pub fn server_names(&self) -> Vec<String> {
122        self.entries.keys().cloned().collect()
123    }
124
125    pub fn status(&self, name: &str) -> &'static str {
126        match self.entries.get(name) {
127            None => "unknown",
128            Some(e)
129                if e.client
130                    .try_read()
131                    .map(|c| c.initialized())
132                    .unwrap_or(false) =>
133            {
134                "connected"
135            }
136            Some(e) => match e.failed.try_lock() {
137                Ok(g) if g.is_some() => "unavailable",
138                _ => "cold",
139            },
140        }
141    }
142
143    pub fn instructions(&self, name: &str) -> Option<String> {
144        self.entries
145            .get(name)?
146            .instructions
147            .try_lock()
148            .ok()?
149            .clone()
150    }
151
152    fn entry(&self, name: &str) -> anyhow::Result<&Arc<Entry>> {
153        self.entries.get(name).ok_or_else(|| {
154            anyhow!(
155                "unknown server {name:?}; available: {}",
156                self.server_names().join(", ")
157            )
158        })
159    }
160
161    async fn ensure(
162        &self,
163        name: &str,
164    ) -> anyhow::Result<Arc<rmcp::service::RunningService<rmcp::service::RoleClient, ()>>> {
165        let e = self.entry(name)?;
166        let cell = e.client.read().await;
167        let r = cell
168            .get_or_try_init(|| async {
169                match connect(e).await {
170                    Ok(c) => {
171                        *e.failed.lock().await = None;
172                        Ok(Arc::new(c))
173                    }
174                    Err(err) => {
175                        *e.failed.lock().await = Some(err.to_string());
176                        Err(err)
177                    }
178                }
179            })
180            .await?
181            .clone();
182        drop(cell);
183        // after first connect, refresh index from live server
184        if e.tools.lock().await.is_empty() {
185            self.refresh_inner(name, &r).await.ok();
186        }
187        Ok(r)
188    }
189
190    pub async fn refresh(&self, name: &str) -> anyhow::Result<()> {
191        let client = self.ensure(name).await?;
192        self.refresh_inner(name, &client).await
193    }
194
195    async fn refresh_inner(
196        &self,
197        name: &str,
198        client: &rmcp::service::RunningService<rmcp::service::RoleClient, ()>,
199    ) -> anyhow::Result<()> {
200        let e = self.entry(name)?;
201        let listed = client.list_all_tools().await?;
202        let mut out = Vec::new();
203        for t in listed {
204            if !e.cfg.is_allowed(&t.name) {
205                continue;
206            }
207            out.push(ToolInfo {
208                name: t.name.to_string(),
209                description: t.description.map(|d| d.to_string()),
210                schema: serde_json::to_value(&t.input_schema)?,
211                annotations: t.annotations.map(serde_json::to_value).transpose()?,
212            });
213        }
214        *e.tools.lock().await = out.clone();
215        if let Some(info) = client.peer_info()
216            && let Some(instr) = info.instructions.clone()
217        {
218            *e.instructions.lock().await = Some(instr.clone());
219            self.cache
220                .lock()
221                .await
222                .instructions
223                .insert(name.to_string(), instr);
224        }
225        self.cache
226            .lock()
227            .await
228            .servers
229            .insert(name.to_string(), out);
230        // persist right away — clients often SIGTERM us, so the shutdown save may never run
231        self.save_cache().await;
232        Ok(())
233    }
234
235    /// In-memory index only, never connects. Empty for cold servers.
236    pub fn cached_tools(&self, name: &str) -> Vec<ToolInfo> {
237        self.entries
238            .get(name)
239            .and_then(|e| e.tools.try_lock().ok().map(|g| g.clone()))
240            .unwrap_or_default()
241    }
242
243    pub async fn tools(&self, name: &str) -> anyhow::Result<Vec<ToolInfo>> {
244        let e = self.entry(name)?;
245        let cached = e.tools.lock().await.clone();
246        if !cached.is_empty() {
247            return Ok(cached);
248        }
249        self.refresh(name).await?;
250        Ok(e.tools.lock().await.clone())
251    }
252
253    pub async fn call(
254        &self,
255        name: &str,
256        tool: &str,
257        args: Option<serde_json::Map<String, serde_json::Value>>,
258    ) -> anyhow::Result<CallToolResult> {
259        let e = self.entry(name)?;
260        if !e.cfg.is_allowed(tool) {
261            bail!("tool {tool:?} on server {name:?} is blocked by config");
262        }
263        let client = self.ensure(name).await?;
264        let mut params = rmcp::model::CallToolRequestParams::new(tool.to_string());
265        if let Some(a) = args {
266            params = params.with_arguments(a);
267        }
268        match client.call_tool(params.clone()).await {
269            Ok(r) => Ok(r),
270            Err(err) => {
271                // ponytail: heuristic — any call error drops the (possibly dead) client, then
272                // reconnects, re-lists and retries exactly once; covers dead upstreams and stale index
273                tracing::debug!(%err, "call failed, reconnecting and retrying once");
274                e.client.write().await.take();
275                let client = self.ensure(name).await?;
276                self.refresh_inner(name, &client).await.ok();
277                Ok(client.call_tool(params).await?)
278            }
279        }
280    }
281
282    /// Begin (or re-report) OAuth authorization for a server.
283    /// Ok(None) = already authorized, Ok(Some) = URL the user must open.
284    pub async fn oauth_begin(&self, name: &str) -> anyhow::Result<Option<String>> {
285        let e = self.entry(name)?;
286        let (Some(oauth), Some(store)) = (&e.oauth, &e.token_store) else {
287            bail!("server {name:?} does not have \"oauth\": true in the config");
288        };
289        match oauth.access_token(&e.cfg, store).await? {
290            Some(_) => Ok(None),
291            None => Ok(Some(oauth.begin_flow(&e.cfg, store).await?)),
292        }
293    }
294
295    /// Complete a headless OAuth flow from the pasted redirect URL.
296    pub async fn oauth_complete(&self, name: &str, pasted: &str) -> anyhow::Result<()> {
297        let e = self.entry(name)?;
298        let Some(oauth) = &e.oauth else {
299            bail!("server {name:?} does not have \"oauth\": true in the config");
300        };
301        oauth.complete_with_url(pasted).await
302    }
303
304    pub async fn save_cache(&self) {
305        if let Err(e) = self.cache.lock().await.save(self.config_hash) {
306            tracing::warn!(%e, "failed to save index cache");
307        }
308    }
309}