feat(mcp): detect mcp servers installed via bun global

This commit is contained in:
弈秋
2026-09-23 16:04:34 +08:00
committed by GitHub
parent 18f144f11c
commit 6869c7fd6e
11 changed files with 412 additions and 51 deletions
+1 -1
View File
@@ -7904,7 +7904,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "MCP giriş siyasətini yadda saxlamaq mümkün olmadı: {error}",
mcpPolicyLoadFailed: "MCP giriş siyasətini yükləmək mümkün olmadı: {error}",
mcpDetectionTiming: "Bu səhifə açıldıqda DBX avtomatik yoxlayır; yeniləmək üçün Yenidən yoxla düyməsindən istifadə edin.",
mcpNpmBoundary: "DBX MCP-nin vəziyyətini yoxlayır; quraşdırma, yeniləmə və quraşdırmanın silinməsi aşkarlanmış npm və ya pnpm icra mühiti vasitəsilə həyata keçirilir.",
mcpNpmBoundary: "DBX MCP-nin vəziyyətini yoxlayır; quraşdırma, yeniləmə və quraşdırmanın silinməsi aşkarlanmış npm, pnpm və ya bun icra mühiti vasitəsilə həyata keçirilir.",
mcpRefresh: "Yenidən yoxla",
mcpGuide: "MCP təlimatı",
mcpAuthTitle: "MCP səlahiyyətləndirməsi",
+1 -1
View File
@@ -8447,7 +8447,7 @@ export default {
mcpPolicySaveFailed: "Failed to save the MCP access policy: {error}",
mcpPolicyLoadFailed: "Failed to load the MCP access policy: {error}",
mcpDetectionTiming: "DBX checks automatically when this page opens; use Check again to refresh.",
mcpNpmBoundary: "DBX checks MCP status; installation, upgrades, and uninstallation run through the detected npm or pnpm runtime.",
mcpNpmBoundary: "DBX checks MCP status; installation, upgrades, and uninstallation run through the detected npm, pnpm, or bun runtime.",
mcpRefresh: "Check again",
mcpGuide: "MCP guide",
mcpAuthTitle: "MCP authorization",
+1 -1
View File
@@ -7780,7 +7780,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "No se pudo guardar la política de acceso MCP: {error}",
mcpPolicyLoadFailed: "No se pudo cargar la política de acceso MCP: {error}",
mcpDetectionTiming: "DBX comprueba automáticamente al abrir esta página; usa Comprobar de nuevo para actualizar.",
mcpNpmBoundary: "DBX solo comprueba y explica el estado de MCP; la instalación y actualización siguen usando npm.",
mcpNpmBoundary: "DBX solo comprueba y explica el estado de MCP; la instalación y actualización siguen usando npm, pnpm o bun.",
mcpRefresh: "Comprobar de nuevo",
mcpGuide: "Guía MCP",
mcpAuthTitle: "Autorización de MCP",
+1 -1
View File
@@ -7779,7 +7779,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "Impossibile salvare la policy di accesso MCP: {error}",
mcpPolicyLoadFailed: "Impossibile caricare la policy di accesso MCP: {error}",
mcpDetectionTiming: "DBX esegue la verifica automaticamente all'apertura di questa pagina; usa di nuovo Verifica per aggiornare.",
mcpNpmBoundary: "DBX verifica e spiega solo lo stato di MCP; l'installazione e gli aggiornamenti vengono comunque eseguiti tramite npm.",
mcpNpmBoundary: "DBX verifica e spiega solo lo stato di MCP; l'installazione e gli aggiornamenti vengono comunque eseguiti tramite npm, pnpm o bun.",
mcpRefresh: "Verifica di nuovo",
mcpGuide: "Guida MCP",
mcpAuthTitle: "Autorizzazione MCP",
+1 -1
View File
@@ -7783,7 +7783,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "MCPアクセスポリシーを保存できませんでした: {error}",
mcpPolicyLoadFailed: "MCPアクセスポリシーを読み込めませんでした: {error}",
mcpDetectionTiming: "このページを開くとDBXが自動的に確認します。更新するには再確認を使用してください。",
mcpNpmBoundary: "DBXはMCPステータスの確認と説明のみを行います。インストールとアップグレードはnpmを通じて実行されます。",
mcpNpmBoundary: "DBXはMCPステータスの確認と説明のみを行います。インストールとアップグレードはnpm、pnpm、またはbunを通じて実行されます。",
mcpRefresh: "再確認",
mcpGuide: "MCPガイド",
mcpAuthTitle: "MCP 権限設定",
+1 -1
View File
@@ -7558,7 +7558,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "MCP 접근 정책 저장 실패: {error}",
mcpPolicyLoadFailed: "MCP 접근 정책 불러오기 실패: {error}",
mcpDetectionTiming: "DBX는 이 페이지를 열 때 자동으로 확인합니다. 새로고침하려면 다시 확인을 사용하세요.",
mcpNpmBoundary: "DBX는 MCP 상태를 확인하고 설명만 합니다. 설치와 업그레이드는 여전히 npm을 통해 실행됩니다.",
mcpNpmBoundary: "DBX는 MCP 상태를 확인하고 설명만 합니다. 설치와 업그레이드는 여전히 npm, pnpm 또는 bun을 통해 실행됩니다.",
mcpRefresh: "다시 확인",
mcpGuide: "MCP 가이드",
mcpAuthTitle: "MCP 권한 설정",
+1 -1
View File
@@ -7781,7 +7781,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "Falha ao salvar a política de acesso MCP: {error}",
mcpPolicyLoadFailed: "Falha ao carregar a política de acesso MCP: {error}",
mcpDetectionTiming: "O DBX verifica automaticamente quando esta página é aberta; use Verificar novamente para atualizar.",
mcpNpmBoundary: "O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm.",
mcpNpmBoundary: "O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm, pnpm ou bun.",
mcpRefresh: "Verificar novamente",
mcpGuide: "Guia do MCP",
mcpAuthTitle: "Autorização do MCP",
+1 -1
View File
@@ -7802,7 +7802,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "MCP erişim politikası kaydedilemedi: {error}",
mcpPolicyLoadFailed: "MCP erişim politikası yüklenemedi: {error}",
mcpDetectionTiming: "DBX bu sayfa açıldığında otomatik denetim yapar; yenilemek için Yeniden denetle'yi kullanın.",
mcpNpmBoundary: "DBX, MCP durumunu denetler; kurulum, yükseltme ve kaldırma işlemleri algılanan npm ya da pnpm çalışma zamanı üzerinden yürür.",
mcpNpmBoundary: "DBX, MCP durumunu denetler; kurulum, yükseltme ve kaldırma işlemleri algılanan npm, pnpm ya da bun çalışma zamanı üzerinden yürür.",
mcpRefresh: "Yeniden denetle",
mcpGuide: "MCP kılavuzu",
mcpAuthTitle: "MCP yetkilendirmesi",
+1 -1
View File
@@ -8420,7 +8420,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "保存 MCP 访问策略失败:{error}",
mcpPolicyLoadFailed: "加载 MCP 访问策略失败:{error}",
mcpDetectionTiming: "打开此页时会自动检测,点击重新检查可刷新。",
mcpNpmBoundary: "DBX 负责检查 MCP 状态;安装、升级和卸载由检测到的 npm 或 pnpm 运行环境执行。",
mcpNpmBoundary: "DBX 负责检查 MCP 状态;安装、升级和卸载由检测到的 npm、pnpm 或 bun 运行环境执行。",
mcpRefresh: "重新检查",
mcpGuide: "MCP 指南",
mcpAuthTitle: "MCP 授权设置",
+1 -1
View File
@@ -7142,7 +7142,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "儲存 MCP 存取策略失敗:{error}",
mcpPolicyLoadFailed: "載入 MCP 存取策略失敗:{error}",
mcpDetectionTiming: "打開此頁時會自動檢測,點擊重新檢查可刷新。",
mcpNpmBoundary: "DBX 只檢測和提示 MCP 狀態;安裝與升級仍由 npm 完成。",
mcpNpmBoundary: "DBX 只檢測和提示 MCP 狀態;安裝與升級仍由 npm、pnpm 或 bun 完成。",
mcpRefresh: "重新檢查",
mcpGuide: "MCP 指南",
mcpAuthTitle: "MCP 授權設定",
+402 -41
View File
@@ -13,6 +13,8 @@ const MCP_INSTALL_COMMAND: &str = "npm install -g @dbx-app/mcp-server@latest";
const MCP_PNPM_UPDATE_COMMAND: &str = "pnpm update -g @dbx-app/mcp-server";
const MCP_UNINSTALL_COMMAND: &str = "npm uninstall -g @dbx-app/mcp-server";
const MCP_PNPM_UNINSTALL_COMMAND: &str = "pnpm remove -g @dbx-app/mcp-server";
const MCP_BUN_UPDATE_COMMAND: &str = "bun add -g @dbx-app/mcp-server@latest";
const MCP_BUN_UNINSTALL_COMMAND: &str = "bun remove -g @dbx-app/mcp-server";
const MCP_MIN_NODE_VERSION: NodeVersion = NodeVersion { major: 18, minor: 18, patch: 0 };
const MCP_MIN_NODE_VERSION_REQUIREMENT: &str = ">=18.18.0";
const SHELL_COMMAND_MARKER: &str = "__DBX_MCP_COMMAND_OUTPUT_START__";
@@ -68,6 +70,7 @@ enum McpPackageManager {
Npm,
Pnpm { command_path: PathBuf, pnpm_home: PathBuf, global_dir: PathBuf },
PnpmUnavailable { pnpm_home: PathBuf, global_dir: PathBuf },
Bun { command_path: Option<PathBuf>, bun_install: PathBuf, global_modules_root: PathBuf },
Unmanaged { launcher_dir: PathBuf },
}
@@ -163,6 +166,7 @@ impl NodeRuntime {
fn update_command(&self) -> &'static str {
match self.mcp_installation.as_ref().map(|installation| &installation.package_manager) {
Some(McpPackageManager::Pnpm { .. } | McpPackageManager::PnpmUnavailable { .. }) => MCP_PNPM_UPDATE_COMMAND,
Some(McpPackageManager::Bun { .. }) => MCP_BUN_UPDATE_COMMAND,
_ => MCP_INSTALL_COMMAND,
}
}
@@ -172,6 +176,7 @@ impl NodeRuntime {
Some(McpPackageManager::Pnpm { .. } | McpPackageManager::PnpmUnavailable { .. }) => {
MCP_PNPM_UNINSTALL_COMMAND
}
Some(McpPackageManager::Bun { .. }) => MCP_BUN_UNINSTALL_COMMAND,
_ => MCP_UNINSTALL_COMMAND,
}
}
@@ -190,7 +195,7 @@ impl NodeRuntime {
global_dir,
],
&self.node_launcher_path,
pnpm_home,
("PNPM_HOME", pnpm_home),
)
}
Some(McpPackageManager::PnpmUnavailable { pnpm_home, global_dir }) => Err(format!(
@@ -199,6 +204,20 @@ impl NodeRuntime {
global_dir.display(),
pnpm_home.display()
)),
Some(McpPackageManager::Bun { command_path: Some(command_path), bun_install, .. }) => {
run_package_manager_command(
command_path,
&[OsString::from("add"), OsString::from("-g"), OsString::from("@dbx-app/mcp-server@latest")],
&self.node_launcher_path,
("BUN_INSTALL", bun_install),
)
}
Some(McpPackageManager::Bun { command_path: None, bun_install, global_modules_root }) => Err(format!(
"Cannot safely update {} in {} because no bun executable was found next to its launcher in {}.",
MCP_PACKAGE_NAME,
global_modules_root.display(),
bun_install.join("bin").display()
)),
Some(McpPackageManager::Unmanaged { launcher_dir }) => Err(format!(
"Cannot safely update {} because the package manager for the launcher in {} could not be verified.",
MCP_PACKAGE_NAME,
@@ -226,7 +245,7 @@ impl NodeRuntime {
global_dir,
],
&self.node_launcher_path,
pnpm_home,
("PNPM_HOME", pnpm_home),
)
}
McpPackageManager::PnpmUnavailable { pnpm_home, global_dir } => Err(format!(
@@ -235,6 +254,20 @@ impl NodeRuntime {
global_dir.display(),
pnpm_home.display()
)),
McpPackageManager::Bun { command_path: Some(command_path), bun_install, .. } => {
run_package_manager_command(
command_path,
&[OsString::from("remove"), OsString::from("-g"), OsString::from(MCP_PACKAGE_NAME)],
&self.node_launcher_path,
("BUN_INSTALL", bun_install),
)
}
McpPackageManager::Bun { command_path: None, bun_install, global_modules_root } => Err(format!(
"Cannot safely uninstall {} from {} because no bun executable was found next to its launcher in {}.",
MCP_PACKAGE_NAME,
global_modules_root.display(),
bun_install.join("bin").display()
)),
McpPackageManager::Unmanaged { launcher_dir } => Err(format!(
"Cannot safely uninstall {} because the package manager for the launcher in {} could not be verified.",
MCP_PACKAGE_NAME,
@@ -271,36 +304,12 @@ pub async fn check_mcp_server_status(app: AppHandle) -> Result<McpServerStatus,
.as_deref()
.zip(latest_version.as_deref())
.is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current));
let error = match (runtime.as_ref(), installation) {
(None, _) => {
Some(format!("Unable to resolve a compatible Node.js ({}) and npm runtime.", MCP_MIN_NODE_VERSION_REQUIREMENT))
}
(
Some(_),
Some(McpInstallation {
package_manager: McpPackageManager::PnpmUnavailable { pnpm_home, global_dir },
..
}),
) => {
Some(format!(
"{} is installed in {}, but automatic update and uninstall are disabled because pnpm was not found in {}.",
MCP_PACKAGE_NAME,
global_dir.display(),
pnpm_home.display()
))
}
(
Some(_),
Some(McpInstallation {
package_manager: McpPackageManager::Unmanaged { launcher_dir },
..
}),
) => Some(format!(
"{} is installed, but automatic update and uninstall are disabled because the package manager for {} could not be verified.",
MCP_PACKAGE_NAME,
launcher_dir.display()
let error = match runtime.as_ref() {
None => Some(format!(
"Unable to resolve a compatible Node.js ({}) and npm runtime.",
MCP_MIN_NODE_VERSION_REQUIREMENT
)),
_ => None,
Some(_) => mcp_status_installation_error(installation),
};
Ok(McpServerStatus {
@@ -809,6 +818,32 @@ fn mcp_installation_status_fields(runtime: Option<&NodeRuntime>) -> McpInstallat
}
}
/// Status-level companion of the update/uninstall guards: explains why automatic
/// management is disabled for a detected installation.
fn mcp_status_installation_error(installation: Option<&McpInstallation>) -> Option<String> {
let package_manager = installation.map(|installation| &installation.package_manager)?;
match package_manager {
McpPackageManager::PnpmUnavailable { pnpm_home, global_dir } => Some(format!(
"{} is installed in {}, but automatic update and uninstall are disabled because pnpm was not found in {}.",
MCP_PACKAGE_NAME,
global_dir.display(),
pnpm_home.display()
)),
McpPackageManager::Bun { command_path: None, bun_install, global_modules_root } => Some(format!(
"{} is installed in {}, but automatic update and uninstall are disabled because bun was not found in {}.",
MCP_PACKAGE_NAME,
global_modules_root.display(),
bun_install.join("bin").display()
)),
McpPackageManager::Unmanaged { launcher_dir } => Some(format!(
"{} is installed, but automatic update and uninstall are disabled because the package manager for {} could not be verified.",
MCP_PACKAGE_NAME,
launcher_dir.display()
)),
_ => None,
}
}
fn quoted_values(line: &str) -> Vec<&str> {
line.split('"').skip(1).step_by(2).collect()
}
@@ -863,7 +898,7 @@ fn run_package_manager_command(
command_path: &Path,
args: &[OsString],
node_launcher_path: &Path,
pnpm_home: &Path,
package_home_env: (&str, &Path),
) -> Result<CommandOutput, String> {
let mut command = dbx_core::process::new_std_command(command_path);
command.args(args);
@@ -877,7 +912,7 @@ fn run_package_manager_command(
if let Ok(path) = env::join_paths(paths) {
command.env("PATH", path);
}
command.env("PNPM_HOME", pnpm_home);
command.env(package_home_env.0, package_home_env.1);
command_output_from_process(command)
}
@@ -969,7 +1004,9 @@ fn mcp_package_from_command_dir(dir: &Path) -> Option<LocatedMcpPackage> {
fn mcp_package_from_command_path(command_path: &Path) -> Option<LocatedMcpPackage> {
let bin_path = canonical_runtime_path(command_path)?;
let script_path = node_script_from_launcher(&bin_path)?;
let Some(script_path) = node_script_from_launcher(&bin_path) else {
return bun_exe_shim_package(&bin_path);
};
let (package_root, package) = mcp_package_from_script(&script_path)?;
let launcher_dir = bin_path.parent()?.to_path_buf();
let package_manager = pnpm_global_dir(&package_root, &launcher_dir)
@@ -982,6 +1019,7 @@ fn mcp_package_from_command_path(command_path: &Path) -> Option<LocatedMcpPackag
})
.unwrap_or(McpPackageManager::PnpmUnavailable { pnpm_home: launcher_dir.clone(), global_dir })
})
.or_else(|| bun_package_manager(&package_root, &launcher_dir))
.unwrap_or(McpPackageManager::Unmanaged { launcher_dir });
Some(LocatedMcpPackage { package_root, package, bin_path: Some(bin_path), package_manager })
}
@@ -1025,6 +1063,84 @@ fn pnpm_command_near(dir: &Path) -> Option<PathBuf> {
})
}
/// bun installs global packages into `<bun_install>/install/global/node_modules`
/// (a flat layout like npm) and links their bins into `<bun_install>/bin`.
/// Recognise that layout from the resolved package root so a bun-installed
/// server is attributed to bun (and can be updated/uninstalled) instead of
/// falling back to `Unmanaged`.
fn bun_package_manager(package_root: &Path, launcher_dir: &Path) -> Option<McpPackageManager> {
let global_modules_root = bun_global_modules_root(package_root)?;
let bun_install = global_modules_root
.ancestors()
.find(|ancestor| ancestor.file_name().is_some_and(|name| name.eq_ignore_ascii_case("install")))?
.parent()?
.to_path_buf();
// bun symlinks its global bins (POSIX) or writes shims (Windows), so the
// executable lives under `<bun_install>/bin` regardless of where the resolved
// launcher ended up. Prefer that, then fall back to the launcher directory.
let command_path = bun_command_near(&bun_install.join("bin")).or_else(|| bun_command_near(launcher_dir));
Some(McpPackageManager::Bun { command_path, bun_install, global_modules_root })
}
/// Returns the `node_modules` directory when `package_root` sits directly inside a
/// bun global install (`<...>/install/global/node_modules/@dbx-app/mcp-server`).
fn bun_global_modules_root(package_root: &Path) -> Option<PathBuf> {
let node_modules = package_root
.ancestors()
.find(|ancestor| ancestor.file_name().is_some_and(|name| name.eq_ignore_ascii_case("node_modules")))?;
let global = node_modules.parent()?;
if !global.file_name().is_some_and(|name| name.eq_ignore_ascii_case("global")) {
return None;
}
let install = global.parent()?;
if !install.file_name().is_some_and(|name| name.eq_ignore_ascii_case("install")) {
return None;
}
Some(node_modules.to_path_buf())
}
/// bun's global `node_modules` root: `<bun_install>/install/global/node_modules`
/// (the layout `bun_global_modules_root` recognises from a resolved package).
fn bun_global_node_modules(bun_install: &Path) -> PathBuf {
bun_install.join("install").join("global").join("node_modules")
}
/// Windows bun bins are native `.exe` shims (`<bun_install>/bin/dbx-mcp-server.exe`)
/// that cannot be resolved back to a node script, so `mcp_package_from_command_path`
/// would otherwise drop the whole detection before reaching bun attribution. Probe
/// bun's global `node_modules` layout directly so such an `.exe` launcher still
/// resolves to the bun-installed package.
fn bun_exe_shim_package(bin_path: &Path) -> Option<LocatedMcpPackage> {
if !bin_path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
{
return None;
}
let launcher_dir = bin_path.parent()?;
let bun_install = bun_install_from_bin_dir(launcher_dir)?;
let package_root = canonical_runtime_path(&bun_global_node_modules(&bun_install).join(MCP_PACKAGE_NAME))?;
let package = mcp_package(&package_root)?;
let package_manager = bun_package_manager(&package_root, launcher_dir)?;
Some(LocatedMcpPackage { package_root, package, bin_path: Some(bin_path.to_path_buf()), package_manager })
}
/// bun links global bins into `<bun_install>/bin`, so a launcher directly inside a
/// `bin` directory resolves back to the enclosing bun installation root.
fn bun_install_from_bin_dir(bin_dir: &Path) -> Option<PathBuf> {
if !bin_dir.file_name().is_some_and(|name| name.eq_ignore_ascii_case("bin")) {
return None;
}
bin_dir.parent().map(Path::to_path_buf)
}
fn bun_command_near(dir: &Path) -> Option<PathBuf> {
[Some(dir), dir.parent()].into_iter().flatten().find_map(|candidate_dir| {
command_file_names("bun").into_iter().map(|name| candidate_dir.join(name)).find(|path| path.is_file())
})
}
fn discover_path_mcp_package() -> Option<LocatedMcpPackage> {
let launcher = locate_mcp_bin()?;
let package = mcp_package_from_command_path(&launcher);
@@ -1093,11 +1209,12 @@ fn mcp_native_binary_path_for(
McpPackageManager::Pnpm { global_dir, .. } | McpPackageManager::PnpmUnavailable { global_dir, .. } => {
(canonical_runtime_path(global_dir)?, false)
}
McpPackageManager::Bun { global_modules_root, .. } => (canonical_runtime_path(global_modules_root)?, true),
McpPackageManager::Unmanaged { .. } => (package_root.clone(), false),
};
let mut candidates = vec![package_root.join("node_modules").join(package_name)];
if allow_hoisted {
candidates.push(npm_root.join(package_name));
candidates.push(boundary.join(package_name));
}
candidates.into_iter().find_map(|native_package_root| {
let native_package_root = canonical_runtime_path(&native_package_root)?;
@@ -1434,12 +1551,13 @@ mod tests {
#[cfg(not(windows))]
use super::{bash_login_script, prefixed_output_path, NodeRuntimeCandidate};
use super::{
bind_mcp_installation, canonical_runtime_path, is_mcp_compatible_node_version, mcp_command_for_runtime,
mcp_installation_status_fields, mcp_native_binary_path_for, mcp_package, mcp_package_from_command_path,
node_script_from_launcher, normalized_reported_path, npm_cli_candidates, parse_minimum_node_version,
parse_node_version, prefer_runtime, require_managed_mcp_command, resolve_managed_mcp_command,
resolve_mise_mcp_command, stdout_after_shell_marker, McpInstallation, McpPackageManager, NodeRuntime,
NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT, MCP_PACKAGE_NAME, SHELL_COMMAND_MARKER,
bind_mcp_installation, bun_package_manager, canonical_runtime_path, is_mcp_compatible_node_version,
mcp_command_for_runtime, mcp_installation_status_fields, mcp_native_binary_path_for, mcp_package,
mcp_package_from_command_path, mcp_status_installation_error, node_script_from_launcher,
normalized_reported_path, npm_cli_candidates, parse_minimum_node_version, parse_node_version, prefer_runtime,
require_managed_mcp_command, resolve_managed_mcp_command, resolve_mise_mcp_command, stdout_after_shell_marker,
McpInstallation, McpPackageManager, NodeRuntime, NodeVersion, MCP_MIN_NODE_VERSION_REQUIREMENT,
MCP_PACKAGE_NAME, SHELL_COMMAND_MARKER,
};
#[cfg(not(windows))]
use super::{shell_command_script, shell_quote};
@@ -1498,6 +1616,50 @@ mod tests {
PnpmFixture { root, pnpm_home, launcher_path, pnpm_path, package_root, script_path, global_dir }
}
struct BunFixture {
root: PathBuf,
bun_install: PathBuf,
launcher_path: PathBuf,
bun_path: PathBuf,
package_root: PathBuf,
script_path: PathBuf,
global_modules_root: PathBuf,
}
impl Drop for BunFixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Mirrors bun's global layout: packages under `<bun_install>/install/global/node_modules`
/// (flat, like npm) with bins linked into `<bun_install>/bin`.
fn bun_fixture(launcher_name: &str) -> BunFixture {
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let root = std::env::temp_dir().join(format!("dbx-bun-fixture-{}-{nonce}", std::process::id()));
let bun_install = root.join(".bun");
let bin_dir = bun_install.join("bin");
let launcher_path = bin_dir.join(launcher_name);
let bun_path = bin_dir.join(if cfg!(windows) { "bun.exe" } else { "bun" });
let global_modules_root = bun_install.join("install").join("global").join("node_modules");
let package_root = global_modules_root.join("@dbx-app").join("mcp-server");
let script_path = package_root.join("bin").join("dbx-mcp-server.js");
std::fs::create_dir_all(script_path.parent().unwrap()).unwrap();
std::fs::create_dir_all(&bin_dir).unwrap();
std::fs::write(&bun_path, "bun 1.2.0 fixture\n").unwrap();
std::fs::write(&script_path, "// @dbx-app/mcp-server fixture\n").unwrap();
std::fs::write(
package_root.join("package.json"),
r#"{"name":"@dbx-app/mcp-server","version":"0.4.71","bin":{"dbx-mcp-server":"bin/dbx-mcp-server.js"},"engines":{"node":">=18.18.0"}}"#,
)
.unwrap();
BunFixture { root, bun_install, launcher_path, bun_path, package_root, script_path, global_modules_root }
}
fn runtime_for_installation(installation: McpInstallation, npm_root: PathBuf) -> NodeRuntime {
NodeRuntime {
node_launcher_path: installation.node_path.clone(),
@@ -1743,6 +1905,205 @@ mod tests {
assert_real_pnpm_launcher("dbx-mcp-server.ps1", PNPM_10_27_POWERSHELL_SHIM);
}
#[cfg(not(windows))]
#[test]
fn bun_global_shim_is_attributed_to_bun() {
use std::os::unix::fs::symlink;
let fixture = bun_fixture("dbx-mcp-server");
symlink(&fixture.script_path, &fixture.launcher_path).unwrap();
let located = mcp_package_from_command_path(&fixture.launcher_path).unwrap();
assert_eq!(located.package_root, canonical_runtime_path(&fixture.package_root).unwrap());
assert_eq!(located.package.version, "0.4.71");
assert!(matches!(
located.package_manager,
McpPackageManager::Bun {
ref command_path,
ref bun_install,
ref global_modules_root,
} if command_path.as_deref() == canonical_runtime_path(&fixture.bun_path).as_deref()
&& bun_install == &canonical_runtime_path(&fixture.bun_install).unwrap()
&& global_modules_root == &canonical_runtime_path(&fixture.global_modules_root).unwrap()
));
}
#[test]
fn bun_package_manager_recognizes_the_global_layout() {
let fixture = bun_fixture("dbx-mcp-server");
let manager = bun_package_manager(&fixture.package_root, &fixture.bun_install.join("bin")).unwrap();
assert!(matches!(
manager,
McpPackageManager::Bun { ref command_path, .. } if command_path.as_deref() == Some(fixture.bun_path.as_path())
));
}
#[test]
fn bun_package_manager_rejects_a_local_project_install() {
let package_root = PathBuf::from("/home/dev/project/node_modules").join(MCP_PACKAGE_NAME);
let bin_dir = PathBuf::from("/home/dev/project/node_modules/.bin");
assert!(bun_package_manager(&package_root, &bin_dir).is_none());
}
#[test]
fn bun_windows_exe_shim_resolves_through_the_global_layout() {
let fixture = bun_fixture("dbx-mcp-server.exe");
std::fs::write(&fixture.launcher_path, "native exe shim fixture\n").unwrap();
assert!(node_script_from_launcher(&fixture.launcher_path).is_none());
let located = mcp_package_from_command_path(&fixture.launcher_path).unwrap();
assert_eq!(located.package_root, canonical_runtime_path(&fixture.package_root).unwrap());
assert_eq!(located.package.version, "0.4.71");
assert_eq!(located.bin_path.as_deref(), canonical_runtime_path(&fixture.launcher_path).as_deref());
assert!(matches!(
located.package_manager,
McpPackageManager::Bun {
ref command_path,
ref bun_install,
ref global_modules_root,
} if command_path.as_deref() == canonical_runtime_path(&fixture.bun_path).as_deref()
&& bun_install == &canonical_runtime_path(&fixture.bun_install).unwrap()
&& global_modules_root == &canonical_runtime_path(&fixture.global_modules_root).unwrap()
));
let installation = bind_mcp_installation(
located,
PathBuf::from("/runtime/node-24").as_path(),
"v24.16.0",
PathBuf::from("/npm-root").as_path(),
)
.unwrap();
assert_eq!(installation.launcher_path.as_deref(), canonical_runtime_path(&fixture.launcher_path).as_deref());
}
#[test]
fn bun_exe_shim_outside_a_bun_bin_directory_is_ignored() {
let fixture = bun_fixture("dbx-mcp-server.exe");
let stray = fixture.root.join("dbx-mcp-server.exe");
std::fs::write(&stray, "native exe shim fixture\n").unwrap();
assert!(mcp_package_from_command_path(&stray).is_none());
}
#[test]
fn bun_installation_reports_bun_update_and_uninstall_commands() {
let installation = McpInstallation {
package_root: PathBuf::from("/bun/install/global/node_modules/@dbx-app/mcp-server"),
launcher_path: None,
script_path: PathBuf::from("/bun/install/global/node_modules/@dbx-app/mcp-server/bin/dbx-mcp-server.js"),
node_path: PathBuf::from("/node"),
node_version: "v24.16.0".to_string(),
package_version: "0.4.71".to_string(),
package_manager: McpPackageManager::Bun {
command_path: Some(PathBuf::from("/bun/bin/bun")),
bun_install: PathBuf::from("/bun"),
global_modules_root: PathBuf::from("/bun/install/global/node_modules"),
},
native_bin_path: None,
};
let runtime = runtime_for_installation(installation, PathBuf::from("/node-root"));
assert_eq!(runtime.update_command(), super::MCP_BUN_UPDATE_COMMAND);
assert_eq!(runtime.uninstall_command(), super::MCP_BUN_UNINSTALL_COMMAND);
}
#[test]
fn bun_without_an_executable_disables_update_and_uninstall() {
let installation = McpInstallation {
package_root: PathBuf::from("/bun/install/global/node_modules/@dbx-app/mcp-server"),
launcher_path: None,
script_path: PathBuf::from("/bun/install/global/node_modules/@dbx-app/mcp-server/bin/dbx-mcp-server.js"),
node_path: PathBuf::from("/node"),
node_version: "v24.16.0".to_string(),
package_version: "0.4.71".to_string(),
package_manager: McpPackageManager::Bun {
command_path: None,
bun_install: PathBuf::from("/bun"),
global_modules_root: PathBuf::from("/bun/install/global/node_modules"),
},
native_bin_path: None,
};
let runtime = runtime_for_installation(installation, PathBuf::from("/node-root"));
assert!(runtime.install_or_update().is_err());
assert!(runtime.uninstall().is_err());
// The UI still shows the bun command so the user can act manually.
assert_eq!(runtime.update_command(), super::MCP_BUN_UPDATE_COMMAND);
}
#[test]
fn bun_without_an_executable_reports_a_status_error() {
let bun_install = PathBuf::from("/bun");
let global_modules_root = bun_install.join("install").join("global").join("node_modules");
let installation = McpInstallation {
package_root: global_modules_root.join(MCP_PACKAGE_NAME),
launcher_path: None,
script_path: global_modules_root.join(MCP_PACKAGE_NAME).join("bin").join("dbx-mcp-server.js"),
node_path: PathBuf::from("/node"),
node_version: "v24.16.0".to_string(),
package_version: "0.4.71".to_string(),
package_manager: McpPackageManager::Bun {
command_path: None,
bun_install: bun_install.clone(),
global_modules_root: global_modules_root.clone(),
},
native_bin_path: None,
};
let error = mcp_status_installation_error(Some(&installation)).unwrap();
assert!(error.contains("bun was not found"));
assert!(error.contains(global_modules_root.to_string_lossy().as_ref()));
assert!(error.contains(bun_install.join("bin").to_string_lossy().as_ref()));
let managed = McpInstallation {
package_manager: McpPackageManager::Bun {
command_path: Some(bun_install.join("bin").join("bun")),
bun_install: bun_install.clone(),
global_modules_root,
},
..installation
};
assert!(mcp_status_installation_error(Some(&managed)).is_none());
assert!(mcp_status_installation_error(None).is_none());
}
#[test]
fn bun_native_binary_resolves_in_the_flat_global_layout() {
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let root = std::env::temp_dir().join(format!("dbx-bun-native-fixture-{}-{nonce}", std::process::id()));
let global_modules_root = root.join("install").join("global").join("node_modules");
let package_root = global_modules_root.join(MCP_PACKAGE_NAME);
let native_package_root = global_modules_root.join("@dbx-app").join("mcp-native");
let native_binary = native_package_root.join("bin").join("dbx-mcp");
std::fs::create_dir_all(package_root.join("bin")).unwrap();
std::fs::create_dir_all(native_binary.parent().unwrap()).unwrap();
std::fs::write(package_root.join("bin").join("dbx-mcp-server.js"), "// entry\n").unwrap();
std::fs::write(
native_package_root.join("package.json"),
r#"{"name":"@dbx-app/mcp-native","version":"0.4.71"}"#,
)
.unwrap();
std::fs::write(&native_binary, "native\n").unwrap();
let package_root = canonical_runtime_path(&package_root).unwrap();
let manager = McpPackageManager::Bun {
command_path: None,
bun_install: root.clone(),
global_modules_root: canonical_runtime_path(&global_modules_root).unwrap(),
};
let resolved = mcp_native_binary_path_for(
&package_root,
&package_root,
&manager,
"0.4.71",
"@dbx-app/mcp-native",
"dbx-mcp",
);
assert_eq!(resolved, canonical_runtime_path(&native_binary));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn rejects_private_marker_and_unrecognized_shell_launcher() {
use std::time::{SystemTime, UNIX_EPOCH};