[APP-4249] Set default cwd for project-scoped MCP servers (#9239)
## Description ### Motivation File-based stdio MCP servers spawned from a project's `.mcp.json` (e.g. `pnpm run mcp:foo`, or anything using `./tooling_scripts/...`) fail with: ``` Transport creation error: No such file or directory (os error 2) ``` Root cause: when the user's `.mcp.json` doesn't include a `working_directory`, Warp's spawner inherits whatever cwd Warp was launched from rather than the directory the config was discovered in. Two failure modes share this same root cause: - **Repo-relative commands/args** (e.g. `./tooling_scripts/foo`, `node ./src/server.js`) can't be resolved from outside the repo, so `execvp` returns `ENOENT`. - **Workspace-aware launchers** like `pnpm`/`npm`/`yarn` walk up from cwd looking for `package.json` (and workspace manifests). When cwd isn't inside the repo, they bail out before launching the requested script — surfacing as `ENOENT` from the spawner's perspective. `working_directory` is a Warp/VS-Code-style extension; the canonical Anthropic/Cursor MCP schemas don't include it, so most `.mcp.json` files in the wild don't set it. ### Implementation - New helper `FileBasedMCPManager::spawn_root_for_installation(uuid)` returning the discovery root for any file-based install: the repo root for project-scoped configs, the home directory for global configs (Warp and third-party). Returns `None` for non-file-based installs (e.g. cloud-templated ones), leaving them unaffected. Global Warp installs are remapped from `~/.warp/` (Warp internal state) to `~` so all global installs share a consistent cwd. - In `TemplatableMCPServerManager::spawn_server_impl`, default `cli_server.cwd_parameter` from this helper when unset. Single funnel — covers both auto-spawn and the manual UI opt-in path. User-supplied `working_directory` always wins. - ENOENT-aware logging at the spawn site: when `TokioChildProcess::spawn()` fails with `NotFound`, the MCP log file now spells out the server name, the missing executable, the cwd we used, and a hint pointing the user at `working_directory`. The user-surfaced error string is unchanged. - Documented the new defaulting behavior and `working_directory` override in `resources/bundled/skills/add-mcp-server/SKILL.md`. ## Testing Verified spawn-site behavior manually. Demo [here](https://www.loom.com/share/ec01d98ae9114433b8ae87f5f17adfd4)! - Added `test_parse_cli_server_preserves_explicit_working_directory` in `mod_test.rs` to lock in that an explicitly-set `working_directory` round-trips through parsing and won't be clobbered by the new defaulting logic. - Skipped a unit test for the new helper itself — it's a thin lookup over `file_based_servers_by_root` and the sorted-pick policy is straightforward enough to validate manually. - Spawn-site behavior (cwd actually applied, ENOENT log emission) verified manually against a `pnpm`\-based repro. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode ## Changelog Entries for Stable CHANGELOG-BUG-FIX: Project-scoped file-based MCP servers now spawn from the repo root by default (and global ones from `~`), so configs with relative commands/args (and workspace launchers like `pnpm`/`npm`) work without an explicit `working_directory`.
This commit is contained in:
@@ -404,6 +404,39 @@ impl FileBasedMCPManager {
|
||||
.sorted()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the directory a file-based MCP installation should be spawned from
|
||||
/// when its config does not specify `working_directory`.
|
||||
///
|
||||
/// The spawn root is the directory the config was discovered in, with one
|
||||
/// exception: global Warp installs are discovered in `~/.warp/` (Warp's data
|
||||
/// dir) which isn't a useful cwd for spawned processes, so they are remapped
|
||||
/// to the home directory instead.
|
||||
/// - Project-scoped installations: the repo root.
|
||||
/// - Global installations (`~/.warp/.mcp.json`, `~/.claude.json`, etc.): the
|
||||
/// home directory.
|
||||
///
|
||||
/// If the installation is referenced from multiple roots, the lexicographically
|
||||
/// smallest is returned for determinism. Returns `None` for installations that
|
||||
/// are not tracked by `FileBasedMCPManager` (e.g. cloud-templated installs).
|
||||
pub fn spawn_root_for_installation(&self, uuid: Uuid) -> Option<PathBuf> {
|
||||
let hash = self.get_hash_by_uuid(uuid)?;
|
||||
let discovery_root = self
|
||||
.file_based_servers_by_root
|
||||
.iter()
|
||||
.filter(|(_, provider_map)| provider_map.values().any(|hashes| hashes.contains(&hash)))
|
||||
.map(|(root, _)| root.clone())
|
||||
.sorted()
|
||||
.next()?;
|
||||
|
||||
// Global Warp installs live under `~/.warp/`, which is internal Warp state
|
||||
// rather than a meaningful working directory. Map them to the home dir so
|
||||
// all global installs (Warp and third-party) share a consistent cwd.
|
||||
if discovery_root == warp_data_dir() {
|
||||
return dirs::home_dir().or(Some(discovery_root));
|
||||
}
|
||||
Some(discovery_root)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum FileBasedMCPManagerEvent {
|
||||
|
||||
@@ -560,6 +560,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cli_server_preserves_explicit_working_directory() {
|
||||
// An explicitly-set `working_directory` in a `.mcp.json`-style config must
|
||||
// round-trip into `CLIServer.cwd_parameter` so the file-based spawner does
|
||||
// not overwrite it with the discovery-root default.
|
||||
let json = r#"{
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["./tooling/mcp/server.js"],
|
||||
"working_directory": "/explicit/override/path"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let servers = MCPServer::from_user_json(json).expect("Failed to parse MCP servers");
|
||||
assert_eq!(servers.len(), 1);
|
||||
|
||||
let TransportType::CLIServer(cli_server) = &servers[0].transport_type else {
|
||||
panic!("Expected CLIServer transport type");
|
||||
};
|
||||
assert_eq!(
|
||||
cli_server.cwd_parameter.as_deref(),
|
||||
Some("/explicit/override/path"),
|
||||
"Explicit working_directory must be preserved through parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_templatable_cli_server_without_args_and_resolve_json() {
|
||||
// Templatable MCP configs should work without an explicit "args" field.
|
||||
|
||||
@@ -782,6 +782,20 @@ impl TemplatableMCPServerManager {
|
||||
value: execution_path,
|
||||
},
|
||||
);
|
||||
|
||||
// For file-based MCP installations without an explicit `working_directory`,
|
||||
// default the spawn cwd to the directory the config was discovered in
|
||||
// (repo root for project-scoped configs, ~/.warp/ or ~ for globals). This
|
||||
// matches user expectations for repo-relative commands in `.mcp.json`.
|
||||
// Cloud-templated installations (lookup returns None) are unaffected and
|
||||
// continue to inherit Warp's process cwd.
|
||||
if cli_server.cwd_parameter.is_none() {
|
||||
if let Some(spawn_root) =
|
||||
FileBasedMCPManager::as_ref(ctx).spawn_root_for_installation(installation_uuid)
|
||||
{
|
||||
cli_server.cwd_parameter = Some(spawn_root.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let executor = ctx.background_executor().clone();
|
||||
@@ -1742,6 +1756,11 @@ async fn spawn_server(
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the command and configured cwd for diagnostics before they're
|
||||
// moved into the Command builder closure.
|
||||
let command_for_log = command.clone();
|
||||
let cwd_for_log = cli_server.cwd_parameter.clone();
|
||||
|
||||
// Try to spawn the child process.
|
||||
let (transport, stderr) = rmcp::transport::TokioChildProcess::builder(
|
||||
tokio::process::Command::new(command).configure(|cmd| {
|
||||
@@ -1768,6 +1787,17 @@ async fn spawn_server(
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
let cwd_display = cwd_for_log
|
||||
.as_deref()
|
||||
.unwrap_or("<inherited from Warp's process cwd>");
|
||||
logger.log(format!(
|
||||
"[error] MCP: Failed to spawn '{server_name}': command '{command_for_log}' \
|
||||
not found (cwd: {cwd_display}). If your MCP server depends on a specific \
|
||||
working directory, set the `working_directory` field in your config to \
|
||||
override the default."
|
||||
));
|
||||
}
|
||||
rmcp::RmcpError::transport_creation::<rmcp::transport::TokioChildProcess>(err)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -61,6 +61,24 @@ Check whether the target config file exists.
|
||||
}
|
||||
```
|
||||
|
||||
By default, Warp spawns stdio servers from the directory the config was discovered in:
|
||||
- Project-scoped configs (`{repo_root}/.warp/.mcp.json`) run from the repo root.
|
||||
- Global configs (`~/.warp/.mcp.json`, `~/.claude.json`, etc.) run from the home directory.
|
||||
|
||||
If the server's `command` or `args` are relative paths (e.g. `./tooling/mcp/server.js`) or the server expects a specific cwd, set `working_directory` to override the default:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["./tooling/mcp/server.js"],
|
||||
"working_directory": "/absolute/path/to/repo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URL-based server (HTTP/SSE streaming transport)
|
||||
|
||||
```json
|
||||
|
||||
Reference in New Issue
Block a user