mcp: install dep reconciler, connector login/status endpoints, agent prompt updates
Nightly Build / build (push) Successful in 6m26s

This commit is contained in:
2026-07-20 17:39:07 +01:00
parent 6b25e7a2bf
commit fefcf95362
5 changed files with 169 additions and 99 deletions
+66
View File
@@ -314,6 +314,72 @@ async fn run_in_container(container: &str, workdir: &Path, script: &str, label:
Ok(())
}
/// Installs a **global** connector's dependencies on the HOST, into `.pydeps`
/// (python) / `node_modules` (node) beside its files in `connectors/<folder>/`.
///
/// The host counterpart of [`ensure_installed`]: a `global` connector runs in the
/// Skald process, not a container (§7), so its declared deps must resolve on the
/// host — `global_row_spec` puts `<dir>/.pydeps` on the server's `PYTHONPATH`. Unlike
/// the per-user reconciler this is not hash-guarded: the deps land in the same
/// `connectors/<folder>/` tree the hash would cover, so it simply relies on `pip`/
/// `npm` being idempotent (a satisfied requirement is a fast no-op). Called at
/// enable time; the installed `.pydeps` is durable and survives a restart, so the
/// boot relaunch needs no reinstall.
pub async fn ensure_installed_host(folder: &str) -> Result<()> {
let dir = connector_dir(folder)?;
if !dir.is_dir() {
// Nothing shipped for this connector on this box; a caller that truly needs
// the files fails later with its own message.
return Ok(());
}
if dir.join("package.json").is_file() {
run_on_host(
&dir,
"npm ci --omit=dev --no-audit --no-fund 2>&1 || npm install --omit=dev --no-audit --no-fund 2>&1",
"npm",
)
.await?;
}
if dir.join("requirements.txt").is_file() {
run_on_host(
&dir,
&format!(
"python3 -m pip install --break-system-packages --target {PYDEPS_DIR} \
-r requirements.txt 2>&1"
),
"pip",
)
.await?;
}
Ok(())
}
/// Runs a shell `script` on the HOST at `workdir`, under the same install timeout,
/// failing with the tail of the output on a non-zero exit. The host counterpart of
/// [`run_in_container`], for a `global` connector whose deps live beside its files in
/// `connectors/<id>/` rather than inside a container.
async fn run_on_host(workdir: &Path, script: &str, label: &str) -> Result<()> {
let output = tokio::time::timeout(
Duration::from_secs(DEPS_INSTALL_TIMEOUT_SECS),
tokio::process::Command::new("sh")
.arg("-c").arg(script)
.current_dir(workdir)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("{label} install timed out after {DEPS_INSTALL_TIMEOUT_SECS}s"))?
.with_context(|| format!("failed to run {label} install on host"))?;
if !output.status.success() {
let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
let tail: String = combined.lines().rev().take(12).collect::<Vec<_>>()
.into_iter().rev().collect::<Vec<_>>().join("\n");
bail!("{label} install failed:\n{tail}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+12 -2
View File
@@ -30,7 +30,7 @@ pub mod oauth;
mod provider;
pub mod verify;
pub use install::{CONNECTORS_DIR, MANIFEST_FILE, connector_dir, install_into_home, split_script_path};
pub use install::{CONNECTORS_DIR, MANIFEST_FILE, connector_dir, ensure_installed_host, install_into_home, split_script_path};
pub use oauth::DeliverSpec;
pub use provider::{McpProvider, UserMcpView};
pub use verify::{VerifyReport, VerifyTarget, apply_placeholders, run_verify};
@@ -508,7 +508,17 @@ fn substitute_named_tokens(
/// Builds a spec for a globally-active connector — host transport (`launch_in`
/// = None), so it runs in the Skald process, not in any container (§7).
pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow) -> McpServerSpec {
let env = row.env();
let mut env = row.env();
// A `global` python `local_script` connector's deps are installed on the host
// under `<dir>/.pydeps` (see `install::ensure_installed_host`); point the
// interpreter at them, mirroring `user_row_spec`. `args()[0]` is the host-absolute
// script path (set by `global_enable`), so the derived `.pydeps` path is absolute
// too and resolves regardless of the process cwd. A no-op for a remote connector
// (no python command → None) or before the first install (python ignores a
// missing `PYTHONPATH` entry).
if let Some(pp) = python_pydeps_path(row.command.as_deref(), &row.args()) {
env.entry("PYTHONPATH".to_string()).or_insert(pp);
}
let (url, api_key) = apply_key_placeholder(row.url.clone(), row.api_key.clone(), &env);
McpServerSpec {
config: McpServerConfig {