feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio

- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认)
- 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go
- 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码)
- Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
logan committed 2026-10-15 11:14:08 +08:00
1 parent 5c17d32647
commit 0b68c8344d
19 files changed
+1989 -79

No files matched your search

+84 -6
View File
@@ -6,6 +6,7 @@ declare global {
interface Window {
__OPSXDB_API__?: string;
__OPSXDB_TOKEN__?: string;
__OPSXDB_BOOT_ERROR__?: string;
}
}
@@ -96,6 +97,7 @@ export type ConnBody = {
confirmDanger?: boolean;
key?: string;
pattern?: string;
keyCursor?: string;
cursor?: number;
redisMode?: string;
redisNodes?: string;
@@ -152,6 +154,7 @@ export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Pa
confirmDanger: extra?.confirmDanger,
key: extra?.key,
pattern: extra?.pattern,
keyCursor: extra?.keyCursor,
service: extra?.service ?? c.service,
requestId: extra?.requestId,
timeoutMs: extra?.timeoutMs,
@@ -419,10 +422,12 @@ export async function explainSql(
}
export type RedisKeyInfo = { name: string; type: string; ttl: number };
export type RedisKeyInfo = { name: string; type: string; ttl: number; exact?: boolean };
export type RedisKeysResult = {
keys: RedisKeyInfo[];
nextCursor?: string;
exactHit?: boolean;
truncated?: boolean;
timeMs?: number;
error?: string;
@@ -446,11 +451,11 @@ export type RedisKeyValue = {
export async function listRedisKeys(
c: SavedConnection,
database: string,
opts?: { sessionId?: string; pattern?: string; limit?: number },
opts?: { sessionId?: string; pattern?: string; limit?: number; keyCursor?: string },
) {
return post<RedisKeysResult>(
"/api/db/redis/keys",
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, driver: c.driver }),
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, keyCursor: opts?.keyCursor, driver: c.driver }),
);
}
@@ -520,27 +525,65 @@ export async function callAi(payload: {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
}
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
export type AiConfig = {
provider: string;
baseURL: string;
model: string;
hasKey: boolean;
apiKey?: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
};
export async function getAiConfig(signal?: AbortSignal) {
return get<AiConfig>("/api/ai/config", signal);
}
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
/** 桌面连接双写到 ~/.opsxdb/connections.json,供 MCP 进程读取(P0)。失败不抛,避免挡保存。 */
export async function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
try {
await fetch(`${API}/api/db/connections-store`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ connections }),
});
} catch {
/* 后端未起时忽略 */
}
}
export async function loadConnectionsFromDisk(): Promise<SavedConnection[] | null> {
try {
const res = await fetch(`${API}/api/db/connections-store`, { headers: { ...authHeaders() } });
if (!res.ok) return null;
const data = (await res.json()) as { connections?: SavedConnection[] };
return Array.isArray(data.connections) ? data.connections : null;
} catch {
return null;
}
}
export async function saveAiConfig(cfg: {
provider: string;
baseURL: string;
apiKey?: string;
model: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
}) {
return post<{ ok: boolean }>("/api/ai/config", cfg);
}
export async function health() {
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
if (!res.ok) throw new Error(`health ${res.status}`);
return res.json();
const data = await res.json() as { service?: string; version?: string };
if (data.service !== "opsxdb") throw new Error("当前地址不是 OpsxDB 后端");
return data;
}
/** fetch AbortController / DOMException AbortError */
@@ -590,3 +633,38 @@ export async function driverImportOffline(file: File) {
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
}
/** 表转储范围:仅结构 / 仅数据 / 结构+数据(服务端 DumpTable 对应值) */
export type DumpMode = "structure" | "data" | "structure_data";
/** 表右键「转储 SQL 文件」:POST /api/db/download → blob 下载(.sql) */
export async function dumpTable(
c: SavedConnection,
opts: { table: string; database?: string; mode: DumpMode; sessionId?: string },
): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/dump`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, { format: opts.mode, table: opts.table, database: opts.database || c.database }),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error || res.statusText || "转储失败");
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** 触发浏览器下载 blob(导出/转储共用) */
export function downloadBlob(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
+87 -14
View File
@@ -3,7 +3,8 @@ import type { DriverId } from "./catalog";
import { driverById } from "./catalog";
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
import { decodeSecret, encodeSecret } from "./secrets";
import { dropSchemaCache } from "./api";
import { dropSchemaCache, syncConnectionsToDisk } from "./api";
import { normalizeRedisDatabase } from "./redisContext";
export type SavedConnection = {
id: string;
@@ -115,6 +116,8 @@ export type ResultSetItem = {
label: string;
sql: string;
result: QueryResult;
/** 仅对象预览填入,避免复制 INSERT 时误用其他标签的全局元数据。 */
source?: { connectionId: string; driver: string; database: string; table: string };
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
page?: number;
pageSize?: number;
@@ -123,6 +126,8 @@ export type ResultSetItem = {
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
viewState?: {
colWidths?: Record<string, number>;
sortCol?: string | null;
sortDir?: "asc" | "desc" | null;
selectedIdx?: number | null;
selectedCol?: string | null;
};
@@ -142,6 +147,7 @@ export type ColumnInfo = {
key?: string;
default?: unknown;
comment?: string;
extra?: string;
};
export type TreeNode = {
@@ -158,6 +164,8 @@ export type TreeNode = {
ttl?: number;
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
fullName?: string;
/** Redis TYPE 精确命中(搜索置顶标「精确」) */
exact?: boolean;
nullable?: boolean;
key?: string;
};
@@ -192,7 +200,7 @@ export type ViewMode = "workbench" | "picker";
export type PickerMode = "list" | "drivers";
type State = {
theme: "dark" | "light";
theme: "dark" | "light" | "system";
view: ViewMode;
connections: SavedConnection[];
connGroups: ConnectionGroup[];
@@ -200,6 +208,8 @@ type State = {
activeConnectionId: string | null;
tree: TreeNode[];
treeLoading: boolean;
/** 侧栏 ↻ 等触发对象树重载 */
treeEpoch: number;
tabs: QueryTab[];
activeTabId: string;
result: QueryResult | null;
@@ -250,6 +260,7 @@ type State = {
viewDataRequest: { database: string; table: string; token: number } | null;
set: <K extends keyof State>(key: K, value: State[K]) => void;
requestTreeRefresh: () => void;
notify: (input: {
kind?: NotifyKind;
title?: string;
@@ -277,11 +288,13 @@ type State = {
openPicker: (driver?: DriverId | null) => void;
openConnectionList: () => void;
editConnection: (c: SavedConnection) => void;
newQueryTab: () => void;
newQueryTab: (database?: string) => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
setTabDatabase: (database: string) => void;
setTabDatabase: (database: string, tabId?: string) => void;
/** 切连接时激活其查询标签,或新建查询;保留别的连接的 SQL 草稿。 */
selectConnection: (id: string) => void;
closeTab: (id: string) => void;
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
closeOtherTabs: (id: string) => void;
@@ -290,6 +303,8 @@ type State = {
setStopOnError: (v: boolean) => void;
setExecTimeoutMs: (v: number) => void;
updateActiveSql: (sql: string) => void;
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
pushAi: (role: AiMessage["role"], content: string) => void;
setSession: (connectionId: string, sessionId: string | null) => void;
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
@@ -323,7 +338,7 @@ function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "r
if (!raw) return {};
const p = JSON.parse(raw) as Record<string, unknown>;
return {
theme: p.theme === "light" ? "light" : "dark",
theme: p.theme === "light" || p.theme === "system" ? p.theme : "dark",
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
@@ -411,6 +426,11 @@ function defaultTab(): QueryTab {
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
}
/** 启动占位标签没有任何用户内容,可在真正创建标签时直接替换。 */
function isEmptyStartupTab(tab: QueryTab): boolean {
return !tab.connectionId && !tab.database && !tab.sql?.trim() && !tab.savedSql?.trim() && /^查询 1$/.test(tab.title);
}
export const useWorkspaceStore = create<State>((set, get) => ({
theme: "dark",
view: "workbench",
@@ -420,6 +440,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
activeConnectionId: null,
tree: [],
treeLoading: false,
treeEpoch: 0,
tabs: [defaultTab()],
activeTabId: "",
result: null,
@@ -463,7 +484,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
viewDataRequest: null,
set: (key, value) => {
set({ [key]: value } as Partial<State>);
set((state) => key === "activeConnectionId" && value !== state.activeConnectionId
? ({ [key]: value, sidebarFilter: "" } as Partial<State>)
: ({ [key]: value } as Partial<State>));
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
get().persistPrefs();
}
@@ -473,6 +496,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
}
},
requestTreeRefresh: () => set((st) => ({ treeEpoch: (st.treeEpoch || 0) + 1 })),
notify: (input) => {
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
const kind = input.kind ?? parsed?.kind ?? "info";
@@ -523,8 +548,10 @@ export const useWorkspaceStore = create<State>((set, get) => ({
}));
const prefs = loadPrefs();
const savedTabs = loadTabs();
const tabs = savedTabs?.tabs ?? get().tabs;
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
const loadedTabs = savedTabs?.tabs ?? get().tabs;
const tabs = loadedTabs.length > 1 ? loadedTabs.filter(t => !isEmptyStartupTab(t)) : loadedTabs;
const activeTabId = tabs.some(t => t.id === savedTabs?.activeTabId)
? savedTabs!.activeTabId : tabs[0]?.id || "";
set({
connections: list,
connGroups: loadGroups(),
@@ -542,9 +569,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
password: encodeSecret(c.password || ""),
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
sentinelPassword: c.sentinelPassword ? encodeSecret(c.sentinelPassword) : c.sentinelPassword,
}));
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
set({ connections: encoded });
// 双写 ~/.opsxdb/connections.json(经本机 Gin),供 opsxdb-mcp 读取。 // Logan.Li
void syncConnectionsToDisk(encoded);
},
upsertConnection: (c) => {
@@ -577,7 +607,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
dropSchemaCache(id);
const sessions = { ...get().sessions };
delete sessions[id];
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
if (get().activeConnectionId === id) set({ activeConnectionId: null, sidebarFilter: "", tree: [], sessions });
else set({ sessions });
},
@@ -721,8 +751,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
});
},
newQueryTab: () => {
const n = get().tabs.length + 1;
newQueryTab: (database) => {
const current = get();
const existingTabs = current.tabs.filter(t => !isEmptyStartupTab(t));
const n = existingTabs.length + 1;
const conn = get().connections.find((c) => c.id === get().activeConnectionId);
const requestedDatabase = database ?? conn?.database;
const tab: QueryTab = {
id: crypto.randomUUID(),
title: `查询 ${n}`,
@@ -730,8 +764,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
savedSql: "",
dirty: false,
connectionId: get().activeConnectionId ?? undefined,
database: conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(requestedDatabase, conn) : requestedDatabase,
};
const tabs = [...get().tabs, tab];
const tabs = [...existingTabs, tab];
set({ tabs, activeTabId: tab.id });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
},
@@ -743,13 +778,42 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
setTabDatabase: (database) => {
setTabDatabase: (database, tabId) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
const id = tabId ?? s.activeTabId;
const target = s.tabs.find(t => t.id === id);
if (!target) return;
const conn = s.connections.find((c) => c.id === target.connectionId);
const nextDatabase = conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(database, conn) : database;
const tabs = s.tabs.map((t) => (t.id === id ? { ...t, database: nextDatabase } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
selectConnection: (id) => {
if (!get().connections.some(c => c.id === id)) return;
get().set("activeConnectionId", id);
const s = get();
const conn = s.connections.find(c => c.id === id)!;
const normalizeTab = (tabId: string) => {
if (conn.driver.toLowerCase() !== "redis") return;
const current = get();
const tabs = current.tabs.map(t => t.id === tabId ? { ...t, database: normalizeRedisDatabase(t.database ?? conn.database, conn) } : t);
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: current.activeTabId }));
};
if (s.tabs.find(t => t.id === s.activeTabId)?.connectionId === id) {
normalizeTab(s.activeTabId);
return;
}
const existing = s.tabs.find(t => t.connectionId === id);
if (existing) {
get().set("activeTabId", existing.id);
normalizeTab(existing.id);
}
else get().newQueryTab();
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
@@ -805,6 +869,15 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
},
updateTabSqlIfUnchanged: (id, originalSql, connectionId, database, sql) => {
const current = get().tabs.find(t => t.id === id);
if (!current || current.sql !== originalSql || current.connectionId !== connectionId || current.database !== database) return false;
const tabs = get().tabs.map(t => t.id === id ? { ...t, sql, dirty: sql !== (t.savedSql ?? "") } : t);
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: get().activeTabId }));
return true;
},
pushAi: (role, content) =>
set((s) => ({
aiMessages: [