taurusdb-mcp 0.5.0-rc.10 → 0.5.0-rc.2

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@latest
13
+ npm install taurusdb-mcp
14
14
  ```
15
15
 
16
16
  Run directly with:
@@ -45,6 +45,10 @@ claude mcp add huaweicloud-taurusdb \
45
45
  --transport stdio \
46
46
  -e TAURUSDB_CLOUD_REGION=<your-region> \
47
47
  -e TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
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' \
48
52
  -- npx -y taurusdb-mcp
49
53
  ```
50
54
 
@@ -54,19 +58,13 @@ Codex:
54
58
  codex mcp add huaweicloud-taurusdb \
55
59
  --env TAURUSDB_CLOUD_REGION=<your-region> \
56
60
  --env TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
61
+ --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' \
57
65
  -- npx -y taurusdb-mcp
58
66
  ```
59
67
 
60
- Interactive connection flow:
61
-
62
- 1. Call `list_cloud_taurus_instances`.
63
- 2. Call `select_cloud_taurus_instance`.
64
- 3. Open the returned `login_url` in a browser and enter the database credentials.
65
- 4. Continue with `list_databases`, `set_default_database`, and readonly tools.
66
-
67
- The browser submits credentials directly to the loopback MCP process. They are not
68
- sent through Agent-visible tool arguments or persisted by MCP.
69
-
70
68
  Initialize local MCP client config:
71
69
 
72
70
  ```bash
@@ -86,25 +84,32 @@ npx taurusdb-mcp credentials check
86
84
 
87
85
  - Requires Node.js `>= 20`
88
86
  - Depends on `taurusdb-core`
89
- - Interactive clients do not need database usernames or passwords in MCP configuration
90
- - Unattended static deployments may use `env:`, `file:`, `hw-csms:`, `hw-kms:`, or `hw-kms-file:` password references
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
89
  - macOS Keychain, Linux Secret Service, or Windows Credential Manager cloud identity is enabled with `TAURUSDB_CLOUD_KEYCHAIN_SERVICE`
92
90
  - SQL TLS with certificate verification is required by default
93
- - General database mutation tools do not exist; the Agent-facing operating plane remains read-only regardless of database account privileges
94
- - `restore_recycle_bin_table` is visible by default and directly restores one exact recycle-bin object to one explicit non-existing destination after readonly preflight; it uses the active in-memory SQL session, post-verifies the destination, and writes audit evidence without a browser approval step
95
- - `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
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
- - 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
101
- - Instance selection and local login tools are enabled by default; fixed static deployments may set `TAURUSDB_ENABLE_DYNAMIC_TARGETS=false`
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`
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
103
94
  - MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
104
95
  configurable size rotation for collection into centralized immutable storage
105
96
  - Run one stdio process per customer/client trust boundary; this package is not
106
97
  a shared multi-tenant HTTP service
107
98
 
108
- Customers execute reviewed general-purpose `advised_sql` through their own controlled
109
- change process. The only MCP database-state exception is the target-bound,
110
- same-browser-confirmed recycle-bin recovery described above.
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
+ ```
@@ -3,27 +3,14 @@ export type SqlLoginCredentials = {
3
3
  username: string;
4
4
  password: string;
5
5
  };
6
- export type SqlLoginTarget = {
7
- datasource: string;
8
- instanceId?: string;
9
- region?: string;
10
- credentialIdleTtlMinutes?: number;
11
- credentialMaxTtlMinutes?: number;
12
- };
13
6
  export type SqlLoginRequest = {
14
7
  datasource: string;
15
- target?: SqlLoginTarget;
16
8
  bind: (credentials: SqlLoginCredentials) => Promise<void>;
17
9
  };
18
10
  export type IssuedSqlLogin = {
19
11
  loginUrl: string;
20
12
  expiresAt: string;
21
13
  };
22
- export type CredentialValidationFailure = "credentials" | "unreachable" | "refused" | "connectivity" | "tls" | "timeout";
23
- export declare class SqlCredentialValidationError extends Error {
24
- readonly kind: CredentialValidationFailure;
25
- constructor(kind: CredentialValidationFailure);
26
- }
27
14
  export interface CredentialLoginService {
28
15
  issueSqlLogin(request: SqlLoginRequest): Promise<IssuedSqlLogin>;
29
16
  close(): Promise<void>;
@@ -31,14 +18,10 @@ export interface CredentialLoginService {
31
18
  export type LocalCredentialLoginServiceOptions = {
32
19
  now?: () => number;
33
20
  tokenTtlMs?: number;
34
- maxAttempts?: number;
35
- failureDelayMs?: number;
36
21
  };
37
22
  export declare class LocalCredentialLoginService implements CredentialLoginService {
38
23
  private readonly now;
39
24
  private readonly tokenTtlMs;
40
- private readonly maxAttempts;
41
- private readonly failureDelayMs;
42
25
  private readonly pending;
43
26
  private server;
44
27
  private port;
@@ -1,253 +1,42 @@
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;
6
4
  const MAX_REQUEST_BODY_BYTES = 16 * 1024;
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
- title: "连接数据库",
19
- intro: "输入数据库账号和密码以验证连接。",
20
- target: "连接目标",
21
- instance: "实例",
22
- region: "区域",
23
- datasource: "数据源",
24
- configured: "已配置的数据源",
25
- unknown: "未指定",
26
- username: "数据库账号",
27
- password: "数据库密码",
28
- usernamePlaceholder: "请输入数据库账号",
29
- passwordPlaceholder: "请输入数据库密码",
30
- submit: "连接数据库",
31
- submitting: "正在验证连接…",
32
- privacy: "凭据对 Agent 不可见,仅用于连接您选择的数据库,不会由 MCP 持久化保存。",
33
- retention: (idle, maximum) => `空闲 ${idle} 分钟后清除 · 最长保留 ${maximum >= 60 ? `${maximum / 60} 小时` : `${maximum} 分钟`}`,
34
- attempts: (remaining) => `本链接还可尝试 ${remaining} 次`,
35
- diagnostic: "连接检查",
36
- required: "请输入数据库账号和密码。",
37
- credentials: "无法验证账号信息,请检查账号和密码。",
38
- unreachable: "无法访问数据库公网地址。请检查实例安全组入方向规则是否已向当前公网出口 IP 放通数据库端口,并检查网络 ACL、VPN 和本机出口防火墙。",
39
- refused: "数据库公网地址可以访问,但端口拒绝连接。请检查实例状态和数据库端口。",
40
- connectivity: "数据库连接失败,请检查公网地址、实例状态和网络配置。",
41
- tls: "TLS 安全连接验证失败,请联系管理员检查证书配置。",
42
- timeout: "数据库端口已连接,但验证未在限定时间内完成。请检查实例负载、连接数和数据库状态。",
43
- busy: "连接正在验证,请勿重复提交。",
44
- successTitle: "账号验证成功",
45
- successMessage: "您现在可以返回 MCP 会话并选择需要访问的数据库。",
46
- expiredTitle: "登录链接已失效",
47
- expiredMessage: "请返回 MCP 会话并重新调用 begin_sql_login。",
48
- lockedTitle: "尝试次数已用完",
49
- lockedMessage: "为保护数据库账号,此链接已失效。请返回 MCP 会话重新生成登录链接。",
50
- methodTitle: "不支持此请求",
51
- methodMessage: "请使用登录表单继续。",
52
- invalidTitle: "无法处理请求",
53
- invalidMessage: "登录请求无效,请重新生成登录链接。",
54
- },
55
- en: {
56
- product: "TaurusDB MCP",
57
- title: "Connect to database",
58
- intro: "Enter your database credentials to validate the connection.",
59
- target: "Connection target",
60
- instance: "Instance",
61
- region: "Region",
62
- datasource: "Datasource",
63
- configured: "Configured datasource",
64
- unknown: "Not specified",
65
- username: "Database username",
66
- password: "Database password",
67
- usernamePlaceholder: "Enter database username",
68
- passwordPlaceholder: "Enter database password",
69
- submit: "Connect to database",
70
- submitting: "Validating connection…",
71
- privacy: "Credentials are not visible to the Agent. They are used only to connect to your selected database and are not persisted by MCP.",
72
- retention: (idle, maximum) => `Cleared after ${idle} idle minutes · ${maximum >= 60 ? `${maximum / 60} ${maximum === 60 ? "hour" : "hours"}` : `${maximum} ${maximum === 1 ? "minute" : "minutes"}`} maximum`,
73
- attempts: (remaining) => `${remaining} attempts remaining for this link`,
74
- diagnostic: "Connection check",
75
- required: "Enter both the database username and password.",
76
- credentials: "The account could not be validated. Check the username and password.",
77
- 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.",
78
- refused: "The public database endpoint is reachable, but the port refused the connection. Check the instance status and database port.",
79
- connectivity: "The database connection failed. Check the public endpoint, instance status, and network configuration.",
80
- tls: "The TLS connection could not be validated. Ask an administrator to check the certificate configuration.",
81
- timeout: "The database port connected, but validation did not finish in time. Check instance load, connection capacity, and database status.",
82
- busy: "Connection validation is already in progress. Do not submit again.",
83
- successTitle: "Account validated",
84
- successMessage: "Return to your MCP session and select the database you want to access.",
85
- expiredTitle: "Login link expired",
86
- expiredMessage: "Return to your MCP session and call begin_sql_login again.",
87
- lockedTitle: "Attempt limit reached",
88
- lockedMessage: "This link has expired to protect the database account. Return to your MCP session and create a new login link.",
89
- methodTitle: "Unsupported request",
90
- methodMessage: "Use the login form to continue.",
91
- invalidTitle: "Request could not be processed",
92
- invalidMessage: "The login request is invalid. Create a new login link.",
93
- },
94
- };
95
- function localeFromRequest(req) {
96
- return /(?:^|,)\s*zh(?:-|_|;|,|$)/i.test(req.headers["accept-language"] ?? "")
97
- ? "zh-CN"
98
- : "en";
99
- }
100
- function escapeHtml(value) {
101
- return value
102
- .replaceAll("&", "&amp;")
103
- .replaceAll("<", "&lt;")
104
- .replaceAll(">", "&gt;")
105
- .replaceAll('"', "&quot;")
106
- .replaceAll("'", "&#39;");
107
- }
108
- function page(input) {
109
- const copy = COPY[input.locale];
110
- const isForm = input.state === "form";
111
- const title = isForm
112
- ? copy.title
113
- : input.state === "success"
114
- ? copy.successTitle
115
- : input.state === "expired"
116
- ? copy.expiredTitle
117
- : input.state === "locked"
118
- ? copy.lockedTitle
119
- : input.state === "method"
120
- ? copy.methodTitle
121
- : copy.invalidTitle;
122
- const message = input.message ?? (isForm
123
- ? copy.intro
124
- : input.state === "success"
125
- ? copy.successMessage
126
- : input.state === "expired"
127
- ? copy.expiredMessage
128
- : input.state === "locked"
129
- ? copy.lockedMessage
130
- : input.state === "method"
131
- ? copy.methodMessage
132
- : copy.invalidMessage);
133
- const nonce = randomBytes(18).toString("base64url");
134
- const target = input.target;
135
- const formHtml = isForm
136
- ? `<form method="post" autocomplete="on" data-login-form>
137
- ${input.message ? `<div class="alert" role="alert">
138
- <div class="alert-head"><strong>${copy.diagnostic}</strong>${input.errorCode ? `<code>${escapeHtml(input.errorCode)}</code>` : ""}</div>
139
- <p>${escapeHtml(input.message)}</p>
140
- </div>` : ""}
141
- <div class="field">
142
- <label for="username">${copy.username}</label>
143
- <input id="username" name="username" autocomplete="username" maxlength="256" value="${escapeHtml(input.username ?? "")}" placeholder="${copy.usernamePlaceholder}" required autofocus>
144
- </div>
145
- <div class="field">
146
- <label for="password">${copy.password}</label>
147
- <input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="${copy.passwordPlaceholder}" required>
148
- </div>
149
- <button type="submit" data-submit data-pending="${copy.submitting}">${copy.submit}</button>
150
- ${input.remainingAttempts !== undefined ? `<p class="attempts">${copy.attempts(input.remainingAttempts)}</p>` : ""}
151
- </form>`
152
- : `<section class="result ${input.state}" aria-live="polite">
153
- <span class="result-mark" aria-hidden="true">${input.state === "success" ? "✓" : "!"}</span>
154
- <h1>${escapeHtml(title)}</h1>
155
- <p>${escapeHtml(message)}</p>
156
- </section>`;
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
+ : "";
157
13
  return `<!doctype html>
158
- <html lang="${input.locale}">
14
+ <html lang="en">
159
15
  <head>
160
16
  <meta charset="utf-8">
161
17
  <meta name="viewport" content="width=device-width, initial-scale=1">
162
- <meta name="color-scheme" content="light">
163
- <title>${escapeHtml(title)} · ${copy.product}</title>
18
+ <title>${title}</title>
164
19
  <style>
165
- :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); }
166
- * { box-sizing:border-box; }
167
- 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; }
168
- .shell { width:min(1000px,calc(100% - 32px)); margin:clamp(24px,7vh,72px) auto; }
169
- .masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; font-size:13px; letter-spacing:.035em; }
170
- .brand { font-size:15px; font-weight:750; letter-spacing:-.015em; }
171
- .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); }
172
- .context { padding:46px 42px 40px; color:#f8f9fb; background:linear-gradient(155deg,var(--ink-soft),var(--ink)); }
173
- .eyebrow { margin:0 0 34px; color:#f3aeb3; font-size:11px; font-weight:760; letter-spacing:.14em; text-transform:uppercase; }
174
- .rail { margin:0; }
175
- .rail-item { position:relative; display:grid; grid-template-columns:12px minmax(0,1fr); column-gap:17px; min-height:86px; }
176
- .rail-item:last-child { min-height:auto; }
177
- .target-row { grid-column:2; min-width:0; padding:0 0 25px; border-bottom:1px solid rgba(255,255,255,.1); }
178
- .rail-item:last-child .target-row { padding-bottom:0; border-bottom:0; }
179
- .rail dt { margin:0 0 7px; color:#aebbd0; font-size:11px; font-weight:650; letter-spacing:.04em; line-height:1.2; }
180
- .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; }
181
- .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); }
182
- .node:not(.last):after { content:""; position:absolute; left:2px; top:11px; width:2px; height:73px; background:linear-gradient(#d53a44,rgba(213,58,68,.16)); }
183
- .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; }
184
- .content { padding:48px 52px 38px; }
185
- .content h1 { margin:0 0 11px; font-size:clamp(30px,4vw,40px); font-weight:760; line-height:1.08; letter-spacing:-.04em; }
186
- .intro { margin:0 0 31px; color:var(--muted); font-size:14px; line-height:1.65; }
187
- form { display:grid; gap:18px; }
188
- .field { display:grid; gap:8px; }
189
- label { font-size:12px; font-weight:730; letter-spacing:.01em; }
190
- 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; }
191
- input:focus { border-color:var(--brand); background:#fff; box-shadow:0 0 0 4px rgba(199,0,11,.11); }
192
- 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; }
193
- button:hover { background:var(--brand-dark); }
194
- button:active { transform:translateY(1px); }
195
- button:focus-visible { outline:3px solid rgba(199,0,11,.24); outline-offset:3px; }
196
- button:disabled { cursor:wait; opacity:.72; }
197
- .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; }
198
- .alert-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:7px; }
199
- .alert-head strong { color:#64121b; font-size:12px; font-weight:760; }
200
- .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; }
201
- .alert p { margin:0; }
202
- .attempts { margin:-8px 0 0; color:var(--muted); text-align:center; font-size:11px; }
203
- .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; }
204
- .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); }
205
- .result { min-height:280px; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; }
206
- .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; }
207
- .result.success .result-mark { background:var(--success); }
208
- .result p { max-width:35rem; color:var(--muted); line-height:1.7; }
209
- @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} }
210
- @media (prefers-reduced-motion:reduce) { *,*:before,*:after{scroll-behavior:auto!important;transition:none!important} }
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; }
211
24
  </style>
212
25
  </head>
213
26
  <body>
214
- <main class="shell">
215
- <header class="masthead"><span class="brand">${copy.product}</span></header>
216
- <section class="panel">
217
- <aside class="context">
218
- <p class="eyebrow">${copy.target}</p>
219
- <dl class="rail">
220
- <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>
221
- <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>
222
- <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>
223
- </dl>
224
- ${target?.credentialIdleTtlMinutes && target.credentialMaxTtlMinutes ? `<p class="retention">${copy.retention(target.credentialIdleTtlMinutes, target.credentialMaxTtlMinutes)}</p>` : ""}
225
- </aside>
226
- <div class="content">
227
- ${isForm ? `<h1>${copy.title}</h1><p class="intro">${copy.intro}</p>` : ""}
228
- ${formHtml}
229
- ${isForm ? `<p class="privacy"><span class="shield" aria-hidden="true">◆</span><span>${copy.privacy}</span></p>` : ""}
230
- </div>
231
- </section>
232
- </main>
233
- ${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>` : ""}
27
+ <h1>${title}</h1>
28
+ <p>${message}</p>
29
+ ${formHtml}
234
30
  </body>
235
31
  </html>`;
236
32
  }
237
33
  function respond(res, status, body) {
238
- const nonce = /<script nonce="([^"]+)"/.exec(body)?.[1];
239
34
  res.writeHead(status, {
240
35
  "cache-control": "no-store",
241
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${nonce ? `; script-src 'nonce-${nonce}'` : ""}`,
36
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
242
37
  "content-type": "text/html; charset=utf-8",
243
- "cross-origin-opener-policy": "same-origin",
244
- "cross-origin-resource-policy": "same-origin",
245
- expires: "0",
246
- "permissions-policy": "camera=(), microphone=(), geolocation=()",
247
- pragma: "no-cache",
248
38
  "referrer-policy": "no-referrer",
249
39
  "x-content-type-options": "nosniff",
250
- "x-frame-options": "DENY",
251
40
  });
252
41
  res.end(body);
253
42
  }
@@ -264,67 +53,21 @@ async function readBody(req) {
264
53
  }
265
54
  return Buffer.concat(chunks).toString("utf8");
266
55
  }
267
- function delay(ms) {
268
- return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
269
- }
270
- function isLoopbackHostname(hostname) {
271
- const normalized = hostname.toLowerCase();
272
- return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "[::1]";
273
- }
274
- function isAllowedBrowserRequest(req, port) {
275
- if (!port)
276
- return false;
277
- try {
278
- const requestUrl = new URL(`http://${req.headers.host ?? ""}`);
279
- if (!isLoopbackHostname(requestUrl.hostname) || requestUrl.port !== String(port)) {
280
- return false;
281
- }
282
- }
283
- catch {
284
- return false;
285
- }
286
- const origin = req.headers.origin;
287
- if (!origin || origin === "null")
288
- return true;
289
- try {
290
- const originUrl = new URL(origin);
291
- return originUrl.protocol === "http:"
292
- && isLoopbackHostname(originUrl.hostname)
293
- && originUrl.port === String(port);
294
- }
295
- catch {
296
- return false;
297
- }
298
- }
299
- function validationErrorCode(kind) {
300
- switch (kind) {
301
- case "credentials": return "DB_AUTH_FAILED";
302
- case "unreachable": return "DB_ENDPOINT_UNREACHABLE";
303
- case "refused": return "DB_CONNECTION_REFUSED";
304
- case "tls": return "DB_TLS_FAILED";
305
- case "timeout": return "DB_VALIDATION_TIMEOUT";
306
- default: return "DB_CONNECTION_FAILED";
307
- }
308
- }
309
56
  export class LocalCredentialLoginService {
310
57
  now;
311
58
  tokenTtlMs;
312
- maxAttempts;
313
- failureDelayMs;
314
59
  pending = new Map();
315
60
  server;
316
61
  port;
317
62
  constructor(options = {}) {
318
63
  this.now = options.now ?? Date.now;
319
64
  this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
320
- this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
321
- this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
322
65
  }
323
66
  async issueSqlLogin(request) {
324
67
  await this.ensureStarted();
325
68
  const token = randomBytes(32).toString("base64url");
326
69
  const expiresAtMs = this.now() + this.tokenTtlMs;
327
- this.pending.set(token, { ...request, expiresAtMs, failedAttempts: 0, inFlight: false });
70
+ this.pending.set(token, { ...request, expiresAtMs });
328
71
  return {
329
72
  loginUrl: `http://127.0.0.1:${this.port}/sql-login/${token}`,
330
73
  expiresAt: new Date(expiresAtMs).toISOString(),
@@ -365,7 +108,6 @@ export class LocalCredentialLoginService {
365
108
  this.port = address.port;
366
109
  }
367
110
  async handle(req, res) {
368
- const locale = localeFromRequest(req);
369
111
  try {
370
112
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
371
113
  const match = url.pathname.match(/^\/sql-login\/([^/]+)$/);
@@ -375,82 +117,40 @@ export class LocalCredentialLoginService {
375
117
  if (token) {
376
118
  this.pending.delete(token);
377
119
  }
378
- respond(res, 200, page({ locale, state: "expired", target: pending?.target }));
120
+ respond(res, 410, page("Invalid login link", "This login link is invalid or has expired."));
379
121
  return;
380
122
  }
381
123
  if (req.method === "GET") {
382
- respond(res, 200, page({
383
- locale,
384
- state: "form",
385
- target: pending.target,
386
- remainingAttempts: this.maxAttempts - pending.failedAttempts,
387
- }));
124
+ respond(res, 200, page("Connect to TaurusDB", "Enter your database credentials.", true));
388
125
  return;
389
126
  }
390
127
  if (req.method !== "POST") {
391
- respond(res, 405, page({ locale, state: "method", target: pending.target }));
392
- return;
393
- }
394
- if (!isAllowedBrowserRequest(req, this.port)) {
395
- respond(res, 403, page({ locale, state: "invalid", target: pending.target }));
396
- return;
397
- }
398
- if (pending.inFlight) {
399
- respond(res, 200, page({
400
- locale,
401
- state: "form",
402
- target: pending.target,
403
- message: COPY[locale].busy,
404
- remainingAttempts: this.maxAttempts - pending.failedAttempts,
405
- }));
128
+ respond(res, 405, page("Unsupported request", "Use the login form to continue."));
406
129
  return;
407
130
  }
408
131
  const form = new URLSearchParams(await readBody(req));
409
132
  const username = form.get("username")?.trim() ?? "";
410
133
  const password = form.get("password") ?? "";
411
134
  if (!username || !password) {
412
- respond(res, 200, page({
413
- locale,
414
- state: "form",
415
- target: pending.target,
416
- message: COPY[locale].required,
417
- username,
418
- remainingAttempts: this.maxAttempts - pending.failedAttempts,
419
- }));
135
+ respond(res, 400, page("Connect to TaurusDB", "Username and password are required.", true));
420
136
  return;
421
137
  }
138
+ this.pending.delete(token);
422
139
  try {
423
- pending.inFlight = true;
424
- await pending.bind({ datasource: pending.datasource, username, password });
425
- }
426
- catch (error) {
427
- pending.inFlight = false;
428
- const kind = error instanceof SqlCredentialValidationError ? error.kind : "connectivity";
429
- if (kind === "credentials") {
430
- pending.failedAttempts += 1;
431
- await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
432
- if (pending.failedAttempts >= this.maxAttempts) {
433
- this.pending.delete(token);
434
- respond(res, 200, page({ locale, state: "locked", target: pending.target }));
435
- return;
436
- }
437
- }
438
- respond(res, 200, page({
439
- locale,
440
- state: "form",
441
- target: pending.target,
442
- message: COPY[locale][kind],
443
- errorCode: validationErrorCode(kind),
140
+ await pending.bind({
141
+ datasource: pending.datasource,
444
142
  username,
445
- remainingAttempts: this.maxAttempts - pending.failedAttempts,
446
- }));
143
+ password,
144
+ });
145
+ }
146
+ catch {
147
+ respond(res, 500, page("Connection failed", "Credentials could not be bound to this session."));
447
148
  return;
448
149
  }
449
- this.pending.delete(token);
450
- respond(res, 200, page({ locale, state: "success", target: pending.target }));
150
+ respond(res, 200, page("Connected", "Database credentials are now active for this MCP session."));
451
151
  }
452
152
  catch {
453
- respond(res, 400, page({ locale, state: "invalid" }));
153
+ respond(res, 400, page("Invalid request", "The login request could not be processed."));
454
154
  }
455
155
  }
456
156
  }
package/dist/server.d.ts CHANGED
@@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { TaurusDBEngine, type Config, type AuditWriter, type RuntimeTargetProfileLoader } from "taurusdb-core";
3
3
  import { type CredentialLoginService } from "./security/local-credential-login.js";
4
4
  import { SessionCoordinator } from "./security/session-coordinator.js";
5
- import { SessionCredentialManager } from "./security/session-credential-manager.js";
6
5
  export interface ServerDeps {
7
6
  config: Config;
8
7
  profileLoader: RuntimeTargetProfileLoader;
@@ -10,13 +9,10 @@ export interface ServerDeps {
10
9
  credentialLogin: CredentialLoginService;
11
10
  auditWriter?: AuditWriter;
12
11
  sessionCoordinator?: SessionCoordinator;
13
- credentialSessions?: SessionCredentialManager;
14
- sqlCredentialValidator?: (engine: TaurusDBEngine, datasource: string, taskId: string) => Promise<void>;
15
12
  clientIdentityProvider?: () => {
16
13
  name: string;
17
14
  version: string;
18
15
  } | undefined;
19
- endpointPreflight?: (host: string, port: number) => Promise<void>;
20
16
  pingResponse: string;
21
17
  }
22
18
  export declare function bootstrapDependencies(): Promise<ServerDeps>;
package/dist/server.js CHANGED
@@ -6,8 +6,6 @@ import { logger } from "taurusdb-core";
6
6
  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
- import { SessionCredentialManager } from "./security/session-credential-manager.js";
10
- import { preflightDatabaseEndpoint } from "./security/database-endpoint-preflight.js";
11
9
  export async function bootstrapDependencies() {
12
10
  const config = getConfig();
13
11
  const profileLoader = new RuntimeOverrideProfileLoader(createSqlProfileLoader({ config }));
@@ -17,23 +15,13 @@ export async function bootstrapDependencies() {
17
15
  maxBytes: config.audit.maxBytes,
18
16
  maxFiles: config.audit.maxFiles,
19
17
  });
20
- const sessionCoordinator = new SessionCoordinator();
21
- const credentialSessions = new SessionCredentialManager({
22
- idleTtlMs: config.security.credentialIdleTtlMinutes * 60_000,
23
- maxTtlMs: config.security.credentialMaxTtlMinutes * 60_000,
24
- onExpirationError: (error, datasource) => {
25
- logger.error({ err: error, datasource }, "Failed to expire SQL credential session");
26
- },
27
- });
28
18
  return {
29
19
  config,
30
20
  profileLoader,
31
21
  engine,
32
22
  credentialLogin: new LocalCredentialLoginService(),
33
23
  auditWriter,
34
- sessionCoordinator,
35
- credentialSessions,
36
- endpointPreflight: preflightDatabaseEndpoint,
24
+ sessionCoordinator: new SessionCoordinator(),
37
25
  pingResponse: "pong",
38
26
  };
39
27
  }
@@ -47,7 +35,6 @@ export function createServer(deps) {
47
35
  const cleanup = () => {
48
36
  cleanupPromise ??= Promise.all([
49
37
  deps.credentialLogin.close(),
50
- deps.credentialSessions?.close() ?? Promise.resolve(),
51
38
  deps.engine.close(),
52
39
  deps.auditWriter?.close() ?? Promise.resolve(),
53
40
  ]).then(() => undefined);