taurusdb-mcp 0.5.0-rc.2 → 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,32 +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
- - Mutation and dynamic-target tools are disabled by default
92
- - Mutation tools require a dedicated `TAURUSDB_SQL_MUTATION_USER` /
93
- `TAURUSDB_SQL_MUTATION_PASSWORD` pair and an external operator-signed approval
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
97
+ - `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
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
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`
94
100
  - MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
95
101
  configurable size rotation for collection into centralized immutable storage
96
102
  - Run one stdio process per customer/client trust boundary; this package is not
97
103
  a shared multi-tenant HTTP service
98
104
 
99
- Enable mutations only when required:
100
-
101
- ```bash
102
- export TAURUSDB_ENABLE_MUTATIONS=true
103
- export TAURUSDB_SQL_MUTATION_USER='<dedicated-writer>'
104
- export TAURUSDB_SQL_MUTATION_PASSWORD='hw-csms:<writer-secret-name>'
105
- export TAURUSDB_MUTATION_APPROVAL_SECRET_FILE='/run/secrets/taurusdb-approval'
106
- ```
107
-
108
- Sign a returned `approval_request` outside the MCP client:
109
-
110
- ```bash
111
- npx taurusdb-mcp approve \
112
- --request '<approval_request>' \
113
- --actor '<operator-identity>' \
114
- --secret-file /run/secrets/taurusdb-approval
115
- ```
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,16 +1,30 @@
1
+ import { BrowserOperatorSessionStore } from "./browser-operator-session.js";
1
2
  export type SqlLoginCredentials = {
2
3
  datasource: string;
3
4
  username: string;
4
5
  password: string;
5
6
  };
7
+ export type SqlLoginTarget = {
8
+ datasource: string;
9
+ instanceId?: string;
10
+ region?: string;
11
+ credentialIdleTtlMinutes?: number;
12
+ credentialMaxTtlMinutes?: number;
13
+ };
6
14
  export type SqlLoginRequest = {
7
15
  datasource: string;
16
+ target?: SqlLoginTarget;
8
17
  bind: (credentials: SqlLoginCredentials) => Promise<void>;
9
18
  };
10
19
  export type IssuedSqlLogin = {
11
20
  loginUrl: string;
12
21
  expiresAt: string;
13
22
  };
23
+ export type CredentialValidationFailure = "credentials" | "connectivity" | "tls" | "timeout";
24
+ export declare class SqlCredentialValidationError extends Error {
25
+ readonly kind: CredentialValidationFailure;
26
+ constructor(kind: CredentialValidationFailure);
27
+ }
14
28
  export interface CredentialLoginService {
15
29
  issueSqlLogin(request: SqlLoginRequest): Promise<IssuedSqlLogin>;
16
30
  close(): Promise<void>;
@@ -18,10 +32,16 @@ export interface CredentialLoginService {
18
32
  export type LocalCredentialLoginServiceOptions = {
19
33
  now?: () => number;
20
34
  tokenTtlMs?: number;
35
+ maxAttempts?: number;
36
+ failureDelayMs?: number;
37
+ operatorSessions?: BrowserOperatorSessionStore;
21
38
  };
22
39
  export declare class LocalCredentialLoginService implements CredentialLoginService {
23
40
  private readonly now;
24
41
  private readonly tokenTtlMs;
42
+ private readonly maxAttempts;
43
+ private readonly failureDelayMs;
44
+ private readonly operatorSessions?;
25
45
  private readonly pending;
26
46
  private server;
27
47
  private port;
@@ -1,42 +1,247 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { createServer, } from "node:http";
3
3
  const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000;
4
+ const DEFAULT_MAX_ATTEMPTS = 3;
5
+ const DEFAULT_FAILURE_DELAY_MS = 1_000;
4
6
  const MAX_REQUEST_BODY_BYTES = 16 * 1024;
5
- function page(title, message, form = false) {
6
- const formHtml = form
7
- ? `<form method="post">
8
- <label>Database username <input name="username" autocomplete="username" required></label>
9
- <label>Database password <input name="password" type="password" autocomplete="current-password" required></label>
10
- <button type="submit">Connect</button>
11
- </form>`
12
- : "";
7
+ export class SqlCredentialValidationError extends Error {
8
+ kind;
9
+ constructor(kind) {
10
+ super(`SQL credential validation failed: ${kind}`);
11
+ this.name = "SqlCredentialValidationError";
12
+ this.kind = kind;
13
+ }
14
+ }
15
+ const COPY = {
16
+ "zh-CN": {
17
+ product: "TaurusDB MCP",
18
+ secureSession: "安全会话",
19
+ title: "连接数据库",
20
+ intro: "输入数据库账号和密码以验证连接。",
21
+ target: "连接目标",
22
+ instance: "实例",
23
+ region: "区域",
24
+ datasource: "数据源",
25
+ configured: "已配置的数据源",
26
+ unknown: "未指定",
27
+ username: "数据库账号",
28
+ password: "数据库密码",
29
+ usernamePlaceholder: "请输入数据库账号",
30
+ passwordPlaceholder: "请输入数据库密码",
31
+ submit: "连接数据库",
32
+ submitting: "正在验证连接…",
33
+ privacy: "凭据对 Agent 不可见,仅用于连接您选择的数据库,不会由 MCP 持久化保存。",
34
+ retention: (idle, maximum) => `空闲 ${idle} 分钟后清除 · 最长保留 ${maximum >= 60 ? `${maximum / 60} 小时` : `${maximum} 分钟`}`,
35
+ attempts: (remaining) => `本链接还可尝试 ${remaining} 次`,
36
+ required: "请输入数据库账号和密码。",
37
+ credentials: "无法验证账号信息,请检查账号和密码。",
38
+ connectivity: "数据库服务暂时不可达,请检查网络和实例状态。",
39
+ tls: "TLS 安全连接验证失败,请联系管理员检查证书配置。",
40
+ timeout: "连接验证超时,请稍后重试。",
41
+ busy: "连接正在验证,请勿重复提交。",
42
+ successTitle: "账号验证成功",
43
+ successMessage: "您现在可以返回 MCP 会话并选择需要访问的数据库。",
44
+ expiredTitle: "登录链接已失效",
45
+ expiredMessage: "请返回 MCP 会话并重新调用 begin_sql_login。",
46
+ lockedTitle: "尝试次数已用完",
47
+ lockedMessage: "为保护数据库账号,此链接已失效。请返回 MCP 会话重新生成登录链接。",
48
+ methodTitle: "不支持此请求",
49
+ methodMessage: "请使用登录表单继续。",
50
+ invalidTitle: "无法处理请求",
51
+ invalidMessage: "登录请求无效,请重新生成登录链接。",
52
+ },
53
+ en: {
54
+ product: "TaurusDB MCP",
55
+ secureSession: "Secure session",
56
+ title: "Connect to database",
57
+ intro: "Enter your database credentials to validate the connection.",
58
+ target: "Connection target",
59
+ instance: "Instance",
60
+ region: "Region",
61
+ datasource: "Datasource",
62
+ configured: "Configured datasource",
63
+ unknown: "Not specified",
64
+ username: "Database username",
65
+ password: "Database password",
66
+ usernamePlaceholder: "Enter database username",
67
+ passwordPlaceholder: "Enter database password",
68
+ submit: "Connect to database",
69
+ submitting: "Validating connection…",
70
+ privacy: "Credentials are not visible to the Agent. They are used only to connect to your selected database and are not persisted by MCP.",
71
+ retention: (idle, maximum) => `Cleared after ${idle} idle minutes · ${maximum >= 60 ? `${maximum / 60} ${maximum === 60 ? "hour" : "hours"}` : `${maximum} ${maximum === 1 ? "minute" : "minutes"}`} maximum`,
72
+ attempts: (remaining) => `${remaining} attempts remaining for this link`,
73
+ required: "Enter both the database username and password.",
74
+ credentials: "The account could not be validated. Check the username and password.",
75
+ connectivity: "The database is currently unreachable. Check the network and instance status.",
76
+ tls: "The TLS connection could not be validated. Ask an administrator to check the certificate configuration.",
77
+ timeout: "Connection validation timed out. Try again shortly.",
78
+ busy: "Connection validation is already in progress. Do not submit again.",
79
+ successTitle: "Account validated",
80
+ successMessage: "Return to your MCP session and select the database you want to access.",
81
+ expiredTitle: "Login link expired",
82
+ expiredMessage: "Return to your MCP session and call begin_sql_login again.",
83
+ lockedTitle: "Attempt limit reached",
84
+ lockedMessage: "This link has expired to protect the database account. Return to your MCP session and create a new login link.",
85
+ methodTitle: "Unsupported request",
86
+ methodMessage: "Use the login form to continue.",
87
+ invalidTitle: "Request could not be processed",
88
+ invalidMessage: "The login request is invalid. Create a new login link.",
89
+ },
90
+ };
91
+ function localeFromRequest(req) {
92
+ return /(?:^|,)\s*zh(?:-|_|;|,|$)/i.test(req.headers["accept-language"] ?? "")
93
+ ? "zh-CN"
94
+ : "en";
95
+ }
96
+ function escapeHtml(value) {
97
+ return value
98
+ .replaceAll("&", "&amp;")
99
+ .replaceAll("<", "&lt;")
100
+ .replaceAll(">", "&gt;")
101
+ .replaceAll('"', "&quot;")
102
+ .replaceAll("'", "&#39;");
103
+ }
104
+ function maskInstanceId(value, fallback) {
105
+ if (!value) {
106
+ return fallback;
107
+ }
108
+ if (value.length <= 10) {
109
+ return value;
110
+ }
111
+ return `${value.slice(0, 5)}…${value.slice(-4)}`;
112
+ }
113
+ function page(input) {
114
+ const copy = COPY[input.locale];
115
+ const isForm = input.state === "form";
116
+ const title = isForm
117
+ ? copy.title
118
+ : input.state === "success"
119
+ ? copy.successTitle
120
+ : input.state === "expired"
121
+ ? copy.expiredTitle
122
+ : input.state === "locked"
123
+ ? copy.lockedTitle
124
+ : input.state === "method"
125
+ ? copy.methodTitle
126
+ : copy.invalidTitle;
127
+ const message = input.message ?? (isForm
128
+ ? copy.intro
129
+ : input.state === "success"
130
+ ? copy.successMessage
131
+ : input.state === "expired"
132
+ ? copy.expiredMessage
133
+ : input.state === "locked"
134
+ ? copy.lockedMessage
135
+ : input.state === "method"
136
+ ? copy.methodMessage
137
+ : copy.invalidMessage);
138
+ const nonce = randomBytes(18).toString("base64url");
139
+ const target = input.target;
140
+ const formHtml = isForm
141
+ ? `<form method="post" autocomplete="on" data-login-form>
142
+ ${input.message ? `<div class="alert" role="alert">${escapeHtml(input.message)}</div>` : ""}
143
+ <label for="username">${copy.username}</label>
144
+ <input id="username" name="username" autocomplete="username" maxlength="256" value="${escapeHtml(input.username ?? "")}" placeholder="${copy.usernamePlaceholder}" required autofocus>
145
+ <label for="password">${copy.password}</label>
146
+ <input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="${copy.passwordPlaceholder}" required>
147
+ <button type="submit" data-submit data-pending="${copy.submitting}">${copy.submit}</button>
148
+ ${input.remainingAttempts !== undefined ? `<p class="attempts">${copy.attempts(input.remainingAttempts)}</p>` : ""}
149
+ </form>`
150
+ : `<section class="result ${input.state}" aria-live="polite">
151
+ <span class="result-mark" aria-hidden="true">${input.state === "success" ? "✓" : "!"}</span>
152
+ <h1>${escapeHtml(title)}</h1>
153
+ <p>${escapeHtml(message)}</p>
154
+ </section>`;
13
155
  return `<!doctype html>
14
- <html lang="en">
156
+ <html lang="${input.locale}">
15
157
  <head>
16
158
  <meta charset="utf-8">
17
159
  <meta name="viewport" content="width=device-width, initial-scale=1">
18
- <title>${title}</title>
160
+ <meta name="color-scheme" content="light">
161
+ <title>${escapeHtml(title)} · ${copy.product}</title>
19
162
  <style>
20
- body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1rem; }
21
- form { display: grid; gap: 1rem; }
22
- label { display: grid; gap: .35rem; }
23
- input, button { font: inherit; padding: .65rem; }
163
+ :root { color-scheme: light; --ink:#172033; --muted:#637083; --cloud:#f3f6fa; --surface:#fff; --line:#dce3eb; --teal:#087f73; --teal-dark:#06665e; --teal-soft:#e7f5f2; --danger:#b42318; --danger-soft:#fff0ee; --shadow:0 22px 60px rgba(23,32,51,.12); }
164
+ * { box-sizing:border-box; }
165
+ body { margin:0; min-height:100vh; color:var(--ink); background:var(--cloud); font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
166
+ .shell { width:min(980px,calc(100% - 32px)); margin:clamp(24px,7vh,72px) auto; }
167
+ .masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; font-size:13px; letter-spacing:.04em; }
168
+ .brand { font-weight:760; font-size:15px; letter-spacing:-.01em; }
169
+ .session { color:var(--teal-dark); background:var(--teal-soft); padding:7px 11px; border-radius:999px; font-weight:650; }
170
+ .panel { display:grid; grid-template-columns:minmax(270px,.82fr) minmax(340px,1.18fr); overflow:hidden; border:1px solid rgba(23,32,51,.08); border-radius:22px; background:var(--surface); box-shadow:var(--shadow); }
171
+ .context { position:relative; padding:44px 40px; color:#f7fbff; background:var(--ink); }
172
+ .context:after { content:""; position:absolute; width:240px; height:240px; right:-120px; bottom:-130px; border:1px solid rgba(255,255,255,.14); border-radius:50%; box-shadow:0 0 0 34px rgba(255,255,255,.035),0 0 0 72px rgba(255,255,255,.025); }
173
+ .eyebrow { margin:0 0 28px; color:#9edbd4; font-size:12px; font-weight:720; letter-spacing:.11em; text-transform:uppercase; }
174
+ .rail { margin:0; }
175
+ .rail-item { display:grid; grid-template-columns:14px 1fr; gap:0 14px; }
176
+ .rail dt,.rail dd { margin:0; padding-bottom:25px; }
177
+ .rail dt { color:#aeb8c8; font-size:12px; }
178
+ .rail dd { margin-top:18px; font-size:15px; font-weight:650; overflow-wrap:anywhere; }
179
+ .node { position:relative; width:10px; height:10px; margin-top:4px; border:2px solid #73d2c8; border-radius:50%; background:var(--ink); }
180
+ .node:not(.last):after { content:""; position:absolute; left:2px; top:10px; width:2px; height:54px; background:linear-gradient(#3b8f88,rgba(59,143,136,.16)); }
181
+ .target-row { grid-column:2; display:grid; grid-template-columns:76px 1fr; }
182
+ .retention { position:relative; z-index:1; margin:18px 0 0; padding-top:18px; border-top:1px solid rgba(255,255,255,.13); color:#aeb8c8; font-size:12px; line-height:1.55; }
183
+ .content { padding:44px 48px 36px; }
184
+ .content h1 { margin:0 0 10px; font-size:clamp(28px,4vw,38px); line-height:1.08; letter-spacing:-.035em; }
185
+ .intro { margin:0 0 30px; color:var(--muted); line-height:1.65; }
186
+ form { display:grid; gap:9px; }
187
+ label { margin-top:9px; font-size:13px; font-weight:700; }
188
+ input { width:100%; min-height:48px; border:1px solid #cbd4df; border-radius:10px; padding:11px 13px; color:var(--ink); background:#fbfcfe; font:inherit; outline:none; transition:border-color .16s,box-shadow .16s,background .16s; }
189
+ input:focus { border-color:var(--teal); background:#fff; box-shadow:0 0 0 4px rgba(8,127,115,.12); }
190
+ button { min-height:50px; margin-top:17px; border:0; border-radius:10px; color:#fff; background:var(--teal); font:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .16s,transform .16s; }
191
+ button:hover { background:var(--teal-dark); }
192
+ button:active { transform:translateY(1px); }
193
+ button:focus-visible { outline:3px solid rgba(8,127,115,.28); outline-offset:3px; }
194
+ button:disabled { cursor:wait; opacity:.72; }
195
+ .alert { margin-bottom:4px; border-left:3px solid var(--danger); border-radius:7px; padding:11px 13px; color:#8d1b13; background:var(--danger-soft); font-size:13px; line-height:1.5; }
196
+ .attempts { margin:2px 0 0; color:var(--muted); text-align:center; font-size:12px; }
197
+ .privacy { display:flex; gap:10px; align-items:flex-start; margin:27px 0 0; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; line-height:1.6; }
198
+ .shield { flex:0 0 auto; color:var(--teal); font-size:15px; line-height:1.3; }
199
+ .result { min-height:280px; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; }
200
+ .result-mark { display:grid; place-items:center; width:46px; height:46px; margin-bottom:22px; border-radius:50%; color:#fff; background:var(--danger); font-size:22px; font-weight:800; }
201
+ .result.success .result-mark { background:var(--teal); }
202
+ .result p { max-width:35rem; color:var(--muted); line-height:1.7; }
203
+ @media (max-width:760px) { .shell{width:min(100% - 20px,560px);margin:18px auto}.panel{grid-template-columns:1fr;border-radius:17px}.context{padding:28px 26px 18px}.content{padding:30px 26px}.rail dt,.rail dd{padding-bottom:17px}.node:not(.last):after{height:44px}.content h1{font-size:30px} }
204
+ @media (prefers-reduced-motion:reduce) { *,*:before,*:after{scroll-behavior:auto!important;transition:none!important} }
24
205
  </style>
25
206
  </head>
26
207
  <body>
27
- <h1>${title}</h1>
28
- <p>${message}</p>
29
- ${formHtml}
208
+ <main class="shell">
209
+ <header class="masthead"><span class="brand">${copy.product}</span><span class="session">${copy.secureSession}</span></header>
210
+ <section class="panel">
211
+ <aside class="context">
212
+ <p class="eyebrow">${copy.target}</p>
213
+ <dl class="rail">
214
+ <div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.instance}</dt><dd>${escapeHtml(maskInstanceId(target?.instanceId, copy.configured))}</dd></div></div>
215
+ <div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.region}</dt><dd>${escapeHtml(target?.region ?? copy.unknown)}</dd></div></div>
216
+ <div class="rail-item"><span class="node last"></span><div class="target-row"><dt>${copy.datasource}</dt><dd>${escapeHtml(target?.datasource ?? copy.unknown)}</dd></div></div>
217
+ </dl>
218
+ ${target?.credentialIdleTtlMinutes && target.credentialMaxTtlMinutes ? `<p class="retention">${copy.retention(target.credentialIdleTtlMinutes, target.credentialMaxTtlMinutes)}</p>` : ""}
219
+ </aside>
220
+ <div class="content">
221
+ ${isForm ? `<h1>${copy.title}</h1><p class="intro">${copy.intro}</p>` : ""}
222
+ ${formHtml}
223
+ ${isForm ? `<p class="privacy"><span class="shield" aria-hidden="true">◆</span><span>${copy.privacy}</span></p>` : ""}
224
+ </div>
225
+ </section>
226
+ </main>
227
+ ${isForm ? `<script nonce="${nonce}">document.querySelector('[data-login-form]').addEventListener('submit',()=>{const b=document.querySelector('[data-submit]');b.disabled=true;b.textContent=b.dataset.pending;});</script>` : ""}
30
228
  </body>
31
229
  </html>`;
32
230
  }
33
231
  function respond(res, status, body) {
232
+ const nonce = /<script nonce="([^"]+)"/.exec(body)?.[1];
34
233
  res.writeHead(status, {
35
234
  "cache-control": "no-store",
36
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
235
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${nonce ? `; script-src 'nonce-${nonce}'` : ""}`,
37
236
  "content-type": "text/html; charset=utf-8",
237
+ "cross-origin-opener-policy": "same-origin",
238
+ "cross-origin-resource-policy": "same-origin",
239
+ expires: "0",
240
+ "permissions-policy": "camera=(), microphone=(), geolocation=()",
241
+ pragma: "no-cache",
38
242
  "referrer-policy": "no-referrer",
39
243
  "x-content-type-options": "nosniff",
244
+ "x-frame-options": "DENY",
40
245
  });
41
246
  res.end(body);
42
247
  }
@@ -53,21 +258,30 @@ async function readBody(req) {
53
258
  }
54
259
  return Buffer.concat(chunks).toString("utf8");
55
260
  }
261
+ function delay(ms) {
262
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
263
+ }
56
264
  export class LocalCredentialLoginService {
57
265
  now;
58
266
  tokenTtlMs;
267
+ maxAttempts;
268
+ failureDelayMs;
269
+ operatorSessions;
59
270
  pending = new Map();
60
271
  server;
61
272
  port;
62
273
  constructor(options = {}) {
63
274
  this.now = options.now ?? Date.now;
64
275
  this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
276
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
277
+ this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
278
+ this.operatorSessions = options.operatorSessions;
65
279
  }
66
280
  async issueSqlLogin(request) {
67
281
  await this.ensureStarted();
68
282
  const token = randomBytes(32).toString("base64url");
69
283
  const expiresAtMs = this.now() + this.tokenTtlMs;
70
- this.pending.set(token, { ...request, expiresAtMs });
284
+ this.pending.set(token, { ...request, expiresAtMs, failedAttempts: 0, inFlight: false });
71
285
  return {
72
286
  loginUrl: `http://127.0.0.1:${this.port}/sql-login/${token}`,
73
287
  expiresAt: new Date(expiresAtMs).toISOString(),
@@ -75,6 +289,7 @@ export class LocalCredentialLoginService {
75
289
  }
76
290
  async close() {
77
291
  this.pending.clear();
292
+ this.operatorSessions?.clear();
78
293
  const server = this.server;
79
294
  this.server = undefined;
80
295
  this.port = undefined;
@@ -108,6 +323,7 @@ export class LocalCredentialLoginService {
108
323
  this.port = address.port;
109
324
  }
110
325
  async handle(req, res) {
326
+ const locale = localeFromRequest(req);
111
327
  try {
112
328
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
113
329
  const match = url.pathname.match(/^\/sql-login\/([^/]+)$/);
@@ -117,40 +333,87 @@ export class LocalCredentialLoginService {
117
333
  if (token) {
118
334
  this.pending.delete(token);
119
335
  }
120
- respond(res, 410, page("Invalid login link", "This login link is invalid or has expired."));
336
+ respond(res, 410, page({ locale, state: "expired", target: pending?.target }));
121
337
  return;
122
338
  }
123
339
  if (req.method === "GET") {
124
- respond(res, 200, page("Connect to TaurusDB", "Enter your database credentials.", true));
340
+ respond(res, 200, page({
341
+ locale,
342
+ state: "form",
343
+ target: pending.target,
344
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
345
+ }));
125
346
  return;
126
347
  }
127
348
  if (req.method !== "POST") {
128
- respond(res, 405, page("Unsupported request", "Use the login form to continue."));
349
+ respond(res, 405, page({ locale, state: "method", target: pending.target }));
350
+ return;
351
+ }
352
+ const origin = req.headers.origin;
353
+ if (origin && origin !== `http://${req.headers.host}`) {
354
+ respond(res, 403, page({ locale, state: "invalid", target: pending.target }));
355
+ return;
356
+ }
357
+ if (pending.inFlight) {
358
+ respond(res, 409, page({
359
+ locale,
360
+ state: "form",
361
+ target: pending.target,
362
+ message: COPY[locale].busy,
363
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
364
+ }));
129
365
  return;
130
366
  }
131
367
  const form = new URLSearchParams(await readBody(req));
132
368
  const username = form.get("username")?.trim() ?? "";
133
369
  const password = form.get("password") ?? "";
134
370
  if (!username || !password) {
135
- respond(res, 400, page("Connect to TaurusDB", "Username and password are required.", true));
371
+ respond(res, 400, page({
372
+ locale,
373
+ state: "form",
374
+ target: pending.target,
375
+ message: COPY[locale].required,
376
+ username,
377
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
378
+ }));
136
379
  return;
137
380
  }
138
- this.pending.delete(token);
139
381
  try {
140
- await pending.bind({
141
- datasource: pending.datasource,
142
- username,
143
- password,
144
- });
382
+ pending.inFlight = true;
383
+ await pending.bind({ datasource: pending.datasource, username, password });
145
384
  }
146
- catch {
147
- respond(res, 500, page("Connection failed", "Credentials could not be bound to this session."));
385
+ catch (error) {
386
+ pending.inFlight = false;
387
+ const kind = error instanceof SqlCredentialValidationError ? error.kind : "connectivity";
388
+ if (kind === "credentials") {
389
+ pending.failedAttempts += 1;
390
+ await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
391
+ if (pending.failedAttempts >= this.maxAttempts) {
392
+ this.pending.delete(token);
393
+ respond(res, 429, page({ locale, state: "locked", target: pending.target }));
394
+ return;
395
+ }
396
+ }
397
+ respond(res, kind === "credentials" ? 401 : kind === "timeout" ? 504 : 502, page({
398
+ locale,
399
+ state: "form",
400
+ target: pending.target,
401
+ message: COPY[locale][kind],
402
+ username,
403
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
404
+ }));
148
405
  return;
149
406
  }
150
- respond(res, 200, page("Connected", "Database credentials are now active for this MCP session."));
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
+ }
413
+ respond(res, 200, page({ locale, state: "success", target: pending.target }));
151
414
  }
152
415
  catch {
153
- respond(res, 400, page("Invalid request", "The login request could not be processed."));
416
+ respond(res, 400, page({ locale, state: "invalid" }));
154
417
  }
155
418
  }
156
419
  }