use newt_core::mcp::{McpServerEntry, TransportKind};
use newt_mcp_client::{connect_http, connect_stdio, namespaced, split_namespaced, ConnectedServer};
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub(crate) enum McpStatus {
Connected {
tools: usize,
confinement: Confinement,
net: NetGate,
},
Skipped(String),
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Confinement {
Confined(String),
Advisory,
Remote,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum NetGate {
Gated(usize),
Advisory,
}
impl NetGate {
pub(crate) fn from_posture(posture: newt_mcp_client::NetPosture) -> Self {
match posture {
newt_mcp_client::NetPosture::Gated(n) => Self::Gated(n),
newt_mcp_client::NetPosture::Advisory => Self::Advisory,
}
}
pub(crate) fn note(&self) -> String {
match self {
Self::Gated(n) => format!(" · net: gated ({n} host{})", if *n == 1 { "" } else { "s" }),
Self::Advisory => " · net: advisory".to_string(),
}
}
}
impl Confinement {
pub(crate) fn from_sandbox(kind: Option<newt_mcp_client::SandboxKind>) -> Self {
match kind {
None => Self::Remote,
Some(newt_mcp_client::SandboxKind::None) => Self::Advisory,
Some(k) => Self::Confined(format!("{k:?}")),
}
}
pub(crate) fn note(&self) -> String {
match self {
Self::Confined(kind) => format!(" — confined: {kind}"),
Self::Advisory => " — advisory (no OS sandbox)".to_string(),
Self::Remote => String::new(),
}
}
}
pub(crate) struct Mcp {
pub(crate) statuses: Vec<(String, McpStatus)>,
servers: Vec<ConnectedServer>,
session_muted: std::collections::BTreeSet<String>,
sanitize_server_names: bool,
}
fn server_prefix(name: &str, sanitize: bool) -> String {
if sanitize {
name.replace('-', "_")
} else {
name.to_owned()
}
}
fn parse_scheme_host(url: Option<&str>) -> (String, String) {
newt_mcp_client::parse_scheme_host(url)
}
fn http_egress_permitted(net: &newt_core::caveats::Scope<String>, host: &str) -> bool {
host.is_empty()
|| host_is_loopback(host)
|| newt_core::caveats::ScopeExt::permits(net, &host.to_string())
}
fn host_is_loopback(host: &str) -> bool {
newt_mcp_client::host_is_loopback(host)
}
fn bearer_allowed_for_url(url: Option<&str>, allow_insecure_hosts: &[String]) -> bool {
let (scheme, host) = parse_scheme_host(url);
if scheme == "https" || host_is_loopback(&host) {
return true;
}
!host.is_empty()
&& allow_insecure_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(&host))
}
fn apply_transport_security(
entry: &mut McpServerEntry,
token: Option<String>,
allow_insecure_hosts: &[String],
) {
let (scheme, host) = parse_scheme_host(entry.url.as_deref());
let secure = scheme == "https" || host_is_loopback(&host);
let allowed = bearer_allowed_for_url(entry.url.as_deref(), allow_insecure_hosts);
if !secure {
match &token {
Some(_) if allowed => tracing::warn!(
"MCP server `{}`: UNENCRYPTED connection to `{}` (no TLS) — sending the \
OAuth Bearer anyway ([tui].mcp_allow_insecure_hosts opt-in)",
entry.name,
host
),
Some(_) => tracing::warn!(
"MCP server `{}`: UNENCRYPTED connection to `{}` (no TLS) — WITHHOLDING the \
OAuth Bearer token. Use https, or add `{}` to [tui].mcp_allow_insecure_hosts \
to override.",
entry.name,
host,
host
),
None => tracing::warn!(
"MCP server `{}`: UNENCRYPTED connection to `{}` (no TLS).",
entry.name,
host
),
}
}
if let (Some(token), true) = (token, allowed) {
entry.headers.insert(
"Authorization".into(),
newt_core::mcp::SecretValue::literal(format!("Bearer {token}")),
);
}
}
impl Mcp {
pub(crate) fn drop_server(&mut self, name: &str) {
self.servers.retain(|s| s.name != name);
self.session_muted.remove(name);
}
#[must_use]
pub(crate) fn is_muted(&self, name: &str) -> bool {
self.session_muted.contains(name)
}
fn is_advertising(&self, server: &ConnectedServer) -> bool {
!self.session_muted.contains(&server.name)
}
pub(crate) fn mute(&mut self, name: &str) -> bool {
let connected = self.servers.iter().any(|s| s.name == name)
|| self
.statuses
.iter()
.any(|(n, st)| n == name && matches!(st, McpStatus::Connected { .. }));
if !connected {
return false;
}
self.session_muted.insert(name.to_owned());
true
}
pub(crate) fn unmute(&mut self, name: &str) -> bool {
let connected = self.servers.iter().any(|s| s.name == name)
|| self
.statuses
.iter()
.any(|(n, st)| n == name && matches!(st, McpStatus::Connected { .. }));
if !connected {
return false;
}
self.session_muted.remove(name);
true
}
pub(crate) fn mute_all(&mut self) -> Vec<String> {
let mut names: std::collections::BTreeSet<String> =
self.servers.iter().map(|s| s.name.clone()).collect();
for (n, st) in &self.statuses {
if matches!(st, McpStatus::Connected { .. }) {
names.insert(n.clone());
}
}
let names: Vec<String> = names.into_iter().collect();
for n in &names {
self.session_muted.insert(n.clone());
}
names
}
pub(crate) fn unmute_all(&mut self) -> Vec<String> {
let names: Vec<String> = self.session_muted.iter().cloned().collect();
self.session_muted.clear();
names
}
#[cfg(test)]
pub(crate) fn empty() -> Self {
Self {
statuses: Vec::new(),
servers: Vec::new(),
session_muted: std::collections::BTreeSet::new(),
sanitize_server_names: true,
}
}
pub(crate) async fn connect(
workspace: &str,
cfg_servers: &[McpServerEntry],
sanitize_server_names: bool,
allow_insecure_hosts: &[String],
caveats: &newt_core::caveats::Caveats,
) -> Self {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
let mcp_toml = newt_core::Config::user_config_dir().map(|d| d.join("mcp.toml"));
let entries = newt_core::mcp::discover(
cfg_servers,
mcp_toml.as_deref(),
home.as_deref(),
std::path::Path::new(workspace),
);
let mut servers = Vec::new();
let mut statuses: Vec<(String, McpStatus)> = Vec::new();
for entry in &entries {
if !entry.enabled {
statuses.push((entry.name.clone(), McpStatus::Disabled));
continue;
}
let result = match entry.transport {
TransportKind::Stdio => connect_stdio(entry, caveats).await,
TransportKind::Http => {
let (_scheme, host) = parse_scheme_host(entry.url.as_deref());
if !http_egress_permitted(&caveats.net, &host) {
tracing::warn!(
"MCP server `{}`: egress to {host} is outside the session net \
allow-list — skipped (grant it in [tui.permissions] net)",
entry.name
);
statuses.push((
entry.name.clone(),
McpStatus::Skipped(format!("net not granted: {host}")),
));
continue;
}
let mut enriched = entry.clone();
let already_authed = enriched.headers.contains_key("Authorization")
|| enriched.headers.contains_key("authorization");
let token = if already_authed {
None
} else {
crate::mcp_token::load_bearer_token(&entry.name).await
};
apply_transport_security(&mut enriched, token, allow_insecure_hosts);
connect_http(&enriched, caveats).await
}
TransportKind::Sse => {
tracing::warn!(
"MCP server `{}`: legacy SSE transport is not supported \
(use streamable-HTTP, `type = \"http\"`) — skipped",
entry.name
);
statuses.push((
entry.name.clone(),
McpStatus::Skipped("legacy SSE transport (use type = \"http\")".into()),
));
continue;
}
};
match result {
Ok(connected) => {
statuses.push((
entry.name.clone(),
McpStatus::Connected {
tools: connected.tools.len(),
confinement: Confinement::from_sandbox(connected.sandbox_kind),
net: NetGate::from_posture(connected.net_posture),
},
));
servers.push(connected);
}
Err(e) => {
tracing::warn!("MCP server `{}` skipped: {e:#}", entry.name);
statuses.push((entry.name.clone(), McpStatus::Skipped(format!("{e:#}"))));
}
}
}
Self {
statuses,
servers,
session_muted: std::collections::BTreeSet::new(),
sanitize_server_names,
}
}
pub(crate) fn is_empty(&self) -> bool {
self.servers.is_empty()
}
pub(crate) fn summary(&self) -> Vec<(String, usize)> {
self.servers
.iter()
.map(|s| (s.name.clone(), s.tools.len()))
.collect()
}
pub(crate) fn tool_defs(&self) -> Vec<Value> {
let mut out = Vec::new();
for server in &self.servers {
if !self.is_advertising(server) {
continue;
}
for tool in &server.tools {
out.push(json!({
"type": "function",
"function": {
"name": namespaced(&server_prefix(&server.name, self.sanitize_server_names), &tool.name),
"description": tool.description,
"parameters": tool.input_schema,
}
}));
}
}
out
}
pub(crate) fn handles(&self, name: &str) -> bool {
match split_namespaced(name) {
Some((server, _)) => self.servers.iter().any(|s| {
self.is_advertising(s)
&& server_prefix(&s.name, self.sanitize_server_names) == server
}),
None => false,
}
}
pub(crate) async fn call(&mut self, name: &str, args: &Value) -> String {
let Some((server_name, tool)) = split_namespaced(name) else {
return format!("error: `{name}` is not a namespaced MCP tool");
};
if let Some(muted) = self
.session_muted
.iter()
.find(|n| server_prefix(n, self.sanitize_server_names) == server_name)
{
return format!(
"error: MCP server `{muted}` is muted this session — `/mcp on {muted}` to restore its tools"
);
}
let Some(server) = self
.servers
.iter_mut()
.find(|s| server_prefix(&s.name, self.sanitize_server_names) == server_name)
else {
return format!("error: no connected MCP server `{server_name}`");
};
match server.conn.call_tool(tool, args.clone()).await {
Ok(result) => newt_core::wrap_untrusted(name, &format_result(&result)),
Err(e) => format!("error: {e}"),
}
}
}
#[async_trait::async_trait]
impl newt_core::agentic::McpTools for Mcp {
fn handles(&self, name: &str) -> bool {
Self::handles(self, name)
}
fn tool_defs(&self) -> Vec<Value> {
Self::tool_defs(self)
}
async fn call(&mut self, name: &str, args: &Value) -> String {
Self::call(self, name, args).await
}
}
fn format_result(result: &Value) -> String {
let mut text = String::new();
if let Some(items) = result.get("content").and_then(Value::as_array) {
for item in items {
if let Some(t) = item.get("text").and_then(Value::as_str) {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
}
if text.is_empty() {
text = result.to_string();
}
if result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false)
{
format!("tool error: {text}")
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_handles_nothing_and_has_no_defs() {
let mcp = Mcp::empty();
assert!(mcp.is_empty());
assert!(!mcp.handles("git__status"));
assert!(mcp.tool_defs().is_empty());
}
#[test]
fn session_mute_round_trip_on_connected_status() {
let mut mcp = Mcp::empty();
mcp.statuses.push((
"github".into(),
McpStatus::Connected {
tools: 4,
confinement: Confinement::Remote,
net: NetGate::Advisory,
},
));
assert!(!mcp.is_muted("github"));
assert!(mcp.mute("github"));
assert!(mcp.is_muted("github"));
assert!(mcp.unmute("github"));
assert!(!mcp.is_muted("github"));
}
#[test]
fn mute_unknown_or_disabled_server_fails() {
let mut mcp = Mcp::empty();
mcp.statuses.push(("dead".into(), McpStatus::Disabled));
assert!(!mcp.mute("dead"));
assert!(!mcp.mute("missing"));
assert!(!mcp.unmute("missing"));
}
#[test]
fn mute_all_and_unmute_all() {
let mut mcp = Mcp::empty();
mcp.statuses.push((
"a".into(),
McpStatus::Connected {
tools: 1,
confinement: Confinement::Remote,
net: NetGate::Advisory,
},
));
mcp.statuses.push((
"b".into(),
McpStatus::Connected {
tools: 2,
confinement: Confinement::Remote,
net: NetGate::Advisory,
},
));
let muted = mcp.mute_all();
assert_eq!(muted, vec!["a".to_string(), "b".to_string()]);
assert!(mcp.is_muted("a"));
assert!(mcp.is_muted("b"));
let unmuted = mcp.unmute_all();
assert_eq!(unmuted, vec!["a".to_string(), "b".to_string()]);
assert!(!mcp.is_muted("a"));
assert!(!mcp.is_muted("b"));
}
#[test]
fn drop_server_clears_session_mute() {
let mut mcp = Mcp::empty();
mcp.statuses.push((
"x".into(),
McpStatus::Connected {
tools: 1,
confinement: Confinement::Remote,
net: NetGate::Advisory,
},
));
assert!(mcp.mute("x"));
mcp.drop_server("x");
assert!(!mcp.is_muted("x"));
}
#[test]
fn confinement_maps_sandbox_kind_to_posture() {
use newt_mcp_client::SandboxKind;
assert_eq!(Confinement::from_sandbox(None), Confinement::Remote);
assert_eq!(
Confinement::from_sandbox(Some(SandboxKind::None)),
Confinement::Advisory
);
assert_eq!(
Confinement::from_sandbox(Some(SandboxKind::Landlock)),
Confinement::Confined("Landlock".to_string())
);
}
#[test]
fn confinement_note_renders_each_posture() {
assert_eq!(Confinement::Remote.note(), "");
assert_eq!(Confinement::Advisory.note(), " — advisory (no OS sandbox)");
assert_eq!(
Confinement::Confined("Landlock".to_string()).note(),
" — confined: Landlock"
);
}
#[test]
fn net_gate_maps_posture_and_renders_note() {
use newt_mcp_client::NetPosture;
assert_eq!(
NetGate::from_posture(NetPosture::Advisory),
NetGate::Advisory
);
assert_eq!(
NetGate::from_posture(NetPosture::Gated(3)),
NetGate::Gated(3)
);
assert_eq!(NetGate::Advisory.note(), " · net: advisory");
assert_eq!(NetGate::Gated(1).note(), " · net: gated (1 host)");
assert_eq!(NetGate::Gated(2).note(), " · net: gated (2 hosts)");
}
fn http_entry(url: &str) -> McpServerEntry {
McpServerEntry {
enabled: true,
name: "MaaS".into(),
transport: TransportKind::Http,
command: None,
args: Vec::new(),
env: std::collections::BTreeMap::new(),
url: Some(url.into()),
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: newt_core::mcp::McpTrust::Trusted,
}
}
#[test]
fn parse_scheme_host_handles_common_shapes() {
assert_eq!(
parse_scheme_host(Some("https://a.b/c")),
("https".into(), "a.b".into())
);
assert_eq!(
parse_scheme_host(Some("http://127.0.0.1:8080/x")),
("http".into(), "127.0.0.1".into())
);
assert_eq!(
parse_scheme_host(Some("http://u@Host:9/x")),
("http".into(), "host".into())
);
assert_eq!(
parse_scheme_host(Some("http://[::1]:7/x")),
("http".into(), "::1".into())
);
assert_eq!(parse_scheme_host(None), (String::new(), String::new()));
}
#[test]
fn bearer_allowed_only_over_https_loopback_or_allowlist() {
let none: &[String] = &[];
assert!(bearer_allowed_for_url(
Some("https://api.maas.com/mcp"),
none
));
assert!(bearer_allowed_for_url(Some("http://localhost:9/mcp"), none));
assert!(bearer_allowed_for_url(Some("http://127.0.0.1:9/mcp"), none));
assert!(bearer_allowed_for_url(Some("http://[::1]:9/mcp"), none));
assert!(!bearer_allowed_for_url(
Some("http://api.maas.com/mcp"),
none
));
assert!(!bearer_allowed_for_url(None, none));
let allow = vec!["api.maas.com".to_string()];
assert!(bearer_allowed_for_url(
Some("http://API.MaaS.com/mcp"),
&allow
));
}
#[test]
fn http_egress_net_gate() {
use newt_core::caveats::Scope;
let none: Scope<String> = Scope::only::<Vec<String>>(vec![]);
assert!(http_egress_permitted(&none, ""));
assert!(http_egress_permitted(&none, "127.0.0.1"));
assert!(http_egress_permitted(&none, "localhost"));
assert!(!http_egress_permitted(&none, "mcp.example.com"));
let granted = Scope::only(["mcp.example.com".to_string()]);
assert!(http_egress_permitted(&granted, "mcp.example.com"));
assert!(!http_egress_permitted(&granted, "evil.example.com"));
assert!(http_egress_permitted(&Scope::All, "anything.example.com"));
}
#[test]
fn apply_transport_security_withholds_token_over_plain_http() {
let mut e = http_entry("http://api.maas.com/mcp");
apply_transport_security(&mut e, Some("SECRET".into()), &[]);
assert!(
!e.headers.contains_key("Authorization"),
"Bearer leaked over plaintext http: {:?}",
e.headers
);
}
#[test]
fn apply_transport_security_injects_over_https_and_allowlisted() {
let mut https = http_entry("https://api.maas.com/mcp");
apply_transport_security(&mut https, Some("SECRET".into()), &[]);
assert_eq!(
https
.headers
.get("Authorization")
.and_then(newt_core::mcp::SecretValue::as_literal),
Some("Bearer SECRET")
);
let mut allowed = http_entry("http://api.maas.com/mcp");
apply_transport_security(
&mut allowed,
Some("SECRET".into()),
&["api.maas.com".to_string()],
);
assert_eq!(
allowed
.headers
.get("Authorization")
.and_then(newt_core::mcp::SecretValue::as_literal),
Some("Bearer SECRET")
);
let mut loopback = http_entry("http://127.0.0.1:9/mcp");
apply_transport_security(&mut loopback, Some("SECRET".into()), &[]);
assert!(loopback.headers.contains_key("Authorization"));
}
#[test]
fn server_prefix_toggle() {
assert_eq!(server_prefix("acme-server", true), "acme_server");
assert_eq!(server_prefix("multi-part-name", true), "multi_part_name");
assert_eq!(server_prefix("plainserver", true), "plainserver");
assert_eq!(server_prefix("acme-server", false), "acme-server");
assert_eq!(server_prefix("multi-part-name", false), "multi-part-name");
assert_eq!(server_prefix("plainserver", false), "plainserver");
let tool = format!("{}__probe_tool", server_prefix("acme-server", true));
assert_eq!(tool, "acme_server__probe_tool");
}
#[test]
fn format_result_joins_text_content() {
let r =
json!({ "content": [{"type":"text","text":"hello"},{"type":"text","text":"world"}] });
assert_eq!(format_result(&r), "hello\nworld");
}
#[test]
fn format_result_flags_errors_and_falls_back_to_json() {
let err = json!({ "content": [{"type":"text","text":"boom"}], "isError": true });
assert_eq!(format_result(&err), "tool error: boom");
let weird = json!({ "structured": 1 });
assert!(format_result(&weird).contains("structured"));
}
}