feat(mcp): configure tool execution properties
This commit is contained in:
parent
4f35b4364d
commit
c8cdce1ced
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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 或泄漏配置秘密。
|
||||||
|
|||||||
@ -147,6 +147,20 @@ MCP 服务器单条配置:
|
|||||||
| `url` | URL(sse / streamable-http 模式) |
|
| `url` | URL(sse / 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 字段
|
||||||
|
|
||||||
|
|||||||
@ -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` 自动推导。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, _)| {
|
||||||
name: name.clone(),
|
let settings = server_config.tool_settings_for(name);
|
||||||
description: desc.clone(),
|
McpToolStatus {
|
||||||
|
name: name.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
<code>{tool.name}</code>
|
<div class="mcp-tool-heading">
|
||||||
|
<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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user