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:
1 parent
5c17d32647
commit
0b68c8344d
19 files changed
+1989
-79
No files matched your search
+84
-6
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user