mirror of
https://github.com/nushell/nushell.git
synced 2026-10-28 17:05:03 +08:00
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:
1 parent
a2138a8e26
commit
9149ea5ede
3 files changed
+291
-24
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user