feat: let an agent ask what its connectors are, instead of guessing
Nightly Build / build (push) Successful in 7m44s

An agent that wanted to know which MCP servers it had called
`list_mcp_servers` — a tool that has never existed anywhere in this
repo — and got "unknown tool". It was not a random hallucination: the
prompt block says "the system prompt shows available servers", and
`render_mcp_list` returned an empty string when nothing was connected.
The model read a promise, found no table, and invented the discovery
tool the text implied. The `mcp` kinds of `list_items`/`toggle_item`
had been removed to close the §14 RCE vector, which was right for the
write half and left no read half at all.

So `list_items` gains `type: "mcp"` and returns the whole picture in
one call, split into four buckets that each answer a different
question: what is already loaded (call its tools directly), what is
ready for `activate_tools`, what is installed but unusable and why,
and what the user could still activate. Conflating the first two is
what produced the original failure, so they stay apart. Every entry
carries a derived note and a next step; when the step is a human one,
it says so and names the UI page, because there is no tool for it.

Read-only, and structurally so: `toggle_item` deliberately gains
nothing, and the new `McpDirectory` trait exposes exactly one method.
Enabling a connector from a tool is the thing §14 removed, and a wider
seam here is how it would come back. Deny-by-default survives the
report — an ungranted connector is not named at all, since a listing
of what to ask for is itself a leak — except for a catalogue manager,
who cannot administer what they cannot see.

Three sources answer three questions and none is redundant: the
registry says what exists and who may have it, the owner database says
what was activated, and the live runtimes say what is connected right
now — a row can read `ready` while its process is dead. The live half
reaches the tool through the turn's extension map, alongside the pool
and the fs view; with no live view the durable picture still renders,
so freshness is an improvement and never a precondition.

The static `__MCP_LIST__` table stays as it was, because it is frozen
per conversation for prompt-cache stability. Its empty case now says
so out loud and points at the tool.
This commit is contained in:
2026-08-04 19:41:18 +01:00
parent daaceff6ba
commit efb5b1dc33
14 changed files with 693 additions and 22 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
# MCP servers
MCP tools are lazy-loaded. The system prompt shows available servers — call `activate_tools(["name", ...])` to load their tools into the session. The grant persists for the whole session (survives restart). You do not need to call it again for the same server.
MCP servers are what users call **Connectors**. Their tools are lazy-loaded: the table below lists the loadable ones — call `activate_tools(["name", ...])` to load their tools into the session. The grant persists for the whole session (survives restart). You do not need to call it again for the same server.
Once active, tools are called as `mcp__<server>__<tool>` (e.g. `mcp__gmail__send_message`, `mcp__gcal__list_events`).
The table is a static summary. For the full picture — which connectors are already loaded, which are installed but unusable and why, and which the user could still activate — call `list_items({"type": "mcp"})`. Never guess at a connector's state, and never look for a tool that enables or configures one: there is none, it is done by the user in the web UI.
<!-- MCP_LIST -->
+32
View File
@@ -58,6 +58,38 @@ pub struct ToolContext {
/// the container they resolve into. `execute_cmd` execs into `fs.container_name`
/// and the disk fs-tools resolve physical paths against `fs`'s host bases.
pub fs: Arc<crate::user_fs::UserFs>,
/// The caller's live MCP runtimes, read-only (blueprint §7). `None` outside a
/// turn that has one — a tool must degrade to whatever the database says
/// rather than fail.
pub mcp: Option<Arc<dyn McpDirectory>>,
}
// ── McpDirectory ──────────────────────────────────────────────────────────────
/// One connected MCP server, as the tool layer sees it.
#[derive(Debug, Clone)]
pub struct McpServerView {
/// Runtime name — the id `activate_tools` takes and the `mcp__<name>__` prefix.
pub name: String,
pub description: Option<String>,
/// Bare tool names, without the `mcp__<server>__` prefix the model calls.
pub tools: Vec<String>,
}
/// Read-only window onto the caller's live MCP runtimes, threaded into
/// [`ToolContext`] so a tool can report what is **actually connected right now**
/// — the one thing no query can answer, since a connector row can read `ready`
/// while its process is dead, and a per-user server appears only once its
/// container has started it.
///
/// Deliberately read-only and deliberately narrow. Enabling, activating or
/// configuring a connector is not an agent-reachable operation (blueprint §14 —
/// the whole reason the old `register_mcp` tool was removed), and a wider trait
/// here is precisely the seam through which it would become one again.
pub trait McpDirectory: Send + Sync {
/// Every server this caller's session can currently reach, in whatever order
/// the runtimes report them.
fn connected(&self) -> Vec<McpServerView>;
}
// ── Tool trait ────────────────────────────────────────────────────────────────
@@ -48,7 +48,7 @@ use crate::loop_adapters::projection_cfg::skald_assembler;
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
use crate::loop_adapters::toolset::{CallerMcp, CallerUserId, SkaldToolSet};
use crate::mcp::McpProvider;
use crate::session::handler::PendingUserInput;
use crate::session::handler::interface_tools::AgentRunConfig;
@@ -294,6 +294,9 @@ impl UserLoopRuntime {
extensions.insert(self.pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
extensions.insert(Arc::new(CallerMcp(Arc::new(
crate::mcp::McpDirectoryHandle(self.mcp.clone()),
))));
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) + the owner's request log ──
+14 -2
View File
@@ -313,15 +313,27 @@ impl AgentSystemContext {
.map(|t| t.server_name)
.collect();
// An empty section used to render as nothing at all, under a paragraph
// that promises "the system prompt shows available servers" — so the
// model read a promise, found no table, and invented a discovery tool
// (`list_mcp_servers`, which has never existed). Say the absence out
// loud, and name the tool that explains it.
if all_servers.is_empty() {
return String::new();
return String::from(
"## MCP servers\n\nNo connector is loadable in this session right now. \
Call `list_items({\"type\": \"mcp\"})` to find out why — some may be \
installed but waiting on a sign-in, and others may be available for \
the user to activate.\n",
);
}
let descriptions = self.mcp.server_descriptions();
let mut out = String::from(
"## MCP servers\n\nConnectors you can load with `activate_tools([\"name\"])`. \
Once loaded, a server's tools are callable as `mcp__<name>__<tool>`:\n\n",
Once loaded, a server's tools are callable as `mcp__<name>__<tool>`. \
For their current state — already loaded, waiting on a sign-in, or \
activatable by the user — call `list_items({\"type\": \"mcp\"})`:\n\n",
);
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &all_servers {
@@ -29,6 +29,10 @@ use crate::tools::tool_names::CONFIG_GROUP;
#[derive(Debug, Clone)]
pub struct CallerUserId(pub String);
/// The caller's live MCP view, as the read-only window a tool may hold.
/// Inserted by the host at TurnParams construction, alongside `CallerUserId`.
pub struct CallerMcp(pub Arc<dyn core_api::tool::McpDirectory>);
/// Reads the `core_api::tool::ToolContext` pieces out of a `ToolCtx`:
/// owner pool + fs from the type-map, session id from the conversation.
fn core_tool_context(ctx: &ToolCtx) -> Result<core_api::tool::ToolContext, ToolFailure> {
@@ -49,7 +53,11 @@ fn core_tool_context(ctx: &ToolCtx) -> Result<core_api::tool::ToolContext, ToolF
.strip_prefix("session:")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_default();
Ok(core_api::tool::ToolContext { session_id, user_id, pool, fs })
let mcp = ctx
.extensions
.get::<CallerMcp>()
.map(|m| Arc::clone(&m.0) as Arc<dyn core_api::tool::McpDirectory>);
Ok(core_api::tool::ToolContext { session_id, user_id, pool, fs, mcp })
}
/// Maps a core-api `ToolResult` to the crate's `ToolOutput`.
+1 -1
View File
@@ -32,7 +32,7 @@ pub mod verify;
pub use install::{CONNECTORS_DIR, MANIFEST_FILE, connector_dir, ensure_installed_host, install_into_home, split_script_path};
pub use oauth::DeliverSpec;
pub use provider::{McpProvider, SharedGlobalAccess, UserMcpView};
pub use provider::{McpDirectoryHandle, McpProvider, SharedGlobalAccess, UserMcpView};
pub use verify::{VerifyReport, VerifyTarget, apply_placeholders, run_verify};
const SERVER_START_TIMEOUT_SECS: u64 = 120;
+29
View File
@@ -148,3 +148,32 @@ impl McpProvider for UserMcpView {
}
}
}
/// Adapts any [`McpProvider`] to the tool layer's read-only
/// [`McpDirectory`](core_api::tool::McpDirectory) window.
///
/// A newtype rather than an impl on the trait object because the two traits live
/// in different crates and only one of them may know about the other: `core-api`
/// must not learn what an `McpManager` is.
pub struct McpDirectoryHandle(pub Arc<dyn McpProvider>);
impl core_api::tool::McpDirectory for McpDirectoryHandle {
fn connected(&self) -> Vec<core_api::tool::McpServerView> {
let descriptions = self.0.server_descriptions();
// Group the flat tool list by server. BTreeMap so the report is stable
// across calls — a model re-reading it should not see things move.
let mut by_server: std::collections::BTreeMap<String, Vec<String>> =
descriptions.keys().map(|n| (n.clone(), Vec::new())).collect();
for t in self.0.tools() {
by_server.entry(t.server_name).or_default().push(t.name);
}
by_server
.into_iter()
.map(|(name, tools)| core_api::tool::McpServerView {
description: descriptions.get(&name).cloned().flatten(),
name,
tools,
})
.collect()
}
}
+8 -4
View File
@@ -218,11 +218,15 @@ impl Tools {
tool_registry.register(crate::tools::ast_outline::AstOutline::new());
tool_registry.register(crate::tools::exec::ExecuteCmd);
tool_registry.register(crate::tools::read_notification::ReadNotification);
// Unified listing / toggling across plugins, cron (+ agents for list). MCP
// is no longer agent-managed (blueprint §14): connectors are curated by the
// admin and activated by the user via the Connectors UI/API, not tools.
// Unified listing / toggling across plugins, cron (+ agents and MCP for
// list). MCP is listed but never agent-*managed* (blueprint §14):
// connectors are curated by the admin and activated by the user via the
// Connectors UI/API — hence `list_items` gained the type and
// `toggle_item` deliberately did not.
tool_registry.register(crate::tools::list_items::ListItems::new(
Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
Arc::clone(&integrations.plugin_manager),
Arc::clone(&tasks.cron),
Arc::clone(&rt.db)));
tool_registry.register(crate::tools::toggle_item::ToggleItem::new(
Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::cron_jobs::DeleteCronJob);
+6 -6
View File
@@ -722,7 +722,7 @@ mod tests {
let write = WriteFile::new(Arc::clone(&shared));
let read = ReadFile::new(Arc::clone(&shared));
let list = ListFiles::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs(), mcp: None };
// Private write lands in the user pool — and never in the shared one.
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
@@ -772,7 +772,7 @@ mod tests {
let insert = InsertAtLine::new(Arc::clone(&shared));
let replace = ReplaceLines::new(Arc::clone(&shared));
let search = SearchFile::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs(), mcp: None };
async fn note(pool: &SqlitePool, path: &str) -> String {
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
@@ -817,7 +817,7 @@ mod tests {
let write = WriteFile::new(Arc::clone(&shared));
let search = MemorySearch::new(Arc::clone(&shared));
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs() };
let ctx = ToolContext { session_id: 1, user_id: "u_test".into(), pool: Arc::clone(&user), fs: test_fs(), mcp: None };
// one note in each store, both mentioning "wifi"
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
@@ -867,7 +867,7 @@ mod tests {
let fs = Arc::new(UserFs::new(
"u1", home.clone(), "skald-u1", PathBuf::from("/root"), vec![], vec![], None,
));
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs };
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs, mcp: None };
let read = ReadFile::new(Arc::clone(&shared));
// image → Media, carrying the resolved host path + MIME.
@@ -911,7 +911,7 @@ mod tests {
let fs = Arc::new(UserFs::new(
"u1", home.clone(), "skald-u1", PathBuf::from("/root"), vec![], vec![], None,
));
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs };
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs, mcp: None };
let write = WriteFile::new(Arc::clone(&shared));
let edit = EditFile::new(Arc::clone(&shared));
let grep = GrepFiles::new();
@@ -957,7 +957,7 @@ mod tests {
let fs = Arc::new(UserFs::new(
"u1", home.clone(), "skald-u1", PathBuf::from("/root"), vec![], vec![], None,
));
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs };
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs, mcp: None };
let append = AppendFile::new(Arc::clone(&shared));
// Absent file → created, with the trailing newline supplied for us.
+46 -5
View File
@@ -2,11 +2,12 @@ use std::sync::Arc;
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::agents;
use crate::cron::TaskManager;
use crate::plugin::PluginManager;
use crate::tools::{Tool, ToolDescriptionLength};
use crate::tools::{Tool, ToolContext, ToolDescriptionLength, ToolExecution};
/// Unified read-only listing tool. Replaces the per-resource `list_mcp`,
/// `list_plugins`, `list_cron_jobs` and `list_agents` tools: same operation
@@ -20,11 +21,20 @@ use crate::tools::{Tool, ToolDescriptionLength};
pub struct ListItems {
plugins: Arc<PluginManager>,
cron: Arc<TaskManager>,
/// The registry (`system.db`), for the `mcp` report's instance-wide half:
/// the catalog, the global connectors and the caller's capabilities. Captured
/// at construction because it is the same file for everyone — the *owner*
/// half arrives per call, on the `ToolContext`.
registry: Arc<SqlitePool>,
}
impl ListItems {
pub fn new(plugins: Arc<PluginManager>, cron: Arc<TaskManager>) -> Self {
Self { plugins, cron }
pub fn new(
plugins: Arc<PluginManager>,
cron: Arc<TaskManager>,
registry: Arc<SqlitePool>,
) -> Self {
Self { plugins, cron, registry }
}
}
@@ -37,6 +47,7 @@ impl Tool for ListItems {
• `plugins` — plugins with id, name, description, enabled flag (persisted), and running flag (live).\n\
• `cron` — scheduled tasks/cron jobs with id, title, cron expression, agent_id, enabled, kind, last/next run.\n\
• `agents` — sub-agents available to delegate to (id, name, description, optional `instructions` on how to call the agent well, optional client). Do NOT invoke the `main` agent.\n\
• `mcp` — MCP servers, which users call \"Connectors\": which ones are already loaded into this session, which are ready for `activate_tools`, which are installed but unusable and why, and which the user could still activate. Read this before assuming a connector is missing.\n\
To list stored secret names use `list_secrets` instead."
}
@@ -47,7 +58,7 @@ impl Tool for ListItems {
"properties": {
"type": {
"type": "string",
"enum": ["plugins", "cron", "agents"],
"enum": ["plugins", "cron", "agents", "mcp"],
"description": "Which kind of item to list."
}
}
@@ -59,11 +70,41 @@ impl Tool for ListItems {
format!("list {kind}")
}
/// `mcp` is the one type that needs the caller: which connectors are theirs,
/// which are loaded into *this* session, and what their role may do. The
/// other three are instance-wide and stay on the context-free `execute`.
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
if args["type"].as_str() != Some("mcp") {
return self.run(args);
}
let registry = Arc::clone(&self.registry);
let owner = Arc::clone(&ctx.pool);
let user_id = ctx.user_id.clone();
let session_id = ctx.session_id;
let mcp = ctx.mcp.clone();
Box::new(crate::tools::SimpleExecution::new(Box::pin(async move {
let report = crate::tools::mcp_report::build(
&registry,
&owner,
&user_id,
session_id,
mcp.as_deref(),
)
.await?;
Ok(crate::tools::ToolResult::Json(report))
})))
}
fn execute(&self, args: Value) -> Result<String> {
let kind = args["type"].as_str()
.ok_or_else(|| anyhow::anyhow!("list_items: missing required argument `type`"))?;
match kind {
// Reached only through the context-free `execute` (no caller, so no
// report to build) — `run_with` intercepts the real call path.
"mcp" => anyhow::bail!(
"list_items: type `mcp` needs a session context and was called without one"
),
"plugins" => {
let plugins = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.plugins.list())
@@ -115,7 +156,7 @@ impl Tool for ListItems {
.collect();
Ok(serde_json::to_string_pretty(&arr)?)
}
other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: plugins, cron, agents)"),
other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: plugins, cron, agents, mcp)"),
}
}
}
+494
View File
@@ -0,0 +1,494 @@
//! The `list_items(type="mcp")` report: everything an agent needs to know about
//! this caller's connectors, in one call.
//!
//! Three sources answer three different questions and none of them is redundant:
//! the **registry** says what exists and who may have it, the caller's **owner
//! database** says what they activated, and the **live runtimes** say what is
//! actually connected right now (a row can read `ready` while its process is
//! dead, and a per-user server only appears once its container started it).
//!
//! The report is deliberately verbose. It is a tool *result*, so it appends to
//! the context rather than rewriting the system prefix — unlike `__MCP_LIST__`,
//! which is frozen per conversation for prompt-cache stability (see
//! `loop_adapters::prefix_cache`) and therefore stays a bare table. Cheap and
//! detailed here, stable and minimal there.
//!
//! **Read-only, and that is structural.** Everything below is a `SELECT`.
//! Enabling, activating or configuring a connector is not agent-reachable
//! (blueprint §14 — the reason the old `register_mcp` tool was deleted), so the
//! report's job when something is unusable is to name the human step, never to
//! offer a tool that performs it.
use std::collections::{HashMap, HashSet};
use anyhow::Result;
use core_api::tool::McpDirectory;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::db::{
mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers,
role_capabilities, users,
};
/// Static orientation, identical for every caller. Says the three things that
/// are actually mis-modelled by LLMs: connectors are called something else by
/// users, their tools are **not** in the tool set until loaded, and no tool
/// enables them.
const HOW_THIS_WORKS: &str = "\
MCP servers are shown to users as \"Connectors\". They are curated by the administrator and \
activated per user from the Connectors page in the web UI. There is no tool that enables, \
disables, activates or configures a connector — when one is not usable, tell the user what to \
do in the UI instead of looking for a tool.
A connector's tools are NOT in your tool set until you load them: call activate_tools([\"<id>\"]) \
with ids from `ready_to_load`, then call its tools as mcp__<id>__<tool>. The activation lasts the \
whole session and survives a restart, so never call activate_tools twice for the same id. \
Connectors in `loaded_now` are already loaded — call their tools directly.
For how connectors work in user-facing terms, read docs/index.md.";
const NOTE_PER_USER: &str = "Per-user connector: it runs in your own container and is bound to \
your own account only, never another user's.";
const NOTE_GLOBAL: &str = "Shared connector: it runs on the server under credentials owned by the \
administrator, and is not tied to your account.";
/// One connector's row in the report, in whichever bucket it lands.
struct Entry {
id: String,
name: String,
description: Option<String>,
scope: &'static str,
state: &'static str,
tools: Vec<String>,
note: String,
next_step: Option<String>,
}
impl Entry {
fn to_json(&self) -> Value {
json!({
"id": self.id,
"name": self.name,
"description": self.description,
"scope": self.scope,
"state": self.state,
"tools": self.tools,
"note": self.note,
"next_step": self.next_step,
})
}
}
/// Build the report. Every lookup degrades rather than fails: a caller whose
/// role cannot be read is reported as a non-manager, which is the narrow
/// reading, and a missing live view leaves the durable picture intact.
pub async fn build(
registry: &SqlitePool,
owner: &SqlitePool,
user_id: &str,
session_id: i64,
live: Option<&dyn McpDirectory>,
) -> Result<Value> {
let role_id = users::get(registry, user_id).await?.map(|u| u.role_id);
let can_manage = match &role_id {
Some(r) => role_capabilities::has(registry, r, role_capabilities::MANAGE_CATALOG).await?,
None => false,
};
// What the live runtimes report, by runtime name.
let connected: HashMap<String, Vec<String>> = live
.map(|l| l.connected().into_iter().map(|s| (s.name, s.tools)).collect())
.unwrap_or_default();
// Session-scoped activations. `activated_tools` also holds the reserved
// `config` group, which simply never matches a server name — so intersecting
// with the connector set is enough and no kind filter is needed. Sub-agent
// frame activations are not visible here (a `ToolContext` carries no stack
// id); under-reporting is the safe direction, since a redundant
// `activate_tools` is idempotent while a missed one is an unknown-tool error.
let loaded: HashSet<String> =
crate::db::activated_tools::list_refs_session(owner, session_id)
.await
.unwrap_or_default()
.into_iter()
.collect();
let catalog_by_name: HashMap<String, mcp_catalog::McpCatalogRow> =
mcp_catalog::list(registry).await?
.into_iter()
.map(|r| (r.name.clone(), r))
.collect();
let mut loaded_now = Vec::new();
let mut ready = Vec::new();
let mut needs_setup = Vec::new();
let mut installable = Vec::new();
// ── Per-user activations (the owner's own rows) ──────────────────────────
let user_rows = mcp_user_servers::all(owner).await?;
let activated_names: HashSet<String> =
user_rows.iter().map(|r| r.name.clone()).collect();
let activated_catalog: HashSet<String> =
user_rows.iter().filter_map(|r| r.catalog_name.clone()).collect();
for row in &user_rows {
let cat = row.catalog_name.as_deref().and_then(|n| catalog_by_name.get(n));
let mut e = Entry {
id: row.name.clone(),
name: cat.and_then(|c| c.friendly_name.clone()).unwrap_or_else(|| row.name.clone()),
description: cat.and_then(|c| c.description.clone()),
scope: "per_user",
state: "",
tools: Vec::new(),
note: NOTE_PER_USER.into(),
next_step: None,
};
if !row.enabled {
e.state = "disabled";
e.note = format!("{NOTE_PER_USER} It is currently deactivated.");
e.next_step = Some(ui_step(&e.name, "re-activate it"));
needs_setup.push(e);
} else if row.auth_state == "pending" {
// The kind of pending is the difference between "paste a code" and
// "scan a QR" (blueprint §15) — both human, but not the same human step.
let kind = cat.map(|c| c.auth_kind.as_str()).unwrap_or("none");
let (state, what) = match kind {
"oauth" => ("pending_oauth", "finish signing in (the sign-in was never completed, so no token is stored)"),
"qr" => ("pending_login", "finish the device login by scanning the QR code"),
_ => ("pending_setup", "finish setting it up"),
};
e.state = state;
e.next_step = Some(ui_step(&e.name, what));
needs_setup.push(e);
} else if let Some(tools) = connected.get(&row.name) {
e.tools = tools.clone();
if loaded.contains(&row.name) {
e.state = "loaded";
loaded_now.push(e);
} else {
e.state = "ready";
e.next_step = Some(activate_step(&row.name));
ready.push(e);
}
} else {
e.state = "not_running";
e.note = format!(
"{NOTE_PER_USER} It is activated and configured, but its process is not running \
right now, so its tools cannot be loaded."
);
e.next_step = Some(ui_step(&e.name, "check it — signing out and back in usually restarts it"));
needs_setup.push(e);
}
}
// ── Global connectors ────────────────────────────────────────────────────
let granted_globals: HashSet<String> =
mcp_global_access::server_names_for_user(registry, user_id).await?
.into_iter()
.collect();
for row in mcp_global_servers::all(registry).await? {
let granted = granted_globals.contains(&row.name);
let mut e = Entry {
id: row.name.clone(),
name: row.friendly_name.clone().unwrap_or_else(|| row.name.clone()),
description: row.description.clone(),
scope: "global",
state: "",
tools: Vec::new(),
note: NOTE_GLOBAL.into(),
next_step: None,
};
if !granted {
// A catalog manager needs to see a connector they have not granted
// themselves, or it is invisible and they cannot reason about it.
// Everyone else must not learn it exists — that is the grant.
if can_manage {
e.state = "not_granted";
e.note = format!("{NOTE_GLOBAL} It is enabled on this instance but not granted to you.");
e.next_step = Some(
"You manage the catalog: grant it to yourself from the Connectors page in the web UI.".into(),
);
installable.push(e);
}
continue;
}
if !row.enabled {
e.state = "disabled";
e.note = format!("{NOTE_GLOBAL} It is currently disabled on this instance.");
e.next_step = Some(admin_step(&e.name, "re-enable it"));
needs_setup.push(e);
} else if let Some(tools) = connected.get(&row.name) {
e.tools = tools.clone();
if loaded.contains(&row.name) {
e.state = "loaded";
loaded_now.push(e);
} else {
e.state = "ready";
e.next_step = Some(activate_step(&row.name));
ready.push(e);
}
} else {
e.state = "not_running";
e.note = format!(
"{NOTE_GLOBAL} It is enabled but not connected right now, so its tools cannot be loaded."
);
e.next_step = Some(admin_step(&e.name, "check why it is not connected"));
needs_setup.push(e);
}
}
// ── Catalog entries the caller could still activate ──────────────────────
let granted_catalog: HashSet<String> =
mcp_catalog_access::catalog_names_for_user(registry, user_id).await?
.into_iter()
.collect();
for row in mcp_catalog::list_for_scope(registry, "per_user").await? {
if !(can_manage || granted_catalog.contains(&row.name)) {
continue;
}
if activated_catalog.contains(&row.name) || activated_names.contains(&row.name) {
continue;
}
let what = match row.auth_kind.as_str() {
"oauth" => "activate it and sign in",
"qr" => "activate it and complete the device login",
_ => "activate it",
};
installable.push(Entry {
id: row.name.clone(),
name: row.friendly_name.clone().unwrap_or_else(|| row.name.clone()),
description: row.description.clone(),
scope: "per_user",
state: "not_activated",
tools: Vec::new(),
note: format!("{NOTE_PER_USER} It is available to you but has never been activated."),
next_step: Some(ui_step(&row.friendly_name.unwrap_or(row.name), what)),
});
}
let guidance = if can_manage {
"You manage the connector catalog: you can add, remove and grant connectors yourself, \
from the Connectors page in the web UI. You still cannot do it from a tool."
} else {
"You cannot add or configure connectors. If you need one that is not listed here, tell \
the user to ask an administrator to grant it."
};
Ok(json!({
"how_this_works": HOW_THIS_WORKS,
"your_role": {
"role_id": role_id,
"can_manage_catalog": can_manage,
"guidance": guidance,
},
"loaded_now": loaded_now.iter().map(Entry::to_json).collect::<Vec<_>>(),
"ready_to_load": ready.iter().map(Entry::to_json).collect::<Vec<_>>(),
"needs_setup": needs_setup.iter().map(Entry::to_json).collect::<Vec<_>>(),
"installable": installable.iter().map(Entry::to_json).collect::<Vec<_>>(),
}))
}
fn activate_step(id: &str) -> String {
format!("Call activate_tools([\"{id}\"]) to load its tools, then call them as mcp__{id}__<tool>.")
}
/// A step only the user can take, named as such — the model must relay it, not
/// attempt it.
fn ui_step(name: &str, what: &str) -> String {
format!("Tell the user to open the Connectors page in the web UI, select \"{name}\" and {what}. \
You cannot do this for them.")
}
fn admin_step(name: &str, what: &str) -> String {
format!("Tell the user that an administrator must open the Connectors page and {what} for \
\"{name}\". You cannot do this for them.")
}
#[cfg(test)]
mod tests {
use super::*;
use core_api::tool::McpServerView;
/// Stands in for the live runtimes: whatever is listed here is "connected".
struct FakeLive(Vec<&'static str>);
impl McpDirectory for FakeLive {
fn connected(&self) -> Vec<McpServerView> {
self.0.iter()
.map(|n| McpServerView {
name: (*n).into(),
description: None,
tools: vec![format!("{n}_do")],
})
.collect()
}
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let mut p = std::env::temp_dir();
p.push(format!("skald-mcpreport-{tag}-{}-{nanos}", std::process::id()));
p
}
/// Only `admin` is seeded with the schema; the ordinary roles come from a
/// setup profile, so a test that wants a non-manager creates one.
async fn seed_member(registry: &SqlitePool) {
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
.execute(registry).await.unwrap();
}
fn ids(bucket: &Value) -> Vec<String> {
bucket.as_array().unwrap().iter()
.map(|e| e["id"].as_str().unwrap().to_string())
.collect()
}
fn state_of(bucket: &Value, id: &str) -> String {
bucket.as_array().unwrap().iter()
.find(|e| e["id"] == id)
.unwrap_or_else(|| panic!("`{id}` not in bucket"))["state"]
.as_str().unwrap().to_string()
}
/// The four buckets are the whole contract: a connector must land in exactly
/// one, and the one it lands in is what tells the model whether to call
/// `activate_tools`, to call the tool directly, or to send the user to the UI.
#[tokio::test]
async fn buckets_split_by_activation_and_liveness() {
let dir = temp_dir("buckets");
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await.unwrap();
let owner = crate::db::create_user_pool(&dir.join("u1.db"), None).await.unwrap();
let r = |q: &'static str| sqlx::query(q).execute(&registry);
let o = |q: &'static str| sqlx::query(q).execute(&owner);
seed_member(&registry).await;
r("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u1', 'u1', 'member', 0)")
.await.unwrap();
// Catalogue: three per-user entries granted, one deliberately not.
for (name, auth) in [("gmail", "oauth"), ("whatsapp", "qr"), ("gcal", "oauth"), ("hidden", "none")] {
sqlx::query("INSERT INTO mcp_catalog (name, scope, source, auth_kind) VALUES (?, 'per_user', 'local_script', ?)")
.bind(name).bind(auth).execute(&registry).await.unwrap();
}
for name in ["gmail", "whatsapp", "gcal"] {
sqlx::query("INSERT INTO mcp_catalog_access (catalog_name, user_id) VALUES (?, 'u1')")
.bind(name).execute(&registry).await.unwrap();
}
// One global, enabled and granted.
r("INSERT INTO mcp_global_servers (id, name, enabled) VALUES (1, 'tavily', 1)").await.unwrap();
r("INSERT INTO mcp_global_access (server_id, user_id) VALUES (1, 'u1')").await.unwrap();
// Owner side: gmail activated and signed in, whatsapp still pairing.
o("INSERT INTO mcp_user_servers (name, catalog_name, source, auth_state) \
VALUES ('gmail', 'gmail', 'local_script', 'ready')").await.unwrap();
o("INSERT INTO mcp_user_servers (name, catalog_name, source, auth_state) \
VALUES ('whatsapp', 'whatsapp', 'local_script', 'pending')").await.unwrap();
// gmail was already loaded into this session; tavily was not.
o("INSERT INTO chat_sessions (id, title) VALUES (1, 't')").await.unwrap();
o("INSERT INTO chat_sessions_stack (id, session_id) VALUES (1, 1)").await.unwrap();
o("INSERT INTO chat_history (id, session_stack_id, role, content) VALUES (1, 1, 'user', 'hi')")
.await.unwrap();
o("INSERT INTO activated_tools (session_id, stack_id, message_id, kind, ref) \
VALUES (1, NULL, 1, 'mcp', 'gmail')").await.unwrap();
let live = FakeLive(vec!["gmail", "tavily"]);
let out = build(&registry, &owner, "u1", 1, Some(&live)).await.unwrap();
assert_eq!(ids(&out["loaded_now"]), ["gmail"]);
assert_eq!(out["loaded_now"][0]["tools"][0], "gmail_do");
// Already loaded ⇒ no next step, or the model activates it a second time.
assert!(out["loaded_now"][0]["next_step"].is_null());
assert_eq!(ids(&out["ready_to_load"]), ["tavily"]);
assert!(out["ready_to_load"][0]["next_step"].as_str().unwrap().contains("activate_tools"));
// The pending kind is the difference between pasting a code and scanning
// a QR — both human steps, but not the same one.
assert_eq!(state_of(&out["needs_setup"], "whatsapp"), "pending_login");
assert_eq!(ids(&out["installable"]), ["gcal"]);
assert_eq!(out["your_role"]["can_manage_catalog"], false);
}
/// Deny-by-default survives the report: an ungranted catalogue entry must not
/// even be named, or the listing becomes a directory of what to ask for.
#[tokio::test]
async fn ungranted_catalog_entries_stay_invisible() {
let dir = temp_dir("deny");
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await.unwrap();
let owner = crate::db::create_user_pool(&dir.join("u1.db"), None).await.unwrap();
seed_member(&registry).await;
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u1', 'u1', 'member', 0)")
.execute(&registry).await.unwrap();
sqlx::query("INSERT INTO mcp_catalog (name, scope, source, auth_kind) VALUES ('hidden', 'per_user', 'local_script', 'none')")
.execute(&registry).await.unwrap();
sqlx::query("INSERT INTO mcp_global_servers (id, name, enabled) VALUES (1, 'secret', 1)")
.execute(&registry).await.unwrap();
let out = build(&registry, &owner, "u1", 1, None).await.unwrap();
let rendered = out.to_string();
assert!(!rendered.contains("hidden"), "ungranted catalog entry leaked: {rendered}");
assert!(!rendered.contains("secret"), "ungranted global leaked: {rendered}");
}
/// A catalogue manager must see what they have not granted themselves, or
/// they cannot reason about the instance they administer.
#[tokio::test]
async fn a_catalog_manager_sees_ungranted_globals() {
let dir = temp_dir("admin");
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await.unwrap();
let owner = crate::db::create_user_pool(&dir.join("a1.db"), None).await.unwrap();
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('a1', 'a1', 'admin', 0)")
.execute(&registry).await.unwrap();
sqlx::query("INSERT INTO mcp_global_servers (id, name, enabled) VALUES (1, 'tavily', 1)")
.execute(&registry).await.unwrap();
let out = build(&registry, &owner, "a1", 1, None).await.unwrap();
assert_eq!(out["your_role"]["can_manage_catalog"], true);
assert_eq!(ids(&out["installable"]), ["tavily"]);
assert_eq!(state_of(&out["installable"], "tavily"), "not_granted");
}
/// A live view is an optimisation for freshness, never a precondition: with
/// the runtimes unreachable the durable picture must still be reported.
#[tokio::test]
async fn a_ready_connector_without_a_live_view_is_not_running() {
let dir = temp_dir("nolive");
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await.unwrap();
let owner = crate::db::create_user_pool(&dir.join("u1.db"), None).await.unwrap();
seed_member(&registry).await;
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('u1', 'u1', 'member', 0)")
.execute(&registry).await.unwrap();
sqlx::query("INSERT INTO mcp_user_servers (name, source, auth_state) VALUES ('gmail', 'local_script', 'ready')")
.execute(&owner).await.unwrap();
let out = build(&registry, &owner, "u1", 1, None).await.unwrap();
assert_eq!(state_of(&out["needs_setup"], "gmail"), "not_running");
assert!(ids(&out["ready_to_load"]).is_empty());
}
}
+1
View File
@@ -39,6 +39,7 @@ pub mod exec;
pub mod fs;
pub mod image_generate;
pub mod list_items;
pub mod mcp_report;
pub mod list_secrets;
pub mod notify;
pub mod set_secret;
+44
View File
@@ -0,0 +1,44 @@
# Connectors
A **connector** gives you tools that reach outside this instance — a mailbox, a calendar, a web search, a messaging account. Internally they are MCP servers, but nobody calls them that in the interface: the sidebar entry is **Connectors**, so use that word when talking to a user.
Before saying anything about which connectors exist or work, call `list_items({"type": "mcp"})`. It reports the real state for the person you are talking to, and its answer beats any assumption — including anything written below.
## Two kinds, and the difference is about whose account
- **Shared connectors** run centrally on the server, under credentials the admin owns (web search is the usual example). They are not tied to anyone's account, and everyone granted one gets the same thing.
- **Per-user connectors** run inside that person's own private container and are bound to *their* account. Gmail means their mailbox, never another member's. This is why setting one up needs them to sign in personally: an admin cannot do it on their behalf, and a grant only authorizes them to set it up.
## Who has one
Installing a connector hands it to everyone straight away, and the admin then removes it from whoever should not have it — the full rules, including the role switch that keeps children out of the automatic hand-out, are in [access.md](access.md).
Being granted a per-user connector is not the same as having it working: the person still has to activate it and sign in.
## Setting one up
All of it happens in the web UI, on the **Connectors** page in the sidebar. There is no way to do it by asking the assistant, and no tool for it — if someone asks you to enable, configure or activate a connector, explain the steps and let them do it.
1. Open **Connectors** and pick one from the list.
2. Activate it. Some connectors ask for a value (an API key, a URL); the form says which.
3. Finish the sign-in, if it needs one. Two shapes exist:
- **Sign-in with an account** (Gmail, Calendar): a button opens the provider's consent page in a browser, which ends by showing a code. Paste that code back into the connector's page. The round trip is deliberate — this instance has no public address for the provider to call back to.
- **Device pairing** (WhatsApp): the connector's page shows a QR code to scan with the phone app, the same way that app pairs any other device.
An admin has one extra job: the catalogue itself. New connectors are installed from the **Marketplace** (reached from the Add-connector menu on the Connectors page), and account-based sign-ins need the provider's credentials entered once, under **Sign-in providers**.
## When a connector does not work
`list_items({"type": "mcp"})` puts each one in a bucket and says what to do. The states worth recognising:
- **Waiting on a sign-in** — activated, but step 3 above was never finished, so there is no stored credential. Nothing will work until the person completes it.
- **Not running** — activated and configured, but its process is not up. Signing out and back in usually restarts it.
- **Available, never activated** — the person is allowed to have it but has not set it up yet.
A connector that is missing from the report entirely was never granted. That is the admin's call, so the answer is to ask them, not to look for a workaround.
## Using one
A connector's tools are not loaded until you ask for them: `activate_tools(["<id>"])` loads them for the rest of the session, and they are then called as `mcp__<id>__<tool>`. You do not need to explain any of this to the user — to them, the connector either works or does not.
See also: [access.md](access.md) for grants and roles, and [index.md](index.md) for the rest of the documentation.
+2 -1
View File
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
This index will grow over time. Right now it covers memory, projects, background tasks, system agents, access grants, voice input and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
This index will grow over time. Right now it covers memory, projects, background tasks, system agents, access grants, connectors, voice input and plugins; more sections (agents, security groups, shared folders…) will be added later.
## Features
@@ -16,6 +16,7 @@ This index will grow over time. Right now it covers memory, projects, background
| [tasks.md](tasks.md) | Background tasks: the strip above the message box, following one live, stopping one, and how every outcome comes back to the conversation |
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it |
| [connectors.md](connectors.md) | Connectors (MCP servers): shared vs per-user, setting one up in the UI, the sign-in and QR-pairing flows, and what to do when one is not working |
| [voice.md](voice.md) | Voice input: configuring a transcription model, and why the microphone button does nothing unless the page is served over HTTPS or localhost |
## Plugins