feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! Per-server MCP log files.
|
||||
//!
|
||||
//! Consumes [`McpLogLine`]s emitted by the MCP client crate and appends each to a
|
||||
//! dedicated file `logs/mcp/<name>.log`. Sources captured (see `crates/mcp-client`):
|
||||
//! child `stderr` (stdio), diverted `notifications/message` log records, and
|
||||
//! connection lifecycle events. No SQLite — a plain file per server, meant to be
|
||||
//! scanned later (e.g. by a diagnostics agent) for `[error]`/`[warning]` lines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mcp_client::McpLogLine;
|
||||
use tokio::fs::{File, OpenOptions};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Size at which a server's `.log` is rotated to `.log.1` (one backup kept), so a
|
||||
/// chatty server can't grow its file without bound.
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// Background task: drain `rx` and append every line to its server's file until
|
||||
/// shutdown or the channel closes. Spawned once from `McpManager::new`.
|
||||
pub(super) async fn log_consumer(
|
||||
mut rx: mpsc::UnboundedReceiver<McpLogLine>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
let mut writer = LogWriter::new(PathBuf::from("logs").join("mcp"));
|
||||
if let Err(e) = tokio::fs::create_dir_all(&writer.dir).await {
|
||||
warn!("mcp logs: cannot create {}: {e}", writer.dir.display());
|
||||
return;
|
||||
}
|
||||
info!("mcp: per-server log consumer started ({})", writer.dir.display());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => {
|
||||
info!("mcp: log consumer shutdown");
|
||||
break;
|
||||
}
|
||||
msg = rx.recv() => match msg {
|
||||
Some(line) => writer.write_line(line).await,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds one append handle per server (plus its tracked byte size for rotation).
|
||||
struct LogWriter {
|
||||
dir: PathBuf,
|
||||
files: HashMap<String, (File, u64)>,
|
||||
}
|
||||
|
||||
impl LogWriter {
|
||||
fn new(dir: PathBuf) -> Self {
|
||||
Self { dir, files: HashMap::new() }
|
||||
}
|
||||
|
||||
/// `logs/mcp/<sanitized>.log`. The name is sanitized so a server called
|
||||
/// `foo/bar` or `a b` can't escape the directory or produce an odd filename.
|
||||
fn path_for(&self, server: &str) -> PathBuf {
|
||||
self.dir.join(format!("{}.log", sanitize(server)))
|
||||
}
|
||||
|
||||
async fn open(&self, server: &str) -> std::io::Result<(File, u64)> {
|
||||
let path = self.path_for(server);
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path).await?;
|
||||
// Seed the tracked size from the existing file so appends keep counting
|
||||
// toward the rotation threshold across restarts.
|
||||
let size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
|
||||
Ok((file, size))
|
||||
}
|
||||
|
||||
/// Renames `<name>.log` → `<name>.log.1` (overwriting any previous backup) and
|
||||
/// reopens a fresh, empty handle.
|
||||
async fn rotate(&self, server: &str) -> std::io::Result<(File, u64)> {
|
||||
let path = self.path_for(server);
|
||||
let backup = PathBuf::from(format!("{}.1", path.display()));
|
||||
let _ = tokio::fs::rename(&path, &backup).await; // best-effort
|
||||
self.open(server).await
|
||||
}
|
||||
|
||||
async fn write_line(&mut self, line: McpLogLine) {
|
||||
let record = format_record(&line);
|
||||
let bytes = record.len() as u64;
|
||||
|
||||
// Open on first use for this server.
|
||||
if !self.files.contains_key(&line.server) {
|
||||
match self.open(&line.server).await {
|
||||
Ok(handle) => { self.files.insert(line.server.clone(), handle); }
|
||||
Err(e) => { warn!("mcp logs: open failed for '{}': {e}", line.server); return; }
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate before writing if this line would push the file over the cap.
|
||||
let over_cap = self.files.get(&line.server)
|
||||
.map(|(_, sz)| *sz + bytes > MAX_LOG_BYTES)
|
||||
.unwrap_or(false);
|
||||
if over_cap {
|
||||
match self.rotate(&line.server).await {
|
||||
Ok(handle) => { self.files.insert(line.server.clone(), handle); }
|
||||
Err(e) => { warn!("mcp logs: rotate failed for '{}': {e}", line.server); }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((file, size)) = self.files.get_mut(&line.server) {
|
||||
match file.write_all(record.as_bytes()).await {
|
||||
Ok(()) => {
|
||||
*size += bytes;
|
||||
let _ = file.flush().await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("mcp logs: write failed for '{}': {e}", line.server);
|
||||
// Drop the handle so the next line retries a fresh open.
|
||||
self.files.remove(&line.server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-07-03T12:34:56.789Z [warning] <text>` — an ISO-8601 UTC timestamp, the
|
||||
/// padded level tag, then the text. The padded tag keeps files column-aligned and
|
||||
/// makes `[error]`/`[warning]` trivial to grep.
|
||||
fn format_record(line: &McpLogLine) -> String {
|
||||
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
|
||||
let tag = format!("[{}]", line.level);
|
||||
format!("{ts} {tag:<12} {}\n", line.text)
|
||||
}
|
||||
|
||||
/// Keeps ASCII alphanumerics and `-_.`; everything else becomes `_`. Guarantees a
|
||||
/// non-empty, path-separator-free filename component.
|
||||
fn sanitize(name: &str) -> String {
|
||||
let s: String = name.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '_' })
|
||||
.collect();
|
||||
if s.is_empty() { "unknown".to_string() } else { s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_replaces_separators_and_spaces() {
|
||||
assert_eq!(sanitize("gmail"), "gmail");
|
||||
assert_eq!(sanitize("foo/bar"), "foo_bar");
|
||||
assert_eq!(sanitize("a b"), "a_b");
|
||||
assert_eq!(sanitize("claude_ai_Gmail"), "claude_ai_Gmail");
|
||||
assert_eq!(sanitize(""), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_record_has_timestamp_tag_and_text() {
|
||||
let rec = format_record(&McpLogLine::stderr("srv", "hello"));
|
||||
assert!(rec.contains("[stderr]"));
|
||||
assert!(rec.ends_with("hello\n"));
|
||||
assert!(rec.starts_with("20")); // year prefix of the ISO timestamp
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_line_appends_one_file_per_server() {
|
||||
let dir = std::env::temp_dir().join(format!("skald_mcplogs_{}", std::process::id()));
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let mut writer = LogWriter::new(dir.clone());
|
||||
writer.write_line(McpLogLine::stderr("gmail", "banner line")).await;
|
||||
writer.write_line(McpLogLine::lifecycle("gmail", "connected — 3 tool(s)")).await;
|
||||
writer.write_line(McpLogLine::from_message(
|
||||
"firecrawl",
|
||||
&serde_json::json!({ "level": "error", "data": "boom" }),
|
||||
)).await;
|
||||
|
||||
// One file per server, named from the sanitized server name.
|
||||
let gmail = tokio::fs::read_to_string(dir.join("gmail.log")).await.unwrap();
|
||||
assert!(gmail.contains("[stderr]") && gmail.contains("banner line"));
|
||||
assert!(gmail.contains("[lifecycle]") && gmail.contains("connected — 3 tool(s)"));
|
||||
|
||||
let fire = tokio::fs::read_to_string(dir.join("firecrawl.log")).await.unwrap();
|
||||
assert!(fire.contains("[error]") && fire.contains("boom"));
|
||||
// gmail's lines must not leak into firecrawl's file.
|
||||
assert!(!fire.contains("banner line"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use rand::RngExt;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::tools::ToolResult;
|
||||
|
||||
pub use mcp_client::{
|
||||
ElicitationHandler,
|
||||
McpCallResult, McpLogLine, McpLogTx, McpMedia, McpMediaData, McpMediaKind,
|
||||
McpServerClient, McpServerConfig, McpServerInfo, McpServerStatus, McpTool, McpTransport as McpTransportKind,
|
||||
parse_mcp_tool_name,
|
||||
http_server::McpHttpServer,
|
||||
server::{McpNotification, McpServer},
|
||||
};
|
||||
|
||||
use mcp_client::McpTransport;
|
||||
|
||||
mod logs;
|
||||
|
||||
const SERVER_START_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
// ── McpManager ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct McpManager {
|
||||
pool: Arc<SqlitePool>,
|
||||
servers: RwLock<HashMap<String, Arc<dyn McpServerClient>>>,
|
||||
errors: RwLock<HashMap<String, String>>,
|
||||
descriptions: RwLock<HashMap<String, Option<String>>>,
|
||||
notification_tx: mpsc::UnboundedSender<McpNotification>,
|
||||
/// Feeds per-server diagnostic lines (stderr, `notifications/message`,
|
||||
/// lifecycle) to the `logs::log_consumer`, which writes `logs/mcp/<name>.log`.
|
||||
log_tx: McpLogTx,
|
||||
/// Bridges server-initiated `elicitation/create` requests to the Inbox.
|
||||
/// Set once via `set_elicitation_handler` before `initialize` runs.
|
||||
elicitation_handler: RwLock<Option<Arc<dyn ElicitationHandler>>>,
|
||||
/// Data root for persisting non-text tool-result media (`media_dir`).
|
||||
data_root: PathBuf,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new(pool: Arc<SqlitePool>, shutdown: CancellationToken, data_root: impl Into<PathBuf>) -> Self {
|
||||
let (notification_tx, notification_rx) = mpsc::unbounded_channel::<McpNotification>();
|
||||
let (log_tx, log_rx) = mpsc::unbounded_channel::<McpLogLine>();
|
||||
|
||||
let pool_bg = pool.clone();
|
||||
tokio::spawn(Self::notification_consumer(pool_bg, notification_rx, shutdown.clone()));
|
||||
tokio::spawn(logs::log_consumer(log_rx, shutdown));
|
||||
|
||||
Self {
|
||||
pool,
|
||||
servers: RwLock::new(HashMap::new()),
|
||||
errors: RwLock::new(HashMap::new()),
|
||||
descriptions: RwLock::new(HashMap::new()),
|
||||
notification_tx,
|
||||
log_tx,
|
||||
elicitation_handler: RwLock::new(None),
|
||||
data_root: data_root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a lifecycle line to a server's per-server log file (start failure,
|
||||
/// timeout, connection). Used for transports that have no `stderr` of their
|
||||
/// own to carry connection diagnostics (notably HTTP/SSE).
|
||||
fn log_lifecycle(&self, server: &str, text: impl Into<String>) {
|
||||
let _ = self.log_tx.send(McpLogLine::lifecycle(server.to_string(), text));
|
||||
}
|
||||
|
||||
/// Directory under the data root where inline tool-result media (images,
|
||||
/// audio, embedded resources) is persisted and served from `/api/mcp-media/`.
|
||||
pub fn media_dir(&self) -> PathBuf {
|
||||
self.data_root.join("mcp_media")
|
||||
}
|
||||
|
||||
/// Wire the elicitation bridge. Must be called before `initialize` so that
|
||||
/// stdio servers are started with a handler for `elicitation/create`.
|
||||
pub fn set_elicitation_handler(&self, handler: Arc<dyn ElicitationHandler>) {
|
||||
*self.elicitation_handler.write().unwrap() = Some(handler);
|
||||
}
|
||||
|
||||
fn elicitation_handler(&self) -> Option<Arc<dyn ElicitationHandler>> {
|
||||
self.elicitation_handler.read().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn notification_consumer(
|
||||
pool: Arc<SqlitePool>,
|
||||
mut rx: mpsc::UnboundedReceiver<McpNotification>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => {
|
||||
info!("mcp: notification consumer shutdown");
|
||||
break;
|
||||
}
|
||||
msg = rx.recv() => match msg {
|
||||
Some((source, payload)) => {
|
||||
let method = payload["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = serde_json::to_string(&payload["params"]).unwrap_or_else(|_| "{}".to_string());
|
||||
match crate::db::mcp_events::insert(&pool, &source, &method, ¶ms).await {
|
||||
Ok(id) => info!("mcp_event stored: id={id} source={source} method={method}"),
|
||||
Err(e) => warn!("mcp_events insert failed (source={source} method={method}): {e}"),
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg_from_row(row: &crate::db::mcp_servers::McpServerRow) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
name: row.name.clone(),
|
||||
transport: match row.transport.as_str() {
|
||||
"http" => McpTransport::Http,
|
||||
"sse" => McpTransport::Sse,
|
||||
_ => McpTransport::Stdio,
|
||||
},
|
||||
command: row.command.clone(),
|
||||
args: Some(row.args()).filter(|v| !v.is_empty()),
|
||||
env: Some(row.env()).filter(|m| !m.is_empty()),
|
||||
url: row.url.clone(),
|
||||
api_key: row.api_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_one(
|
||||
cfg: &McpServerConfig,
|
||||
notification_tx: Option<mpsc::UnboundedSender<McpNotification>>,
|
||||
log_tx: Option<McpLogTx>,
|
||||
elicitation_handler: Option<Arc<dyn ElicitationHandler>>,
|
||||
) -> Result<Arc<dyn McpServerClient>> {
|
||||
match cfg.transport {
|
||||
McpTransport::Stdio => {
|
||||
// Elicitation and per-server diagnostic capture (stderr +
|
||||
// notifications/message) are stdio-only. HTTP/SSE has no stderr and
|
||||
// no async notification stream, so it only gets lifecycle lines,
|
||||
// emitted by the manager (see `log_lifecycle`).
|
||||
McpServer::start(cfg, notification_tx, log_tx, elicitation_handler).await
|
||||
.map(|s| Arc::new(s) as Arc<dyn McpServerClient>)
|
||||
}
|
||||
McpTransport::Http | McpTransport::Sse => {
|
||||
McpHttpServer::start(cfg).await
|
||||
.map(|s| Arc::new(s) as Arc<dyn McpServerClient>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&self) {
|
||||
let rows = match crate::db::mcp_servers::all_enabled(&self.pool).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => { warn!("McpManager::initialize: failed to read DB: {e}"); return; }
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
info!("No enabled MCP servers in DB — MCP disabled.");
|
||||
crate::boot::section("MCP servers — none enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
let cfgs: Vec<_> = rows.iter().map(Self::cfg_from_row).collect();
|
||||
{
|
||||
let mut descs = self.descriptions.write().unwrap();
|
||||
for row in &rows {
|
||||
descs.insert(row.name.clone(), row.description.clone());
|
||||
}
|
||||
}
|
||||
crate::boot::section(format!(
|
||||
"MCP servers — connecting to {} in background", cfgs.len()
|
||||
));
|
||||
let handles: Vec<_> = cfgs.into_iter().map(|cfg| {
|
||||
let tx = self.notification_tx.clone();
|
||||
let log_tx = self.log_tx.clone();
|
||||
let eh = self.elicitation_handler();
|
||||
tokio::spawn(async move {
|
||||
info!("MCP server '{}': starting…", cfg.name);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(SERVER_START_TIMEOUT_SECS),
|
||||
Self::start_one(&cfg, Some(tx), Some(log_tx), eh),
|
||||
).await;
|
||||
(cfg.name, cfg.transport, result)
|
||||
})
|
||||
}).collect();
|
||||
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok((name, _, Ok(Ok(s)))) => {
|
||||
let tool_names: Vec<_> = s.tools().iter().map(|t| t.name.as_str()).collect();
|
||||
info!("MCP server '{}' ready — {} tool(s): {}", name, tool_names.len(), tool_names.join(", "));
|
||||
let n = tool_names.len();
|
||||
crate::boot::ok(format!("{name} ({n} tool{})", if n == 1 { "" } else { "s" }));
|
||||
self.log_lifecycle(&name, format!("connected — {n} tool(s)"));
|
||||
self.servers.write().unwrap().insert(name, s);
|
||||
}
|
||||
Ok((name, _, Ok(Err(e)))) => {
|
||||
warn!("MCP server '{}' failed to start: {e}", name);
|
||||
crate::boot::fail(format!("{name} — {e}"));
|
||||
self.log_lifecycle(&name, format!("failed to start: {e}"));
|
||||
self.errors.write().unwrap().insert(name, e.to_string());
|
||||
}
|
||||
Ok((name, _, Err(_))) => {
|
||||
let msg = format!("startup timed out after {SERVER_START_TIMEOUT_SECS}s");
|
||||
warn!("MCP server '{}' {msg}", name);
|
||||
crate::boot::fail(format!("{name} — {msg}"));
|
||||
self.log_lifecycle(&name, &msg);
|
||||
self.errors.write().unwrap().insert(name, msg);
|
||||
}
|
||||
Err(e) => { warn!("MCP startup task panicked: {e}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register(&self, p: crate::db::mcp_servers::UpsertParams<'_>) -> Result<Vec<String>> {
|
||||
let name = p.name.to_string();
|
||||
|
||||
crate::db::mcp_servers::upsert(&self.pool, p).await?;
|
||||
|
||||
let rows = crate::db::mcp_servers::all_enabled(&self.pool).await?;
|
||||
let row = rows.into_iter().find(|r| r.name == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("register: server '{}' not found after upsert", name))?;
|
||||
let cfg = Self::cfg_from_row(&row);
|
||||
|
||||
let client = tokio::time::timeout(
|
||||
Duration::from_secs(SERVER_START_TIMEOUT_SECS),
|
||||
Self::start_one(&cfg, Some(self.notification_tx.clone()), Some(self.log_tx.clone()), self.elicitation_handler()),
|
||||
).await
|
||||
.map_err(|_| {
|
||||
self.log_lifecycle(&name, "timed out during connection");
|
||||
anyhow::anyhow!("MCP server '{}' timed out during connection", name)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
self.log_lifecycle(&name, format!("failed to start: {e}"));
|
||||
anyhow::anyhow!("MCP server '{}' failed to start: {e}", name)
|
||||
})?;
|
||||
|
||||
let tool_names: Vec<String> = client.tools().iter().map(|t| t.name.clone()).collect();
|
||||
self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len()));
|
||||
self.errors.write().unwrap().remove(&name);
|
||||
self.descriptions.write().unwrap().insert(name.clone(), row.description.clone());
|
||||
self.servers.write().unwrap().insert(name, client);
|
||||
|
||||
Ok(tool_names)
|
||||
}
|
||||
|
||||
pub async fn unregister(&self, name: &str) -> Result<()> {
|
||||
crate::db::mcp_servers::delete(&self.pool, name).await?;
|
||||
self.servers.write().unwrap().remove(name);
|
||||
self.errors.write().unwrap().remove(name);
|
||||
self.descriptions.write().unwrap().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
|
||||
crate::db::mcp_servers::set_enabled(&self.pool, name, enabled).await
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<McpServerInfo>> {
|
||||
let rows = crate::db::mcp_servers::all(&self.pool).await?;
|
||||
let servers = self.servers.read().unwrap();
|
||||
let errors = self.errors.read().unwrap();
|
||||
|
||||
let infos = rows.into_iter().map(|row| {
|
||||
let status = if !row.enabled {
|
||||
McpServerStatus::Disabled
|
||||
} else if let Some(s) = servers.get(&row.name) {
|
||||
McpServerStatus::Running {
|
||||
tools: s.tools().iter().map(|t| t.name.clone()).collect(),
|
||||
}
|
||||
} else if let Some(e) = errors.get(&row.name) {
|
||||
McpServerStatus::Error { message: e.clone() }
|
||||
} else {
|
||||
McpServerStatus::Error { message: "not connected".to_string() }
|
||||
};
|
||||
McpServerInfo {
|
||||
name: row.name,
|
||||
transport: row.transport,
|
||||
description: row.description,
|
||||
friendly_name: row.friendly_name,
|
||||
status,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(infos)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<McpTool> {
|
||||
self.servers.read().unwrap().values()
|
||||
.flat_map(|s| s.tools().iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
|
||||
self.servers.read().unwrap().iter()
|
||||
.filter(|(name, _)| names.contains(name))
|
||||
.flat_map(|(_, s)| s.tools().iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn server_descriptions(&self) -> HashMap<String, Option<String>> {
|
||||
self.descriptions.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn server_infos(&self) -> Vec<Value> {
|
||||
self.servers.read().unwrap().iter()
|
||||
.map(|(name, s)| json!({
|
||||
"name": name,
|
||||
"tools": s.tools().iter().map(|t| json!({
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
|
||||
let s = self.servers.read().unwrap()
|
||||
.get(server)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP server '{server}' not found"))?;
|
||||
match s.call_tool(tool, args).await? {
|
||||
McpCallResult::Text(t) => Ok(ToolResult::Text(t)),
|
||||
McpCallResult::Json(v) => Ok(ToolResult::Json(v)),
|
||||
McpCallResult::Media { text, structured, items } =>
|
||||
Ok(ToolResult::Text(self.persist_media(server, text, structured, items).await)),
|
||||
// Experimental Tasks — defensive fallback. Normally the transport's
|
||||
// `call_tool` polls a deferred task to completion (block-and-poll) and
|
||||
// returns the real result, so this arm is not hit. It only surfaces a
|
||||
// raw handle if polling was bypassed, so the result is never lost.
|
||||
McpCallResult::Task(t) => {
|
||||
let ttl = t.ttl_ms.map(|ms| format!(", ttl {}s", ms / 1000)).unwrap_or_default();
|
||||
Ok(ToolResult::Text(format!(
|
||||
"MCP server '{server}' deferred this call as task `{}` (status: {:?}{ttl}). \
|
||||
Task polling is not implemented yet, so the result can't be retrieved automatically.",
|
||||
t.task_id, t.status,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the inline media of an MCP tool result under [`media_dir`] and
|
||||
/// composes a markdown text result that references each item by URL — so the
|
||||
/// model can surface it (the frontend renders the markdown) instead of the
|
||||
/// bytes being silently dropped. `resource_link`s are passed through by URI
|
||||
/// without downloading. Falls back to a textual placeholder if a write fails,
|
||||
/// so a disk error never loses the rest of the result.
|
||||
async fn persist_media(
|
||||
&self,
|
||||
server: &str,
|
||||
text: Option<String>,
|
||||
structured: Option<Value>,
|
||||
items: Vec<McpMedia>,
|
||||
) -> String {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if let Some(t) = text.filter(|t| !t.is_empty()) {
|
||||
out.push(t);
|
||||
}
|
||||
|
||||
for item in items {
|
||||
match item.data {
|
||||
McpMediaData::Inline { bytes, mime } => {
|
||||
let file = format!("{}.{}", random_id(), ext_for_mime(&mime));
|
||||
let dir = self.media_dir();
|
||||
let saved = async {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
tokio::fs::write(dir.join(&file), &bytes).await
|
||||
}.await;
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
let url = format!("/api/mcp-media/{file}");
|
||||
let kb = bytes.len().div_ceil(1024);
|
||||
out.push(match item.kind {
|
||||
McpMediaKind::Image => format!(" ({mime}, {kb} KB)"),
|
||||
McpMediaKind::Audio => format!("[audio]({url}) ({mime}, {kb} KB)"),
|
||||
McpMediaKind::Resource => format!("[file]({url}) ({mime}, {kb} KB)"),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MCP '{server}': failed to persist tool-result media: {e}");
|
||||
out.push(format!("[media not saved: {mime}]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
McpMediaData::Link { uri, mime } => {
|
||||
let label = mime.as_deref().unwrap_or("resource");
|
||||
out.push(format!("[{label}]({uri})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sc) = structured {
|
||||
if let Ok(s) = serde_json::to_string_pretty(&sc) {
|
||||
out.push(format!("```json\n{s}\n```"));
|
||||
}
|
||||
}
|
||||
|
||||
out.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a 32-char alphanumeric id for a persisted media filename
|
||||
/// (mirrors `ImageGeneratorManager`).
|
||||
fn random_id() -> String {
|
||||
rand::rng()
|
||||
.sample_iter(rand::distr::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maps a MIME type to a file extension for persisted MCP media; `bin` for unknown.
|
||||
pub fn ext_for_mime(mime: &str) -> &'static str {
|
||||
match mime.split(';').next().unwrap_or("").trim() {
|
||||
"image/png" => "png",
|
||||
"image/jpeg" => "jpg",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
"image/svg+xml" => "svg",
|
||||
"audio/wav" | "audio/x-wav" => "wav",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/ogg" => "ogg",
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"application/pdf" => "pdf",
|
||||
"application/json" => "json",
|
||||
"text/plain" => "txt",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`ext_for_mime`] for serving persisted media with the right
|
||||
/// `Content-Type`; generic binary for unknown extensions.
|
||||
pub fn content_type_for_ext(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"png" => "image/png",
|
||||
"jpg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"wav" => "audio/wav",
|
||||
"mp3" => "audio/mpeg",
|
||||
"ogg" => "audio/ogg",
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => "video/webm",
|
||||
"pdf" => "application/pdf",
|
||||
"json" => "application/json",
|
||||
"txt" => "text/plain",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user