Use shell to launch MCP and ACP servers (#42382)

`npx`, and any `npm install`-ed programs, exist as batch
scripts/PowerShell scripts on the PATH. We have to use a shell to launch
these programs.

Fixes https://github.com/zed-industries/zed/issues/41435
Closes https://github.com/zed-industries/zed/pull/42651


Release Notes:

- windows: Custom MCP and ACP servers installed through `npm` now launch
correctly.

---------

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
This commit is contained in:
John Tur 2025-12-10 12:08:37 -05:00 committed by GitHub
parent 8ee85eab3c
commit d83201256d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 389 additions and 43 deletions

1
Cargo.lock generated
View file

@ -3595,6 +3595,7 @@ dependencies = [
"settings",
"smol",
"tempfile",
"terminal",
"url",
"util",
]

View file

@ -9,6 +9,10 @@ use futures::io::BufReader;
use project::Project;
use project::agent_server_store::AgentServerCommand;
use serde::Deserialize;
use settings::Settings as _;
use task::ShellBuilder;
#[cfg(windows)]
use task::ShellKind;
use util::ResultExt as _;
use std::path::PathBuf;
@ -21,7 +25,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit
use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent};
use terminal::TerminalBuilder;
use terminal::terminal_settings::{AlternateScroll, CursorShape};
use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
#[derive(Debug, Error)]
#[error("Unsupported version")]
@ -86,9 +90,26 @@ impl AcpConnection {
is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Self> {
let mut child = util::command::new_smol_command(&command.path);
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
let builder = ShellBuilder::new(&shell, cfg!(windows));
#[cfg(windows)]
let kind = builder.kind();
let (cmd, args) = builder.build(Some(command.path.display().to_string()), &command.args);
let mut child = util::command::new_smol_command(cmd);
#[cfg(windows)]
if kind == ShellKind::Cmd {
use smol::process::windows::CommandExt;
for arg in args {
child.raw_arg(arg);
}
} else {
child.args(args);
}
#[cfg(not(windows))]
child.args(args);
child
.args(command.args.iter().map(|arg| arg.as_str()))
.envs(command.env.iter().flatten())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())

View file

@ -114,7 +114,6 @@ impl AgentServer for CustomAgentServer {
let default_model = self.default_model(cx);
let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx);
cx.spawn(async move |cx| {
let (command, root_dir, login) = store
.update(cx, |store, cx| {

View file

@ -33,6 +33,7 @@ smol.workspace = true
tempfile.workspace = true
url = { workspace = true, features = ["serde"] }
util.workspace = true
terminal.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View file

@ -8,9 +8,12 @@ use futures::{
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
};
use gpui::AsyncApp;
use settings::Settings as _;
use smol::channel;
use smol::process::Child;
use terminal::terminal_settings::TerminalSettings;
use util::TryFutureExt as _;
use util::shell_builder::ShellBuilder;
use crate::client::ModelContextServerBinary;
use crate::transport::Transport;
@ -28,9 +31,14 @@ impl StdioTransport {
working_directory: &Option<PathBuf>,
cx: &AsyncApp,
) -> Result<Self> {
let mut command = util::command::new_smol_command(&binary.executable);
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
let builder = ShellBuilder::new(&shell, cfg!(windows));
let (command, args) =
builder.build(Some(binary.executable.display().to_string()), &binary.args);
let mut command = util::command::new_smol_command(command);
command
.args(&binary.args)
.args(args)
.envs(binary.env.unwrap_or_default())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())

View file

@ -1344,7 +1344,7 @@ impl ToolchainLister for PythonToolchainProvider {
ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
ShellKind::PowerShell => None,
ShellKind::PowerShell | ShellKind::Pwsh => None,
ShellKind::Csh => None,
ShellKind::Tcsh => None,
ShellKind::Cmd => None,

View file

@ -411,11 +411,11 @@ impl ContextServerStore {
) {
self.stop_server(&id, cx).log_err();
}
let task = cx.spawn({
let id = server.id();
let server = server.clone();
let configuration = configuration.clone();
async move |this, cx| {
match server.clone().start(cx).await {
Ok(_) => {

View file

@ -31,7 +31,8 @@ use tempfile::TempDir;
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
shell::ShellKind,
shell::{Shell, ShellKind},
shell_builder::ShellBuilder,
};
pub(crate) struct SshRemoteConnection {
@ -1362,6 +1363,8 @@ fn build_command(
} else {
write!(exec, "{ssh_shell} -l")?;
};
let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
.build(Some(exec.clone()), &[]);
let mut args = Vec::new();
args.extend(ssh_args);
@ -1372,7 +1375,9 @@ fn build_command(
}
args.push("-t".into());
args.push(exec);
args.push(command);
args.extend(command_args);
Ok(CommandTemplate {
program: "ssh".into(),
args,
@ -1411,6 +1416,9 @@ mod tests {
"-p",
"2222",
"-t",
"/bin/fish",
"-i",
"-c",
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
]
);
@ -1443,6 +1451,9 @@ mod tests {
"-L",
"1:foo:2",
"-t",
"/bin/fish",
"-i",
"-c",
"cd && exec env INPUT_VA=val /bin/fish -l"
]
);

View file

@ -23,7 +23,8 @@ use std::{
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
shell::ShellKind,
shell::{Shell, ShellKind},
shell_builder::ShellBuilder,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
@ -453,8 +454,10 @@ impl RemoteConnection for WslRemoteConnection {
} else {
write!(&mut exec, "{} -l", self.shell)?;
}
let (command, args) =
ShellBuilder::new(&Shell::Program(self.shell.clone()), false).build(Some(exec), &[]);
let wsl_args = if let Some(user) = &self.connection_options.user {
let mut wsl_args = if let Some(user) = &self.connection_options.user {
vec![
"--distribution".to_string(),
self.connection_options.distro_name.clone(),
@ -463,9 +466,7 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(),
working_dir,
"--".to_string(),
self.shell.clone(),
"-c".to_string(),
exec,
command,
]
} else {
vec![
@ -474,11 +475,10 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(),
working_dir,
"--".to_string(),
self.shell.clone(),
"-c".to_string(),
exec,
command,
]
};
wsl_args.extend(args);
Ok(CommandTemplate {
program: "wsl.exe".to_string(),

View file

@ -56,7 +56,10 @@ pub enum ShellKind {
Tcsh,
Rc,
Fish,
/// Pre-installed "legacy" powershell for windows
PowerShell,
/// PowerShell 7.x
Pwsh,
Nushell,
Cmd,
Xonsh,
@ -238,6 +241,7 @@ impl fmt::Display for ShellKind {
ShellKind::Tcsh => write!(f, "tcsh"),
ShellKind::Fish => write!(f, "fish"),
ShellKind::PowerShell => write!(f, "powershell"),
ShellKind::Pwsh => write!(f, "pwsh"),
ShellKind::Nushell => write!(f, "nu"),
ShellKind::Cmd => write!(f, "cmd"),
ShellKind::Rc => write!(f, "rc"),
@ -260,7 +264,8 @@ impl ShellKind {
.to_string_lossy();
match &*program {
"powershell" | "pwsh" => ShellKind::PowerShell,
"powershell" => ShellKind::PowerShell,
"pwsh" => ShellKind::Pwsh,
"cmd" => ShellKind::Cmd,
"nu" => ShellKind::Nushell,
"fish" => ShellKind::Fish,
@ -279,7 +284,7 @@ impl ShellKind {
pub fn to_shell_variable(self, input: &str) -> String {
match self {
Self::PowerShell => Self::to_powershell_variable(input),
Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
Self::Cmd => Self::to_cmd_variable(input),
Self::Posix => input.to_owned(),
Self::Fish => input.to_owned(),
@ -407,8 +412,12 @@ impl ShellKind {
pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
match self {
ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
ShellKind::Cmd => vec![
"/S".to_owned(),
"/C".to_owned(),
format!("\"{combined_command}\""),
],
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Fish
@ -426,7 +435,7 @@ impl ShellKind {
pub const fn command_prefix(&self) -> Option<char> {
match self {
ShellKind::PowerShell => Some('&'),
ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
ShellKind::Nushell => Some('^'),
ShellKind::Posix
| ShellKind::Csh
@ -457,6 +466,7 @@ impl ShellKind {
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => ';',
@ -471,6 +481,7 @@ impl ShellKind {
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Pwsh
| ShellKind::PowerShell
| ShellKind::Xonsh => "&&",
ShellKind::Nushell | ShellKind::Elvish => ";",
@ -478,11 +489,10 @@ impl ShellKind {
}
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
shlex::try_quote(arg).ok().map(|arg| match self {
// If we are running in PowerShell, we want to take extra care when escaping strings.
// In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
match self {
ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
ShellKind::Cmd => Some(Self::quote_cmd(arg)),
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
@ -490,8 +500,173 @@ impl ShellKind {
| ShellKind::Fish
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => arg,
})
| ShellKind::Elvish => shlex::try_quote(arg).ok(),
}
}
fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
if arg.is_empty() {
return Cow::Borrowed("\"\"");
}
let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
if !needs_quoting {
return Cow::Borrowed(arg);
}
let mut result = String::with_capacity(arg.len() + 2);
if enclose {
result.push('"');
}
let chars: Vec<char> = arg.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\\' {
let mut num_backslashes = 0;
while i < chars.len() && chars[i] == '\\' {
num_backslashes += 1;
i += 1;
}
if i < chars.len() && chars[i] == '"' {
// Backslashes followed by quote: double the backslashes and escape the quote
for _ in 0..(num_backslashes * 2 + 1) {
result.push('\\');
}
result.push('"');
i += 1;
} else if i >= chars.len() {
// Trailing backslashes: double them (they precede the closing quote)
for _ in 0..(num_backslashes * 2) {
result.push('\\');
}
} else {
// Backslashes not followed by quote: output as-is
for _ in 0..num_backslashes {
result.push('\\');
}
}
} else if chars[i] == '"' {
// Quote not preceded by backslash: escape it
result.push('\\');
result.push('"');
i += 1;
} else {
result.push(chars[i]);
i += 1;
}
}
if enclose {
result.push('"');
}
Cow::Owned(result)
}
fn needs_quoting_powershell(s: &str) -> bool {
s.is_empty()
|| s.chars().any(|c| {
c.is_whitespace()
|| matches!(
c,
'"' | '`'
| '$'
| '&'
| '|'
| '<'
| '>'
| ';'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| ','
| '\''
| '@'
)
})
}
fn need_quotes_powershell(arg: &str) -> bool {
let mut quote_count = 0;
for c in arg.chars() {
if c == '"' {
quote_count += 1;
} else if c.is_whitespace() && (quote_count % 2 == 0) {
return true;
}
}
false
}
fn escape_powershell_quotes(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
result.push('\'');
for c in s.chars() {
if c == '\'' {
result.push('\'');
}
result.push(c);
}
result.push('\'');
result
}
pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
let ps_will_quote = Self::need_quotes_powershell(arg);
let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
if !Self::needs_quoting_powershell(arg) {
return crt_quoted;
}
Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
}
pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
if arg.is_empty() {
return Cow::Borrowed("''");
}
if !Self::needs_quoting_powershell(arg) {
return Cow::Borrowed(arg);
}
Cow::Owned(Self::escape_powershell_quotes(arg))
}
pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
let crt_quoted = Self::quote_windows(arg, true);
let needs_cmd_escaping = crt_quoted.contains('"')
|| crt_quoted.contains('%')
|| crt_quoted
.chars()
.any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')'));
if !needs_cmd_escaping {
return crt_quoted;
}
let mut result = String::with_capacity(crt_quoted.len() * 2);
for c in crt_quoted.chars() {
match c {
'^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
result.push('^');
result.push(c);
}
'%' => {
result.push_str("%%cd:~,%");
}
_ => result.push(c),
}
}
Cow::Owned(result)
}
/// Quotes the given argument if necessary, taking into account the command prefix.
@ -538,7 +713,7 @@ impl ShellKind {
match self {
ShellKind::Cmd => "",
ShellKind::Nushell => "overlay use",
ShellKind::PowerShell => ".",
ShellKind::PowerShell | ShellKind::Pwsh => ".",
ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
@ -558,6 +733,7 @@ impl ShellKind {
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => "clear",
@ -576,6 +752,7 @@ impl ShellKind {
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell
| ShellKind::Xonsh
| ShellKind::Elvish => true,
@ -605,7 +782,7 @@ mod tests {
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap()
.into_owned(),
"\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
"'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
);
}
@ -617,7 +794,113 @@ mod tests {
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap()
.into_owned(),
"\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
"^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
);
}
#[test]
fn test_try_quote_powershell_edge_cases() {
let shell_kind = ShellKind::PowerShell;
// Empty string
assert_eq!(
shell_kind.try_quote("").unwrap().into_owned(),
"'\"\"'".to_string()
);
// String without special characters (no quoting needed)
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
// String with spaces
assert_eq!(
shell_kind.try_quote("hello world").unwrap().into_owned(),
"'hello world'".to_string()
);
// String with dollar signs
assert_eq!(
shell_kind.try_quote("$variable").unwrap().into_owned(),
"'$variable'".to_string()
);
// String with backticks
assert_eq!(
shell_kind.try_quote("test`command").unwrap().into_owned(),
"'test`command'".to_string()
);
// String with multiple special characters
assert_eq!(
shell_kind
.try_quote("test `\"$var`\" end")
.unwrap()
.into_owned(),
"'test `\\\"$var`\\\" end'".to_string()
);
// String with backslashes and colon (path without spaces doesn't need quoting)
assert_eq!(
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
"C:\\path\\to\\file"
);
}
#[test]
fn test_try_quote_cmd_edge_cases() {
let shell_kind = ShellKind::Cmd;
// Empty string
assert_eq!(
shell_kind.try_quote("").unwrap().into_owned(),
"^\"^\"".to_string()
);
// String without special characters (no quoting needed)
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
// String with spaces
assert_eq!(
shell_kind.try_quote("hello world").unwrap().into_owned(),
"^\"hello world^\"".to_string()
);
// String with space and backslash (backslash not at end, so not doubled)
assert_eq!(
shell_kind.try_quote("path\\ test").unwrap().into_owned(),
"^\"path\\ test^\"".to_string()
);
// String ending with backslash (must be doubled before closing quote)
assert_eq!(
shell_kind.try_quote("test path\\").unwrap().into_owned(),
"^\"test path\\\\^\"".to_string()
);
// String ending with multiple backslashes (all doubled before closing quote)
assert_eq!(
shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
"^\"test path\\\\\\\\^\"".to_string()
);
// String with embedded quote (quote is escaped, backslash before it is doubled)
assert_eq!(
shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
"^\"test\\\\\\^\"quote^\"".to_string()
);
// String with multiple backslashes before embedded quote (all doubled)
assert_eq!(
shell_kind
.try_quote("test\\\\\"quote")
.unwrap()
.into_owned(),
"^\"test\\\\\\\\\\^\"quote^\"".to_string()
);
// String with backslashes not before quotes (path without spaces doesn't need quoting)
assert_eq!(
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
"C:\\path\\to\\file"
);
}

View file

@ -1,3 +1,5 @@
use std::borrow::Cow;
use crate::shell::get_system_shell;
use crate::shell::{Shell, ShellKind};
@ -42,7 +44,7 @@ impl ShellBuilder {
self.program.clone()
} else {
match self.kind {
ShellKind::PowerShell => {
ShellKind::PowerShell | ShellKind::Pwsh => {
format!("{} -C '{}'", self.program, command_to_use_in_label)
}
ShellKind::Cmd => {
@ -78,11 +80,27 @@ impl ShellBuilder {
task_args: &[String],
) -> (String, Vec<String>) {
if let Some(task_command) = task_command {
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
command.push(' ');
command.push_str(&self.kind.to_shell_variable(arg));
command
});
let task_command = self.kind.prepend_command_prefix(&task_command);
let task_command = if !task_args.is_empty() {
match self.kind.try_quote_prefix_aware(&task_command) {
Some(task_command) => task_command,
None => task_command,
}
} else {
task_command
};
let mut combined_command =
task_args
.iter()
.fold(task_command.into_owned(), |mut command, arg| {
command.push(' ');
let shell_variable = self.kind.to_shell_variable(arg);
command.push_str(&match self.kind.try_quote(&shell_variable) {
Some(shell_variable) => shell_variable,
None => Cow::Owned(shell_variable),
});
command
});
if self.redirect_stdin {
match self.kind {
ShellKind::Fish => {
@ -99,7 +117,7 @@ impl ShellBuilder {
combined_command.insert(0, '(');
combined_command.push_str(") </dev/null");
}
ShellKind::PowerShell => {
ShellKind::PowerShell | ShellKind::Pwsh => {
combined_command.insert_str(0, "$null | & {");
combined_command.push_str("}");
}
@ -115,6 +133,10 @@ impl ShellBuilder {
(self.program, self.args)
}
pub fn kind(&self) -> ShellKind {
self.kind
}
}
#[cfg(test)]
@ -144,7 +166,7 @@ mod test {
vec![
"-i",
"-c",
"echo $env.hello $env.world nothing --($env.something) $ ${test"
"^echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'"
]
);
}
@ -159,7 +181,7 @@ mod test {
.build(Some("echo".into()), &["nothing".to_string()]);
assert_eq!(program, "nu");
assert_eq!(args, vec!["-i", "-c", "(echo nothing) </dev/null"]);
assert_eq!(args, vec!["-i", "-c", "(^echo nothing) </dev/null"]);
}
#[test]

View file

@ -159,7 +159,7 @@ async fn capture_windows(
zed_path.display()
),
]),
ShellKind::PowerShell => cmd.args([
ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
"-NonInteractive",
"-NoProfile",
"-Command",