Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+144 -10
View File
@@ -30,6 +30,9 @@ pub struct McpToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
/// The server marks the tool as read-only (annotations.readOnlyHint).
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Serialize)]
@@ -183,6 +186,7 @@ impl McpServer {
name: t["name"].as_str().unwrap_or_default().to_string(),
description: t["description"].as_str().unwrap_or_default().to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})),
read_only: t["annotations"]["readOnlyHint"].as_bool().unwrap_or(false),
});
}
cursor = res["nextCursor"].as_str().map(String::from);
@@ -498,20 +502,147 @@ impl McpManager {
}
}
pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
/// All MCP tools, split into tools given to the model directly and tools
/// that are only reachable through mcp_search / mcp_call.
pub fn tools(&self, cfg: &crate::config::Config) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
let servers = self.servers.lock().unwrap();
let mut out: Vec<Arc<dyn Tool>> = Vec::new();
let mut direct: Vec<Arc<dyn Tool>> = Vec::new();
let mut deferred = Vec::new();
for (sname, server) in servers.iter() {
for t in server.tools.lock().unwrap().iter() {
out.push(Arc::new(McpTool {
full_name: tool_name(sname, &t.name),
server: server.clone(),
info: t.clone(),
}));
let tools = server.tools.lock().unwrap();
let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into());
let on_demand = match load.as_str() {
"always" => false,
"on_demand" => true,
_ => tools.len() > cfg.server.mcp_direct_limit,
};
for t in tools.iter() {
let tool = Arc::new(McpTool { full_name: tool_name(sname, &t.name), server: server.clone(), info: t.clone() });
if on_demand {
deferred.push(tool);
} else {
direct.push(tool);
}
}
}
out
(direct, deferred)
}
/// Short overview of on-demand servers for the system prompt.
pub fn deferred_summary(&self, cfg: &crate::config::Config) -> Vec<(String, usize, Vec<String>)> {
let (_, deferred) = self.tools(cfg);
let mut by_server: BTreeMap<String, Vec<String>> = BTreeMap::new();
for t in deferred {
by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone());
}
by_server
.into_iter()
.map(|(s, names)| {
let n = names.len();
(s, n, names.into_iter().take(12).collect())
})
.collect()
}
}
/// Search the on-demand MCP tools by keywords.
pub struct McpSearch;
#[async_trait]
impl Tool for McpSearch {
fn name(&self) -> &str {
"mcp_search"
}
fn description(&self) -> String {
"Find tools of the on-demand MCP servers listed in the system prompt. Returns matching tools \
with their input schema; call them with mcp_call. Search with a few keywords (e.g. \
\"container restart\", \"light living room\")."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": "Keywords"},
"server": {"type": "string", "description": "Only search this server (optional)"}
},
"required": ["query"]
})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Search MCP tools: {}", input["query"].as_str().unwrap_or(""))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let query = input["query"].as_str().unwrap_or("").to_lowercase();
let server = input["server"].as_str();
let words: Vec<&str> = query.split_whitespace().collect();
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
let mut scored: Vec<(usize, &Arc<McpTool>)> = deferred
.iter()
.filter(|t| server.is_none_or(|s| t.server.name == s))
.map(|t| {
let name = t.info.name.to_lowercase();
let desc = t.info.description.to_lowercase();
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
(score, t)
})
.filter(|(s, _)| *s > 0 || words.is_empty())
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
if scored.is_empty() {
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
}
let mut out = String::new();
for (_, t) in scored.iter().take(8) {
let desc: String = t.info.description.chars().take(300).collect();
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
}
if scored.len() > 8 {
out.push_str(&format!("({} more matches – refine the query)\n", scored.len() - 8));
}
Ok(ToolOutput::text(out))
}
}
/// Call an on-demand MCP tool found with mcp_search. Permissions are checked
/// against the target tool by the runner.
pub struct McpCall;
#[async_trait]
impl Tool for McpCall {
fn name(&self) -> &str {
"mcp_call"
}
fn description(&self) -> String {
"Call an MCP tool found with mcp_search, by its full name (mcp__<server>__<tool>).".into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"tool": {"type": "string", "description": "Full tool name, e.g. mcp__dockhand__list_containers"},
"arguments": {"type": "object", "description": "Arguments matching the tool's input schema"}
},
"required": ["tool"]
})
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let target = resolve_call_target(ctx, &input)?;
target.run(input.get("arguments").cloned().unwrap_or(json!({})), ctx).await
}
}
/// Find the MCP tool an mcp_call refers to.
pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result<Arc<McpTool>> {
let name = input["tool"].as_str().unwrap_or("");
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
deferred
.into_iter()
.find(|t| t.full_name == name)
.ok_or_else(|| anyhow!("unknown MCP tool '{name}' – use mcp_search to find tool names"))
}
/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers.
@@ -523,7 +654,7 @@ pub fn tool_name(server: &str, tool: &str) -> String {
}
pub struct McpTool {
full_name: String,
pub full_name: String,
server: Arc<McpServer>,
info: McpToolInfo,
}
@@ -546,6 +677,9 @@ impl Tool for McpTool {
fn title(&self, _input: &Value) -> String {
format!("{} → {}", self.server.name, self.info.name)
}
fn read_only(&self) -> bool {
self.info.read_only
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await
}