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 +3 -0
- package/dist/security/database-endpoint-preflight.d.ts +8 -0
- package/dist/security/database-endpoint-preflight.js +33 -0
- package/dist/security/local-credential-login.d.ts +1 -1
- package/dist/security/local-credential-login.js +79 -58
- package/dist/server.d.ts +1 -0
- package/dist/server.js +2 -0
- package/dist/tools/error-handling.js +28 -0
- package/dist/tools/taurus/cloud-context.js +2 -1
- package/dist/tools/taurus/sql-login.js +9 -4
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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('"', """)
|
|
102
108
|
.replaceAll("'", "'");
|
|
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"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
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:
|
|
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:
|
|
166
|
-
.shell { width:min(
|
|
167
|
-
.masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:
|
|
168
|
-
.brand { font-
|
|
169
|
-
.session { color:var(--
|
|
170
|
-
.
|
|
171
|
-
.
|
|
172
|
-
.context
|
|
173
|
-
.eyebrow { margin:0 0
|
|
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:
|
|
176
|
-
.rail
|
|
177
|
-
.
|
|
178
|
-
.rail
|
|
179
|
-
.
|
|
180
|
-
.
|
|
181
|
-
.
|
|
182
|
-
.
|
|
183
|
-
.
|
|
184
|
-
.content
|
|
185
|
-
.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
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:
|
|
196
|
-
.
|
|
197
|
-
.
|
|
198
|
-
.
|
|
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(--
|
|
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:
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
441
|
+
respond(res, 200, page({ locale, state: "locked", target: pending.target }));
|
|
422
442
|
return;
|
|
423
443
|
}
|
|
424
444
|
}
|
|
425
|
-
respond(res,
|
|
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
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
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|