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

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
@@ -88,28 +88,14 @@ npx taurusdb-mcp credentials check
88
88
  - Huawei DEW CSMS is recommended when the database password should be stored and retrieved from Huawei Cloud
89
89
  - macOS Keychain, Linux Secret Service, or Windows Credential Manager cloud identity is enabled with `TAURUSDB_CLOUD_KEYCHAIN_SERVICE`
90
90
  - 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
91
+ - Database mutation tools do not exist; the MCP remains read-only regardless of database account privileges
92
+ - `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
93
+ - Local SQL login validates the connection before binding credentials, allows at most three attempts per five-minute link, and never sends the password through Agent-visible tool arguments
94
+ - 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
95
  - MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
95
96
  configurable size rotation for collection into centralized immutable storage
96
97
  - Run one stdio process per customer/client trust boundary; this package is not
97
98
  a shared multi-tenant HTTP service
98
99
 
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
- ```
100
+ Customers execute reviewed `advised_sql` through their own controlled change process;
101
+ the MCP never executes database state changes.
@@ -3,14 +3,27 @@ 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
+ };
6
13
  export type SqlLoginRequest = {
7
14
  datasource: string;
15
+ target?: SqlLoginTarget;
8
16
  bind: (credentials: SqlLoginCredentials) => Promise<void>;
9
17
  };
10
18
  export type IssuedSqlLogin = {
11
19
  loginUrl: string;
12
20
  expiresAt: string;
13
21
  };
22
+ export type CredentialValidationFailure = "credentials" | "connectivity" | "tls" | "timeout";
23
+ export declare class SqlCredentialValidationError extends Error {
24
+ readonly kind: CredentialValidationFailure;
25
+ constructor(kind: CredentialValidationFailure);
26
+ }
14
27
  export interface CredentialLoginService {
15
28
  issueSqlLogin(request: SqlLoginRequest): Promise<IssuedSqlLogin>;
16
29
  close(): Promise<void>;
@@ -18,10 +31,14 @@ export interface CredentialLoginService {
18
31
  export type LocalCredentialLoginServiceOptions = {
19
32
  now?: () => number;
20
33
  tokenTtlMs?: number;
34
+ maxAttempts?: number;
35
+ failureDelayMs?: number;
21
36
  };
22
37
  export declare class LocalCredentialLoginService implements CredentialLoginService {
23
38
  private readonly now;
24
39
  private readonly tokenTtlMs;
40
+ private readonly maxAttempts;
41
+ private readonly failureDelayMs;
25
42
  private readonly pending;
26
43
  private server;
27
44
  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,28 @@ 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;
59
269
  pending = new Map();
60
270
  server;
61
271
  port;
62
272
  constructor(options = {}) {
63
273
  this.now = options.now ?? Date.now;
64
274
  this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
275
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
276
+ this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
65
277
  }
66
278
  async issueSqlLogin(request) {
67
279
  await this.ensureStarted();
68
280
  const token = randomBytes(32).toString("base64url");
69
281
  const expiresAtMs = this.now() + this.tokenTtlMs;
70
- this.pending.set(token, { ...request, expiresAtMs });
282
+ this.pending.set(token, { ...request, expiresAtMs, failedAttempts: 0, inFlight: false });
71
283
  return {
72
284
  loginUrl: `http://127.0.0.1:${this.port}/sql-login/${token}`,
73
285
  expiresAt: new Date(expiresAtMs).toISOString(),
@@ -108,6 +320,7 @@ export class LocalCredentialLoginService {
108
320
  this.port = address.port;
109
321
  }
110
322
  async handle(req, res) {
323
+ const locale = localeFromRequest(req);
111
324
  try {
112
325
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
113
326
  const match = url.pathname.match(/^\/sql-login\/([^/]+)$/);
@@ -117,40 +330,82 @@ export class LocalCredentialLoginService {
117
330
  if (token) {
118
331
  this.pending.delete(token);
119
332
  }
120
- respond(res, 410, page("Invalid login link", "This login link is invalid or has expired."));
333
+ respond(res, 410, page({ locale, state: "expired", target: pending?.target }));
121
334
  return;
122
335
  }
123
336
  if (req.method === "GET") {
124
- respond(res, 200, page("Connect to TaurusDB", "Enter your database credentials.", true));
337
+ respond(res, 200, page({
338
+ locale,
339
+ state: "form",
340
+ target: pending.target,
341
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
342
+ }));
125
343
  return;
126
344
  }
127
345
  if (req.method !== "POST") {
128
- respond(res, 405, page("Unsupported request", "Use the login form to continue."));
346
+ respond(res, 405, page({ locale, state: "method", target: pending.target }));
347
+ return;
348
+ }
349
+ const origin = req.headers.origin;
350
+ if (origin && origin !== `http://${req.headers.host}`) {
351
+ respond(res, 403, page({ locale, state: "invalid", target: pending.target }));
352
+ return;
353
+ }
354
+ if (pending.inFlight) {
355
+ respond(res, 409, page({
356
+ locale,
357
+ state: "form",
358
+ target: pending.target,
359
+ message: COPY[locale].busy,
360
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
361
+ }));
129
362
  return;
130
363
  }
131
364
  const form = new URLSearchParams(await readBody(req));
132
365
  const username = form.get("username")?.trim() ?? "";
133
366
  const password = form.get("password") ?? "";
134
367
  if (!username || !password) {
135
- respond(res, 400, page("Connect to TaurusDB", "Username and password are required.", true));
368
+ respond(res, 400, page({
369
+ locale,
370
+ state: "form",
371
+ target: pending.target,
372
+ message: COPY[locale].required,
373
+ username,
374
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
375
+ }));
136
376
  return;
137
377
  }
138
- this.pending.delete(token);
139
378
  try {
140
- await pending.bind({
141
- datasource: pending.datasource,
142
- username,
143
- password,
144
- });
379
+ pending.inFlight = true;
380
+ await pending.bind({ datasource: pending.datasource, username, password });
145
381
  }
146
- catch {
147
- respond(res, 500, page("Connection failed", "Credentials could not be bound to this session."));
382
+ catch (error) {
383
+ pending.inFlight = false;
384
+ const kind = error instanceof SqlCredentialValidationError ? error.kind : "connectivity";
385
+ if (kind === "credentials") {
386
+ pending.failedAttempts += 1;
387
+ await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
388
+ if (pending.failedAttempts >= this.maxAttempts) {
389
+ this.pending.delete(token);
390
+ respond(res, 429, page({ locale, state: "locked", target: pending.target }));
391
+ return;
392
+ }
393
+ }
394
+ respond(res, kind === "credentials" ? 401 : kind === "timeout" ? 504 : 502, page({
395
+ locale,
396
+ state: "form",
397
+ target: pending.target,
398
+ message: COPY[locale][kind],
399
+ username,
400
+ remainingAttempts: this.maxAttempts - pending.failedAttempts,
401
+ }));
148
402
  return;
149
403
  }
150
- respond(res, 200, page("Connected", "Database credentials are now active for this MCP session."));
404
+ this.pending.delete(token);
405
+ respond(res, 200, page({ locale, state: "success", target: pending.target }));
151
406
  }
152
407
  catch {
153
- respond(res, 400, page("Invalid request", "The login request could not be processed."));
408
+ respond(res, 400, page({ locale, state: "invalid" }));
154
409
  }
155
410
  }
156
411
  }
@@ -0,0 +1,29 @@
1
+ type TimerHandle = ReturnType<typeof setTimeout>;
2
+ export type SessionCredentialManagerOptions = {
3
+ idleTtlMs?: number;
4
+ maxTtlMs?: number;
5
+ now?: () => number;
6
+ schedule?: (callback: () => void, delayMs: number) => TimerHandle;
7
+ cancel?: (timer: TimerHandle) => void;
8
+ onExpirationError?: (error: unknown, datasource: string) => void;
9
+ };
10
+ export declare class SessionCredentialManager {
11
+ private readonly idleTtlMs;
12
+ private readonly maxTtlMs;
13
+ private readonly now;
14
+ private readonly scheduleTimer;
15
+ private readonly cancelTimer;
16
+ private readonly onExpirationError;
17
+ private readonly sessions;
18
+ constructor(options?: SessionCredentialManagerOptions);
19
+ activate(datasource: string, onExpire: () => Promise<void>): void;
20
+ ensureFresh(datasource: string): Promise<void>;
21
+ touch(datasource: string): void;
22
+ clear(datasource: string): void;
23
+ clearAll(): void;
24
+ close(): Promise<void>;
25
+ private deadline;
26
+ private schedule;
27
+ private expireIfDue;
28
+ }
29
+ export {};
@@ -0,0 +1,85 @@
1
+ const MINUTE_MS = 60_000;
2
+ export class SessionCredentialManager {
3
+ idleTtlMs;
4
+ maxTtlMs;
5
+ now;
6
+ scheduleTimer;
7
+ cancelTimer;
8
+ onExpirationError;
9
+ sessions = new Map();
10
+ constructor(options = {}) {
11
+ this.idleTtlMs = options.idleTtlMs ?? 30 * MINUTE_MS;
12
+ this.maxTtlMs = options.maxTtlMs ?? 8 * 60 * MINUTE_MS;
13
+ this.now = options.now ?? Date.now;
14
+ this.scheduleTimer = options.schedule ?? setTimeout;
15
+ this.cancelTimer = options.cancel ?? clearTimeout;
16
+ this.onExpirationError = options.onExpirationError ?? (() => undefined);
17
+ }
18
+ activate(datasource, onExpire) {
19
+ this.clear(datasource);
20
+ const now = this.now();
21
+ const session = {
22
+ createdAtMs: now,
23
+ lastActivityAtMs: now,
24
+ onExpire,
25
+ };
26
+ this.sessions.set(datasource, session);
27
+ this.schedule(datasource, session);
28
+ }
29
+ async ensureFresh(datasource) {
30
+ const now = this.now();
31
+ const session = this.sessions.get(datasource);
32
+ if (session && now >= this.deadline(session)) {
33
+ await this.expireIfDue(datasource, session);
34
+ }
35
+ }
36
+ touch(datasource) {
37
+ const session = this.sessions.get(datasource);
38
+ if (session) {
39
+ session.lastActivityAtMs = this.now();
40
+ this.schedule(datasource, session);
41
+ }
42
+ }
43
+ clear(datasource) {
44
+ const session = this.sessions.get(datasource);
45
+ if (session?.timer) {
46
+ this.cancelTimer(session.timer);
47
+ }
48
+ this.sessions.delete(datasource);
49
+ }
50
+ clearAll() {
51
+ for (const datasource of [...this.sessions.keys()]) {
52
+ this.clear(datasource);
53
+ }
54
+ }
55
+ async close() {
56
+ this.clearAll();
57
+ }
58
+ deadline(session) {
59
+ return Math.min(session.lastActivityAtMs + this.idleTtlMs, session.createdAtMs + this.maxTtlMs);
60
+ }
61
+ schedule(datasource, session) {
62
+ if (session.timer) {
63
+ this.cancelTimer(session.timer);
64
+ }
65
+ const delayMs = Math.max(0, this.deadline(session) - this.now());
66
+ session.timer = this.scheduleTimer(() => {
67
+ void this.expireIfDue(datasource, session).catch((error) => {
68
+ this.onExpirationError(error, datasource);
69
+ });
70
+ }, delayMs);
71
+ session.timer.unref?.();
72
+ }
73
+ async expireIfDue(datasource, expected) {
74
+ const current = this.sessions.get(datasource);
75
+ if (current !== expected) {
76
+ return;
77
+ }
78
+ if (this.now() < this.deadline(current)) {
79
+ this.schedule(datasource, current);
80
+ return;
81
+ }
82
+ this.sessions.delete(datasource);
83
+ await current.onExpire();
84
+ }
85
+ }
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ 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";
5
6
  export interface ServerDeps {
6
7
  config: Config;
7
8
  profileLoader: RuntimeTargetProfileLoader;
@@ -9,6 +10,8 @@ export interface ServerDeps {
9
10
  credentialLogin: CredentialLoginService;
10
11
  auditWriter?: AuditWriter;
11
12
  sessionCoordinator?: SessionCoordinator;
13
+ credentialSessions?: SessionCredentialManager;
14
+ sqlCredentialValidator?: (engine: TaurusDBEngine, datasource: string, taskId: string) => Promise<void>;
12
15
  clientIdentityProvider?: () => {
13
16
  name: string;
14
17
  version: string;
package/dist/server.js CHANGED
@@ -6,6 +6,7 @@ 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";
9
10
  export async function bootstrapDependencies() {
10
11
  const config = getConfig();
11
12
  const profileLoader = new RuntimeOverrideProfileLoader(createSqlProfileLoader({ config }));
@@ -15,13 +16,22 @@ export async function bootstrapDependencies() {
15
16
  maxBytes: config.audit.maxBytes,
16
17
  maxFiles: config.audit.maxFiles,
17
18
  });
19
+ const sessionCoordinator = new SessionCoordinator();
20
+ const credentialSessions = new SessionCredentialManager({
21
+ idleTtlMs: config.security.credentialIdleTtlMinutes * 60_000,
22
+ maxTtlMs: config.security.credentialMaxTtlMinutes * 60_000,
23
+ onExpirationError: (error, datasource) => {
24
+ logger.error({ err: error, datasource }, "Failed to expire SQL credential session");
25
+ },
26
+ });
18
27
  return {
19
28
  config,
20
29
  profileLoader,
21
30
  engine,
22
31
  credentialLogin: new LocalCredentialLoginService(),
23
32
  auditWriter,
24
- sessionCoordinator: new SessionCoordinator(),
33
+ sessionCoordinator,
34
+ credentialSessions,
25
35
  pingResponse: "pong",
26
36
  };
27
37
  }
@@ -35,6 +45,7 @@ export function createServer(deps) {
35
45
  const cleanup = () => {
36
46
  cleanupPromise ??= Promise.all([
37
47
  deps.credentialLogin.close(),
48
+ deps.credentialSessions?.close() ?? Promise.resolve(),
38
49
  deps.engine.close(),
39
50
  deps.auditWriter?.close() ?? Promise.resolve(),
40
51
  ]).then(() => undefined);