use std::collections::BTreeSet;
use std::io::{BufRead, Write};
use std::sync::{Mutex, PoisonError};
use anyhow::{Context as _, Result, bail};
use serde_json::{Value, json};
use super::tools::ToolPins;
const MAX_LINE: u64 = 64 * 1024 * 1024;
const UNKNOWN_TOOL: i64 = -32602;
pub trait Recorder: Send + Sync {
fn record(&self, tool: &str, refused: Option<&str>) -> Result<()>;
}
pub type Save = Box<dyn Fn(&ToolPins) -> Result<()> + Send + Sync>;
pub struct Relay {
server: String,
state: Mutex<State>,
recorder: Box<dyn Recorder>,
save: Save,
now_ms: fn() -> i64,
}
struct State {
pins: ToolPins,
first: bool,
lists: BTreeSet<String>,
broken: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Routed {
Server(String),
Host(String),
}
impl Relay {
pub fn new(
server: &str,
pins: Option<ToolPins>,
recorder: Box<dyn Recorder>,
save: Save,
now_ms: fn() -> i64,
) -> Self {
let first = pins.is_none();
Self {
server: server.to_owned(),
state: Mutex::new(State {
pins: pins.unwrap_or_else(|| ToolPins::new(server)),
first,
lists: BTreeSet::new(),
broken: None,
}),
recorder,
save,
now_ms,
}
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn host_line(&self, line: &str) -> Vec<Routed> {
let Ok(message) = serde_json::from_str::<Value>(line) else {
return vec![Routed::Server(line.to_owned())];
};
let Value::Array(batch) = message else {
return match self.request(&message) {
Ok(()) => vec![Routed::Server(line.to_owned())],
Err(Some(reply)) => vec![Routed::Host(reply.to_string())],
Err(None) => Vec::new(),
};
};
let (mut forward, mut replies) = (Vec::new(), Vec::new());
for message in batch {
match self.request(&message) {
Ok(()) => forward.push(message),
Err(Some(reply)) => replies.push(reply),
Err(None) => {}
}
}
let mut out = Vec::new();
if !forward.is_empty() {
out.push(Routed::Server(Value::Array(forward).to_string()));
}
if !replies.is_empty() {
out.push(Routed::Host(Value::Array(replies).to_string()));
}
out
}
fn request(&self, message: &Value) -> Result<(), Option<Value>> {
let id = message.get("id");
match message.get("method").and_then(Value::as_str) {
Some("tools/list") => {
if let Some(id) = id {
self.state().lists.insert(id.to_string());
}
Ok(())
}
Some("tools/call") => {
let tool = message
.pointer("/params/name")
.and_then(Value::as_str)
.unwrap_or("");
let refused = self.refusal(tool);
let name = format!("mcp__{}__{tool}", self.server);
let refused = match self.recorder.record(&name, refused.as_deref()) {
Ok(()) => refused,
Err(e) => Some(format!(
"OpenMoat cannot record this call in its audit log ({e:#}), so it is refused"
)),
};
match refused {
None => Ok(()),
Some(why) => Err(id.map(|id| error(id, &why))),
}
}
_ => Ok(()),
}
}
fn refusal(&self, tool: &str) -> Option<String> {
let state = self.state();
let server = &self.server;
if let Some(why) = &state.broken {
return Some(format!(
"OpenMoat cannot save the tool definitions of MCP server `{server}` ({why}), \
so it refuses its tool calls"
));
}
if state.pins.is_held(tool) || (!state.first && !state.pins.tools.contains_key(tool)) {
return Some(format!(
"OpenMoat holds tool `{tool}` of MCP server `{server}`: its description or \
input schema is not the one a person accepted, or it is new. Run \
`moat mcp accept {server}` in a terminal to review it"
));
}
None
}
pub fn server_line(&self, line: &str) -> String {
let Ok(mut message) = serde_json::from_str::<Value>(line) else {
return line.to_owned();
};
let changed = match &mut message {
Value::Array(batch) => batch
.iter_mut()
.fold(false, |any, m| self.response(m) | any),
single => self.response(single),
};
if changed {
message.to_string()
} else {
line.to_owned()
}
}
fn response(&self, message: &mut Value) -> bool {
if message.get("method").is_some() {
return false;
}
let Some(id) = message.get("id").map(Value::to_string) else {
return false;
};
let mut state = self.state();
if !state.lists.remove(&id) {
return false;
}
let Some(Value::Array(tools)) = message.pointer_mut("/result/tools") else {
return false;
};
let first = state.first;
let listing = state.pins.list(tools, first, (self.now_ms)());
state.first = false;
if listing.changed
&& let Err(e) = (self.save)(&state.pins)
{
state.broken = Some(format!("{e:#}"));
}
let before = tools.len();
tools.retain(|t| {
t.get("name")
.and_then(Value::as_str)
.is_none_or(|n| !listing.hidden.contains(n))
});
tools.len() != before
}
}
fn error(id: &Value, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": UNKNOWN_TOOL, "message": message}})
}
fn read_line(input: &mut impl BufRead) -> Result<Option<String>> {
let mut bytes = Vec::new();
let read = std::io::Read::take(&mut *input, MAX_LINE + 1)
.read_until(b'\n', &mut bytes)
.context("reading a message")?;
if read == 0 {
return Ok(None);
}
if bytes.len() as u64 > MAX_LINE {
bail!("a message is longer than {} MiB", MAX_LINE / (1024 * 1024));
}
while bytes.last().is_some_and(|b| *b == b'\n' || *b == b'\r') {
bytes.pop();
}
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
}
fn send(out: &Mutex<impl Write>, line: &str) -> Result<()> {
let mut out = out.lock().unwrap_or_else(PoisonError::into_inner);
out.write_all(line.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
Ok(())
}
pub fn pump_host(
relay: &Relay,
mut input: impl BufRead,
mut server: impl Write,
host: &Mutex<impl Write>,
) -> Result<()> {
while let Some(line) = read_line(&mut input)? {
for routed in relay.host_line(&line) {
match routed {
Routed::Server(text) => {
server.write_all(text.as_bytes())?;
server.write_all(b"\n")?;
server.flush()?;
}
Routed::Host(text) => send(host, &text)?,
}
}
}
Ok(())
}
pub fn pump_server(relay: &Relay, mut input: impl BufRead, host: &Mutex<impl Write>) -> Result<()> {
while let Some(line) = read_line(&mut input)? {
send(host, &relay.server_line(&line))?;
}
Ok(())
}
#[cfg(test)]
mod tests;