taurusdb-mcp 0.5.0-rc.3 → 0.5.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -13
- package/dist/security/browser-operator-session.d.ts +14 -0
- package/dist/security/browser-operator-session.js +57 -0
- package/dist/security/local-credential-login.d.ts +3 -0
- package/dist/security/local-credential-login.js +8 -0
- package/dist/security/local-recovery-approval.d.ts +62 -0
- package/dist/security/local-recovery-approval.js +258 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +12 -1
- package/dist/tools/registry.d.ts +1 -0
- package/dist/tools/registry.js +9 -1
- package/dist/tools/taurus/cloud-context.js +21 -12
- package/dist/tools/taurus/recycle-bin.d.ts +2 -0
- package/dist/tools/taurus/recycle-bin.js +262 -2
- package/dist/tools/taurus/sql-login.d.ts +1 -0
- package/dist/tools/taurus/sql-login.js +99 -95
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ TaurusDB MCP server for MCP clients such as:
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install taurusdb-mcp
|
|
13
|
+
npm install taurusdb-mcp@latest
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Run directly with:
|
|
@@ -46,9 +46,6 @@ claude mcp add huaweicloud-taurusdb \
|
|
|
46
46
|
-e TAURUSDB_CLOUD_REGION=<your-region> \
|
|
47
47
|
-e TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
|
|
48
48
|
-e TAURUSDB_ENABLE_DYNAMIC_TARGETS=true \
|
|
49
|
-
-e TAURUSDB_SQL_DATABASE=<your-database> \
|
|
50
|
-
-e TAURUSDB_SQL_USER=<your-readonly-user> \
|
|
51
|
-
-e TAURUSDB_SQL_PASSWORD='hw-kms-file:~/.taurusdb-mcp/password.ciphertext' \
|
|
52
49
|
-- npx -y taurusdb-mcp
|
|
53
50
|
```
|
|
54
51
|
|
|
@@ -59,12 +56,19 @@ codex mcp add huaweicloud-taurusdb \
|
|
|
59
56
|
--env TAURUSDB_CLOUD_REGION=<your-region> \
|
|
60
57
|
--env TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
|
|
61
58
|
--env TAURUSDB_ENABLE_DYNAMIC_TARGETS=true \
|
|
62
|
-
--env TAURUSDB_SQL_DATABASE=<your-database> \
|
|
63
|
-
--env TAURUSDB_SQL_USER=<your-readonly-user> \
|
|
64
|
-
--env TAURUSDB_SQL_PASSWORD='hw-kms-file:~/.taurusdb-mcp/password.ciphertext' \
|
|
65
59
|
-- npx -y taurusdb-mcp
|
|
66
60
|
```
|
|
67
61
|
|
|
62
|
+
Interactive connection flow:
|
|
63
|
+
|
|
64
|
+
1. Call `list_cloud_taurus_instances`.
|
|
65
|
+
2. Call `select_cloud_taurus_instance`.
|
|
66
|
+
3. Open the returned `login_url` in a browser and enter the database credentials.
|
|
67
|
+
4. Continue with `list_databases`, `set_default_database`, and readonly tools.
|
|
68
|
+
|
|
69
|
+
The browser submits credentials directly to the loopback MCP process. They are not
|
|
70
|
+
sent through Agent-visible tool arguments or persisted by MCP.
|
|
71
|
+
|
|
68
72
|
Initialize local MCP client config:
|
|
69
73
|
|
|
70
74
|
```bash
|
|
@@ -84,18 +88,20 @@ npx taurusdb-mcp credentials check
|
|
|
84
88
|
|
|
85
89
|
- Requires Node.js `>= 20`
|
|
86
90
|
- Depends on `taurusdb-core`
|
|
87
|
-
-
|
|
88
|
-
-
|
|
91
|
+
- Interactive clients do not need database usernames or passwords in MCP configuration
|
|
92
|
+
- Unattended static deployments may use `env:`, `file:`, `hw-csms:`, `hw-kms:`, or `hw-kms-file:` password references
|
|
89
93
|
- macOS Keychain, Linux Secret Service, or Windows Credential Manager cloud identity is enabled with `TAURUSDB_CLOUD_KEYCHAIN_SERVICE`
|
|
90
94
|
- SQL TLS with certificate verification is required by default
|
|
91
|
-
-
|
|
95
|
+
- General database mutation tools do not exist; the Agent-facing operating plane remains read-only regardless of database account privileges
|
|
96
|
+
- Recycle-bin request/status tools are visible by default: `prepare_recycle_bin_restore` performs readonly preflight and the loopback-only operator page requires the Agent-invisible HttpOnly browser session established by database login before executing one target-bound native restore with the active in-memory SQL session; no secret file, direct restore tool, or Agent-held execution token is required
|
|
92
97
|
- `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
|
|
93
|
-
-
|
|
98
|
+
- Selecting a cloud instance returns a local SQL login link immediately; login validates the connection before binding credentials, allows at most three attempts per five-minute link, and never sends the password through Agent-visible tool arguments
|
|
94
99
|
- Session SQL credentials expire after 30 idle minutes and eight absolute hours; administrators may shorten these limits with `TAURUSDB_SQL_CREDENTIAL_IDLE_TTL_MINUTES` and `TAURUSDB_SQL_CREDENTIAL_MAX_TTL_MINUTES`
|
|
95
100
|
- MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
|
|
96
101
|
configurable size rotation for collection into centralized immutable storage
|
|
97
102
|
- Run one stdio process per customer/client trust boundary; this package is not
|
|
98
103
|
a shared multi-tenant HTTP service
|
|
99
104
|
|
|
100
|
-
Customers execute reviewed `advised_sql` through their own controlled
|
|
101
|
-
|
|
105
|
+
Customers execute reviewed general-purpose `advised_sql` through their own controlled
|
|
106
|
+
change process. The only MCP database-state exception is the target-bound,
|
|
107
|
+
same-browser-confirmed recycle-bin recovery described above.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class BrowserOperatorSessionStore {
|
|
2
|
+
private readonly now;
|
|
3
|
+
private readonly cookieName;
|
|
4
|
+
private readonly sessions;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
});
|
|
8
|
+
issue(datasource: string, ttlMs?: number): string;
|
|
9
|
+
authorizes(cookieHeader: string | undefined, datasource: string): boolean;
|
|
10
|
+
clear(): void;
|
|
11
|
+
revokeDatasource(datasource: string): void;
|
|
12
|
+
private readCookie;
|
|
13
|
+
private expire;
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const DEFAULT_TTL_MS = 8 * 60 * 60 * 1000;
|
|
3
|
+
export class BrowserOperatorSessionStore {
|
|
4
|
+
now;
|
|
5
|
+
cookieName;
|
|
6
|
+
sessions = new Map();
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.now = options.now ?? Date.now;
|
|
9
|
+
this.cookieName = `taurusdb_mcp_operator_${randomBytes(8).toString("hex")}`;
|
|
10
|
+
}
|
|
11
|
+
issue(datasource, ttlMs = DEFAULT_TTL_MS) {
|
|
12
|
+
this.expire();
|
|
13
|
+
const token = randomBytes(32).toString("base64url");
|
|
14
|
+
const boundedTtlMs = Math.max(1, Math.min(ttlMs, DEFAULT_TTL_MS));
|
|
15
|
+
this.sessions.set(token, {
|
|
16
|
+
datasource,
|
|
17
|
+
expiresAtMs: this.now() + boundedTtlMs,
|
|
18
|
+
});
|
|
19
|
+
return `${this.cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil(boundedTtlMs / 1000)}`;
|
|
20
|
+
}
|
|
21
|
+
authorizes(cookieHeader, datasource) {
|
|
22
|
+
this.expire();
|
|
23
|
+
const token = this.readCookie(cookieHeader);
|
|
24
|
+
const session = token ? this.sessions.get(token) : undefined;
|
|
25
|
+
return session?.datasource === datasource;
|
|
26
|
+
}
|
|
27
|
+
clear() {
|
|
28
|
+
this.sessions.clear();
|
|
29
|
+
}
|
|
30
|
+
revokeDatasource(datasource) {
|
|
31
|
+
for (const [token, session] of this.sessions) {
|
|
32
|
+
if (session.datasource === datasource)
|
|
33
|
+
this.sessions.delete(token);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
readCookie(cookieHeader) {
|
|
37
|
+
if (!cookieHeader)
|
|
38
|
+
return undefined;
|
|
39
|
+
for (const part of cookieHeader.split(";")) {
|
|
40
|
+
const separator = part.indexOf("=");
|
|
41
|
+
if (separator < 0)
|
|
42
|
+
continue;
|
|
43
|
+
const name = part.slice(0, separator).trim();
|
|
44
|
+
if (name === this.cookieName) {
|
|
45
|
+
return part.slice(separator + 1).trim() || undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
expire() {
|
|
51
|
+
const now = this.now();
|
|
52
|
+
for (const [token, session] of this.sessions) {
|
|
53
|
+
if (session.expiresAtMs <= now)
|
|
54
|
+
this.sessions.delete(token);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BrowserOperatorSessionStore } from "./browser-operator-session.js";
|
|
1
2
|
export type SqlLoginCredentials = {
|
|
2
3
|
datasource: string;
|
|
3
4
|
username: string;
|
|
@@ -33,12 +34,14 @@ export type LocalCredentialLoginServiceOptions = {
|
|
|
33
34
|
tokenTtlMs?: number;
|
|
34
35
|
maxAttempts?: number;
|
|
35
36
|
failureDelayMs?: number;
|
|
37
|
+
operatorSessions?: BrowserOperatorSessionStore;
|
|
36
38
|
};
|
|
37
39
|
export declare class LocalCredentialLoginService implements CredentialLoginService {
|
|
38
40
|
private readonly now;
|
|
39
41
|
private readonly tokenTtlMs;
|
|
40
42
|
private readonly maxAttempts;
|
|
41
43
|
private readonly failureDelayMs;
|
|
44
|
+
private readonly operatorSessions?;
|
|
42
45
|
private readonly pending;
|
|
43
46
|
private server;
|
|
44
47
|
private port;
|
|
@@ -266,6 +266,7 @@ export class LocalCredentialLoginService {
|
|
|
266
266
|
tokenTtlMs;
|
|
267
267
|
maxAttempts;
|
|
268
268
|
failureDelayMs;
|
|
269
|
+
operatorSessions;
|
|
269
270
|
pending = new Map();
|
|
270
271
|
server;
|
|
271
272
|
port;
|
|
@@ -274,6 +275,7 @@ export class LocalCredentialLoginService {
|
|
|
274
275
|
this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
|
275
276
|
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
276
277
|
this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
|
|
278
|
+
this.operatorSessions = options.operatorSessions;
|
|
277
279
|
}
|
|
278
280
|
async issueSqlLogin(request) {
|
|
279
281
|
await this.ensureStarted();
|
|
@@ -287,6 +289,7 @@ export class LocalCredentialLoginService {
|
|
|
287
289
|
}
|
|
288
290
|
async close() {
|
|
289
291
|
this.pending.clear();
|
|
292
|
+
this.operatorSessions?.clear();
|
|
290
293
|
const server = this.server;
|
|
291
294
|
this.server = undefined;
|
|
292
295
|
this.port = undefined;
|
|
@@ -402,6 +405,11 @@ export class LocalCredentialLoginService {
|
|
|
402
405
|
return;
|
|
403
406
|
}
|
|
404
407
|
this.pending.delete(token);
|
|
408
|
+
const maxTtlMinutes = Math.min(pending.target?.credentialIdleTtlMinutes ?? 30, pending.target?.credentialMaxTtlMinutes ?? 480);
|
|
409
|
+
const operatorCookie = this.operatorSessions?.issue(pending.datasource, maxTtlMinutes * 60_000);
|
|
410
|
+
if (operatorCookie) {
|
|
411
|
+
res.setHeader("set-cookie", operatorCookie);
|
|
412
|
+
}
|
|
405
413
|
respond(res, 200, page({ locale, state: "success", target: pending.target }));
|
|
406
414
|
}
|
|
407
415
|
catch {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { BrowserOperatorSessionStore } from "./browser-operator-session.js";
|
|
2
|
+
export type RecoveryTarget = {
|
|
3
|
+
datasource: string;
|
|
4
|
+
recycleTable: string;
|
|
5
|
+
destinationDatabase: string;
|
|
6
|
+
destinationTable: string;
|
|
7
|
+
};
|
|
8
|
+
export type RecoveryExecutionResult = {
|
|
9
|
+
queryId: string;
|
|
10
|
+
affectedRows: number;
|
|
11
|
+
verified: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type RecoveryRequestStatus = {
|
|
14
|
+
requestId: string;
|
|
15
|
+
status: "pending" | "executing" | "succeeded" | "failed" | "expired";
|
|
16
|
+
target: RecoveryTarget;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
operator?: string;
|
|
20
|
+
completedAt?: string;
|
|
21
|
+
result?: RecoveryExecutionResult;
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
export type RecoveryApprovalRequest = {
|
|
25
|
+
target: RecoveryTarget;
|
|
26
|
+
execute: (operator: string, requestId: string) => Promise<RecoveryExecutionResult>;
|
|
27
|
+
};
|
|
28
|
+
export type IssuedRecoveryApproval = {
|
|
29
|
+
requestId: string;
|
|
30
|
+
approvalUrl: string;
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
};
|
|
33
|
+
export interface RecoveryApprovalService {
|
|
34
|
+
issue(request: RecoveryApprovalRequest): Promise<IssuedRecoveryApproval>;
|
|
35
|
+
getStatus(requestId: string): RecoveryRequestStatus | undefined;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export type LocalRecoveryApprovalServiceOptions = {
|
|
39
|
+
operatorSessions: BrowserOperatorSessionStore;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
ttlMs?: number;
|
|
42
|
+
maxPending?: number;
|
|
43
|
+
maxAttempts?: number;
|
|
44
|
+
};
|
|
45
|
+
export declare class LocalRecoveryApprovalService implements RecoveryApprovalService {
|
|
46
|
+
private readonly now;
|
|
47
|
+
private readonly ttlMs;
|
|
48
|
+
private readonly maxPending;
|
|
49
|
+
private readonly maxAttempts;
|
|
50
|
+
private readonly operatorSessions;
|
|
51
|
+
private readonly byToken;
|
|
52
|
+
private readonly byRequestId;
|
|
53
|
+
private server?;
|
|
54
|
+
private port?;
|
|
55
|
+
constructor(options: LocalRecoveryApprovalServiceOptions);
|
|
56
|
+
issue(request: RecoveryApprovalRequest): Promise<IssuedRecoveryApproval>;
|
|
57
|
+
getStatus(requestId: string): RecoveryRequestStatus | undefined;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
private expirePending;
|
|
60
|
+
private ensureStarted;
|
|
61
|
+
private handle;
|
|
62
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, } from "node:http";
|
|
3
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
4
|
+
const DEFAULT_MAX_PENDING = 100;
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
6
|
+
const MAX_REQUEST_BODY_BYTES = 8 * 1024;
|
|
7
|
+
function localeFromRequest(req) {
|
|
8
|
+
return /(?:^|,)\s*zh(?:-|_|;|,|$)/i.test(req.headers["accept-language"] ?? "")
|
|
9
|
+
? "zh-CN"
|
|
10
|
+
: "en";
|
|
11
|
+
}
|
|
12
|
+
function escapeHtml(value) {
|
|
13
|
+
return value
|
|
14
|
+
.replaceAll("&", "&")
|
|
15
|
+
.replaceAll("<", "<")
|
|
16
|
+
.replaceAll(">", ">")
|
|
17
|
+
.replaceAll('"', """)
|
|
18
|
+
.replaceAll("'", "'");
|
|
19
|
+
}
|
|
20
|
+
function confirmationText(target) {
|
|
21
|
+
return `RESTORE ${target.destinationDatabase}.${target.destinationTable}`;
|
|
22
|
+
}
|
|
23
|
+
function page(input) {
|
|
24
|
+
const zh = input.locale === "zh-CN";
|
|
25
|
+
const target = input.target;
|
|
26
|
+
const title = input.state === "confirm"
|
|
27
|
+
? (zh ? "确认回收站恢复" : "Confirm recycle-bin recovery")
|
|
28
|
+
: input.state === "success"
|
|
29
|
+
? (zh ? "恢复已完成" : "Recovery completed")
|
|
30
|
+
: input.state === "failed"
|
|
31
|
+
? (zh ? "恢复失败" : "Recovery failed")
|
|
32
|
+
: input.state === "expired"
|
|
33
|
+
? (zh ? "恢复申请已失效" : "Recovery request expired")
|
|
34
|
+
: (zh ? "无法处理申请" : "Request could not be processed");
|
|
35
|
+
const message = input.message ?? (input.state === "confirm"
|
|
36
|
+
? (zh
|
|
37
|
+
? "请核对目标并输入操作人身份和确认短语。确认后将立即执行,无法由 Agent 撤销。"
|
|
38
|
+
: "Review the target, then enter the operator identity and confirmation phrase. Approval executes immediately and cannot be revoked by the Agent.")
|
|
39
|
+
: input.state === "success"
|
|
40
|
+
? (zh ? "目标表已经恢复并通过只读验证,可以返回 MCP 会话。" : "The table was restored and verified. Return to the MCP session.")
|
|
41
|
+
: input.state === "expired"
|
|
42
|
+
? (zh ? "请返回 MCP 会话重新创建恢复申请。" : "Return to the MCP session and create a new recovery request.")
|
|
43
|
+
: (zh ? "请返回 MCP 会话查看恢复状态。" : "Return to the MCP session to inspect recovery status."));
|
|
44
|
+
const phrase = target ? confirmationText(target) : "";
|
|
45
|
+
const body = input.state === "confirm" && target
|
|
46
|
+
? `<form method="post" autocomplete="off">
|
|
47
|
+
<label>${zh ? "操作人身份" : "Operator identity"}</label>
|
|
48
|
+
<input name="operator" maxlength="256" required placeholder="name@example.com">
|
|
49
|
+
<label>${zh ? "确认短语" : "Confirmation phrase"}</label>
|
|
50
|
+
<code>${escapeHtml(phrase)}</code>
|
|
51
|
+
<input name="confirmation" maxlength="512" required autocomplete="off">
|
|
52
|
+
<button type="submit">${zh ? "确认并立即恢复" : "Approve and restore now"}</button>
|
|
53
|
+
</form>`
|
|
54
|
+
: `<section class="result"><span>${input.state === "success" ? "✓" : "!"}</span><p>${escapeHtml(message)}</p></section>`;
|
|
55
|
+
return `<!doctype html><html lang="${input.locale}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · TaurusDB MCP</title><style>
|
|
56
|
+
:root{color-scheme:light;--ink:#172033;--muted:#647184;--bg:#f3f6fa;--line:#dce3eb;--danger:#b42318;--teal:#087f73}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.panel{width:min(720px,calc(100% - 32px));background:#fff;border:1px solid var(--line);border-radius:22px;padding:40px;box-shadow:0 22px 60px rgba(23,32,51,.12)}.brand{font-weight:760;color:var(--teal)}h1{font-size:32px;margin:18px 0 10px}p{color:var(--muted);line-height:1.65}.warning{padding:14px 16px;border-radius:12px;background:#fff0ee;color:var(--danger);font-weight:650}.grid{display:grid;grid-template-columns:160px 1fr;gap:10px;margin:24px 0;padding:18px;background:#f8fafc;border-radius:14px}.grid b{color:var(--muted)}label{display:block;margin:18px 0 7px;font-weight:700}input{width:100%;padding:13px;border:1px solid #bdc8d5;border-radius:10px;font:inherit}code{display:block;padding:12px;border-radius:9px;background:#eef3f7;font-weight:700}button{width:100%;margin-top:24px;padding:14px;border:0;border-radius:10px;background:var(--danger);color:#fff;font:inherit;font-weight:760;cursor:pointer}.result span{display:inline-grid;place-items:center;width:44px;height:44px;border-radius:50%;background:#e7f5f2;color:var(--teal);font-size:24px;font-weight:800}@media(max-width:600px){.panel{padding:26px}.grid{grid-template-columns:1fr}h1{font-size:27px}}</style></head><body><main class="panel"><div class="brand">TaurusDB MCP · Controlled Recovery</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p>${target ? `<div class="warning">${zh ? "此操作会改变数据库状态,是只读 Harness 的唯一受控例外。" : "This changes database state and is the only controlled exception to the read-only Harness."}</div><div class="grid"><b>Datasource</b><span>${escapeHtml(target.datasource)}</span><b>${zh ? "回收站对象" : "Recycle object"}</b><span>${escapeHtml(target.recycleTable)}</span><b>${zh ? "恢复目标" : "Destination"}</b><span>${escapeHtml(`${target.destinationDatabase}.${target.destinationTable}`)}</span></div>` : ""}${body}</main></body></html>`;
|
|
57
|
+
}
|
|
58
|
+
function respond(res, status, body) {
|
|
59
|
+
res.writeHead(status, {
|
|
60
|
+
"cache-control": "no-store",
|
|
61
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
62
|
+
"content-type": "text/html; charset=utf-8",
|
|
63
|
+
"cross-origin-opener-policy": "same-origin",
|
|
64
|
+
"cross-origin-resource-policy": "same-origin",
|
|
65
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
|
66
|
+
"referrer-policy": "no-referrer",
|
|
67
|
+
"x-content-type-options": "nosniff",
|
|
68
|
+
"x-frame-options": "DENY",
|
|
69
|
+
});
|
|
70
|
+
res.end(body);
|
|
71
|
+
}
|
|
72
|
+
async function readBody(req) {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
for await (const chunk of req) {
|
|
76
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
77
|
+
size += buffer.length;
|
|
78
|
+
if (size > MAX_REQUEST_BODY_BYTES)
|
|
79
|
+
throw new Error("Request body too large.");
|
|
80
|
+
chunks.push(buffer);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
83
|
+
}
|
|
84
|
+
export class LocalRecoveryApprovalService {
|
|
85
|
+
now;
|
|
86
|
+
ttlMs;
|
|
87
|
+
maxPending;
|
|
88
|
+
maxAttempts;
|
|
89
|
+
operatorSessions;
|
|
90
|
+
byToken = new Map();
|
|
91
|
+
byRequestId = new Map();
|
|
92
|
+
server;
|
|
93
|
+
port;
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.operatorSessions = options.operatorSessions;
|
|
96
|
+
this.now = options.now ?? Date.now;
|
|
97
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
98
|
+
this.maxPending = options.maxPending ?? DEFAULT_MAX_PENDING;
|
|
99
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
100
|
+
}
|
|
101
|
+
async issue(request) {
|
|
102
|
+
this.expirePending();
|
|
103
|
+
if (this.byToken.size >= this.maxPending)
|
|
104
|
+
throw new Error("Too many pending recovery requests.");
|
|
105
|
+
await this.ensureStarted();
|
|
106
|
+
const token = randomBytes(32).toString("base64url");
|
|
107
|
+
const requestId = `rrq_${randomBytes(16).toString("base64url")}`;
|
|
108
|
+
const createdAtMs = this.now();
|
|
109
|
+
const expiresAtMs = createdAtMs + this.ttlMs;
|
|
110
|
+
const status = {
|
|
111
|
+
requestId,
|
|
112
|
+
status: "pending",
|
|
113
|
+
target: { ...request.target },
|
|
114
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
115
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
116
|
+
};
|
|
117
|
+
const pending = { token, request, status, failedAttempts: 0 };
|
|
118
|
+
this.byToken.set(token, pending);
|
|
119
|
+
this.byRequestId.set(requestId, status);
|
|
120
|
+
return {
|
|
121
|
+
requestId,
|
|
122
|
+
approvalUrl: `http://127.0.0.1:${this.port}/recovery/${token}`,
|
|
123
|
+
expiresAt: status.expiresAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
getStatus(requestId) {
|
|
127
|
+
this.expirePending();
|
|
128
|
+
const status = this.byRequestId.get(requestId);
|
|
129
|
+
return status ? structuredClone(status) : undefined;
|
|
130
|
+
}
|
|
131
|
+
async close() {
|
|
132
|
+
this.byToken.clear();
|
|
133
|
+
this.byRequestId.clear();
|
|
134
|
+
const server = this.server;
|
|
135
|
+
this.server = undefined;
|
|
136
|
+
this.port = undefined;
|
|
137
|
+
if (!server)
|
|
138
|
+
return;
|
|
139
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
140
|
+
}
|
|
141
|
+
expirePending() {
|
|
142
|
+
const now = this.now();
|
|
143
|
+
for (const [token, pending] of this.byToken) {
|
|
144
|
+
if (Date.parse(pending.status.expiresAt) <= now) {
|
|
145
|
+
pending.status.status = "expired";
|
|
146
|
+
pending.status.completedAt = new Date(now).toISOString();
|
|
147
|
+
this.byToken.delete(token);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async ensureStarted() {
|
|
152
|
+
if (this.server)
|
|
153
|
+
return;
|
|
154
|
+
const server = createServer((req, res) => void this.handle(req, res));
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
server.once("error", reject);
|
|
157
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
158
|
+
});
|
|
159
|
+
const address = server.address();
|
|
160
|
+
if (!address || typeof address === "string") {
|
|
161
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
162
|
+
throw new Error("Unable to resolve local recovery approval address.");
|
|
163
|
+
}
|
|
164
|
+
this.server = server;
|
|
165
|
+
this.port = address.port;
|
|
166
|
+
}
|
|
167
|
+
async handle(req, res) {
|
|
168
|
+
const locale = localeFromRequest(req);
|
|
169
|
+
try {
|
|
170
|
+
this.expirePending();
|
|
171
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
172
|
+
const token = url.pathname.match(/^\/recovery\/([^/]+)$/)?.[1];
|
|
173
|
+
const pending = token ? this.byToken.get(token) : undefined;
|
|
174
|
+
if (!token || !pending) {
|
|
175
|
+
respond(res, 410, page({ locale, state: "expired" }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const cookieHeader = Array.isArray(req.headers.cookie)
|
|
179
|
+
? req.headers.cookie.join("; ")
|
|
180
|
+
: req.headers.cookie;
|
|
181
|
+
const browserAuthorized = this.operatorSessions.authorizes(cookieHeader, pending.request.target.datasource);
|
|
182
|
+
if (req.method === "GET") {
|
|
183
|
+
respond(res, browserAuthorized ? 200 : 401, browserAuthorized
|
|
184
|
+
? page({ locale, state: "confirm", target: pending.request.target })
|
|
185
|
+
: page({
|
|
186
|
+
locale,
|
|
187
|
+
state: "invalid",
|
|
188
|
+
target: pending.request.target,
|
|
189
|
+
message: locale === "zh-CN"
|
|
190
|
+
? "请先在同一浏览器中完成该数据源的数据库登录,再确认恢复。"
|
|
191
|
+
: "Complete database login for this datasource in the same browser before approving recovery.",
|
|
192
|
+
}));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (req.method !== "POST") {
|
|
196
|
+
respond(res, 405, page({ locale, state: "invalid", target: pending.request.target }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const origin = req.headers.origin;
|
|
200
|
+
if (origin && origin !== `http://${req.headers.host}`) {
|
|
201
|
+
respond(res, 403, page({ locale, state: "invalid", target: pending.request.target }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!browserAuthorized) {
|
|
205
|
+
respond(res, 401, page({
|
|
206
|
+
locale,
|
|
207
|
+
state: "invalid",
|
|
208
|
+
target: pending.request.target,
|
|
209
|
+
message: locale === "zh-CN"
|
|
210
|
+
? "请先在同一浏览器中完成该数据源的数据库登录,再确认恢复。"
|
|
211
|
+
: "Complete database login for this datasource in the same browser before approving recovery.",
|
|
212
|
+
}));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const form = new URLSearchParams(await readBody(req));
|
|
216
|
+
const operator = form.get("operator")?.trim() ?? "";
|
|
217
|
+
const confirmation = form.get("confirmation") ?? "";
|
|
218
|
+
if (!operator || confirmation !== confirmationText(pending.request.target)) {
|
|
219
|
+
pending.failedAttempts += 1;
|
|
220
|
+
if (pending.failedAttempts >= this.maxAttempts) {
|
|
221
|
+
this.byToken.delete(token);
|
|
222
|
+
pending.status.status = "failed";
|
|
223
|
+
pending.status.error = "Recovery approval attempt limit reached. Create a new request.";
|
|
224
|
+
pending.status.completedAt = new Date(this.now()).toISOString();
|
|
225
|
+
respond(res, 429, page({ locale, state: "failed", target: pending.request.target }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
respond(res, 400, page({
|
|
229
|
+
locale,
|
|
230
|
+
state: "confirm",
|
|
231
|
+
target: pending.request.target,
|
|
232
|
+
message: locale === "zh-CN" ? "操作人身份或确认短语不正确,未执行恢复。" : "Operator identity or confirmation phrase is invalid. Recovery was not executed.",
|
|
233
|
+
}));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.byToken.delete(token);
|
|
237
|
+
pending.status.status = "executing";
|
|
238
|
+
pending.status.operator = operator;
|
|
239
|
+
try {
|
|
240
|
+
const result = await pending.request.execute(operator, pending.status.requestId);
|
|
241
|
+
pending.status.status = "succeeded";
|
|
242
|
+
pending.status.result = result;
|
|
243
|
+
pending.status.completedAt = new Date(this.now()).toISOString();
|
|
244
|
+
respond(res, 200, page({ locale, state: "success", target: pending.request.target }));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
pending.status.status = "failed";
|
|
248
|
+
void error;
|
|
249
|
+
pending.status.error = "Recovery failed. Inspect the MCP audit log and database state before retrying.";
|
|
250
|
+
pending.status.completedAt = new Date(this.now()).toISOString();
|
|
251
|
+
respond(res, 500, page({ locale, state: "failed", target: pending.request.target }));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
respond(res, 400, page({ locale, state: "invalid" }));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { TaurusDBEngine, type Config, type AuditWriter, type RuntimeTargetProfil
|
|
|
3
3
|
import { type CredentialLoginService } from "./security/local-credential-login.js";
|
|
4
4
|
import { SessionCoordinator } from "./security/session-coordinator.js";
|
|
5
5
|
import { SessionCredentialManager } from "./security/session-credential-manager.js";
|
|
6
|
+
import { type RecoveryApprovalService } from "./security/local-recovery-approval.js";
|
|
7
|
+
import { BrowserOperatorSessionStore } from "./security/browser-operator-session.js";
|
|
6
8
|
export interface ServerDeps {
|
|
7
9
|
config: Config;
|
|
8
10
|
profileLoader: RuntimeTargetProfileLoader;
|
|
9
11
|
engine: TaurusDBEngine;
|
|
10
12
|
credentialLogin: CredentialLoginService;
|
|
13
|
+
operatorSessions?: BrowserOperatorSessionStore;
|
|
14
|
+
recoveryApproval?: RecoveryApprovalService;
|
|
11
15
|
auditWriter?: AuditWriter;
|
|
12
16
|
sessionCoordinator?: SessionCoordinator;
|
|
13
17
|
credentialSessions?: SessionCredentialManager;
|
package/dist/server.js
CHANGED
|
@@ -7,8 +7,11 @@ import { VERSION } from "./version.js";
|
|
|
7
7
|
import { LocalCredentialLoginService, } from "./security/local-credential-login.js";
|
|
8
8
|
import { SessionCoordinator } from "./security/session-coordinator.js";
|
|
9
9
|
import { SessionCredentialManager } from "./security/session-credential-manager.js";
|
|
10
|
+
import { LocalRecoveryApprovalService, } from "./security/local-recovery-approval.js";
|
|
11
|
+
import { BrowserOperatorSessionStore } from "./security/browser-operator-session.js";
|
|
10
12
|
export async function bootstrapDependencies() {
|
|
11
13
|
const config = getConfig();
|
|
14
|
+
const operatorSessions = new BrowserOperatorSessionStore();
|
|
12
15
|
const profileLoader = new RuntimeOverrideProfileLoader(createSqlProfileLoader({ config }));
|
|
13
16
|
const engine = await TaurusDBEngine.create({ config, profileLoader });
|
|
14
17
|
const auditWriter = await createJsonlAuditWriter({
|
|
@@ -28,7 +31,14 @@ export async function bootstrapDependencies() {
|
|
|
28
31
|
config,
|
|
29
32
|
profileLoader,
|
|
30
33
|
engine,
|
|
31
|
-
credentialLogin: new LocalCredentialLoginService(),
|
|
34
|
+
credentialLogin: new LocalCredentialLoginService({ operatorSessions }),
|
|
35
|
+
operatorSessions,
|
|
36
|
+
recoveryApproval: config.security.recycleBinRestoreEnabled
|
|
37
|
+
? new LocalRecoveryApprovalService({
|
|
38
|
+
operatorSessions,
|
|
39
|
+
ttlMs: config.security.approvalTtlSeconds * 1000,
|
|
40
|
+
})
|
|
41
|
+
: undefined,
|
|
32
42
|
auditWriter,
|
|
33
43
|
sessionCoordinator,
|
|
34
44
|
credentialSessions,
|
|
@@ -45,6 +55,7 @@ export function createServer(deps) {
|
|
|
45
55
|
const cleanup = () => {
|
|
46
56
|
cleanupPromise ??= Promise.all([
|
|
47
57
|
deps.credentialLogin.close(),
|
|
58
|
+
deps.recoveryApproval?.close() ?? Promise.resolve(),
|
|
48
59
|
deps.credentialSessions?.close() ?? Promise.resolve(),
|
|
49
60
|
deps.engine.close(),
|
|
50
61
|
deps.auditWriter?.close() ?? Promise.resolve(),
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -19,4 +19,5 @@ export declare const commonToolDefinitions: ToolDefinition[];
|
|
|
19
19
|
export declare const capabilityToolDefinitions: ToolDefinition[];
|
|
20
20
|
export declare const dynamicTargetToolDefinitions: ToolDefinition[];
|
|
21
21
|
export declare const taurusToolDefinitions: ToolDefinition[];
|
|
22
|
+
export declare const controlledRecoveryToolDefinitions: ToolDefinition[];
|
|
22
23
|
export declare function registerTools(server: McpServer, deps: ToolDeps, config: Config, tools?: ToolDefinition[]): void;
|
package/dist/tools/registry.js
CHANGED
|
@@ -11,7 +11,7 @@ import { listCloudTaurusInstancesTool } from "./taurus/cloud-instances.js";
|
|
|
11
11
|
import { diagnosticToolDefinitions } from "./taurus/diagnostics.js";
|
|
12
12
|
import { explainSqlEnhancedTool } from "./taurus/explain.js";
|
|
13
13
|
import { flashbackQueryTool } from "./taurus/flashback.js";
|
|
14
|
-
import { listRecycleBinTool } from "./taurus/recycle-bin.js";
|
|
14
|
+
import { getRecycleBinRestoreStatusTool, listRecycleBinTool, prepareRecycleBinRestoreTool, } from "./taurus/recycle-bin.js";
|
|
15
15
|
import { ErrorCode, formatError, toMcpToolResult, } from "../utils/formatter.js";
|
|
16
16
|
function formatUnhandledToolError(error, taskId) {
|
|
17
17
|
void error;
|
|
@@ -133,6 +133,7 @@ const SQL_CREDENTIAL_ACTIVITY_TOOLS = new Set([
|
|
|
133
133
|
"explain_sql_enhanced",
|
|
134
134
|
"flashback_query",
|
|
135
135
|
"list_recycle_bin",
|
|
136
|
+
"prepare_recycle_bin_restore",
|
|
136
137
|
...diagnosticToolDefinitions.map((tool) => tool.name),
|
|
137
138
|
]);
|
|
138
139
|
const TOOL_OUTPUT_SHAPE = {
|
|
@@ -220,6 +221,10 @@ export const taurusToolDefinitions = [
|
|
|
220
221
|
flashbackQueryTool,
|
|
221
222
|
listRecycleBinTool,
|
|
222
223
|
];
|
|
224
|
+
export const controlledRecoveryToolDefinitions = [
|
|
225
|
+
prepareRecycleBinRestoreTool,
|
|
226
|
+
getRecycleBinRestoreStatusTool,
|
|
227
|
+
];
|
|
223
228
|
function buildDefaultToolDefinitions(config) {
|
|
224
229
|
return [
|
|
225
230
|
...commonToolDefinitions,
|
|
@@ -227,6 +232,9 @@ function buildDefaultToolDefinitions(config) {
|
|
|
227
232
|
...(config.security.dynamicTargetsEnabled ? dynamicTargetToolDefinitions : []),
|
|
228
233
|
...diagnosticToolDefinitions,
|
|
229
234
|
...taurusToolDefinitions,
|
|
235
|
+
...(config.security.recycleBinRestoreEnabled
|
|
236
|
+
? controlledRecoveryToolDefinitions
|
|
237
|
+
: []),
|
|
230
238
|
];
|
|
231
239
|
}
|
|
232
240
|
export function registerTools(server, deps, config, tools = buildDefaultToolDefinitions(config)) {
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { formatSuccess } from "../../utils/formatter.js";
|
|
4
4
|
import { formatToolError, ToolInputError } from "../error-handling.js";
|
|
5
5
|
import { metadata } from "../common.js";
|
|
6
|
+
import { issueSqlLoginForDatasource } from "./sql-login.js";
|
|
6
7
|
function buildHuaweiCloudEndpoint(service, region, domainSuffix) {
|
|
7
8
|
return `https://${service}.${region}.${domainSuffix}`;
|
|
8
9
|
}
|
|
@@ -109,6 +110,7 @@ export const setCloudRegionTool = {
|
|
|
109
110
|
clearCloudSelection(nextConfig, deps);
|
|
110
111
|
});
|
|
111
112
|
deps.credentialSessions?.clearAll();
|
|
113
|
+
deps.operatorSessions?.clear();
|
|
112
114
|
return formatSuccess({
|
|
113
115
|
region,
|
|
114
116
|
api_endpoint: deps.config.cloud.apiEndpoint,
|
|
@@ -222,6 +224,7 @@ export const clearSqlCredentialsTool = {
|
|
|
222
224
|
deps.profileLoader.clearRuntimeUser(datasource);
|
|
223
225
|
});
|
|
224
226
|
deps.credentialSessions?.clear(datasource);
|
|
227
|
+
deps.operatorSessions?.revokeDatasource(datasource);
|
|
225
228
|
return formatSuccess({
|
|
226
229
|
datasource,
|
|
227
230
|
}, {
|
|
@@ -320,6 +323,12 @@ export const selectCloudTaurusInstanceTool = {
|
|
|
320
323
|
const boundDatasource = await resolveBindingDatasource(deps, typeof input.datasource === "string" ? input.datasource : undefined);
|
|
321
324
|
const selectedHost = selectInstanceAddress(matched);
|
|
322
325
|
const selectedPort = normalizePort(matched.port);
|
|
326
|
+
if (!boundDatasource) {
|
|
327
|
+
throw new ToolInputError("No datasource template is available for SQL login. Configure TAURUSDB_DEFAULT_DATASOURCE or pass datasource explicitly.");
|
|
328
|
+
}
|
|
329
|
+
if (!selectedHost) {
|
|
330
|
+
throw new ToolInputError(`Cloud instance ${matched.id} does not expose a database address that can be bound for SQL login.`);
|
|
331
|
+
}
|
|
323
332
|
await updateSessionState(deps, (nextConfig) => {
|
|
324
333
|
nextConfig.cloud.projectId = projectId;
|
|
325
334
|
nextConfig.cloud.instanceId = matched.id;
|
|
@@ -332,18 +341,16 @@ export const selectCloudTaurusInstanceTool = {
|
|
|
332
341
|
nextConfig.metricsSource.ces.projectId = projectId;
|
|
333
342
|
nextConfig.metricsSource.ces.instanceId = matched.id;
|
|
334
343
|
nextConfig.metricsSource.ces.nodeId = matched.primaryNodeId;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
});
|
|
342
|
-
}
|
|
344
|
+
deps.profileLoader.setRuntimeTarget(boundDatasource, {
|
|
345
|
+
host: selectedHost,
|
|
346
|
+
port: selectedPort,
|
|
347
|
+
instanceId: matched.id,
|
|
348
|
+
nodeId: matched.primaryNodeId,
|
|
349
|
+
});
|
|
343
350
|
});
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
deps.credentialSessions?.clear(boundDatasource);
|
|
352
|
+
deps.operatorSessions?.revokeDatasource(boundDatasource);
|
|
353
|
+
const login = await issueSqlLoginForDatasource(deps, boundDatasource);
|
|
347
354
|
return formatSuccess({
|
|
348
355
|
project_id: projectId,
|
|
349
356
|
instance_id: matched.id,
|
|
@@ -356,8 +363,10 @@ export const selectCloudTaurusInstanceTool = {
|
|
|
356
363
|
bound_datasource: boundDatasource,
|
|
357
364
|
bound_host: selectedHost,
|
|
358
365
|
bound_port: selectedPort,
|
|
366
|
+
login_url: login.loginUrl,
|
|
367
|
+
login_expires_at: login.expiresAt,
|
|
359
368
|
}, {
|
|
360
|
-
summary: `Selected cloud instance ${matched.name} (${matched.id}).`,
|
|
369
|
+
summary: `Selected cloud instance ${matched.name} (${matched.id}) and created a local SQL login link.`,
|
|
361
370
|
metadata: metadata(context.taskId),
|
|
362
371
|
});
|
|
363
372
|
}
|
|
@@ -1,6 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildRestoreRecycleBinTableSql, normalizeSql, sqlHash, } from "taurusdb-core";
|
|
1
3
|
import { formatSuccess } from "../../utils/formatter.js";
|
|
2
|
-
import { formatToolError } from "../error-handling.js";
|
|
3
|
-
import { contextInputShape, metadata, resolveContext, summarizeRows, toPublicQueryResult, } from "../common.js";
|
|
4
|
+
import { formatToolError, ToolInputError } from "../error-handling.js";
|
|
5
|
+
import { asOptionalPositiveInteger, asOptionalString, asRequiredString, contextInputShape, metadata, resolveContext, summarizeRows, toPublicQueryResult, } from "../common.js";
|
|
6
|
+
function recycleObjectExists(result, recycleTable) {
|
|
7
|
+
if (!result || typeof result !== "object" || !("rows" in result))
|
|
8
|
+
return false;
|
|
9
|
+
const rows = result.rows;
|
|
10
|
+
return Array.isArray(rows) && rows.some((row) => Array.isArray(row) && row.some((value) => String(value) === recycleTable));
|
|
11
|
+
}
|
|
12
|
+
function sameRecoveryTarget(prepared, current) {
|
|
13
|
+
return prepared.datasource === current.datasource &&
|
|
14
|
+
prepared.host === current.host &&
|
|
15
|
+
prepared.port === current.port &&
|
|
16
|
+
prepared.projectId === current.projectId &&
|
|
17
|
+
prepared.instanceId === current.instanceId &&
|
|
18
|
+
prepared.nodeId === current.nodeId;
|
|
19
|
+
}
|
|
20
|
+
async function writeRecoveryAudit(deps, input) {
|
|
21
|
+
if (!deps.auditWriter) {
|
|
22
|
+
throw new Error("Controlled recovery requires a durable audit writer.");
|
|
23
|
+
}
|
|
24
|
+
const profile = await deps.profileLoader.get(input.datasource);
|
|
25
|
+
const target = deps.profileLoader.getRuntimeTarget(input.datasource);
|
|
26
|
+
await deps.auditWriter.write({
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
task_id: input.requestId,
|
|
29
|
+
tool: input.tool,
|
|
30
|
+
actor: input.actor,
|
|
31
|
+
datasource: input.datasource,
|
|
32
|
+
database: input.database,
|
|
33
|
+
host: profile?.host,
|
|
34
|
+
port: profile?.port,
|
|
35
|
+
project_id: deps.config.cloud.projectId,
|
|
36
|
+
instance_id: target?.instanceId ?? profile?.instanceId ?? deps.config.cloud.instanceId,
|
|
37
|
+
node_id: target?.nodeId ?? profile?.nodeId ?? deps.config.cloud.nodeId,
|
|
38
|
+
sql_hash: sqlHash(normalizeSql(input.sql)),
|
|
39
|
+
raw_sql: deps.config.audit.includeRawSql ? input.sql : undefined,
|
|
40
|
+
decision: input.outcome === "success" ? "allowed" : "failed",
|
|
41
|
+
outcome: input.outcome,
|
|
42
|
+
error_code: input.errorCode,
|
|
43
|
+
duration_ms: input.durationMs,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
4
46
|
export const listRecycleBinTool = {
|
|
5
47
|
name: "list_recycle_bin",
|
|
6
48
|
description: "List TaurusDB recycle bin tables. This is readonly and is intended for recovery triage after accidental DROP TABLE.",
|
|
@@ -33,3 +75,221 @@ export const listRecycleBinTool = {
|
|
|
33
75
|
}
|
|
34
76
|
},
|
|
35
77
|
};
|
|
78
|
+
export const prepareRecycleBinRestoreTool = {
|
|
79
|
+
name: "prepare_recycle_bin_restore",
|
|
80
|
+
description: "Prepare a short-lived local operator approval for one TaurusDB recycle-bin table. This tool performs readonly preflight only; the Agent cannot execute the restore.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
datasource: contextInputShape.datasource,
|
|
83
|
+
recycle_table: z.string().trim().min(1),
|
|
84
|
+
destination_database: z.string().trim().min(1),
|
|
85
|
+
destination_table: z.string().trim().min(1),
|
|
86
|
+
timeout_ms: contextInputShape.timeout_ms,
|
|
87
|
+
},
|
|
88
|
+
async handler(input, deps, context) {
|
|
89
|
+
try {
|
|
90
|
+
if (!deps.config.security.recycleBinRestoreEnabled) {
|
|
91
|
+
throw new ToolInputError("Controlled recycle-bin recovery is not enabled by the administrator.");
|
|
92
|
+
}
|
|
93
|
+
if (!deps.recoveryApproval) {
|
|
94
|
+
throw new ToolInputError("Controlled recycle-bin recovery approval is unavailable.");
|
|
95
|
+
}
|
|
96
|
+
if (!deps.auditWriter) {
|
|
97
|
+
throw new ToolInputError("Controlled recycle-bin recovery requires durable audit persistence.");
|
|
98
|
+
}
|
|
99
|
+
const recycleTable = asRequiredString(input.recycle_table, "recycle_table");
|
|
100
|
+
const destinationDatabase = asRequiredString(input.destination_database, "destination_database");
|
|
101
|
+
const destinationTable = asRequiredString(input.destination_table, "destination_table");
|
|
102
|
+
const timeoutMs = asOptionalPositiveInteger(input.timeout_ms, "timeout_ms");
|
|
103
|
+
const datasource = asOptionalString(input.datasource, "datasource");
|
|
104
|
+
const ctx = await deps.engine.resolveContext({
|
|
105
|
+
datasource,
|
|
106
|
+
database: destinationDatabase,
|
|
107
|
+
timeout_ms: timeoutMs,
|
|
108
|
+
readonly: true,
|
|
109
|
+
}, context.taskId);
|
|
110
|
+
const profile = await deps.profileLoader.get(ctx.datasource);
|
|
111
|
+
if (!profile?.user) {
|
|
112
|
+
throw new ToolInputError(`Datasource "${ctx.datasource}" has no active SQL credential session. Select the TaurusDB instance and open its returned local login URL first.`);
|
|
113
|
+
}
|
|
114
|
+
const [recycleBin, destinationTables] = await Promise.all([
|
|
115
|
+
deps.engine.listRecycleBin(ctx),
|
|
116
|
+
deps.engine.listTables(ctx, destinationDatabase),
|
|
117
|
+
]);
|
|
118
|
+
if (!recycleObjectExists(recycleBin, recycleTable)) {
|
|
119
|
+
throw new ToolInputError(`Recycle-bin object "${recycleTable}" was not found during readonly preflight.`);
|
|
120
|
+
}
|
|
121
|
+
if (destinationTables.some((table) => table.name.toLowerCase() === destinationTable.toLowerCase())) {
|
|
122
|
+
throw new ToolInputError(`Destination table "${destinationDatabase}.${destinationTable}" already exists. Controlled recovery never overwrites an existing table.`);
|
|
123
|
+
}
|
|
124
|
+
const restoreInput = {
|
|
125
|
+
recycleTable,
|
|
126
|
+
method: "native_restore",
|
|
127
|
+
destinationDatabase,
|
|
128
|
+
destinationTable,
|
|
129
|
+
};
|
|
130
|
+
const restoreSql = buildRestoreRecycleBinTableSql(restoreInput);
|
|
131
|
+
const issued = await deps.recoveryApproval.issue({
|
|
132
|
+
target: {
|
|
133
|
+
datasource: ctx.datasource,
|
|
134
|
+
recycleTable,
|
|
135
|
+
destinationDatabase,
|
|
136
|
+
destinationTable,
|
|
137
|
+
},
|
|
138
|
+
execute: async (operator, requestId) => {
|
|
139
|
+
const performRecovery = async () => {
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
let auditTool = "approve_recycle_bin_restore";
|
|
142
|
+
try {
|
|
143
|
+
const executionCtx = await deps.engine.resolveContext({
|
|
144
|
+
datasource: ctx.datasource,
|
|
145
|
+
database: destinationDatabase,
|
|
146
|
+
timeout_ms: timeoutMs,
|
|
147
|
+
readonly: false,
|
|
148
|
+
}, requestId);
|
|
149
|
+
if (!sameRecoveryTarget(ctx, executionCtx)) {
|
|
150
|
+
throw new Error("Datasource target changed after recovery preflight. Create a new recovery request for the current target.");
|
|
151
|
+
}
|
|
152
|
+
const currentProfile = await deps.profileLoader.get(ctx.datasource);
|
|
153
|
+
if (!currentProfile?.user) {
|
|
154
|
+
throw new Error("The SQL credential session is no longer available.");
|
|
155
|
+
}
|
|
156
|
+
const [currentRecycleBin, currentDestinationTables] = await Promise.all([
|
|
157
|
+
deps.engine.listRecycleBin(executionCtx),
|
|
158
|
+
deps.engine.listTables(executionCtx, destinationDatabase),
|
|
159
|
+
]);
|
|
160
|
+
if (!recycleObjectExists(currentRecycleBin, recycleTable)) {
|
|
161
|
+
throw new Error("Recycle-bin object is no longer available. Create a new recovery request.");
|
|
162
|
+
}
|
|
163
|
+
if (currentDestinationTables.some((table) => table.name.toLowerCase() === destinationTable.toLowerCase())) {
|
|
164
|
+
throw new Error("Destination table now exists. Controlled recovery will not overwrite it.");
|
|
165
|
+
}
|
|
166
|
+
await writeRecoveryAudit(deps, {
|
|
167
|
+
requestId,
|
|
168
|
+
actor: operator,
|
|
169
|
+
datasource: executionCtx.datasource,
|
|
170
|
+
database: destinationDatabase,
|
|
171
|
+
sql: restoreSql,
|
|
172
|
+
tool: auditTool,
|
|
173
|
+
outcome: "success",
|
|
174
|
+
durationMs: Date.now() - startedAt,
|
|
175
|
+
});
|
|
176
|
+
auditTool = "restore_recycle_bin_table";
|
|
177
|
+
const result = await deps.engine.restoreRecycleBinTable(restoreInput, executionCtx, {
|
|
178
|
+
timeoutMs,
|
|
179
|
+
});
|
|
180
|
+
const verifiedTables = await deps.engine.listTables(executionCtx, destinationDatabase);
|
|
181
|
+
const verified = verifiedTables.some((table) => table.name.toLowerCase() === destinationTable.toLowerCase());
|
|
182
|
+
if (!verified) {
|
|
183
|
+
throw new Error("Recovery command completed, but readonly destination verification failed. Inspect database state before retrying.");
|
|
184
|
+
}
|
|
185
|
+
await writeRecoveryAudit(deps, {
|
|
186
|
+
requestId,
|
|
187
|
+
actor: operator,
|
|
188
|
+
datasource: executionCtx.datasource,
|
|
189
|
+
database: destinationDatabase,
|
|
190
|
+
sql: restoreSql,
|
|
191
|
+
tool: "restore_recycle_bin_table",
|
|
192
|
+
outcome: "success",
|
|
193
|
+
durationMs: Date.now() - startedAt,
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
queryId: result.queryId,
|
|
197
|
+
affectedRows: result.affectedRows,
|
|
198
|
+
verified,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
await writeRecoveryAudit(deps, {
|
|
203
|
+
requestId,
|
|
204
|
+
actor: operator,
|
|
205
|
+
datasource: ctx.datasource,
|
|
206
|
+
database: destinationDatabase,
|
|
207
|
+
sql: restoreSql,
|
|
208
|
+
tool: auditTool,
|
|
209
|
+
outcome: "error",
|
|
210
|
+
errorCode: "RECOVERY_FAILED",
|
|
211
|
+
durationMs: Date.now() - startedAt,
|
|
212
|
+
});
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
return deps.sessionCoordinator
|
|
217
|
+
? deps.sessionCoordinator.runExclusive(performRecovery)
|
|
218
|
+
: performRecovery();
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
return formatSuccess({
|
|
222
|
+
request_id: issued.requestId,
|
|
223
|
+
status: "pending",
|
|
224
|
+
approval_url: issued.approvalUrl,
|
|
225
|
+
expires_at: issued.expiresAt,
|
|
226
|
+
target: {
|
|
227
|
+
datasource: ctx.datasource,
|
|
228
|
+
recycle_table: recycleTable,
|
|
229
|
+
destination_database: destinationDatabase,
|
|
230
|
+
destination_table: destinationTable,
|
|
231
|
+
},
|
|
232
|
+
agent_can_execute: false,
|
|
233
|
+
}, {
|
|
234
|
+
summary: "Readonly preflight passed. A local operator must approve the recovery before it can execute.",
|
|
235
|
+
metadata: metadata(context.taskId, {
|
|
236
|
+
statement_type: "show",
|
|
237
|
+
sql_hash: sqlHash(normalizeSql(restoreSql)),
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return formatToolError(error, {
|
|
243
|
+
action: "prepare_recycle_bin_restore",
|
|
244
|
+
metadata: metadata(context.taskId),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
export const getRecycleBinRestoreStatusTool = {
|
|
250
|
+
name: "get_recycle_bin_restore_status",
|
|
251
|
+
description: "Get the status of a previously prepared recycle-bin recovery request.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
request_id: z.string().trim().min(1),
|
|
254
|
+
},
|
|
255
|
+
async handler(input, deps, context) {
|
|
256
|
+
try {
|
|
257
|
+
if (!deps.recoveryApproval) {
|
|
258
|
+
throw new ToolInputError("Controlled recycle-bin recovery is not enabled by the administrator.");
|
|
259
|
+
}
|
|
260
|
+
const requestId = asRequiredString(input.request_id, "request_id");
|
|
261
|
+
const status = deps.recoveryApproval.getStatus(requestId);
|
|
262
|
+
if (!status)
|
|
263
|
+
throw new ToolInputError("Recovery request was not found or is no longer retained.");
|
|
264
|
+
return formatSuccess({
|
|
265
|
+
request_id: status.requestId,
|
|
266
|
+
status: status.status,
|
|
267
|
+
target: {
|
|
268
|
+
datasource: status.target.datasource,
|
|
269
|
+
recycle_table: status.target.recycleTable,
|
|
270
|
+
destination_database: status.target.destinationDatabase,
|
|
271
|
+
destination_table: status.target.destinationTable,
|
|
272
|
+
},
|
|
273
|
+
created_at: status.createdAt,
|
|
274
|
+
expires_at: status.expiresAt,
|
|
275
|
+
operator: status.operator,
|
|
276
|
+
completed_at: status.completedAt,
|
|
277
|
+
result: status.result ? {
|
|
278
|
+
query_id: status.result.queryId,
|
|
279
|
+
affected_rows: status.result.affectedRows,
|
|
280
|
+
verified: status.result.verified,
|
|
281
|
+
} : undefined,
|
|
282
|
+
error: status.error,
|
|
283
|
+
}, {
|
|
284
|
+
summary: `Recovery request status: ${status.status}.`,
|
|
285
|
+
metadata: metadata(context.taskId),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return formatToolError(error, {
|
|
290
|
+
action: "get_recycle_bin_restore_status",
|
|
291
|
+
metadata: metadata(context.taskId),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { ToolDefinition } from "../registry.js";
|
|
2
|
+
export declare function issueSqlLoginForDatasource(deps: Parameters<ToolDefinition["handler"]>[1], datasource: string): Promise<import("../../security/local-credential-login.js").IssuedSqlLogin>;
|
|
2
3
|
export declare const beginSqlLoginTool: ToolDefinition;
|
|
@@ -44,6 +44,7 @@ function classifyValidationFailure(error) {
|
|
|
44
44
|
async function expireCredentials(deps, datasource) {
|
|
45
45
|
const expire = async () => {
|
|
46
46
|
deps.profileLoader.clearRuntimeUser(datasource);
|
|
47
|
+
deps.operatorSessions?.revokeDatasource(datasource);
|
|
47
48
|
const previousEngine = deps.engine;
|
|
48
49
|
if (previousEngine?.close) {
|
|
49
50
|
await previousEngine.close();
|
|
@@ -60,6 +61,103 @@ async function expireCredentials(deps, datasource) {
|
|
|
60
61
|
await expire();
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
export async function issueSqlLoginForDatasource(deps, datasource) {
|
|
65
|
+
const profile = await deps.profileLoader.get(datasource);
|
|
66
|
+
if (!profile) {
|
|
67
|
+
throw new ToolInputError(`Datasource "${datasource}" was not found.`);
|
|
68
|
+
}
|
|
69
|
+
if (!profile.host) {
|
|
70
|
+
throw new ToolInputError(`Datasource "${datasource}" does not define a database host. Select a TaurusDB instance before beginning SQL login.`);
|
|
71
|
+
}
|
|
72
|
+
const runtimeTarget = deps.profileLoader.getRuntimeTarget(datasource);
|
|
73
|
+
return deps.credentialLogin.issueSqlLogin({
|
|
74
|
+
datasource,
|
|
75
|
+
target: {
|
|
76
|
+
datasource,
|
|
77
|
+
instanceId: runtimeTarget?.instanceId ??
|
|
78
|
+
profile.instanceId ??
|
|
79
|
+
deps.config.cloud.instanceId,
|
|
80
|
+
region: deps.config.cloud.region,
|
|
81
|
+
credentialIdleTtlMinutes: deps.config.security.credentialIdleTtlMinutes,
|
|
82
|
+
credentialMaxTtlMinutes: deps.config.security.credentialMaxTtlMinutes,
|
|
83
|
+
},
|
|
84
|
+
bind: async ({ username, password }) => {
|
|
85
|
+
const bindCredentials = async () => {
|
|
86
|
+
const currentTarget = deps.profileLoader.getRuntimeTarget(datasource);
|
|
87
|
+
const restoreTarget = () => {
|
|
88
|
+
deps.profileLoader.clearRuntimeTarget(datasource);
|
|
89
|
+
if (currentTarget) {
|
|
90
|
+
deps.profileLoader.setRuntimeTarget(datasource, currentTarget);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
deps.profileLoader.setRuntimeTarget(datasource, {
|
|
94
|
+
host: currentTarget?.host ?? profile.host,
|
|
95
|
+
port: currentTarget?.port ?? profile.port,
|
|
96
|
+
database: currentTarget?.database ?? profile.database,
|
|
97
|
+
instanceId: currentTarget?.instanceId,
|
|
98
|
+
nodeId: currentTarget?.nodeId,
|
|
99
|
+
user: {
|
|
100
|
+
username,
|
|
101
|
+
password: { type: "plain", value: password },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
let nextEngine;
|
|
105
|
+
try {
|
|
106
|
+
nextEngine = await TaurusDBEngine.create({
|
|
107
|
+
config: deps.config,
|
|
108
|
+
profileLoader: deps.profileLoader,
|
|
109
|
+
});
|
|
110
|
+
const validationTaskId = `credential_validation_${Date.now()}`;
|
|
111
|
+
if (deps.sqlCredentialValidator) {
|
|
112
|
+
await deps.sqlCredentialValidator(nextEngine, datasource, validationTaskId);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const validationContext = await nextEngine.resolveContext({ datasource, readonly: true }, validationTaskId);
|
|
116
|
+
await nextEngine.executeReadonly("SELECT 1 AS ok", validationContext, {
|
|
117
|
+
timeoutMs: Math.min(deps.config.limits.maxStatementMs, 10_000),
|
|
118
|
+
maxRows: 1,
|
|
119
|
+
maxColumns: 1,
|
|
120
|
+
maxFieldChars: 16,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
await nextEngine?.close().catch(() => undefined);
|
|
126
|
+
restoreTarget();
|
|
127
|
+
throw new SqlCredentialValidationError(classifyValidationFailure(error));
|
|
128
|
+
}
|
|
129
|
+
const previousEngine = deps.engine;
|
|
130
|
+
try {
|
|
131
|
+
if (previousEngine?.close) {
|
|
132
|
+
await previousEngine.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await nextEngine.close().catch(() => undefined);
|
|
137
|
+
restoreTarget();
|
|
138
|
+
try {
|
|
139
|
+
deps.engine = await TaurusDBEngine.create({
|
|
140
|
+
config: deps.config,
|
|
141
|
+
profileLoader: deps.profileLoader,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
deps.engine = previousEngine;
|
|
146
|
+
}
|
|
147
|
+
throw new SqlCredentialValidationError(classifyValidationFailure(error));
|
|
148
|
+
}
|
|
149
|
+
deps.engine = nextEngine;
|
|
150
|
+
deps.credentialSessions?.activate(datasource, () => expireCredentials(deps, datasource));
|
|
151
|
+
};
|
|
152
|
+
if (deps.sessionCoordinator) {
|
|
153
|
+
await deps.sessionCoordinator.runExclusive(bindCredentials);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
await bindCredentials();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
63
161
|
export const beginSqlLoginTool = {
|
|
64
162
|
name: "begin_sql_login",
|
|
65
163
|
description: "Create a short-lived local login URL where the user can enter SQL credentials without sending the password through the Agent or MCP tool arguments.",
|
|
@@ -78,101 +176,7 @@ export const beginSqlLoginTool = {
|
|
|
78
176
|
if (!datasource) {
|
|
79
177
|
throw new ToolInputError("No datasource selected. Configure a default datasource or pass datasource explicitly.");
|
|
80
178
|
}
|
|
81
|
-
const
|
|
82
|
-
if (!profile) {
|
|
83
|
-
throw new ToolInputError(`Datasource "${datasource}" was not found.`);
|
|
84
|
-
}
|
|
85
|
-
if (!profile.host) {
|
|
86
|
-
throw new ToolInputError(`Datasource "${datasource}" does not define a database host. Select a TaurusDB instance before beginning SQL login.`);
|
|
87
|
-
}
|
|
88
|
-
const runtimeTarget = deps.profileLoader.getRuntimeTarget(datasource);
|
|
89
|
-
const issued = await deps.credentialLogin.issueSqlLogin({
|
|
90
|
-
datasource,
|
|
91
|
-
target: {
|
|
92
|
-
datasource,
|
|
93
|
-
instanceId: runtimeTarget?.instanceId ??
|
|
94
|
-
profile.instanceId ??
|
|
95
|
-
deps.config.cloud.instanceId,
|
|
96
|
-
region: deps.config.cloud.region,
|
|
97
|
-
credentialIdleTtlMinutes: deps.config.security.credentialIdleTtlMinutes,
|
|
98
|
-
credentialMaxTtlMinutes: deps.config.security.credentialMaxTtlMinutes,
|
|
99
|
-
},
|
|
100
|
-
bind: async ({ username, password }) => {
|
|
101
|
-
const bindCredentials = async () => {
|
|
102
|
-
const currentTarget = deps.profileLoader.getRuntimeTarget(datasource);
|
|
103
|
-
const restoreTarget = () => {
|
|
104
|
-
deps.profileLoader.clearRuntimeTarget(datasource);
|
|
105
|
-
if (currentTarget) {
|
|
106
|
-
deps.profileLoader.setRuntimeTarget(datasource, currentTarget);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
deps.profileLoader.setRuntimeTarget(datasource, {
|
|
110
|
-
host: currentTarget?.host ?? profile.host,
|
|
111
|
-
port: currentTarget?.port ?? profile.port,
|
|
112
|
-
database: currentTarget?.database ?? profile.database,
|
|
113
|
-
instanceId: currentTarget?.instanceId,
|
|
114
|
-
nodeId: currentTarget?.nodeId,
|
|
115
|
-
user: {
|
|
116
|
-
username,
|
|
117
|
-
password: { type: "plain", value: password },
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
let nextEngine;
|
|
121
|
-
try {
|
|
122
|
-
nextEngine = await TaurusDBEngine.create({
|
|
123
|
-
config: deps.config,
|
|
124
|
-
profileLoader: deps.profileLoader,
|
|
125
|
-
});
|
|
126
|
-
const validationTaskId = `credential_validation_${Date.now()}`;
|
|
127
|
-
if (deps.sqlCredentialValidator) {
|
|
128
|
-
await deps.sqlCredentialValidator(nextEngine, datasource, validationTaskId);
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
const validationContext = await nextEngine.resolveContext({ datasource, readonly: true }, validationTaskId);
|
|
132
|
-
await nextEngine.executeReadonly("SELECT 1 AS ok", validationContext, {
|
|
133
|
-
timeoutMs: Math.min(deps.config.limits.maxStatementMs, 10_000),
|
|
134
|
-
maxRows: 1,
|
|
135
|
-
maxColumns: 1,
|
|
136
|
-
maxFieldChars: 16,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
catch (error) {
|
|
141
|
-
await nextEngine?.close().catch(() => undefined);
|
|
142
|
-
restoreTarget();
|
|
143
|
-
throw new SqlCredentialValidationError(classifyValidationFailure(error));
|
|
144
|
-
}
|
|
145
|
-
const previousEngine = deps.engine;
|
|
146
|
-
try {
|
|
147
|
-
if (previousEngine?.close) {
|
|
148
|
-
await previousEngine.close();
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
catch (error) {
|
|
152
|
-
await nextEngine.close().catch(() => undefined);
|
|
153
|
-
restoreTarget();
|
|
154
|
-
try {
|
|
155
|
-
deps.engine = await TaurusDBEngine.create({
|
|
156
|
-
config: deps.config,
|
|
157
|
-
profileLoader: deps.profileLoader,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
deps.engine = previousEngine;
|
|
162
|
-
}
|
|
163
|
-
throw new SqlCredentialValidationError(classifyValidationFailure(error));
|
|
164
|
-
}
|
|
165
|
-
deps.engine = nextEngine;
|
|
166
|
-
deps.credentialSessions?.activate(datasource, () => expireCredentials(deps, datasource));
|
|
167
|
-
};
|
|
168
|
-
if (deps.sessionCoordinator) {
|
|
169
|
-
await deps.sessionCoordinator.runExclusive(bindCredentials);
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
await bindCredentials();
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
});
|
|
179
|
+
const issued = await issueSqlLoginForDatasource(deps, datasource);
|
|
176
180
|
return formatSuccess({
|
|
177
181
|
datasource,
|
|
178
182
|
login_url: issued.loginUrl,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taurusdb-mcp",
|
|
3
|
-
"version": "0.5.0-rc.
|
|
3
|
+
"version": "0.5.0-rc.4",
|
|
4
4
|
"description": "TaurusDB MCP server for Claude, Cursor, VS Code, and other MCP clients.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test": "node --test tests/*.test.mjs"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"taurusdb-core": "0.5.0-rc.
|
|
47
|
+
"taurusdb-core": "0.5.0-rc.4",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
49
49
|
"mysql2": "^3.22.1",
|
|
50
50
|
"zod": "^3.24.1"
|