feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint
This commit is contained in:
@@ -15,8 +15,12 @@ use super::ApiError;
|
||||
|
||||
// ── Response types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// One choice in a dropdown-style property (see [`PropertyType`]). Deliberately
|
||||
/// generic — `id` is the stored value, `name` the human label — so every custom
|
||||
/// "pick from a fixed/derived set" property type reuses it (security groups,
|
||||
/// locales, and whatever the next section needs).
|
||||
#[derive(Serialize, Clone)]
|
||||
struct SecurityGroupOption {
|
||||
struct SelectOption {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
@@ -30,7 +34,7 @@ struct PropertyView {
|
||||
value: Option<String>,
|
||||
default_value: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
options: Option<Vec<SecurityGroupOption>>,
|
||||
options: Option<Vec<SelectOption>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -45,10 +49,21 @@ struct ConfigSetView {
|
||||
pub async fn list_properties(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
// Option sources for the dropdown-style property types. Each custom
|
||||
// `PropertyType` that renders as a `<select>` computes its choices here and
|
||||
// ships them in `options`. To add a new one: build its `Vec<SelectOption>`
|
||||
// and wire it into the `match` below (see `PropertyType` for the full
|
||||
// three-step recipe).
|
||||
let security_groups = skald.run_context_manager().list_groups().await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|g| SecurityGroupOption { id: g.id, name: g.name })
|
||||
.map(|g| SelectOption { id: g.id, name: g.name })
|
||||
.collect::<Vec<_>>();
|
||||
let locales = skald_core::i18n::SUPPORTED_LOCALES.iter()
|
||||
.map(|code| SelectOption {
|
||||
id: code.to_string(),
|
||||
name: skald_core::i18n::native_language_name(code),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut sets = Vec::with_capacity(skald.config_properties().len());
|
||||
@@ -56,11 +71,13 @@ pub async fn list_properties(
|
||||
let mut props = Vec::with_capacity(set.properties.len());
|
||||
for prop in &set.properties {
|
||||
let value = skald.config().get(&prop.key).await?;
|
||||
// Scalars carry no `options`; dropdown types attach their choices.
|
||||
let (type_str, options) = match prop.property_type {
|
||||
PropertyType::Int => ("int", None),
|
||||
PropertyType::Bool => ("bool", None),
|
||||
PropertyType::String => ("string", None),
|
||||
PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())),
|
||||
PropertyType::Locale => ("locale", Some(locales.clone())),
|
||||
};
|
||||
props.push(PropertyView {
|
||||
key: prop.key.clone(),
|
||||
|
||||
@@ -105,6 +105,28 @@ struct IndexEntry {
|
||||
#[serde(default)] scope: Option<String>,
|
||||
/// `mcp_local` | `mcp_remote` — the §14 risk axis.
|
||||
#[serde(default, rename = "type")] kind: Option<String>,
|
||||
/// Versioning (§ marketplace updates): `version` is the monotonic **integer**
|
||||
/// build number — the comparison key for "update available". Tolerant of a
|
||||
/// legacy string `version` during the schema migration (parsed to `None`).
|
||||
#[serde(default, deserialize_with = "de_flexible_i64")] version: Option<i64>,
|
||||
#[serde(default)] version_string: Option<String>,
|
||||
#[serde(default)] version_release_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Deserializes an optional integer that may arrive as a JSON number or (during the
|
||||
/// string-`version` → integer-`version` migration) as a numeric string. A
|
||||
/// non-numeric string (`"2.0.1"`) yields `None` rather than a hard parse error, so
|
||||
/// one un-migrated entry never fails the whole feed.
|
||||
fn de_flexible_i64<'de, D>(d: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = Option::<serde_json::Value>::deserialize(d)?;
|
||||
Ok(v.and_then(|v| match v {
|
||||
serde_json::Value::Number(n) => n.as_i64(),
|
||||
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
|
||||
_ => None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -182,7 +204,11 @@ struct VerifySpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct Manifest {
|
||||
#[serde(default)] name: Option<String>,
|
||||
#[serde(default)] version: Option<String>,
|
||||
/// The monotonic **integer** build number (see [`IndexEntry::version`]). Tolerant
|
||||
/// of a legacy string during migration.
|
||||
#[serde(default, deserialize_with = "de_flexible_i64")] version: Option<i64>,
|
||||
#[serde(default)] version_string: Option<String>,
|
||||
#[serde(default)] version_release_date: Option<String>,
|
||||
#[serde(default, rename = "type")] kind: Option<String>,
|
||||
#[serde(default)] transport: Option<String>,
|
||||
#[serde(default)] requires: Vec<String>,
|
||||
@@ -316,7 +342,14 @@ fn files_of<'a>(entry: &'a IndexEntry, manifest: &'a Manifest) -> &'a [FileEntry
|
||||
pub struct MarketplaceCard {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
/// The feed's build number (integer) and its display metadata.
|
||||
pub version: Option<i64>,
|
||||
pub version_string: Option<String>,
|
||||
pub version_release_date: Option<String>,
|
||||
/// The installed catalog row's build number, when installed. `update_available`
|
||||
/// is `true` when the feed's `version` is strictly greater.
|
||||
pub installed_version: Option<i64>,
|
||||
pub update_available: bool,
|
||||
/// `per_user` | `global`
|
||||
pub scope: String,
|
||||
/// `remote` | `local_script`
|
||||
@@ -342,15 +375,25 @@ pub struct MarketplaceCard {
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
fn card_of(h: &Hydrated, installed: bool) -> MarketplaceCard {
|
||||
fn card_of(h: &Hydrated, installed: bool, installed_version: Option<i64>) -> MarketplaceCard {
|
||||
let source = norm_source(&h.entry, &h.manifest);
|
||||
let doc = h.manifest.docs.first().cloned().unwrap_or_default();
|
||||
// Prefer the manifest's version trio, falling back to the index entry's.
|
||||
let version = h.manifest.version.or(h.entry.version);
|
||||
let version_string = h.manifest.version_string.clone().or_else(|| h.entry.version_string.clone());
|
||||
let version_release_date = h.manifest.version_release_date.clone().or_else(|| h.entry.version_release_date.clone());
|
||||
// "Update available" is a strict integer bump on an already-installed connector.
|
||||
let update_available = matches!((version, installed_version), (Some(feed), Some(have)) if feed > have);
|
||||
MarketplaceCard {
|
||||
id: h.entry.id.clone(),
|
||||
name: h.entry.name.clone()
|
||||
.or_else(|| h.manifest.name.clone())
|
||||
.unwrap_or_else(|| h.entry.id.clone()),
|
||||
version: h.manifest.version.clone(),
|
||||
version,
|
||||
version_string,
|
||||
version_release_date,
|
||||
installed_version,
|
||||
update_available,
|
||||
scope: norm_scope(&h.entry, &h.manifest),
|
||||
transport: norm_transport(&h.manifest, &source),
|
||||
source,
|
||||
@@ -507,14 +550,16 @@ pub async fn list(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
|
||||
let feed = feed(q.refresh).await?;
|
||||
let installed: std::collections::HashSet<String> = mcp_catalog::list(skald.db())
|
||||
// name → installed build number (present = installed; the value drives the
|
||||
// "update available" comparison, `None` for a pre-versioning install).
|
||||
let installed: std::collections::HashMap<String, Option<i64>> = mcp_catalog::list(skald.db())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.name)
|
||||
.map(|r| (r.name, r.version))
|
||||
.collect();
|
||||
let cards: Vec<MarketplaceCard> = feed
|
||||
.iter()
|
||||
.map(|h| card_of(h, installed.contains(&h.entry.id)))
|
||||
.map(|h| card_of(h, installed.contains_key(&h.entry.id), installed.get(&h.entry.id).copied().flatten()))
|
||||
.collect();
|
||||
Ok(Json(json!({ "base_url": base_url(), "connectors": cards })))
|
||||
}
|
||||
@@ -717,6 +762,13 @@ pub async fn install(
|
||||
.llm_short_description
|
||||
.as_deref()
|
||||
.or(h.entry.user_description.as_deref()),
|
||||
// Snapshot the feed's version so a later listing can compare it against a
|
||||
// newer feed and surface "update available". Manifest wins over index.
|
||||
version: h.manifest.version.or(h.entry.version),
|
||||
version_string: h.manifest.version_string.as_deref()
|
||||
.or(h.entry.version_string.as_deref()),
|
||||
version_release_date: h.manifest.version_release_date.as_deref()
|
||||
.or(h.entry.version_release_date.as_deref()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -1103,7 +1155,7 @@ mod tests {
|
||||
assert!(!feed.is_empty(), "feed returned no connectors");
|
||||
|
||||
for h in &feed {
|
||||
let c = card_of(h, false);
|
||||
let c = card_of(h, false, None);
|
||||
println!(
|
||||
"{:<8} scope={:<8} source={:<12} transport={:<6} auth={:<7} files={}",
|
||||
c.id, c.scope, c.source, c.transport, c.auth_kind, c.file_count
|
||||
|
||||
@@ -332,6 +332,11 @@ pub async fn catalog_upsert(
|
||||
icon_large_path: None,
|
||||
friendly_name: body.friendly_name.as_deref(),
|
||||
description: body.description.as_deref(),
|
||||
// Versioning is the feed's to set (marketplace install); the manual form
|
||||
// leaves it untouched (COALESCE in `upsert`).
|
||||
version: None,
|
||||
version_string: None,
|
||||
version_release_date: None,
|
||||
}).await?;
|
||||
Ok(Json(json!({ "id": id })))
|
||||
}
|
||||
@@ -751,6 +756,22 @@ pub async fn activate(
|
||||
.and_then(|e| serde_json::to_string(e).ok())
|
||||
.or_else(|| entry.env_json.clone());
|
||||
|
||||
// Reconcile node/python dependencies into the container before anything
|
||||
// tries to run the server (verify, the QR login, or a first message).
|
||||
// Blocking and one-time: the content-hash lock in `ensure_installed`
|
||||
// makes every later activation/login a no-op. A hard failure here is a
|
||||
// clear error rather than a connector that silently never starts.
|
||||
if entry.source == "local_script" {
|
||||
if let Some(script) = entry.script_path.as_deref() {
|
||||
if let Ok((folder, _)) = skald_core::mcp::split_script_path(script) {
|
||||
let container = skald_core::container::container_name(&auth.user_id);
|
||||
skald_core::mcp::install::ensure_installed(&auth.user_id, &name, folder, &container)
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(format!("dependency install failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth connectors do NOT activate directly (§15): the refresh token
|
||||
// comes from an interactive consent, not from the activation form. We
|
||||
// persist a PENDING row (files installed, command wired) and hand off to
|
||||
@@ -794,6 +815,42 @@ pub async fn activate(
|
||||
})));
|
||||
}
|
||||
|
||||
// QR (and other interactive-login) connectors, e.g. WhatsApp: unlike
|
||||
// OAuth there is no code to paste back — the server must RUN to produce
|
||||
// the QR, and the credential is the on-disk session it persists after the
|
||||
// scan. Insert a PENDING row, start the server so it emits a QR, and hand
|
||||
// off to the login panel, which polls `/mcp/login/status` until it reports
|
||||
// `ready` (flipping the row so `all_startable` picks it up next login).
|
||||
if entry.auth_kind == "qr" {
|
||||
let id = mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
source: &entry.source,
|
||||
transport: &entry.transport,
|
||||
command: command.as_deref(),
|
||||
args_json,
|
||||
env_json,
|
||||
url: entry.url.as_deref(),
|
||||
api_key: None, // the "credential" is the on-disk session
|
||||
oauth_provider: None,
|
||||
deliver_json: None,
|
||||
script_rel_path: script_rel_path.as_deref(),
|
||||
verify_command: None,
|
||||
verify_script_rel_path: None,
|
||||
auth_state: "pending",
|
||||
}).await?;
|
||||
if let Some(row) = mcp_user_servers::get(&ctx.pool, id).await? {
|
||||
let container = skald_core::container::container_name(&auth.user_id);
|
||||
let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
|
||||
// The QR only appears once the socket connects; ignore a start
|
||||
// error here — the login panel surfaces the real state via polling.
|
||||
let _ = ctx.user_mcp.start_server(spec).await;
|
||||
}
|
||||
return Ok(Json(json!({
|
||||
"id": id, "auth_state": "pending", "needs_login": true, "login_kind": "qr",
|
||||
})));
|
||||
}
|
||||
|
||||
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
|
||||
name: &name,
|
||||
catalog_name: Some(&entry.name),
|
||||
@@ -1045,3 +1102,94 @@ pub async fn oauth_complete(
|
||||
Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── user: interactive QR / device login for a per-user connector (§15) ─────────
|
||||
//
|
||||
// The generic seam for any connector whose login is neither an api-key nor an
|
||||
// OAuth code-paste (WhatsApp's QR today; SSH / other device pairings later): the
|
||||
// connector's server exposes a standard `login_status` tool returning
|
||||
// `{state, qr?, message}`, and Skald calls it DIRECTLY (never the agent). Unlike
|
||||
// OAuth, the server must be RUNNING to produce the credential (a QR the user
|
||||
// scans), and the credential is the on-disk session it persists — so there is
|
||||
// nothing to paste back, only a state to poll until it reports `ready`.
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginBody {
|
||||
/// The pending `mcp_user_servers` row to sign in.
|
||||
pub server_id: i64,
|
||||
}
|
||||
|
||||
/// Starts `row`'s server in the user's runtime if it is not already live —
|
||||
/// reconciling its deps first (a container recreated since activation may lack
|
||||
/// them). Idempotent: a no-op when the server is already connected.
|
||||
async fn ensure_user_server_running(
|
||||
skald: &Skald,
|
||||
ctx: &skald_core::skald::UserContext,
|
||||
user_id: &str,
|
||||
row: &mcp_user_servers::McpUserServerRow,
|
||||
) -> Result<(), ApiError> {
|
||||
if ctx.user_mcp.is_running(&row.name) {
|
||||
return Ok(());
|
||||
}
|
||||
let container = skald_core::container::container_name(user_id);
|
||||
skald_core::mcp::prepare_local_connector(skald.db(), user_id, &container, row).await;
|
||||
let spec = skald_core::mcp::user_row_spec_resolved(row, &container, skald.db()).await;
|
||||
ctx.user_mcp.start_server(spec).await
|
||||
.map_err(|e| ApiError::bad_request(format!("could not start the connector: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/mcp/login/status` — polls a connector's interactive-login state.
|
||||
/// Ensures the server is running, calls its `login_status` tool, and returns the
|
||||
/// `{state, qr, message}` it reports (with `id`/`auth_state`). When the connector
|
||||
/// reports `ready`, its row is flipped so `all_startable` starts it on the next
|
||||
/// login. Safe to poll on an interval from the login panel.
|
||||
pub async fn login_status(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
|
||||
.ok_or_else(|| ApiError::not_found("no such connector"))?;
|
||||
ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?;
|
||||
|
||||
let result = ctx.user_mcp.call(&row.name, "login_status", json!({})).await
|
||||
.map_err(|e| ApiError::bad_request(format!(
|
||||
"this connector has no interactive login (no login_status tool): {e}"
|
||||
)))?;
|
||||
// The tool returns a JSON string in a text part; fall back to a plain message
|
||||
// if a connector ever returns something else.
|
||||
let wire = result.to_wire();
|
||||
let mut v: Value = serde_json::from_str(&wire)
|
||||
.unwrap_or_else(|_| json!({ "state": "connecting", "message": wire }));
|
||||
let state = v.get("state").and_then(|s| s.as_str()).unwrap_or("connecting").to_string();
|
||||
|
||||
if state == "ready" && row.auth_state != "ready" {
|
||||
mcp_user_servers::set_auth_state(&ctx.pool, row.id, "ready").await?;
|
||||
}
|
||||
if let Value::Object(ref mut m) = v {
|
||||
m.insert("id".into(), json!(row.id));
|
||||
m.insert("auth_state".into(), json!(if state == "ready" { "ready" } else { "pending" }));
|
||||
}
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
/// `POST /api/mcp/login/reset` — re-arm the login (e.g. link a different phone).
|
||||
/// Calls the connector's `logout` tool to clear the on-disk session and force a
|
||||
/// fresh QR, and marks the row pending again.
|
||||
pub async fn login_reset(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
|
||||
.ok_or_else(|| ApiError::not_found("no such connector"))?;
|
||||
ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?;
|
||||
let _ = ctx.user_mcp.call(&row.name, "logout", json!({})).await;
|
||||
if row.auth_state == "ready" {
|
||||
mcp_user_servers::set_auth_state(&ctx.pool, row.id, "pending").await?;
|
||||
}
|
||||
Ok(Json(json!({ "ok": true, "id": row.id, "auth_state": "pending" })))
|
||||
}
|
||||
|
||||
@@ -158,6 +158,9 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// user: interactive OAuth login for a pending per-user connector (§15)
|
||||
.route("/mcp/oauth/start", post(mcp::oauth_start))
|
||||
.route("/mcp/oauth/complete", post(mcp::oauth_complete))
|
||||
// user: interactive QR / device login for a pending per-user connector (§15)
|
||||
.route("/mcp/login/status", post(mcp::login_status))
|
||||
.route("/mcp/login/reset", post(mcp::login_reset))
|
||||
// Dev / debug
|
||||
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode))
|
||||
.route("/dev/llm-requests", get(dev::list_llm_requests))
|
||||
|
||||
Reference in New Issue
Block a user