MCP: Added allowed-hosts and allowed-origins flags (#19076)

## Description
Added MCP flags for allowed-hosts and allowed-origins. This is required
if you wish to bind the mcp server to other interfaces than localhost. I
also fixed some missing parts portions for bind-host.

## User-facing changes (Release notes)
Added MCP flags for allowed-hosts and allowed-origins. This is required
if you wish to bind the mcp server to other interfaces than localhost.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jack WrightandCopilot Autofix powered by AI authored and GitHub committed 2026-10-07 13:29:59 -07:00
1 parent a2138a8e26
commit 9149ea5ede
3 files changed
+291 -24

No files matched your search

+59 -4
View File
@@ -15,6 +15,7 @@ use rmcp::{
},
};
use server::NushellMcpServer;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Runtime;
@@ -37,17 +38,48 @@ pub enum McpTransport {
bind_host: String,
/// Port to listen on
bind_port: u16,
/// Optional list of allowed `Host` header values, guarding against DNS-rebinding
/// attacks (default: rmcp's built-in loopback-only list)
allowed_hosts: Option<Vec<String>>,
/// Optional list of allowed `Origin` header values for HTTP request validation
/// (default: none, which disables Origin validation for backward compatibility)
allowed_origins: Option<Vec<String>>,
},
}
impl McpTransport {
/// Create a new MCP transport configuration for HTTP
/// Create a new MCP transport configuration for HTTP, using rmcp's default
/// `Host`/`Origin` validation. Use [`McpTransport::with_allowed_hosts`] and
/// [`McpTransport::with_allowed_origins`] to customize it.
pub fn http(bind_address: Option<String>, port: Option<u16>) -> Self {
McpTransport::Http {
bind_host: bind_address.unwrap_or("127.0.0.1".into()),
bind_port: port.unwrap_or(8080),
allowed_hosts: None,
allowed_origins: None,
}
}
/// Set the allowed `Host` header values for the HTTP transport.
/// Has no effect on [`McpTransport::Stdio`].
pub fn with_allowed_hosts(mut self, hosts: Option<Vec<String>>) -> Self {
if let McpTransport::Http { allowed_hosts, .. } = &mut self {
*allowed_hosts = hosts;
}
self
}
/// Set the allowed CORS origins for the HTTP transport.
/// Has no effect on [`McpTransport::Stdio`].
pub fn with_allowed_origins(mut self, origins: Option<Vec<String>>) -> Self {
if let McpTransport::Http {
allowed_origins, ..
} = &mut self
{
*allowed_origins = origins;
}
self
}
}
pub fn initialize_mcp_server(
@@ -99,9 +131,17 @@ pub fn initialize_mcp_server(
McpTransport::Http {
bind_host,
bind_port,
allowed_hosts,
allowed_origins,
} => {
let addr = format!("{bind_host}:{bind_port}");
run_http_server(engine_state, &addr).await
// Bare IPv6 literals (e.g. `::1`) must be bracketed to form a valid
// socket address; hostnames, IPv4, and already-bracketed hosts are used as-is.
let addr = if bind_host.parse::<Ipv6Addr>().is_ok() {
format!("[{bind_host}]:{bind_port}")
} else {
format!("{bind_host}:{bind_port}")
};
run_http_server(engine_state, &addr, allowed_hosts, allowed_origins).await
}
};
if let Err(e) = result {
@@ -132,6 +172,8 @@ const SESSION_CHANNEL_CAPACITY: usize = 16;
async fn run_http_server(
engine_state: EngineState,
bind_address: &str,
bind_hosts: Option<Vec<String>>,
bind_origins: Option<Vec<String>>,
) -> Result<(), Box<dyn std::error::Error>> {
let engine_state = Arc::new(engine_state);
@@ -146,17 +188,30 @@ async fn run_http_server(
session_manager.session_config = session_config;
let session_manager = Arc::new(session_manager);
// Only override rmcp's defaults when the user explicitly configured a list: rmcp's
// default `allowed_hosts` (loopback only) guards against DNS-rebinding attacks, and
// passing an empty Vec here would disable that protection by allowing any Host header.
let mut http_config = StreamableHttpServerConfig::default();
if let Some(hosts) = bind_hosts.filter(|hosts| !hosts.is_empty()) {
http_config = http_config.with_allowed_hosts(hosts);
}
if let Some(origins) = bind_origins {
http_config = http_config.with_allowed_origins(origins);
}
let service = TowerToHyperService::new(StreamableHttpService::new(
{
let engine_state = engine_state.clone();
move || Ok(NushellMcpServer::new((*engine_state).clone()))
},
session_manager,
StreamableHttpServerConfig::default(),
http_config.clone(),
));
let listener = tokio::net::TcpListener::bind(bind_address).await?;
tracing::info!("MCP HTTP server listening on http://{bind_address}");
tracing::info!("Allowed hosts: {:?}", http_config.allowed_hosts);
tracing::info!("Allowed origins: {:?}", http_config.allowed_origins);
eprintln!("MCP HTTP server listening on http://{bind_address}");
loop {
+222 -20
View File
@@ -414,6 +414,24 @@ const CLI_FLAGS: &[CliFlag] = &[
CliCategory::Startup,
"nu --mcp --mcp-transhost http --mcp-host 0.0.0.0",
),
#[cfg(feature = "mcp")]
CliFlag::value(
"mcp-allowed-hosts",
None,
ValueHint::ListString,
"list of allowed `Host` header values, guarding against DNS-rebinding attacks (default: loopback-only)",
CliCategory::Startup,
r#"nu --mcp --mcp-transport http --mcp-allowed-hosts="example.com,localhost""#,
),
#[cfg(feature = "mcp")]
CliFlag::value(
"mcp-allowed-origins",
None,
ValueHint::ListString,
"list of allowed CORS origins (default: none, which disables Origin validation for backward compatibility)",
CliCategory::Startup,
r#"nu --mcp --mcp-transport http --mcp-allowed-origins="https://example.com,https://localhost""#,
),
];
// Container for parsed CLI values before conversion to NushellCliArgs.
@@ -461,6 +479,10 @@ struct CliValues {
mcp_port: Option<u16>,
#[cfg(feature = "mcp")]
mcp_host: Option<String>,
#[cfg(feature = "mcp")]
mcp_allowed_hosts: Option<Vec<Spanned<String>>>,
#[cfg(feature = "mcp")]
mcp_allowed_origins: Option<Vec<Spanned<String>>>,
}
// Error type for CLI parsing with optional help text.
@@ -747,6 +769,27 @@ pub(crate) fn parse_cli_args(args: Vec<OsString>) -> Result<ParsedCli, CliError>
let value = parse_port_value(&mut parser, "mcp-port")?;
cli.mcp_port = Some(value);
}
#[cfg(feature = "mcp")]
Long("mcp-host") => {
let value = parse_string_value(&mut parser, "mcp-host")?;
cli.mcp_host = Some(value);
}
#[cfg(feature = "mcp")]
Long("mcp-allowed-hosts") => {
let values = parse_list_values(&mut parser, "mcp-allowed-hosts")?;
let parsed = split_list_values(values);
cli.mcp_allowed_hosts
.get_or_insert_with(Vec::new)
.extend(parsed.into_iter().map(spanned_value));
}
#[cfg(feature = "mcp")]
Long("mcp-allowed-origins") => {
let values = parse_list_values(&mut parser, "mcp-allowed-origins")?;
let parsed = split_list_values(values);
cli.mcp_allowed_origins
.get_or_insert_with(Vec::new)
.extend(parsed.into_iter().map(spanned_value));
}
Value(value) => {
let value = value.string().map_err(|_| {
CliError::new("Invalid argument", "argument is not valid unicode")
@@ -810,6 +853,10 @@ pub(crate) fn parse_cli_args(args: Vec<OsString>) -> Result<ParsedCli, CliError>
mcp_port: cli.mcp_port,
#[cfg(feature = "mcp")]
mcp_host: cli.mcp_host,
#[cfg(feature = "mcp")]
mcp_allowed_hosts: cli.mcp_allowed_hosts,
#[cfg(feature = "mcp")]
mcp_allowed_origins: cli.mcp_allowed_origins,
},
script_name,
args_to_script,
@@ -1065,34 +1112,65 @@ fn parse_experimental_options(parser: &mut lexopt::Parser) -> Result<Vec<String>
Ok(parsed)
}
// Parse log filters and ensure they match known log levels.
// Supports multiple formats: [error,warn], [error, warn], error warn, etc.
fn parse_log_filters(values: Vec<String>) -> Vec<String> {
// Strip list-delimiting brackets from a single argument while keeping brackets that
// belong to an item, such as the IPv6 authority in `[::1]:8080`.
//
// An unquoted list like `[a, b]` reaches us split by the shell as `[a,` and `b]`, so a
// leading `[` is removed when it is never closed or is closed by the final character,
// and a trailing `]` is removed when it has no opening `[` or closes the leading one.
fn strip_list_brackets(value: &str) -> &str {
let last = value.len().saturating_sub(1);
let mut open = Vec::new();
let mut leading_closed_at = None;
let mut trailing_unmatched = false;
for (i, c) in value.char_indices() {
match c {
'[' => open.push(i),
']' => match open.pop() {
Some(0) => leading_closed_at = Some(i),
Some(_) => {}
None => trailing_unmatched |= i == last,
},
_ => {}
}
}
let encloses = leading_closed_at == Some(last);
let mut stripped = value;
if value.starts_with('[') && (leading_closed_at.is_none() || encloses) {
stripped = &stripped[1..];
}
if value.ends_with(']') && (trailing_unmatched || encloses) {
stripped = &stripped[..stripped.len() - 1];
}
stripped
}
// Split list-flag values on commas and strip list brackets, so `--flag="a,b"`,
// `--flag="[a, b]"`, `--flag [a, b]` (split by the shell), and `--flag a --flag b`
// all parse the same way.
fn split_list_values(values: Vec<String>) -> Vec<String> {
let mut parsed = Vec::new();
// Process each value, handling brackets and comma-delimited forms
for value in values {
let trimmed = value.trim();
let trimmed = trimmed.strip_prefix('[').unwrap_or(trimmed);
let trimmed = trimmed.strip_suffix(']').unwrap_or(trimmed);
// Split on commas if present, otherwise treat as single value
if trimmed.contains(',') {
for item in trimmed.split(',') {
let item = item.trim();
if !item.is_empty() {
let normalized = item.to_ascii_lowercase();
parsed.push(normalized);
}
let trimmed = strip_list_brackets(value.trim());
for item in trimmed.split(',') {
let item = item.trim();
if !item.is_empty() {
parsed.push(item.to_string());
}
} else if !trimmed.is_empty() {
let normalized = trimmed.to_ascii_lowercase();
parsed.push(normalized);
}
}
parsed
}
// Parse log filters and ensure they match known log levels.
// Supports multiple formats: [error,warn], [error, warn], error warn, etc.
fn parse_log_filters(values: Vec<String>) -> Vec<String> {
split_list_values(values)
.into_iter()
.map(|value| value.to_ascii_lowercase())
.collect()
}
// Validate an experimental option name against the known list.
fn validate_experimental_option(name: &str, valid_options: &[&str]) -> Result<(), CliError> {
if valid_options.contains(&name) {
@@ -1484,6 +1562,10 @@ pub(crate) struct NushellCliArgs {
pub(crate) mcp_port: Option<u16>,
#[cfg(feature = "mcp")]
pub(crate) mcp_host: Option<String>,
#[cfg(feature = "mcp")]
pub(crate) mcp_allowed_hosts: Option<Vec<Spanned<String>>>,
#[cfg(feature = "mcp")]
pub(crate) mcp_allowed_origins: Option<Vec<Spanned<String>>>,
}
#[cfg(test)]
@@ -1512,6 +1594,126 @@ mod tests {
assert_eq!(parsed.nu.log_level.as_ref().unwrap().item, "info");
}
#[cfg(feature = "mcp")]
fn items(values: Option<Vec<Spanned<String>>>) -> Vec<String> {
values
.expect("list should be set")
.into_iter()
.map(|v| v.item)
.collect()
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_allowed_hosts_accepts_comma_bracketed_and_repeated_forms() {
let args = vec![
OsString::from("nu"),
OsString::from("--mcp-allowed-hosts"),
OsString::from("localhost,example.com"),
OsString::from("--mcp-allowed-hosts=[a.test, b.test:8080]"),
OsString::from("--mcp-allowed-hosts"),
OsString::from("c.test"),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert_eq!(
items(parsed.nu.mcp_allowed_hosts),
vec![
"localhost",
"example.com",
"a.test",
"b.test:8080",
"c.test"
]
);
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_allowed_hosts_preserves_ipv6_authorities() {
let args = vec![
OsString::from("nu"),
OsString::from("--mcp-allowed-hosts=[::1]:8080"),
OsString::from("--mcp-allowed-hosts=[[::1]:9090, localhost]"),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert_eq!(
items(parsed.nu.mcp_allowed_hosts),
vec!["[::1]:8080", "[::1]:9090", "localhost"]
);
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_allowed_lists_accept_empty_input() {
let args = vec![
OsString::from("nu"),
OsString::from("--mcp-allowed-hosts=[]"),
OsString::from("--mcp-allowed-origins="),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert!(items(parsed.nu.mcp_allowed_hosts).is_empty());
assert!(items(parsed.nu.mcp_allowed_origins).is_empty());
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_allowed_origins_parses_list() {
let args = vec![
OsString::from("nu"),
OsString::from("--mcp-allowed-origins=[http://localhost:3000, https://example.com]"),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert_eq!(
items(parsed.nu.mcp_allowed_origins),
vec!["http://localhost:3000", "https://example.com"]
);
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_allowed_hosts_accepts_unquoted_shell_split_list() {
// `--mcp-allowed-hosts [[::1]:8080, localhost]` without quotes
let args = vec![
OsString::from("nu"),
OsString::from("--mcp-allowed-hosts"),
OsString::from("[[::1]:8080,"),
OsString::from("localhost]"),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert_eq!(
items(parsed.nu.mcp_allowed_hosts),
vec!["[::1]:8080", "localhost"]
);
}
#[test]
fn log_include_accepts_unquoted_shell_split_list() {
// `--log-include [error, warn]` without quotes
let args = vec![
OsString::from("nu"),
OsString::from("--log-include"),
OsString::from("[error,"),
OsString::from("warn]"),
];
let parsed = parse_cli_args(args).expect("should parse args");
assert_eq!(
parsed
.nu
.log_include
.expect("log include")
.into_iter()
.map(|v| v.item)
.collect::<Vec<_>>(),
vec!["error", "warn"]
);
}
#[test]
fn test_log_target_validation() {
let args = vec![
+10
View File
@@ -630,6 +630,16 @@ fn main() -> Result<()> {
Some("http") => nu_mcp::McpTransport::http(
parsed_nu_cli_args.mcp_host.clone(),
parsed_nu_cli_args.mcp_port,
)
.with_allowed_hosts(
parsed_nu_cli_args
.mcp_allowed_hosts
.map(|hosts| hosts.into_iter().map(|s| s.item).collect()),
)
.with_allowed_origins(
parsed_nu_cli_args
.mcp_allowed_origins
.map(|origins| origins.into_iter().map(|s| s.item).collect()),
),
_ => nu_mcp::McpTransport::Stdio,
};