Skip to content

Commit e9dcc77

Browse files
authored
Merge pull request #5918 from Hmbown/fix/plugin-esm-multifile-launch
fix(mcp): launch multi-file Node ESM plugin servers by staged path on macOS
2 parents 12cce92 + 7e965fe commit e9dcc77

3 files changed

Lines changed: 216 additions & 13 deletions

File tree

crates/tui/src/mcp.rs

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -679,23 +679,46 @@ impl ReviewedPluginMcpSource {
679679
if Path::new(command).is_absolute() {
680680
launch.bind_command(staged_root, Path::new(command), &validated.file_hashes)?;
681681
}
682+
// Darwin cannot hand Node an ESM entry by descriptor when that entry
683+
// imports sibling modules: `/dev/fd/N` has no directory, so every
684+
// relative specifier resolves against `/dev/` (#5916). Such an entry
685+
// keeps its staged path — its bytes are still hash-verified by
686+
// `bind_file` below, and the siblings were always read by path.
687+
#[cfg(target_os = "macos")]
688+
let esm_entry_index = is_node_command(command)
689+
.then(|| {
690+
args.iter().position(|argument| {
691+
let path = Path::new(argument);
692+
path.is_absolute()
693+
&& path.starts_with(staged_root)
694+
&& path.extension().is_some_and(|extension| extension == "mjs")
695+
})
696+
})
697+
.flatten();
698+
#[cfg(target_os = "macos")]
699+
let esm_entry_keeps_path = esm_entry_index.is_some_and(|index| {
700+
esm_entry_has_module_siblings(
701+
staged_root,
702+
Path::new(&args[index]),
703+
&validated.file_hashes,
704+
)
705+
});
682706
for (index, argument) in args.iter().enumerate() {
683707
let path = Path::new(argument);
684708
if path.is_absolute() && path.starts_with(staged_root) && path.is_file() {
685-
launch.args[index] = launch.bind_file(staged_root, path, &validated.file_hashes)?;
709+
let bound = launch.bind_file(staged_root, path, &validated.file_hashes)?;
710+
#[cfg(target_os = "macos")]
711+
if esm_entry_keeps_path && esm_entry_index == Some(index) {
712+
continue;
713+
}
714+
launch.args[index] = bound;
686715
}
687716
}
688717
#[cfg(target_os = "macos")]
689-
if is_node_command(command) {
690-
let entry_index = args.iter().position(|argument| {
691-
let path = Path::new(argument);
692-
path.is_absolute()
693-
&& path.starts_with(staged_root)
694-
&& path.extension().is_some_and(|extension| extension == "mjs")
695-
});
696-
if let Some(entry_index) = entry_index {
697-
launch.args = node_esm_descriptor_args(&launch.args, entry_index);
698-
}
718+
if let Some(entry_index) = esm_entry_index
719+
&& !esm_entry_keeps_path
720+
{
721+
launch.args = node_esm_descriptor_args(&launch.args, entry_index);
699722
}
700723
if let Some(cwd) = cwd {
701724
if !cwd.starts_with(staged_root) {
@@ -759,6 +782,30 @@ impl ReviewedPluginMcpSource {
759782
}
760783
}
761784

785+
/// Whether a reviewed `.mjs` entry shares its stage with other module files.
786+
/// Such an entry must launch by path: Node resolves its relative imports
787+
/// against the entry's own URL, and a `/dev/fd/N` URL has no directory to
788+
/// resolve against (#5916). Manifests and data files do not count.
789+
#[cfg(target_os = "macos")]
790+
fn esm_entry_has_module_siblings(
791+
staged_root: &Path,
792+
entry: &Path,
793+
file_hashes: &std::collections::BTreeMap<PathBuf, String>,
794+
) -> bool {
795+
let Ok(entry) = entry.strip_prefix(staged_root) else {
796+
return false;
797+
};
798+
file_hashes.keys().any(|path| {
799+
path != entry
800+
&& path.extension().is_some_and(|extension| {
801+
matches!(
802+
extension.to_string_lossy().as_ref(),
803+
"mjs" | "js" | "cjs" | "node" | "wasm"
804+
)
805+
})
806+
})
807+
}
808+
762809
#[cfg(target_os = "macos")]
763810
fn is_node_command(command: &str) -> bool {
764811
Path::new(command)

crates/tui/src/mcp/stdio.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,17 @@ impl McpTransport for StdioTransport {
321321
}
322322
};
323323
if bytes == 0 {
324+
// Let the stderr drain task catch up before snapshotting, and
325+
// name the exit status: a reviewed plugin's stderr is never
326+
// retained, so the status is the only reason the operator
327+
// gets when the child dies before the handshake (#5916).
328+
tokio::task::yield_now().await;
329+
let exit = self.child.lock().await.try_wait().ok().flatten();
330+
let exit = exit.map_or_else(String::new, |status| format!(" ({status})"));
324331
if let Some(stderr) = format_stderr_context(&self.stderr_tail).await {
325-
anyhow::bail!("Stdio transport closed\n{stderr}");
332+
anyhow::bail!("Stdio transport closed{exit}\n{stderr}");
326333
}
327-
anyhow::bail!("Stdio transport closed");
334+
anyhow::bail!("Stdio transport closed{exit}");
328335
}
329336

330337
let line = String::from_utf8_lossy(&line_bytes);

crates/tui/src/mcp/tests.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,155 @@ connect_timeout = 2
11211121
assert_eq!(connection.tools()[0].name, "ready");
11221122
}
11231123

1124+
#[cfg(target_os = "macos")]
1125+
#[tokio::test]
1126+
async fn reviewed_multi_file_node_mjs_plugin_launches_by_staged_path() {
1127+
if std::process::Command::new("node")
1128+
.arg("--version")
1129+
.output()
1130+
.is_err()
1131+
{
1132+
eprintln!("skipping reviewed multi-file Node ESM launch test because node is unavailable");
1133+
return;
1134+
}
1135+
1136+
// The entry imports a sibling module, exactly like the computer-use
1137+
// bundle (#5916). Launched by descriptor, Node would resolve `./lib/...`
1138+
// against `/dev/` and the child would die before the handshake.
1139+
let dir = tempfile::tempdir().unwrap();
1140+
let plugins_root = dir.path().join("plugins");
1141+
let plugin_base = plugins_root.join("node-esm-multi");
1142+
fs::create_dir_all(plugin_base.join("mcp")).unwrap();
1143+
fs::create_dir_all(plugin_base.join("lib")).unwrap();
1144+
fs::write(
1145+
plugin_base.join("lib").join("reply.mjs"),
1146+
r#"import path from 'node:path';
1147+
import url from 'node:url';
1148+
export const TOOL = 'ready-from-sibling';
1149+
export const ENTRY_DIR = path.basename(path.dirname(url.fileURLToPath(import.meta.url)));
1150+
"#,
1151+
)
1152+
.unwrap();
1153+
fs::write(
1154+
plugin_base.join("mcp").join("server.mjs"),
1155+
r#"import readline from 'node:readline';
1156+
import { TOOL, ENTRY_DIR } from '../lib/reply.mjs';
1157+
const lines = readline.createInterface({ input: process.stdin });
1158+
lines.on('line', (line) => {
1159+
const request = JSON.parse(line);
1160+
if (request.id === undefined) return;
1161+
let result;
1162+
if (request.method === 'initialize') {
1163+
result = {
1164+
protocolVersion: '2025-06-18',
1165+
capabilities: { tools: {} },
1166+
serverInfo: { name: 'node-esm-multi', version: '1.0.0' }
1167+
};
1168+
} else if (request.method === 'tools/list') {
1169+
result = {
1170+
tools: [{ name: TOOL, description: ENTRY_DIR, inputSchema: { type: 'object' } }]
1171+
};
1172+
} else {
1173+
result = {};
1174+
}
1175+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
1176+
});
1177+
"#,
1178+
)
1179+
.unwrap();
1180+
fs::write(
1181+
plugin_base.join("plugin.toml"),
1182+
r#"
1183+
schema_version = 1
1184+
[plugin]
1185+
name = "node-esm-multi"
1186+
version = "1.0.0"
1187+
1188+
[mcp_servers.local]
1189+
command = "node"
1190+
args = ["mcp/server.mjs"]
1191+
connect_timeout = 2
1192+
"#,
1193+
)
1194+
.unwrap();
1195+
1196+
let discovery = crate::plugins::discovery::DiscoveryConfig {
1197+
workspace: dir.path().join("project"),
1198+
user_plugins_dir: plugins_root,
1199+
workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
1200+
builtin_plugin_dirs: Vec::new(),
1201+
state_path: dir.path().join("plugin-state/state.json"),
1202+
};
1203+
let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1204+
registry.trust("node-esm-multi").unwrap();
1205+
registry.enable("node-esm-multi").unwrap();
1206+
let active = registry.active_plugins()[0].clone();
1207+
let authority = registry.authority_for("node-esm-multi").unwrap();
1208+
let merged = merge_plugin_mcp_servers_from_plugins(
1209+
McpConfig::default(),
1210+
vec![("node-esm-multi".to_string(), active, authority)],
1211+
)
1212+
.unwrap();
1213+
let mut pool = McpPool::new(merged);
1214+
1215+
let connection = pool
1216+
.get_or_connect("plugin-14-node-esm-multi-local")
1217+
.await
1218+
.unwrap();
1219+
assert_eq!(connection.tools().len(), 1);
1220+
assert_eq!(connection.tools()[0].name, "ready-from-sibling");
1221+
// The sibling resolved from the staged tree, not from `/dev/`.
1222+
assert_eq!(connection.tools()[0].description.as_deref(), Some("lib"));
1223+
}
1224+
1225+
#[cfg(target_os = "macos")]
1226+
#[test]
1227+
fn esm_entry_with_module_siblings_keeps_its_staged_path() {
1228+
use std::collections::BTreeMap;
1229+
let staged_root = Path::new("/stage/plugin");
1230+
let entry = staged_root.join("mcp/server.mjs");
1231+
let hash = |paths: &[&str]| {
1232+
paths
1233+
.iter()
1234+
.map(|path| (PathBuf::from(path), "h".to_string()))
1235+
.collect::<BTreeMap<_, _>>()
1236+
};
1237+
// Manifests, docs, and data files are not modules.
1238+
assert!(!esm_entry_has_module_siblings(
1239+
staged_root,
1240+
&entry,
1241+
&hash(&[
1242+
"mcp/server.mjs",
1243+
"plugin.json",
1244+
"mcp.json",
1245+
"README.md",
1246+
"skills/a/SKILL.md"
1247+
]),
1248+
));
1249+
for sibling in [
1250+
"src/tools.mjs",
1251+
"lib/x.js",
1252+
"lib/x.cjs",
1253+
"native/x.node",
1254+
"wasm/x.wasm",
1255+
] {
1256+
assert!(
1257+
esm_entry_has_module_siblings(
1258+
staged_root,
1259+
&entry,
1260+
&hash(&["mcp/server.mjs", "plugin.json", sibling]),
1261+
),
1262+
"{sibling} must force a path launch"
1263+
);
1264+
}
1265+
// An entry outside the stage never qualifies.
1266+
assert!(!esm_entry_has_module_siblings(
1267+
Path::new("/elsewhere"),
1268+
&entry,
1269+
&hash(&["mcp/server.mjs", "src/tools.mjs"]),
1270+
));
1271+
}
1272+
11241273
#[cfg(target_os = "macos")]
11251274
#[test]
11261275
fn node_esm_descriptor_launch_keeps_options_argv_shape_and_script_arguments() {

0 commit comments

Comments
 (0)