feat: move MCP call log into its own settings section with full detail and filters

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 <noreply@anthropic.com>
This commit is contained in:
weidu12123andClaude Opus 4.8 committed 2026-10-16 08:02:36 +08:00
1 parent 5645b08f22
commit 21ac1dd4c5
4 files changed
+421 -67

No files matched your search

+243 -41
View File
@@ -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<McpCallRecord[]>([]);
const mcpCallHosts = ref<string[]>([]);
const mcpCallTools = ref<string[]>([]);
const loadingMcpCalls = ref(false);
// 列表只回预览,展开某一行时才按 id 取回完整参数与返回结果
const expandedCallId = ref<string | null>(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<SettingsTabKey>('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()"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1723,7 +1790,7 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-bold">系统设置</h2>
<p class="text-xs text-zinc-400">管理 MCP Server 远程/本机接入、API Key 与 Web 访问安全</p>
<p class="text-xs text-zinc-400">管理 MCP Server 远程/本机接入、调用记录与 Web 访问安全</p>
</div>
<div class="flex items-center gap-2">
<button
@@ -1796,6 +1863,23 @@ onBeforeUnmount(() => {
<span>MCP Server</span>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
:class="
activeSettingsTab === 'calls'
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
@click="activeSettingsTab = 'calls'"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16v16H4z" />
<path d="M8 9h8M8 13h8M8 17h5" />
</svg>
<span>调用记录</span>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
@@ -2164,8 +2248,10 @@ onBeforeUnmount(() => {
一行一条,<code class="font-mono">#</code> 起头是注释。<code class="font-mono">*</code> 通配,没有 <code class="font-mono">*</code> 就是精确匹配,按词边界匹配(<code class="font-mono">rm -rf /</code> 不会误伤 <code class="font-mono">rm -rf /var/log</code>)。判定前会剥引号、转小写,并把 <code class="font-mono">-r -f</code> 与 <code class="font-mono">-fr</code> 视作等价。这份名单防手滑,不防蓄意绕过。
</p>
</section>
</div>
<!-- 板块 3:MCP 调用记录(全程留痕) -->
<!-- 分类 3:调用记录 (MCP Call Log) -->
<div v-show="activeSettingsTab === 'calls'" class="space-y-4">
<section
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
>
@@ -2173,7 +2259,7 @@ onBeforeUnmount(() => {
<div>
<h3 class="text-sm font-semibold">MCP 调用记录</h3>
<p class="mt-0.5 text-xs text-zinc-400">
外部 agent 经 MCP 通道的每一次调用都在此留痕(工具、目标主机、参数、成败、耗时、结果摘要)。只记 MCP 这条链,内部执行不入库
外部 agent 经 MCP 通道的每一次调用都在此留痕。只记 MCP 这条链,内部执行(探针、压缩、注册表等)不入库。点任意一行展开完整参数与返回结果
</p>
</div>
<button
@@ -2186,17 +2272,88 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- 筛选条:时间范围 / 主机 / 工具 / 结果 -->
<div class="flex flex-wrap items-end gap-2.5">
<label class="flex flex-col gap-1">
<span class="text-[11px] font-medium text-zinc-500">起始日期</span>
<input
v-model="mcpCallFilters.from"
type="date"
class="rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px] text-zinc-700 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200 dark:[color-scheme:dark]"
@change="loadMcpCalls"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] font-medium text-zinc-500">结束日期</span>
<input
v-model="mcpCallFilters.to"
type="date"
class="rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px] text-zinc-700 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200 dark:[color-scheme:dark]"
@change="loadMcpCalls"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] font-medium text-zinc-500">主机</span>
<select
v-model="mcpCallFilters.host"
class="min-w-[9rem] rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px] text-zinc-700 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
@change="loadMcpCalls"
>
<option value="">全部主机</option>
<option v-for="h in mcpCallHosts" :key="h" :value="h">{{ h }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] font-medium text-zinc-500">工具</span>
<select
v-model="mcpCallFilters.tool"
class="min-w-[9rem] rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px] text-zinc-700 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
@change="loadMcpCalls"
>
<option value="">全部工具</option>
<option v-for="t in mcpCallTools" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] font-medium text-zinc-500">结果</span>
<select
v-model="mcpCallFilters.ok"
class="rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px] text-zinc-700 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
@change="loadMcpCalls"
>
<option value="">全部</option>
<option value="true">成功</option>
<option value="false">失败</option>
</select>
</label>
<button
v-if="mcpCallFiltered"
type="button"
class="rounded-xl border border-zinc-200/80 px-3 py-1.5 text-[11px] font-medium text-zinc-500 transition hover:bg-zinc-100/80 cursor-pointer dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click="resetMcpCallFilters"
>
重置筛选
</button>
</div>
<div class="text-[11px] text-zinc-400">
{{ mcpCallFiltered ? '符合筛选的调用' : '最近调用' }}
<span class="font-medium text-zinc-600 dark:text-zinc-300">{{ mcpCalls.length }}</span>
条{{ mcpCalls.length >= MCP_CALL_LIMIT ? `(已达 ${MCP_CALL_LIMIT} 条上限,更早的记录请用筛选查看)` : '' }}
</div>
<div
v-if="mcpCalls.length === 0"
class="rounded-xl border border-dashed border-zinc-200 py-8 text-center text-xs text-zinc-400 dark:border-zinc-800"
>
暂无调用记录
{{ mcpCallFiltered ? '没有符合筛选条件的调用记录' : '暂无调用记录' }}
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-left text-[11px]">
<thead>
<tr class="text-zinc-400">
<th class="w-6 py-2"></th>
<th class="py-2 pr-3 font-medium whitespace-nowrap">时间</th>
<th class="py-2 pr-3 font-medium">工具</th>
<th class="py-2 pr-3 font-medium">主机</th>
@@ -2206,36 +2363,81 @@ onBeforeUnmount(() => {
</tr>
</thead>
<tbody>
<tr
v-for="(c, i) in mcpCalls"
:key="i"
class="border-t border-zinc-100 align-top dark:border-zinc-800"
>
<td class="py-2 pr-3 font-mono whitespace-nowrap text-zinc-500">{{ formatCallTime(c.ts) }}</td>
<td class="py-2 pr-3 font-mono text-zinc-800 dark:text-zinc-200">{{ c.tool }}</td>
<td class="py-2 pr-3 text-zinc-600 dark:text-zinc-300">{{ c.host || '—' }}</td>
<td class="py-2 pr-3">
<span
class="rounded-md px-1.5 py-0.5 font-medium whitespace-nowrap"
:class="
c.ok
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/50 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-950/50 dark:text-red-400'
"
>
{{ c.ok ? '成功' : '失败' }}
</span>
</td>
<td class="py-2 pr-3 font-mono whitespace-nowrap text-zinc-500">{{ c.durationMs }}ms</td>
<td class="py-2 pr-3">
<div class="max-w-[440px] truncate font-mono text-zinc-600 dark:text-zinc-300" :title="c.args">
{{ c.args }}
</div>
<div class="mt-0.5 max-w-[440px] truncate text-zinc-400" :title="c.summary">
{{ c.summary }}
</div>
</td>
</tr>
<template v-for="c in mcpCalls" :key="c.id">
<tr
class="cursor-pointer border-t border-zinc-100 align-top transition hover:bg-zinc-50/80 dark:border-zinc-800 dark:hover:bg-zinc-800/40"
@click="toggleCall(c.id)"
>
<td class="py-2 pr-1 text-zinc-400 select-none">
{{ expandedCallId === c.id ? '▾' : '▸' }}
</td>
<td class="py-2 pr-3 font-mono whitespace-nowrap text-zinc-500">{{ formatCallTime(c.ts) }}</td>
<td class="py-2 pr-3 font-mono text-zinc-800 dark:text-zinc-200">{{ c.tool }}</td>
<td class="py-2 pr-3 text-zinc-600 dark:text-zinc-300">{{ c.host || '—' }}</td>
<td class="py-2 pr-3">
<span
class="rounded-md px-1.5 py-0.5 font-medium whitespace-nowrap"
:class="
c.ok
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/50 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-950/50 dark:text-red-400'
"
>
{{ c.ok ? '成功' : '失败' }}
</span>
</td>
<td class="py-2 pr-3 font-mono whitespace-nowrap text-zinc-500">{{ c.durationMs }}ms</td>
<td class="py-2 pr-3">
<div class="max-w-[440px] truncate font-mono text-zinc-600 dark:text-zinc-300" :title="c.args">
{{ c.args }}
</div>
<div class="mt-0.5 max-w-[440px] truncate text-zinc-400" :title="c.summary">
{{ c.summary }}
</div>
</td>
</tr>
<tr
v-if="expandedCallId === c.id"
class="border-t border-zinc-100 bg-zinc-50/70 dark:border-zinc-800 dark:bg-zinc-950/50"
>
<td colspan="7" class="px-4 py-3.5">
<div v-if="loadingCallDetail" class="text-[11px] text-zinc-400">加载完整内容中…</div>
<div v-else class="space-y-3">
<div>
<div class="mb-1.5 flex items-center gap-2">
<span class="text-[11px] font-semibold text-zinc-500">参数(完整)</span>
<button
type="button"
class="rounded-md border border-zinc-200/80 px-2 py-0.5 text-[10px] text-zinc-500 transition hover:bg-zinc-100/80 cursor-pointer dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click.stop="copyText(callDetail?.args || '', '已复制完整参数')"
>
复制
</button>
</div>
<pre
class="max-h-72 overflow-auto rounded-xl border border-zinc-200/80 bg-white p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-700 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
>{{ callDetail?.args || '(无参数)' }}</pre>
</div>
<div>
<div class="mb-1.5 flex items-center gap-2">
<span class="text-[11px] font-semibold text-zinc-500">返回结果(完整)</span>
<button
type="button"
class="rounded-md border border-zinc-200/80 px-2 py-0.5 text-[10px] text-zinc-500 transition hover:bg-zinc-100/80 cursor-pointer dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click.stop="copyText(callDetail?.result || '', '已复制完整返回结果')"
>
复制
</button>
</div>
<pre
class="max-h-96 overflow-auto rounded-xl border border-zinc-200/80 bg-white p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-700 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"
>{{ callDetail?.result || '(本次调用早于留痕升级,未记录完整返回结果)' }}</pre>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
+41 -3
View File
@@ -68,6 +68,7 @@ pub fn build_api_router(state: Arc<AppState>) -> 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<i64>,
tool: Option<String>,
host: Option<String>,
ok: Option<bool>,
/// RFC3339 时间下界(含)/ 上界(不含),由前端按本地时区换算好再发过来
from: Option<String>,
to: Option<String>,
}
/// 读取最近的 MCP 调用记录(全程留痕的查看口)。
fn non_empty(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
/// 读取 MCP 调用记录(全程留痕的查看口):支持按时间范围、主机、工具、成败筛选。
async fn api_list_mcp_calls(
State(state): State<Arc<AppState>>,
Query(q): Query<McpCallsQuery>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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()
}
+131 -20
View File
@@ -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<String>,
pub host: Option<String>,
pub ok: Option<bool>,
/// RFC3339 时间下界(含)
pub from: Option<String>,
/// RFC3339 时间上界(不含)
pub to: Option<String>,
}
pub struct Database {
conn: Mutex<Connection>,
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<Vec<serde_json::Value>> {
/// 按条件读取 MCP 调用记录(倒序)。列表只回预览,正文由 `get_mcp_call` 单条取,
/// 免得一次拉几百条把上百 KB 的输出全灌进浏览器。
pub fn list_mcp_calls(&self, f: &McpCallFilter) -> Result<Vec<serde_json::Value>> {
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<String>>(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<String>>(3)?,
"ok": r.get::<_, i64>(4)? != 0,
"durationMs": r.get::<_, i64>(5)?,
"summary": r.get::<_, String>(6)?,
"args": clip(&args, 400),
}))
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// 一条 MCP 调用记录的完整正文(参数与返回结果原文)。
pub fn get_mcp_call(&self, id: &str) -> Result<Option<serde_json::Value>> {
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<String>>(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<String>, Vec<String>)> {
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::<rusqlite::Result<Vec<_>>>()?;
// 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::<rusqlite::Result<Vec<_>>>()?;
let mut hosts: Vec<String> = 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<AppSettings> {
let is_vps_mode = default_auth_enabled();
+6 -3
View File
@@ -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,