#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
use reqwest::blocking::{Client as HttpClient, RequestBuilder};
use reqwest::header::{HeaderValue, ACCEPT, CONTENT_TYPE};
use reqwest::Url;
use serde_json::{json, Value};
use std::{env, error, fmt, time::Duration};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const USER_AGENT: &str = concat!(
"worldmonitor-rust/",
env!("CARGO_PKG_VERSION"),
" (+https://worldmonitor.app)"
);
pub struct Config {
pub api_key: Option<String>,
pub base_url: String,
pub mcp_url: String,
pub timeout: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
api_key: None,
base_url: "https://api.worldmonitor.app".into(),
mcp_url: "https://worldmonitor.app/mcp".into(),
timeout: Duration::from_secs(30),
}
}
}
impl Config {
pub fn from_env() -> Self {
fn value(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.is_empty())
}
Self {
api_key: value("WORLDMONITOR_API_KEY").or_else(|| value("WM_API_KEY")),
base_url: value("WORLDMONITOR_BASE_URL").unwrap_or_else(|| Self::default().base_url),
mcp_url: value("WORLDMONITOR_MCP_URL").unwrap_or_else(|| Self::default().mcp_url),
..Self::default()
}
}
}
#[derive(Debug)]
pub enum Error {
InvalidInput(&'static str),
Http {
status: u16,
body: String,
},
Mcp {
code: i64,
message: String,
data: Value,
},
Transport(reqwest::Error),
Json(serde_json::Error),
Protocol(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) | Self::Protocol(message) => {
write!(f, "worldmonitor: {message}")
}
Self::Http { status, .. } => write!(f, "worldmonitor: HTTP {status}"),
Self::Mcp { code, message, .. } => {
write!(f, "worldmonitor: MCP {code}: {message}")?;
if *code == -32001 {
write!(
f,
" (set WORLDMONITOR_API_KEY; get a key at https://worldmonitor.app/pro)"
)?;
}
Ok(())
}
Self::Transport(error) => write!(f, "worldmonitor: {error}"),
Self::Json(error) => write!(f, "worldmonitor: {error}"),
}
}
}
impl error::Error for Error {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
Self::Transport(error) => Some(error),
Self::Json(error) => Some(error),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::Transport(error.without_url())
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
pub struct Client {
http: HttpClient,
api_key: Option<HeaderValue>,
base_url: Url,
mcp_url: Url,
}
impl Client {
pub fn new(config: Config) -> Result<Self, Error> {
let base_url = endpoint(&config.base_url)?;
let mcp_url = endpoint(&config.mcp_url)?;
if base_url.path() != "/" {
return Err(Error::InvalidInput(
"base_url must be an origin without a path",
));
}
if config.timeout.is_zero() {
return Err(Error::InvalidInput("timeout must be greater than zero"));
}
let api_key = config
.api_key
.filter(|key| !key.is_empty())
.map(|key| {
let mut header = HeaderValue::from_str(&key)
.map_err(|_| Error::InvalidInput("invalid API key header"))?;
header.set_sensitive(true);
Ok::<_, Error>(header)
})
.transpose()?;
Ok(Self {
http: HttpClient::builder()
.timeout(config.timeout)
.redirect(reqwest::redirect::Policy::none())
.user_agent(USER_AGENT)
.build()?,
api_key,
base_url,
mcp_url,
})
}
pub fn from_env() -> Result<Self, Error> {
Self::new(Config::from_env())
}
pub fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, Error> {
if !arguments.is_object() {
return Err(Error::InvalidInput("tool arguments must be a JSON object"));
}
self.rpc(
"tools/call",
Some(json!({"name": name, "arguments": arguments})),
)
}
pub fn list_tools(&self) -> Result<Value, Error> {
self.rpc("tools/list", None)
}
pub fn list_prompts(&self) -> Result<Value, Error> {
self.rpc("prompts/list", None)
}
pub fn list_resources(&self) -> Result<Value, Error> {
self.rpc("resources/list", None)
}
pub fn get(&self, path: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
if !path.starts_with('/') || path.starts_with("//") || path.contains(['\\', '?', '#']) {
return Err(Error::InvalidInput(
"use a host-relative path and separate query parameters",
));
}
let url = self
.base_url
.join(path)
.map_err(|_| Error::InvalidInput("invalid REST path"))?;
if url.origin() != self.base_url.origin() {
return Err(Error::InvalidInput(
"REST path must keep the configured origin",
));
}
let request = self
.http
.get(url)
.header(ACCEPT, "application/json")
.query(query);
let (status, _, body) = self.response(request)?;
if !(200..300).contains(&status) {
return Err(Error::Http { status, body });
}
Ok(serde_json::from_str(&body)?)
}
pub fn health(&self) -> Result<Value, Error> {
self.get("/api/health", &[])
}
pub fn country_risk(&self, country_code: &str) -> Result<Value, Error> {
self.call_tool("get_country_risk", json!({"country_code": country_code}))
}
fn rpc(&self, method: &str, params: Option<Value>) -> Result<Value, Error> {
let mut envelope = json!({"jsonrpc": "2.0", "id": 1, "method": method});
if let Some(params) = params {
envelope["params"] = params;
}
let request = self
.http
.post(self.mcp_url.clone())
.header(ACCEPT, "application/json, text/event-stream")
.json(&envelope);
let (status, content_type, body) = self.response(request)?;
let payload = if content_type.split(';').next() == Some("text/event-stream") {
sse_payload(&body).unwrap_or_else(|| body.clone())
} else {
body.clone()
};
let parsed = serde_json::from_str::<Value>(&payload);
if let Ok(value) = &parsed {
if let Some(error) = value.get("error") {
if let (Some(code), Some(message)) =
(error["code"].as_i64(), error["message"].as_str())
{
return Err(Error::Mcp {
code,
message: message.to_owned(),
data: error.get("data").cloned().unwrap_or(Value::Null),
});
}
}
}
if !(200..300).contains(&status) {
return Err(Error::Http { status, body });
}
let value = parsed?;
if value["jsonrpc"] != "2.0" || value["id"] != 1 || value.get("error").is_some() {
return Err(Error::Protocol("invalid or mismatched JSON-RPC response"));
}
value
.get("result")
.cloned()
.ok_or(Error::Protocol("missing JSON-RPC result"))
}
fn response(&self, mut request: RequestBuilder) -> Result<(u16, String, String), Error> {
if let Some(key) = &self.api_key {
request = request.header("X-WorldMonitor-Key", key);
}
let response = request.send()?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_owned();
Ok((status, content_type, response.text()?))
}
}
fn endpoint(value: &str) -> Result<Url, Error> {
let url = Url::parse(value).map_err(|_| Error::InvalidInput("invalid endpoint URL"))?;
if !matches!(url.scheme(), "https" | "http")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(Error::InvalidInput(
"endpoints require HTTP(S), a host, and no credentials, query, or fragment",
));
}
Ok(url)
}
fn sse_payload(body: &str) -> Option<String> {
let normalized = body.replace("\r\n", "\n");
for event in normalized.rsplit("\n\n") {
let data = event
.lines()
.filter_map(|line| {
line.strip_prefix("data:")
.map(|value| value.strip_prefix(' ').unwrap_or(value))
})
.collect::<Vec<_>>()
.join("\n");
if let Ok(value) = serde_json::from_str::<Value>(&data) {
if value.get("result").is_some() || value.get("error").is_some() {
return Some(data);
}
}
}
None
}