Live-refresh connectors after marketplace reinstall
Nightly Build / build (push) Successful in 6m45s
Nightly Build / build (push) Successful in 6m45s
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
This commit is contained in:
@@ -147,6 +147,19 @@ pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()
|
|||||||
Ok(())
|
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<()> {
|
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM mcp_global_servers WHERE id = ?")
|
sqlx::query("DELETE FROM mcp_global_servers WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -669,6 +669,17 @@ pub async fn user_row_spec_resolved(
|
|||||||
if let Some(catalog_name) = row.catalog_name.as_deref() {
|
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 {
|
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());
|
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)) =
|
if let (Some(provider), Some(deliver), Some(refresh)) =
|
||||||
|
|||||||
@@ -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<crate::auth::SessionStore> { &self.rt.sessions }
|
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
|
||||||
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
|
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
|
||||||
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
|
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
|
||||||
|
|||||||
@@ -660,7 +660,12 @@ pub async fn install(
|
|||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
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
|
let h = feed
|
||||||
.iter()
|
.iter()
|
||||||
.find(|h| h.entry.id == body.id)
|
.find(|h| h.entry.id == body.id)
|
||||||
@@ -789,6 +794,14 @@ pub async fn install(
|
|||||||
)
|
)
|
||||||
.await?;
|
.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!({
|
Ok(Json(json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": h.entry.id,
|
"name": h.entry.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user