1#![cfg_attr(docsrs, feature(doc_auto_cfg))]
5use 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
205pub const VERSION: &str = env!("CARGO_PKG_VERSION");
207
208#[cfg(test)]
209#[path = "lib_tests.rs"]
210mod tests;