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

+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: [