mcp-consultant-tools 7.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/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, Azure Application Insights, and Azure SQL Database 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,11 +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
19
20
  - **Azure SQL Database** (9 tools): Explore database schema, query tables safely with read-only access, investigate database structure
20
21
 
21
22
  All integrations are **optional** - configure only the services you need.
22
23
 
23
- **Total: 105+ MCP tools & 21 prompts** 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.
24
25
 
25
26
  ## Known limitations
26
27
  - Cannot create Model-Driven-Apps
@@ -81,6 +82,12 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
81
82
  "APPINSIGHTS_CLIENT_SECRET": "your-client-secret",
82
83
  "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]",
83
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
+
84
91
  "AZURE_SQL_SERVER": "yourserver.database.windows.net",
85
92
  "AZURE_SQL_DATABASE": "yourdatabase",
86
93
  "AZURE_SQL_USERNAME": "your-username",
@@ -130,6 +137,12 @@ Create `.vscode/mcp.json` in your project:
130
137
  "APPINSIGHTS_CLIENT_SECRET": "your-client-secret",
131
138
  "APPINSIGHTS_RESOURCES": "[{\"id\":\"prod-api\",\"name\":\"Production API\",\"appId\":\"your-app-id\",\"active\":true}]",
132
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
+
133
146
  "AZURE_SQL_SERVER": "yourserver.database.windows.net",
134
147
  "AZURE_SQL_DATABASE": "yourdatabase",
135
148
  "AZURE_SQL_USERNAME": "your-username",
@@ -0,0 +1,409 @@
1
+ import { ConfidentialClientApplication } from '@azure/msal-node';
2
+ import axios from 'axios';
3
+ export class LogAnalyticsService {
4
+ config;
5
+ msalClient = null;
6
+ accessToken = null;
7
+ tokenExpirationTime = 0;
8
+ baseUrl = 'https://api.loganalytics.io/v1';
9
+ constructor(config) {
10
+ this.config = config;
11
+ // Initialize MSAL client if using Entra ID auth
12
+ if (this.config.authMethod === 'entra-id') {
13
+ if (!this.config.tenantId || !this.config.clientId || !this.config.clientSecret) {
14
+ throw new Error('Entra ID authentication requires tenantId, clientId, and clientSecret');
15
+ }
16
+ this.msalClient = new ConfidentialClientApplication({
17
+ auth: {
18
+ clientId: this.config.clientId,
19
+ clientSecret: this.config.clientSecret,
20
+ authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
21
+ },
22
+ });
23
+ }
24
+ }
25
+ /**
26
+ * Get access token for Log Analytics API using Microsoft Entra ID OAuth
27
+ * Implements token caching with 5-minute buffer before expiry
28
+ */
29
+ async getAccessToken() {
30
+ if (!this.msalClient) {
31
+ throw new Error('MSAL client not initialized. Use Entra ID authentication method.');
32
+ }
33
+ // Return cached token if still valid (with 5-minute buffer)
34
+ const currentTime = Date.now();
35
+ const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
36
+ if (this.accessToken && this.tokenExpirationTime > currentTime + bufferTime) {
37
+ return this.accessToken;
38
+ }
39
+ // Acquire new token
40
+ try {
41
+ const result = await this.msalClient.acquireTokenByClientCredential({
42
+ scopes: ['https://api.loganalytics.io/.default'],
43
+ });
44
+ if (!result || !result.accessToken) {
45
+ throw new Error('Failed to acquire access token');
46
+ }
47
+ this.accessToken = result.accessToken;
48
+ this.tokenExpirationTime = result.expiresOn ? result.expiresOn.getTime() : 0;
49
+ return this.accessToken;
50
+ }
51
+ catch (error) {
52
+ throw new Error(`Failed to acquire access token: ${error.message}`);
53
+ }
54
+ }
55
+ /**
56
+ * Get authorization headers based on authentication method
57
+ */
58
+ async getAuthHeaders(resource) {
59
+ if (this.config.authMethod === 'entra-id') {
60
+ const token = await this.getAccessToken();
61
+ return {
62
+ Authorization: `Bearer ${token}`,
63
+ 'Content-Type': 'application/json',
64
+ };
65
+ }
66
+ else if (this.config.authMethod === 'api-key') {
67
+ if (!resource.apiKey) {
68
+ throw new Error(`API key not configured for resource: ${resource.id}`);
69
+ }
70
+ return {
71
+ 'X-Api-Key': resource.apiKey,
72
+ 'Content-Type': 'application/json',
73
+ };
74
+ }
75
+ else {
76
+ throw new Error(`Unsupported authentication method: ${this.config.authMethod}`);
77
+ }
78
+ }
79
+ /**
80
+ * Execute a KQL query against a Log Analytics workspace
81
+ */
82
+ async executeQuery(resourceId, query, timespan) {
83
+ const resource = this.getResourceById(resourceId);
84
+ try {
85
+ const headers = await this.getAuthHeaders(resource);
86
+ const url = `${this.baseUrl}/workspaces/${resource.workspaceId}/query`;
87
+ const requestBody = { query };
88
+ if (timespan) {
89
+ requestBody.timespan = timespan;
90
+ }
91
+ const response = await axios.post(url, requestBody, {
92
+ headers,
93
+ timeout: 30000, // 30-second timeout
94
+ });
95
+ return response.data;
96
+ }
97
+ catch (error) {
98
+ // Enhanced error handling
99
+ let errorMessage = 'Unknown error';
100
+ let errorDetails = {};
101
+ if (error.response) {
102
+ const status = error.response.status;
103
+ const data = error.response.data;
104
+ switch (status) {
105
+ case 401:
106
+ errorMessage = 'Authentication failed. Check your credentials and ensure the app registration has proper permissions.';
107
+ break;
108
+ case 403:
109
+ errorMessage = 'Access denied. Ensure the service principal has "Log Analytics Reader" role on the workspace.';
110
+ break;
111
+ case 429:
112
+ errorMessage = 'Rate limit exceeded. Reduce query frequency or upgrade authentication method.';
113
+ if (error.response.headers['retry-after']) {
114
+ errorMessage += ` Retry after ${error.response.headers['retry-after']} seconds.`;
115
+ }
116
+ break;
117
+ case 400:
118
+ if (data && data.error) {
119
+ if (data.error.code === 'SyntaxError') {
120
+ errorMessage = `KQL syntax error: ${data.error.message}`;
121
+ }
122
+ else if (data.error.code === 'SemanticError') {
123
+ errorMessage = `KQL semantic error: ${data.error.message}. Check table/column names.`;
124
+ }
125
+ else {
126
+ errorMessage = `Bad request: ${data.error.message}`;
127
+ }
128
+ }
129
+ else {
130
+ errorMessage = 'Bad request. Check your query syntax.';
131
+ }
132
+ break;
133
+ case 504:
134
+ errorMessage = 'Query timeout. Try reducing the time range or simplifying the query.';
135
+ break;
136
+ default:
137
+ errorMessage = `HTTP ${status}: ${data?.error?.message || error.message}`;
138
+ }
139
+ errorDetails = {
140
+ status,
141
+ code: data?.error?.code,
142
+ message: data?.error?.message,
143
+ };
144
+ }
145
+ else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
146
+ errorMessage = 'Network error: Unable to reach Log Analytics API. Check your internet connection.';
147
+ }
148
+ else if (error.code === 'ETIMEDOUT') {
149
+ errorMessage = 'Request timeout. The query took too long to execute.';
150
+ }
151
+ else {
152
+ errorMessage = error.message;
153
+ }
154
+ throw new Error(errorMessage);
155
+ }
156
+ }
157
+ /**
158
+ * Get workspace metadata (schema)
159
+ */
160
+ async getMetadata(resourceId) {
161
+ const resource = this.getResourceById(resourceId);
162
+ try {
163
+ const headers = await this.getAuthHeaders(resource);
164
+ const url = `${this.baseUrl}/workspaces/${resource.workspaceId}/metadata`;
165
+ const response = await axios.get(url, {
166
+ headers,
167
+ timeout: 15000,
168
+ });
169
+ return response.data;
170
+ }
171
+ catch (error) {
172
+ throw new Error(`Failed to get metadata: ${error.message}`);
173
+ }
174
+ }
175
+ /**
176
+ * Test workspace access by executing a simple query
177
+ */
178
+ async testWorkspaceAccess(resourceId) {
179
+ const resource = this.getResourceById(resourceId);
180
+ try {
181
+ // Execute a minimal query to test access
182
+ const result = await this.executeQuery(resourceId, 'print test="success"');
183
+ return {
184
+ success: true,
185
+ message: `Successfully connected to workspace: ${resource.name}`,
186
+ details: {
187
+ workspaceId: resource.workspaceId,
188
+ authMethod: this.config.authMethod,
189
+ },
190
+ };
191
+ }
192
+ catch (error) {
193
+ return {
194
+ success: false,
195
+ message: `Failed to access workspace: ${error.message}`,
196
+ details: {
197
+ workspaceId: resource.workspaceId,
198
+ error: error.message,
199
+ },
200
+ };
201
+ }
202
+ }
203
+ /**
204
+ * Get recent events from any table
205
+ */
206
+ async getRecentEvents(resourceId, tableName, timespan = 'PT1H', limit = 100) {
207
+ const query = `
208
+ ${tableName}
209
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
210
+ | order by TimeGenerated desc
211
+ | take ${limit}
212
+ `.trim();
213
+ return this.executeQuery(resourceId, query, timespan);
214
+ }
215
+ /**
216
+ * Search logs across tables or specific table
217
+ */
218
+ async searchLogs(resourceId, searchText, tableName, timespan = 'PT1H', limit = 100) {
219
+ const tableFilter = tableName || '*';
220
+ const query = `
221
+ ${tableFilter}
222
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
223
+ | where * contains "${searchText}"
224
+ | order by TimeGenerated desc
225
+ | take ${limit}
226
+ `.trim();
227
+ return this.executeQuery(resourceId, query, timespan);
228
+ }
229
+ /**
230
+ * Get Azure Function logs from FunctionAppLogs table
231
+ */
232
+ async getFunctionLogs(resourceId, functionName, timespan = 'PT1H', severityLevel, limit = 100) {
233
+ let query = `
234
+ FunctionAppLogs
235
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
236
+ `;
237
+ if (functionName) {
238
+ query += `\n | where FunctionName == "${functionName}"`;
239
+ }
240
+ if (severityLevel !== undefined) {
241
+ query += `\n | where SeverityLevel >= ${severityLevel}`;
242
+ }
243
+ query += `
244
+ | order by TimeGenerated desc
245
+ | take ${limit}
246
+ | project TimeGenerated, FunctionName, Message, SeverityLevel, ExceptionDetails, HostInstanceId
247
+ `.trim();
248
+ return this.executeQuery(resourceId, query, timespan);
249
+ }
250
+ /**
251
+ * Get Azure Function errors
252
+ */
253
+ async getFunctionErrors(resourceId, functionName, timespan = 'PT1H', limit = 100) {
254
+ let query = `
255
+ FunctionAppLogs
256
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
257
+ | where ExceptionDetails != ""
258
+ `;
259
+ if (functionName) {
260
+ query += `\n | where FunctionName == "${functionName}"`;
261
+ }
262
+ query += `
263
+ | order by TimeGenerated desc
264
+ | take ${limit}
265
+ | project TimeGenerated, FunctionName, Message, ExceptionDetails, SeverityLevel, HostInstanceId
266
+ `.trim();
267
+ return this.executeQuery(resourceId, query, timespan);
268
+ }
269
+ /**
270
+ * Get Azure Function execution statistics
271
+ */
272
+ async getFunctionStats(resourceId, functionName, timespan = 'PT1H') {
273
+ let query = `
274
+ FunctionAppLogs
275
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
276
+ `;
277
+ if (functionName) {
278
+ query += `\n | where FunctionName == "${functionName}"`;
279
+ query += `
280
+ | summarize
281
+ TotalExecutions = count(),
282
+ ErrorCount = countif(ExceptionDetails != ""),
283
+ SuccessCount = countif(ExceptionDetails == ""),
284
+ UniqueHosts = dcount(HostInstanceId)
285
+ | extend SuccessRate = round(100.0 * SuccessCount / TotalExecutions, 2)
286
+ `.trim();
287
+ }
288
+ else {
289
+ query += `
290
+ | summarize
291
+ TotalExecutions = count(),
292
+ ErrorCount = countif(ExceptionDetails != ""),
293
+ SuccessCount = countif(ExceptionDetails == ""),
294
+ UniqueFunctions = dcount(FunctionName),
295
+ UniqueHosts = dcount(HostInstanceId)
296
+ by FunctionName
297
+ | extend SuccessRate = round(100.0 * SuccessCount / TotalExecutions, 2)
298
+ | order by TotalExecutions desc
299
+ `.trim();
300
+ }
301
+ return this.executeQuery(resourceId, query, timespan);
302
+ }
303
+ /**
304
+ * Get Azure Function invocations from traces or requests table
305
+ */
306
+ async getFunctionInvocations(resourceId, functionName, timespan = 'PT1H', limit = 100) {
307
+ // Try requests table first (for HTTP-triggered functions)
308
+ let query = `
309
+ union isfuzzy=true requests, traces
310
+ | where TimeGenerated > ago(${this.convertTimespanToKQL(timespan)})
311
+ `;
312
+ if (functionName) {
313
+ query += `\n | where operation_Name contains "${functionName}" or name contains "${functionName}"`;
314
+ }
315
+ query += `
316
+ | order by TimeGenerated desc
317
+ | take ${limit}
318
+ | project TimeGenerated, operation_Name, name, success, resultCode, duration, timestamp
319
+ `.trim();
320
+ return this.executeQuery(resourceId, query, timespan);
321
+ }
322
+ /**
323
+ * Get all configured resources
324
+ */
325
+ getAllResources() {
326
+ return this.config.resources;
327
+ }
328
+ /**
329
+ * Get only active resources
330
+ */
331
+ getActiveResources() {
332
+ return this.config.resources.filter((r) => r.active);
333
+ }
334
+ /**
335
+ * Get resource by ID and validate it's active
336
+ */
337
+ getResourceById(resourceId) {
338
+ const resource = this.config.resources.find((r) => r.id === resourceId);
339
+ if (!resource) {
340
+ const availableIds = this.config.resources.map((r) => r.id).join(', ');
341
+ throw new Error(`Resource '${resourceId}' not found. Available resources: ${availableIds || 'none'}`);
342
+ }
343
+ if (!resource.active) {
344
+ throw new Error(`Resource '${resourceId}' is inactive. Set 'active: true' in configuration to enable it.`);
345
+ }
346
+ return resource;
347
+ }
348
+ /**
349
+ * Convert ISO 8601 duration to KQL timespan format
350
+ * PT1H -> 1h, P1D -> 1d, PT30M -> 30m, etc.
351
+ */
352
+ convertTimespanToKQL(iso8601Duration) {
353
+ // Handle common patterns
354
+ const patterns = {
355
+ 'PT15M': '15m',
356
+ 'PT30M': '30m',
357
+ 'PT1H': '1h',
358
+ 'PT2H': '2h',
359
+ 'PT6H': '6h',
360
+ 'PT12H': '12h',
361
+ 'PT24H': '24h',
362
+ 'P1D': '1d',
363
+ 'P2D': '2d',
364
+ 'P7D': '7d',
365
+ 'P30D': '30d',
366
+ };
367
+ if (patterns[iso8601Duration]) {
368
+ return patterns[iso8601Duration];
369
+ }
370
+ // Parse ISO 8601 duration
371
+ const regex = /P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/;
372
+ const match = iso8601Duration.match(regex);
373
+ if (!match) {
374
+ // If no match, return as-is (might be KQL format already)
375
+ return iso8601Duration;
376
+ }
377
+ const [, days, hours, minutes, seconds] = match;
378
+ // Convert to KQL format (use largest unit)
379
+ if (days)
380
+ return `${days}d`;
381
+ if (hours)
382
+ return `${hours}h`;
383
+ if (minutes)
384
+ return `${minutes}m`;
385
+ if (seconds)
386
+ return `${seconds}s`;
387
+ return '1h'; // Default fallback
388
+ }
389
+ /**
390
+ * Validate KQL query (basic check)
391
+ */
392
+ validateQuery(query) {
393
+ if (!query || query.trim().length === 0) {
394
+ return { valid: false, error: 'Query cannot be empty' };
395
+ }
396
+ // Check for dangerous operations (optional - KQL is read-only by nature)
397
+ const dangerousKeywords = ['invoke', 'execute', 'evaluate'];
398
+ const lowerQuery = query.toLowerCase();
399
+ for (const keyword of dangerousKeywords) {
400
+ if (lowerQuery.includes(keyword)) {
401
+ return {
402
+ valid: false,
403
+ error: `Query contains potentially dangerous keyword: ${keyword}`
404
+ };
405
+ }
406
+ }
407
+ return { valid: true };
408
+ }
409
+ }