From 42c0eaf2eccf6aba216bc438567449127111dd2d Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Wed, 22 Jul 2026 23:21:03 +0100 Subject: [PATCH] Live-refresh connectors after marketplace reinstall After a reinstall the catalog entry carries new llm_short_description, icon and code. Previously the running servers (global + per-user) kept their old metadata and code until the next login. - Add refresh_connector_after_reinstall on Skald: re-snapshots the description from the catalog, reconciles local files on per-user connectors, and restarts both the global and per-user servers - Add set_description db accessor for mcp_global_servers - user_row_spec_resolved now injects the live catalog description (over the bare name) so user-runtime connectors show the right blurb - marketplace install() fetches a fresh feed instead of the browse cache, so a reinstall reflects the changed manifest immediately --- .../skald-core/src/db/mcp_global_servers.rs | 13 ++++ crates/skald-core/src/mcp/mod.rs | 11 ++++ crates/skald-core/src/skald/accessors.rs | 61 +++++++++++++++++++ src/frontend/api/marketplace.rs | 15 ++++- 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/skald-core/src/db/mcp_global_servers.rs b/crates/skald-core/src/db/mcp_global_servers.rs index ecc8af5..7635f8e 100644 --- a/crates/skald-core/src/db/mcp_global_servers.rs +++ b/crates/skald-core/src/db/mcp_global_servers.rs @@ -147,6 +147,19 @@ pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<() Ok(()) } +/// Re-snapshots the LLM-facing description on an enabled global server, without +/// touching its config/credentials — used when a marketplace **reinstall** rewrites +/// the catalog's `llm_short_description` and the running server's snapshot must catch +/// up (the caller then restarts the server so its in-RAM description updates too). +pub async fn set_description(pool: &SqlitePool, id: i64, description: Option<&str>) -> Result<()> { + sqlx::query("UPDATE mcp_global_servers SET description = ?1 WHERE id = ?2") + .bind(description) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> { sqlx::query("DELETE FROM mcp_global_servers WHERE id = ?") .bind(id) diff --git a/crates/skald-core/src/mcp/mod.rs b/crates/skald-core/src/mcp/mod.rs index 15acc72..34397e2 100644 --- a/crates/skald-core/src/mcp/mod.rs +++ b/crates/skald-core/src/mcp/mod.rs @@ -669,6 +669,17 @@ pub async fn user_row_spec_resolved( if let Some(catalog_name) = row.catalog_name.as_deref() { if let Ok(Some(entry)) = crate::db::mcp_catalog::get_by_name(registry, catalog_name).await { spec.tool_titles = crate::db::mcp_catalog::parse_tool_titles(entry.tool_meta_json.as_deref()); + // The catalog's `description` is the connector's `llm_short_description` — + // the line the model reads when deciding whether to `activate_tools()` on + // this server (see `render_mcp_list`). `user_row_spec` only had the bare + // catalog name to fall back on; inject the real blurb here (this is the + // "the caller injects it if richer" the sync builder defers to), and keep + // the name when the catalog has none. Because it is read from the catalog + // live, a marketplace reinstall that rewrites the description is reflected + // the next time this spec is built. + if let Some(desc) = entry.description { + spec.description = Some(desc); + } } } if let (Some(provider), Some(deliver), Some(refresh)) = diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 3656cf7..2dfaaad 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -133,6 +133,67 @@ impl Skald { } } } + + /// Pushes a marketplace **reinstall** into every live copy of the connector so + /// active sessions pick up the new metadata (`llm_short_description`) and code + /// 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. + /// - **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 + /// spec now carries the fresh catalog description (see `user_row_spec_resolved`). + /// + /// Best-effort: the catalog write already committed, so a Docker/MCP hiccup here + /// must not fail the reinstall — anything not refreshed settles at the user's next + /// login. A fresh install (nothing live yet) is a cheap no-op: no row matches. + pub async fn refresh_connector_after_reinstall(&self, catalog_name: &str) { + // The metadata the reinstall just wrote — the source of truth to push out. + let entry = match crate::db::mcp_catalog::get_by_name(self.db(), catalog_name).await { + Ok(Some(e)) => e, + Ok(None) => return, + Err(e) => { + tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: catalog lookup failed"); + return; + } + }; + + // 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)) { + 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; + } + match crate::db::mcp_global_servers::get(self.db(), g.id).await { + Ok(Some(row)) => { + let spec = crate::mcp::global_row_spec(&row); + if let Err(e) = self.mcp().start_server(spec).await { + tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: failed to restart global server"); + } + } + _ => tracing::warn!(connector = %catalog_name, "reinstall refresh: global row vanished before restart"), + } + } + } + + // 2. Per-user runtimes — restart this one connector for each live user who runs it. + for ctx in self.rt_user_contexts().all_live().await { + let rows = crate::db::mcp_user_servers::all_startable(&ctx.pool).await.unwrap_or_default(); + let Some(row) = rows.into_iter().find(|r| r.catalog_name.as_deref() == Some(catalog_name)) else { + continue; + }; + let container = crate::container::container_name(&ctx.user_id); + crate::mcp::prepare_local_connector(self.db(), &ctx.user_id, &container, &row).await; + let spec = crate::mcp::user_row_spec_resolved(&row, &container, self.db()).await; + if let Err(e) = ctx.user_mcp.start_server(spec).await { + tracing::warn!(user = %ctx.user_id, connector = %catalog_name, error = %e, "reinstall refresh: failed to restart per-user connector"); + } + } + } pub fn sessions(&self) -> &Arc { &self.rt.sessions } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } diff --git a/src/frontend/api/marketplace.rs b/src/frontend/api/marketplace.rs index 8b2d36e..f298906 100644 --- a/src/frontend/api/marketplace.rs +++ b/src/frontend/api/marketplace.rs @@ -660,7 +660,12 @@ pub async fn install( ) -> Result, ApiError> { require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; - let feed = feed(false).await?; + // An install (or reinstall) always pulls the **current** feed, never the 300 s + // browse cache: a reinstall exists precisely to pick up a changed manifest + // (new `llm_short_description`, icon, code), so reading a stale snapshot would + // silently reapply the old metadata. Browsing the list stays cached; the + // mutating path fetches fresh. + let feed = feed(true).await?; let h = feed .iter() .find(|h| h.entry.id == body.id) @@ -789,6 +794,14 @@ pub async fn install( ) .await?; + // Push the (re)installed metadata + code into anything already running it, so a + // reinstall lands live instead of waiting for each user's next login: enabled + // global servers re-snapshot the description and restart; each live user who + // activated it gets its files/deps reconciled and the connector restarted with + // the fresh `llm_short_description`. A first-time install matches nothing live + // and is a cheap no-op. + skald.refresh_connector_after_reinstall(&body.id).await; + Ok(Json(json!({ "id": id, "name": h.entry.id,