fix(mcp): install and expose a global connector's deps where they are needed
Nightly Build / build (push) Successful in 8m7s

Two halves of the same failure, found debugging a marketplace connector that
logged "connected — 6 tool(s)" while every call died on a missing module.

The verify ran as a bare `sh -c` and inherited nothing, so a python connector
was rejected by its own verify for a dependency installed one directory away —
`global_enable` installs before it verifies, so the deps were provably there at
the moment the check denied them, and the row ended up disabled. Only
connectors that bother to declare a verify could hit it. `verify_env` now
builds the verify's environment in one place and derives PYTHONPATH from the
workdir, which is already the connector dir in both targets; `or_insert`, so a
value the form declares still wins.

The global branch of the reinstall refresh restarted the server without ever
installing its deps: `ensure_installed_host` was reachable from `global_enable`
alone, so a marketplace Update that adds a requirements.txt landed the file and
brought the connector back exactly as broken. It now runs once per connector
folder before the restart loop, best-effort. The per-user branch had always
reinstalled, which is why nothing with scope=user ever showed the bug.

Known gap, deliberate: POST /api/mcp/test shares run_verify but not the
install, so testing a python connector never enabled on the box still fails on
missing deps. Making a "try it" button write to disk for minutes is the worse
trade.
This commit is contained in:
Daniele
2026-08-10 13:11:02 +01:00
parent 5fb5854ff2
commit 59549d2b3b
3 changed files with 138 additions and 19 deletions
+36 -4
View File
@@ -243,9 +243,11 @@ impl Skald {
/// without a re-login — the reinstall counterpart of the §6/§7 remount helpers.
/// The reinstall has already rewritten `mcp_catalog`; this reconnects what runs:
///
/// - **Global runtime**: for each *enabled* `mcp_global_servers` row snapshotting
/// this catalog entry, re-snapshot its `description` from the catalog and restart
/// it, so the running server's in-RAM description (and code) catches up.
/// - **Global runtime**: install the connector's declared dependencies on the host
/// (`ensure_installed_host`, once per folder), then for each *enabled*
/// `mcp_global_servers` row snapshotting this catalog entry, re-snapshot its
/// `description` from the catalog and restart it, so the running server's in-RAM
/// description (and code) catches up.
/// - **Per-user runtimes**: for each live user who has this connector *startable*,
/// re-copy its files/deps into the container (`prepare_local_connector` — a hash
/// no-op when the source is unchanged) and restart that one server. The rebuilt
@@ -267,7 +269,37 @@ impl Skald {
// 1. Global runtime.
if let Ok(globals) = crate::db::mcp_global_servers::all_enabled(self.db()).await {
for g in globals.iter().filter(|g| g.catalog_name.as_deref() == Some(catalog_name)) {
let live: Vec<_> = globals
.iter()
.filter(|g| g.catalog_name.as_deref() == Some(catalog_name))
.collect();
// Dependencies before code. A global connector runs on the host, where
// nothing reconciles it the way the container reconciler does below, and
// `ensure_installed_host` was otherwise reachable from `global_enable`
// alone — so an Update that *adds* a `requirements.txt` landed the file,
// restarted the server, and never installed what it declared: the
// connector came back exactly as broken as before, curable only by
// re-saving its config from the UI.
//
// Once per connector folder rather than per row: the deps live beside the
// files, so two runtime names snapshotting one catalog entry share them.
// Not hash-guarded, unlike the per-user `ensure_installed` — it leans on
// `pip`/`npm` being idempotent, so a no-change reinstall pays one fast
// satisfied-requirements pass. Best-effort like the rest of this function.
if !live.is_empty() && entry.source == "local_script" {
match entry.script_path.as_deref().map(crate::mcp::split_script_path) {
Some(Ok((folder, _))) => {
if let Err(e) = crate::mcp::ensure_installed_host(folder).await {
tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: global dependency install failed");
}
}
Some(Err(e)) => tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: unusable script_path, skipping dependency install"),
None => tracing::warn!(connector = %catalog_name, "reinstall refresh: local_script entry has no script_path, skipping dependency install"),
}
}
for g in live {
if let Err(e) = crate::db::mcp_global_servers::set_description(self.db(), g.id, entry.description.as_deref()).await {
tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: failed to update global description");
continue;