@gizmodata/hardcharger-mcp 0.0.0 → 2.1.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.
package/dist/server.js ADDED
@@ -0,0 +1,753 @@
1
+ // Transport-agnostic MCP server: tool + resource definitions for Hardcharger.
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { HardchargerConnection, QueryTimeoutError, errorCode } from "./connection.js";
5
+ import { cellToJson, cellToText, escapeMarkdownCell, formatTable, resultFooter, toMarkdownTable, truncateText, } from "./format.js";
6
+ import { boundCount, convertParams, paramsSchema, PARAMS_DESCRIPTION, ParameterError } from "./params.js";
7
+ import { ConnectionRegistry } from "./registry.js";
8
+ import { runSsoLogin } from "./sso.js";
9
+ import { countPlaceholders, guardStatement, normalizeStatement, WRAPPER_ALIAS, wrapWithLimit, } from "./sql-guard.js";
10
+ import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.js";
11
+ function instructions(registry, transport, perUser) {
12
+ const multi = registry.names().length > 1;
13
+ const scope = transport === "http" && !perUser ? " Session state (USE, use_connection) is shared by every client of this server." : "";
14
+ return ("Hardcharger is GizmoData's DuckDB 2.0 server (it speaks Chargewire), so DuckDB SQL syntax and functions apply. " +
15
+ "Start with list_catalogs / list_schemas / list_tables / describe_table to discover the schema, then " +
16
+ "use run_query for SELECT queries. Results are capped at a configurable number of rows; use LIMIT, " +
17
+ "aggregation, or WHERE filters to keep results small. Bind user-supplied literals with ? placeholders " +
18
+ "and the params argument instead of interpolating them into SQL. Unless HARDCHARGER_ALLOW_WRITES is " +
19
+ "enabled the server is read-only. Unqualified table names resolve against the session's current " +
20
+ "catalog and schema (see server_info); use use_schema or fully qualified names to work elsewhere." +
21
+ scope +
22
+ (multi
23
+ ? ` Several Hardcharger servers are configured (${registry.names().join(", ")}; current: ${registry.current()}). ` +
24
+ "Every tool accepts an optional connection argument, or call use_connection to switch the default; " +
25
+ "list_connections shows them."
26
+ : ""));
27
+ }
28
+ /** Result envelope metadata so a client can tell which build answered without calling server_info. */
29
+ const RESULT_META = { hardcharger_mcp: { name: PACKAGE_NAME, version: PACKAGE_VERSION } };
30
+ function text(body, structured) {
31
+ // Hosts such as Claude Desktop show structuredContent to the model but not
32
+ // _meta, so the version travels in both.
33
+ return {
34
+ content: [{ type: "text", text: body }],
35
+ structuredContent: { ...structured, mcp_server_version: PACKAGE_VERSION },
36
+ _meta: RESULT_META,
37
+ };
38
+ }
39
+ function errorResult(message) {
40
+ // No structuredContent here: clients validate it against run_query's
41
+ // output schema even for errors. The version rides in the text instead.
42
+ return {
43
+ isError: true,
44
+ content: [{ type: "text", text: `Error: ${message}\n\n(${PACKAGE_NAME} ${PACKAGE_VERSION})` }],
45
+ _meta: RESULT_META,
46
+ };
47
+ }
48
+ /**
49
+ * Converts a thrown error into a redacted, user-facing message. The client's errors
50
+ * answered by the server end with their code, SQLSTATE, HTTP status and request id, e.g.
51
+ * "Catalog Error: Table with name nope does not exist! ... (sql_error, SQLSTATE 42P01,
52
+ * HTTP 400, request r_...)", which is what a bug report needs, so they pass through.
53
+ */
54
+ export function describeError(err, redact) {
55
+ let text = (err instanceof Error ? err.message : String(err)).replace(/^\[hardcharger\] /u, "").trim();
56
+ // Every client error has a code; the ones raised before an HTTP answer
57
+ // (client_transport_error, client_invalid_argument, ...) don't say it in their message, so it is added for the caller.
58
+ const code = errorCode(err) ?? errorCode(err?.cause);
59
+ if (code !== undefined && !text.includes(code))
60
+ text += ` (${code})`;
61
+ return redact(text);
62
+ }
63
+ /** Rejects a call whose parameter count does not match the statement's placeholders. */
64
+ function assertParameterCount(sql, bound) {
65
+ const expected = countPlaceholders(sql);
66
+ const supplied = boundCount(bound);
67
+ if (expected === supplied)
68
+ return;
69
+ const ph = (n) => `${n} placeholder${n === 1 ? "" : "s"}`;
70
+ const pv = (n) => `${n} parameter${n === 1 ? "" : "s"}`;
71
+ throw new ParameterError(`The statement has ${ph(expected)} (? or $n) but ${pv(supplied)} ${supplied === 1 ? "was" : "were"} supplied in params. ` +
72
+ "Pass exactly one value per placeholder, in order.");
73
+ }
74
+ /**
75
+ * Wraps a raw Zod shape so the SDK advertises it as JSON Schema 2020-12.
76
+ * The MCP SDK converts Zod v4 schemas with a draft-07 target and stamps
77
+ * `$schema: draft-07` on every tool schema (typescript-sdk #2721); strict
78
+ * hosts such as Claude Desktop reject such tools before calling them.
79
+ * Zod merges `.meta()` into the generated schema, overriding the stamp.
80
+ */
81
+ const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema";
82
+ function jsonSchema2020(shape) {
83
+ return z.object(shape).meta({ $schema: JSON_SCHEMA_2020_12 });
84
+ }
85
+ /**
86
+ * Per-backend temporary schemas of an attached Postgres database
87
+ * (`pg_temp_N` / `pg_toast_temp_N`). They hold nothing a client can use,
88
+ * one pair exists per Postgres backend, and there can be hundreds, so
89
+ * list_schemas never enumerates them.
90
+ */
91
+ export function isTransientSchema(name) {
92
+ const lower = name.toLowerCase();
93
+ return lower.startsWith("pg_temp_") || lower.startsWith("pg_toast_temp_");
94
+ }
95
+ /**
96
+ * Schemas DuckDB and the Postgres-compatibility layer create in every
97
+ * catalog. Attached Postgres databases also expose one `pg_temp_N` /
98
+ * `pg_toast_temp_N` pair per backend, which can run into the hundreds.
99
+ */
100
+ export function isSystemSchema(name) {
101
+ const lower = name.toLowerCase();
102
+ return (isTransientSchema(name) ||
103
+ lower === "information_schema" ||
104
+ lower === "pg_catalog" ||
105
+ lower === "pg_toast");
106
+ }
107
+ const sqlArg = z.string().min(1).describe("A single SQL statement (DuckDB dialect).");
108
+ const connectionArg = z
109
+ .string()
110
+ .optional()
111
+ .describe("Name of the configured connection to use (see list_connections); defaults to the current one.");
112
+ /** Identifier quoting for generated SQL (used only for DDL synthesis). */
113
+ function quoteIdent(name) {
114
+ return `"${name.replace(/"/g, '""')}"`;
115
+ }
116
+ /** Table/view DDL from DuckDB's catalog functions, or synthesized from information_schema. */
117
+ async function fetchDdl(connection, ref) {
118
+ const fromTables = await connection.query("SELECT sql FROM duckdb_tables() WHERE database_name = ? AND schema_name = ? AND table_name = ?", [ref.catalog, ref.schema, ref.table]);
119
+ if (fromTables.numRows > 0) {
120
+ const sql = fromTables.getChildAt(0)?.get(0);
121
+ if (typeof sql === "string" && sql.trim())
122
+ return sql;
123
+ }
124
+ const fromViews = await connection.query("SELECT sql FROM duckdb_views() WHERE database_name = ? AND schema_name = ? AND view_name = ?", [ref.catalog, ref.schema, ref.table]);
125
+ if (fromViews.numRows > 0) {
126
+ const sql = fromViews.getChildAt(0)?.get(0);
127
+ if (typeof sql === "string" && sql.trim())
128
+ return sql;
129
+ }
130
+ const cols = await connection.query("SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
131
+ "WHERE table_catalog = ? AND table_schema = ? AND table_name = ? ORDER BY ordinal_position", [ref.catalog, ref.schema, ref.table]);
132
+ if (cols.numRows === 0)
133
+ return null;
134
+ const lines = [];
135
+ for (let i = 0; i < cols.numRows; i++) {
136
+ const name = String(cols.getChildAt(0)?.get(i));
137
+ const type = String(cols.getChildAt(1)?.get(i));
138
+ const nullable = String(cols.getChildAt(2)?.get(i)) !== "NO";
139
+ lines.push(` ${quoteIdent(name)} ${type}${nullable ? "" : " NOT NULL"}`);
140
+ }
141
+ return `CREATE TABLE ${quoteIdent(ref.catalog)}.${quoteIdent(ref.schema)}.${quoteIdent(ref.table)} (\n${lines.join(",\n")}\n);`;
142
+ }
143
+ /** The string column `name` of a metadata table, row by row. */
144
+ function column(table, name) {
145
+ const vector = table.getChild(name);
146
+ const out = [];
147
+ for (let i = 0; i < table.numRows; i++)
148
+ out.push(String(vector?.get(i) ?? ""));
149
+ return out;
150
+ }
151
+ async function listCatalogs(c) {
152
+ return column(await c.metadata.catalogs(), "catalog_name");
153
+ }
154
+ async function listSchemas(c, catalog) {
155
+ const t = await c.metadata.dbSchemas(catalog !== undefined ? { catalog } : {});
156
+ const catalogs = column(t, "catalog_name");
157
+ return column(t, "db_schema_name").map((schema, i) => ({ catalog: catalogs[i], schema }));
158
+ }
159
+ async function listTables(c, catalog, schema, like) {
160
+ const options = {};
161
+ if (catalog !== undefined)
162
+ options.catalog = catalog;
163
+ if (schema !== undefined)
164
+ options.dbSchemaFilterPattern = schema;
165
+ if (like !== undefined)
166
+ options.tableNameFilterPattern = like;
167
+ const t = await c.metadata.tables(options);
168
+ const catalogs = column(t, "catalog_name");
169
+ const schemas = column(t, "db_schema_name");
170
+ const types = column(t, "table_type");
171
+ return column(t, "table_name").map((tableName, i) => ({
172
+ catalog: catalogs[i],
173
+ schema: schemas[i],
174
+ tableName,
175
+ tableType: types[i],
176
+ }));
177
+ }
178
+ /** Builds the McpServer with every tool/resource registered against `ctx`. */
179
+ export function createServer(ctx) {
180
+ const { registry, config } = ctx;
181
+ const redact = (t) => registry.redact(t);
182
+ const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, { instructions: instructions(registry, ctx.transport, ctx.session !== undefined) });
183
+ /**
184
+ * Wraps a tool body so every failure becomes a redacted isError result,
185
+ * and tells the caller once when their previous session expired (their
186
+ * use_schema / use_connection choices no longer apply).
187
+ */
188
+ const tool = (fn) => {
189
+ return async (args) => {
190
+ let result;
191
+ try {
192
+ result = await fn(args);
193
+ }
194
+ catch (err) {
195
+ return errorResult(describeError(err, redact));
196
+ }
197
+ const resetAt = ctx.session?.resetAt;
198
+ if (resetAt && !result.isError) {
199
+ const note = `(Note: your previous session expired after ${config.mcpSessionIdleSeconds}s idle at ${resetAt.toISOString()}; ` +
200
+ "a new one started with the configured defaults, so earlier use_schema / use_connection choices no longer apply.)";
201
+ const first = result.content.find((c) => c.type === "text");
202
+ if (first)
203
+ first.text = `${first.text}\n\n${note}`;
204
+ else
205
+ result.content.push({ type: "text", text: note });
206
+ if (result.structuredContent) {
207
+ result.structuredContent = { ...result.structuredContent, session_reset: { expired_at: resetAt.toISOString() } };
208
+ }
209
+ ctx.acknowledgeSessionReset?.();
210
+ }
211
+ return result;
212
+ };
213
+ };
214
+ /** Resolves the connection for a tool call (throws UnknownConnectionError). */
215
+ const conn = (name) => registry.get(name);
216
+ server.registerTool("list_connections", {
217
+ title: "List connections",
218
+ description: "Lists the configured Hardcharger connections (name, host, auth method, default catalog/schema) and " +
219
+ "which one is current. Credentials are never returned.",
220
+ inputSchema: jsonSchema2020({}),
221
+ annotations: { readOnlyHint: true, idempotentHint: true },
222
+ }, tool(async () => {
223
+ const rows = registry.summaries();
224
+ const md = toMarkdownTable(["name", "host", "port", "tls", "auth", "default catalog", "default schema", "current"], rows.map((r) => [
225
+ escapeMarkdownCell(r.name),
226
+ escapeMarkdownCell(r.host),
227
+ String(r.port),
228
+ r.tls ? "yes" : "no",
229
+ escapeMarkdownCell(r.auth),
230
+ escapeMarkdownCell(r.default_catalog ?? ""),
231
+ escapeMarkdownCell(r.default_schema ?? ""),
232
+ r.current ? "✓" : "",
233
+ ]));
234
+ return text(md, { connections: rows, current: registry.current() });
235
+ }));
236
+ server.registerTool("use_connection", {
237
+ title: "Switch connection",
238
+ description: "Makes the named connection the default for subsequent tool calls (see list_connections). " +
239
+ "Tools also accept a per-call connection argument.",
240
+ inputSchema: jsonSchema2020({ name: z.string().min(1).describe("Connection name.") }),
241
+ annotations: { readOnlyHint: true, idempotentHint: true },
242
+ }, tool(async ({ name }) => {
243
+ const canonical = registry.use(name);
244
+ const c = registry.configFor(canonical);
245
+ return text(`Current connection is now "${canonical}" (${c.host}:${c.port}).`, {
246
+ current: canonical,
247
+ host: c.host,
248
+ port: c.port,
249
+ });
250
+ }));
251
+ server.registerTool("list_catalogs", {
252
+ title: "List catalogs",
253
+ description: "Lists the catalogs (attached databases) visible to the connected Hardcharger user.",
254
+ inputSchema: jsonSchema2020({ connection: connectionArg }),
255
+ annotations: { readOnlyHint: true, idempotentHint: true },
256
+ }, tool(async ({ connection }) => {
257
+ const catalogs = await conn(connection).run((c) => listCatalogs(c));
258
+ const body = catalogs.length
259
+ ? catalogs.map((c) => `- ${c}`).join("\n")
260
+ : "(no catalogs visible)";
261
+ return text(body, { catalogs });
262
+ }));
263
+ server.registerTool("list_schemas", {
264
+ title: "List schemas",
265
+ description: "Lists schemas, optionally filtered to one catalog. System schemas (information_schema, " +
266
+ "pg_catalog, pg_toast) are hidden unless include_system is true; the per-backend temp schemas " +
267
+ "of attached Postgres databases (pg_temp_N, pg_toast_temp_N) are never listed. DuckDB keeps " +
268
+ "information_schema and pg_catalog in the `system` catalog only, so a user catalog normally " +
269
+ "lists just its own schemas even with include_system.",
270
+ inputSchema: jsonSchema2020({
271
+ catalog: z.string().optional().describe("Catalog name to filter by (exact match)."),
272
+ include_system: z
273
+ .boolean()
274
+ .optional()
275
+ .describe("Also list system schemas (information_schema, pg_catalog, pg_toast). Default false."),
276
+ connection: connectionArg,
277
+ }),
278
+ annotations: { readOnlyHint: true, idempotentHint: true },
279
+ }, tool(async ({ catalog, include_system, connection }) => {
280
+ const found = await conn(connection).run((c) => listSchemas(c, catalog));
281
+ const all = found.filter((s) => !isTransientSchema(s.schema));
282
+ const hiddenTemp = found.length - all.length;
283
+ const schemas = include_system ? all : all.filter((s) => !isSystemSchema(s.schema));
284
+ const hidden = all.length - schemas.length;
285
+ const rows = schemas.map((s) => [escapeMarkdownCell(s.catalog), escapeMarkdownCell(s.schema)]);
286
+ let body = rows.length ? toMarkdownTable(["catalog", "schema"], rows) : "(no schemas found)";
287
+ if (hidden > 0)
288
+ body += `\n\n(${hidden} system schema${hidden === 1 ? "" : "s"} hidden; pass include_system: true to list them.)`;
289
+ return text(body, { schemas, hidden_system_schemas: hidden, hidden_temp_schemas: hiddenTemp });
290
+ }));
291
+ server.registerTool("list_tables", {
292
+ title: "List tables",
293
+ description: "Lists tables and views with their type. Filters are optional; `like` is a SQL LIKE pattern " +
294
+ "on the table name (e.g. 'orders%').",
295
+ inputSchema: jsonSchema2020({
296
+ catalog: z.string().optional().describe("Catalog name (exact match)."),
297
+ schema: z.string().optional().describe("Schema name (SQL LIKE pattern, e.g. 'main')."),
298
+ like: z.string().optional().describe("Table-name LIKE pattern, e.g. 'cust%'."),
299
+ connection: connectionArg,
300
+ }),
301
+ annotations: { readOnlyHint: true, idempotentHint: true },
302
+ }, tool(async ({ catalog, schema, like, connection }) => {
303
+ const tables = await conn(connection).run((c) => listTables(c, catalog, schema, like));
304
+ const rows = tables.map((t) => [
305
+ escapeMarkdownCell(t.catalog),
306
+ escapeMarkdownCell(t.schema),
307
+ escapeMarkdownCell(t.tableName),
308
+ escapeMarkdownCell(t.tableType),
309
+ ]);
310
+ const body = rows.length
311
+ ? toMarkdownTable(["catalog", "schema", "table", "type"], rows)
312
+ : "(no tables matched)";
313
+ return text(body, { tables });
314
+ }));
315
+ server.registerTool("describe_table", {
316
+ title: "Describe table",
317
+ description: "Describes a table or view: columns with types and nullability, constraints, and an " +
318
+ "estimated row count when it is cheap to obtain. Provide schema/catalog when the table " +
319
+ "name is ambiguous.",
320
+ inputSchema: jsonSchema2020({
321
+ table: z.string().min(1).describe("Table or view name (exact match)."),
322
+ schema: z.string().optional().describe("Schema name (exact match)."),
323
+ catalog: z.string().optional().describe("Catalog name (exact match)."),
324
+ connection: connectionArg,
325
+ }),
326
+ annotations: { readOnlyHint: true, idempotentHint: true },
327
+ }, tool(async ({ table, schema, catalog, connection: connectionName }) => {
328
+ const connection = conn(connectionName);
329
+ const filters = ["table_name = ?"];
330
+ const params = [table];
331
+ if (schema !== undefined) {
332
+ filters.push("table_schema = ?");
333
+ params.push(schema);
334
+ }
335
+ if (catalog !== undefined) {
336
+ filters.push("table_catalog = ?");
337
+ params.push(catalog);
338
+ }
339
+ const columns = await connection.query("SELECT table_catalog, table_schema, table_name, column_name, data_type, is_nullable, column_default " +
340
+ `FROM information_schema.columns WHERE ${filters.join(" AND ")} ORDER BY table_catalog, table_schema, ordinal_position`, params);
341
+ if (columns.numRows === 0) {
342
+ return errorResult(`Table "${table}" not found${schema ? ` in schema "${schema}"` : ""}${catalog ? ` (catalog "${catalog}")` : ""}. ` +
343
+ "Use list_tables to find the exact name.");
344
+ }
345
+ const locations = new Set();
346
+ for (let i = 0; i < columns.numRows; i++) {
347
+ locations.add(`${columns.getChildAt(0)?.get(i)}.${columns.getChildAt(1)?.get(i)}`);
348
+ }
349
+ if (locations.size > 1) {
350
+ return errorResult(`Table "${table}" exists in multiple locations: ${[...locations].join(", ")}. ` +
351
+ "Pass schema (and catalog) to disambiguate.");
352
+ }
353
+ const ref = {
354
+ catalog: String(columns.getChildAt(0)?.get(0)),
355
+ schema: String(columns.getChildAt(1)?.get(0)),
356
+ table,
357
+ };
358
+ const colRows = [];
359
+ const colJson = [];
360
+ for (let i = 0; i < columns.numRows; i++) {
361
+ const name = String(columns.getChildAt(3)?.get(i));
362
+ const type = String(columns.getChildAt(4)?.get(i));
363
+ const nullable = String(columns.getChildAt(5)?.get(i)) === "YES";
364
+ const def = columns.getChildAt(6)?.get(i);
365
+ colRows.push([
366
+ escapeMarkdownCell(name),
367
+ escapeMarkdownCell(type),
368
+ nullable ? "YES" : "NO",
369
+ escapeMarkdownCell(def === null || def === undefined ? "" : String(def)),
370
+ ]);
371
+ colJson.push({ name, type, nullable, default: def === undefined ? null : def });
372
+ }
373
+ const meta = await connection.query("SELECT 'BASE TABLE' AS kind, estimated_size FROM duckdb_tables() " +
374
+ "WHERE database_name = ? AND schema_name = ? AND table_name = ? " +
375
+ "UNION ALL SELECT 'VIEW', NULL FROM duckdb_views() " +
376
+ "WHERE database_name = ? AND schema_name = ? AND view_name = ?", [ref.catalog, ref.schema, ref.table, ref.catalog, ref.schema, ref.table]);
377
+ let kind = "TABLE";
378
+ let estimatedRows = null;
379
+ if (meta.numRows > 0) {
380
+ kind = String(meta.getChildAt(0)?.get(0));
381
+ const est = meta.getChildAt(1)?.get(0);
382
+ if (est !== null && est !== undefined)
383
+ estimatedRows = Number(est);
384
+ }
385
+ if (estimatedRows === 0) {
386
+ // DuckDB reports 0 for tables it has no statistics for (attached
387
+ // Postgres/SQLite catalogs, freshly attached files). A one-row probe
388
+ // is cheap and turns a misleading 0 into "unknown" when data exists.
389
+ // The probe can be refused (e.g. Hardcharger's system-managed
390
+ // instrumentation catalog is admin-only); metadata is still useful
391
+ // then, so a failed probe just leaves the estimate unknown.
392
+ try {
393
+ const probe = await connection.query(`SELECT 1 FROM ${quoteIdent(ref.catalog)}.${quoteIdent(ref.schema)}.${quoteIdent(ref.table)} LIMIT 1`);
394
+ if (probe.numRows > 0)
395
+ estimatedRows = null;
396
+ }
397
+ catch {
398
+ estimatedRows = null;
399
+ }
400
+ }
401
+ const constraints = await connection.query("SELECT constraint_type, constraint_column_names, constraint_text FROM duckdb_constraints() " +
402
+ "WHERE database_name = ? AND schema_name = ? AND table_name = ? " +
403
+ "AND constraint_type IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY', 'CHECK') " +
404
+ "ORDER BY constraint_index", [ref.catalog, ref.schema, ref.table]);
405
+ const constraintJson = [];
406
+ const constraintLines = [];
407
+ for (let i = 0; i < constraints.numRows; i++) {
408
+ const type = String(constraints.getChildAt(0)?.get(i));
409
+ const cols = cellToJson(constraints.getChildAt(1)?.get(i), constraints.schema.fields[1].type);
410
+ const ctext = String(constraints.getChildAt(2)?.get(i) ?? "");
411
+ constraintJson.push({ type, columns: cols, text: ctext });
412
+ constraintLines.push(`- ${type}: ${ctext || cellToText(cols)}`);
413
+ }
414
+ const header = `**${ref.catalog}.${ref.schema}.${ref.table}** (${kind})` +
415
+ (estimatedRows !== null ? ` · estimated rows: ${estimatedRows}` : "");
416
+ const parts = [header, "", toMarkdownTable(["column", "type", "nullable", "default"], colRows)];
417
+ if (constraintLines.length)
418
+ parts.push("", "Constraints:", ...constraintLines);
419
+ return text(parts.join("\n"), {
420
+ connection: connection.config.name,
421
+ catalog: ref.catalog,
422
+ schema: ref.schema,
423
+ table: ref.table,
424
+ kind,
425
+ estimated_rows: estimatedRows,
426
+ columns: colJson,
427
+ constraints: constraintJson,
428
+ });
429
+ }));
430
+ server.registerTool("use_schema", {
431
+ title: "Set default catalog/schema",
432
+ description: "Sets the session's default catalog and/or schema (Chargewire session options, like DuckDB USE) so " +
433
+ "unqualified table names resolve there for the rest of the session, including on a replacement " +
434
+ "session if the server drops this one. Does not read or modify data.",
435
+ inputSchema: jsonSchema2020({
436
+ catalog: z.string().optional().describe("Catalog (database) name."),
437
+ schema: z.string().optional().describe("Schema name within the catalog."),
438
+ connection: connectionArg,
439
+ }),
440
+ annotations: { readOnlyHint: true, idempotentHint: true },
441
+ }, tool(async ({ catalog, schema, connection: connectionName }) => {
442
+ if (!catalog && !schema)
443
+ return errorResult("Provide a catalog and/or a schema.");
444
+ const connection = conn(connectionName);
445
+ await connection.useSchema({ catalog, schema });
446
+ const t = await connection.query("SELECT current_catalog() AS c, current_schema() AS s");
447
+ // `t` is what the server resolves now, after the session options were applied.
448
+ const current = { catalog: String(t.getChildAt(0)?.get(0)), schema: String(t.getChildAt(1)?.get(0)) };
449
+ return text(`Default set to ${current.catalog}.${current.schema} for this session.`, current);
450
+ }));
451
+ server.registerTool("run_query", {
452
+ title: "Run query",
453
+ description: "Runs a SQL query against Hardcharger (DuckDB dialect) and returns the rows as a Markdown table " +
454
+ `plus structured JSON. Results are capped at max_rows (default ${config.maxRows}, hard maximum ` +
455
+ `${config.maxRows}); long cells are truncated to ${config.maxCellChars} characters with …. ` +
456
+ (config.allowWrites
457
+ ? "Writes are enabled on this server; prefer execute_statement for DML/DDL."
458
+ : "This server is read-only: only SELECT/WITH/SHOW/DESCRIBE/SUMMARIZE/EXPLAIN/PRAGMA(read)/USE statements are accepted."),
459
+ inputSchema: jsonSchema2020({
460
+ sql: sqlArg,
461
+ params: paramsSchema.optional().describe(PARAMS_DESCRIPTION),
462
+ max_rows: z
463
+ .number()
464
+ .int()
465
+ .min(1)
466
+ .optional()
467
+ .describe(`Row cap for this call (1..${config.maxRows}; default ${config.maxRows}).`),
468
+ connection: connectionArg,
469
+ }),
470
+ outputSchema: jsonSchema2020({
471
+ columns: z.array(z.object({ name: z.string(), type: z.string() })),
472
+ rows: z.array(z.array(z.unknown())),
473
+ row_count: z.number(),
474
+ truncated: z.boolean(),
475
+ elapsed_ms: z.number(),
476
+ connection: z.string(),
477
+ mcp_server_version: z.string(),
478
+ }),
479
+ annotations: { readOnlyHint: !config.allowWrites, idempotentHint: !config.allowWrites },
480
+ }, tool(async ({ sql, params, max_rows, connection: connectionName }) => {
481
+ const connection = conn(connectionName);
482
+ const guarded = guardStatement(sql, config.allowWrites);
483
+ const maxRows = Math.min(max_rows ?? config.maxRows, config.maxRows);
484
+ const bound = convertParams(params);
485
+ assertParameterCount(guarded.sql, bound);
486
+ const effectiveSql = guarded.classification.wrappable
487
+ ? wrapWithLimit(guarded.sql, maxRows + 1)
488
+ : guarded.sql;
489
+ const started = Date.now();
490
+ let capped;
491
+ try {
492
+ capped = await connection.queryCapped(effectiveSql, bound, maxRows);
493
+ }
494
+ catch (err) {
495
+ // Only errors that cite a position need the wrapper explained.
496
+ const described = describeError(err, redact);
497
+ if (guarded.classification.wrappable && !(err instanceof QueryTimeoutError) && /\bLINE \d+\b/u.test(described)) {
498
+ throw new Error(`${described}\n(Note: to enforce max_rows the query was executed as ` +
499
+ `SELECT * FROM (<your query>) AS ${WRAPPER_ALIAS} LIMIT ${maxRows + 1}, so line numbers in the ` +
500
+ "server's message are offset by one.)", { cause: err });
501
+ }
502
+ throw err;
503
+ }
504
+ const elapsedMs = Date.now() - started;
505
+ // A USE changes the session's catalog/schema on the server; pin it as session options
506
+ // so the client replays it on a replacement session, as it does for use_schema.
507
+ if (guarded.classification.keyword === "USE")
508
+ await connection.syncSearchPath();
509
+ const formatted = formatTable(capped.table, { maxRows, maxCellChars: config.maxCellChars });
510
+ const truncated = formatted.truncated || capped.truncated;
511
+ const footer = resultFooter({
512
+ rowCount: formatted.rowCount,
513
+ truncated,
514
+ maxRows,
515
+ elapsedMs,
516
+ });
517
+ const body = formatted.rowCount > 0 ? `${formatted.markdown}\n\n${footer}` : `(no rows)\n\n${footer}`;
518
+ return text(body, {
519
+ columns: formatted.columns,
520
+ rows: formatted.rows,
521
+ row_count: formatted.rowCount,
522
+ truncated,
523
+ elapsed_ms: elapsedMs,
524
+ connection: connection.config.name,
525
+ });
526
+ }));
527
+ server.registerTool("explain_query", {
528
+ title: "Explain query",
529
+ description: "Returns DuckDB's EXPLAIN output for a SQL statement without executing it: the physical plan " +
530
+ "as Markdown plus structured JSON (physical_plan, sections).",
531
+ inputSchema: jsonSchema2020({ sql: sqlArg, connection: connectionArg }),
532
+ annotations: { readOnlyHint: true, idempotentHint: true },
533
+ }, tool(async ({ sql, connection: connectionName }) => {
534
+ const connection = conn(connectionName);
535
+ const guarded = guardStatement(sql, config.allowWrites);
536
+ const normalized = normalizeStatement(guarded.sql);
537
+ const explainSql = /^\s*EXPLAIN\b/iu.test(normalized) ? normalized : `EXPLAIN ${normalized}`;
538
+ const table = await connection.query(explainSql);
539
+ const sections = [];
540
+ const keyCol = table.schema.fields.findIndex((f) => f.name === "explain_key");
541
+ const valCol = table.schema.fields.findIndex((f) => f.name === "explain_value");
542
+ for (let i = 0; i < table.numRows; i++) {
543
+ const key = keyCol >= 0 ? String(table.getChildAt(keyCol)?.get(i)) : `row ${i + 1}`;
544
+ const value = valCol >= 0
545
+ ? String(table.getChildAt(valCol)?.get(i) ?? "")
546
+ : table.schema.fields.map((_, c) => String(table.getChildAt(c)?.get(i))).join(" ");
547
+ sections.push({ key, plan: value.replace(/\s+$/u, "") });
548
+ }
549
+ const body = sections.map((s) => `### ${s.key}\n\`\`\`\n${s.plan}\n\`\`\``).join("\n\n");
550
+ // DuckDB returns one physical_plan row; other EXPLAIN variants may add more sections.
551
+ const physical = sections.find((s) => s.key === "physical_plan") ?? sections[0];
552
+ return text(body || "(no plan returned)", {
553
+ connection: connection.config.name,
554
+ physical_plan: physical?.plan ?? null,
555
+ sections,
556
+ });
557
+ }));
558
+ if (config.allowWrites) {
559
+ server.registerTool("execute_statement", {
560
+ title: "Execute statement",
561
+ description: "Executes a DML/DDL statement (INSERT/UPDATE/DELETE/CREATE/...) with optional bound parameters " +
562
+ "and returns the affected-row count (-1 when the server does not report one).",
563
+ inputSchema: jsonSchema2020({
564
+ sql: sqlArg,
565
+ params: paramsSchema.optional().describe(PARAMS_DESCRIPTION),
566
+ connection: connectionArg,
567
+ }),
568
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
569
+ }, tool(async ({ sql, params, connection: connectionName }) => {
570
+ const connection = conn(connectionName);
571
+ const guarded = guardStatement(sql, true);
572
+ const bound = convertParams(params);
573
+ assertParameterCount(guarded.sql, bound);
574
+ const started = Date.now();
575
+ const affected = await connection.update(guarded.sql, bound);
576
+ const elapsedMs = Date.now() - started;
577
+ const count = affected < 0 ? "unknown" : String(affected);
578
+ return text(`Statement executed. Affected rows: ${count} · ${elapsedMs} ms`, {
579
+ affected_rows: affected,
580
+ elapsed_ms: elapsedMs,
581
+ connection: connection.config.name,
582
+ });
583
+ }));
584
+ }
585
+ server.registerTool("server_info", {
586
+ title: "Server info",
587
+ description: "Reports the Hardcharger and DuckDB versions of a connection, its connection URI (credentials " +
588
+ "redacted), the effective limits, every configured connection, and this MCP server's version.",
589
+ inputSchema: jsonSchema2020({ connection: connectionArg }),
590
+ annotations: { readOnlyHint: true, idempotentHint: true },
591
+ }, tool(async ({ connection: connectionName }) => {
592
+ const connection = conn(connectionName);
593
+ const cfg = connection.config;
594
+ let hardchargerVersion = "unknown";
595
+ let duckdbVersion = "unknown";
596
+ let user = "unknown";
597
+ let role = "unknown";
598
+ let currentCatalog = "unknown";
599
+ let currentSchema = "unknown";
600
+ try {
601
+ const t = await connection.query("SELECT HARDCHARGER_VERSION() AS hv, version() AS dv, HARDCHARGER_USER() AS u, HARDCHARGER_ROLE() AS r, " +
602
+ "current_catalog() AS c, current_schema() AS s");
603
+ hardchargerVersion = String(t.getChildAt(0)?.get(0));
604
+ duckdbVersion = String(t.getChildAt(1)?.get(0));
605
+ user = String(t.getChildAt(2)?.get(0));
606
+ role = String(t.getChildAt(3)?.get(0));
607
+ currentCatalog = String(t.getChildAt(4)?.get(0));
608
+ currentSchema = String(t.getChildAt(5)?.get(0));
609
+ }
610
+ catch {
611
+ // A Chargewire server other than Hardcharger: its server-info document names it.
612
+ try {
613
+ const info = await connection.run((c) => c.metadata.serverInfo());
614
+ hardchargerVersion = `${String(info.server_name ?? "unknown server")} ${String(info.server_version ?? "")}`.trim();
615
+ duckdbVersion = String(info["duckdb_version"] ?? "unknown");
616
+ }
617
+ catch {
618
+ // leave unknowns
619
+ }
620
+ }
621
+ const auth = connection.effectiveAuth();
622
+ const discovery = connection.discovery();
623
+ const settings = connection.serverSettingsSnapshot();
624
+ const info = {
625
+ connection: cfg.name,
626
+ current_connection: registry.current(),
627
+ hardcharger_version: hardchargerVersion,
628
+ duckdb_version: duckdbVersion,
629
+ connection_uri: cfg.uri,
630
+ auth_method: auth.method,
631
+ user,
632
+ role,
633
+ tls: cfg.tls,
634
+ tls_skip_verify: cfg.tlsSkipVerify,
635
+ current_catalog: currentCatalog,
636
+ current_schema: currentSchema,
637
+ allow_writes: config.allowWrites,
638
+ max_rows: config.maxRows,
639
+ max_cell_chars: config.maxCellChars,
640
+ query_timeout_seconds: config.queryTimeoutSeconds,
641
+ default_catalog: connection.currentSearchPath().catalog ?? null,
642
+ default_schema: connection.currentSearchPath().schema ?? null,
643
+ transport: ctx.transport,
644
+ authenticated_user: ctx.user ? ctx.user.name : null,
645
+ session_scope: ctx.transport === "stdio" ? "process" : ctx.session ? "per-user" : "shared",
646
+ session_started: ctx.session ? ctx.session.createdAt.toISOString() : null,
647
+ session_idle_timeout_seconds: ctx.session ? config.mcpSessionIdleSeconds : null,
648
+ // Hardcharger's own idle timeout for the session; the client replaces a session the
649
+ // server dropped and replays its catalog and schema.
650
+ hardcharger_session_idle_timeout_seconds: await connection.sessionIdleTimeoutSeconds(),
651
+ session_replacements: connection.sessionReplacements(),
652
+ reconnects: connection.reconnects,
653
+ protocol: discovery ? `${discovery.protocol} ${(discovery.protocol_versions ?? []).join(",")}`.trim() : null,
654
+ capabilities: discovery?.capabilities ?? [],
655
+ hardcharger_settings: settings,
656
+ mcp_server: `${PACKAGE_NAME} ${PACKAGE_VERSION}`,
657
+ session_warnings: connection.sessionWarnings(),
658
+ connections: registry.summaries(),
659
+ };
660
+ const lines = Object.entries(info)
661
+ .filter(([k]) => k !== "connections" && k !== "hardcharger_settings")
662
+ .map(([k, v]) => `- ${k}: ${Array.isArray(v) ? (v.length ? v.join("; ") : "none") : v === null ? "(not set)" : String(v)}`);
663
+ const shown = Object.entries(settings);
664
+ lines.push(shown.length
665
+ ? `- hardcharger_settings: ${shown.map(([k, v]) => `${k.replace(/^hardcharger\./u, "")}=${v === "" ? '""' : v}`).join(", ")}`
666
+ : "- hardcharger_settings: (not reported by this server)");
667
+ if (info.connections.length > 1) {
668
+ lines.push("- connections: " +
669
+ info.connections.map((c) => `${c.name} (${c.uri})${c.current ? " [current]" : ""}`).join("; "));
670
+ }
671
+ return text(lines.join("\n"), info);
672
+ }));
673
+ // login_sso opens a browser on the machine running the server, which only
674
+ // makes sense for the local stdio transport.
675
+ if (config.enableSso && ctx.transport === "stdio") {
676
+ server.registerTool("login_sso", {
677
+ title: "Sign in with SSO",
678
+ description: "Signs in to Hardcharger with the server's OAuth/SSO identity provider: opens the provider's " +
679
+ "login page in your browser, waits for you to finish, then reconnects with the server's access " +
680
+ "token (kept in memory only). If the wait times out, call login_sso again to keep waiting.",
681
+ inputSchema: jsonSchema2020({
682
+ wait_seconds: z
683
+ .number()
684
+ .int()
685
+ .min(5)
686
+ .max(600)
687
+ .optional()
688
+ .describe("How long to wait for the browser login before returning (default 90)."),
689
+ connection: connectionArg,
690
+ }),
691
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
692
+ }, tool(async ({ wait_seconds, connection: connectionName }) => {
693
+ const outcome = await runSsoLogin(conn(connectionName), { waitSeconds: wait_seconds ?? 90 });
694
+ return text(outcome.message, { status: outcome.status, auth_url: outcome.authUrl ?? null });
695
+ }));
696
+ }
697
+ const multi = registry.names().length > 1;
698
+ server.registerResource("table_schema", new ResourceTemplate("hardcharger://{connection}/schema/{catalog}/{schema}/{table}", {
699
+ list: async () => {
700
+ const resources = [];
701
+ for (const connection of registry.all()) {
702
+ if (resources.length >= 500)
703
+ break;
704
+ let tables;
705
+ try {
706
+ tables = await connection.run((c) => listTables(c));
707
+ }
708
+ catch {
709
+ continue; // an unreachable server must not hide the others
710
+ }
711
+ for (const t of tables) {
712
+ if (resources.length >= 500)
713
+ break;
714
+ const qualified = `${t.catalog}.${t.schema}.${t.tableName}`;
715
+ const label = multi ? `${connection.config.name}: ${qualified}` : qualified;
716
+ resources.push({
717
+ uri: `hardcharger://${encodeURIComponent(connection.config.name)}/schema/${encodeURIComponent(t.catalog)}/${encodeURIComponent(t.schema)}/${encodeURIComponent(t.tableName)}`,
718
+ name: label,
719
+ // Clients (e.g. Claude Desktop's resource picker) display `title`.
720
+ title: label,
721
+ description: `DDL of ${t.tableType.toLowerCase()} ${qualified} on connection ${connection.config.name}`,
722
+ mimeType: "text/plain",
723
+ });
724
+ }
725
+ }
726
+ return { resources };
727
+ },
728
+ }), {
729
+ title: "Table DDL",
730
+ description: "CREATE statement (DDL) for a table or view: hardcharger://{connection}/schema/{catalog}/{schema}/{table}",
731
+ mimeType: "text/plain",
732
+ }, async (uri, variables) => {
733
+ const pick = (v) => decodeURIComponent(Array.isArray(v) ? v[0] : (v ?? ""));
734
+ const ref = {
735
+ catalog: pick(variables.catalog),
736
+ schema: pick(variables.schema),
737
+ table: pick(variables.table),
738
+ };
739
+ try {
740
+ const connection = registry.get(pick(variables.connection));
741
+ const ddl = await fetchDdl(connection, ref);
742
+ const body = ddl ?? `-- ${ref.catalog}.${ref.schema}.${ref.table} not found`;
743
+ return { contents: [{ uri: uri.href, mimeType: "text/plain", text: truncateText(body, 100000) }] };
744
+ }
745
+ catch (err) {
746
+ return {
747
+ contents: [{ uri: uri.href, mimeType: "text/plain", text: `-- error: ${describeError(err, redact)}` }],
748
+ };
749
+ }
750
+ });
751
+ return server;
752
+ }
753
+ //# sourceMappingURL=server.js.map