feat(mcp): configure tool execution properties
This commit is contained in:
parent
4f35b4364d
commit
c8cdce1ced
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -396,6 +396,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
||||
| `memory.recall_limit` | `5`(当前运行时固定为 5) |
|
||||
| `memory.timeline_retention_days` | `90` |
|
||||
| `mcp.tool_timeout_secs` | `180` |
|
||||
| `mcp.servers[].tool_settings` | `{}`;可按工具名声明 `read_only` / `exclusive`,并发状态自动推导 |
|
||||
| `browser.enabled` | `true` |
|
||||
| `channels.feishu.live_updates` | `false` |
|
||||
| `channels.feishu.live_update_interval_ms` | `500` |
|
||||
|
||||
@ -74,7 +74,7 @@ flowchart LR
|
||||
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
|
||||
| `providers` | 把统一请求映射为原生模型流,并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
|
||||
| `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
|
||||
| `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 |
|
||||
| `tools` / `mcp` | 工具定义、注册和执行适配;MCP 工具的本地执行属性声明 | 隐式修改会话路由 |
|
||||
| `health` | 聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider |
|
||||
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
|
||||
| `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` 内容数组,持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
||||
|
||||
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)。
|
||||
|
||||
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor,但不能安装/修复软件、连接模型 API 或泄漏配置秘密。
|
||||
|
||||
@ -147,6 +147,20 @@ MCP 服务器单条配置:
|
||||
| `url` | URL(sse / streamable-http 模式) |
|
||||
| `headers` | HTTP 传输额外请求头 |
|
||||
| `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 字段
|
||||
|
||||
|
||||
@ -195,7 +195,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
||||
|
||||
## 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>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@ -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]
|
||||
fn default_config_path_is_stable_across_working_directory_changes() {
|
||||
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!({
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"read_only": tool.read_only,
|
||||
"exclusive": tool.exclusive,
|
||||
"concurrency_safe": tool.concurrency_safe,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
@ -846,7 +849,7 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
|
||||
let tools: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(name, tool)| {
|
||||
let source = if name.contains("__") {
|
||||
let source = if crate::mcp::is_mcp_tool_name(&name) {
|
||||
"mcp"
|
||||
} else {
|
||||
"builtin"
|
||||
@ -1018,7 +1021,8 @@ pub async fn get_agent_options(
|
||||
// a definition's `tools` list (it turns on the scoped skill wrapper),
|
||||
// so it must be offered in the editor.
|
||||
.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() }))
|
||||
.collect();
|
||||
|
||||
@ -355,6 +355,7 @@ impl GatewayState {
|
||||
tool_info.description,
|
||||
tool_info.schema,
|
||||
tool_info.connection,
|
||||
tool_info.settings,
|
||||
);
|
||||
self.session_manager.tools().register(wrapper);
|
||||
}
|
||||
|
||||
@ -11,16 +11,32 @@ use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
|
||||
use rmcp::{Peer, RoleClient, ServiceExt};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::config::{McpConfig, McpServerConfig, McpTransport};
|
||||
use crate::config::{McpConfig, McpServerConfig, McpToolSettings, McpTransport};
|
||||
use crate::tools::ToolResult;
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpToolStatus {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub read_only: bool,
|
||||
pub exclusive: bool,
|
||||
pub concurrency_safe: bool,
|
||||
}
|
||||
|
||||
/// Status of a single MCP server.
|
||||
@ -122,6 +138,7 @@ pub struct ToolInfo {
|
||||
pub description: String,
|
||||
pub schema: serde_json::Value,
|
||||
pub connection: Arc<McpConnection>,
|
||||
pub settings: McpToolSettings,
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
.map(|(name, desc, _)| McpToolStatus {
|
||||
.map(|(name, desc, _)| {
|
||||
let settings = server_config.tool_settings_for(name);
|
||||
McpToolStatus {
|
||||
name: name.clone(),
|
||||
description: desc.clone(),
|
||||
read_only: settings.read_only,
|
||||
exclusive: settings.exclusive,
|
||||
concurrency_safe: settings.concurrency_safe(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
server_statuses.push(McpServerStatus {
|
||||
@ -168,12 +191,14 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
||||
tools: tool_statuses,
|
||||
});
|
||||
for (orig_name, desc, schema) in server_tools {
|
||||
let settings = server_config.tool_settings_for(&orig_name);
|
||||
tools.push(ToolInfo {
|
||||
server_name: server_config.name.clone(),
|
||||
tool_name: orig_name,
|
||||
description: desc,
|
||||
schema,
|
||||
connection: connection.clone(),
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -301,3 +326,18 @@ async fn list_tools(
|
||||
})
|
||||
.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 crate::config::McpToolSettings;
|
||||
use crate::tools::{Tool, ToolResult};
|
||||
|
||||
use super::McpConnection;
|
||||
use super::{McpConnection, qualified_tool_name};
|
||||
|
||||
pub struct McpToolWrapper {
|
||||
full_name: String,
|
||||
@ -12,6 +13,7 @@ pub struct McpToolWrapper {
|
||||
parameters_schema: serde_json::Value,
|
||||
original_tool_name: String,
|
||||
connection: Arc<McpConnection>,
|
||||
settings: McpToolSettings,
|
||||
}
|
||||
|
||||
impl McpToolWrapper {
|
||||
@ -21,13 +23,15 @@ impl McpToolWrapper {
|
||||
description: String,
|
||||
parameters_schema: serde_json::Value,
|
||||
connection: Arc<McpConnection>,
|
||||
settings: McpToolSettings,
|
||||
) -> Self {
|
||||
Self {
|
||||
full_name: format!("{}__{}", server_name, original_tool_name),
|
||||
full_name: qualified_tool_name(server_name, &original_tool_name),
|
||||
description,
|
||||
parameters_schema,
|
||||
original_tool_name,
|
||||
connection,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,6 +50,14 @@ impl Tool for McpToolWrapper {
|
||||
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> {
|
||||
self.connection
|
||||
.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",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picobot-webui",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
let error = $state("");
|
||||
let query = $state("");
|
||||
let filter = $state("all");
|
||||
let mcpToolSettingsDraft = $state({});
|
||||
|
||||
const filters = [
|
||||
["all", "全部"],
|
||||
@ -64,6 +65,7 @@
|
||||
const mcpToolCount = $derived(
|
||||
mcpList.reduce((sum, server) => sum + (server.tools?.length || 0), 0)
|
||||
);
|
||||
const pendingMcpToolSettingCount = $derived(Object.keys(mcpToolSettingsDraft).length);
|
||||
|
||||
function schema(tool) {
|
||||
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) {
|
||||
if (tab === "mcp" && value !== "mcp") discardMcpToolSettings();
|
||||
tab = value;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
discardMcpToolSettings();
|
||||
load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
@ -146,7 +222,7 @@
|
||||
<Tabs.Trigger value="mcp">MCP</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</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>
|
||||
|
||||
{#if loading}
|
||||
@ -228,6 +304,21 @@
|
||||
<div class="metric"><b>{mcpConnected}</b><small>已连接</small></div>
|
||||
<div class="metric"><b>{mcpToolCount}</b><small>工具总数</small></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">
|
||||
{#each mcpList as server (server.name)}
|
||||
<article class="card" class:disabled={!server.enabled}>
|
||||
@ -264,9 +355,39 @@
|
||||
<summary>{server.tools.length} 个工具</summary>
|
||||
<ul class="mcp-tools">
|
||||
{#each server.tools as tool (tool.name)}
|
||||
{@const settings = mcpToolSettingsFor(server, tool)}
|
||||
<li>
|
||||
<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}
|
||||
<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>
|
||||
{/each}
|
||||
</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; }
|
||||
.card-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.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 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 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-empty { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
|
||||
@media (max-width: 800px) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user