From 21ac1dd4c5a9ee5db2d8658d926cd6f8fb5ea772 Mon Sep 17 00:00:00 2001 From: weidu12123 Date: Fri, 16 Oct 2026 08:02:36 +0800 Subject: [PATCH] feat: move MCP call log into its own settings section with full detail and filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call log was a bare table crammed into the MCP Server settings tab: no way to read what an agent actually sent or got back, and no way to narrow it down. - mcp_calls gains a `result` column holding the full text returned to the agent (previously only a one-line summary survived). Args and result are each capped at 256 KiB, and hitting the cap writes the truncation notice into the body itself instead of hiding it behind a flag. Existing databases pick the column up via ALTER TABLE; old rows stay empty. - GET /api/mcp-calls now filters by host, tool, ok, and a closed-open time range. Host matching expands comma-joined batch targets, so `vip-hk` finds "vip-hk,gateway,..." while `hk` still matches nothing. - The list returns a 400-char args preview only; a new GET /api/mcp-calls/:id serves one record's full args and result, so a 500-row view cannot pull hundreds of megabytes into the browser. - Settings gets a fourth category, "调用记录": date range (local-day boundaries converted to UTC instants), host/tool/result selects, reset, and rows that expand into the complete args and result with copy buttons. Verified on the running build against 686 existing records: host, tool, result, date and combined filters all narrow correctly, and a 300 KB output shows the in-body truncation note. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.vue | 284 ++++++++++++++++++++++++++++++++++++------- src/api.rs | 44 ++++++- src/db.rs | 151 ++++++++++++++++++++--- src/mcp.rs | 9 +- 4 files changed, 421 insertions(+), 67 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3868e66..9784ad0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -107,6 +107,7 @@ const savingGuard = ref(false); // MCP 调用记录(全程留痕):外部 agent 经 MCP 通道的每一次调用 type McpCallRecord = { + id: string; ts: string; tool: string; host: string | null; @@ -116,7 +117,25 @@ type McpCallRecord = { summary: string; }; const mcpCalls = ref([]); +const mcpCallHosts = ref([]); +const mcpCallTools = ref([]); const loadingMcpCalls = ref(false); +// 列表只回预览,展开某一行时才按 id 取回完整参数与返回结果 +const expandedCallId = ref(null); +const callDetail = ref<{ args: string; result: string } | null>(null); +const loadingCallDetail = ref(false); +const MCP_CALL_LIMIT = 500; +const mcpCallFilters = ref({ from: '', to: '', host: '', tool: '', ok: '' }); +const mcpCallFiltered = computed( + () => + Boolean( + mcpCallFilters.value.from || + mcpCallFilters.value.to || + mcpCallFilters.value.host || + mcpCallFilters.value.tool || + mcpCallFilters.value.ok + ) +); const mcpStatus = ref<{ exePath: string; @@ -129,7 +148,7 @@ const mcpStatus = ref<{ } | null>(null); // 设置页分类导航与状态 -type SettingsTabKey = 'general' | 'security' | 'mcp' | 'about'; +type SettingsTabKey = 'general' | 'security' | 'mcp' | 'calls' | 'about'; const activeSettingsTab = ref('general'); const autostartEnabled = ref(false); @@ -427,7 +446,21 @@ async function saveGuardList() { async function loadMcpCalls() { loadingMcpCalls.value = true; try { - const res = await fetch('/api/mcp-calls?limit=200'); + const q = new URLSearchParams(); + q.set('limit', String(MCP_CALL_LIMIT)); + const f = mcpCallFilters.value; + // 日期按使用者本地时区取整天,换算成 UTC 瞬时再交给后端做闭开区间比较 + if (f.from) q.set('from', new Date(`${f.from}T00:00:00`).toISOString()); + if (f.to) { + const end = new Date(`${f.to}T00:00:00`); + end.setDate(end.getDate() + 1); + q.set('to', end.toISOString()); + } + if (f.host) q.set('host', f.host); + if (f.tool) q.set('tool', f.tool); + if (f.ok) q.set('ok', f.ok); + + const res = await fetch(`/api/mcp-calls?${q.toString()}`); if (res.status === 401) { authenticated.value = false; return; @@ -435,6 +468,8 @@ async function loadMcpCalls() { const data = await res.json(); if (data.ok) { mcpCalls.value = Array.isArray(data.calls) ? data.calls : []; + mcpCallHosts.value = Array.isArray(data.hosts) ? data.hosts : []; + mcpCallTools.value = Array.isArray(data.tools) ? data.tools : []; } } catch { // ignore @@ -443,17 +478,49 @@ async function loadMcpCalls() { } } -// 把 RFC3339 时间戳显示成本地「MM-DD HH:MM:SS」 +function resetMcpCallFilters() { + mcpCallFilters.value = { from: '', to: '', host: '', tool: '', ok: '' }; + void loadMcpCalls(); +} + +// 展开/收起一行:展开时才去取这条记录的完整正文 +async function toggleCall(id: string) { + if (expandedCallId.value === id) { + expandedCallId.value = null; + callDetail.value = null; + return; + } + expandedCallId.value = id; + callDetail.value = null; + loadingCallDetail.value = true; + try { + const res = await fetch(`/api/mcp-calls/${encodeURIComponent(id)}`); + const data = await res.json(); + if (data.ok && data.call) { + callDetail.value = { args: data.call.args || '', result: data.call.result || '' }; + } + } catch { + // ignore + } finally { + loadingCallDetail.value = false; + } +} + +// 把 RFC3339 时间戳显示成本地「YYYY-MM-DD HH:MM:SS」 function formatCallTime(ts: string): string { const d = new Date(ts); if (Number.isNaN(d.getTime())) return ts; const p = (n: number) => String(n).padStart(2, '0'); - return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } -// 切到「MCP Server」分类时自动拉取调用记录 +// 切到「调用记录」分类时自动拉取 watch(activeSettingsTab, (tab) => { - if (tab === 'mcp') void loadMcpCalls(); + if (tab === 'calls') { + expandedCallId.value = null; + callDetail.value = null; + void loadMcpCalls(); + } }); async function loadAutostartSettings() { @@ -1690,7 +1757,7 @@ onBeforeUnmount(() => { ? 'border-zinc-900 bg-zinc-900 text-white shadow-sm dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900' : 'border-zinc-200/80 bg-white/85 text-zinc-600 hover:bg-white hover:text-zinc-900 dark:border-zinc-700/80 dark:bg-zinc-800/85 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white' " - title="进入设置页(MCP Server / API Key / 登录安全)" + title="进入设置页(MCP Server / 调用记录 / 登录安全)" @click="openSettingsPage()" > @@ -1723,7 +1790,7 @@ onBeforeUnmount(() => {

系统设置

-

管理 MCP Server 远程/本机接入、API Key 与 Web 访问安全

+

管理 MCP Server 远程/本机接入、调用记录与 Web 访问安全

+ +
- + +
@@ -2173,7 +2259,7 @@ onBeforeUnmount(() => {

MCP 调用记录

- 外部 agent 经 MCP 通道的每一次调用都在此留痕(工具、目标主机、参数、成败、耗时、结果摘要)。只记 MCP 这条链,内部执行不入库 + 外部 agent 经 MCP 通道的每一次调用都在此留痕。只记 MCP 这条链,内部执行(探针、压缩、注册表等)不入库。点任意一行展开完整参数与返回结果

+ +
+ + + + + + +
+ +
+ {{ mcpCallFiltered ? '符合筛选的调用' : '最近调用' }} + {{ mcpCalls.length }} + 条{{ mcpCalls.length >= MCP_CALL_LIMIT ? `(已达 ${MCP_CALL_LIMIT} 条上限,更早的记录请用筛选查看)` : '' }} +
+
- 暂无调用记录 + {{ mcpCallFiltered ? '没有符合筛选条件的调用记录' : '暂无调用记录' }}
+ @@ -2206,36 +2363,81 @@ onBeforeUnmount(() => { - - - - - - - - +
时间 工具 主机
{{ formatCallTime(c.ts) }}{{ c.tool }}{{ c.host || '—' }} - - {{ c.ok ? '成功' : '失败' }} - - {{ c.durationMs }}ms -
- {{ c.args }} -
-
- {{ c.summary }} -
-
diff --git a/src/api.rs b/src/api.rs index 7935647..dfc8101 100644 --- a/src/api.rs +++ b/src/api.rs @@ -68,6 +68,7 @@ pub fn build_api_router(state: Arc) -> Router { .route("/api/settings/mcp", put(api_update_mcp_settings)) .route("/api/guard", get(api_get_guard).put(api_update_guard)) .route("/api/mcp-calls", get(api_list_mcp_calls)) + .route("/api/mcp-calls/:id", get(api_get_mcp_call)) .route("/api/events", get(api_sse_events)) .route("/api/hosts", get(list_hosts).post(create_host)) .route("/api/hosts/reorder", put(reorder_hosts)) @@ -1577,17 +1578,54 @@ async fn api_mcp_rpc( } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] struct McpCallsQuery { limit: Option, + tool: Option, + host: Option, + ok: Option, + /// RFC3339 时间下界(含)/ 上界(不含),由前端按本地时区换算好再发过来 + from: Option, + to: Option, } -/// 读取最近的 MCP 调用记录(全程留痕的查看口)。 +fn non_empty(v: Option) -> Option { + v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// 读取 MCP 调用记录(全程留痕的查看口):支持按时间范围、主机、工具、成败筛选。 async fn api_list_mcp_calls( State(state): State>, Query(q): Query, ) -> impl IntoResponse { - match state.db.list_mcp_calls(q.limit.unwrap_or(200)) { - Ok(calls) => (StatusCode::OK, Json(json!({ "ok": true, "calls": calls }))).into_response(), + let filter = crate::db::McpCallFilter { + limit: q.limit.unwrap_or(200), + tool: non_empty(q.tool), + host: non_empty(q.host), + ok: q.ok, + from: non_empty(q.from), + to: non_empty(q.to), + }; + match state.db.list_mcp_calls(&filter) { + Ok(calls) => { + let (hosts, tools) = state.db.mcp_call_facets().unwrap_or_default(); + (StatusCode::OK, Json(json!({ "ok": true, "calls": calls, "hosts": hosts, "tools": tools }))) + .into_response() + } + Err(e) => { + err_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("{e:#}")).into_response() + } + } +} + +/// 单条 MCP 调用记录的完整正文(参数与返回结果原文)。 +async fn api_get_mcp_call( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.db.get_mcp_call(&id) { + Ok(Some(call)) => (StatusCode::OK, Json(json!({ "ok": true, "call": call }))).into_response(), + Ok(None) => err_response(StatusCode::NOT_FOUND, "调用记录不存在").into_response(), Err(e) => { err_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("{e:#}")).into_response() } diff --git a/src/db.rs b/src/db.rs index 3bf64c5..2f342df 100644 --- a/src/db.rs +++ b/src/db.rs @@ -26,6 +26,36 @@ fn clip(s: &str, max: usize) -> String { out } +/// MCP 调用留痕里「参数」与「结果」正文各自的上限(按 char)。超出即截断并在正文里 +/// 写明原始长度——留痕是给人看的,宁可占点磁盘,也不要让人对着半截内容猜。 +const MCP_CALL_TEXT_LIMIT: usize = 256 * 1024; + +/// 与 `clip` 相同,但把截断这件事写进正文本身,读取方不必额外记标志位。 +fn clip_with_note(s: &str, max: usize) -> String { + let total = s.chars().count(); + if total <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push_str(&format!( + "\n\n…(已截断:原始 {total} 字符,此处保留前 {max} 字符)" + )); + out +} + +/// MCP 调用记录的查询条件。所有字段都是可选的,为空即不参与过滤。 +#[derive(Debug, Default, Clone)] +pub struct McpCallFilter { + pub limit: i64, + pub tool: Option, + pub host: Option, + pub ok: Option, + /// RFC3339 时间下界(含) + pub from: Option, + /// RFC3339 时间上界(不含) + pub to: Option, +} + pub struct Database { conn: Mutex, master_key: MasterKey, @@ -122,7 +152,8 @@ impl Database { args TEXT NOT NULL DEFAULT '', ok INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, - summary TEXT NOT NULL DEFAULT '' + summary TEXT NOT NULL DEFAULT '', + result TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_mcp_calls_ts ON mcp_calls(ts);", )?; @@ -131,6 +162,11 @@ impl Database { "ALTER TABLE hosts ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0", [], ); + // 兼容升级:MCP 调用留痕补上「返回给 agent 的完整结果」一列 + let _ = conn.execute( + "ALTER TABLE mcp_calls ADD COLUMN result TEXT NOT NULL DEFAULT ''", + [], + ); let db = Self { conn: Mutex::new(conn), @@ -874,58 +910,133 @@ impl Database { Ok(()) } - /// 记录一次 MCP 工具调用(全程留痕:工具、目标主机、参数、成败、耗时、结果摘要)。 + /// 记录一次 MCP 工具调用(全程留痕:工具、目标主机、参数、成败、耗时、结果摘要、完整结果)。 /// 只覆盖 MCP 这条链——这是外部 agent 唯一能下达命令的通道,其余内部执行不入库。 pub fn record_mcp_call( &self, tool: &str, host: Option<&str>, args: &str, + result: &str, ok: bool, duration_ms: u64, summary: &str, ) -> Result<()> { let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?; conn.execute( - "INSERT INTO mcp_calls (id, ts, tool, host, args, ok, duration_ms, summary) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO mcp_calls (id, ts, tool, host, args, ok, duration_ms, summary, result) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ Uuid::new_v4().to_string(), Utc::now().to_rfc3339(), tool, host, - clip(args, 4000), + clip_with_note(args, MCP_CALL_TEXT_LIMIT), if ok { 1 } else { 0 }, duration_ms as i64, clip(summary, 2000), + clip_with_note(result, MCP_CALL_TEXT_LIMIT), ], )?; Ok(()) } - /// 读取最近的 MCP 调用记录(倒序),供界面/排查查看。 - pub fn list_mcp_calls(&self, limit: i64) -> Result> { + /// 按条件读取 MCP 调用记录(倒序)。列表只回预览,正文由 `get_mcp_call` 单条取, + /// 免得一次拉几百条把上百 KB 的输出全灌进浏览器。 + pub fn list_mcp_calls(&self, f: &McpCallFilter) -> Result> { let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?; let mut stmt = conn.prepare( - "SELECT ts, tool, host, args, ok, duration_ms, summary - FROM mcp_calls ORDER BY ts DESC LIMIT ?1", + "SELECT id, ts, tool, host, ok, duration_ms, summary, args + FROM mcp_calls + WHERE (?1 IS NULL OR tool = ?1) + AND (?2 IS NULL OR host = ?2 OR instr(',' || host || ',', ',' || ?2 || ',') > 0) + AND (?3 IS NULL OR ok = ?3) + AND (?4 IS NULL OR ts >= ?4) + AND (?5 IS NULL OR ts < ?5) + ORDER BY ts DESC LIMIT ?6", )?; + let ok_flag = f.ok.map(|b| if b { 1i64 } else { 0i64 }); let rows = stmt - .query_map(params![limit.clamp(1, 1000)], |r| { - Ok(serde_json::json!({ - "ts": r.get::<_, String>(0)?, - "tool": r.get::<_, String>(1)?, - "host": r.get::<_, Option>(2)?, - "args": r.get::<_, String>(3)?, - "ok": r.get::<_, i64>(4)? != 0, - "durationMs": r.get::<_, i64>(5)?, - "summary": r.get::<_, String>(6)?, - })) - })? + .query_map( + params![ + f.tool, + f.host, + ok_flag, + f.from, + f.to, + f.limit.clamp(1, 5000) + ], + |r| { + let args: String = r.get(7)?; + Ok(serde_json::json!({ + "id": r.get::<_, String>(0)?, + "ts": r.get::<_, String>(1)?, + "tool": r.get::<_, String>(2)?, + "host": r.get::<_, Option>(3)?, + "ok": r.get::<_, i64>(4)? != 0, + "durationMs": r.get::<_, i64>(5)?, + "summary": r.get::<_, String>(6)?, + "args": clip(&args, 400), + })) + }, + )? .collect::>>()?; Ok(rows) } + /// 一条 MCP 调用记录的完整正文(参数与返回结果原文)。 + pub fn get_mcp_call(&self, id: &str) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?; + let row = conn + .query_row( + "SELECT id, ts, tool, host, ok, duration_ms, summary, args, result + FROM mcp_calls WHERE id = ?1", + params![id], + |r| { + Ok(serde_json::json!({ + "id": r.get::<_, String>(0)?, + "ts": r.get::<_, String>(1)?, + "tool": r.get::<_, String>(2)?, + "host": r.get::<_, Option>(3)?, + "ok": r.get::<_, i64>(4)? != 0, + "durationMs": r.get::<_, i64>(5)?, + "summary": r.get::<_, String>(6)?, + "args": r.get::<_, String>(7)?, + "result": r.get::<_, String>(8)?, + })) + }, + ) + .optional()?; + Ok(row) + } + + /// 记录里出现过的工具与主机名,供筛选下拉框使用(取全表,不随筛选条件收缩)。 + pub fn mcp_call_facets(&self) -> Result<(Vec, Vec)> { + let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?; + let mut stmt = conn.prepare( + "SELECT DISTINCT tool FROM mcp_calls WHERE tool <> '' ORDER BY tool COLLATE NOCASE", + )?; + let tools = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>>()?; + + // host 列可能是 "a,b,c" 这种逗号串(批量调用),这里摊平成单个主机名去重。 + let mut stmt = conn.prepare("SELECT host FROM mcp_calls WHERE host IS NOT NULL")?; + let raw = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>>()?; + let mut hosts: Vec = raw + .iter() + .flat_map(|s| s.split(',')) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + hosts.sort_by_key(|s| s.to_lowercase()); + hosts.dedup(); + Ok((hosts, tools)) + } + pub fn get_app_settings(&self) -> Result { let is_vps_mode = default_auth_enabled(); diff --git a/src/mcp.rs b/src/mcp.rs index e18119a..da5dfc2 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1139,24 +1139,26 @@ pub async fn handle_mcp_request( let started = Instant::now(); let outcome = dispatch_tool_call(state, exec_tasks, &tool_name, args).await; - let (payload, record_ok, summary) = match outcome { + let (payload, record_ok, summary, result_text) = match outcome { Ok(val) => { let text = serde_json::to_string_pretty(&val).unwrap_or_default(); let ok = val.get("ok").and_then(|v| v.as_bool()).unwrap_or(true); let summary = format!("ok={ok} {}", mcp_result_summary(&val)); ( - json!({ "content": [{ "type": "text", "text": text }], "isError": false }), + json!({ "content": [{ "type": "text", "text": &text }], "isError": false }), ok, summary, + text, ) } Err(err) => { let msg = format!("{err:#}"); let text = json!({ "ok": false, "error": msg }).to_string(); ( - json!({ "content": [{ "type": "text", "text": text }], "isError": true }), + json!({ "content": [{ "type": "text", "text": &text }], "isError": true }), false, format!("error: {msg}"), + text, ) } }; @@ -1165,6 +1167,7 @@ pub async fn handle_mcp_request( &tool_name, host_label.as_deref(), &args_json, + &result_text, record_ok, started.elapsed().as_millis() as u64, &summary,