taurusdb-mcp 0.5.0-rc.7 → 0.5.0-rc.9

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
@@ -95,6 +95,9 @@ npx taurusdb-mcp credentials check
95
95
  - `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
96
96
  - 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
97
97
  - Interactive instance selection binds the read/write public IP and fails immediately when the instance has no public IP; it never falls back to a VPC-private address that a local MCP client cannot route to
98
+ - Before issuing a login link, instance selection performs a credential-free TCP endpoint preflight and returns actionable security-group/network guidance when the public database port is unreachable
99
+ - Login validation distinguishes unreachable endpoint, refused port, TLS, authentication, and database validation timeout failures instead of returning a generic HTTP 504
100
+ - Recoverable login errors render their structured code and remediation directly in the browser page with HTTP 200 so embedded webviews do not hide the message; cross-origin requests remain blocked with HTTP 403
98
101
  - Instance selection and local login tools are enabled by default; fixed static deployments may set `TAURUSDB_ENABLE_DYNAMIC_TARGETS=false`
99
102
  - 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`
100
103
  - MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
@@ -0,0 +1,8 @@
1
+ export type DatabaseEndpointFailure = "refused" | "unreachable";
2
+ export declare class DatabaseEndpointPreflightError extends Error {
3
+ readonly kind: DatabaseEndpointFailure;
4
+ readonly host: string;
5
+ readonly port: number;
6
+ constructor(kind: DatabaseEndpointFailure, host: string, port: number);
7
+ }
8
+ export declare function preflightDatabaseEndpoint(host: string, port: number, timeoutMs?: number): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { createConnection } from "node:net";
2
+ export class DatabaseEndpointPreflightError extends Error {
3
+ kind;
4
+ host;
5
+ port;
6
+ constructor(kind, host, port) {
7
+ super(`Database endpoint preflight failed: ${kind}`);
8
+ this.name = "DatabaseEndpointPreflightError";
9
+ this.kind = kind;
10
+ this.host = host;
11
+ this.port = port;
12
+ }
13
+ }
14
+ export function preflightDatabaseEndpoint(host, port, timeoutMs = 3_000) {
15
+ return new Promise((resolve, reject) => {
16
+ let settled = false;
17
+ const finish = (error) => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ socket.destroy();
22
+ if (error)
23
+ reject(error);
24
+ else
25
+ resolve();
26
+ };
27
+ const socket = createConnection({ host, port });
28
+ socket.setTimeout(timeoutMs);
29
+ socket.once("connect", () => finish());
30
+ socket.once("timeout", () => finish(new DatabaseEndpointPreflightError("unreachable", host, port)));
31
+ socket.once("error", (error) => finish(new DatabaseEndpointPreflightError(error.code === "ECONNREFUSED" ? "refused" : "unreachable", host, port)));
32
+ });
33
+ }
@@ -20,7 +20,7 @@ export type IssuedSqlLogin = {
20
20
  loginUrl: string;
21
21
  expiresAt: string;
22
22
  };
23
- export type CredentialValidationFailure = "credentials" | "connectivity" | "tls" | "timeout";
23
+ export type CredentialValidationFailure = "credentials" | "unreachable" | "refused" | "connectivity" | "tls" | "timeout";
24
24
  export declare class SqlCredentialValidationError extends Error {
25
25
  readonly kind: CredentialValidationFailure;
26
26
  constructor(kind: CredentialValidationFailure);
@@ -33,11 +33,14 @@ const COPY = {
33
33
  privacy: "凭据对 Agent 不可见,仅用于连接您选择的数据库,不会由 MCP 持久化保存。",
34
34
  retention: (idle, maximum) => `空闲 ${idle} 分钟后清除 · 最长保留 ${maximum >= 60 ? `${maximum / 60} 小时` : `${maximum} 分钟`}`,
35
35
  attempts: (remaining) => `本链接还可尝试 ${remaining} 次`,
36
+ diagnostic: "连接检查",
36
37
  required: "请输入数据库账号和密码。",
37
38
  credentials: "无法验证账号信息,请检查账号和密码。",
38
- connectivity: "数据库服务暂时不可达,请检查网络和实例状态。",
39
+ unreachable: "无法访问数据库公网地址。请检查实例安全组入方向规则是否已向当前公网出口 IP 放通数据库端口,并检查网络 ACL、VPN 和本机出口防火墙。",
40
+ refused: "数据库公网地址可以访问,但端口拒绝连接。请检查实例状态和数据库端口。",
41
+ connectivity: "数据库连接失败,请检查公网地址、实例状态和网络配置。",
39
42
  tls: "TLS 安全连接验证失败,请联系管理员检查证书配置。",
40
- timeout: "连接验证超时,请稍后重试。",
43
+ timeout: "数据库端口已连接,但验证未在限定时间内完成。请检查实例负载、连接数和数据库状态。",
41
44
  busy: "连接正在验证,请勿重复提交。",
42
45
  successTitle: "账号验证成功",
43
46
  successMessage: "您现在可以返回 MCP 会话并选择需要访问的数据库。",
@@ -70,11 +73,14 @@ const COPY = {
70
73
  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
74
  retention: (idle, maximum) => `Cleared after ${idle} idle minutes · ${maximum >= 60 ? `${maximum / 60} ${maximum === 60 ? "hour" : "hours"}` : `${maximum} ${maximum === 1 ? "minute" : "minutes"}`} maximum`,
72
75
  attempts: (remaining) => `${remaining} attempts remaining for this link`,
76
+ diagnostic: "Connection check",
73
77
  required: "Enter both the database username and password.",
74
78
  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.",
79
+ unreachable: "The public database endpoint is unreachable. Allow the database port from this client's public egress IP in the instance security group's inbound rules, then check network ACL, VPN, and outbound firewall rules.",
80
+ refused: "The public database endpoint is reachable, but the port refused the connection. Check the instance status and database port.",
81
+ connectivity: "The database connection failed. Check the public endpoint, instance status, and network configuration.",
76
82
  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.",
83
+ timeout: "The database port connected, but validation did not finish in time. Check instance load, connection capacity, and database status.",
78
84
  busy: "Connection validation is already in progress. Do not submit again.",
79
85
  successTitle: "Account validated",
80
86
  successMessage: "Return to your MCP session and select the database you want to access.",
@@ -101,15 +107,6 @@ function escapeHtml(value) {
101
107
  .replaceAll('"', "&quot;")
102
108
  .replaceAll("'", "&#39;");
103
109
  }
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
110
  function page(input) {
114
111
  const copy = COPY[input.locale];
115
112
  const isForm = input.state === "form";
@@ -139,11 +136,18 @@ function page(input) {
139
136
  const target = input.target;
140
137
  const formHtml = isForm
141
138
  ? `<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>
139
+ ${input.message ? `<div class="alert" role="alert">
140
+ <div class="alert-head"><strong>${copy.diagnostic}</strong>${input.errorCode ? `<code>${escapeHtml(input.errorCode)}</code>` : ""}</div>
141
+ <p>${escapeHtml(input.message)}</p>
142
+ </div>` : ""}
143
+ <div class="field">
144
+ <label for="username">${copy.username}</label>
145
+ <input id="username" name="username" autocomplete="username" maxlength="256" value="${escapeHtml(input.username ?? "")}" placeholder="${copy.usernamePlaceholder}" required autofocus>
146
+ </div>
147
+ <div class="field">
148
+ <label for="password">${copy.password}</label>
149
+ <input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="${copy.passwordPlaceholder}" required>
150
+ </div>
147
151
  <button type="submit" data-submit data-pending="${copy.submitting}">${copy.submit}</button>
148
152
  ${input.remainingAttempts !== undefined ? `<p class="attempts">${copy.attempts(input.remainingAttempts)}</p>` : ""}
149
153
  </form>`
@@ -160,47 +164,53 @@ function page(input) {
160
164
  <meta name="color-scheme" content="light">
161
165
  <title>${escapeHtml(title)} · ${copy.product}</title>
162
166
  <style>
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); }
167
+ :root { color-scheme:light; --ink:#20242c; --ink-soft:#303640; --muted:#687181; --cloud:#f5f6f8; --surface:#fff; --line:#dfe3e8; --brand:#c7000b; --brand-dark:#9f0712; --brand-soft:#fdebec; --danger:#a61b29; --danger-soft:#fff1f2; --success:#16794e; --shadow:0 24px 68px rgba(32,36,44,.14); }
164
168
  * { 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; }
169
+ body { margin:0; min-height:100vh; color:var(--ink); background:var(--cloud); font-family:"Avenir Next","Segoe UI Variable","PingFang SC","Microsoft YaHei",ui-sans-serif,sans-serif; }
170
+ .shell { width:min(1000px,calc(100% - 32px)); margin:clamp(24px,7vh,72px) auto; }
171
+ .masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; font-size:13px; letter-spacing:.035em; }
172
+ .brand { font-size:15px; font-weight:750; letter-spacing:-.015em; }
173
+ .session { display:inline-flex; align-items:center; gap:8px; color:var(--brand-dark); background:var(--brand-soft); padding:7px 12px; border-radius:999px; font-weight:680; }
174
+ .session:before { content:""; width:7px; height:7px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 3px rgba(199,0,11,.12); }
175
+ .panel { display:grid; grid-template-columns:minmax(300px,.9fr) minmax(390px,1.25fr); overflow:hidden; border:1px solid rgba(21,34,56,.08); border-radius:20px; background:var(--surface); box-shadow:var(--shadow); }
176
+ .context { padding:46px 42px 40px; color:#f8f9fb; background:linear-gradient(155deg,var(--ink-soft),var(--ink)); }
177
+ .eyebrow { margin:0 0 34px; color:#f3aeb3; font-size:11px; font-weight:760; letter-spacing:.14em; text-transform:uppercase; }
174
178
  .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); }
179
+ .rail-item { position:relative; display:grid; grid-template-columns:12px minmax(0,1fr); column-gap:17px; min-height:86px; }
180
+ .rail-item:last-child { min-height:auto; }
181
+ .target-row { grid-column:2; min-width:0; padding:0 0 25px; border-bottom:1px solid rgba(255,255,255,.1); }
182
+ .rail-item:last-child .target-row { padding-bottom:0; border-bottom:0; }
183
+ .rail dt { margin:0 0 7px; color:#aebbd0; font-size:11px; font-weight:650; letter-spacing:.04em; line-height:1.2; }
184
+ .rail dd { margin:0; color:#fff; font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace; font-size:14px; font-weight:620; line-height:1.45; letter-spacing:-.01em; overflow-wrap:anywhere; }
185
+ .node { position:relative; width:10px; height:10px; margin-top:2px; border:2px solid #f05b64; border-radius:50%; background:var(--ink-soft); box-shadow:0 0 0 4px rgba(199,0,11,.11); }
186
+ .node:not(.last):after { content:""; position:absolute; left:2px; top:11px; width:2px; height:73px; background:linear-gradient(#d53a44,rgba(213,58,68,.16)); }
187
+ .retention { margin:30px 0 0 29px; padding-top:18px; border-top:1px solid rgba(255,255,255,.1); color:#aebbd0; font-size:11px; line-height:1.65; }
188
+ .content { padding:48px 52px 38px; }
189
+ .content h1 { margin:0 0 11px; font-size:clamp(30px,4vw,40px); font-weight:760; line-height:1.08; letter-spacing:-.04em; }
190
+ .intro { margin:0 0 31px; color:var(--muted); font-size:14px; line-height:1.65; }
191
+ form { display:grid; gap:18px; }
192
+ .field { display:grid; gap:8px; }
193
+ label { font-size:12px; font-weight:730; letter-spacing:.01em; }
194
+ input { width:100%; min-height:50px; border:1px solid #c8d2de; border-radius:9px; padding:12px 14px; color:var(--ink); background:#fbfcfe; font:inherit; outline:none; transition:border-color .16s,box-shadow .16s,background .16s; }
195
+ input:focus { border-color:var(--brand); background:#fff; box-shadow:0 0 0 4px rgba(199,0,11,.11); }
196
+ button { min-height:50px; margin-top:3px; border:0; border-radius:9px; color:#fff; background:var(--brand); font:inherit; font-size:14px; font-weight:730; cursor:pointer; transition:background .16s,transform .16s; }
197
+ button:hover { background:var(--brand-dark); }
192
198
  button:active { transform:translateY(1px); }
193
- button:focus-visible { outline:3px solid rgba(8,127,115,.28); outline-offset:3px; }
199
+ button:focus-visible { outline:3px solid rgba(199,0,11,.24); outline-offset:3px; }
194
200
  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; }
201
+ .alert { margin-bottom:1px; border:1px solid #f3cbd0; border-left:4px solid var(--danger); border-radius:8px; padding:13px 14px; color:#771721; background:var(--danger-soft); font-size:12px; line-height:1.55; }
202
+ .alert-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:7px; }
203
+ .alert-head strong { color:#64121b; font-size:12px; font-weight:760; }
204
+ .alert-head code { border:1px solid #eab9bf; border-radius:5px; padding:2px 6px; color:#8b1824; background:#fff8f8; font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace; font-size:10px; font-weight:650; overflow-wrap:anywhere; }
205
+ .alert p { margin:0; }
206
+ .attempts { margin:-8px 0 0; color:var(--muted); text-align:center; font-size:11px; }
207
+ .privacy { display:flex; gap:11px; align-items:flex-start; margin:27px 0 0; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:11px; line-height:1.65; }
208
+ .shield { flex:0 0 auto; width:8px; height:8px; margin-top:5px; border-radius:50%; color:transparent; background:var(--brand); box-shadow:0 0 0 4px var(--brand-soft); }
199
209
  .result { min-height:280px; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; }
200
210
  .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); }
211
+ .result.success .result-mark { background:var(--success); }
202
212
  .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} }
213
+ @media (max-width:760px) { .shell{width:min(100% - 20px,560px);margin:18px auto}.panel{grid-template-columns:1fr;border-radius:17px}.context{padding:30px 27px}.content{padding:34px 27px 29px}.eyebrow{margin-bottom:25px}.rail-item{min-height:76px}.node:not(.last):after{height:63px}.retention{margin-top:24px}.content h1{font-size:31px} }
204
214
  @media (prefers-reduced-motion:reduce) { *,*:before,*:after{scroll-behavior:auto!important;transition:none!important} }
205
215
  </style>
206
216
  </head>
@@ -211,7 +221,7 @@ function page(input) {
211
221
  <aside class="context">
212
222
  <p class="eyebrow">${copy.target}</p>
213
223
  <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>
224
+ <div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.instance}</dt><dd>${escapeHtml(target?.instanceId ?? copy.configured)}</dd></div></div>
215
225
  <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
226
  <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
227
  </dl>
@@ -290,6 +300,16 @@ function isAllowedBrowserRequest(req, port) {
290
300
  return false;
291
301
  }
292
302
  }
303
+ function validationErrorCode(kind) {
304
+ switch (kind) {
305
+ case "credentials": return "DB_AUTH_FAILED";
306
+ case "unreachable": return "DB_ENDPOINT_UNREACHABLE";
307
+ case "refused": return "DB_CONNECTION_REFUSED";
308
+ case "tls": return "DB_TLS_FAILED";
309
+ case "timeout": return "DB_VALIDATION_TIMEOUT";
310
+ default: return "DB_CONNECTION_FAILED";
311
+ }
312
+ }
293
313
  export class LocalCredentialLoginService {
294
314
  now;
295
315
  tokenTtlMs;
@@ -362,7 +382,7 @@ export class LocalCredentialLoginService {
362
382
  if (token) {
363
383
  this.pending.delete(token);
364
384
  }
365
- respond(res, 410, page({ locale, state: "expired", target: pending?.target }));
385
+ respond(res, 200, page({ locale, state: "expired", target: pending?.target }));
366
386
  return;
367
387
  }
368
388
  if (req.method === "GET") {
@@ -383,7 +403,7 @@ export class LocalCredentialLoginService {
383
403
  return;
384
404
  }
385
405
  if (pending.inFlight) {
386
- respond(res, 409, page({
406
+ respond(res, 200, page({
387
407
  locale,
388
408
  state: "form",
389
409
  target: pending.target,
@@ -396,7 +416,7 @@ export class LocalCredentialLoginService {
396
416
  const username = form.get("username")?.trim() ?? "";
397
417
  const password = form.get("password") ?? "";
398
418
  if (!username || !password) {
399
- respond(res, 400, page({
419
+ respond(res, 200, page({
400
420
  locale,
401
421
  state: "form",
402
422
  target: pending.target,
@@ -418,15 +438,16 @@ export class LocalCredentialLoginService {
418
438
  await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
419
439
  if (pending.failedAttempts >= this.maxAttempts) {
420
440
  this.pending.delete(token);
421
- respond(res, 429, page({ locale, state: "locked", target: pending.target }));
441
+ respond(res, 200, page({ locale, state: "locked", target: pending.target }));
422
442
  return;
423
443
  }
424
444
  }
425
- respond(res, kind === "credentials" ? 401 : kind === "timeout" ? 504 : 502, page({
445
+ respond(res, 200, page({
426
446
  locale,
427
447
  state: "form",
428
448
  target: pending.target,
429
449
  message: COPY[locale][kind],
450
+ errorCode: validationErrorCode(kind),
430
451
  username,
431
452
  remainingAttempts: this.maxAttempts - pending.failedAttempts,
432
453
  }));
package/dist/server.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface ServerDeps {
20
20
  name: string;
21
21
  version: string;
22
22
  } | undefined;
23
+ endpointPreflight?: (host: string, port: number) => Promise<void>;
23
24
  pingResponse: string;
24
25
  }
25
26
  export declare function bootstrapDependencies(): Promise<ServerDeps>;
package/dist/server.js CHANGED
@@ -9,6 +9,7 @@ import { SessionCoordinator } from "./security/session-coordinator.js";
9
9
  import { SessionCredentialManager } from "./security/session-credential-manager.js";
10
10
  import { LocalRecoveryApprovalService, } from "./security/local-recovery-approval.js";
11
11
  import { BrowserOperatorSessionStore } from "./security/browser-operator-session.js";
12
+ import { preflightDatabaseEndpoint } from "./security/database-endpoint-preflight.js";
12
13
  export async function bootstrapDependencies() {
13
14
  const config = getConfig();
14
15
  const operatorSessions = new BrowserOperatorSessionStore();
@@ -42,6 +43,7 @@ export async function bootstrapDependencies() {
42
43
  auditWriter,
43
44
  sessionCoordinator,
44
45
  credentialSessions,
46
+ endpointPreflight: preflightDatabaseEndpoint,
45
47
  pingResponse: "pong",
46
48
  };
47
49
  }
@@ -1,5 +1,6 @@
1
1
  import { ConnectionPoolError, DatasourceResolutionError, FlashbackNoViewError, logger, QueryConcurrencyError, SchemaIntrospectionError, UnsupportedFeatureError, } from "taurusdb-core";
2
2
  import { ErrorCode, formatError, } from "../utils/formatter.js";
3
+ import { DatabaseEndpointPreflightError } from "../security/database-endpoint-preflight.js";
3
4
  export class ToolInputError extends Error {
4
5
  constructor(message) {
5
6
  super(message);
@@ -25,6 +26,33 @@ function detailsOf(error) {
25
26
  : undefined;
26
27
  }
27
28
  export function formatToolError(error, context) {
29
+ if (error instanceof DatabaseEndpointPreflightError) {
30
+ const refused = error.kind === "refused";
31
+ return formatError({
32
+ code: refused
33
+ ? ErrorCode.DB_CONNECTION_REFUSED
34
+ : ErrorCode.DB_ENDPOINT_UNREACHABLE,
35
+ message: refused
36
+ ? `The TaurusDB public endpoint ${error.host}:${error.port} is reachable, but the database port refused the connection. Verify the instance status and database port.`
37
+ : `The TaurusDB public endpoint ${error.host}:${error.port} is unreachable from this MCP client. In the instance security group's inbound rules, allow TCP port ${error.port} from the client's current public egress IP/32, then verify network ACL, VPN, and outbound firewall rules.`,
38
+ summary: refused
39
+ ? "The TaurusDB public database port refused the connection."
40
+ : "The TaurusDB public endpoint is unreachable from this MCP client.",
41
+ metadata: context.metadata,
42
+ retryable: true,
43
+ details: {
44
+ endpoint: `${error.host}:${error.port}`,
45
+ likely_causes: refused
46
+ ? ["incorrect_database_port", "database_service_unavailable"]
47
+ : [
48
+ "security_group_inbound_rule",
49
+ "network_acl",
50
+ "vpn_or_proxy_egress_change",
51
+ "client_outbound_firewall",
52
+ ],
53
+ },
54
+ });
55
+ }
28
56
  if (error instanceof ToolInputError) {
29
57
  return formatError({
30
58
  code: ErrorCode.INVALID_INPUT,
@@ -322,13 +322,14 @@ export const selectCloudTaurusInstanceTool = {
322
322
  }
323
323
  const boundDatasource = await resolveBindingDatasource(deps, typeof input.datasource === "string" ? input.datasource : undefined);
324
324
  const selectedHost = selectInstanceAddress(matched);
325
- const selectedPort = normalizePort(matched.port);
325
+ const selectedPort = normalizePort(matched.port) ?? 3306;
326
326
  if (!boundDatasource) {
327
327
  throw new ToolInputError("No datasource template is available for SQL login. Configure TAURUSDB_DEFAULT_DATASOURCE or pass datasource explicitly.");
328
328
  }
329
329
  if (!selectedHost) {
330
330
  throw new ToolInputError(`Cloud instance ${matched.id} does not have a public database IP. Enable a read/write public IP and restrict port ${selectedPort ?? 3306} to the MCP client's egress IP before using local SQL login.`);
331
331
  }
332
+ await deps.endpointPreflight?.(selectedHost, selectedPort);
332
333
  await updateSessionState(deps, (nextConfig) => {
333
334
  nextConfig.cloud.projectId = projectId;
334
335
  nextConfig.cloud.instanceId = matched.id;
@@ -9,8 +9,7 @@ const AUTH_ERROR_CODES = new Set([
9
9
  "ER_ACCESS_DENIED_NO_PASSWORD_ERROR",
10
10
  "ER_DBACCESS_DENIED_ERROR",
11
11
  ]);
12
- const TIMEOUT_ERROR_CODES = new Set([
13
- "ETIMEDOUT",
12
+ const VALIDATION_TIMEOUT_ERROR_CODES = new Set([
14
13
  "PROTOCOL_SEQUENCE_TIMEOUT",
15
14
  "POOL_QUEUE_TIMEOUT",
16
15
  ]);
@@ -33,10 +32,16 @@ function classifyValidationFailure(error) {
33
32
  if (codes.some((code) => AUTH_ERROR_CODES.has(code))) {
34
33
  return "credentials";
35
34
  }
36
- if (codes.some((code) => TIMEOUT_ERROR_CODES.has(code))) {
35
+ if (codes.includes("ECONNREFUSED")) {
36
+ return "refused";
37
+ }
38
+ if (codes.includes("ETIMEDOUT") || codes.includes("EHOSTUNREACH") || codes.includes("ENETUNREACH")) {
39
+ return "unreachable";
40
+ }
41
+ if (codes.some((code) => VALIDATION_TIMEOUT_ERROR_CODES.has(code))) {
37
42
  return "timeout";
38
43
  }
39
- if (codes.some((code) => code.includes("TLS") || code.includes("CERT") || code.includes("SIGNATURE"))) {
44
+ if (codes.some((code) => code.includes("TLS") || code.includes("SSL") || code.includes("CERT") || code.includes("SIGNATURE"))) {
40
45
  return "tls";
41
46
  }
42
47
  return "connectivity";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taurusdb-mcp",
3
- "version": "0.5.0-rc.7",
3
+ "version": "0.5.0-rc.9",
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.7",
47
+ "taurusdb-core": "0.5.0-rc.9",
48
48
  "@modelcontextprotocol/sdk": "^1.17.0",
49
49
  "mysql2": "^3.22.1",
50
50
  "zod": "^3.24.1"