Skip to main content

soma_mcp_client/upstream/
pool.rs

1use std::collections::BTreeMap;
2use std::sync::{Arc, RwLock};
3
4use serde_json::Value;
5
6use crate::config::UpstreamConfig;
7use crate::process::guard::SpawnGuard;
8use crate::upstream::http_client::{decide_http_transport, transport_kind_for_decision};
9use crate::upstream::{
10    McpRequestOutcome, McpRoundTrip, ResponseCaps, ToolDescriptor, TransportKind, UpstreamError,
11    UpstreamHealth, UpstreamSnapshot,
12};
13
14pub mod connect_stdio;
15pub mod discovery;
16pub mod health;
17mod lifecycle_compat;
18pub mod live;
19pub mod prompts;
20pub mod resources;
21#[cfg(feature = "oauth")]
22pub mod subject;
23pub mod tasks;
24pub mod tools;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PoolOptions {
28    pub response_caps: ResponseCaps,
29    pub discovery_concurrency: usize,
30}
31
32impl Default for PoolOptions {
33    fn default() -> Self {
34        Self {
35            response_caps: ResponseCaps::default(),
36            discovery_concurrency: 8,
37        }
38    }
39}
40
41impl PoolOptions {
42    #[must_use]
43    pub fn normalized(mut self) -> Self {
44        self.discovery_concurrency = self.discovery_concurrency.max(1);
45        self
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ToolCall {
51    pub upstream: String,
52    pub tool: String,
53    pub params: Value,
54}
55
56#[derive(Debug, Clone)]
57pub struct InProcessUpstream {
58    snapshot: UpstreamSnapshot,
59    tool_results: BTreeMap<String, Value>,
60}
61
62impl InProcessUpstream {
63    #[must_use]
64    pub fn new(name: impl Into<String>) -> Self {
65        Self {
66            snapshot: UpstreamSnapshot::empty(name, TransportKind::InProcess),
67            tool_results: BTreeMap::new(),
68        }
69    }
70
71    #[must_use]
72    pub fn with_tool(mut self, tool: ToolDescriptor, result: Value) -> Self {
73        self.tool_results.insert(tool.name.clone(), result);
74        self.snapshot.tools.push(tool);
75        self
76    }
77
78    #[must_use]
79    pub fn with_snapshot(mut self, snapshot: UpstreamSnapshot) -> Self {
80        self.snapshot = snapshot;
81        self
82    }
83
84    fn call_tool(&self, call: &ToolCall) -> Result<Value, UpstreamError> {
85        if !call.params.is_object() {
86            return Err(UpstreamError::ParamsMustBeObject);
87        }
88        self.tool_results
89            .get(&call.tool)
90            .cloned()
91            .ok_or_else(|| UpstreamError::NotExposed {
92                upstream: call.upstream.clone(),
93                item: call.tool.clone(),
94            })
95    }
96}
97
98struct PoolEntry {
99    config: UpstreamConfig,
100    snapshot: UpstreamSnapshot,
101    in_process: Option<InProcessUpstream>,
102    live: Option<Arc<live::LiveUpstream>>,
103}
104
105#[cfg(feature = "oauth")]
106struct SubjectPoolEntry {
107    snapshot: UpstreamSnapshot,
108    live: Arc<live::LiveUpstream>,
109}
110
111#[derive(Clone)]
112pub struct UpstreamPool {
113    entries: Arc<RwLock<BTreeMap<String, PoolEntry>>>,
114    #[cfg(feature = "oauth")]
115    subject_entries: Arc<RwLock<BTreeMap<(String, String), SubjectPoolEntry>>>,
116    #[cfg(feature = "oauth")]
117    oauth_provider: Arc<RwLock<Option<Arc<dyn crate::oauth::UpstreamOAuthProvider>>>>,
118    options: PoolOptions,
119}
120
121impl Default for UpstreamPool {
122    fn default() -> Self {
123        Self::new(PoolOptions::default())
124    }
125}
126
127impl UpstreamPool {
128    #[must_use]
129    pub fn new(options: PoolOptions) -> Self {
130        Self {
131            entries: Arc::new(RwLock::new(BTreeMap::new())),
132            #[cfg(feature = "oauth")]
133            subject_entries: Arc::new(RwLock::new(BTreeMap::new())),
134            #[cfg(feature = "oauth")]
135            oauth_provider: Arc::new(RwLock::new(None)),
136            options: options.normalized(),
137        }
138    }
139
140    #[must_use]
141    pub fn response_caps(&self) -> &ResponseCaps {
142        &self.options.response_caps
143    }
144
145    #[must_use]
146    pub fn discovery_concurrency(&self) -> usize {
147        self.options.discovery_concurrency
148    }
149
150    pub fn register_config(&self, config: UpstreamConfig) -> Result<(), UpstreamError> {
151        let transport = transport_for_config(&config);
152        let health = if config.enabled {
153            health_for_config(config.name.as_str(), transport)
154        } else {
155            UpstreamHealth::Disabled
156        };
157        let mut snapshot = UpstreamSnapshot::empty(config.name.clone(), transport);
158        snapshot.health = health;
159        let entry = PoolEntry {
160            config: config.clone(),
161            snapshot,
162            in_process: None,
163            live: None,
164        };
165        self.entries
166            .write()
167            .expect("upstream pool lock poisoned")
168            .insert(config.name.clone(), entry);
169        Ok(())
170    }
171
172    pub fn register_in_process(
173        &self,
174        config: UpstreamConfig,
175        upstream: InProcessUpstream,
176    ) -> Result<(), UpstreamError> {
177        let mut snapshot = upstream.snapshot.clone();
178        snapshot.name = config.name.clone();
179        snapshot.transport = TransportKind::InProcess;
180        snapshot.health = if config.enabled {
181            UpstreamHealth::Connected
182        } else {
183            UpstreamHealth::Disabled
184        };
185        let entry = PoolEntry {
186            config: config.clone(),
187            snapshot,
188            in_process: Some(upstream),
189            live: None,
190        };
191        self.entries
192            .write()
193            .expect("upstream pool lock poisoned")
194            .insert(config.name.clone(), entry);
195        Ok(())
196    }
197
198    pub async fn call_tool(&self, call: ToolCall) -> Result<Value, UpstreamError> {
199        self.ensure_connected(&call.upstream).await?;
200        let live_peer = {
201            let entries = self.entries.read().expect("upstream pool lock poisoned");
202            let entry =
203                entries
204                    .get(&call.upstream)
205                    .ok_or_else(|| UpstreamError::UnknownUpstream {
206                        upstream: call.upstream.clone(),
207                    })?;
208            ensure_routable(entry)?;
209            tools::ensure_tool_exposed(entry, &call.tool)?;
210            if let Some(in_process) = &entry.in_process {
211                let result = in_process.call_tool(&call)?;
212                let bytes = serde_json::to_vec(&result).map_or(usize::MAX, |bytes| bytes.len());
213                self.response_caps()
214                    .enforce(crate::upstream::CapScope::ToolsCall, bytes)?;
215                return Ok(result);
216            }
217            entry.live.as_ref().map(|live| live.peer())
218        };
219        let Some(peer) = live_peer else {
220            return Err(UpstreamError::Unsupported {
221                upstream: call.upstream,
222                capability: "tools/call",
223            });
224        };
225        let upstream = call.upstream.clone();
226        let result = live::call_live_tool(&upstream, peer, call.tool, call.params).await?;
227        let bytes = serde_json::to_vec(&result).map_or(usize::MAX, |bytes| bytes.len());
228        self.response_caps()
229            .enforce(crate::upstream::CapScope::ToolsCall, bytes)?;
230        Ok(result)
231    }
232
233    pub async fn call_tool_once(
234        &self,
235        call: ToolCall,
236        round_trip: McpRoundTrip,
237    ) -> Result<McpRequestOutcome, UpstreamError> {
238        self.ensure_connected(&call.upstream).await?;
239        let (live_peer, config) = {
240            let entries = self.entries.read().expect("upstream pool lock poisoned");
241            let entry =
242                entries
243                    .get(&call.upstream)
244                    .ok_or_else(|| UpstreamError::UnknownUpstream {
245                        upstream: call.upstream.clone(),
246                    })?;
247            ensure_routable(entry)?;
248            tools::ensure_tool_exposed(entry, &call.tool)?;
249            if let Some(in_process) = &entry.in_process {
250                let value = in_process.call_tool(&call)?;
251                let result = rmcp::model::CallToolResult::structured(value);
252                let value =
253                    serde_json::to_value(result).map_err(|error| UpstreamError::LiveCall {
254                        upstream: call.upstream.clone(),
255                        operation: "tools/call",
256                        message: error.to_string(),
257                    })?;
258                let outcome = McpRequestOutcome::Complete(value);
259                let bytes =
260                    serde_json::to_vec(outcome.payload()).map_or(usize::MAX, |bytes| bytes.len());
261                self.response_caps()
262                    .enforce(crate::upstream::CapScope::ToolsCall, bytes)?;
263                return Ok(outcome);
264            }
265            (
266                entry.live.as_ref().map(|live| live.peer()),
267                entry.config.clone(),
268            )
269        };
270        let upstream = call.upstream.clone();
271        let outcome = if round_trip.request_meta.is_some() {
272            live::call_live_tool_once_scoped(
273                &config,
274                live::LiveConnectContext::shared(self.response_caps()),
275                call.tool,
276                call.params,
277                round_trip,
278            )
279            .await?
280        } else {
281            let Some(peer) = live_peer else {
282                return Err(UpstreamError::Unsupported {
283                    upstream: call.upstream,
284                    capability: "tools/call",
285                });
286            };
287            live::call_live_tool_once(&upstream, peer, call.tool, call.params, round_trip).await?
288        };
289        let bytes = serde_json::to_vec(outcome.payload()).map_or(usize::MAX, |bytes| bytes.len());
290        self.response_caps()
291            .enforce(crate::upstream::CapScope::ToolsCall, bytes)?;
292        Ok(outcome)
293    }
294
295    pub async fn ensure_connected(&self, upstream: &str) -> Result<(), UpstreamError> {
296        let config = {
297            let entries = self.entries.read().expect("upstream pool lock poisoned");
298            let entry = entries
299                .get(upstream)
300                .ok_or_else(|| UpstreamError::UnknownUpstream {
301                    upstream: upstream.to_owned(),
302                })?;
303            if entry.in_process.is_some() || entry.live.is_some() || !entry.config.enabled {
304                return Ok(());
305            }
306            entry.config.clone()
307        };
308        let context = live::LiveConnectContext::shared(self.response_caps());
309        let (live, snapshot) = live::connect_live(&config, &SpawnGuard::default(), context).await?;
310        let mut entries = self.entries.write().expect("upstream pool lock poisoned");
311        let entry = entries
312            .get_mut(upstream)
313            .ok_or_else(|| UpstreamError::UnknownUpstream {
314                upstream: upstream.to_owned(),
315            })?;
316        entry.snapshot = snapshot;
317        entry.live = Some(Arc::new(live));
318        Ok(())
319    }
320
321    pub async fn refresh_all(&self) {
322        let names = self
323            .entries
324            .read()
325            .expect("upstream pool lock poisoned")
326            .keys()
327            .cloned()
328            .collect::<Vec<_>>();
329        for name in names {
330            if let Err(error) = self.ensure_connected(&name).await {
331                let _ = self.record_discovery_error(&name, error);
332            }
333        }
334    }
335
336    pub(super) fn record_discovery_error(
337        &self,
338        upstream: &str,
339        error: UpstreamError,
340    ) -> Result<(), UpstreamError> {
341        let mut entries = self.entries.write().expect("upstream pool lock poisoned");
342        let entry = entries
343            .get_mut(upstream)
344            .ok_or_else(|| UpstreamError::UnknownUpstream {
345                upstream: upstream.to_owned(),
346            })?;
347        match error {
348            UpstreamError::Unsupported {
349                upstream,
350                capability,
351            } => {
352                entry.snapshot.health = UpstreamHealth::Unsupported {
353                    reason: format!("upstream `{upstream}` does not support `{capability}`"),
354                };
355            }
356            other => {
357                entry.snapshot.health = UpstreamHealth::Degraded {
358                    consecutive_failures: 1,
359                    error: Some(other.to_string()),
360                };
361                entry.snapshot.stale = true;
362            }
363        }
364        Ok(())
365    }
366
367    fn snapshots(&self) -> Vec<UpstreamSnapshot> {
368        self.entries
369            .read()
370            .expect("upstream pool lock poisoned")
371            .values()
372            .map(|entry| entry.snapshot.clone())
373            .collect()
374    }
375
376    fn with_entry<T>(
377        &self,
378        upstream: &str,
379        f: impl FnOnce(&PoolEntry) -> Result<T, UpstreamError>,
380    ) -> Result<T, UpstreamError> {
381        let entries = self.entries.read().expect("upstream pool lock poisoned");
382        let entry = entries
383            .get(upstream)
384            .ok_or_else(|| UpstreamError::UnknownUpstream {
385                upstream: upstream.to_owned(),
386            })?;
387        f(entry)
388    }
389}
390
391fn ensure_routable(entry: &PoolEntry) -> Result<(), UpstreamError> {
392    if entry.snapshot.health.is_routable() {
393        return Ok(());
394    }
395    Err(UpstreamError::NotRoutable {
396        upstream: entry.snapshot.name.clone(),
397        reason: health_reason(&entry.snapshot.health),
398    })
399}
400
401fn health_reason(health: &UpstreamHealth) -> String {
402    match health {
403        UpstreamHealth::Connected => "connected".to_owned(),
404        UpstreamHealth::Disabled => "disabled".to_owned(),
405        UpstreamHealth::Degraded { error, .. } => error
406            .clone()
407            .unwrap_or_else(|| "capability degraded".to_owned()),
408        UpstreamHealth::Unsupported { reason } => reason.clone(),
409    }
410}
411
412fn transport_for_config(config: &UpstreamConfig) -> TransportKind {
413    if let Some(url) = config.url.as_deref() {
414        return transport_kind_for_decision(&decide_http_transport(url));
415    }
416    if config.command.is_some() {
417        return TransportKind::Stdio;
418    }
419    TransportKind::InProcess
420}
421
422fn health_for_config(name: &str, transport: TransportKind) -> UpstreamHealth {
423    match transport {
424        TransportKind::InProcess => UpstreamHealth::Unsupported {
425            reason: format!("configured upstream `{name}` has no live in-process connector"),
426        },
427        TransportKind::HttpJson | TransportKind::HttpSse | TransportKind::WebSocket => {
428            UpstreamHealth::Unsupported {
429                reason: format!("live upstream `{name}` is not connected yet"),
430            }
431        }
432        TransportKind::Stdio => UpstreamHealth::Unsupported {
433            reason: format!("live stdio upstream `{name}` is not connected yet"),
434        },
435    }
436}
437
438#[cfg(test)]
439#[path = "pool_tests.rs"]
440mod tests;