mcp-consultant-tools 6.0.0 → 8.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.
package/LICENSE CHANGED
@@ -1,5 +1,30 @@
1
1
  MIT License
2
2
 
3
+ Copyright (c) 2025 Klemens Stelk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ================================================================================
24
+ This project includes code derived from powerplatform-mcp
25
+ (https://github.com/michsob/powerplatform-mcp)
26
+
27
+ Original Copyright Notice:
3
28
  Copyright (c) 2025 Michal Sobieraj
4
29
 
5
30
  Permission is hereby granted, free of charge, to any person obtaining a copy
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MCP Consultant Tools
2
2
 
3
- A Model Context Protocol (MCP) server providing intelligent access to PowerPlatform/Dataverse, Azure DevOps, Figma, and Azure Application Insights through an AI-friendly interface.
3
+ A Model Context Protocol (MCP) server providing intelligent access to PowerPlatform/Dataverse, Azure DevOps, Figma, Azure Application Insights, Azure Log Analytics, and Azure SQL Database through an AI-friendly interface.
4
4
 
5
5
  ## Overview
6
6
 
@@ -16,10 +16,12 @@ This MCP server enables AI assistants to:
16
16
  - **Azure DevOps** (12 tools): Search wikis, manage work items, execute WIQL queries
17
17
  - **Figma** (2 tools): Extract design data in simplified, AI-friendly format
18
18
  - **Application Insights** (10 tools): Query telemetry, analyze exceptions, monitor performance, troubleshoot issues
19
+ - **Log Analytics** (10 tools): Query Azure Functions logs, analyze errors, monitor function performance, search workspace logs
20
+ - **Azure SQL Database** (9 tools): Explore database schema, query tables safely with read-only access, investigate database structure
19
21
 
20
22
  All integrations are **optional** - configure only the services you need.
21
23
 
22
- **Total: 96+ MCP tools** providing comprehensive access to your development and operations lifecycle.
24
+ **Total: 116 MCP tools & 23 prompts** providing comprehensive access to your development and operations lifecycle.
23
25
 
24
26
  ## Known limitations
25
27
  - Cannot create Model-Driven-Apps
@@ -78,7 +80,19 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
78
80
  "APPINSIGHTS_TENANT_ID": "your-tenant-id",
79
81
  "APPINSIGHTS_CLIENT_ID": "your-client-id",
80
82
  "APPINSIGHTS_CLIENT_SECRET": "your-client-secret",
81
- "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]"
83
+ "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]",
84
+
85
+ "LOGANALYTICS_AUTH_METHOD": "entra-id",
86
+ "LOGANALYTICS_TENANT_ID": "your-tenant-id",
87
+ "LOGANALYTICS_CLIENT_ID": "your-client-id",
88
+ "LOGANALYTICS_CLIENT_SECRET": "your-client-secret",
89
+ "LOGANALYTICS_RESOURCES": "[{\"id\":\"prod-functions\",\"name\":\"Production Functions\",\"workspaceId\":\"your-workspace-id\",\"active\":true}]",
90
+
91
+ "AZURE_SQL_SERVER": "yourserver.database.windows.net",
92
+ "AZURE_SQL_DATABASE": "yourdatabase",
93
+ "AZURE_SQL_USERNAME": "your-username",
94
+ "AZURE_SQL_PASSWORD": "your-password",
95
+ "AZURE_SQL_USE_AZURE_AD": "false"
82
96
  }
83
97
  }
84
98
  }
@@ -121,7 +135,19 @@ Create `.vscode/mcp.json` in your project:
121
135
  "APPINSIGHTS_TENANT_ID": "your-tenant-id",
122
136
  "APPINSIGHTS_CLIENT_ID": "your-client-id",
123
137
  "APPINSIGHTS_CLIENT_SECRET": "your-client-secret",
124
- "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]"
138
+ "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]",
139
+
140
+ "LOGANALYTICS_AUTH_METHOD": "entra-id",
141
+ "LOGANALYTICS_TENANT_ID": "your-tenant-id",
142
+ "LOGANALYTICS_CLIENT_ID": "your-client-id",
143
+ "LOGANALYTICS_CLIENT_SECRET": "your-client-secret",
144
+ "LOGANALYTICS_RESOURCES": "[{\"id\":\"prod-functions\",\"name\":\"Production Functions\",\"workspaceId\":\"your-workspace-id\",\"active\":true}]",
145
+
146
+ "AZURE_SQL_SERVER": "yourserver.database.windows.net",
147
+ "AZURE_SQL_DATABASE": "yourdatabase",
148
+ "AZURE_SQL_USERNAME": "your-username",
149
+ "AZURE_SQL_PASSWORD": "your-password",
150
+ "AZURE_SQL_USE_AZURE_AD": "false"
125
151
  }
126
152
  }
127
153
  }
@@ -293,10 +319,15 @@ The server includes **18 prompts** that provide formatted, context-rich output:
293
319
  - `appinsights-availability-report` - Availability and uptime report
294
320
  - `appinsights-troubleshooting-guide` - Comprehensive troubleshooting guide combining all telemetry
295
321
 
322
+ **Azure SQL Database:**
323
+ - `sql-database-overview` - Comprehensive database schema overview with all objects
324
+ - `sql-table-details` - Detailed table report with columns, indexes, and relationships
325
+ - `sql-query-results` - Formatted query results with column headers
326
+
296
327
  ## Documentation
297
328
 
298
329
  - **[SETUP.md](SETUP.md)** - Complete setup guide with credentials, troubleshooting, and security
299
- - **[TOOLS.md](TOOLS.md)** - Full reference for all 96+ tools and 18 prompts
330
+ - **[TOOLS.md](TOOLS.md)** - Full reference for all 105+ tools and 21 prompts
300
331
  - **[USAGE.md](USAGE.md)** - Examples and use cases for all integrations
301
332
  - **[CLAUDE.md](CLAUDE.md)** - Architecture details and development guide
302
333
 
@@ -376,6 +407,28 @@ All integrations are optional and can be configured independently:
376
407
  - Requires Personal Access Token or OAuth
377
408
  - Read-only access to design files
378
409
 
410
+ **Azure SQL Database:**
411
+ - Supports SQL Authentication or Azure AD authentication
412
+ - **Read-only access by design** - only SELECT queries permitted
413
+ - Safety mechanisms:
414
+ - Query validation blocks INSERT, UPDATE, DELETE, DROP, EXEC, and other write operations
415
+ - 10MB response size limit to prevent memory exhaustion
416
+ - 1000 row result limit (configurable)
417
+ - 30-second query timeout protection
418
+ - Connection pooling with health checks (max 10 connections)
419
+ - Credential sanitization in error messages
420
+ - Audit logging for all user queries
421
+ - Recommended database permissions:
422
+ ```sql
423
+ ALTER ROLE db_datareader ADD MEMBER [mcp_readonly];
424
+ GRANT VIEW DEFINITION TO [mcp_readonly];
425
+ ```
426
+
427
+ **Application Insights:**
428
+ - Supports Entra ID (OAuth) or API Key authentication
429
+ - Read-only access to telemetry data
430
+ - Entra ID recommended for production (higher rate limits)
431
+
379
432
  See [SETUP.md](SETUP.md#security-best-practices) for security best practices.
380
433
 
381
434
  ## Examples
@@ -412,6 +465,20 @@ AI: [uses wiki-search-results prompt]
412
465
  Returns formatted search results with snippets
413
466
  ```
414
467
 
468
+ ### Investigate SQL Database schema
469
+
470
+ ```
471
+ User: "Show me the database schema overview"
472
+ AI: [uses sql-database-overview prompt]
473
+ Returns formatted overview with tables, views, procedures, and statistics
474
+ ```
475
+
476
+ ```
477
+ User: "Query the Users table for active accounts"
478
+ AI: [uses sql-execute-query tool with "SELECT TOP 10 * FROM dbo.Users WHERE IsActive = 1"]
479
+ Returns formatted query results with column headers
480
+ ```
481
+
415
482
  See [USAGE.md](USAGE.md) for more examples.
416
483
 
417
484
  ## Development
@@ -0,0 +1,497 @@
1
+ import sql from 'mssql';
2
+ import { auditLogger } from './utils/audit-logger.js';
3
+ // Configuration constants
4
+ const MAX_RESPONSE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
5
+ /**
6
+ * Azure SQL Database Service
7
+ *
8
+ * Provides read-only access to Azure SQL Database for investigation and analysis.
9
+ * Implements security controls including query validation, result limits, and audit logging.
10
+ */
11
+ export class AzureSqlService {
12
+ config;
13
+ pool = null;
14
+ constructor(config) {
15
+ this.config = {
16
+ ...config,
17
+ port: config.port || 1433,
18
+ queryTimeout: config.queryTimeout || 30000,
19
+ maxResultRows: config.maxResultRows || 1000,
20
+ connectionTimeout: config.connectionTimeout || 15000,
21
+ poolMin: config.poolMin || 0,
22
+ poolMax: config.poolMax || 10,
23
+ useAzureAd: config.useAzureAd ?? false,
24
+ };
25
+ }
26
+ /**
27
+ * Initialize connection pool on demand with health checks
28
+ */
29
+ async getPool() {
30
+ // Check if pool exists, is connected, and is healthy
31
+ if (this.pool && this.pool.connected && this.pool.healthy) {
32
+ return this.pool;
33
+ }
34
+ // Close unhealthy pool if it exists
35
+ if (this.pool && !this.pool.healthy) {
36
+ try {
37
+ await this.pool.close();
38
+ }
39
+ catch (error) {
40
+ console.error('Error closing unhealthy pool:', error);
41
+ }
42
+ this.pool = null;
43
+ }
44
+ try {
45
+ const poolConfig = {
46
+ server: this.config.server,
47
+ database: this.config.database,
48
+ port: this.config.port,
49
+ connectionTimeout: this.config.connectionTimeout,
50
+ requestTimeout: this.config.queryTimeout,
51
+ pool: {
52
+ min: this.config.poolMin,
53
+ max: this.config.poolMax,
54
+ idleTimeoutMillis: 30000,
55
+ },
56
+ options: {
57
+ encrypt: true, // Required for Azure SQL
58
+ trustServerCertificate: false,
59
+ enableArithAbort: true,
60
+ },
61
+ };
62
+ if (this.config.useAzureAd) {
63
+ // Azure AD Authentication
64
+ poolConfig.authentication = {
65
+ type: 'azure-active-directory-service-principal-secret',
66
+ options: {
67
+ clientId: this.config.clientId,
68
+ clientSecret: this.config.clientSecret,
69
+ tenantId: this.config.tenantId,
70
+ },
71
+ };
72
+ }
73
+ else {
74
+ // SQL Authentication
75
+ poolConfig.user = this.config.username;
76
+ poolConfig.password = this.config.password;
77
+ }
78
+ this.pool = await sql.connect(poolConfig);
79
+ console.error('Azure SQL connection pool established');
80
+ return this.pool;
81
+ }
82
+ catch (error) {
83
+ console.error('Failed to connect to Azure SQL Database:', {
84
+ server: this.config.server,
85
+ database: this.config.database,
86
+ error: this.sanitizeErrorMessage(error.message),
87
+ });
88
+ throw new Error(`Database connection failed: ${this.sanitizeErrorMessage(error.message)}`);
89
+ }
90
+ }
91
+ /**
92
+ * Sanitize error messages to prevent credential leakage
93
+ */
94
+ sanitizeErrorMessage(message) {
95
+ return message
96
+ .replace(/password=[^;]+/gi, 'password=***')
97
+ .replace(/pwd=[^;]+/gi, 'pwd=***')
98
+ .replace(/clientSecret=[^;]+/gi, 'clientSecret=***')
99
+ .replace(/Authentication=ActiveDirectoryServicePrincipal;([^;]*);/gi, 'Authentication=***;');
100
+ }
101
+ /**
102
+ * Execute a query with safety limits and size protection
103
+ */
104
+ async executeQuery(query, parameters) {
105
+ try {
106
+ const pool = await this.getPool();
107
+ const request = pool.request();
108
+ // Add parameters if provided
109
+ if (parameters) {
110
+ for (const [key, value] of Object.entries(parameters)) {
111
+ request.input(key, value);
112
+ }
113
+ }
114
+ const result = await request.query(query);
115
+ const rows = result.recordset || [];
116
+ const columns = result.recordset?.columns
117
+ ? Object.keys(result.recordset.columns)
118
+ : [];
119
+ // Check response size BEFORE processing
120
+ const jsonSize = JSON.stringify(rows).length;
121
+ if (jsonSize > MAX_RESPONSE_SIZE_BYTES) {
122
+ throw new Error(`Query results too large (${(jsonSize / 1024 / 1024).toFixed(2)} MB). ` +
123
+ `Maximum allowed: ${MAX_RESPONSE_SIZE_BYTES / 1024 / 1024} MB. ` +
124
+ `Add WHERE clause or SELECT specific columns to reduce result size.`);
125
+ }
126
+ // Enforce row limit
127
+ const truncated = rows.length > this.config.maxResultRows;
128
+ const limitedRows = rows.slice(0, this.config.maxResultRows);
129
+ return {
130
+ columns,
131
+ rows: limitedRows,
132
+ rowCount: limitedRows.length,
133
+ truncated,
134
+ };
135
+ }
136
+ catch (error) {
137
+ console.error('SQL query execution failed:', {
138
+ error: this.sanitizeErrorMessage(error.message),
139
+ query: query.substring(0, 200), // Log first 200 chars
140
+ });
141
+ // Provide user-friendly error messages
142
+ if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) {
143
+ throw new Error(`Query timeout exceeded (${this.config.queryTimeout}ms). ` +
144
+ `Try simplifying your query or adding WHERE clause filters.`);
145
+ }
146
+ if (error.message.includes('permission denied') || error.message.includes('denied')) {
147
+ throw new Error('Permission denied. Ensure the database user has SELECT permissions ' +
148
+ 'on the requested objects.');
149
+ }
150
+ throw new Error(`Query execution failed: ${this.sanitizeErrorMessage(error.message)}`);
151
+ }
152
+ }
153
+ /**
154
+ * Close connection pool (cleanup)
155
+ */
156
+ async close() {
157
+ if (this.pool) {
158
+ try {
159
+ await this.pool.close();
160
+ this.pool = null;
161
+ console.error('Azure SQL connection pool closed');
162
+ }
163
+ catch (error) {
164
+ console.error('Error closing connection pool:', this.sanitizeErrorMessage(error.message));
165
+ }
166
+ }
167
+ }
168
+ /**
169
+ * Test database connectivity
170
+ */
171
+ async testConnection() {
172
+ try {
173
+ const pool = await this.getPool();
174
+ const result = await pool.request().query(`
175
+ SELECT
176
+ @@VERSION as sqlVersion,
177
+ DB_NAME() as currentDatabase,
178
+ SUSER_SNAME() as loginName,
179
+ USER_NAME() as userName
180
+ `);
181
+ return {
182
+ connected: true,
183
+ server: this.config.server,
184
+ database: this.config.database,
185
+ sqlVersion: result.recordset[0].sqlVersion,
186
+ currentDatabase: result.recordset[0].currentDatabase,
187
+ loginName: result.recordset[0].loginName,
188
+ userName: result.recordset[0].userName,
189
+ };
190
+ }
191
+ catch (error) {
192
+ return {
193
+ connected: false,
194
+ server: this.config.server,
195
+ database: this.config.database,
196
+ error: this.sanitizeErrorMessage(error.message),
197
+ };
198
+ }
199
+ }
200
+ /**
201
+ * List all user tables in the database
202
+ */
203
+ async listTables() {
204
+ const query = `
205
+ SELECT
206
+ t.TABLE_SCHEMA as schemaName,
207
+ t.TABLE_NAME as tableName,
208
+ p.rows as rowCount,
209
+ CAST(SUM(a.total_pages) * 8 / 1024.0 AS DECIMAL(10,2)) as sizeMB
210
+ FROM INFORMATION_SCHEMA.TABLES t
211
+ LEFT JOIN sys.tables st ON t.TABLE_NAME = st.name
212
+ LEFT JOIN sys.partitions p ON st.object_id = p.object_id AND p.index_id IN (0,1)
213
+ LEFT JOIN sys.allocation_units a ON p.partition_id = a.container_id
214
+ WHERE t.TABLE_TYPE = 'BASE TABLE'
215
+ AND t.TABLE_SCHEMA != 'sys'
216
+ GROUP BY t.TABLE_SCHEMA, t.TABLE_NAME, p.rows
217
+ ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME
218
+ `;
219
+ const result = await this.executeQuery(query);
220
+ return result.rows;
221
+ }
222
+ /**
223
+ * List all views in the database
224
+ */
225
+ async listViews() {
226
+ const query = `
227
+ SELECT
228
+ TABLE_SCHEMA as schemaName,
229
+ TABLE_NAME as viewName,
230
+ VIEW_DEFINITION as definition
231
+ FROM INFORMATION_SCHEMA.VIEWS
232
+ WHERE TABLE_SCHEMA != 'sys'
233
+ ORDER BY TABLE_SCHEMA, TABLE_NAME
234
+ `;
235
+ const result = await this.executeQuery(query);
236
+ return result.rows;
237
+ }
238
+ /**
239
+ * List all stored procedures
240
+ */
241
+ async listStoredProcedures() {
242
+ const query = `
243
+ SELECT
244
+ ROUTINE_SCHEMA as schemaName,
245
+ ROUTINE_NAME as procedureName,
246
+ CREATED as createdDate,
247
+ LAST_ALTERED as modifiedDate
248
+ FROM INFORMATION_SCHEMA.ROUTINES
249
+ WHERE ROUTINE_TYPE = 'PROCEDURE'
250
+ AND ROUTINE_SCHEMA != 'sys'
251
+ ORDER BY ROUTINE_SCHEMA, ROUTINE_NAME
252
+ `;
253
+ const result = await this.executeQuery(query);
254
+ return result.rows;
255
+ }
256
+ /**
257
+ * List all database triggers
258
+ */
259
+ async listTriggers() {
260
+ const query = `
261
+ SELECT
262
+ s.name as schemaName,
263
+ t.name as triggerName,
264
+ OBJECT_NAME(t.parent_id) as objectName,
265
+ CASE
266
+ WHEN OBJECTPROPERTY(t.object_id, 'ExecIsInsertTrigger') = 1 THEN 'INSERT'
267
+ WHEN OBJECTPROPERTY(t.object_id, 'ExecIsUpdateTrigger') = 1 THEN 'UPDATE'
268
+ WHEN OBJECTPROPERTY(t.object_id, 'ExecIsDeleteTrigger') = 1 THEN 'DELETE'
269
+ ELSE 'UNKNOWN'
270
+ END as triggerEvent,
271
+ t.is_disabled as isDisabled,
272
+ t.create_date as createdDate,
273
+ t.modify_date as modifiedDate
274
+ FROM sys.triggers t
275
+ INNER JOIN sys.objects o ON t.parent_id = o.object_id
276
+ INNER JOIN sys.schemas s ON o.schema_id = s.schema_id
277
+ WHERE t.parent_class = 1 -- Object triggers (not database triggers)
278
+ ORDER BY s.name, t.name
279
+ `;
280
+ const result = await this.executeQuery(query);
281
+ return result.rows;
282
+ }
283
+ /**
284
+ * List all user-defined functions
285
+ */
286
+ async listFunctions() {
287
+ const query = `
288
+ SELECT
289
+ ROUTINE_SCHEMA as schemaName,
290
+ ROUTINE_NAME as functionName,
291
+ DATA_TYPE as returnType,
292
+ CREATED as createdDate,
293
+ LAST_ALTERED as modifiedDate
294
+ FROM INFORMATION_SCHEMA.ROUTINES
295
+ WHERE ROUTINE_TYPE = 'FUNCTION'
296
+ AND ROUTINE_SCHEMA != 'sys'
297
+ ORDER BY ROUTINE_SCHEMA, ROUTINE_NAME
298
+ `;
299
+ const result = await this.executeQuery(query);
300
+ return result.rows;
301
+ }
302
+ /**
303
+ * Get detailed schema information for a table
304
+ */
305
+ async getTableSchema(schemaName, tableName) {
306
+ // First, verify table exists
307
+ const existsQuery = `
308
+ SELECT 1 FROM INFORMATION_SCHEMA.TABLES
309
+ WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table
310
+ `;
311
+ const existsResult = await this.executeQuery(existsQuery, { schema: schemaName, table: tableName });
312
+ if (existsResult.rows.length === 0) {
313
+ throw new Error(`Table '${schemaName}.${tableName}' not found. ` +
314
+ `Use sql-list-tables to see available tables.`);
315
+ }
316
+ // Get columns
317
+ const columnsQuery = `
318
+ SELECT
319
+ COLUMN_NAME as columnName,
320
+ DATA_TYPE as dataType,
321
+ CHARACTER_MAXIMUM_LENGTH as maxLength,
322
+ IS_NULLABLE as isNullable,
323
+ COLUMN_DEFAULT as defaultValue,
324
+ COLUMNPROPERTY(OBJECT_ID(@schema + '.' + @table), COLUMN_NAME, 'IsIdentity') as isIdentity
325
+ FROM INFORMATION_SCHEMA.COLUMNS
326
+ WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table
327
+ ORDER BY ORDINAL_POSITION
328
+ `;
329
+ // Get indexes
330
+ const indexesQuery = `
331
+ SELECT
332
+ i.name as indexName,
333
+ i.type_desc as indexType,
334
+ i.is_unique as isUnique,
335
+ i.is_primary_key as isPrimaryKey,
336
+ STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) as columns
337
+ FROM sys.indexes i
338
+ INNER JOIN sys.tables t ON i.object_id = t.object_id
339
+ INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
340
+ INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
341
+ INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
342
+ WHERE s.name = @schema AND t.name = @table
343
+ GROUP BY i.name, i.type_desc, i.is_unique, i.is_primary_key
344
+ ORDER BY i.is_primary_key DESC, i.name
345
+ `;
346
+ // Get foreign keys
347
+ const foreignKeysQuery = `
348
+ SELECT
349
+ fk.name as foreignKeyName,
350
+ OBJECT_SCHEMA_NAME(fk.parent_object_id) as schemaName,
351
+ OBJECT_NAME(fk.parent_object_id) as tableName,
352
+ COL_NAME(fkc.parent_object_id, fkc.parent_column_id) as columnName,
353
+ OBJECT_SCHEMA_NAME(fk.referenced_object_id) as referencedSchema,
354
+ OBJECT_NAME(fk.referenced_object_id) as referencedTable,
355
+ COL_NAME(fkc.referenced_object_id, fkc.referenced_column_id) as referencedColumn
356
+ FROM sys.foreign_keys fk
357
+ INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
358
+ WHERE OBJECT_SCHEMA_NAME(fk.parent_object_id) = @schema
359
+ AND OBJECT_NAME(fk.parent_object_id) = @table
360
+ ORDER BY fk.name
361
+ `;
362
+ // Query all schema information with graceful degradation
363
+ try {
364
+ const [columnsResult, indexesResult, foreignKeysResult] = await Promise.all([
365
+ this.executeQuery(columnsQuery, { schema: schemaName, table: tableName }),
366
+ this.executeQuery(indexesQuery, { schema: schemaName, table: tableName })
367
+ .catch(() => ({ rows: [], rowCount: 0, columns: [] })),
368
+ this.executeQuery(foreignKeysQuery, { schema: schemaName, table: tableName })
369
+ .catch(() => ({ rows: [], rowCount: 0, columns: [] })),
370
+ ]);
371
+ return {
372
+ schemaName,
373
+ tableName,
374
+ columns: columnsResult.rows,
375
+ indexes: indexesResult.rows,
376
+ foreignKeys: foreignKeysResult.rows,
377
+ };
378
+ }
379
+ catch (error) {
380
+ throw new Error(`Failed to retrieve schema for '${schemaName}.${tableName}': ${this.sanitizeErrorMessage(error.message)}`);
381
+ }
382
+ }
383
+ /**
384
+ * Get the SQL definition for views, stored procedures, functions, or triggers
385
+ */
386
+ async getObjectDefinition(schemaName, objectName, objectType) {
387
+ const query = `
388
+ SELECT
389
+ o.name as objectName,
390
+ s.name as schemaName,
391
+ o.type_desc as objectType,
392
+ o.create_date as createdDate,
393
+ o.modify_date as modifiedDate,
394
+ OBJECT_DEFINITION(o.object_id) as definition
395
+ FROM sys.objects o
396
+ INNER JOIN sys.schemas s ON o.schema_id = s.schema_id
397
+ WHERE s.name = @schema
398
+ AND o.name = @object
399
+ AND o.type_desc LIKE '%' + @type + '%'
400
+ `;
401
+ const result = await this.executeQuery(query, {
402
+ schema: schemaName,
403
+ object: objectName,
404
+ type: objectType,
405
+ });
406
+ if (result.rows.length === 0) {
407
+ throw new Error(`${objectType} '${schemaName}.${objectName}' not found. ` +
408
+ `Check the schema name, object name, and object type.`);
409
+ }
410
+ return result.rows[0];
411
+ }
412
+ /**
413
+ * Execute a user-provided SELECT query with enhanced safety validation
414
+ */
415
+ async executeSelectQuery(query) {
416
+ const timer = auditLogger.startTimer();
417
+ // Step 1: Remove comments (SQL and C-style)
418
+ let cleanQuery = query
419
+ .replace(/--.*$/gm, '') // Remove -- comments
420
+ .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
421
+ .replace(/\s+/g, ' ') // Normalize whitespace
422
+ .trim()
423
+ .toLowerCase();
424
+ // Step 2: Validate SELECT query
425
+ if (!cleanQuery.startsWith('select')) {
426
+ const error = 'Only SELECT queries are allowed. Write operations (INSERT, UPDATE, DELETE, etc.) are not permitted.';
427
+ auditLogger.log({
428
+ operation: 'execute-select-query',
429
+ operationType: 'READ',
430
+ componentType: 'Query',
431
+ success: false,
432
+ error,
433
+ parameters: { query: query.substring(0, 500) },
434
+ executionTimeMs: timer()
435
+ });
436
+ throw new Error(error);
437
+ }
438
+ // Step 3: Check for dangerous keywords with word boundaries
439
+ const dangerousPatterns = [
440
+ { pattern: /\b(insert|update|delete|merge)\b/i, name: 'write operations' },
441
+ { pattern: /\b(drop|create|alter|truncate)\b/i, name: 'schema modifications' },
442
+ { pattern: /\b(exec|execute|sp_executesql)\b/i, name: 'command execution' },
443
+ { pattern: /\b(xp_|sp_)\w+/i, name: 'system stored procedures' },
444
+ { pattern: /\b(grant|revoke|deny)\b/i, name: 'permission changes' },
445
+ { pattern: /\binto\b/i, name: 'SELECT INTO' },
446
+ { pattern: /\b(openquery|openrowset|opendatasource)\b/i, name: 'linked server queries' },
447
+ ];
448
+ for (const { pattern, name } of dangerousPatterns) {
449
+ if (pattern.test(cleanQuery)) {
450
+ const error = `Query contains forbidden keyword or pattern (${name}). Only SELECT queries are allowed for investigation purposes.`;
451
+ auditLogger.log({
452
+ operation: 'execute-select-query',
453
+ operationType: 'READ',
454
+ componentType: 'Query',
455
+ success: false,
456
+ error,
457
+ parameters: { query: query.substring(0, 500) },
458
+ executionTimeMs: timer()
459
+ });
460
+ throw new Error(error);
461
+ }
462
+ }
463
+ // Execute query with audit logging
464
+ try {
465
+ const result = await this.executeQuery(query);
466
+ auditLogger.log({
467
+ operation: 'execute-select-query',
468
+ operationType: 'READ',
469
+ componentType: 'Query',
470
+ parameters: {
471
+ query: query.substring(0, 500),
472
+ rowCount: result.rowCount,
473
+ truncated: result.truncated
474
+ },
475
+ success: true,
476
+ executionTimeMs: timer()
477
+ });
478
+ if (result.truncated) {
479
+ console.error(`Query results truncated. Returned ${result.rowCount} of potentially more rows. ` +
480
+ `Maximum: ${this.config.maxResultRows}. Add WHERE clause to filter results.`);
481
+ }
482
+ return result;
483
+ }
484
+ catch (error) {
485
+ auditLogger.log({
486
+ operation: 'execute-select-query',
487
+ operationType: 'READ',
488
+ componentType: 'Query',
489
+ success: false,
490
+ error: error instanceof Error ? error.message : String(error),
491
+ parameters: { query: query.substring(0, 500) },
492
+ executionTimeMs: timer()
493
+ });
494
+ throw error;
495
+ }
496
+ }
497
+ }