use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use super::{json_escape, json_rpc, McpContent, McpServer, ToolAnnotations};
use crate::http_client::Client;
const PROTOCOL_VERSION: &str = "2025-11-25";
const MODERN_VERSION: &str = "2026-07-28";
pub const SEPARATOR: &str = "__";
const MAX_LIST_PAGES: usize = 100;
#[derive(Clone)]
pub struct Upstream {
prefix: String,
url: String,
headers: Vec<(String, String)>,
caller_headers: Vec<(String, CallerHeader)>,
timeout_ms: u64,
}
type CallerHeader = Arc<dyn Fn(Option<&str>) -> Option<String> + Send + Sync>;
impl std::fmt::Debug for Upstream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Upstream")
.field("prefix", &self.prefix)
.field("url", &self.url)
.field("headers", &self.headers.iter().map(|(k, _)| k).collect::<Vec<_>>())
.field("caller_headers", &self.caller_headers.iter().map(|(k, _)| k).collect::<Vec<_>>())
.field("timeout_ms", &self.timeout_ms)
.finish()
}
}
impl Upstream {
pub fn new(prefix: impl Into<String>, url: impl Into<String>) -> Self {
Upstream { prefix: prefix.into(), url: url.into(), headers: Vec::new(), caller_headers: Vec::new(), timeout_ms: 30_000 }
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn header_for<F>(mut self, name: impl Into<String>, value: F) -> Self
where
F: Fn(Option<&str>) -> Option<String> + Send + Sync + 'static,
{
self.caller_headers.push((name.into(), Arc::new(value)));
self
}
pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = timeout_ms;
self
}
}
pub struct McpGateway {
name: String,
version: String,
upstreams: Vec<Upstream>,
rules: Vec<(String, Vec<String>)>,
refresh: Option<std::time::Duration>,
limits: Vec<ToolLimit>,
audit: bool,
}
struct ToolLimit {
pattern: String,
max_calls: u32,
window_secs: u64,
limiter: crate::rate_limit::RateLimiter,
}
pub const DEFAULT_REFRESH: std::time::Duration = std::time::Duration::from_secs(300);
impl McpGateway {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
McpGateway { name: name.into(), version: version.into(), upstreams: Vec::new(), rules: Vec::new(), refresh: Some(DEFAULT_REFRESH), limits: Vec::new(), audit: false }
}
pub fn limit(mut self, tools: impl Into<String>, max_calls: u32, window_secs: u64) -> Self {
self.limits.push(ToolLimit { pattern: tools.into(), max_calls, window_secs, limiter: crate::rate_limit::RateLimiter::new(max_calls, window_secs) });
self
}
pub fn audit_log(mut self, on: bool) -> Self {
self.audit = on;
self
}
pub fn refresh_every(mut self, every: Option<std::time::Duration>) -> Self {
self.refresh = every;
self
}
pub fn upstream(mut self, upstream: Upstream) -> Self {
self.upstreams.push(upstream);
self
}
pub fn allow<I, S>(mut self, identity: impl Into<String>, tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.rules.push((identity.into(), tools.into_iter().map(Into::into).collect()));
self
}
pub fn build(self) -> Result<McpServer, String> {
let mut prefixes: Vec<&str> = Vec::new();
for upstream in &self.upstreams {
if !valid_prefix(&upstream.prefix) {
return Err(format!("upstream prefix \"{}\": use letters, digits, '-' and single '_'", upstream.prefix));
}
if prefixes.contains(&upstream.prefix.as_str()) {
return Err(format!("upstream prefix \"{}\" is used twice", upstream.prefix));
}
prefixes.push(&upstream.prefix);
}
let rules = Arc::new(self.rules);
let tool_rules = Arc::clone(&rules);
let mut server = McpServer::new(self.name, self.version).tool_filter(move |ctx, tool| {
let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
identity.is_some_and(|identity| allowed(&tool_rules, &identity, tool))
});
if self.audit {
server = server.after_tool_call(|ctx, call| {
let sub = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub")).unwrap_or_default();
let upstream = call.tool.split_once(SEPARATOR).map_or("", |(prefix, _)| prefix);
let outcome = match call.outcome {
crate::mcp::ToolCallOutcome::Ok => "ok",
crate::mcp::ToolCallOutcome::ToolError => "tool_error",
crate::mcp::ToolCallOutcome::Refused => "refused",
crate::mcp::ToolCallOutcome::Denied => "denied",
};
let line = audit_line(&sub, call.tool, upstream, outcome, call.duration);
crate::log::access_sink::write_line(|out| out.extend_from_slice(line.as_bytes()));
});
}
if !self.limits.is_empty() {
let limits = self.limits;
server = server.before_tool_call(move |ctx, tool| {
let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub")).unwrap_or_default();
for limit in limits.iter().filter(|l| matches_pattern(&l.pattern, tool)) {
let key = format!("{identity}\u{0}{tool}");
if !limit.limiter.check(&key) {
return Err(format!("Rate limited: {tool} allows {} calls per {} s; retry in {} s", limit.max_calls, limit.window_secs, limit.limiter.retry_after(&key).max(1)));
}
}
Ok(())
});
}
let resource_owner: Arc<Mutex<std::collections::HashMap<String, String>>> = Arc::new(Mutex::new(std::collections::HashMap::new()));
{
let rules = Arc::clone(&rules);
let owners = Arc::clone(&resource_owner);
server = server.resource_filter(move |ctx, uri| {
let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
let owner = owners.lock().ok().and_then(|o| o.get(uri).cloned());
match (identity, owner) {
(Some(identity), Some(prefix)) => allowed(&rules, &identity, &format!("{prefix}{SEPARATOR}{uri}")),
(_, None) => true, (None, Some(_)) => false,
}
});
}
{
let rules = Arc::clone(&rules);
server = server.prompt_filter(move |ctx, name| {
let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
identity.is_some_and(|identity| allowed(&rules, &identity, name))
});
}
let mut federated = Vec::new();
for upstream in self.upstreams {
let connection = Arc::new(Connection::new(upstream));
let mut known = std::collections::HashMap::new();
for tool in connection.list_tools()? {
known.insert(tool.name.clone(), tool.fingerprint());
server = register(server, &connection, tool);
}
let listed = |method: &str, field: &str| {
connection.list_all(method, field).unwrap_or_else(|e| {
eprintln!("mcp gateway: not federating {field}: {e}");
Vec::new()
})
};
for prompt in listed("prompts/list", "prompts") {
server = register_prompt(server, &connection, &prompt);
}
for (method, field, key) in [("resources/list", "resources", "uri"), ("resources/templates/list", "resourceTemplates", "uriTemplate")] {
for resource in listed(method, field) {
let Some(uri) = top_level_str(&resource, key) else { continue };
{
let mut owners = resource_owner.lock().map_err(|_| "resource lock poisoned".to_string())?;
if owners.contains_key(&uri) {
eprintln!("mcp gateway: {} also publishes {uri}; keeping the first", connection.name());
continue;
}
owners.insert(uri.clone(), connection.upstream.prefix.clone());
}
server = register_resource(server, &connection, &uri, &resource);
}
}
federated.push((connection, known));
}
if let Some(every) = self.refresh {
let live = server.clone();
let spawned = std::thread::Builder::new().name("rws-mcp-gateway-refresh".to_string()).spawn(move || loop {
std::thread::sleep(every);
for (connection, known) in federated.iter_mut() {
refresh(&live, connection, known);
}
});
if let Err(e) = spawned {
eprintln!("mcp gateway: tool list refresh not started: {e}");
}
}
Ok(server)
}
}
fn refresh(server: &McpServer, connection: &Arc<Connection>, known: &mut std::collections::HashMap<String, String>) {
let tools = match connection.list_tools() {
Ok(tools) => tools,
Err(e) => {
eprintln!("mcp gateway: keeping {}'s tools: {e}", connection.name());
return;
}
};
let prefix = &connection.upstream.prefix;
let mut changed = false;
let mut seen = std::collections::HashSet::new();
for tool in tools {
seen.insert(tool.name.clone());
let fingerprint = tool.fingerprint();
if known.get(&tool.name) == Some(&fingerprint) {
continue;
}
let exposed = format!("{prefix}{SEPARATOR}{}", tool.name);
if known.contains_key(&tool.name) {
server.remove_tool(&exposed);
}
known.insert(tool.name.clone(), fingerprint);
let _ = register(server.clone(), connection, tool);
changed = true;
}
let gone: Vec<String> = known.keys().filter(|name| !seen.contains(*name)).cloned().collect();
for name in gone {
known.remove(&name);
server.remove_tool(&format!("{prefix}{SEPARATOR}{name}"));
changed = true;
}
if changed {
server.notify("notifications/tools/list_changed", None);
}
}
#[derive(Clone)]
struct UpstreamTool {
name: String,
description: String,
title: Option<String>,
input_schema: String,
output_schema: Option<String>,
annotations: Option<ToolAnnotations>,
}
impl UpstreamTool {
fn fingerprint(&self) -> String {
format!("{}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}\u{0}{:?}", self.name, self.description, self.title, self.input_schema, self.output_schema, self.annotations)
}
}
fn register(server: McpServer, connection: &Arc<Connection>, tool: UpstreamTool) -> McpServer {
let exposed = format!("{}{SEPARATOR}{}", connection.upstream.prefix, tool.name);
let forward = Arc::clone(connection);
let upstream_name = tool.name.clone();
let mut server = server.tool_with_context(&exposed, &tool.description, &tool.input_schema, move |ctx, args| {
forward.call_tool(&upstream_name, args, ctx.auth_claims.as_deref()).map(McpContent::raw_result)
});
if let Some(title) = tool.title {
server = server.tool_title(&exposed, title);
}
if let Some(schema) = tool.output_schema {
server = server.tool_output_schema(&exposed, &schema);
}
if let Some(annotations) = tool.annotations {
server = server.tool_annotations(&exposed, annotations);
}
server
}
fn register_prompt(server: McpServer, connection: &Arc<Connection>, prompt: &str) -> McpServer {
let Some(name) = top_level_str(prompt, "name") else { return server };
let exposed = format!("{}{SEPARATOR}{name}", connection.upstream.prefix);
let description = top_level_str(prompt, "description").unwrap_or_default();
let args = top_level_raw(prompt, "arguments")
.map(|list| {
json_rpc::split_array_elements(&list)
.into_iter()
.filter_map(|arg| {
let name = top_level_str(&arg, "name")?;
let description = top_level_str(&arg, "description").unwrap_or_default();
let required = top_level_raw(&arg, "required").as_deref() == Some("true");
Some(if required { super::PromptArgDef::required(name, description) } else { super::PromptArgDef::optional(name, description) })
})
.collect()
})
.unwrap_or_default();
let forward = Arc::clone(connection);
server.prompt_with_args(&exposed, &description, args, move |arguments| {
let params = format!(r#"{{"name":"{}","arguments":{}}}"#, json_escape(&name), if arguments.trim().is_empty() { "{}" } else { arguments });
let result = forward.request("prompts/get", ¶ms).map_err(|e| format!("{}: {e}", forward.upstream.prefix))?;
let messages = top_level_raw(&result, "messages").ok_or_else(|| format!("{}: prompts/get without messages", forward.upstream.prefix))?;
Ok(json_rpc::split_array_elements(&messages)
.into_iter()
.filter_map(|message| {
let role = if top_level_str(&message, "role").as_deref() == Some("assistant") { "assistant" } else { "user" };
let content = top_level_raw(&message, "content").filter(|c| c.starts_with('{'))?;
Some(super::PromptMessage { role, content: McpContent::raw_block(content) })
})
.collect())
})
}
fn register_resource(server: McpServer, connection: &Arc<Connection>, uri: &str, resource: &str) -> McpServer {
let name = top_level_str(resource, "name").unwrap_or_else(|| uri.to_string());
let description = top_level_str(resource, "description").unwrap_or_default();
let forward = Arc::clone(connection);
server.resource(uri, &name, &description, move |requested| {
let params = format!(r#"{{"uri":"{}"}}"#, json_escape(requested));
forward.request("resources/read", ¶ms).map(McpContent::raw_result).map_err(|e| format!("{}: {e}", forward.upstream.prefix))
})
}
fn valid_prefix(prefix: &str) -> bool {
!prefix.is_empty()
&& !prefix.contains(SEPARATOR)
&& !prefix.ends_with('_')
&& prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn allowed(rules: &[(String, Vec<String>)], identity: &str, tool: &str) -> bool {
rules
.iter()
.filter(|(who, _)| who == identity || who == "*")
.flat_map(|(_, patterns)| patterns)
.any(|pattern| matches_pattern(pattern, tool))
}
fn audit_line(sub: &str, tool: &str, upstream: &str, outcome: &str, duration: std::time::Duration) -> String {
let mut time = String::new();
crate::log::iso8601_now(|iso| time.push_str(iso));
format!(
r#"{{"time":"{time}","event":"mcp_tool_call","sub":"{}","tool":"{}","upstream":"{}","outcome":"{outcome}","duration_ms":{:.3}}}"#,
json_escape(sub),
json_escape(tool),
json_escape(upstream),
duration.as_secs_f64() * 1000.0
)
}
fn matches_pattern(pattern: &str, tool: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => tool.starts_with(prefix),
None => pattern == tool,
}
}
fn top_level_str(json: &str, key: &str) -> Option<String> {
let raw = top_level_raw(json, key)?;
raw.starts_with('"').then(|| json_rpc::extract_str(&format!(r#"{{"v":{raw}}}"#), "v")).flatten()
}
fn top_level_raw(json: &str, key: &str) -> Option<String> {
json_rpc::object_members(json)?.into_iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
struct Connection {
upstream: Upstream,
client: Client,
session: Mutex<Option<Session>>,
next_id: AtomicU64,
}
#[derive(Clone)]
struct Session {
id: Option<String>,
version: String,
modern: bool,
}
impl Connection {
fn new(upstream: Upstream) -> Self {
let client = Client::new().timeout_ms(upstream.timeout_ms).max_redirects(0);
Connection { upstream, client, session: Mutex::new(None), next_id: AtomicU64::new(1) }
}
fn name(&self) -> String {
format!("upstream {} ({})", self.upstream.prefix, self.upstream.url)
}
fn list_tools(&self) -> Result<Vec<UpstreamTool>, String> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..MAX_LIST_PAGES {
let params = match &cursor {
Some(cursor) => format!(r#"{{"cursor":"{}"}}"#, json_escape(cursor)),
None => "{}".to_string(),
};
let result = self.request("tools/list", ¶ms).map_err(|e| format!("{}: {e}", self.name()))?;
let list = top_level_raw(&result, "tools").ok_or_else(|| format!("{}: tools/list without tools", self.name()))?;
for tool in json_rpc::split_array_elements(&list) {
tools.push(parse_tool(&tool).ok_or_else(|| format!("{}: malformed tool {tool}", self.name()))?);
}
cursor = top_level_str(&result, "nextCursor");
if cursor.is_none() {
return Ok(tools);
}
}
Err(format!("{}: more than {MAX_LIST_PAGES} pages of tools", self.name()))
}
fn list_all(&self, method: &str, field: &str) -> Result<Vec<String>, String> {
let mut items = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..MAX_LIST_PAGES {
let params = match &cursor {
Some(cursor) => format!(r#"{{"cursor":"{}"}}"#, json_escape(cursor)),
None => "{}".to_string(),
};
let result = match self.request(method, ¶ms) {
Ok(result) => result,
Err(e) if e.contains("not found") || e.contains("Unknown method") || e.contains("-32601") || e.contains("answered HTTP 404") => return Ok(items),
Err(e) => return Err(format!("{}: {method}: {e}", self.name())),
};
let Some(list) = top_level_raw(&result, field) else { return Ok(items) };
items.extend(json_rpc::split_array_elements(&list));
cursor = top_level_str(&result, "nextCursor");
if cursor.is_none() {
return Ok(items);
}
}
Err(format!("{}: more than {MAX_LIST_PAGES} pages of {field}", self.name()))
}
fn call_tool(&self, name: &str, arguments: &str, claims: Option<&str>) -> Result<String, String> {
let params = format!(r#"{{"name":"{}","arguments":{arguments}}}"#, json_escape(name));
let caller: Vec<(String, String)> = self
.upstream
.caller_headers
.iter()
.filter_map(|(header, value)| value(claims).map(|v| (header.clone(), v)))
.collect();
let result = self.request_as("tools/call", ¶ms, &caller).map_err(|e| format!("{}: {e}", self.upstream.prefix))?;
if top_level_str(&result, "resultType").as_deref() == Some("input_required") {
return Err(format!("{}: the tool asked for more input (2026-07-28 multi round-trip), which the gateway doesn't relay", self.upstream.prefix));
}
Ok(without_modern_fields(&result))
}
fn request(&self, method: &str, params: &str) -> Result<String, String> {
self.request_as(method, params, &[])
}
fn request_as(&self, method: &str, params: &str, caller: &[(String, String)]) -> Result<String, String> {
let session = self.session()?;
match self.send(&session, method, params, caller)? {
Reply::Result(result) => Ok(result),
Reply::SessionGone => {
self.forget(&session);
let session = self.session()?;
match self.send(&session, method, params, caller)? {
Reply::Result(result) => Ok(result),
Reply::SessionGone => Err("session not found twice in a row".to_string()),
}
}
}
}
fn session(&self) -> Result<Session, String> {
let mut guard = self.session.lock().map_err(|_| "session lock poisoned".to_string())?;
if let Some(session) = guard.as_ref() {
return Ok(session.clone());
}
let session = self.initialize()?;
*guard = Some(session.clone());
Ok(session)
}
fn forget(&self, stale: &Session) {
if let Ok(mut guard) = self.session.lock() {
if guard.as_ref().is_some_and(|current| current.id == stale.id) {
*guard = None;
}
}
}
fn initialize(&self) -> Result<Session, String> {
match self.initialize_legacy() {
Ok(session) => Ok(session),
Err(legacy) => self.discover().map_err(|modern| format!("{legacy}; as {MODERN_VERSION}: {modern}")),
}
}
fn discover(&self) -> Result<Session, String> {
let session = Session { id: None, version: MODERN_VERSION.to_string(), modern: true };
match self.send(&session, "server/discover", "{}", &[])? {
Reply::Result(result) if result.contains(MODERN_VERSION) => Ok(session),
Reply::Result(_) => Err(format!("server/discover doesn't list {MODERN_VERSION}")),
Reply::SessionGone => Err("server/discover answered 404".to_string()),
}
}
fn initialize_legacy(&self) -> Result<Session, String> {
let params = format!(
r#"{{"protocolVersion":"{PROTOCOL_VERSION}","capabilities":{{}},"clientInfo":{{"name":"rws-mcp-gateway","version":"{}"}}}}"#,
env!("CARGO_PKG_VERSION"),
);
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let response = self.post(None, PROTOCOL_VERSION, &[], &envelope(id, "initialize", ¶ms))?;
if !(200..300).contains(&response.status()) {
return Err(format!("initialize answered HTTP {}", response.status()));
}
let session_id = response.header("mcp-session-id").map(str::to_string);
let result = parse_reply(response.header("content-type").unwrap_or(""), response.bytes(), id)?;
let version = top_level_str(&result, "protocolVersion").unwrap_or_else(|| PROTOCOL_VERSION.to_string());
let session = Session { id: session_id, version, modern: false };
let initialized = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
self.post(session.id.as_deref(), &session.version, &[], initialized)?;
Ok(session)
}
fn send(&self, session: &Session, method: &str, params: &str, caller: &[(String, String)]) -> Result<Reply, String> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let response = if session.modern {
let mut headers: Vec<(&str, String)> = vec![("Mcp-Method", method.to_string())];
let named = match method {
"tools/call" | "prompts/get" => top_level_str(params, "name"),
"resources/read" => top_level_str(params, "uri"),
_ => None,
};
if let Some(name) = named {
headers.push(("Mcp-Name", name));
}
headers.extend(caller.iter().map(|(k, v)| (k.as_str(), v.clone())));
self.post(None, &session.version, &headers, &envelope(id, method, &with_modern_meta(params)))?
} else {
let headers: Vec<(&str, String)> = caller.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
self.post(session.id.as_deref(), &session.version, &headers, &envelope(id, method, params))?
};
if response.status() == 404 && session.id.is_some() {
return Ok(Reply::SessionGone);
}
if !(200..300).contains(&response.status()) {
return Err(format!("answered HTTP {}", response.status()));
}
parse_reply(response.header("content-type").unwrap_or(""), response.bytes(), id).map(Reply::Result)
}
fn post(&self, session_id: Option<&str>, version: &str, extra: &[(&str, String)], body: &str) -> Result<crate::http_client::Response, String> {
let mut request = self
.client
.post(&self.upstream.url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", version);
if let Some(id) = session_id {
request = request.header("Mcp-Session-Id", id);
}
for (name, value) in extra {
request = request.header(*name, value);
}
for (name, value) in &self.upstream.headers {
request = request.header(name, value);
}
request.body(body.as_bytes().to_vec()).send().map_err(|e| format!("unreachable: {e}"))
}
}
enum Reply {
Result(String),
SessionGone,
}
fn without_modern_fields(result: &str) -> String {
let Some(members) = json_rpc::object_members(result) else { return result.to_string() };
if !members.iter().any(|(k, _)| matches!(k.as_str(), "resultType" | "ttlMs" | "cacheScope")) {
return result.to_string();
}
let kept: Vec<String> = members
.into_iter()
.filter(|(k, _)| !matches!(k.as_str(), "resultType" | "ttlMs" | "cacheScope"))
.map(|(k, v)| format!(r#""{}":{v}"#, json_escape(&k)))
.collect();
format!("{{{}}}", kept.join(","))
}
fn with_modern_meta(params: &str) -> String {
let meta = format!(
r#""_meta":{{"io.modelcontextprotocol/protocolVersion":"{MODERN_VERSION}","io.modelcontextprotocol/clientInfo":{{"name":"rws-mcp-gateway","version":"{}"}},"io.modelcontextprotocol/clientCapabilities":{{}}}}"#,
env!("CARGO_PKG_VERSION"),
);
let inner = params.trim().strip_prefix('{').and_then(|p| p.strip_suffix('}')).unwrap_or("").trim();
if inner.is_empty() { format!("{{{meta}}}") } else { format!("{{{inner},{meta}}}") }
}
fn envelope(id: u64, method: &str, params: &str) -> String {
format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
}
fn parse_tool(json: &str) -> Option<UpstreamTool> {
let members = json_rpc::object_members(json)?;
let raw = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
let string = |key: &str| top_level_str(json, key);
let name = string("name")?;
let input_schema = raw("inputSchema").filter(|schema| schema.starts_with('{'))?;
let annotations = raw("annotations").filter(|a| a.starts_with('{')).map(|a| {
let hint = |key: &str| top_level_raw(&a, key).and_then(|v| v.parse::<bool>().ok());
ToolAnnotations {
read_only_hint: hint("readOnlyHint"),
destructive_hint: hint("destructiveHint"),
idempotent_hint: hint("idempotentHint"),
open_world_hint: hint("openWorldHint"),
}
});
Some(UpstreamTool {
name,
description: string("description").unwrap_or_default(),
title: string("title"),
input_schema,
output_schema: raw("outputSchema").filter(|schema| schema.starts_with('{')),
annotations,
})
}
#[doc(hidden)]
pub fn parse_reply(content_type: &str, body: &[u8], id: u64) -> Result<String, String> {
let body = std::str::from_utf8(body).map_err(|_| "reply is not UTF-8".to_string())?;
let media_type = content_type.split(';').next().unwrap_or("").trim();
let id = id.to_string();
if media_type.eq_ignore_ascii_case("text/event-stream") {
let mut data = String::new();
for line in body.lines().chain(std::iter::once("")) {
if let Some(value) = line.strip_prefix("data:") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(value.strip_prefix(' ').unwrap_or(value));
} else if line.is_empty() && !data.is_empty() {
let event = std::mem::take(&mut data);
if top_level_raw(&event, "id").as_deref() == Some(id.as_str()) && top_level_raw(&event, "method").is_none() {
return response_result(&event);
}
}
}
Err("the event stream ended without a response".to_string())
} else {
if top_level_raw(body, "id").as_deref() != Some(id.as_str()) {
return Err("reply is not the response to this request".to_string());
}
response_result(body)
}
}
fn response_result(response: &str) -> Result<String, String> {
if let Some(result) = top_level_raw(response, "result") {
return Ok(result);
}
let error = top_level_raw(response, "error").ok_or_else(|| "response has neither result nor error".to_string())?;
Err(top_level_str(&error, "message").unwrap_or(error))
}
#[doc(hidden)]
pub mod fuzzing {
pub fn tools(result: &str) -> usize {
let Some(list) = super::top_level_raw(result, "tools") else { return 0 };
super::json_rpc::split_array_elements(&list).iter().filter_map(|tool| super::parse_tool(tool)).count()
}
}
#[cfg(test)]
mod tests;