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:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+144
-10
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user