sqlite-hub 1.5.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.
@@ -1,4 +1,5 @@
1
1
  const express = require("express");
2
+ const path = require("node:path");
2
3
  const { AppError, DatabaseRequiredError, route, successResponse } = require("../utils/errors");
3
4
  const {
4
5
  checkLatestAppVersion,
@@ -7,6 +8,8 @@ const {
7
8
  readSettingsMetadata,
8
9
  readSqliteVersion,
9
10
  } = require("../services/appInfoService");
11
+ const { McpStatusService } = require("../services/mcpStatusService");
12
+ const { MCP_TOOL_DEFINITIONS } = require("../services/mcpToolService");
10
13
 
11
14
  function getActiveTokenContext({ connectionManager, tokenService }) {
12
15
  const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
@@ -34,6 +37,37 @@ function buildSettingsMetadata(context) {
34
37
  };
35
38
  }
36
39
 
40
+ function buildMcpCodexConfig() {
41
+ const serverPath = path.resolve(__dirname, "../../bin/sqlite-hub-mcp.js");
42
+
43
+ return [
44
+ "[mcp_servers.sqlitehub]",
45
+ 'command = "node"',
46
+ `args = [${JSON.stringify(serverPath)}]`,
47
+ "startup_timeout_sec = 10",
48
+ "tool_timeout_sec = 60",
49
+ ].join("\n");
50
+ }
51
+
52
+ function buildMcpSettingsStatus(appStateStore) {
53
+ const statusService = new McpStatusService({
54
+ appStateStore,
55
+ exposedTools: MCP_TOOL_DEFINITIONS,
56
+ transport: "stdio",
57
+ });
58
+ const status = statusService.getStatus();
59
+
60
+ return {
61
+ ...status,
62
+ toolDetails: MCP_TOOL_DEFINITIONS.map((tool) => ({
63
+ name: tool.name,
64
+ description: tool.description,
65
+ })),
66
+ command: `node ${path.resolve(__dirname, "../../bin/sqlite-hub-mcp.js")}`,
67
+ codexConfig: buildMcpCodexConfig(),
68
+ };
69
+ }
70
+
37
71
  function createSettingsRouter({ appStateStore, connectionManager, tokenService, versionCheckService }) {
38
72
  const router = express.Router();
39
73
  const context = { connectionManager, tokenService };
@@ -89,6 +123,17 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
89
123
  })
90
124
  );
91
125
 
126
+ router.get(
127
+ "/mcp",
128
+ route((req, res) => {
129
+ res.json(
130
+ successResponse({
131
+ data: buildMcpSettingsStatus(appStateStore),
132
+ })
133
+ );
134
+ })
135
+ );
136
+
92
137
  router.post(
93
138
  "/api-tokens",
94
139
  route((req, res) => {
@@ -126,6 +171,8 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
126
171
 
127
172
  module.exports = {
128
173
  createSettingsRouter,
174
+ buildMcpCodexConfig,
175
+ buildMcpSettingsStatus,
129
176
  buildSettingsMetadata,
130
177
  checkLatestAppVersion,
131
178
  compareSemver,
@@ -4,9 +4,11 @@ const { ConnectionManager } = require("./sqlite/connectionManager");
4
4
  const { BackupService } = require("./sqlite/backupService");
5
5
  const { DataBrowserService } = require("./sqlite/dataBrowserService");
6
6
  const { ExportService } = require("./sqlite/exportService");
7
- const { getTableDetail } = require("./sqlite/introspection");
8
- const { SqlExecutor } = require("./sqlite/sqlExecutor");
7
+ const { getTableDetail, listSchema } = require("./sqlite/introspection");
8
+ const { OverviewService } = require("./sqlite/overviewService");
9
+ const { SqlExecutor, splitSqlStatements } = require("./sqlite/sqlExecutor");
9
10
  const { TypeGenerationService } = require("./typeGenerationService");
11
+ const { detectQueryType } = require("./storage/queryHistoryUtils");
10
12
 
11
13
  function normalizeLookupValue(value, label) {
12
14
  const normalized = String(value ?? "").trim();
@@ -65,6 +67,99 @@ function normalizeMarkdownExportFilename(filename, fallback = "document.md") {
65
67
  return normalizedFilename;
66
68
  }
67
69
 
70
+ function stripSqlTerminators(sql) {
71
+ return String(sql ?? "").trim().replace(/;+\s*$/g, "").trim();
72
+ }
73
+
74
+ function removeLeadingSqlComments(sql) {
75
+ let text = String(sql ?? "").trim();
76
+ let changed = true;
77
+
78
+ while (changed) {
79
+ changed = false;
80
+ const nextText = text
81
+ .replace(/^--[^\n]*(?:\n|$)/, "")
82
+ .replace(/^\/\*[\s\S]*?\*\//, "")
83
+ .trim();
84
+
85
+ if (nextText !== text) {
86
+ changed = true;
87
+ text = nextText;
88
+ }
89
+ }
90
+
91
+ return text;
92
+ }
93
+
94
+ function explainInnerSql(statement) {
95
+ return removeLeadingSqlComments(statement)
96
+ .replace(/^EXPLAIN\s+QUERY\s+PLAN\s+/i, "")
97
+ .replace(/^EXPLAIN\s+/i, "")
98
+ .trim();
99
+ }
100
+
101
+ function isAllowedReadonlyStatement(statement) {
102
+ const text = removeLeadingSqlComments(statement);
103
+ const queryType = detectQueryType(text);
104
+
105
+ if (queryType === "select" || queryType === "pragma") {
106
+ return true;
107
+ }
108
+
109
+ if (/^EXPLAIN\b/i.test(text)) {
110
+ const innerType = detectQueryType(explainInnerSql(text));
111
+ return innerType === "select" || innerType === "pragma";
112
+ }
113
+
114
+ return false;
115
+ }
116
+
117
+ function assertReadOnlySql(db, sql) {
118
+ const statements = splitSqlStatements(sql);
119
+
120
+ if (!statements.length) {
121
+ throw new ValidationError("No executable SQL statements were found.");
122
+ }
123
+
124
+ statements.forEach((statement, index) => {
125
+ if (!isAllowedReadonlyStatement(statement)) {
126
+ throw new ValidationError(
127
+ `Statement ${index + 1} is not allowed for read-only MCP queries. Only SELECT, PRAGMA, and EXPLAIN statements are allowed.`,
128
+ { code: "MCP_READONLY_SQL_REQUIRED" }
129
+ );
130
+ }
131
+
132
+ const prepared = db.prepare(statement);
133
+
134
+ if (!prepared.reader) {
135
+ throw new ValidationError(
136
+ `Statement ${index + 1} does not return rows and is blocked by the read-only query guard.`,
137
+ { code: "MCP_READONLY_SQL_REQUIRED" }
138
+ );
139
+ }
140
+ });
141
+
142
+ return statements;
143
+ }
144
+
145
+ function buildExplainQueryPlanSql(sql) {
146
+ const stripped = stripSqlTerminators(sql);
147
+
148
+ if (/^EXPLAIN\s+QUERY\s+PLAN\b/i.test(removeLeadingSqlComments(stripped))) {
149
+ return stripped;
150
+ }
151
+
152
+ return `EXPLAIN QUERY PLAN ${stripped}`;
153
+ }
154
+
155
+ function buildQueryPlanHints(rows = []) {
156
+ return rows
157
+ .map((row) => String(row.detail ?? row["QUERY PLAN"] ?? "").trim())
158
+ .filter(Boolean)
159
+ .filter((detail) => /\bSCAN\b/i.test(detail) && !/\bUSING\s+(?:COVERING\s+)?INDEX\b/i.test(detail))
160
+ .map((detail) => `Review whether an index would help: ${detail}`);
161
+ }
162
+
68
163
  function coerceIdentityValue(column, value) {
69
164
  const text = String(value ?? "");
70
165
  const affinity = String(column?.affinity ?? "").toUpperCase();
@@ -216,6 +311,7 @@ class DatabaseCommandService {
216
311
  connectionManager,
217
312
  sqlExecutor,
218
313
  }),
314
+ overviewService: new OverviewService({ connectionManager }),
219
315
  sqlExecutor,
220
316
  close() {
221
317
  connectionManager.closeCurrent();
@@ -257,6 +353,10 @@ class DatabaseCommandService {
257
353
  return this.withDatabase(databaseReference, ({ runtime }) => runtime.dataBrowserService.listTables());
258
354
  }
259
355
 
356
+ getDatabaseOverview(databaseReference) {
357
+ return this.withDatabase(databaseReference, ({ runtime }) => runtime.overviewService.getOverview());
358
+ }
359
+
260
360
  getTable(databaseReference, tableName) {
261
361
  const normalizedTableName = normalizeLookupValue(tableName, "Table name");
262
362
  return this.withDatabase(databaseReference, ({ runtime }) =>
@@ -264,6 +364,33 @@ class DatabaseCommandService {
264
364
  );
265
365
  }
266
366
 
367
+ getSchema(databaseReference) {
368
+ return this.withDatabase(databaseReference, ({ runtime }) => listSchema(runtime.db));
369
+ }
370
+
371
+ getIndexes(databaseReference, tableName = null) {
372
+ const normalizedTableName = normalizeOptionalValue(tableName);
373
+
374
+ if (normalizedTableName) {
375
+ return this.getTable(databaseReference, normalizedTableName).indexes ?? [];
376
+ }
377
+
378
+ return this.getSchema(databaseReference).indexes ?? [];
379
+ }
380
+
381
+ getForeignKeys(databaseReference, tableName = null) {
382
+ const normalizedTableName = normalizeOptionalValue(tableName);
383
+
384
+ if (normalizedTableName) {
385
+ return this.getTable(databaseReference, normalizedTableName).foreignKeys ?? [];
386
+ }
387
+
388
+ return this.getSchema(databaseReference).tables.map((table) => ({
389
+ tableName: table.name,
390
+ foreignKeys: table.foreignKeys ?? [],
391
+ }));
392
+ }
393
+
267
394
  generateTableTypes(databaseReference, tableName, target, options = {}) {
268
395
  const normalizedTableName = normalizeLookupValue(tableName, "Table name");
269
396
  return this.withDatabase(databaseReference, ({ runtime }) =>
@@ -276,6 +403,98 @@ class DatabaseCommandService {
276
403
  );
277
404
  }
278
405
 
406
+ generateTypes(databaseReference, { tableName = null, allTables = false, target, options = {} } = {}) {
407
+ const normalizedTableName = normalizeOptionalValue(tableName);
408
+
409
+ return this.withDatabase(databaseReference, ({ runtime }) => {
410
+ const tables = allTables || !normalizedTableName
411
+ ? runtime.dataBrowserService.listTables().map((table) => table.name)
412
+ : [normalizedTableName];
413
+ const files = tables.map((name) =>
414
+ this.typeGenerationService.generateTypesFromDatabase(runtime.db, name, target, options)
415
+ );
416
+
417
+ return {
418
+ target: files[0]?.target ?? target,
419
+ files,
420
+ warnings: files.flatMap((file) =>
421
+ (file.warnings ?? []).map((warning) => `${file.tableName}: ${warning}`)
422
+ ),
423
+ };
424
+ });
425
+ }
426
+
427
+ executeReadOnlyQuery(databaseReference, sql, options = {}) {
428
+ return this.withDatabase(databaseReference, ({ runtime }) => {
429
+ const statements = assertReadOnlySql(runtime.db, sql);
430
+ const result = runtime.sqlExecutor.execute(sql, {
431
+ executedBy: options.executedBy ?? "mcp",
432
+ maxRows: options.maxRows ?? 500,
433
+ persistHistory: options.persistHistory,
434
+ requireReader: true,
435
+ });
436
+
437
+ return {
438
+ statementsValidated: statements.length,
439
+ result,
440
+ };
441
+ });
442
+ }
443
+
444
+ explainQueryPlan(databaseReference, sql) {
445
+ return this.withDatabase(databaseReference, ({ runtime }) => {
446
+ assertReadOnlySql(runtime.db, sql);
447
+ const explainSql = buildExplainQueryPlanSql(sql);
448
+ const rows = runtime.db.prepare(explainSql).all();
449
+
450
+ return {
451
+ sql: explainSql,
452
+ rows,
453
+ hints: buildQueryPlanHints(rows),
454
+ };
455
+ });
456
+ }
457
+
458
+ createChartFromQuery(databaseReference, { sql, name, chartType = "bar", config, resultColumns, tableVisible = true } = {}) {
459
+ const normalizedSql = normalizeLookupValue(sql, "SQL");
460
+
461
+ if (/^EXPLAIN\b/i.test(removeLeadingSqlComments(normalizedSql)) || detectQueryType(normalizedSql) !== "select") {
462
+ throw new ValidationError("Charts can only be created from read-only SELECT queries.", {
463
+ code: "CHART_SELECT_QUERY_REQUIRED",
464
+ });
465
+ }
466
+
467
+ return this.withDatabase(databaseReference, ({ connection, runtime }) => {
468
+ assertReadOnlySql(runtime.db, normalizedSql);
469
+ const result = runtime.sqlExecutor.execute(normalizedSql, {
470
+ executedBy: "mcp",
471
+ maxRows: 500,
472
+ requireReader: true,
473
+ });
474
+ const chart = this.appStateStore.createQueryHistoryChart({
475
+ databaseKey: connection.id,
476
+ queryHistoryId: result.historyId,
477
+ name,
478
+ chartType,
479
+ config,
480
+ resultColumns: resultColumns ?? result.columns.map((column) => ({
481
+ name: column,
482
+ type: "unknown",
483
+ })),
484
+ tableVisible,
485
+ });
486
+
487
+ return {
488
+ chart,
489
+ queryHistoryId: result.historyId,
490
+ export: {
491
+ png: false,
492
+ message: "Charts are saved in SQLite Hub. PNG export is available from the Charts UI.",
493
+ },
494
+ };
495
+ });
496
+ }
497
+
279
498
  getTableRow(databaseReference, tableName, exportTarget) {
280
499
  const normalizedTableName = normalizeLookupValue(tableName, "Table name");
281
500
  normalizeLookupValue(exportTarget, "Row key");
@@ -440,6 +659,22 @@ class DatabaseCommandService {
440
659
  return this.appStateStore.listDatabaseDocuments(connection.id);
441
660
  }
442
661
 
662
+ readDocuments(databaseReference, documentName = null) {
663
+ const normalizedDocumentName = normalizeOptionalValue(documentName);
664
+
665
+ if (normalizedDocumentName) {
666
+ return {
667
+ items: [this.getDocument(databaseReference, normalizedDocumentName)],
668
+ };
669
+ }
670
+
671
+ return {
672
+ items: this.listDocuments(databaseReference).map((document) =>
673
+ this.appStateStore.getDatabaseDocument(this.getDatabase(databaseReference).id, document.id)
674
+ ),
675
+ };
676
+ }
677
+
443
678
  findDocument(databaseReference, documentName) {
444
679
  const connection = this.getDatabase(databaseReference);
445
680
  const normalizedDocumentName = normalizeLookupValue(documentName, "Document name").toLowerCase();
@@ -494,8 +729,10 @@ class DatabaseCommandService {
494
729
 
495
730
  module.exports = {
496
731
  DatabaseCommandService,
732
+ assertReadOnlySql,
497
733
  buildIdentityFromExportTarget,
498
734
  buildRowJsonObject,
735
+ isAllowedReadonlyStatement,
499
736
  getDocumentTitle,
500
737
  getQueryTitle,
501
738
  normalizeMarkdownExportFilename,
@@ -0,0 +1,140 @@
1
+ const DEFAULT_MCP_STATUS = {
2
+ enabled: true,
3
+ serverRunning: false,
4
+ connected: false,
5
+ activeClientCount: 0,
6
+ lastConnectedAt: null,
7
+ lastDisconnectedAt: null,
8
+ lastToolCallAt: null,
9
+ lastToolName: null,
10
+ transport: "unknown",
11
+ exposedTools: [],
12
+ error: null,
13
+ };
14
+
15
+ function normalizeToolNames(exposedTools = []) {
16
+ return Array.from(
17
+ new Set(
18
+ (Array.isArray(exposedTools) ? exposedTools : [])
19
+ .map((tool) => String(typeof tool === "string" ? tool : tool?.name ?? "").trim())
20
+ .filter(Boolean)
21
+ )
22
+ );
23
+ }
24
+
25
+ class McpStatusService {
26
+ constructor({ appStateStore, exposedTools = [], transport = "unknown" } = {}) {
27
+ this.appStateStore = appStateStore;
28
+ this.transport = transport;
29
+ this.exposedTools = normalizeToolNames(exposedTools);
30
+ }
31
+
32
+ getDefaultStatus() {
33
+ return {
34
+ ...DEFAULT_MCP_STATUS,
35
+ transport: this.transport,
36
+ exposedTools: this.exposedTools,
37
+ };
38
+ }
39
+
40
+ getStatus() {
41
+ const status = this.appStateStore?.getMcpStatus?.(this.getDefaultStatus()) ?? this.getDefaultStatus();
42
+
43
+ return {
44
+ ...this.getDefaultStatus(),
45
+ ...status,
46
+ exposedTools: this.exposedTools.length ? this.exposedTools : normalizeToolNames(status.exposedTools),
47
+ };
48
+ }
49
+
50
+ patchStatus(patch) {
51
+ const nextStatus = this.appStateStore?.patchMcpStatus?.(
52
+ {
53
+ ...patch,
54
+ exposedTools: this.exposedTools,
55
+ },
56
+ this.getDefaultStatus()
57
+ ) ?? {
58
+ ...this.getStatus(),
59
+ ...patch,
60
+ exposedTools: this.exposedTools,
61
+ };
62
+
63
+ return {
64
+ ...this.getDefaultStatus(),
65
+ ...nextStatus,
66
+ };
67
+ }
68
+
69
+ markServerRunning() {
70
+ return this.patchStatus({
71
+ enabled: true,
72
+ serverRunning: true,
73
+ transport: this.transport,
74
+ error: null,
75
+ });
76
+ }
77
+
78
+ markConnected() {
79
+ const current = this.getStatus();
80
+
81
+ return this.patchStatus({
82
+ enabled: true,
83
+ serverRunning: true,
84
+ connected: true,
85
+ activeClientCount: Math.max(1, Number(current.activeClientCount ?? 0)),
86
+ lastConnectedAt: new Date().toISOString(),
87
+ transport: this.transport,
88
+ error: null,
89
+ });
90
+ }
91
+
92
+ markDisconnected() {
93
+ const current = this.getStatus();
94
+ const activeClientCount = Math.max(0, Number(current.activeClientCount ?? 0) - 1);
95
+
96
+ return this.patchStatus({
97
+ connected: activeClientCount > 0,
98
+ serverRunning: activeClientCount > 0,
99
+ activeClientCount,
100
+ lastDisconnectedAt: new Date().toISOString(),
101
+ transport: this.transport,
102
+ });
103
+ }
104
+
105
+ markToolCall(toolName) {
106
+ return this.patchStatus({
107
+ enabled: true,
108
+ serverRunning: true,
109
+ connected: true,
110
+ activeClientCount: Math.max(1, Number(this.getStatus().activeClientCount ?? 0)),
111
+ lastToolCallAt: new Date().toISOString(),
112
+ lastToolName: String(toolName ?? "") || null,
113
+ transport: this.transport,
114
+ error: null,
115
+ });
116
+ }
117
+
118
+ markError(error) {
119
+ return this.patchStatus({
120
+ error: error?.message ?? String(error ?? "Unknown MCP error"),
121
+ transport: this.transport,
122
+ });
123
+ }
124
+
125
+ markStopped() {
126
+ return this.patchStatus({
127
+ serverRunning: false,
128
+ connected: false,
129
+ activeClientCount: 0,
130
+ lastDisconnectedAt: new Date().toISOString(),
131
+ transport: this.transport,
132
+ });
133
+ }
134
+ }
135
+
136
+ module.exports = {
137
+ DEFAULT_MCP_STATUS,
138
+ McpStatusService,
139
+ normalizeToolNames,
140
+ };