@gizmodata/hardcharger-mcp 0.0.0 → 2.0.0

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.
@@ -0,0 +1,797 @@
1
+ // Configuration parsing, credential redaction, and the lazily opened Hardcharger
2
+ // connection behind each configured server.
3
+ import { readFileSync } from "node:fs";
4
+ import { HardchargerError, HardchargerErrorCode, connect as hardchargerConnect, parseUrl, } from "@gizmodata/hardcharger";
5
+ import { Table } from "apache-arrow";
6
+ export const DEFAULTS = {
7
+ maxRows: 500,
8
+ maxCellChars: 200,
9
+ queryTimeoutSeconds: 60,
10
+ connectionName: "default",
11
+ sessionIdleSeconds: 1800,
12
+ maxSessions: 200,
13
+ };
14
+ /** Extra connection slots the Claude Desktop manifest exposes (`HARDCHARGER_2_*`, `HARDCHARGER_3_*`). */
15
+ export const MANIFEST_SLOTS = ["2", "3"];
16
+ export class ConfigError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "ConfigError";
20
+ }
21
+ }
22
+ const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
23
+ const FALSE_VALUES = new Set(["0", "false", "no", "off", ""]);
24
+ /**
25
+ * Unsubstituted MCPB template placeholders. Claude Desktop passes the
26
+ * literal `${user_config.<key>}` for optional settings the user left
27
+ * blank, which must count as "not set".
28
+ */
29
+ const TEMPLATE_PLACEHOLDER = /^\$\{[^}]*\}$/u;
30
+ /** True when an env value should be treated as absent. */
31
+ export function isUnset(raw) {
32
+ if (raw === undefined)
33
+ return true;
34
+ const v = raw.trim();
35
+ return v === "" || TEMPLATE_PLACEHOLDER.test(v);
36
+ }
37
+ export function parseBoolean(name, raw, fallback) {
38
+ if (isUnset(raw))
39
+ return fallback;
40
+ const v = raw.trim().toLowerCase();
41
+ if (TRUE_VALUES.has(v))
42
+ return true;
43
+ if (FALSE_VALUES.has(v))
44
+ return false;
45
+ throw new ConfigError(`${name} must be true/false (got "${raw}").`);
46
+ }
47
+ export function parseInteger(name, raw, fallback, { min, max }) {
48
+ if (isUnset(raw))
49
+ return fallback;
50
+ const n = Number(raw.trim());
51
+ if (!Number.isInteger(n) || n < min || (max !== undefined && n > max)) {
52
+ const range = max === undefined ? `>= ${min}` : `between ${min} and ${max}`;
53
+ throw new ConfigError(`${name} must be an integer ${range} (got "${raw}").`);
54
+ }
55
+ return n;
56
+ }
57
+ function nonEmpty(raw) {
58
+ return isUnset(raw) ? undefined : raw.trim();
59
+ }
60
+ /** Env-variable key fragment for a connection key: `prod-eu` -> `PROD_EU`. */
61
+ export function envKey(key) {
62
+ return key.trim().toUpperCase().replace(/[^A-Z0-9]+/gu, "_");
63
+ }
64
+ /** Reads and validates a `hardcharger://` URI the way the client will (throws ConfigError). */
65
+ export function parseHardchargerUri(name, raw) {
66
+ let base;
67
+ try {
68
+ base = parseUrl(raw);
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message.replace(/^\[hardcharger\] /u, "") : String(err);
72
+ throw new ConfigError(`${name}: ${message}`);
73
+ }
74
+ const tls = base.protocol === "https:";
75
+ const host = base.hostname.replace(/^\[(.*)\]$/u, "$1");
76
+ return { uri: raw.trim(), host, port: Number(base.port || (tls ? 443 : 80)), tls };
77
+ }
78
+ /**
79
+ * Parses one connection from `env` using variables named `<prefix>URI`,
80
+ * `<prefix>USERNAME`, ... (prefix `HARDCHARGER_` for the primary connection,
81
+ * `HARDCHARGER_<KEY>_` for additional ones). Returns null when no URI is set.
82
+ */
83
+ function parseConnection(env, prefix, defaultName, globals) {
84
+ const v = (suffix) => env[`${prefix}${suffix}`];
85
+ const rawUri = nonEmpty(v("URI"));
86
+ if (!rawUri)
87
+ return null;
88
+ const name = nonEmpty(v("NAME")) ?? defaultName;
89
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(name)) {
90
+ throw new ConfigError(`Connection name "${name}" (${prefix}NAME) must be 1-64 letters, digits, dots, dashes or underscores.`);
91
+ }
92
+ const target = parseHardchargerUri(`${prefix}URI`, rawUri);
93
+ const username = nonEmpty(v("USERNAME"));
94
+ const rawPassword = v("PASSWORD");
95
+ const password = isUnset(rawPassword) && rawPassword !== "" ? undefined : rawPassword;
96
+ const token = nonEmpty(v("TOKEN"));
97
+ const mtlsCertFile = nonEmpty(v("MTLS_CERT"));
98
+ const mtlsKeyFile = nonEmpty(v("MTLS_KEY"));
99
+ const tlsRootsFile = nonEmpty(v("TLS_ROOTS"));
100
+ const hasPassword = username !== undefined || (password !== undefined && password !== "");
101
+ if (hasPassword && (username === undefined || password === undefined)) {
102
+ throw new ConfigError(`Connection "${name}": ${prefix}USERNAME and ${prefix}PASSWORD must be set together.`);
103
+ }
104
+ if (hasPassword && token !== undefined) {
105
+ throw new ConfigError(`Connection "${name}": set either ${prefix}USERNAME/${prefix}PASSWORD or ${prefix}TOKEN, not both.`);
106
+ }
107
+ if ((mtlsCertFile === undefined) !== (mtlsKeyFile === undefined)) {
108
+ throw new ConfigError(`Connection "${name}": ${prefix}MTLS_CERT and ${prefix}MTLS_KEY must be set together.`);
109
+ }
110
+ const hasCertificate = mtlsCertFile !== undefined;
111
+ if ((hasCertificate || tlsRootsFile !== undefined) && !target.tls) {
112
+ throw new ConfigError(`Connection "${name}": ${hasCertificate ? `${prefix}MTLS_CERT` : `${prefix}TLS_ROOTS`} needs TLS, ` +
113
+ `but ${prefix}URI says ?tls=false.`);
114
+ }
115
+ if (!hasPassword && token === undefined && !hasCertificate && !globals.enableSso) {
116
+ throw new ConfigError(`Connection "${name}" (${prefix}URI) has no credentials: set ${prefix}USERNAME + ${prefix}PASSWORD, ` +
117
+ `${prefix}TOKEN (a signed sign-in token), ${prefix}MTLS_CERT + ${prefix}MTLS_KEY (client certificate ` +
118
+ "sign-in), or HARDCHARGER_ENABLE_SSO=true to sign in with the login_sso tool.");
119
+ }
120
+ return {
121
+ name,
122
+ ...target,
123
+ username: hasPassword ? username : undefined,
124
+ password: hasPassword ? password : undefined,
125
+ token,
126
+ mtlsCertFile,
127
+ mtlsKeyFile,
128
+ tlsRootsFile,
129
+ tlsSkipVerify: parseBoolean(`${prefix}TLS_SKIP_VERIFY`, v("TLS_SKIP_VERIFY"), false),
130
+ defaultCatalog: nonEmpty(v("DEFAULT_CATALOG")),
131
+ defaultSchema: nonEmpty(v("DEFAULT_SCHEMA")),
132
+ queryTimeoutSeconds: globals.queryTimeoutSeconds,
133
+ };
134
+ }
135
+ /**
136
+ * Builds the effective configuration from environment variables.
137
+ *
138
+ * Connections:
139
+ * - primary: `HARDCHARGER_URI` etc. (name `HARDCHARGER_CONNECTION_NAME`, default "default");
140
+ * - manifest slots: `HARDCHARGER_2_URI` / `HARDCHARGER_3_URI` etc. (names `HARDCHARGER_2_NAME`,
141
+ * default "server2"/"server3");
142
+ * - any names listed in `HARDCHARGER_CONNECTIONS` (comma separated), read from
143
+ * `HARDCHARGER_<NAME>_URI` etc. with the name upper-cased and non-alphanumerics
144
+ * replaced by `_`.
145
+ * Slots without a URI are ignored. At least one connection is required.
146
+ */
147
+ export function parseConfig(env = process.env) {
148
+ const enableSso = parseBoolean("HARDCHARGER_ENABLE_SSO", env.HARDCHARGER_ENABLE_SSO, false);
149
+ const queryTimeoutSeconds = parseInteger("HARDCHARGER_QUERY_TIMEOUT_SECONDS", env.HARDCHARGER_QUERY_TIMEOUT_SECONDS, DEFAULTS.queryTimeoutSeconds, { min: 0, max: 86400 });
150
+ const globals = { queryTimeoutSeconds, enableSso };
151
+ const connections = [];
152
+ const primary = parseConnection(env, "HARDCHARGER_", nonEmpty(env.HARDCHARGER_CONNECTION_NAME) ?? DEFAULTS.connectionName, globals);
153
+ if (primary)
154
+ connections.push(primary);
155
+ const extraKeys = MANIFEST_SLOTS.map((slot) => ({
156
+ key: slot,
157
+ defaultName: `server${slot}`,
158
+ }));
159
+ for (const listed of (nonEmpty(env.HARDCHARGER_CONNECTIONS) ?? "").split(",")) {
160
+ const key = listed.trim();
161
+ if (!key)
162
+ continue;
163
+ if (envKey(key) === "MCP") {
164
+ throw new ConfigError(`HARDCHARGER_CONNECTIONS: "${key}" is reserved (HARDCHARGER_MCP_* configures the HTTP transport).`);
165
+ }
166
+ extraKeys.push({ key: envKey(key), defaultName: key.toLowerCase() });
167
+ }
168
+ for (const { key, defaultName } of extraKeys) {
169
+ const c = parseConnection(env, `HARDCHARGER_${key}_`, defaultName, globals);
170
+ if (c)
171
+ connections.push(c);
172
+ }
173
+ if (connections.length === 0) {
174
+ throw new ConfigError("HARDCHARGER_URI is required: the Hardcharger server's URI, e.g. hardcharger://db.example.com " +
175
+ "(TLS to port 31337), or hardcharger://localhost?tls=false for a server without TLS.");
176
+ }
177
+ const seen = new Map();
178
+ for (const c of connections) {
179
+ const k = c.name.toLowerCase();
180
+ if (seen.has(k)) {
181
+ throw new ConfigError(`Connection name "${c.name}" is used more than once (names are case-insensitive).`);
182
+ }
183
+ seen.set(k, c.name);
184
+ }
185
+ const mcpBearerToken = nonEmpty(env.HARDCHARGER_MCP_BEARER_TOKEN);
186
+ const mcpOAuth = parseOAuth(env);
187
+ if (mcpBearerToken !== undefined && mcpOAuth !== undefined) {
188
+ throw new ConfigError("HARDCHARGER_MCP_BEARER_TOKEN and HARDCHARGER_MCP_OAUTH_ISSUER are mutually exclusive: pick one way to authenticate HTTP clients.");
189
+ }
190
+ return {
191
+ connections,
192
+ allowWrites: parseBoolean("HARDCHARGER_ALLOW_WRITES", env.HARDCHARGER_ALLOW_WRITES, false),
193
+ maxRows: parseInteger("HARDCHARGER_MAX_ROWS", env.HARDCHARGER_MAX_ROWS, DEFAULTS.maxRows, { min: 1, max: 100000 }),
194
+ maxCellChars: parseInteger("HARDCHARGER_MAX_CELL_CHARS", env.HARDCHARGER_MAX_CELL_CHARS, DEFAULTS.maxCellChars, {
195
+ min: 1,
196
+ max: 100000,
197
+ }),
198
+ queryTimeoutSeconds,
199
+ mcpBearerToken,
200
+ mcpOAuth,
201
+ mcpSessionIdleSeconds: parseInteger("HARDCHARGER_MCP_SESSION_IDLE_SECONDS", env.HARDCHARGER_MCP_SESSION_IDLE_SECONDS, DEFAULTS.sessionIdleSeconds, { min: 1, max: 86400 }),
202
+ mcpMaxSessions: parseInteger("HARDCHARGER_MCP_MAX_SESSIONS", env.HARDCHARGER_MCP_MAX_SESSIONS, DEFAULTS.maxSessions, {
203
+ min: 1,
204
+ max: 100000,
205
+ }),
206
+ enableSso,
207
+ };
208
+ }
209
+ /** Splits a comma- or whitespace-separated list, dropping blanks. */
210
+ function parseList(raw) {
211
+ const v = nonEmpty(raw);
212
+ if (v === undefined)
213
+ return [];
214
+ return v
215
+ .split(/[\s,]+/u)
216
+ .map((s) => s.trim())
217
+ .filter((s) => s !== "");
218
+ }
219
+ function parseHttpsUrl(name, raw, { allowHttp }) {
220
+ const v = nonEmpty(raw);
221
+ if (v === undefined)
222
+ return undefined;
223
+ let url;
224
+ try {
225
+ url = new URL(v);
226
+ }
227
+ catch {
228
+ throw new ConfigError(`${name} must be an absolute URL (got "${v}").`);
229
+ }
230
+ if (url.protocol !== "https:" && !(allowHttp && url.protocol === "http:")) {
231
+ throw new ConfigError(`${name} must use https (got "${v}").`);
232
+ }
233
+ if (url.hash)
234
+ throw new ConfigError(`${name} must not contain a fragment (got "${v}").`);
235
+ return v.replace(/\/+$/u, "");
236
+ }
237
+ /** Default claim precedence used to name an authenticated caller. */
238
+ export const DEFAULT_USER_CLAIMS = ["email", "preferred_username", "upn", "name", "sub"];
239
+ /**
240
+ * Reads the OAuth resource-server settings for the HTTP transport. Returns
241
+ * undefined unless HARDCHARGER_MCP_OAUTH_ISSUER is set. Plain http URLs are
242
+ * accepted for the issuer and public URL only when
243
+ * HARDCHARGER_MCP_OAUTH_ALLOW_INSECURE=true (local testing).
244
+ */
245
+ export function parseOAuth(env) {
246
+ const allowHttp = parseBoolean("HARDCHARGER_MCP_OAUTH_ALLOW_INSECURE", env.HARDCHARGER_MCP_OAUTH_ALLOW_INSECURE, false);
247
+ const issuer = parseHttpsUrl("HARDCHARGER_MCP_OAUTH_ISSUER", env.HARDCHARGER_MCP_OAUTH_ISSUER, { allowHttp });
248
+ const publicUrl = parseHttpsUrl("HARDCHARGER_MCP_PUBLIC_URL", env.HARDCHARGER_MCP_PUBLIC_URL, { allowHttp });
249
+ if (issuer === undefined) {
250
+ for (const key of Object.keys(env)) {
251
+ if (key.startsWith("HARDCHARGER_MCP_OAUTH_") && key !== "HARDCHARGER_MCP_OAUTH_ALLOW_INSECURE" && !isUnset(env[key])) {
252
+ throw new ConfigError(`${key} is set but HARDCHARGER_MCP_OAUTH_ISSUER is not; the issuer enables OAuth.`);
253
+ }
254
+ }
255
+ return undefined;
256
+ }
257
+ if (publicUrl === undefined) {
258
+ throw new ConfigError("HARDCHARGER_MCP_PUBLIC_URL is required with HARDCHARGER_MCP_OAUTH_ISSUER: the public URL of the /mcp endpoint " +
259
+ "(e.g. https://mcp.example.com/mcp) is the OAuth resource identifier clients request tokens for.");
260
+ }
261
+ const jwksUri = parseHttpsUrl("HARDCHARGER_MCP_OAUTH_JWKS_URI", env.HARDCHARGER_MCP_OAUTH_JWKS_URI, { allowHttp });
262
+ const audiences = parseList(env.HARDCHARGER_MCP_OAUTH_AUDIENCE);
263
+ const userClaims = parseList(env.HARDCHARGER_MCP_OAUTH_USER_CLAIM);
264
+ return {
265
+ publicUrl,
266
+ issuer,
267
+ audiences: audiences.length > 0 ? audiences : [publicUrl],
268
+ jwksUri,
269
+ scopes: parseList(env.HARDCHARGER_MCP_OAUTH_SCOPES),
270
+ userClaims: userClaims.length > 0 ? userClaims : [...DEFAULT_USER_CLAIMS],
271
+ authorizedEmails: parseList(env.HARDCHARGER_MCP_OAUTH_AUTHORIZED_EMAILS),
272
+ tokenProxy: parseBoolean("HARDCHARGER_MCP_OAUTH_TOKEN_PROXY", env.HARDCHARGER_MCP_OAUTH_TOKEN_PROXY, false),
273
+ };
274
+ }
275
+ /** The sign-in a connection's settings call for (before any login_sso). */
276
+ export function authMethod(config) {
277
+ if (config.username !== undefined)
278
+ return "password";
279
+ if (config.token !== undefined)
280
+ return "token";
281
+ if (config.mtlsCertFile !== undefined)
282
+ return "mtls";
283
+ return "none";
284
+ }
285
+ /** Replaces every configured secret in `text` with `***`. */
286
+ export function redactSecrets(text, secrets) {
287
+ let out = text;
288
+ for (const s of secrets) {
289
+ if (!s || s.length === 0)
290
+ continue;
291
+ out = out.split(s).join("***");
292
+ const encoded = encodeURIComponent(s);
293
+ if (encoded !== s)
294
+ out = out.split(encoded).join("***");
295
+ }
296
+ return out;
297
+ }
298
+ export class QueryTimeoutError extends Error {
299
+ constructor(seconds) {
300
+ super(`Query exceeded the ${seconds}s timeout (HARDCHARGER_QUERY_TIMEOUT_SECONDS) and was cancelled ` +
301
+ "(statement_timeout).");
302
+ this.name = "QueryTimeoutError";
303
+ }
304
+ }
305
+ /** Grace added to the server-side timeout before the client-side abort fires. */
306
+ const CLIENT_DEADLINE_GRACE_MS = 5000;
307
+ /** If an abort does not settle the call, drop the connection after this. */
308
+ const HARD_RESET_GRACE_MS = 15000;
309
+ /** The error's Hardcharger (Warp SQL) code, if it is one of the client's errors. */
310
+ export function errorCode(err) {
311
+ return err instanceof HardchargerError ? err.code : undefined;
312
+ }
313
+ /**
314
+ * `session_ended` reasons after which the user's sign-in itself no longer holds (Warp SQL
315
+ * §4.2, §5.6): a revoked token, changed credentials, a dropped user. Nothing signs in again
316
+ * on its own after these; the error is the answer.
317
+ */
318
+ const SIGN_IN_WITHDRAWN = new Set(["token_revoked", "credentials_changed", "principal_dropped"]);
319
+ /**
320
+ * Does this error mean the connection (or the server's session behind it) is gone in a
321
+ * way that a fresh connection with the same credentials cures? The client itself already
322
+ * replaces a session the server lost (`session_not_found`, an expired token) and replays
323
+ * the session's options; this covers what it can't: a session an administrator ended
324
+ * (`session_ended` with reason `killed`, e.g. `KILL SESSION`, an expired token, or a reason
325
+ * this server doesn't know), a closed connection, a transport failure, and a lost session
326
+ * the client could not replace. A refused credential is never retried, and neither is a
327
+ * session ended because the sign-in was withdrawn (`token_revoked`, `credentials_changed`,
328
+ * `principal_dropped`).
329
+ */
330
+ export function isConnectionLost(err) {
331
+ const code = errorCode(err);
332
+ if (code === HardchargerErrorCode.sessionEnded)
333
+ return !SIGN_IN_WITHDRAWN.has(String(err.reason ?? ""));
334
+ return (code === HardchargerErrorCode.sessionNotFound ||
335
+ code === HardchargerErrorCode.clientConnectionClosed ||
336
+ code === HardchargerErrorCode.clientTransportError);
337
+ }
338
+ /** A plain-HTTP server answered a TLS handshake: the URI needs `?tls=false`. */
339
+ function isPlainHttpServer(err) {
340
+ if (errorCode(err) !== HardchargerErrorCode.clientTransportError)
341
+ return false;
342
+ const text = `${err instanceof Error ? err.message : ""} ${String(err.cause ?? "")}`;
343
+ return /wrong version number|ERR_SSL_WRONG_VERSION_NUMBER|packet length too long/iu.test(text);
344
+ }
345
+ /** Setting names read from hardcharger_settings() and reported by server_info. */
346
+ export const SERVER_SETTINGS_OF_INTEREST = [
347
+ "hardcharger.version",
348
+ "hardcharger.read_only",
349
+ "hardcharger.max_sessions",
350
+ "hardcharger.session_idle_timeout",
351
+ "hardcharger.query_timeout",
352
+ "hardcharger.memory_limit",
353
+ "hardcharger.max_concurrent_statements",
354
+ "hardcharger.instance_id",
355
+ "hardcharger.cluster_id",
356
+ ];
357
+ function readPem(what, file) {
358
+ try {
359
+ return readFileSync(file, "utf8");
360
+ }
361
+ catch (err) {
362
+ throw new ConfigError(`Cannot read the ${what} file ${file}: ${err instanceof Error ? err.message : String(err)}`);
363
+ }
364
+ }
365
+ /**
366
+ * One Hardcharger connection per configured server, opened lazily and serialized so a
367
+ * single statement is in flight at a time. Timeouts are enforced by the server
368
+ * (`statementTimeoutMs`, Warp SQL §6.9) with a client-side abort as backstop that cancels
369
+ * the statement on the server; only if that fails to settle the call is the connection
370
+ * dropped and reopened. The client replaces a session the server lost and replays its
371
+ * catalog and schema; a connection that is gone altogether (a killed session, a closed
372
+ * connection, a transport failure) is reopened once and the operation retried.
373
+ */
374
+ export class HardchargerConnection {
375
+ config;
376
+ log;
377
+ client = null;
378
+ connecting = null;
379
+ queue = Promise.resolve();
380
+ overrides = {};
381
+ warnings = [];
382
+ searchPath;
383
+ /** Wall-clock at which the current client was opened (for server_info). */
384
+ connectedAt = null;
385
+ /** Sessions the server lost and the client replaced, over every client this connection opened. */
386
+ replacedBefore = 0;
387
+ /** Times a lost connection was reopened by this layer (a killed session, a transport failure). */
388
+ reconnects = 0;
389
+ /** The server's startup settings from hardcharger_settings(); null until read or when unavailable. */
390
+ serverSettings = null;
391
+ openClient;
392
+ constructor(config, log = (m) => console.error(m), hooks = {}) {
393
+ this.config = config;
394
+ this.log = log;
395
+ this.searchPath = { catalog: config.defaultCatalog, schema: config.defaultSchema };
396
+ this.openClient = hooks.connect ?? hardchargerConnect;
397
+ }
398
+ /** The search path (catalog, schema) applied to every session. */
399
+ currentSearchPath() {
400
+ return { ...this.searchPath };
401
+ }
402
+ /**
403
+ * Switches the session's default catalog and/or schema (Warp SQL session options, which
404
+ * the client replays on a replacement session). Throws when the server refuses it.
405
+ */
406
+ async useSchema(path) {
407
+ if (!path.catalog && !path.schema)
408
+ throw new Error("use_schema needs a catalog and/or schema name.");
409
+ return this.run(async (c) => {
410
+ const options = {};
411
+ if (path.catalog)
412
+ options.catalog = path.catalog;
413
+ if (path.schema)
414
+ options.schema = path.schema;
415
+ await c.setOptions(options);
416
+ return this.pinSearchPath(c);
417
+ });
418
+ }
419
+ /**
420
+ * Re-reads the session's catalog and schema (after a `USE`, which the server applies but
421
+ * the client does not replay) and sets both as session options, so a replacement session
422
+ * gets the same search path.
423
+ */
424
+ async syncSearchPath() {
425
+ return this.run((c) => this.pinSearchPath(c));
426
+ }
427
+ async pinSearchPath(c) {
428
+ const doc = await c.getSession();
429
+ const catalog = typeof doc.options?.catalog === "string" ? doc.options.catalog : undefined;
430
+ const schema = typeof doc.options?.schema === "string" ? doc.options.schema : undefined;
431
+ if (catalog !== undefined && schema !== undefined)
432
+ await c.setOptions({ catalog, schema });
433
+ this.searchPath = { catalog, schema };
434
+ return { ...this.searchPath };
435
+ }
436
+ /** Secrets to redact from any message that might reach a client. */
437
+ secrets() {
438
+ return [this.config.password, this.config.token, this.overrides.accessToken];
439
+ }
440
+ redact(text) {
441
+ return redactSecrets(text, this.secrets());
442
+ }
443
+ /** Effective auth method, taking login_sso into account. */
444
+ effectiveAuth() {
445
+ if (this.overrides.accessToken !== undefined)
446
+ return { method: "sso" };
447
+ const method = authMethod(this.config);
448
+ return { method, user: method === "password" ? this.config.username : undefined };
449
+ }
450
+ /** Session-setup warnings (e.g. a default catalog the server does not have). */
451
+ sessionWarnings() {
452
+ return [...this.warnings];
453
+ }
454
+ /** Sessions the server lost and the client replaced (over this connection's lifetime). */
455
+ sessionReplacements() {
456
+ return this.replacedBefore + (this.client?.sessionReplacements ?? 0);
457
+ }
458
+ /** The server's idle timeout for this connection's session, as the session document announced it. */
459
+ async sessionIdleTimeoutSeconds() {
460
+ try {
461
+ const doc = await this.run((c) => c.getSession());
462
+ return typeof doc.idle_timeout_s === "number" ? doc.idle_timeout_s : null;
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ }
468
+ /** The server's discovery document (name, version, capabilities, grants), once connected. */
469
+ discovery() {
470
+ return this.client?.server ?? null;
471
+ }
472
+ /** Swaps in new credentials (login_sso) and drops the current connection. */
473
+ async useCredentials(overrides) {
474
+ this.overrides = overrides;
475
+ await this.reset();
476
+ }
477
+ /** Where a browser sign-in (login_sso) runs: the connection's own URI, with its TLS settings. */
478
+ signInTarget() {
479
+ const target = { url: this.config.uri };
480
+ const tls = this.tlsOptions();
481
+ if (tls !== undefined)
482
+ target.tls = tls;
483
+ return target;
484
+ }
485
+ /** The TLS settings (roots, client certificate, skip verify) for this connection, if any. */
486
+ tlsOptions() {
487
+ const cfg = this.config;
488
+ if (!cfg.tls)
489
+ return undefined;
490
+ const tls = {};
491
+ if (cfg.tlsRootsFile)
492
+ tls.ca = readPem("TLS roots", cfg.tlsRootsFile);
493
+ if (cfg.mtlsCertFile && cfg.mtlsKeyFile) {
494
+ tls.cert = readPem("client certificate", cfg.mtlsCertFile);
495
+ tls.key = readPem("client key", cfg.mtlsKeyFile);
496
+ }
497
+ if (cfg.tlsSkipVerify)
498
+ tls.skipVerify = true;
499
+ return Object.keys(tls).length > 0 ? tls : undefined;
500
+ }
501
+ /** The client's connect options for this connection's settings. */
502
+ clientOptions() {
503
+ const cfg = this.config;
504
+ const options = {
505
+ url: cfg.uri,
506
+ logger: {
507
+ warn: (message, fields) => this.log(`[hardcharger-mcp] ${this.redact(message)}${fields ? ` ${this.redact(JSON.stringify(fields))}` : ""}`),
508
+ },
509
+ };
510
+ const tls = this.tlsOptions();
511
+ if (tls !== undefined)
512
+ options.tls = tls;
513
+ if (this.overrides.accessToken !== undefined) {
514
+ options.token = this.overrides.accessToken;
515
+ }
516
+ else if (cfg.username !== undefined) {
517
+ options.username = cfg.username;
518
+ options.password = cfg.password ?? "";
519
+ }
520
+ else if (cfg.token !== undefined) {
521
+ options.subjectToken = cfg.token;
522
+ }
523
+ else if (cfg.mtlsCertFile !== undefined) {
524
+ options.authType = "mtls";
525
+ }
526
+ return options;
527
+ }
528
+ /** Opens a fresh client and applies the session settings. */
529
+ async open() {
530
+ if (this.overrides.accessToken === undefined && authMethod(this.config) === "none") {
531
+ throw new Error(`Connection "${this.config.name}" has no credentials yet: call login_sso to sign in through the ` +
532
+ "server's identity provider in your browser.");
533
+ }
534
+ let client;
535
+ try {
536
+ client = await this.openClient(this.clientOptions());
537
+ }
538
+ catch (err) {
539
+ if (isPlainHttpServer(err)) {
540
+ throw new Error(`${err instanceof Error ? err.message : String(err)}\nThe server at ${this.config.host}:${this.config.port} ` +
541
+ "speaks plain HTTP, not TLS: add ?tls=false to the connection URI (hardcharger://host:port?tls=false).", { cause: err });
542
+ }
543
+ throw err;
544
+ }
545
+ await this.applySessionSettings(client);
546
+ await this.probeServerSettings(client);
547
+ this.connectedAt = new Date();
548
+ return client;
549
+ }
550
+ /**
551
+ * The search path, as Warp SQL session options. A catalog or schema the server does not
552
+ * have is reported (with the catalogs it does have) instead of failing the connection;
553
+ * the server's default then applies.
554
+ */
555
+ async applySessionSettings(client) {
556
+ this.warnings = [];
557
+ const options = {};
558
+ if (this.searchPath.catalog)
559
+ options.catalog = this.searchPath.catalog;
560
+ if (this.searchPath.schema)
561
+ options.schema = this.searchPath.schema;
562
+ if (Object.keys(options).length === 0)
563
+ return;
564
+ try {
565
+ await client.setOptions(options);
566
+ }
567
+ catch (err) {
568
+ const msg = this.redact(err instanceof Error ? err.message : String(err));
569
+ let available = "";
570
+ try {
571
+ const catalogs = await client.metadata.catalogs();
572
+ const names = [];
573
+ for (let i = 0; i < catalogs.numRows; i++)
574
+ names.push(String(catalogs.getChild("catalog_name")?.get(i)));
575
+ available = ` Available catalogs: ${names.join(", ")}.`;
576
+ }
577
+ catch {
578
+ // best effort
579
+ }
580
+ const wanted = [this.searchPath.catalog, this.searchPath.schema].filter(Boolean).join(".");
581
+ this.warnings.push(`Default catalog/schema could not be applied (${wanted}); the server's own default is in effect.${available} ${msg}`);
582
+ this.log(`[hardcharger-mcp] warning: ${this.warnings[this.warnings.length - 1]}`);
583
+ }
584
+ }
585
+ /** Reads the server's startup settings once per connection; never fails the connection. */
586
+ async probeServerSettings(client) {
587
+ const names = SERVER_SETTINGS_OF_INTEREST.map((n) => `'${n}'`).join(", ");
588
+ try {
589
+ const table = await client.query(`SELECT name, value FROM hardcharger_settings() WHERE name IN (${names})`);
590
+ const found = new Map();
591
+ const nameCol = table.getChild("name");
592
+ const valueCol = table.getChild("value");
593
+ if (nameCol && valueCol) {
594
+ for (let i = 0; i < table.numRows; i++) {
595
+ const n = nameCol.get(i);
596
+ const v = valueCol.get(i);
597
+ if (typeof n === "string" && v !== null && v !== undefined)
598
+ found.set(n, String(v));
599
+ }
600
+ }
601
+ this.serverSettings = found;
602
+ }
603
+ catch (err) {
604
+ this.serverSettings = null;
605
+ this.log(`[hardcharger-mcp] server settings unavailable: ${this.redact(err instanceof Error ? err.message : String(err))}`);
606
+ }
607
+ }
608
+ /** The server's reported startup settings (name -> value); empty when unknown. */
609
+ serverSettingsSnapshot() {
610
+ return Object.fromEntries(this.serverSettings ?? []);
611
+ }
612
+ /** Returns the shared client, opening it on first use. */
613
+ async get() {
614
+ if (this.client)
615
+ return this.client;
616
+ if (!this.connecting) {
617
+ this.connecting = this.open()
618
+ .then((c) => {
619
+ this.client = c;
620
+ return c;
621
+ })
622
+ .finally(() => {
623
+ this.connecting = null;
624
+ });
625
+ }
626
+ return this.connecting;
627
+ }
628
+ /** Closes the current client (ending its session on the server). */
629
+ async reset() {
630
+ const client = this.client;
631
+ this.client = null;
632
+ this.connectedAt = null;
633
+ if (client) {
634
+ this.replacedBefore += client.sessionReplacements;
635
+ try {
636
+ await client.close();
637
+ }
638
+ catch {
639
+ // already gone
640
+ }
641
+ }
642
+ }
643
+ async close() {
644
+ await this.reset();
645
+ }
646
+ /**
647
+ * Runs `fn` against the shared client with: serialization, one retry after the
648
+ * connection was lost (see isConnectionLost), and a deadline: the server's statement
649
+ * timeout, a client-side abort a few seconds later that cancels the statement on the
650
+ * server, and as a last resort dropping the connection.
651
+ */
652
+ async run(fn, options = {}) {
653
+ const task = async () => {
654
+ let attempt = 0;
655
+ for (;;) {
656
+ attempt++;
657
+ const client = await this.get();
658
+ try {
659
+ return await this.withDeadline((ctx) => fn(client, ctx), options.timeoutSeconds);
660
+ }
661
+ catch (err) {
662
+ if (err instanceof QueryTimeoutError)
663
+ throw err;
664
+ if (attempt < 2 && isConnectionLost(err)) {
665
+ this.log(`[hardcharger-mcp] connection lost (${errorCode(err)}), reconnecting: ${this.redact(err instanceof Error ? err.message : String(err))}`);
666
+ this.reconnects++;
667
+ await this.reset();
668
+ continue;
669
+ }
670
+ throw err;
671
+ }
672
+ }
673
+ };
674
+ const result = this.queue.then(task, task);
675
+ this.queue = result.catch(() => undefined);
676
+ return result;
677
+ }
678
+ async withDeadline(fn, timeoutSeconds) {
679
+ const seconds = timeoutSeconds ?? this.config.queryTimeoutSeconds;
680
+ if (!seconds || seconds <= 0)
681
+ return fn({ signal: undefined, statementTimeoutMs: undefined });
682
+ // The server cancels the statement at `seconds` (statementTimeoutMs); the client-side
683
+ // abort is a backstop a few seconds later.
684
+ const controller = new AbortController();
685
+ const timeoutError = new QueryTimeoutError(seconds);
686
+ let abortTimer;
687
+ let resetTimer;
688
+ const hardReset = new Promise((_, reject) => {
689
+ abortTimer = setTimeout(() => {
690
+ controller.abort(timeoutError);
691
+ // If the abort cannot settle the call, drop the connection.
692
+ resetTimer = setTimeout(() => {
693
+ void this.reset().finally(() => reject(timeoutError));
694
+ }, HARD_RESET_GRACE_MS);
695
+ }, seconds * 1000 + CLIENT_DEADLINE_GRACE_MS);
696
+ });
697
+ try {
698
+ return await Promise.race([fn({ signal: controller.signal, statementTimeoutMs: seconds * 1000 }), hardReset]);
699
+ }
700
+ catch (err) {
701
+ const code = errorCode(err);
702
+ if (code === "statement_timeout" || err === timeoutError || (controller.signal.aborted && code === "statement_cancelled")) {
703
+ throw timeoutError;
704
+ }
705
+ throw err;
706
+ }
707
+ finally {
708
+ if (abortTimer)
709
+ clearTimeout(abortTimer);
710
+ if (resetTimer)
711
+ clearTimeout(resetTimer);
712
+ }
713
+ }
714
+ /** Convenience: execute a query returning an Arrow table. */
715
+ query(sql, params, options) {
716
+ return this.run(async (c, ctx) => {
717
+ if (params instanceof Table) {
718
+ const prepared = await c.prepare(sql, { signal: ctx.signal });
719
+ try {
720
+ return await prepared.query(params, statementOptions(ctx));
721
+ }
722
+ finally {
723
+ await prepared.close().catch(() => undefined);
724
+ }
725
+ }
726
+ return c.query(sql, params, statementOptions(ctx));
727
+ }, options);
728
+ }
729
+ /**
730
+ * Executes a query and reads at most `maxRows + 1` rows from one result stream, then
731
+ * closes the stream, which stops the statement on the server: no further batches are
732
+ * transferred or computed.
733
+ */
734
+ queryCapped(sql, params, maxRows, options) {
735
+ return this.run(async (c, ctx) => {
736
+ // One partition: a capped result is small, and one stream stops cleanly at the cap.
737
+ const callOptions = { ...statementOptions(ctx), resultPartitions: 1 };
738
+ let prepared;
739
+ try {
740
+ let stream;
741
+ if (params instanceof Table) {
742
+ prepared = await c.prepare(sql, { signal: ctx.signal });
743
+ stream = prepared.stream(params, callOptions);
744
+ }
745
+ else {
746
+ stream = c.stream(sql, params, callOptions);
747
+ }
748
+ const batches = [];
749
+ let rows = 0;
750
+ try {
751
+ for await (const batch of stream) {
752
+ batches.push(batch);
753
+ rows += batch.numRows;
754
+ if (rows > maxRows)
755
+ break; // closes the stream
756
+ }
757
+ }
758
+ finally {
759
+ await stream.close().catch(() => undefined);
760
+ }
761
+ const table = batches.length > 0 ? new Table(batches) : new Table(await stream.schema());
762
+ return { table: rows > maxRows ? table.slice(0, maxRows) : table, truncated: rows > maxRows };
763
+ }
764
+ finally {
765
+ await prepared?.close().catch(() => undefined);
766
+ }
767
+ }, options);
768
+ }
769
+ /** Convenience: execute a statement returning the affected-row count (-1 when the engine does not count). */
770
+ update(sql, params, options) {
771
+ return this.run(async (c, ctx) => {
772
+ let count;
773
+ if (params instanceof Table) {
774
+ const prepared = await c.prepare(sql, { signal: ctx.signal });
775
+ try {
776
+ count = await prepared.execute(params, statementOptions(ctx));
777
+ }
778
+ finally {
779
+ await prepared.close().catch(() => undefined);
780
+ }
781
+ }
782
+ else {
783
+ count = await c.execute(sql, params, statementOptions(ctx));
784
+ }
785
+ return count ?? -1;
786
+ }, options);
787
+ }
788
+ }
789
+ function statementOptions(ctx) {
790
+ const out = {};
791
+ if (ctx.signal)
792
+ out.signal = ctx.signal;
793
+ if (ctx.statementTimeoutMs !== undefined)
794
+ out.statementTimeoutMs = ctx.statementTimeoutMs;
795
+ return out;
796
+ }
797
+ //# sourceMappingURL=connection.js.map