sqlcipher-mcp-server 1.0.4 → 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,601 @@
1
+ /**
2
+ * MCP Prompt Handlers
3
+ * Handlers for MCP prompt requests
4
+ */
5
+
6
+ import { PROMPT_DEFINITIONS } from '../definitions/prompts.js';
7
+ import { getDatabasePassword } from '../config/environment.js';
8
+ import { resolveDatabasePath } from '../utils/validators.js';
9
+ import {
10
+ getTableListFromDatabase,
11
+ getTableSchemaFromDatabase,
12
+ getForeignKeysFromDatabase,
13
+ getTableInfoFromDatabase,
14
+ sampleTableDataFromDatabase,
15
+ explainQueryPlanFromDatabase,
16
+ getTableStatisticsFromDatabase
17
+ } from '../services/database-service.js';
18
+
19
+ /**
20
+ * Handle list prompts request
21
+ * @returns {Object} List of available prompts
22
+ */
23
+ export function handleListPrompts() {
24
+ return {
25
+ prompts: Object.values(PROMPT_DEFINITIONS),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Handle explore_database_schema prompt
31
+ * @param {Object} args - Prompt arguments
32
+ * @returns {Promise<Object>} Prompt response
33
+ */
34
+ export async function handleExploreDatabaseSchemaPrompt(args) {
35
+ const { database_path } = args || {};
36
+ const dbPath = resolveDatabasePath(database_path);
37
+ const password = getDatabasePassword();
38
+
39
+ const tables = await getTableListFromDatabase(dbPath, password);
40
+
41
+ let messages = [
42
+ {
43
+ role: 'user',
44
+ content: {
45
+ type: 'text',
46
+ text: `I want to explore the database schema at: ${dbPath}`
47
+ }
48
+ },
49
+ {
50
+ role: 'assistant',
51
+ content: {
52
+ type: 'text',
53
+ text: `I'll help you explore the database schema. I found ${tables.length} table(s) in the database.\n\n` +
54
+ `Tables:\n${tables.map(t => `- ${t.name} (${t.type}) - ${t.row_count} rows`).join('\n')}\n\n` +
55
+ `Would you like me to:\n` +
56
+ `1. Show detailed schema for a specific table?\n` +
57
+ `2. Show foreign key relationships?\n` +
58
+ `3. Analyze data in a specific table?`
59
+ }
60
+ }
61
+ ];
62
+
63
+ return { messages };
64
+ }
65
+
66
+ /**
67
+ * Handle describe_table_structure prompt
68
+ * @param {Object} args - Prompt arguments
69
+ * @returns {Promise<Object>} Prompt response
70
+ */
71
+ export async function handleDescribeTableStructurePrompt(args) {
72
+ const { database_path, table_name } = args || {};
73
+
74
+ if (!table_name) {
75
+ return {
76
+ messages: [
77
+ {
78
+ role: 'user',
79
+ content: {
80
+ type: 'text',
81
+ text: 'I want to understand the structure of a table'
82
+ }
83
+ },
84
+ {
85
+ role: 'assistant',
86
+ content: {
87
+ type: 'text',
88
+ text: 'Please provide the table name you want to explore.'
89
+ }
90
+ }
91
+ ]
92
+ };
93
+ }
94
+
95
+ const dbPath = resolveDatabasePath(database_path);
96
+ const password = getDatabasePassword();
97
+
98
+ const schema = await getTableSchemaFromDatabase(dbPath, password, table_name);
99
+ const info = await getTableInfoFromDatabase(dbPath, password, table_name);
100
+ const sample = await sampleTableDataFromDatabase(dbPath, password, table_name, 5, 0);
101
+
102
+ let description = `Table: ${table_name}\n\n`;
103
+ description += `Type: ${info.type}\n`;
104
+ description += `Rows: ${info.row_count}\n`;
105
+ description += `Columns: ${info.column_count}\n\n`;
106
+
107
+ description += `Column Details:\n`;
108
+ schema.columns.forEach(col => {
109
+ description += `- ${col.name} (${col.type || 'UNKNOWN'})`;
110
+ if (col.pk) description += ' [PRIMARY KEY]';
111
+ if (col.notnull) description += ' [NOT NULL]';
112
+ description += '\n';
113
+ });
114
+
115
+ if (schema.foreign_keys && schema.foreign_keys.length > 0) {
116
+ description += `\nForeign Keys:\n`;
117
+ schema.foreign_keys.forEach(fk => {
118
+ description += `- ${fk.from} -> ${fk.table}.${fk.to}\n`;
119
+ });
120
+ }
121
+
122
+ if (schema.indexes && schema.indexes.length > 0) {
123
+ description += `\nIndexes:\n`;
124
+ schema.indexes.forEach(idx => {
125
+ description += `- ${idx.name}`;
126
+ if (idx.unique) description += ' (UNIQUE)';
127
+ description += '\n';
128
+ });
129
+ }
130
+
131
+ description += `\nSample Data (first 5 rows):\n`;
132
+ if (sample.rows.length > 0) {
133
+ description += `Columns: ${sample.columns.join(', ')}\n`;
134
+ sample.rows.forEach((row, i) => {
135
+ description += `Row ${i + 1}: ${sample.columns.map(c => row[c]).join(', ')}\n`;
136
+ });
137
+ }
138
+
139
+ return {
140
+ messages: [
141
+ {
142
+ role: 'user',
143
+ content: {
144
+ type: 'text',
145
+ text: `Describe the structure of table "${table_name}"`
146
+ }
147
+ },
148
+ {
149
+ role: 'assistant',
150
+ content: {
151
+ type: 'text',
152
+ text: description
153
+ }
154
+ }
155
+ ]
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Handle find_data_relationships prompt
161
+ * @param {Object} args - Prompt arguments
162
+ * @returns {Promise<Object>} Prompt response
163
+ */
164
+ export async function handleFindDataRelationshipsPrompt(args) {
165
+ const { database_path, table_name } = args || {};
166
+ const dbPath = resolveDatabasePath(database_path);
167
+ const password = getDatabasePassword();
168
+
169
+ const foreignKeys = await getForeignKeysFromDatabase(dbPath, password, table_name);
170
+
171
+ let description = table_name
172
+ ? `Foreign key relationships for table "${table_name}":\n\n`
173
+ : `All foreign key relationships in the database:\n\n`;
174
+
175
+ if (foreignKeys.length === 0) {
176
+ description += 'No foreign key relationships found.';
177
+ } else {
178
+ // Group by table
179
+ const byTable = {};
180
+ foreignKeys.forEach(fk => {
181
+ if (!byTable[fk.table]) {
182
+ byTable[fk.table] = [];
183
+ }
184
+ byTable[fk.table].push(fk);
185
+ });
186
+
187
+ Object.keys(byTable).forEach(tbl => {
188
+ description += `Table: ${tbl}\n`;
189
+ byTable[tbl].forEach(fk => {
190
+ description += ` - ${fk.from} -> ${fk.table}.${fk.to}\n`;
191
+ });
192
+ description += '\n';
193
+ });
194
+ }
195
+
196
+ return {
197
+ messages: [
198
+ {
199
+ role: 'user',
200
+ content: {
201
+ type: 'text',
202
+ text: table_name
203
+ ? `Show me the data relationships for table "${table_name}"`
204
+ : 'Show me all data relationships in the database'
205
+ }
206
+ },
207
+ {
208
+ role: 'assistant',
209
+ content: {
210
+ type: 'text',
211
+ text: description
212
+ }
213
+ }
214
+ ]
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Handle generate_query_template prompt
220
+ * @param {Object} args - Prompt arguments
221
+ * @returns {Promise<Object>} Prompt response
222
+ */
223
+ export async function handleGenerateQueryTemplatePrompt(args) {
224
+ const { database_path, table_name, intent } = args || {};
225
+
226
+ if (!table_name) {
227
+ return {
228
+ messages: [
229
+ {
230
+ role: 'user',
231
+ content: {
232
+ type: 'text',
233
+ text: 'I need help writing a SQL query'
234
+ }
235
+ },
236
+ {
237
+ role: 'assistant',
238
+ content: {
239
+ type: 'text',
240
+ text: 'Please provide the table name and what you want to do (count, sample, join, aggregate, or search).'
241
+ }
242
+ }
243
+ ]
244
+ };
245
+ }
246
+
247
+ const dbPath = resolveDatabasePath(database_path);
248
+ const password = getDatabasePassword();
249
+
250
+ const schema = await getTableSchemaFromDatabase(dbPath, password, table_name);
251
+ const columns = schema.columns.map(c => c.name).join(', ');
252
+
253
+ let templates = [];
254
+ const intentType = intent || 'sample';
255
+
256
+ switch (intentType) {
257
+ case 'count':
258
+ templates.push({
259
+ description: 'Count all rows',
260
+ query: `SELECT COUNT(*) as total FROM "${table_name}"`
261
+ });
262
+ templates.push({
263
+ description: 'Count by group',
264
+ query: `SELECT column_name, COUNT(*) as count FROM "${table_name}" GROUP BY column_name`
265
+ });
266
+ break;
267
+ case 'sample':
268
+ templates.push({
269
+ description: 'Get first 10 rows',
270
+ query: `SELECT ${columns} FROM "${table_name}" LIMIT 10`
271
+ });
272
+ templates.push({
273
+ description: 'Get rows with condition',
274
+ query: `SELECT ${columns} FROM "${table_name}" WHERE condition LIMIT 10`
275
+ });
276
+ break;
277
+ case 'aggregate':
278
+ const numericCols = schema.columns.filter(c =>
279
+ c.type && (c.type.toUpperCase().includes('INT') ||
280
+ c.type.toUpperCase().includes('REAL') ||
281
+ c.type.toUpperCase().includes('NUMERIC'))
282
+ );
283
+ if (numericCols.length > 0) {
284
+ const col = numericCols[0].name;
285
+ templates.push({
286
+ description: 'Calculate statistics',
287
+ query: `SELECT MIN("${col}"), MAX("${col}"), AVG("${col}"), SUM("${col}") FROM "${table_name}"`
288
+ });
289
+ }
290
+ break;
291
+ case 'join':
292
+ if (schema.foreign_keys && schema.foreign_keys.length > 0) {
293
+ const fk = schema.foreign_keys[0];
294
+ templates.push({
295
+ description: 'Join with related table',
296
+ query: `SELECT t1.*, t2.* FROM "${table_name}" t1 JOIN "${fk.table}" t2 ON t1."${fk.from}" = t2."${fk.to}"`
297
+ });
298
+ }
299
+ break;
300
+ case 'search':
301
+ const textCols = schema.columns.filter(c =>
302
+ !c.type || c.type.toUpperCase().includes('TEXT') ||
303
+ c.type.toUpperCase().includes('VARCHAR')
304
+ );
305
+ if (textCols.length > 0) {
306
+ const col = textCols[0].name;
307
+ templates.push({
308
+ description: 'Search by text',
309
+ query: `SELECT ${columns} FROM "${table_name}" WHERE "${col}" LIKE '%search_term%'`
310
+ });
311
+ }
312
+ break;
313
+ }
314
+
315
+ let response = `Query templates for table "${table_name}" (intent: ${intentType}):\n\n`;
316
+ templates.forEach((t, i) => {
317
+ response += `${i + 1}. ${t.description}:\n${t.query}\n\n`;
318
+ });
319
+
320
+ return {
321
+ messages: [
322
+ {
323
+ role: 'user',
324
+ content: {
325
+ type: 'text',
326
+ text: `Generate query templates for table "${table_name}" with intent "${intentType}"`
327
+ }
328
+ },
329
+ {
330
+ role: 'assistant',
331
+ content: {
332
+ type: 'text',
333
+ text: response
334
+ }
335
+ }
336
+ ]
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Handle optimize_query prompt
342
+ * @param {Object} args - Prompt arguments
343
+ * @returns {Promise<Object>} Prompt response
344
+ */
345
+ export async function handleOptimizeQueryPrompt(args) {
346
+ const { database_path, query } = args || {};
347
+
348
+ if (!query) {
349
+ return {
350
+ messages: [
351
+ {
352
+ role: 'user',
353
+ content: {
354
+ type: 'text',
355
+ text: 'I want to optimize a SQL query'
356
+ }
357
+ },
358
+ {
359
+ role: 'assistant',
360
+ content: {
361
+ type: 'text',
362
+ text: 'Please provide the SQL query you want to optimize.'
363
+ }
364
+ }
365
+ ]
366
+ };
367
+ }
368
+
369
+ const dbPath = resolveDatabasePath(database_path);
370
+ const password = getDatabasePassword();
371
+
372
+ const plan = await explainQueryPlanFromDatabase(dbPath, password, query);
373
+
374
+ let response = `Query Execution Plan:\n\n`;
375
+ plan.forEach(step => {
376
+ response += `${step.detail || step.notused || 'N/A'}\n`;
377
+ });
378
+
379
+ response += `\n\nOptimization Suggestions:\n`;
380
+
381
+ // Analyze plan for common issues
382
+ const planText = JSON.stringify(plan).toLowerCase();
383
+ if (planText.includes('scan')) {
384
+ response += `- Consider adding indexes to avoid table scans\n`;
385
+ }
386
+ if (planText.includes('temp')) {
387
+ response += `- Query uses temporary tables, consider simplifying\n`;
388
+ }
389
+ if (!planText.includes('index')) {
390
+ response += `- No indexes detected in execution plan\n`;
391
+ }
392
+
393
+ response += `- Ensure WHERE clauses use indexed columns\n`;
394
+ response += `- Limit result sets when possible\n`;
395
+ response += `- Avoid SELECT * in production queries\n`;
396
+
397
+ return {
398
+ messages: [
399
+ {
400
+ role: 'user',
401
+ content: {
402
+ type: 'text',
403
+ text: `Optimize this query: ${query}`
404
+ }
405
+ },
406
+ {
407
+ role: 'assistant',
408
+ content: {
409
+ type: 'text',
410
+ text: response
411
+ }
412
+ }
413
+ ]
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Handle analyze_table_data prompt
419
+ * @param {Object} args - Prompt arguments
420
+ * @returns {Promise<Object>} Prompt response
421
+ */
422
+ export async function handleAnalyzeTableDataPrompt(args) {
423
+ const { database_path, table_name } = args || {};
424
+
425
+ if (!table_name) {
426
+ return {
427
+ messages: [
428
+ {
429
+ role: 'user',
430
+ content: {
431
+ type: 'text',
432
+ text: 'I want to analyze table data'
433
+ }
434
+ },
435
+ {
436
+ role: 'assistant',
437
+ content: {
438
+ type: 'text',
439
+ text: 'Please provide the table name you want to analyze.'
440
+ }
441
+ }
442
+ ]
443
+ };
444
+ }
445
+
446
+ const dbPath = resolveDatabasePath(database_path);
447
+ const password = getDatabasePassword();
448
+
449
+ const stats = await getTableStatisticsFromDatabase(dbPath, password, table_name);
450
+ const sample = await sampleTableDataFromDatabase(dbPath, password, table_name, 5, 0);
451
+
452
+ let response = `Data Analysis for table "${table_name}":\n\n`;
453
+ response += `Total Rows: ${stats.total_rows}\n`;
454
+ response += `Total Columns: ${stats.column_count}\n\n`;
455
+
456
+ response += `Column Statistics:\n`;
457
+ stats.columns.forEach(col => {
458
+ response += `\n${col.name} (${col.type}):\n`;
459
+ response += ` - Distinct values: ${col.distinct_count}\n`;
460
+ response += ` - Null values: ${col.null_count}\n`;
461
+ if (col.min_value !== undefined) {
462
+ response += ` - Min: ${col.min_value}\n`;
463
+ response += ` - Max: ${col.max_value}\n`;
464
+ response += ` - Avg: ${col.avg_value}\n`;
465
+ }
466
+ });
467
+
468
+ response += `\n\nSample Data (first 5 rows):\n`;
469
+ if (sample.rows.length > 0) {
470
+ sample.rows.forEach((row, i) => {
471
+ response += `Row ${i + 1}: ${sample.columns.map(c => `${c}=${row[c]}`).join(', ')}\n`;
472
+ });
473
+ }
474
+
475
+ // Data quality checks
476
+ response += `\n\nData Quality Observations:\n`;
477
+ stats.columns.forEach(col => {
478
+ const nullPercentage = (col.null_count / stats.total_rows) * 100;
479
+ if (nullPercentage > 50) {
480
+ response += `- Column "${col.name}" has ${nullPercentage.toFixed(1)}% null values\n`;
481
+ }
482
+ if (col.distinct_count === stats.total_rows && stats.total_rows > 0) {
483
+ response += `- Column "${col.name}" appears to be unique (potential key)\n`;
484
+ }
485
+ });
486
+
487
+ return {
488
+ messages: [
489
+ {
490
+ role: 'user',
491
+ content: {
492
+ type: 'text',
493
+ text: `Analyze the data in table "${table_name}"`
494
+ }
495
+ },
496
+ {
497
+ role: 'assistant',
498
+ content: {
499
+ type: 'text',
500
+ text: response
501
+ }
502
+ }
503
+ ]
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Handle compare_tables prompt
509
+ * @param {Object} args - Prompt arguments
510
+ * @returns {Promise<Object>} Prompt response
511
+ */
512
+ export async function handleCompareTablesPrompt(args) {
513
+ const { database_path, table1_name, table2_name } = args || {};
514
+
515
+ if (!table1_name || !table2_name) {
516
+ return {
517
+ messages: [
518
+ {
519
+ role: 'user',
520
+ content: {
521
+ type: 'text',
522
+ text: 'I want to compare two tables'
523
+ }
524
+ },
525
+ {
526
+ role: 'assistant',
527
+ content: {
528
+ type: 'text',
529
+ text: 'Please provide both table names you want to compare.'
530
+ }
531
+ }
532
+ ]
533
+ };
534
+ }
535
+
536
+ const dbPath = resolveDatabasePath(database_path);
537
+ const password = getDatabasePassword();
538
+
539
+ const schema1 = await getTableSchemaFromDatabase(dbPath, password, table1_name);
540
+ const schema2 = await getTableSchemaFromDatabase(dbPath, password, table2_name);
541
+ const info1 = await getTableInfoFromDatabase(dbPath, password, table1_name);
542
+ const info2 = await getTableInfoFromDatabase(dbPath, password, table2_name);
543
+
544
+ let response = `Comparison of "${table1_name}" and "${table2_name}":\n\n`;
545
+
546
+ response += `Row Counts:\n`;
547
+ response += ` ${table1_name}: ${info1.row_count} rows\n`;
548
+ response += ` ${table2_name}: ${info2.row_count} rows\n\n`;
549
+
550
+ response += `Column Counts:\n`;
551
+ response += ` ${table1_name}: ${info1.column_count} columns\n`;
552
+ response += ` ${table2_name}: ${info2.column_count} columns\n\n`;
553
+
554
+ // Compare columns
555
+ const cols1 = schema1.columns.map(c => c.name);
556
+ const cols2 = schema2.columns.map(c => c.name);
557
+
558
+ const commonCols = cols1.filter(c => cols2.includes(c));
559
+ const uniqueToCols1 = cols1.filter(c => !cols2.includes(c));
560
+ const uniqueToCols2 = cols2.filter(c => !cols1.includes(c));
561
+
562
+ response += `Common Columns (${commonCols.length}):\n`;
563
+ commonCols.forEach(c => {
564
+ const col1 = schema1.columns.find(col => col.name === c);
565
+ const col2 = schema2.columns.find(col => col.name === c);
566
+ response += ` - ${c}: ${col1.type} vs ${col2.type}`;
567
+ if (col1.type !== col2.type) {
568
+ response += ` [TYPE MISMATCH]`;
569
+ }
570
+ response += `\n`;
571
+ });
572
+
573
+ if (uniqueToCols1.length > 0) {
574
+ response += `\nColumns only in "${table1_name}" (${uniqueToCols1.length}):\n`;
575
+ uniqueToCols1.forEach(c => response += ` - ${c}\n`);
576
+ }
577
+
578
+ if (uniqueToCols2.length > 0) {
579
+ response += `\nColumns only in "${table2_name}" (${uniqueToCols2.length}):\n`;
580
+ uniqueToCols2.forEach(c => response += ` - ${c}\n`);
581
+ }
582
+
583
+ return {
584
+ messages: [
585
+ {
586
+ role: 'user',
587
+ content: {
588
+ type: 'text',
589
+ text: `Compare tables "${table1_name}" and "${table2_name}"`
590
+ }
591
+ },
592
+ {
593
+ role: 'assistant',
594
+ content: {
595
+ type: 'text',
596
+ text: response
597
+ }
598
+ }
599
+ ]
600
+ };
601
+ }
@@ -6,7 +6,28 @@
6
6
  import express from 'express';
7
7
  import { HTTP_CONFIG } from '../config/constants.js';
8
8
  import { getPort, isPasswordConfigured } from '../config/environment.js';
9
- import { handleHealthCheck, handleInfo, handleQuery } from '../handlers/http-handlers.js';
9
+ import {
10
+ handleHealthCheck,
11
+ handleInfo,
12
+ handleQuery,
13
+ handleListTables,
14
+ handleGetTableSchema,
15
+ handleListColumns,
16
+ handleGetForeignKeys,
17
+ handleGetIndexes,
18
+ handleFindRelatedTables,
19
+ handleGetDatabaseInfo,
20
+ handleGetTableInfo,
21
+ handleTestConnection,
22
+ handleExplainQuery,
23
+ handleValidateQuerySyntax,
24
+ handleSuggestQuery,
25
+ handleGetTableStatistics,
26
+ handleSampleTableData,
27
+ handleGetColumnStatistics,
28
+ handleSearchTables,
29
+ handleSearchColumns
30
+ } from '../handlers/http-handlers.js';
10
31
 
11
32
  /**
12
33
  * Create and configure Express app
@@ -18,11 +39,40 @@ export function createHttpApp() {
18
39
  // Middleware to parse JSON request bodies
19
40
  app.use(express.json());
20
41
 
21
- // Register routes
42
+ // Server Status Routes
22
43
  app.get('/health', handleHealthCheck);
23
44
  app.get('/api/info', handleInfo);
45
+
46
+ // Query Execution
24
47
  app.post('/api/query', handleQuery);
25
48
 
49
+ // Schema Exploration Routes
50
+ app.post('/api/tool/list_tables', handleListTables);
51
+ app.post('/api/tool/get_table_schema', handleGetTableSchema);
52
+ app.post('/api/tool/list_columns', handleListColumns);
53
+ app.post('/api/tool/get_foreign_keys', handleGetForeignKeys);
54
+ app.post('/api/tool/get_indexes', handleGetIndexes);
55
+ app.post('/api/tool/find_related_tables', handleFindRelatedTables);
56
+
57
+ // Database & Table Info Routes
58
+ app.post('/api/tool/get_database_info', handleGetDatabaseInfo);
59
+ app.post('/api/tool/get_table_info', handleGetTableInfo);
60
+ app.post('/api/tool/test_connection', handleTestConnection);
61
+
62
+ // Query Helper Routes
63
+ app.post('/api/tool/explain_query', handleExplainQuery);
64
+ app.post('/api/tool/validate_query_syntax', handleValidateQuerySyntax);
65
+ app.post('/api/tool/suggest_query', handleSuggestQuery);
66
+
67
+ // Data Analysis Routes
68
+ app.post('/api/tool/get_table_statistics', handleGetTableStatistics);
69
+ app.post('/api/tool/sample_table_data', handleSampleTableData);
70
+ app.post('/api/tool/get_column_statistics', handleGetColumnStatistics);
71
+
72
+ // Search Routes
73
+ app.post('/api/tool/search_tables', handleSearchTables);
74
+ app.post('/api/tool/search_columns', handleSearchColumns);
75
+
26
76
  return app;
27
77
  }
28
78