feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint
This commit is contained in:
@@ -1,12 +1,32 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a config property is rendered and edited in the Config UI.
|
||||
///
|
||||
/// Beyond the plain scalars (`String`/`Int`/`Bool`, rendered as text/number/
|
||||
/// switch), a variant can stand for a **custom, higher-level control** whose
|
||||
/// allowed values are computed by the backend rather than typed by hand —
|
||||
/// `SecurityGroup` and `Locale` are both of this kind: they turn into a
|
||||
/// dropdown fed by a server-supplied `options` list.
|
||||
///
|
||||
/// **Adding your own is cheap and encouraged.** If a new config section would
|
||||
/// otherwise expose a free-text field where only a fixed/derived set of values
|
||||
/// is valid, prefer adding a variant here instead. The wiring is three small,
|
||||
/// symmetric edits:
|
||||
/// 1. add the variant below;
|
||||
/// 2. in `frontend/api/config.rs`, map it to a type string and (if it's a
|
||||
/// dropdown) build its `options: Vec<SelectOption>`;
|
||||
/// 3. in `web/components/config-page.js`, add a render branch for that type.
|
||||
/// Anything carrying `options` renders as a `<select>` — see `_renderInput`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropertyType {
|
||||
String,
|
||||
Int,
|
||||
Bool,
|
||||
/// Dropdown of the instance's security groups (run-context groups).
|
||||
SecurityGroup,
|
||||
/// Dropdown of the interface languages the instance supports.
|
||||
Locale,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -54,6 +54,13 @@ pub struct McpCatalogRow {
|
||||
pub icon_large_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Marketplace build number — the **comparison key** for updates (a feed entry
|
||||
/// with a higher `version` than this installed one is "update available").
|
||||
/// Monotonic per connector; `version_string`/`version_release_date` are display
|
||||
/// only. NULL for a pre-versioning or manually-added entry.
|
||||
pub version: Option<i64>,
|
||||
pub version_string: Option<String>,
|
||||
pub version_release_date: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -98,7 +105,7 @@ const SELECT: &str =
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
|
||||
deliver_json, role_filter, verify_command, \
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
|
||||
description, created_at \
|
||||
description, version, version_string, version_release_date, created_at \
|
||||
FROM mcp_catalog";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
@@ -160,6 +167,11 @@ pub struct UpsertCatalog<'a> {
|
||||
pub icon_large_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
/// Versioning (from the feed). All three `None` for the admin's manual form,
|
||||
/// which COALESCEs them away rather than blanking an installed entry's version.
|
||||
pub version: Option<i64>,
|
||||
pub version_string: Option<&'a str>,
|
||||
pub version_release_date: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
@@ -168,8 +180,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
(name, scope, source, transport, command, args_json, env_json, url,
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
|
||||
deliver_json, role_filter, verify_command,
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, description)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
|
||||
version, version_string, version_release_date)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
@@ -194,7 +207,13 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path),
|
||||
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description
|
||||
description = excluded.description,
|
||||
-- Version fields come from the feed on (re)install; the admin's manual
|
||||
-- form passes NULL, so COALESCE keeps the installed version rather than
|
||||
-- wiping it (same rationale as icons above).
|
||||
version = COALESCE(excluded.version, mcp_catalog.version),
|
||||
version_string = COALESCE(excluded.version_string, mcp_catalog.version_string),
|
||||
version_release_date = COALESCE(excluded.version_release_date, mcp_catalog.version_release_date)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(e.name)
|
||||
@@ -218,6 +237,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
.bind(e.icon_large_path)
|
||||
.bind(e.friendly_name)
|
||||
.bind(e.description)
|
||||
.bind(e.version)
|
||||
.bind(e.version_string)
|
||||
.bind(e.version_release_date)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
|
||||
@@ -488,6 +488,9 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
icon_large_path TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
version INTEGER, -- marketplace build number: the update-comparison key
|
||||
version_string TEXT, -- semver, display only
|
||||
version_release_date TEXT, -- ISO date, display only
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
@@ -497,6 +500,11 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
|
||||
// Versioning columns are additive: the installed `version` integer is compared
|
||||
// against the feed's to surface "update available" in the marketplace UI.
|
||||
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
|
||||
ensure_column(pool, "mcp_catalog", "version_string", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "version_release_date", "TEXT").await?;
|
||||
|
||||
// Concrete globally-active connectors (shared, stateless — web-search etc.).
|
||||
// They run on the HOST. The global secret (admin's API key) is fine here:
|
||||
|
||||
@@ -54,6 +54,20 @@ pub fn language_name(locale: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native (endonym) language name for UI language pickers (`"it"` → `"Italiano"`).
|
||||
/// Unlike [`language_name`] — an English exonym for prompt rendering — this is
|
||||
/// what a user expects to see when *choosing* their language, and it reads the
|
||||
/// same regardless of the interface language currently active. Unknown codes
|
||||
/// pass through unchanged.
|
||||
pub fn native_language_name(locale: &str) -> String {
|
||||
match locale {
|
||||
"en" => "English".into(),
|
||||
"it" => "Italiano".into(),
|
||||
"fr" => "Français".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the instance default locale straight to the registry `config` table.
|
||||
/// Used by first-run provisioning shells (e.g. `skald-setup`), where no
|
||||
/// `GlobalConfigManager` — hence no system bus — exists. A running server
|
||||
@@ -82,8 +96,10 @@ pub fn config_set() -> ConfigSet {
|
||||
ConfigProperty {
|
||||
key: DEFAULT_LOCALE_KEY.into(),
|
||||
name: "Language".into(),
|
||||
description: "Default interface language for the whole instance (e.g. en, it). Each user can override it on their profile.".into(),
|
||||
property_type: PropertyType::String,
|
||||
description: "Default interface language for the whole instance. Each user can override it on their profile.".into(),
|
||||
// A dropdown of `SUPPORTED_LOCALES` rather than a free-text box:
|
||||
// the valid values are a fixed set the backend already owns.
|
||||
property_type: PropertyType::Locale,
|
||||
default_value: Some("en".into()),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
//! diverges the moment the admin edits the catalog row.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::container::{CONTAINER_HOME, HOMES_DIR};
|
||||
|
||||
@@ -31,6 +33,23 @@ pub const CONNECTORS_DIR: &str = "connectors";
|
||||
/// The manifest, saved verbatim at install time as provenance (never read back).
|
||||
pub const MANIFEST_FILE: &str = "connector.json";
|
||||
|
||||
/// Marker file in a user's installed connector dir recording the content hash of
|
||||
/// the source folder that produced the current files + dependencies. When the
|
||||
/// source changes (a marketplace update) this hash changes, and the reconciler
|
||||
/// re-copies + re-installs; when it matches, a startup is a cheap no-op.
|
||||
const INSTALL_LOCK: &str = ".skald-install.lock";
|
||||
|
||||
/// Where `pip install --target` lands a python connector's dependencies, a sibling
|
||||
/// of the server file inside the connector dir. Injected onto the server process's
|
||||
/// `PYTHONPATH` at spec-build time (see `mcp::user_row_spec`). Node needs no
|
||||
/// equivalent: `node_modules/` beside the entry file is resolved automatically.
|
||||
pub const PYDEPS_DIR: &str = ".pydeps";
|
||||
|
||||
/// Ceiling for a single `npm`/`pip` install inside the container. Baileys or a
|
||||
/// heavy python wheel set can take a while on a cold cache; a genuinely stuck
|
||||
/// install must still fail rather than hang a login forever.
|
||||
const DEPS_INSTALL_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Where a per-user connector's files land inside the container, under the home
|
||||
/// mount. `{CONTAINER_HOME}/.skald/mcp/<runtime_name>/`.
|
||||
const IN_CONTAINER_MCP_SUBDIR: &str = ".skald/mcp";
|
||||
@@ -142,6 +161,159 @@ fn copy_runtime_files(src: &Path, dest: &Path, rel: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── dependency reconciler (blueprint §6/§7) ─────────────────────────────────────
|
||||
//
|
||||
// Copying a connector's files into a container never made its dependencies exist
|
||||
// there: a python server still needs its wheels, a node server its `node_modules`.
|
||||
// [`ensure_installed`] closes that gap and keeps it closed across updates.
|
||||
//
|
||||
// It is a **content-hash reconciler**, not a one-shot installer. The trigger is a
|
||||
// hash of the *source* folder's runtime files, not a version string the author
|
||||
// might forget to bump: any real change to what the connector ships (including its
|
||||
// `package.json` / `requirements.txt`) changes the hash and forces a refresh. It
|
||||
// runs on every per-user startup path (first login, container recreate/remount)
|
||||
// and at activation, so:
|
||||
// - a brand-new container (no home files) installs from scratch,
|
||||
// - an updated connector (source changed) re-copies + re-installs,
|
||||
// - an unchanged one (hash matches the lock) is skipped in microseconds.
|
||||
|
||||
/// Reconciles user `user_id`'s copy of local-script connector `folder` (runtime
|
||||
/// name `runtime_name`) inside `container`: refreshes the files when the source
|
||||
/// changed, then (re)installs node and/or python dependencies. Idempotent and
|
||||
/// hash-guarded. Dependency install is best-effort at the call site (a failure is
|
||||
/// returned, and callers log-and-continue so the server still starts and surfaces
|
||||
/// its own import error) — but a changed source with a failed install does NOT
|
||||
/// write the lock, so the next startup retries.
|
||||
pub async fn ensure_installed(
|
||||
user_id: &str,
|
||||
runtime_name: &str,
|
||||
folder: &str,
|
||||
container: &str,
|
||||
) -> Result<()> {
|
||||
let src = connector_dir(folder)?;
|
||||
if !src.is_dir() {
|
||||
// Nothing shipped for this connector on this box; leave any existing files
|
||||
// in place (a caller that truly needs them says so with its own message).
|
||||
return Ok(());
|
||||
}
|
||||
let home = home_dir_for(user_id, runtime_name)?;
|
||||
let lock = home.join(INSTALL_LOCK);
|
||||
let src_hash = hash_source(&src)?;
|
||||
if let Ok(prev) = std::fs::read_to_string(&lock) {
|
||||
if prev.trim() == src_hash {
|
||||
return Ok(()); // files + deps already current
|
||||
}
|
||||
}
|
||||
|
||||
// (Re)copy source files. `install_into_home` overwrites shipped files but never
|
||||
// deletes others, so the durable `auth/`, `node_modules/`, `.pydeps/` and the
|
||||
// lock itself survive an update.
|
||||
let container_dir = match install_into_home(user_id, runtime_name, folder)? {
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Install whatever ecosystem the connector ships. A connector may ship both.
|
||||
if home.join("package.json").is_file() {
|
||||
run_in_container(
|
||||
container,
|
||||
&container_dir,
|
||||
// `npm ci` is reproducible when a lockfile is present; fall back to
|
||||
// `npm install` when it is not (or when ci rejects a drifted lock).
|
||||
"npm ci --omit=dev --no-audit --no-fund 2>&1 || npm install --omit=dev --no-audit --no-fund 2>&1",
|
||||
"npm",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if home.join("requirements.txt").is_file() {
|
||||
run_in_container(
|
||||
container,
|
||||
&container_dir,
|
||||
// `--target .pydeps` keeps deps beside the server (durable, per-connector)
|
||||
// and out of the PEP-668 externally-managed system site; `--break-system-
|
||||
// packages` silences that guard even though `--target` already avoids it.
|
||||
&format!(
|
||||
"python3 -m pip install --break-system-packages --target {PYDEPS_DIR} \
|
||||
-r requirements.txt 2>&1"
|
||||
),
|
||||
"pip",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
std::fs::write(&lock, &src_hash)
|
||||
.with_context(|| format!("failed to write {}", lock.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A deterministic content hash of a connector's **runtime** files (host assets —
|
||||
/// icons, `connector.json` — excluded, since they never reach the container and so
|
||||
/// cannot change what runs). Path + bytes of every file, sorted, folded into one
|
||||
/// SHA-256. Two installs of the same source produce the same hash on any box.
|
||||
fn hash_source(src: &Path) -> Result<String> {
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
collect_source_files(src, Path::new(""), &mut files)?;
|
||||
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut h = Sha256::new();
|
||||
for (rel, bytes) in files {
|
||||
h.update(rel.as_bytes());
|
||||
h.update([0u8]);
|
||||
h.update(&bytes);
|
||||
h.update([0u8]);
|
||||
}
|
||||
Ok(h.finalize().iter().fold(String::with_capacity(64), |mut s, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
}))
|
||||
}
|
||||
|
||||
fn collect_source_files(dir: &Path, rel: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
let child_rel = rel.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
collect_source_files(&entry.path(), &child_rel, out)?;
|
||||
continue;
|
||||
}
|
||||
let rel_str = child_rel.to_string_lossy().to_string();
|
||||
if is_host_asset(&rel_str) {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(entry.path())
|
||||
.with_context(|| format!("cannot read {}", entry.path().display()))?;
|
||||
out.push((rel_str, bytes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs a shell `script` inside `container` at `workdir` via `docker exec`, under a
|
||||
/// timeout, and fails with the tail of the output on a non-zero exit. Output is not
|
||||
/// captured into the DB — only surfaced in the returned error for the caller's log.
|
||||
async fn run_in_container(container: &str, workdir: &Path, script: &str, label: &str) -> Result<()> {
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_secs(DEPS_INSTALL_TIMEOUT_SECS),
|
||||
tokio::process::Command::new("docker")
|
||||
.arg("exec")
|
||||
.arg("-w").arg(workdir)
|
||||
.arg(container)
|
||||
.arg("sh").arg("-c").arg(script)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("{label} install timed out after {DEPS_INSTALL_TIMEOUT_SECS}s"))?
|
||||
.with_context(|| format!("failed to run docker exec for {label} install"))?;
|
||||
|
||||
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::*;
|
||||
|
||||
@@ -279,6 +279,13 @@ impl McpManager {
|
||||
self.descriptions.write().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Whether a server by this name currently has a live connection in the
|
||||
/// runtime. Used by the interactive-login API to decide whether it must
|
||||
/// (re)start a pending connector before polling its `login_status`.
|
||||
pub fn is_running(&self, name: &str) -> bool {
|
||||
self.servers.read().unwrap().contains_key(name)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<McpTool> {
|
||||
self.servers.read().unwrap().values()
|
||||
.flat_map(|s| s.tools().iter().cloned())
|
||||
@@ -518,6 +525,53 @@ pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PYTHONPATH` to hand a python connector so it imports the deps installed
|
||||
/// under `<connector-dir>/.pydeps`. `None` for anything that is not a python
|
||||
/// command, or a python one with no script argument to derive the dir from.
|
||||
fn python_pydeps_path(command: Option<&str>, args: &[String]) -> Option<String> {
|
||||
let cmd = command?;
|
||||
let base = std::path::Path::new(cmd).file_name().and_then(|s| s.to_str()).unwrap_or(cmd);
|
||||
if !base.starts_with("python") {
|
||||
return None;
|
||||
}
|
||||
let script = args.first()?;
|
||||
let dir = std::path::Path::new(script).parent()?;
|
||||
Some(dir.join(install::PYDEPS_DIR).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Reconciles a per-user local-script connector's files + dependencies inside the
|
||||
/// user's container before it is (re)started — see [`install::ensure_installed`].
|
||||
/// Best-effort: logs and returns on any failure so a broken connector never blocks
|
||||
/// the others from starting. A no-op for remote / self-registered rows (no vetted
|
||||
/// catalog folder to install from).
|
||||
pub async fn prepare_local_connector(
|
||||
registry: &SqlitePool,
|
||||
user_id: &str,
|
||||
container: &str,
|
||||
row: &crate::db::mcp_user_servers::McpUserServerRow,
|
||||
) {
|
||||
if row.source != "local_script" {
|
||||
return;
|
||||
}
|
||||
let Some(catalog_name) = row.catalog_name.as_deref() else { return };
|
||||
let folder = match crate::db::mcp_catalog::get_by_name(registry, catalog_name).await {
|
||||
Ok(Some(entry)) => entry
|
||||
.script_path
|
||||
.as_deref()
|
||||
.and_then(|sp| install::split_script_path(sp).ok())
|
||||
.map(|(folder, _)| folder.to_string()),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("connector '{}': catalog lookup failed: {e}", row.name);
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(folder) = folder else { return };
|
||||
if let Err(e) = install::ensure_installed(user_id, &row.name, &folder, container).await {
|
||||
warn!("connector '{}': dependency install failed: {e}", row.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a spec for a user's per-user connector — container transport: a
|
||||
/// `local_script` (or any stdio server) runs INSIDE the user's container
|
||||
/// (`launch_in = Some(container)`), against the script copied into the
|
||||
@@ -528,7 +582,15 @@ pub fn user_row_spec(
|
||||
) -> McpServerSpec {
|
||||
let transport = transport_of(&row.transport);
|
||||
let launch_in = matches!(transport, McpTransport::Stdio).then(|| container.to_string());
|
||||
let env = row.env();
|
||||
let mut env = row.env();
|
||||
// A python connector's deps are installed under `<dir>/.pydeps` (pip `--target`,
|
||||
// see `install::ensure_installed`); point the interpreter at them. Node needs
|
||||
// nothing — `node_modules/` beside the entry file resolves on its own. Setting
|
||||
// it unconditionally for a python command is safe even before the first install:
|
||||
// python silently ignores a non-existent `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 {
|
||||
|
||||
@@ -107,6 +107,10 @@ impl Skald {
|
||||
let container = crate::container::container_name(user_id);
|
||||
let mut specs = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
// The home mount (and its `node_modules`/`.pydeps`) survives a
|
||||
// recreate, so this is normally a hash-match no-op; it still covers
|
||||
// the case where the source changed while the user was logged in.
|
||||
crate::mcp::prepare_local_connector(self.db(), user_id, &container, r).await;
|
||||
specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await);
|
||||
}
|
||||
ctx.user_mcp.connect_all(specs, false).await;
|
||||
|
||||
@@ -205,12 +205,17 @@ impl UserContextFactory {
|
||||
let upool = Arc::clone(&pool);
|
||||
let registry = Arc::clone(&self.registry_pool);
|
||||
let container = crate::container::container_name(user_id);
|
||||
let uid = user_id.to_string();
|
||||
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
|
||||
self.supervisor.adopt_one(mname, tokio::spawn(async move {
|
||||
match crate::db::mcp_user_servers::all_startable(&upool).await {
|
||||
Ok(rows) => {
|
||||
let mut specs = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
// Reconcile files + node/python deps in the container
|
||||
// before starting (covers a fresh container and any
|
||||
// connector update — see `prepare_local_connector`).
|
||||
crate::mcp::prepare_local_connector(®istry, &uid, &container, r).await;
|
||||
// OAuth connectors resolve their stored refresh token into
|
||||
// the env-delivered credential here (§15).
|
||||
specs.push(crate::mcp::user_row_spec_resolved(r, &container, ®istry).await);
|
||||
|
||||
Reference in New Issue
Block a user