diff --git a/Cargo.toml b/Cargo.toml index 1e2b50e..67a10f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.16.0" +version = "1.17.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 61cb34d..7dcc97e 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fde03c..4b98039 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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__` 命名空间,避免与内置工具混淆;`tool_settings` 仍按 MCP 原始 `` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约,因此每个 `mcp.servers[].tool_settings.` 可在受信任本地配置中声明 `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 或泄漏配置秘密。 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index 1591ca1..ccb560f 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -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 字段 diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index dba4b70..4dac23b 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -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` 自动推导。 --- diff --git a/src/config/mod.rs b/src/config/mod.rs index 58d438a..604fb40 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -551,6 +551,40 @@ pub struct McpServerConfig { pub headers: HashMap, #[serde(default)] pub tool_timeout_secs: Option, + /// 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, +} + +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()); diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 5220f6d..0f70f12 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -800,6 +800,9 @@ pub async fn get_status(State(state): State>) -> Result>(), }) @@ -846,7 +849,7 @@ pub async fn get_tools(State(state): State>) -> Result = 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(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 4259431..d5b8a0d 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -355,6 +355,7 @@ impl GatewayState { tool_info.description, tool_info.schema, tool_info.connection, + tool_info.settings, ); self.session_manager.tools().register(wrapper); } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 26c77e1..f291896 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -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, + pub settings: McpToolSettings, } pub async fn connect_all(config: &McpConfig) -> Vec { @@ -155,9 +172,15 @@ pub async fn connect_all(config: &McpConfig) -> Vec { ); let tool_statuses: Vec = server_tools .iter() - .map(|(name, desc, _)| McpToolStatus { - name: name.clone(), - description: desc.clone(), + .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 { 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")); + } +} diff --git a/src/mcp/tool_wrapper.rs b/src/mcp/tool_wrapper.rs index 5b59127..945665c 100644 --- a/src/mcp/tool_wrapper.rs +++ b/src/mcp/tool_wrapper.rs @@ -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, + settings: McpToolSettings, } impl McpToolWrapper { @@ -21,13 +23,15 @@ impl McpToolWrapper { description: String, parameters_schema: serde_json::Value, connection: Arc, + 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 { self.connection .call_tool(&self.original_tool_name, args) diff --git a/webui/package-lock.json b/webui/package-lock.json index 736257d..0473ef8 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package.json b/webui/package.json index 710baca..cef2db4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.16.0", + "version": "1.17.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/pages/ToolsPage.svelte b/webui/src/pages/ToolsPage.svelte index 49bbe92..3ecf93e 100644 --- a/webui/src/pages/ToolsPage.svelte +++ b/webui/src/pages/ToolsPage.svelte @@ -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); @@ -146,7 +222,7 @@ MCP - + {#if loading} @@ -228,6 +304,21 @@
{mcpConnected}已连接
{mcpToolCount}工具总数
+
0}> + + {#if pendingMcpToolSettingCount} + 已暂存 {pendingMcpToolSettingCount} 项执行属性;离开 MCP 标签或刷新页面会丢弃未保存更改。 + {:else} + 修改工具属性后,可一次保存并应用。 + {/if} + + {#if pendingMcpToolSettingCount} +
+ + +
+ {/if} +
{#each mcpList as server (server.name)}
@@ -264,9 +355,39 @@ {server.tools.length} 个工具
    {#each server.tools as tool (tool.name)} + {@const settings = mcpToolSettingsFor(server, tool)}
  • - {tool.name} +
    + {tool.name} +
    + {#if settings.read_only}● 只读{:else}● 有副作用{/if} + {#if settings.read_only && !settings.exclusive}● 可并发{/if} + {#if settings.exclusive}● 独占{/if} +
    +
    {#if tool.description}{tool.description}{/if} +
    + 执行属性 + + + “可并发”由“只读且非独占”自动计算,不单独保存。 +
  • {/each}
@@ -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) {