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 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
- - Database passwords support `env:`, `file:`, `hw-csms:`, `hw-kms:`, and `hw-kms-file:` references
88
- - Huawei DEW CSMS is recommended when the database password should be stored and retrieved from Huawei Cloud
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
- - Database mutation tools do not exist; the MCP remains read-only regardless of database account privileges
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
- - Local SQL 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
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 change process;
101
- the MCP never executes database state changes.
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("&", "&amp;")
15
+ .replaceAll("<", "&lt;")
16
+ .replaceAll(">", "&gt;")
17
+ .replaceAll('"', "&quot;")
18
+ .replaceAll("'", "&#39;");
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(),
@@ -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;
@@ -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
- if (boundDatasource && selectedHost) {
336
- deps.profileLoader.setRuntimeTarget(boundDatasource, {
337
- host: selectedHost,
338
- port: selectedPort,
339
- instanceId: matched.id,
340
- nodeId: matched.primaryNodeId,
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
- if (boundDatasource) {
345
- deps.credentialSessions?.clear(boundDatasource);
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,2 +1,4 @@
1
1
  import type { ToolDefinition } from "../registry.js";
2
2
  export declare const listRecycleBinTool: ToolDefinition;
3
+ export declare const prepareRecycleBinRestoreTool: ToolDefinition;
4
+ export declare const getRecycleBinRestoreStatusTool: ToolDefinition;
@@ -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 profile = await deps.profileLoader.get(datasource);
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",
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.3",
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"