Skip to main content

soma_mcp_proxy/
lib.rs

1// Render per-item feature-requirement badges when rustdoc runs on nightly with
2// `--cfg docsrs` (docs.rs posture; locally via `cargo xtask doc --docsrs-cfg`).
3// Inert under the stable CI doc gate: stable rustdoc never sets `docsrs`.
4#![cfg_attr(docsrs, feature(doc_auto_cfg))]
5//! Reusable MCP proxy route projection helpers.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use soma_mcp_client::upstream::{PromptDescriptor, ResourceDescriptor, ToolDescriptor};
10
11mod projection;
12
13pub use projection::{rmcp_prompt_from_route, rmcp_resource_from_route, rmcp_tool_from_route};
14
15const DEFAULT_UPSTREAM_RESOURCE_PREFIX: &str = "mcp-gateway://upstream/";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct McpToolRoute {
19    pub name: String,
20    pub upstream: String,
21    pub native_name: String,
22    pub descriptor: ToolDescriptor,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct McpResourceRoute {
27    pub uri: String,
28    pub upstream: String,
29    pub native_uri: String,
30    pub descriptor: ResourceDescriptor,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct McpPromptRoute {
35    pub name: String,
36    pub upstream: String,
37    pub native_name: String,
38    pub descriptor: PromptDescriptor,
39}
40
41pub fn tool_routes_from_candidates(
42    candidates: Vec<(String, ToolDescriptor)>,
43    reserved_names: impl IntoIterator<Item = impl AsRef<str>>,
44) -> Vec<McpToolRoute> {
45    let counts = name_counts(candidates.iter().map(|(_, descriptor)| &descriptor.name));
46    let reserved = reserved_names
47        .into_iter()
48        .map(|name| name.as_ref().to_owned())
49        .collect::<BTreeSet<_>>();
50    let mut used = BTreeSet::new();
51    candidates
52        .into_iter()
53        .map(|(upstream, descriptor)| {
54            let native_name = descriptor.name.clone();
55            let preferred = if counts.get(native_name.as_str()) == Some(&1)
56                && !reserved.contains(&native_name)
57            {
58                native_name.clone()
59            } else {
60                format!(
61                    "{}__{}",
62                    route_segment(&upstream),
63                    route_segment(&native_name)
64                )
65            };
66            McpToolRoute {
67                name: unique_route_name(preferred, &mut used),
68                upstream,
69                native_name,
70                descriptor,
71            }
72        })
73        .collect()
74}
75
76pub fn resource_route(upstream: &str, descriptor: ResourceDescriptor) -> McpResourceRoute {
77    McpResourceRoute {
78        uri: upstream_resource_uri(upstream, &descriptor.uri),
79        upstream: upstream.to_owned(),
80        native_uri: descriptor.uri.clone(),
81        descriptor,
82    }
83}
84
85pub fn prompt_routes_from_candidates(
86    candidates: Vec<(String, PromptDescriptor)>,
87) -> Vec<McpPromptRoute> {
88    let counts = name_counts(candidates.iter().map(|(_, descriptor)| &descriptor.name));
89    let mut used = BTreeSet::new();
90    candidates
91        .into_iter()
92        .map(|(upstream, descriptor)| {
93            let native_name = descriptor.name.clone();
94            let preferred = if counts.get(native_name.as_str()) == Some(&1) {
95                native_name.clone()
96            } else {
97                format!(
98                    "{}__{}",
99                    route_segment(&upstream),
100                    route_segment(&native_name)
101                )
102            };
103            McpPromptRoute {
104                name: unique_route_name(preferred, &mut used),
105                upstream,
106                native_name,
107                descriptor,
108            }
109        })
110        .collect()
111}
112
113pub fn upstream_resource_uri(upstream: &str, native_uri: &str) -> String {
114    format!(
115        "{DEFAULT_UPSTREAM_RESOURCE_PREFIX}{upstream}/{}",
116        percent_encode(native_uri.as_bytes())
117    )
118}
119
120pub fn parse_upstream_resource_uri(uri: &str) -> Option<(String, String)> {
121    let rest = uri.strip_prefix(DEFAULT_UPSTREAM_RESOURCE_PREFIX)?;
122    let (upstream, encoded) = rest.split_once('/')?;
123    let native = percent_decode(encoded).ok()?;
124    Some((upstream.to_owned(), native))
125}
126
127fn name_counts<'a>(names: impl Iterator<Item = &'a String>) -> BTreeMap<String, usize> {
128    let mut counts = BTreeMap::new();
129    for name in names {
130        *counts.entry(name.clone()).or_insert(0) += 1;
131    }
132    counts
133}
134
135fn route_segment(value: &str) -> String {
136    let routed: String = value
137        .chars()
138        .map(|ch| {
139            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
140                ch
141            } else {
142                '_'
143            }
144        })
145        .collect();
146    if routed.is_empty() {
147        "route".to_owned()
148    } else {
149        routed
150    }
151}
152
153fn unique_route_name(preferred: String, used: &mut BTreeSet<String>) -> String {
154    if used.insert(preferred.clone()) {
155        return preferred;
156    }
157    for index in 2usize.. {
158        let candidate = format!("{preferred}_{index}");
159        if used.insert(candidate.clone()) {
160            return candidate;
161        }
162    }
163    unreachable!("unbounded route suffix loop should always return")
164}
165
166fn percent_decode(value: &str) -> Result<String, ()> {
167    let bytes = value.as_bytes();
168    let mut decoded = Vec::with_capacity(bytes.len());
169    let mut index = 0;
170    while index < bytes.len() {
171        if bytes[index] == b'%' {
172            let hi = bytes.get(index + 1).copied().ok_or(())?;
173            let lo = bytes.get(index + 2).copied().ok_or(())?;
174            decoded.push(from_hex(hi)? << 4 | from_hex(lo)?);
175            index += 3;
176        } else {
177            decoded.push(bytes[index]);
178            index += 1;
179        }
180    }
181    String::from_utf8(decoded).map_err(|_| ())
182}
183
184fn percent_encode(bytes: &[u8]) -> String {
185    let mut encoded = String::new();
186    for byte in bytes {
187        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
188            encoded.push(*byte as char);
189        } else {
190            encoded.push_str(&format!("%{byte:02X}"));
191        }
192    }
193    encoded
194}
195
196fn from_hex(byte: u8) -> Result<u8, ()> {
197    match byte {
198        b'0'..=b'9' => Ok(byte - b'0'),
199        b'a'..=b'f' => Ok(byte - b'a' + 10),
200        b'A'..=b'F' => Ok(byte - b'A' + 10),
201        _ => Err(()),
202    }
203}
204
205/// Crate version from Cargo metadata.
206pub const VERSION: &str = env!("CARGO_PKG_VERSION");
207
208#[cfg(test)]
209#[path = "lib_tests.rs"]
210mod tests;