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);
}