feat(mcp): configure tool execution properties

This commit is contained in:
xiaoxixi 2026-08-20 10:05:23 +08:00
parent 4f35b4364d
commit c8cdce1ced
13 changed files with 277 additions and 16 deletions

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "1.16.0" version = "1.17.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -396,6 +396,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `memory.recall_limit` | `5`(当前运行时固定为 5 | | `memory.recall_limit` | `5`(当前运行时固定为 5 |
| `memory.timeline_retention_days` | `90` | | `memory.timeline_retention_days` | `90` |
| `mcp.tool_timeout_secs` | `180` | | `mcp.tool_timeout_secs` | `180` |
| `mcp.servers[].tool_settings` | `{}`;可按工具名声明 `read_only` / `exclusive`,并发状态自动推导 |
| `browser.enabled` | `true` | | `browser.enabled` | `true` |
| `channels.feishu.live_updates` | `false` | | `channels.feishu.live_updates` | `false` |
| `channels.feishu.live_update_interval_ms` | `500` | | `channels.feishu.live_update_interval_ms` | `500` |

View File

@ -74,7 +74,7 @@ flowchart LR
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 | | `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
| `providers` | 把统一请求映射为原生模型流并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 | | `providers` | 把统一请求映射为原生模型流并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
| `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 | | `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
| `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 | | `tools` / `mcp` | 工具定义、注册和执行适配MCP 工具的本地执行属性声明 | 隐式修改会话路由 |
| `health` | 聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider | | `health` | 聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider |
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 | | `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 | | `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
@ -262,6 +262,8 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
所有工具调用统一归一化为 `ToolOutput`,并由 `AgentLoop` 中唯一的 `ToolOutputProcessor` 后处理。普通文本工具仍实现 `ToolResult`,默认转换会将其包装为无产物的 `ToolOutput`;产物工具返回带 `ToolArtifact` 的输出,并用 `Model``User``ModelAndUser` 声明受众。处理器只发布成功工具的产物去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图不感知当前模型、Provider、Session 或 Channel。`AgentLoop` 仅将最新连续工具结果批次的模型媒体交给 `MediaHandlerRegistry`,紧随工具结果的 steering 不会使该批次媒体失去可见性,而旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息,同时保持后续 user steering 的顺序Anthropic Provider 将同批媒体放入对应 `tool_result.content`,并将紧随的 user steering 合并进 API 所需的同一 `role=user` 内容数组,持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。 所有工具调用统一归一化为 `ToolOutput`,并由 `AgentLoop` 中唯一的 `ToolOutputProcessor` 后处理。普通文本工具仍实现 `ToolResult`,默认转换会将其包装为无产物的 `ToolOutput`;产物工具返回带 `ToolArtifact` 的输出,并用 `Model``User``ModelAndUser` 声明受众。处理器只发布成功工具的产物去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图不感知当前模型、Provider、Session 或 Channel。`AgentLoop` 仅将最新连续工具结果批次的模型媒体交给 `MediaHandlerRegistry`,紧随工具结果的 steering 不会使该批次媒体失去可见性,而旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息,同时保持后续 user steering 的顺序Anthropic Provider 将同批媒体放入对应 `tool_result.content`,并将紧随的 user steering 合并进 API 所需的同一 `role=user` 内容数组,持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>` 命名空间,避免与内置工具混淆;`tool_settings` 仍按 MCP 原始 `<tool-name>` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约,因此每个 `mcp.servers[].tool_settings.<tool-name>` 可在受信任本地配置中声明 `read_only``exclusive`。未声明的 MCP 工具保守地按“可能有副作用、顺序执行”处理。`concurrency_safe` 不保存为独立状态,而是严格由 `read_only && !exclusive` 推导工具批次只有全部工具满足该条件才允许并发执行。WebUI 的 MCP 工具展开项提供这两个声明的复选框,并将推导结果显示为“可并发”;属性编辑先保留在页面草稿中,只有选择“保存并应用”才原子写入配置并触发一次热重载,离开 MCP 标签或刷新页面会丢弃草稿。
`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session并用每 session mutex 保证同一页面串行、不同 dialog 并发长期工作需要保留登录或站点状态时Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发Gateway 重启或 daemon 退出后继续使用原 Profile没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID标签只负责识别选择仍使用不可变 ID删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI设置硬超时、输出/content boundaries/domain allowlist并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。 `browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session并用每 session mutex 保证同一页面串行、不同 dialog 并发长期工作需要保留登录或站点状态时Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发Gateway 重启或 daemon 退出后继续使用原 Profile没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID标签只负责识别选择仍使用不可变 ID删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI设置硬超时、输出/content boundaries/domain allowlist并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor但不能安装/修复软件、连接模型 API 或泄漏配置秘密。 `HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor但不能安装/修复软件、连接模型 API 或泄漏配置秘密。

View File

@ -147,6 +147,20 @@ MCP 服务器单条配置:
| `url` | URLsse / streamable-http 模式) | | `url` | URLsse / streamable-http 模式) |
| `headers` | HTTP 传输额外请求头 | | `headers` | HTTP 传输额外请求头 |
| `tool_timeout_secs` | 单独的超时设置 | | `tool_timeout_secs` | 单独的超时设置 |
| `tool_settings` | 按 MCP 原始工具名索引的本地执行属性声明;每项可设 `read_only``exclusive`,均默认 false |
`tool_settings` 示例:
```json
{
"tool_settings": {
"read_file": { "read_only": true },
"refresh_index": { "exclusive": true }
}
}
```
`可并发` 不需要也不能单独配置:它始终等于 `read_only && !exclusive`。未声明的 MCP 工具按可能有副作用且顺序执行处理。WebUI「工具 → MCP」展开服务器后可编辑这些复选框编辑先暂存可跨多个工具一次“保存并应用”并热重载离开 MCP 标签或刷新页面会丢弃未保存草稿。
## browser 字段 ## browser 字段

View File

@ -195,7 +195,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
## MCP 工具 ## MCP 工具
如果 `config.mcp.servers` 配置了 MCP 服务器Gateway 启动时会连接服务器、发现工具,并把 MCP 工具包装后注册到 ToolRegistry。使用 `/mcp` 查看当前连接状态和工具列表。 如果 `config.mcp.servers` 配置了 MCP 服务器Gateway 启动时会连接服务器、发现工具,并把 MCP 工具包装后注册到 ToolRegistry。注册名固定为 `mcp_<服务器名>_<工具名>`,例如 `mcp_filesystem_read_file`;配置 `tool_settings` 仍使用 MCP 原始工具名 `read_file`使用 `/mcp` 查看当前连接状态和工具列表。MCP 协议不声明副作用或并发安全性;可在服务器配置的 `tool_settings` 中为各工具设置本地受信任的 `read_only``exclusive` 属性。未设置的工具顺序执行;“可并发”由 `read_only && !exclusive` 自动推导。
--- ---

View File

@ -551,6 +551,40 @@ pub struct McpServerConfig {
pub headers: HashMap<String, String>, pub headers: HashMap<String, String>,
#[serde(default)] #[serde(default)]
pub tool_timeout_secs: Option<u64>, pub tool_timeout_secs: Option<u64>,
/// Per-tool execution declarations for tools discovered from this server.
/// Unknown MCP tools remain conservative: side-effecting and sequential.
#[serde(default)]
pub tool_settings: HashMap<String, McpToolSettings>,
}
impl McpServerConfig {
/// Return the effective execution declarations for one discovered tool.
pub fn tool_settings_for(&self, tool_name: &str) -> McpToolSettings {
self.tool_settings
.get(tool_name)
.copied()
.unwrap_or_default()
}
}
/// Locally declared execution properties for an externally provided MCP tool.
///
/// MCP tool discovery does not provide a trustworthy side-effect contract, so
/// PicoBot keeps the declaration in its own trusted configuration. Concurrent
/// execution is intentionally derived rather than stored: a tool is safe to
/// run alongside another tool only when it is read-only and non-exclusive.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct McpToolSettings {
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub exclusive: bool,
}
impl McpToolSettings {
pub fn concurrency_safe(self) -> bool {
self.read_only && !self.exclusive
}
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -1197,6 +1231,28 @@ mod tests {
); );
} }
#[test]
fn mcp_tool_settings_default_to_conservative_and_derive_concurrency() {
let server: McpServerConfig = serde_json::from_str(
r#"{
"name": "filesystem",
"tool_settings": {
"read_file": { "read_only": true },
"locked_lookup": { "read_only": true, "exclusive": true }
}
}"#,
)
.unwrap();
let unknown = server.tool_settings_for("unknown");
assert!(!unknown.read_only);
assert!(!unknown.exclusive);
assert!(!unknown.concurrency_safe());
assert!(server.tool_settings_for("read_file").concurrency_safe());
assert!(!server.tool_settings_for("locked_lookup").concurrency_safe());
}
#[test] #[test]
fn default_config_path_is_stable_across_working_directory_changes() { fn default_config_path_is_stable_across_working_directory_changes() {
assert!(resolve_default_config_path().is_absolute()); assert!(resolve_default_config_path().is_absolute());

View File

@ -800,6 +800,9 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
.map(|tool| json!({ .map(|tool| json!({
"name": tool.name, "name": tool.name,
"description": tool.description, "description": tool.description,
"read_only": tool.read_only,
"exclusive": tool.exclusive,
"concurrency_safe": tool.concurrency_safe,
})) }))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
}) })
@ -846,7 +849,7 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
let tools: Vec<Value> = entries let tools: Vec<Value> = entries
.into_iter() .into_iter()
.map(|(name, tool)| { .map(|(name, tool)| {
let source = if name.contains("__") { let source = if crate::mcp::is_mcp_tool_name(&name) {
"mcp" "mcp"
} else { } else {
"builtin" "builtin"
@ -1018,7 +1021,8 @@ pub async fn get_agent_options(
// a definition's `tools` list (it turns on the scoped skill wrapper), // a definition's `tools` list (it turns on the scoped skill wrapper),
// so it must be offered in the editor. // so it must be offered in the editor.
.filter(|(name, tool)| { .filter(|(name, tool)| {
(!tool.runtime_injected() || name == "get_skill") && !name.contains("__") (!tool.runtime_injected() || name == "get_skill")
&& !crate::mcp::is_mcp_tool_name(name)
}) })
.map(|(name, tool)| json!({ "name": name, "description": tool.description() })) .map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
.collect(); .collect();

View File

@ -355,6 +355,7 @@ impl GatewayState {
tool_info.description, tool_info.description,
tool_info.schema, tool_info.schema,
tool_info.connection, tool_info.connection,
tool_info.settings,
); );
self.session_manager.tools().register(wrapper); self.session_manager.tools().register(wrapper);
} }

View File

@ -11,16 +11,32 @@ use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
use rmcp::{Peer, RoleClient, ServiceExt}; use rmcp::{Peer, RoleClient, ServiceExt};
use tokio::process::Command; use tokio::process::Command;
use crate::config::{McpConfig, McpServerConfig, McpTransport}; use crate::config::{McpConfig, McpServerConfig, McpToolSettings, McpTransport};
use crate::tools::ToolResult; use crate::tools::ToolResult;
pub use tool_wrapper::McpToolWrapper; pub use tool_wrapper::McpToolWrapper;
/// Reserved prefix for tool names registered from MCP servers.
pub const MCP_TOOL_PREFIX: &str = "mcp_";
/// Build the public ToolRegistry name for an MCP-discovered tool.
pub fn qualified_tool_name(server_name: &str, tool_name: &str) -> String {
format!("{MCP_TOOL_PREFIX}{server_name}_{tool_name}")
}
/// Whether a ToolRegistry name belongs to the MCP namespace.
pub fn is_mcp_tool_name(name: &str) -> bool {
name.starts_with(MCP_TOOL_PREFIX)
}
/// Status of a single MCP tool. /// Status of a single MCP tool.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct McpToolStatus { pub struct McpToolStatus {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub read_only: bool,
pub exclusive: bool,
pub concurrency_safe: bool,
} }
/// Status of a single MCP server. /// Status of a single MCP server.
@ -122,6 +138,7 @@ pub struct ToolInfo {
pub description: String, pub description: String,
pub schema: serde_json::Value, pub schema: serde_json::Value,
pub connection: Arc<McpConnection>, pub connection: Arc<McpConnection>,
pub settings: McpToolSettings,
} }
pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> { pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
@ -155,9 +172,15 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
); );
let tool_statuses: Vec<McpToolStatus> = server_tools let tool_statuses: Vec<McpToolStatus> = server_tools
.iter() .iter()
.map(|(name, desc, _)| McpToolStatus { .map(|(name, desc, _)| {
let settings = server_config.tool_settings_for(name);
McpToolStatus {
name: name.clone(), name: name.clone(),
description: desc.clone(), description: desc.clone(),
read_only: settings.read_only,
exclusive: settings.exclusive,
concurrency_safe: settings.concurrency_safe(),
}
}) })
.collect(); .collect();
server_statuses.push(McpServerStatus { server_statuses.push(McpServerStatus {
@ -168,12 +191,14 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
tools: tool_statuses, tools: tool_statuses,
}); });
for (orig_name, desc, schema) in server_tools { for (orig_name, desc, schema) in server_tools {
let settings = server_config.tool_settings_for(&orig_name);
tools.push(ToolInfo { tools.push(ToolInfo {
server_name: server_config.name.clone(), server_name: server_config.name.clone(),
tool_name: orig_name, tool_name: orig_name,
description: desc, description: desc,
schema, schema,
connection: connection.clone(), connection: connection.clone(),
settings,
}); });
} }
} }
@ -301,3 +326,18 @@ async fn list_tools(
}) })
.collect()) .collect())
} }
#[cfg(test)]
mod tests {
use super::{is_mcp_tool_name, qualified_tool_name};
#[test]
fn qualified_tool_names_use_the_mcp_namespace() {
assert_eq!(
qualified_tool_name("filesystem", "read_file"),
"mcp_filesystem_read_file"
);
assert!(is_mcp_tool_name("mcp_filesystem_read_file"));
assert!(!is_mcp_tool_name("file_read"));
}
}

View File

@ -2,9 +2,10 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::config::McpToolSettings;
use crate::tools::{Tool, ToolResult}; use crate::tools::{Tool, ToolResult};
use super::McpConnection; use super::{McpConnection, qualified_tool_name};
pub struct McpToolWrapper { pub struct McpToolWrapper {
full_name: String, full_name: String,
@ -12,6 +13,7 @@ pub struct McpToolWrapper {
parameters_schema: serde_json::Value, parameters_schema: serde_json::Value,
original_tool_name: String, original_tool_name: String,
connection: Arc<McpConnection>, connection: Arc<McpConnection>,
settings: McpToolSettings,
} }
impl McpToolWrapper { impl McpToolWrapper {
@ -21,13 +23,15 @@ impl McpToolWrapper {
description: String, description: String,
parameters_schema: serde_json::Value, parameters_schema: serde_json::Value,
connection: Arc<McpConnection>, connection: Arc<McpConnection>,
settings: McpToolSettings,
) -> Self { ) -> Self {
Self { Self {
full_name: format!("{}__{}", server_name, original_tool_name), full_name: qualified_tool_name(server_name, &original_tool_name),
description, description,
parameters_schema, parameters_schema,
original_tool_name, original_tool_name,
connection, connection,
settings,
} }
} }
} }
@ -46,6 +50,14 @@ impl Tool for McpToolWrapper {
self.parameters_schema.clone() self.parameters_schema.clone()
} }
fn read_only(&self) -> bool {
self.settings.read_only
}
fn exclusive(&self) -> bool {
self.settings.exclusive
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> { async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.connection self.connection
.call_tool(&self.original_tool_name, args) .call_tool(&self.original_tool_name, args)

View File

@ -1,12 +1,12 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.16.0", "version": "1.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.16.0", "version": "1.17.0",
"dependencies": { "dependencies": {
"bits-ui": "^2.0.0", "bits-ui": "^2.0.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"private": true, "private": true,
"version": "1.16.0", "version": "1.17.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -15,6 +15,7 @@
let error = $state(""); let error = $state("");
let query = $state(""); let query = $state("");
let filter = $state("all"); let filter = $state("all");
let mcpToolSettingsDraft = $state({});
const filters = [ const filters = [
["all", "全部"], ["all", "全部"],
@ -64,6 +65,7 @@
const mcpToolCount = $derived( const mcpToolCount = $derived(
mcpList.reduce((sum, server) => sum + (server.tools?.length || 0), 0) mcpList.reduce((sum, server) => sum + (server.tools?.length || 0), 0)
); );
const pendingMcpToolSettingCount = $derived(Object.keys(mcpToolSettingsDraft).length);
function schema(tool) { function schema(tool) {
return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}"; return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}";
@ -130,10 +132,84 @@
} }
} }
function mcpToolSettingKey(server, tool) {
return JSON.stringify([server.name, tool.name]);
}
function configuredMcpToolSettings(server, tool) {
const configured = config?.mcp?.servers
?.find((item) => item.name === server.name)
?.tool_settings?.[tool.name];
return {
read_only: configured?.read_only === true,
exclusive: configured?.exclusive === true
};
}
function mcpToolSettingsFor(server, tool) {
return mcpToolSettingsDraft[mcpToolSettingKey(server, tool)]?.settings
|| configuredMcpToolSettings(server, tool);
}
function updateMcpToolSetting(server, tool, setting, enabled) {
const key = mcpToolSettingKey(server, tool);
const current = mcpToolSettingsFor(server, tool);
mcpToolSettingsDraft = {
...mcpToolSettingsDraft,
[key]: {
serverName: server.name,
toolName: tool.name,
settings: { ...current, [setting]: enabled }
}
};
}
function discardMcpToolSettings() {
mcpToolSettingsDraft = {};
}
async function applyMcpToolSettings() {
if (!config?.mcp?.servers || !pendingMcpToolSettingCount) return;
saving = true;
try {
const next = JSON.parse(JSON.stringify(config));
for (const entry of Object.values(mcpToolSettingsDraft)) {
const target = next.mcp.servers.find((item) => item.name === entry.serverName);
if (!target) continue;
target.tool_settings ||= {};
if (entry.settings.read_only || entry.settings.exclusive) {
target.tool_settings[entry.toolName] = entry.settings;
} else {
delete target.tool_settings[entry.toolName];
if (!Object.keys(target.tool_settings).length) delete target.tool_settings;
}
}
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config: next }) });
config = result.config || next;
await api("/api/config/reload", { method: "POST" });
const count = pendingMcpToolSettingCount;
discardMcpToolSettings();
notify(`已保存 ${count} 项 MCP 工具执行属性,重载后生效`);
setTimeout(load, 1500);
} catch (caught) {
notify(caught.message, true);
} finally {
saving = false;
}
}
function changeTab(value) { function changeTab(value) {
if (tab === "mcp" && value !== "mcp") discardMcpToolSettings();
tab = value; tab = value;
} }
function refresh() {
discardMcpToolSettings();
load();
}
onMount(load); onMount(load);
</script> </script>
@ -146,7 +222,7 @@
<Tabs.Trigger value="mcp">MCP</Tabs.Trigger> <Tabs.Trigger value="mcp">MCP</Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs.Root> </Tabs.Root>
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button> <button class="secondary" onclick={refresh}><Icon name="refresh" size={16} />刷新</button>
</div> </div>
{#if loading} {#if loading}
@ -228,6 +304,21 @@
<div class="metric"><b>{mcpConnected}</b><small>已连接</small></div> <div class="metric"><b>{mcpConnected}</b><small>已连接</small></div>
<div class="metric"><b>{mcpToolCount}</b><small>工具总数</small></div> <div class="metric"><b>{mcpToolCount}</b><small>工具总数</small></div>
</div> </div>
<div class="mcp-draft-bar" class:has-draft={pendingMcpToolSettingCount > 0}>
<span>
{#if pendingMcpToolSettingCount}
已暂存 {pendingMcpToolSettingCount} 项执行属性;离开 MCP 标签或刷新页面会丢弃未保存更改。
{:else}
修改工具属性后,可一次保存并应用。
{/if}
</span>
{#if pendingMcpToolSettingCount}
<div class="card-actions">
<button class="secondary" onclick={discardMcpToolSettings} disabled={saving}>放弃修改</button>
<button onclick={applyMcpToolSettings} disabled={saving}>保存并应用</button>
</div>
{/if}
</div>
<div class="cards"> <div class="cards">
{#each mcpList as server (server.name)} {#each mcpList as server (server.name)}
<article class="card" class:disabled={!server.enabled}> <article class="card" class:disabled={!server.enabled}>
@ -264,9 +355,39 @@
<summary>{server.tools.length} 个工具</summary> <summary>{server.tools.length} 个工具</summary>
<ul class="mcp-tools"> <ul class="mcp-tools">
{#each server.tools as tool (tool.name)} {#each server.tools as tool (tool.name)}
{@const settings = mcpToolSettingsFor(server, tool)}
<li> <li>
<div class="mcp-tool-heading">
<code>{tool.name}</code> <code>{tool.name}</code>
<div class="cap-row compact">
{#if settings.read_only}<span class="cap signal">● 只读</span>{:else}<span class="cap accent">● 有副作用</span>{/if}
{#if settings.read_only && !settings.exclusive}<span class="cap info">● 可并发</span>{/if}
{#if settings.exclusive}<span class="cap danger">● 独占</span>{/if}
</div>
</div>
{#if tool.description}<span>{tool.description}</span>{/if} {#if tool.description}<span>{tool.description}</span>{/if}
<fieldset class="mcp-tool-settings">
<legend>执行属性</legend>
<label title="声明该工具没有外部副作用;只读且非独占的工具会自动并发执行。">
<input
type="checkbox"
checked={settings.read_only}
onchange={(event) => updateMcpToolSetting(server, tool, "read_only", event.currentTarget.checked)}
disabled={saving || !server.enabled}
/>
只读
</label>
<label title="声明该工具必须单独执行,即使它是只读工具也不会与其他工具并发。">
<input
type="checkbox"
checked={settings.exclusive}
onchange={(event) => updateMcpToolSetting(server, tool, "exclusive", event.currentTarget.checked)}
disabled={saving || !server.enabled}
/>
独占
</label>
<small>“可并发”由“只读且非独占”自动计算,不单独保存。</small>
</fieldset>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -293,10 +414,20 @@
.schema-wrap pre { max-height: 300px; margin: 8px 0 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font-size: 12px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } .schema-wrap pre { max-height: 300px; margin: 8px 0 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font-size: 12px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.card-actions { display: flex; align-items: center; gap: 10px; } .card-actions { display: flex; align-items: center; gap: 10px; }
.card.disabled { opacity: 0.65; } .card.disabled { opacity: 0.65; }
.mcp-draft-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.mcp-draft-bar.has-draft { border-color: var(--accent-border); background: var(--accent-soft); color: var(--text-soft); }
.mcp-tools { display: grid; gap: 7px; margin: 10px 0 0; padding: 0; list-style: none; } .mcp-tools { display: grid; gap: 7px; margin: 10px 0 0; padding: 0; list-style: none; }
.mcp-tools li { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; } .mcp-tools li { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; }
.mcp-tool-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.cap-row.compact { margin-top: 0; }
.mcp-tools code { font: 600 12px/1.5 var(--font-mono); color: var(--text); } .mcp-tools code { font: 600 12px/1.5 var(--font-mono); color: var(--text); }
.mcp-tools span { font-size: 12px; color: var(--muted); line-height: 1.5; } .mcp-tools span { font-size: 12px; color: var(--muted); line-height: 1.5; }
.mcp-tool-settings { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; margin: 6px 0 0; padding: 7px 0 0; border: 0; border-top: 1px solid var(--line); }
.mcp-tool-settings legend { padding: 0; color: var(--muted); font-size: 11px; }
.mcp-tool-settings label { display: inline-flex; align-items: center; gap: 5px; color: var(--text-soft); font-size: 12px; cursor: pointer; }
.mcp-tool-settings input { accent-color: var(--brand); }
.mcp-tool-settings label:has(input:disabled) { cursor: not-allowed; opacity: 0.65; }
.mcp-tool-settings small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.mcp-error { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.5; word-break: break-all; } .mcp-error { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.5; word-break: break-all; }
.mcp-empty { margin: 10px 0 0; color: var(--muted); font-size: 12px; } .mcp-empty { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
@media (max-width: 800px) { @media (max-width: 800px) {